claude-code-kanban 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/git-branch.js +47 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +2 -1
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +8 -8
- package/server.js +106 -95
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { EngineInterface, Register, ToolCallArgs } from 'claude-code'
|
|
2
|
+
import { cckRoot } from './context'
|
|
3
|
+
|
|
4
|
+
// Mods cannot delete files. The server reads any status but "waiting" as no ask.
|
|
5
|
+
export const CLEARED = JSON.stringify({ status: 'cleared' })
|
|
6
|
+
|
|
7
|
+
export const BOARD_ALLOWED = 'Approved from the board — running'
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 500
|
|
10
|
+
|
|
11
|
+
type Kind = 'permission' | 'question' | 'plan'
|
|
12
|
+
type Ask = { id: string; kind: Kind; dir: string; waitMs?: number }
|
|
13
|
+
type Decision = {
|
|
14
|
+
behavior?: 'allow' | 'deny'
|
|
15
|
+
message?: string
|
|
16
|
+
updatedInput?: Record<string, unknown>
|
|
17
|
+
answers?: Record<string, unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// tool.check carries the call's id on a real call only, so a call without one is found by its input.
|
|
21
|
+
const pending = new Set<{ id?: string; tool: string; args: Record<string, unknown>; asked: (ask: Ask) => void }>()
|
|
22
|
+
// The board's allow runs the tool again through $.tool.call, and its tool.check must not ask twice.
|
|
23
|
+
const allowOnce = new Set<string>()
|
|
24
|
+
const callKey = (tool: string, input: unknown) => `${tool}\0${JSON.stringify(input)}`
|
|
25
|
+
|
|
26
|
+
let cck: Promise<string> | undefined
|
|
27
|
+
|
|
28
|
+
// The engine does not follow $ into an imported function, so only the path rule is shared.
|
|
29
|
+
function cckDir($: EngineInterface) {
|
|
30
|
+
cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
|
|
31
|
+
([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
|
|
32
|
+
)
|
|
33
|
+
return cck
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readText($: EngineInterface, path: string) {
|
|
37
|
+
try {
|
|
38
|
+
return await $.fs.read(path)
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readJson<T>($: EngineInterface, path: string): Promise<T | undefined> {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse((await readText($, path)) ?? '')
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The server folds an agent's lines last-key-wins, so a line leaves out a field it does not know.
|
|
53
|
+
async function appendLine($: EngineInterface, path: string, record: object, { onlyIfExists = false } = {}) {
|
|
54
|
+
const text = await readText($, path)
|
|
55
|
+
if (text === undefined && onlyIfExists) return
|
|
56
|
+
await $.fs.write(path, `${text ?? ''}${JSON.stringify(record)}\n`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sessionDir($: EngineInterface) {
|
|
60
|
+
const [dir, id] = await Promise.all([cckDir($), $.session.id()])
|
|
61
|
+
return `${dir}/agent-activity/${id}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const kindOf = (tool: string): Kind =>
|
|
65
|
+
tool === 'AskUserQuestion' ? 'question' : tool === 'ExitPlanMode' ? 'plan' : 'permission'
|
|
66
|
+
|
|
67
|
+
// Mirrors approvalsFrom and isKindGated in lib/approvals.js: the gate is on unless config.json
|
|
68
|
+
// says enabled: false, and a missing or broken file means the defaults.
|
|
69
|
+
async function boardWaitMs($: EngineInterface, kind: Kind) {
|
|
70
|
+
type Approvals = { enabled?: unknown; mode?: unknown; waitSeconds?: unknown }
|
|
71
|
+
const a = (await readJson<{ approvals?: Approvals }>($, `${await cckDir($)}/config.json`))?.approvals ?? {}
|
|
72
|
+
if (a.enabled === false) return undefined
|
|
73
|
+
if (kind === 'question' && a.mode === 'permission') return undefined
|
|
74
|
+
const seconds = Number.isInteger(a.waitSeconds) && (a.waitSeconds as number) >= 0 ? (a.waitSeconds as number) : 1800
|
|
75
|
+
return Math.min(seconds, 1800) * 1000
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The marker shows the ask on the board whether or not the board may answer it.
|
|
79
|
+
async function openAsk($: EngineInterface, tool: string, input: unknown, rule?: string): Promise<Ask> {
|
|
80
|
+
const kind = kindOf(tool)
|
|
81
|
+
const id = crypto.randomUUID()
|
|
82
|
+
const [dir, cwd, waitMs] = await Promise.all([sessionDir($), $.session.cwd(), boardWaitMs($, kind)])
|
|
83
|
+
await $.fs.write(
|
|
84
|
+
`${dir}/_waiting.json`,
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
status: 'waiting',
|
|
87
|
+
kind,
|
|
88
|
+
id,
|
|
89
|
+
toolName: tool,
|
|
90
|
+
toolInput: JSON.stringify(input),
|
|
91
|
+
cwd,
|
|
92
|
+
rule,
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
return { id, kind, dir, waitMs }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A newer ask (a parallel call) may own the marker by now.
|
|
100
|
+
async function closeAsk($: EngineInterface, ask: Ask) {
|
|
101
|
+
const marker = `${ask.dir}/_waiting.json`
|
|
102
|
+
if ((await readJson<{ id?: string }>($, marker))?.id === ask.id) await $.fs.write(marker, CLEARED)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function boardDecision($: EngineInterface, ask: Ask, signal: AbortSignal) {
|
|
106
|
+
const file = `${ask.dir}/_decision-${ask.id}.json`
|
|
107
|
+
for (let waited = 0; waited < (ask.waitMs ?? 0); waited += POLL_MS) {
|
|
108
|
+
try {
|
|
109
|
+
await $.clock.sleep(POLL_MS, { signal })
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
const d = await readJson<Decision>($, file)
|
|
114
|
+
if (d) return d
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only returning ends the terminal prompt, so an allow runs the tool again from here. A terminal
|
|
120
|
+
// "Yes" in the moment before the hook returns runs it twice (docs/ui-approvals.md).
|
|
121
|
+
async function answerFromBoard($: EngineInterface, tool: string, args: Record<string, unknown>, ask: Ask, d: Decision) {
|
|
122
|
+
if (ask.kind === 'question') {
|
|
123
|
+
return d.answers ? { result: { questions: args.questions, answers: d.answers } } : undefined
|
|
124
|
+
}
|
|
125
|
+
if (d.behavior !== 'allow') return { deny: d.message ?? 'Denied from the board' }
|
|
126
|
+
$.ui.status(BOARD_ALLOWED)
|
|
127
|
+
const input = { ...args, ...d.updatedInput }
|
|
128
|
+
const key = callKey(tool, input)
|
|
129
|
+
allowOnce.add(key)
|
|
130
|
+
try {
|
|
131
|
+
return await $.tool.call({ ...input, tool } as ToolCallArgs)
|
|
132
|
+
} finally {
|
|
133
|
+
allowOnce.delete(key)
|
|
134
|
+
$.ui.status(undefined)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function mapTaskList($: EngineInterface, cwd: string) {
|
|
139
|
+
const taskListId = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
|
|
140
|
+
if (!taskListId) return
|
|
141
|
+
const path = `${await cckDir($)}/agent-activity/_task-maps/${taskListId}.json`
|
|
142
|
+
const maps = (await readJson<Record<string, unknown>>($, path)) ?? {}
|
|
143
|
+
maps[await $.session.id()] = { project: cwd, updatedAt: new Date().toISOString() }
|
|
144
|
+
await $.fs.write(path, JSON.stringify(maps))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function markStopped($: EngineInterface) {
|
|
148
|
+
await $.fs.write(`${await sessionDir($)}/_stop.json`, '')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The name map lets TeammateIdle, which names the teammate, find its agent id.
|
|
152
|
+
async function agentStarted($: EngineInterface, agentId: string, type: string, name?: string) {
|
|
153
|
+
const dir = await sessionDir($)
|
|
154
|
+
const ts = new Date().toISOString()
|
|
155
|
+
await Promise.all([
|
|
156
|
+
appendLine($, `${dir}/${agentId}.jsonl`, {
|
|
157
|
+
agentId,
|
|
158
|
+
type,
|
|
159
|
+
event: 'start',
|
|
160
|
+
status: 'active',
|
|
161
|
+
startedAt: ts,
|
|
162
|
+
updatedAt: ts,
|
|
163
|
+
}),
|
|
164
|
+
$.fs.write(`${dir}/_name-${name ?? type}.id`, agentId),
|
|
165
|
+
])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// turn.complete also ends loops agent.spawn never started (internal agents): those have no file.
|
|
169
|
+
async function agentStopped($: EngineInterface, agentId: string, lastMessage: string) {
|
|
170
|
+
const ts = new Date().toISOString()
|
|
171
|
+
await appendLine(
|
|
172
|
+
$,
|
|
173
|
+
`${await sessionDir($)}/${agentId}.jsonl`,
|
|
174
|
+
{
|
|
175
|
+
agentId,
|
|
176
|
+
event: 'stop',
|
|
177
|
+
status: 'stopped',
|
|
178
|
+
...(lastMessage && { lastMessage }),
|
|
179
|
+
stoppedAt: ts,
|
|
180
|
+
updatedAt: ts,
|
|
181
|
+
},
|
|
182
|
+
{ onlyIfExists: true },
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// TeammateIdle names the teammate but carries no agent_id outside the teammate's own loop.
|
|
187
|
+
async function teammateIdle($: EngineInterface, name: string, agentId?: string, agentType?: string) {
|
|
188
|
+
const dir = await sessionDir($)
|
|
189
|
+
const id = agentId || (await readText($, `${dir}/_name-${name}.id`))
|
|
190
|
+
if (!id) return
|
|
191
|
+
const type = agentId ? agentType : name
|
|
192
|
+
await appendLine(
|
|
193
|
+
$,
|
|
194
|
+
`${dir}/${id}.jsonl`,
|
|
195
|
+
{
|
|
196
|
+
agentId: id,
|
|
197
|
+
...(type && { type }),
|
|
198
|
+
event: 'idle',
|
|
199
|
+
status: 'idle',
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
},
|
|
202
|
+
{ onlyIfExists: !type },
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const register: Register = on => {
|
|
207
|
+
on('tool.check', async ($, e, next) => {
|
|
208
|
+
if (allowOnce.delete(callKey(e.tool, e.input))) return { decision: 'allow' }
|
|
209
|
+
const result = await next(e)
|
|
210
|
+
if (result.decision !== 'ask') return result
|
|
211
|
+
const input = e.tool_use_id ? undefined : JSON.stringify(e.input)
|
|
212
|
+
const call = [...pending].find(p =>
|
|
213
|
+
e.tool_use_id ? p.id === e.tool_use_id : p.tool === e.tool && JSON.stringify(p.args) === input,
|
|
214
|
+
)
|
|
215
|
+
call?.asked(await openAsk($, e.tool, e.input, result.rule))
|
|
216
|
+
return result
|
|
217
|
+
})
|
|
218
|
+
on('tool.call', async ($, e, next) => {
|
|
219
|
+
const { tool, tool_use_id, agentId: _agentId, consent: _consent, ...args } = e as typeof e & { consent?: unknown }
|
|
220
|
+
let asked: (ask: Ask) => void = () => {}
|
|
221
|
+
const ask = new Promise<Ask>(resolve => {
|
|
222
|
+
asked = resolve
|
|
223
|
+
})
|
|
224
|
+
const call = { id: tool_use_id, tool, args, asked }
|
|
225
|
+
pending.add(call)
|
|
226
|
+
const run = next(e)
|
|
227
|
+
const settled = new AbortController()
|
|
228
|
+
const done = run.then(() => undefined).finally(() => settled.abort())
|
|
229
|
+
try {
|
|
230
|
+
const opened = await Promise.race([done, ask])
|
|
231
|
+
if (!opened) return await run
|
|
232
|
+
try {
|
|
233
|
+
const d = await Promise.race([
|
|
234
|
+
done,
|
|
235
|
+
boardDecision($, opened, AbortSignal.any([next.signal, settled.signal])),
|
|
236
|
+
])
|
|
237
|
+
return (d && (await answerFromBoard($, tool, args, opened, d))) ?? (await run)
|
|
238
|
+
} finally {
|
|
239
|
+
await closeAsk($, opened)
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
pending.delete(call)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
on('session.start', async ($, e, next) => {
|
|
246
|
+
await mapTaskList($, e.cwd)
|
|
247
|
+
return next(e)
|
|
248
|
+
})
|
|
249
|
+
on('agent.spawn', async ($, e, next) => {
|
|
250
|
+
const result = await next(e)
|
|
251
|
+
if (result.agentId) await agentStarted($, result.agentId, e.subagentType, e.name)
|
|
252
|
+
return result
|
|
253
|
+
})
|
|
254
|
+
// An interrupted turn fires no Stop hook, so it does not mark the session unread either.
|
|
255
|
+
on('turn.complete', async ($, e, next) => {
|
|
256
|
+
if (e.agentId) await agentStopped($, e.agentId, e.answer)
|
|
257
|
+
else if (!e.isAborted) await markStopped($)
|
|
258
|
+
return next(e)
|
|
259
|
+
})
|
|
260
|
+
// Managed settings bypass user-tier mods on classic.* (cc-plugin-sec-default), so this one
|
|
261
|
+
// has no effect there; there is no native teammate-idle event to use instead.
|
|
262
|
+
on('classic.TeammateIdle', async ($, e, next) => {
|
|
263
|
+
await teammateIdle($, e.teammate_name, e.agent_id, e.agent_type)
|
|
264
|
+
return next(e)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EngineInterface, ModelUsage, Register, SessionMeasureInput } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
type Measure = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
|
|
4
|
+
|
|
5
|
+
export function displayName(model: string) {
|
|
6
|
+
const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(model)
|
|
7
|
+
if (!m?.[1]) return model
|
|
8
|
+
const family = m[1][0]!.toUpperCase() + m[1].slice(1)
|
|
9
|
+
return `${family} ${m[3] ? `${m[2]}.${m[3]}` : m[2]}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
|
|
13
|
+
|
|
14
|
+
// The board reads the statusLine's JSON shape, so the file keeps its field names.
|
|
15
|
+
export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
|
|
16
|
+
const { context } = m
|
|
17
|
+
return {
|
|
18
|
+
model: { id: model, display_name: displayName(model) },
|
|
19
|
+
cost: { total_cost_usd: m.cost?.usd ?? 0 },
|
|
20
|
+
context_window: {
|
|
21
|
+
total_input_tokens: context.tokens ?? 0,
|
|
22
|
+
context_window_size: context.window,
|
|
23
|
+
current_usage: usage ?? null,
|
|
24
|
+
used_percentage: context.percent ?? null,
|
|
25
|
+
},
|
|
26
|
+
rate_limits: Object.fromEntries(
|
|
27
|
+
m.rateLimits.map(r => [r.kind, { used_percentage: r.percentUsed, resets_at: toEpochSeconds(r.resetsAt) }]),
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const cckRoot = (configDir?: string, home?: string, userProfile?: string) =>
|
|
33
|
+
configDir ? `${configDir}/.cck` : `${home ?? userProfile}/.claude/.cck`
|
|
34
|
+
|
|
35
|
+
let cck: Promise<string> | undefined
|
|
36
|
+
|
|
37
|
+
function cckDir($: EngineInterface) {
|
|
38
|
+
cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
|
|
39
|
+
([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
|
|
40
|
+
)
|
|
41
|
+
return cck
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let lastUsage: ModelUsage | undefined
|
|
45
|
+
let lastModel: string | undefined
|
|
46
|
+
const lastWritten = new Map<string, string>()
|
|
47
|
+
|
|
48
|
+
async function write($: EngineInterface, m: Measure) {
|
|
49
|
+
const [sessionId, model, dir] = await Promise.all([
|
|
50
|
+
$.session.id(),
|
|
51
|
+
lastModel ?? $.session.model(),
|
|
52
|
+
cckDir($),
|
|
53
|
+
])
|
|
54
|
+
const text = JSON.stringify(toStatus(model, m, lastUsage))
|
|
55
|
+
const file = `${dir}/context-status/${sessionId}.json`
|
|
56
|
+
if (lastWritten.get(file) === text) return
|
|
57
|
+
await $.fs.write(file, text)
|
|
58
|
+
lastWritten.set(file, text)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const register: Register = on => {
|
|
62
|
+
on('turn.step', async function* ($, e, next) {
|
|
63
|
+
const result = yield* next(e)
|
|
64
|
+
if (e.agentId === undefined && result.usage) {
|
|
65
|
+
const { model, ...usage } = result.usage
|
|
66
|
+
lastUsage = usage
|
|
67
|
+
lastModel = model
|
|
68
|
+
await write($, await $.session.usage())
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
on('session.measure', async ($, e, next) => {
|
|
74
|
+
await write($, e)
|
|
75
|
+
return next(e)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
@@ -1,95 +1,3 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"SessionStart": [
|
|
4
|
-
{
|
|
5
|
-
"matcher": "",
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
10
|
-
"shell": "bash",
|
|
11
|
-
"timeout": 5
|
|
12
|
-
}
|
|
13
|
-
]
|
|
14
|
-
}
|
|
15
|
-
],
|
|
16
|
-
"SubagentStart": [
|
|
17
|
-
{
|
|
18
|
-
"matcher": "",
|
|
19
|
-
"hooks": [
|
|
20
|
-
{
|
|
21
|
-
"type": "command",
|
|
22
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
23
|
-
"shell": "bash",
|
|
24
|
-
"timeout": 5
|
|
25
|
-
}
|
|
26
|
-
]
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"SubagentStop": [
|
|
30
|
-
{
|
|
31
|
-
"matcher": "",
|
|
32
|
-
"hooks": [
|
|
33
|
-
{
|
|
34
|
-
"type": "command",
|
|
35
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
36
|
-
"shell": "bash",
|
|
37
|
-
"timeout": 30
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
],
|
|
42
|
-
"Stop": [
|
|
43
|
-
{
|
|
44
|
-
"matcher": "",
|
|
45
|
-
"hooks": [
|
|
46
|
-
{
|
|
47
|
-
"type": "command",
|
|
48
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
49
|
-
"shell": "bash",
|
|
50
|
-
"timeout": 5
|
|
51
|
-
}
|
|
52
|
-
]
|
|
53
|
-
}
|
|
54
|
-
],
|
|
55
|
-
"TeammateIdle": [
|
|
56
|
-
{
|
|
57
|
-
"matcher": "",
|
|
58
|
-
"hooks": [
|
|
59
|
-
{
|
|
60
|
-
"type": "command",
|
|
61
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
62
|
-
"shell": "bash",
|
|
63
|
-
"timeout": 5
|
|
64
|
-
}
|
|
65
|
-
]
|
|
66
|
-
}
|
|
67
|
-
],
|
|
68
|
-
"PermissionRequest": [
|
|
69
|
-
{
|
|
70
|
-
"matcher": "",
|
|
71
|
-
"hooks": [
|
|
72
|
-
{
|
|
73
|
-
"type": "command",
|
|
74
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/approval-gate.sh\"",
|
|
75
|
-
"shell": "bash",
|
|
76
|
-
"timeout": 86400
|
|
77
|
-
}
|
|
78
|
-
]
|
|
79
|
-
}
|
|
80
|
-
],
|
|
81
|
-
"PostToolUse": [
|
|
82
|
-
{
|
|
83
|
-
"matcher": "",
|
|
84
|
-
"hooks": [
|
|
85
|
-
{
|
|
86
|
-
"type": "command",
|
|
87
|
-
"command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh\"",
|
|
88
|
-
"shell": "bash",
|
|
89
|
-
"timeout": 5
|
|
90
|
-
}
|
|
91
|
-
]
|
|
92
|
-
}
|
|
93
|
-
]
|
|
94
|
-
}
|
|
2
|
+
"modules": ["./index.ts"]
|
|
95
3
|
}
|
|
@@ -26,8 +26,8 @@ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
|
|
|
26
26
|
// because an enqueue wakes the poll immediately.
|
|
27
27
|
const WAIT_SEC = 120;
|
|
28
28
|
const RETRY_MS = 15000;
|
|
29
|
-
// Windows sometimes fails a loopback connect with ETIMEDOUT
|
|
30
|
-
//
|
|
29
|
+
// Windows sometimes fails a loopback connect with ETIMEDOUT, or resets it once open, while the
|
|
30
|
+
// board is up, so those errors get a few short waits before the normal one.
|
|
31
31
|
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
32
32
|
// `--topic dispatch` is the dispatch inbox: reports from sessions this one started.
|
|
33
33
|
const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
|
|
@@ -75,8 +75,8 @@ async function poll(base) {
|
|
|
75
75
|
} catch (e) {
|
|
76
76
|
// No board yet, or it went away. It may come back later in the session, so keep
|
|
77
77
|
// waiting quietly -- a missing server is the normal case, not an error.
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
78
|
+
const blip = (e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect') || e.cause?.code === 'ECONNRESET';
|
|
79
|
+
if (blip && connectRetries < CONNECT_RETRY_MS.length) {
|
|
80
80
|
await sleep(CONNECT_RETRY_MS[connectRetries++]);
|
|
81
81
|
} else {
|
|
82
82
|
connectRetries = 0;
|