Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nh

## [Unreleased]

## [0.93.0] — 2026-10-16
### Fixed
- **HQ "Giao việc" to an idle session no longer hangs.** Owner (2026-10-16): "giao task khi ccc còn working nó mới làm, còn khi nó chưa working gửi task nó bị treo". A session HQ did not start (every `8sync ccc` room, every session on Windows) gets tasks through its inbox, and only the kit's Stop hook read it, at the end of a turn; an idle session has no turn to end, so the task waited for good. New mod **`8sync-inbox`** (installed by `8sync ccc kit` into `~/.claude/skills/8sync-inbox`; open sessions need one restart) polls while the main loop is idle (every 5 s for the first minute, then 15 s, then 30 s; a finished turn starts over) and submits pending tasks as a new prompt (`$.prompt.submit`). It calls the new **`8sync ccc inbox [--session <id>] [--ack|--release] [--json]`**, which claims the messages instead of deleting them: the mod acks once the prompt entered the session, appends a refused prompt to the prompt box (the owner's draft stays) with a toast, and releases the claim back to the inbox when neither worked; a claim nobody acks (killed process) is served again after 2 minutes. The Stop hook leaves a claimed message alone, so a task is delivered once and never lost. The HQ API that writes the inbox requires the local HQ token; the text is framed as the owner's message. HQ now says "qua inbox (nhận ngay khi phiên rảnh)". Tests: `inbox_claim_ack_release_and_stale_claims`, `inbox_cmd_parses_flags_and_falls_back_to_the_env_session`, `kit_installs_the_inbox_mod`, `claude plugin test` (9). Not verified until a session restarts with the mod: a task arriving while the owner has a half-typed prompt.

### Added
- **Realtime hints inside Claude Code (`8sync ccc hint` + mod `8sync-hints`).** Owner (2026-10-16): "enhance prompt in ccc can auto hint real time like omp suggestion to tab completion". The kit now installs a Claude Code function-hooks mod at `~/.claude/skills/8sync-hints` (auto-loaded; open sessions need a restart) that draws three things from one local Rust call: **ghost text** for the next prompt (Tab/→ takes it: "tiếp tục loop <slug> (iter k/N)", own urgent flag, the prompt the owner typed after this one before — a bigram over `~/.claude/history.jsonl` filtered by project, local-only — then "/sec rồi commit" or "push nhánh … rồi mở PR" from git), **typeahead** rows with descriptions for `/skill` (installed + repo skills), `8sync <cmd>` and `8sync ccc <verb>` / `ccc <verb>` tokens, and a dim **band above the prompt** (≤ 2 lines: loop · branch ↑ahead ✎dirty · urgent · watchdog alert < 10 min) with an "Ẩn" button. The mod only calls `8sync ccc hint [--complete <token>] --json` (no network, no model, no `gh`; one `git status` with a 300 ms budget, run beside the prompt; silent when `8sync` is missing) — idea ported from omp's predictive composer, code ours. Tests: `hint_*` (Rust; < 50 ms without git on a 41-skill + 512 KiB-history fixture), `claude plugin validate|test` (8 mod tests), `kit_installs_the_hints_mod`, `scripts/uat/hints-load.sh`.
- **`8sync completions <bash|zsh|fish|powershell|elvish>`**: shell tab-completion scripts generated from the same clap tree the binary parses (`8sync completions bash > ~/.local/share/bash-completion/completions/8sync`).
- `research/omp-features.md`: scan of omp 18.11.1 features (MIT; ideas only) with what ccc already has, what this batch ports and what is queued for later.


## [0.92.1] — 2026-10-15
### Fixed
- **HQ "Giao việc" › Một phiên** on real data: every session the owner started outside HQ landed under "Phiên khác", and two rooms with the same name (two `rd`, two `web`) were indistinguishable. The list now groups those sessions by the git folder + branch their office room reports, exactly like the sidebar ("su-code ⎇ main"), and a duplicated name gets its first 8 session-id characters ("rd · 1a2b3c4d") in the list, the fan-out chips and the summary line (`groupTargets(sessions, projects, rooms)`, `distinctLabels`; tests in tests/assign-ui.test.ts).
Expand Down
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/ccc-core"]

[workspace.package]
version = "0.92.1"
version = "0.93.0"
edition = "2021"
license = "MIT"
authors = ["8sync-dev <[email protected]>"]
Expand Down
4 changes: 2 additions & 2 deletions apps/hq-ui/components/hq/assign-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, rooms
else {
const s = sessions.find((x) => x.name === r.session || x.session_id === r.session);
const who = s ? sessionLabel(s) : r.session.slice(0, 8);
toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận khi xong lượt)" : ""}${plan}`);
toast.success(`${r.started ? "Đã mở phiên mới và giao cho" : "Đã giao cho"} ${who}${r.delivery === "inbox" ? " qua inbox (nhận ngay khi phiên rảnh)" : ""}${plan}`);
}
}
setSubject("");
Expand All @@ -149,7 +149,7 @@ export function AssignDialog({ hq, open, onOpenChange, sessions, projects, rooms
<form onSubmit={submit} className="grid gap-5">
<DialogHeader>
<DialogTitle>Giao việc</DialogTitle>
<DialogDescription>Việc được gõ thẳng vào phiên nhận; phiên mở ngoài HQ nhận qua inbox khi xong lượt.</DialogDescription>
<DialogDescription>Việc được gõ thẳng vào phiên nhận; phiên mở ngoài HQ nhận qua inbox: ngay khi rảnh (mod 8sync-inbox), hoặc khi xong lượt đang làm.</DialogDescription>
</DialogHeader>

<Section title="Việc">
Expand Down
2 changes: 1 addition & 1 deletion apps/hq-ui/components/hq/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function NewTask({ hq, sessions }: { hq: Hq; sessions: Session[] }) {
if (r.delivery === "failed") toast.error(`Task saved, but not delivered: ${r.error}`);
else
toast.success(
`Đã giao cho ${sessionLabel(target)} ${r.delivery === "channel" ? "qua channel" : r.delivery === "inbox" ? "qua inbox (nhận khi xong lượt)" : "qua terminal"}`,
`Đã giao cho ${sessionLabel(target)} ${r.delivery === "channel" ? "qua channel" : r.delivery === "inbox" ? "qua inbox (nhận ngay khi phiên rảnh)" : "qua terminal"}`,
);
setSubject("");
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion apps/hq-ui/components/hq/team-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ function Composer({
const hint = !current
? "Start a session to message it."
: via === "inbox"
? "Inbox: session nhận tin khi xong lượt đang làm (Stop hook), trả lời hiện ở đây."
? "Inbox: session nhận tin ngay khi rảnh (mod 8sync-inbox) hoặc khi xong lượt đang làm (Stop hook), trả lời hiện ở đây."
: current.channel_state === "blocked-by-org"
? "Typed into the session's terminal (Channels off for your org)."
: current.channel_state === "unresponsive"
Expand Down
2 changes: 1 addition & 1 deletion apps/hq-ui/lib/assign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export type Summary = {
export function assignSummary(x: Summary): string {
if (x.mode === "session") {
if (!x.session) return "";
const via = deliveryOf(x.session) === "inbox" ? "qua inbox (nhận khi xong lượt)" : "vào terminal";
const via = deliveryOf(x.session) === "inbox" ? "qua inbox (nhận ngay khi phiên rảnh)" : "vào terminal";
return `Giao cho phiên ${sessionLabel(x.session)} ${via}`;
}
if (x.mode === "role") {
Expand Down
2 changes: 1 addition & 1 deletion apps/hq-ui/tests/assign-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("roleTargets / projectTargets", () => {
describe("assignSummary", () => {
it("session: names the session and how it is reached", () => {
expect(assignSummary({ mode: "session", session: s("hq-rd-1") })).toBe("Giao cho phiên rd-1 vào terminal");
expect(assignSummary({ mode: "session", session: s("rd", { tmux: false, session_id: SID }) })).toBe("Giao cho phiên rd qua inbox (nhận khi xong lượt)");
expect(assignSummary({ mode: "session", session: s("rd", { tmux: false, session_id: SID }) })).toBe("Giao cho phiên rd qua inbox (nhận ngay khi phiên rảnh)");
expect(assignSummary({ mode: "session", session: null })).toBe("");
});
it("role: reuse one, pick among many, or start a new session in the project", () => {
Expand Down
2 changes: 1 addition & 1 deletion assets/claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "8sync-kit",
"version": "0.92.1",
"version": "0.93.0",
"description": "8 Sync kit for Claude Code: role-routed subagents (explore, task, quick-task, reviewer, designer, researcher, verifier, pm, architect, qa, critic) and the /ship /rules /sec /harden skills. Hooks, rules and the full skill set come from `8sync ccc kit`.",
"author": { "name": "8 Sync", "url": "https://8syncdev.com" },
"homepage": "https://github.com/8-Sync-Dev/su-code",
Expand Down
7 changes: 7 additions & 0 deletions assets/claude/skills/8sync-hints/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "8sync-hints",
"version": "0.1.0",
"author": { "name": "8 Sync", "url": "https://github.com/8-Sync-Dev/su-code" },
"description": "Gợi ý realtime trong prompt của Claude Code từ 8sync ccc hint: ghost text (Tab), typeahead /skill · 8sync · ccc, dải trạng thái trên prompt. Local-only.",
"types": "./types/index.d.ts"
}
147 changes: 147 additions & 0 deletions assets/claude/skills/8sync-hints/hooks/hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { expect, mock, test } from 'claude-code/testing'

import { completeArg, parseComplete, parseHint } from './hint'

const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })

test('parseHint reads suggest + band and drops junk', async () => {
expect(parseHint('{"suggest":" tiếp tục loop x (iter 1/2) ","band":["loop x 1/2","⎇ main","three"]}')).toEqual({
suggest: 'tiếp tục loop x (iter 1/2)',
band: ['loop x 1/2', '⎇ main'],
})
expect(parseHint('{"suggest":null,"band":[]}')).toEqual({ suggest: null, band: [] })
expect(parseHint('')).toBe(null)
expect(parseHint('not json')).toBe(null)
expect(parseHint(null)).toBe(null)
expect(parseHint('[1]')).toBe(null)
})

test('parseComplete keeps well-formed rows only, at most 8', async () => {
const rows = Array.from({ length: 10 }, (_, i) => ({ text: `/s${i}`, label: `s${i}`, description: `d${i}` }))
const out = parseComplete(JSON.stringify({ complete: [...rows, { text: '' }, { label: 'no text' }, 7] }))
expect(out.length).toBe(8)
expect(out[0]).toEqual({ text: '/s0', label: 's0', description: 'd0' })
expect(parseComplete('{"complete":"x"}')).toEqual([])
expect(parseComplete(undefined)).toEqual([])
})

test('completeArg: slash token (not a path, not at the box start), 8sync/ccc lines, nothing else', async () => {
expect(completeArg('/sh', 3, '/sh')).toBe('/sh')
expect(completeArg('fix it then /sh', 15, '/sh', 12)).toBe('/sh')
expect(completeArg('/sh', 3, '/sh', 0)).toBe(null)
expect(completeArg('see /home/me/x', 14, '/home/me/x', 4)).toBe(null)
expect(completeArg('x /a:b.c-d', 10, '/a:b.c-d', 2)).toBe('/a:b.c-d')
expect(completeArg('8sync ccc lo', 12, 'lo')).toBe('8sync ccc lo')
expect(completeArg('run\n ccc lo', 12, 'lo')).toBe('ccc lo')
expect(completeArg('8sync', 5, '8sync')).toBe('8sync')
expect(completeArg('please run 8sync ccc lo', 23, 'lo')).toBe(null)
expect(completeArg('hello', 5, 'hello')).toBe(null)
})

test('session.start asks 8sync ccc hint beside the start and proposes its suggestion', async ($, on) => {
const clock = mock.clock(on)
on('session.start', (_$, e) => ({ cwd: e.cwd }))
let argv: readonly string[] = []
let cwd: string | undefined
let env: Record<string, string> | undefined
on('session.id', () => ({ value: 'sess-1' }))
on('process.run', (_$, e) => {
argv = e.argv
cwd = e.init?.cwd
env = e.init?.env
return ok('{"suggest":"tiếp tục loop x (iter 1/2)","band":["loop x 1/2"]}')
})
let suggested = ''
on('prompt.suggest', (_$, e) => {
suggested = e.text
return { isShown: true }
})
const r = await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true })
expect(r.cwd).toBe('/repo')
await clock.settle() // the refresh is never awaited on the start path
expect(argv).toEqual(['8sync', 'ccc', 'hint', '--json'])
expect(cwd).toBe('/repo')
expect(env?.CLAUDE_CODE_SESSION_ID).toBe('sess-1')
expect(suggested).toBe('tiếp tục loop x (iter 1/2)')
suggested = ''
await clock.advance(20_000) // idle poll
expect(suggested).toBe('tiếp tục loop x (iter 1/2)')
})

test('a subagent turn does not pause or trigger the refresh; the main turn does', async ($, on) => {
const clock = mock.clock(on)
on('session.start', (_$, e) => ({ cwd: e.cwd }))
let runs = 0
on('process.run', () => {
runs += 1
return ok('{"suggest":null,"band":["⎇ main"]}')
})
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
on('turn.complete', () => ({ text: 'done' }))
await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true })
await clock.settle()
expect(runs).toBe(1)
await $.turn.start({ text: 'go', turnId: 't1' })
await clock.advance(20_000) // the poll is skipped while the main turn runs
await $.turn.complete({ turnId: 't2', agentId: 'a1', answer: 'sub', reason: 'answer' })
await clock.settle()
expect(runs).toBe(1)
const r = await $.turn.complete({ turnId: 't1', answer: 'main', reason: 'answer' })
expect(r.text).toBe('done')
await clock.settle()
expect(runs).toBe(2)
})

test('a failing 8sync is silent: no suggestion, session still starts', async ($, on) => {
mock.clock(on)
on('session.start', (_$, e) => ({ cwd: e.cwd }))
on('process.run', () => ({ value: { exitCode: 127, stdout: '', stderr: 'not found', isStdoutTruncated: false, isStderrTruncated: false } }))
let suggested = 0
on('prompt.suggest', () => {
suggested += 1
return { isShown: true }
})
const r = await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true })
expect(r.cwd).toBe('/repo')
expect(suggested).toBe(0)
})

test('headless session does not poll', async ($, on) => {
mock.clock(on)
on('session.start', (_$, e) => ({ cwd: e.cwd }))
let runs = 0
on('process.run', () => {
runs += 1
return ok('{"suggest":null,"band":[]}')
})
await $.session.start({ cwd: '/repo', surface: null, isInteractive: false })
expect(runs).toBe(0)
})

test('AbovePrompt draws the band and Ẩn hides it', async ($, on) => {
mock.clock(on)
on('session.start', (_$, e) => ({ cwd: e.cwd }))
on('process.run', () => ok('{"suggest":null,"band":["loop x 1/2","⎇ feat ↑2 ✎"]}'))
// The engine's own band when the plugin yields (hidden): an empty line.
on('ui.render', ($, e) => {
const { Text } = $.ui.resolve(e)
return Text({ children: 'engine-band' })
})
await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true })
const mounts = []
for (const surface of ['terminal', 'desktop'] as const) {
const m = await $.ui.mount({
plugin: '8sync-hints',
surface,
component: 'AbovePrompt',
props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 100, scroll: { offset: 0, bodyRows: 8 }, view: {} },
})
expect(JSON.stringify(await m.drawn())).toContain('loop x 1/2')
mounts.push(m)
}
// Ẩn is session state: pressed on one surface, the band is gone on every surface.
await mounts[0].press({ key: 'hide' })
for (const m of mounts) {
expect(JSON.stringify(await m.drawn())).not.toContain('loop x 1/2')
}
})
66 changes: 66 additions & 0 deletions assets/claude/skills/8sync-hints/hooks/hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Pure helpers of the 8sync-hints mod: parse what `8sync ccc hint --json` prints and decide
// what to ask it for. No `$`, no I/O — so `hint.test.ts` can check them directly.

export type Hint = { suggest: string | null; band: string[] }
export type Row = { text: string; label?: string; description?: string }

const MAX_BAND_LINES = 2
const MAX_ROWS = 8

/** `{suggest, band}` from `8sync ccc hint --json`; null when the output is not that. */
export function parseHint(stdout: string | null | undefined): Hint | null {
const v = parseJson(stdout)
if (!v) return null
const suggest = typeof v.suggest === 'string' && v.suggest.trim() !== '' ? v.suggest.trim() : null
const band = Array.isArray(v.band)
? v.band.filter((l): l is string => typeof l === 'string' && l.trim() !== '').slice(0, MAX_BAND_LINES)
: []
return { suggest, band }
}

/** Typeahead rows from `8sync ccc hint --complete <arg> --json`; [] when none or malformed. */
export function parseComplete(stdout: string | null | undefined): Row[] {
const v = parseJson(stdout)
if (!v || !Array.isArray(v.complete)) return []
const rows: Row[] = []
for (const r of v.complete) {
if (!r || typeof r !== 'object') continue
const o = r as Record<string, unknown>
if (typeof o.text !== 'string' || o.text === '') continue
const row: Row = { text: o.text }
if (typeof o.label === 'string' && o.label !== '') row.label = o.label
if (typeof o.description === 'string' && o.description !== '') row.description = o.description
rows.push(row)
if (rows.length >= MAX_ROWS) break
}
return rows
}

/**
* What to pass to `--complete` for the prompt box as it stands, or null when the token is not
* ours. `/…` tokens: the token itself when it looks like a skill name (not a path like
* `/home/x`) and does not stand at the very start of the box (there the engine's own slash
* menu already lists every skill). A line that starts with `8sync` or `ccc`: the line up to the
* cursor (the Rust side answers with the replacement for the last word).
*/
export function completeArg(text: string, cursor: number, token: string, start = -1): string | null {
if (token.startsWith('/')) {
return start !== 0 && /^\/[\w:.-]*$/.test(token) ? token : null
}
const head = text.slice(0, cursor)
const line = head.slice(head.lastIndexOf('\n') + 1).trimStart()
if (/^(8sync|ccc)(\s|$)/.test(line)) return line
return null
}

function parseJson(stdout: string | null | undefined): Record<string, unknown> | null {
if (typeof stdout !== 'string') return null
const s = stdout.trim()
if (s === '' || s[0] !== '{') return null
try {
const v: unknown = JSON.parse(s)
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
} catch {
return null
}
}
1 change: 1 addition & 0 deletions assets/claude/skills/8sync-hints/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
Loading
Loading