typeclaw 0.22.0 → 0.24.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/package.json +1 -1
- package/src/agent/index.ts +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +41 -2
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +28 -10
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +31 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +18 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +75 -8
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +477 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +95 -0
- package/src/cli/inspect-controller.ts +99 -0
- package/src/cli/inspect.ts +21 -123
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +30 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- package/typeclaw.schema.json +10 -0
package/src/inspect/live.ts
CHANGED
|
@@ -10,8 +10,11 @@ export type StreamLiveOptions = {
|
|
|
10
10
|
WebSocketImpl?: typeof WebSocket
|
|
11
11
|
onSubscribed?: (live: boolean) => void
|
|
12
12
|
onError?: (message: string) => void
|
|
13
|
+
connectTimeoutMs?: number
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
|
|
17
|
+
|
|
15
18
|
export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
|
|
16
19
|
const WS = opts.WebSocketImpl ?? WebSocket
|
|
17
20
|
const ws = new WS(opts.url)
|
|
@@ -63,9 +66,11 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
63
66
|
}
|
|
64
67
|
})
|
|
65
68
|
|
|
66
|
-
// Settle on open OR on any terminal condition (error/close/abort).
|
|
67
|
-
// false
|
|
68
|
-
// otherwise `await onOpen` would hang forever and freeze the inspect CLI.
|
|
69
|
+
// Settle on open OR on any terminal condition (error/close/abort/timeout).
|
|
70
|
+
// Resolving false on abort/close/timeout is what unblocks the connect gate —
|
|
71
|
+
// otherwise `await onOpen` would hang forever and freeze the inspect CLI. The
|
|
72
|
+
// timeout bounds Bun/websocket states that neither open nor error promptly.
|
|
73
|
+
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
|
69
74
|
const onOpen = new Promise<boolean>((resolve, reject) => {
|
|
70
75
|
ws.addEventListener('open', () => resolve(true), { once: true })
|
|
71
76
|
ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
|
|
@@ -74,6 +79,8 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
74
79
|
if (opts.signal.aborted) resolve(false)
|
|
75
80
|
else opts.signal.addEventListener('abort', () => resolve(false), { once: true })
|
|
76
81
|
}
|
|
82
|
+
const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
83
|
+
connectTimer = setTimeout(() => reject(new Error('websocket connect timed out')), timeoutMs)
|
|
77
84
|
})
|
|
78
85
|
ws.addEventListener('close', () => {
|
|
79
86
|
closed = true
|
|
@@ -109,7 +116,14 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
109
116
|
opened = await onOpen
|
|
110
117
|
} catch (err) {
|
|
111
118
|
closed = true
|
|
119
|
+
try {
|
|
120
|
+
ws.close()
|
|
121
|
+
} catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
}
|
|
112
124
|
throw err
|
|
125
|
+
} finally {
|
|
126
|
+
if (connectTimer !== null) clearTimeout(connectTimer)
|
|
113
127
|
}
|
|
114
128
|
if (!opened || closed || opts.signal?.aborted === true) return
|
|
115
129
|
|
package/src/inspect/loop.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
|
|
2
2
|
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
export type TailController = {
|
|
4
|
+
signal: AbortSignal
|
|
5
|
+
intent: () => 'back' | 'exit' | null
|
|
6
|
+
dispose: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
|
|
10
|
+
// Builds a fresh interaction scope for ONE live-tail attempt: a new
|
|
11
|
+
// AbortController plus a temporary raw-mode listener. The loop disposes it
|
|
12
|
+
// before the picker re-opens so clack always owns a clean, non-raw stdin —
|
|
13
|
+
// this is what replaces the old pause/resume-same-controller model.
|
|
14
|
+
createTailScope: () => TailController
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
|
|
14
18
|
let sessionArg = opts.sessionIdOrPrefix
|
|
15
|
-
// Remember the last session the user picked from the interactive picker so
|
|
16
|
-
// an ESC-back-to-picker re-opens with that row pre-selected. The picker
|
|
17
|
-
// receives this through the `initialSessionId` hint on its second arg.
|
|
18
19
|
let lastPickedId: string | undefined
|
|
19
20
|
const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
|
|
20
21
|
const hint = selectOpts?.initialSessionId ?? lastPickedId
|
|
@@ -24,18 +25,23 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
while (true) {
|
|
27
|
-
const
|
|
28
|
-
const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
|
|
29
|
-
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
30
|
-
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
31
|
-
|
|
28
|
+
const scope = opts.createTailScope()
|
|
32
29
|
let result: RunInspectResult
|
|
33
30
|
try {
|
|
31
|
+
const callOpts: RunInspectOptions = {
|
|
32
|
+
...opts,
|
|
33
|
+
selectSession: wrappedSelectSession,
|
|
34
|
+
signal: scope.signal,
|
|
35
|
+
}
|
|
36
|
+
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
37
|
+
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
34
38
|
result = await runInspect(callOpts)
|
|
35
39
|
} finally {
|
|
36
|
-
|
|
40
|
+
scope.dispose()
|
|
37
41
|
}
|
|
42
|
+
|
|
38
43
|
if (!result.ok) return result
|
|
44
|
+
if (scope.intent() === 'exit') return result
|
|
39
45
|
if (result.escToPicker !== true) return result
|
|
40
46
|
sessionArg = undefined
|
|
41
47
|
}
|
package/src/run/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createSession, createSessionWithDispose } from '@/agent'
|
|
|
4
4
|
import { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
5
5
|
import { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
6
6
|
import { requestContainerRestart } from '@/agent/restart'
|
|
7
|
+
import { consumeRestartHandoff } from '@/agent/restart-handoff'
|
|
7
8
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
8
9
|
import {
|
|
9
10
|
awaitWithSubagentTimeout,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
type SubagentRegistry,
|
|
17
18
|
type SubagentShared,
|
|
18
19
|
} from '@/agent/subagents'
|
|
20
|
+
import { clearTodosForOrigin } from '@/agent/todo/continuation-wiring'
|
|
19
21
|
import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
20
22
|
import {
|
|
21
23
|
createChannelManager,
|
|
@@ -282,14 +284,31 @@ export async function startAgent({
|
|
|
282
284
|
// `typeclaw run` outside Docker), the handler reports that instead of the
|
|
283
285
|
// command resolving as unknown, which would make the advertised contract
|
|
284
286
|
// depend on the runtime environment.
|
|
285
|
-
onRestart: async (): Promise<string> => {
|
|
287
|
+
onRestart: async (ctx): Promise<string> => {
|
|
286
288
|
if (containerName === undefined) {
|
|
287
289
|
return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
|
|
288
290
|
}
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
291
|
+
// When the /restart command resolved a live channel session, ctx carries
|
|
292
|
+
// its identity: pass stream + session id/file + channel handoffOrigin so
|
|
293
|
+
// the dying container appends the `typeclaw.restart-self` entry (via the
|
|
294
|
+
// broadcast) and writes a channel-origin handoff. On the next boot the
|
|
295
|
+
// channel resume path reopens that exact conversation. With no live
|
|
296
|
+
// session (cold channel / native slash), ctx is undefined and the
|
|
297
|
+
// container just bounces — the next inbound resumes pending todos.
|
|
298
|
+
const result = await requestContainerRestart({
|
|
299
|
+
containerName,
|
|
300
|
+
...(ctx !== undefined
|
|
301
|
+
? {
|
|
302
|
+
stream,
|
|
303
|
+
agentDir: cwd,
|
|
304
|
+
originatingSessionId: ctx.originatingSessionId,
|
|
305
|
+
...(ctx.originatingSessionFile !== undefined
|
|
306
|
+
? { originatingSessionFile: ctx.originatingSessionFile }
|
|
307
|
+
: {}),
|
|
308
|
+
handoffOrigin: ctx.handoffOrigin,
|
|
309
|
+
}
|
|
310
|
+
: {}),
|
|
311
|
+
})
|
|
293
312
|
return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
|
|
294
313
|
},
|
|
295
314
|
})
|
|
@@ -434,6 +453,13 @@ export async function startAgent({
|
|
|
434
453
|
// marker so the audit trail records "user edited cron.json".
|
|
435
454
|
scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
|
|
436
455
|
}
|
|
456
|
+
// Cron todos are per-fire ephemeral by default: each scheduled run starts
|
|
457
|
+
// with a clean list so an incomplete item from a prior fire cannot
|
|
458
|
+
// resurrect indefinitely on every tick. (A future opt-in could carry them
|
|
459
|
+
// forward; until then, clearing is the safe default.)
|
|
460
|
+
await clearTodosForOrigin(cwd, cronOrigin).catch((err) =>
|
|
461
|
+
console.error(`[cron] ${job.id}: clear todos failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
462
|
+
)
|
|
437
463
|
const session = await createSession({
|
|
438
464
|
reloadRegistry,
|
|
439
465
|
sessionManager,
|
|
@@ -507,8 +533,37 @@ export async function startAgent({
|
|
|
507
533
|
})
|
|
508
534
|
|
|
509
535
|
reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
|
|
536
|
+
|
|
537
|
+
// Two-phase channel restart-resume around adapter startup, to close the race
|
|
538
|
+
// where an adapter starts receiving before the resume claims the handoff:
|
|
539
|
+
// 1. Claim the channel handoff and RESERVE the originating key BEFORE
|
|
540
|
+
// channelManager.start(). The reservation installs a per-key gate, so an
|
|
541
|
+
// inbound that arrives the instant an adapter connects coalesces onto the
|
|
542
|
+
// resume instead of stale-rolling the mapping or creating a rival session.
|
|
543
|
+
// 2. start() the adapters (registers outbound callbacks the wake reply needs).
|
|
544
|
+
// 3. resume() the reservation: reopen the exact session and enqueue the wake
|
|
545
|
+
// — skipped automatically if a real inbound already coalesced in (2)→(3).
|
|
546
|
+
// Claims ONLY channel handoffs; tui handoffs are left on disk (peek-then-delete
|
|
547
|
+
// never removes an unclaimed handoff) for the websocket open handler to claim.
|
|
548
|
+
// Best-effort throughout: any failure leaves the todo to resume on the next inbound.
|
|
549
|
+
let restartReservation: ReturnType<typeof channelManager.router.reserveRestartHandoff> = null
|
|
550
|
+
try {
|
|
551
|
+
const handoff = await consumeRestartHandoff(cwd, { accept: (h) => h.origin.kind === 'channel' })
|
|
552
|
+
if (handoff !== null) restartReservation = channelManager.router.reserveRestartHandoff(handoff)
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.warn(`[run] channel restart-resume reserve failed: ${err instanceof Error ? err.message : err}`)
|
|
555
|
+
}
|
|
556
|
+
|
|
510
557
|
await channelManager.start()
|
|
511
558
|
|
|
559
|
+
if (restartReservation !== null) {
|
|
560
|
+
try {
|
|
561
|
+
await restartReservation.resume()
|
|
562
|
+
} catch (err) {
|
|
563
|
+
console.warn(`[run] channel restart-resume failed: ${err instanceof Error ? err.message : err}`)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
512
567
|
// Captured separately from setSpawnSubagent so both the plugin context and
|
|
513
568
|
// the plugin-command runner can dispatch through the same path. The setter
|
|
514
569
|
// returns void, so without this local binding we couldn't reuse the fn.
|
package/src/sandbox/build.ts
CHANGED
|
@@ -110,6 +110,7 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
appendMasks(argv, policy)
|
|
113
|
+
appendWritable(argv, policy)
|
|
113
114
|
|
|
114
115
|
if (policy.cwd !== undefined) {
|
|
115
116
|
argv.push('--chdir', policy.cwd)
|
|
@@ -128,6 +129,15 @@ function appendMasks(argv: string[], policy: SandboxPolicy): void {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function appendWritable(argv: string[], policy: SandboxPolicy): void {
|
|
133
|
+
for (const dir of policy.writable?.dirs ?? []) {
|
|
134
|
+
argv.push('--bind', dir, dir)
|
|
135
|
+
}
|
|
136
|
+
for (const file of policy.writable?.files ?? []) {
|
|
137
|
+
argv.push('--bind', file, file)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
function appendMount(argv: string[], mount: SandboxMount): void {
|
|
132
142
|
switch (mount.type) {
|
|
133
143
|
case 'ro-bind':
|
package/src/sandbox/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
2
|
export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
3
3
|
export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
|
|
4
|
+
export { resolveWritableZones, subtractMasked, type WritableZones } from './writable-zones'
|
|
4
5
|
export { formatCommand, shellQuote } from './quote'
|
|
5
6
|
export { SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
6
7
|
export {
|
|
@@ -12,4 +13,5 @@ export {
|
|
|
12
13
|
type SandboxPolicy,
|
|
13
14
|
type SandboxProcessPolicy,
|
|
14
15
|
type SandboxProcStrategy,
|
|
16
|
+
type SandboxWritablePolicy,
|
|
15
17
|
} from './policy'
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -37,11 +37,21 @@ export type SandboxMaskPolicy = {
|
|
|
37
37
|
files?: string[]
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Writable carve-outs re-exposed on top of a read-only project root AND its
|
|
41
|
+
// masks. Rendered last so "last op wins" makes these the only RW paths: an RW
|
|
42
|
+
// bind here overrides the broad --ro-bind parent, while anything not listed
|
|
43
|
+
// stays read-only (EROFS) or masked.
|
|
44
|
+
export type SandboxWritablePolicy = {
|
|
45
|
+
dirs?: string[]
|
|
46
|
+
files?: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export type SandboxPolicy = {
|
|
41
50
|
bwrapPath?: string
|
|
42
51
|
cwd?: string
|
|
43
52
|
mounts?: SandboxMount[]
|
|
44
53
|
masks?: SandboxMaskPolicy
|
|
54
|
+
writable?: SandboxWritablePolicy
|
|
45
55
|
network?: SandboxNetwork
|
|
46
56
|
env?: SandboxEnvPolicy
|
|
47
57
|
commandFilter?: SandboxCommandFilter
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises'
|
|
2
|
+
import path, { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type WritableZones = {
|
|
5
|
+
dirs: string[]
|
|
6
|
+
files: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// SECURITY: a blanket RW bind is coarser than the write/edit guards, so this set
|
|
10
|
+
// is deliberately NARROWER than the write/edit allowlist — only genuinely
|
|
11
|
+
// free-write scratch zones. `.agents/skills` and `packages` are excluded: the
|
|
12
|
+
// former is validated (SKILL.md shape, name, frontmatter) by the skillAuthoring
|
|
13
|
+
// guard and the latter holds executable plugin code; bash must not get blanket
|
|
14
|
+
// RW to either. Skill authoring and package writes go through the guarded
|
|
15
|
+
// write/edit tool only.
|
|
16
|
+
const WRITABLE_DIRS = ['workspace', 'public', 'mounts'] as const
|
|
17
|
+
|
|
18
|
+
// Bash may EDIT these when present; creating a MISSING root file goes through
|
|
19
|
+
// write/edit (bwrap cannot RW-bind a non-existent source without pre-creating it).
|
|
20
|
+
const WRITABLE_ROOT_FILES = [
|
|
21
|
+
'AGENTS.md',
|
|
22
|
+
'IDENTITY.md',
|
|
23
|
+
'SOUL.md',
|
|
24
|
+
'USER.md',
|
|
25
|
+
'cron.json',
|
|
26
|
+
'package.json',
|
|
27
|
+
'typeclaw.json',
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
// SECURITY: the symlink rejection is load-bearing. An RW bind follows symlinks,
|
|
31
|
+
// so a `workspace -> /etc` symlink at a zone root would grant write access to an
|
|
32
|
+
// outside path. (Symlinks INSIDE a real zone are already safe — the kernel
|
|
33
|
+
// resolves them to the read-only parent mount.)
|
|
34
|
+
export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
|
|
35
|
+
const dirs = await collectExisting(
|
|
36
|
+
WRITABLE_DIRS.map((d) => join(agentDir, d)),
|
|
37
|
+
'dir',
|
|
38
|
+
)
|
|
39
|
+
const files = await collectExisting(
|
|
40
|
+
WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
|
|
41
|
+
'file',
|
|
42
|
+
)
|
|
43
|
+
return { dirs, files }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// SECURITY: a writable RW bind renders AFTER the masks and last-op-wins, so an
|
|
47
|
+
// RW bind on a masked path would re-expose the real (hidden) directory. Drop any
|
|
48
|
+
// writable zone that is, or is nested under, a masked path so the confidentiality
|
|
49
|
+
// boundary survives — e.g. a guest's masked `workspace/` is never re-exposed RW.
|
|
50
|
+
export function subtractMasked(writable: WritableZones, masked: { dirs: string[]; files: string[] }): WritableZones {
|
|
51
|
+
const maskedDirs = masked.dirs
|
|
52
|
+
const isMasked = (target: string): boolean =>
|
|
53
|
+
masked.files.includes(target) || maskedDirs.some((dir) => target === dir || isInside(dir, target))
|
|
54
|
+
return {
|
|
55
|
+
dirs: writable.dirs.filter((dir) => !isMasked(dir)),
|
|
56
|
+
files: writable.files.filter((file) => !isMasked(file)),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isInside(parent: string, child: string): boolean {
|
|
61
|
+
const relative = path.relative(parent, child)
|
|
62
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectExisting(paths: string[], kind: 'dir' | 'file'): Promise<string[]> {
|
|
66
|
+
const checks = await Promise.all(paths.map((p) => isRealEntry(p, kind)))
|
|
67
|
+
return paths.filter((_, i) => checks[i])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function isRealEntry(path: string, kind: 'dir' | 'file'): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
const stats = await lstat(path)
|
|
73
|
+
if (stats.isSymbolicLink()) return false
|
|
74
|
+
return kind === 'dir' ? stats.isDirectory() : stats.isFile()
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -17,6 +17,14 @@ import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-hand
|
|
|
17
17
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
18
18
|
import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
19
19
|
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
20
|
+
import { TODO_CONTINUATION_SOURCE } from '@/agent/todo/continuation'
|
|
21
|
+
import {
|
|
22
|
+
armRestartKickForOrigin,
|
|
23
|
+
extractTurnUsage,
|
|
24
|
+
recordTurnOutcome,
|
|
25
|
+
recordTurnStart,
|
|
26
|
+
runIdleContinuation,
|
|
27
|
+
} from '@/agent/todo/continuation-wiring'
|
|
20
28
|
import type { ChannelRouter } from '@/channels/router'
|
|
21
29
|
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
22
30
|
import type { McpManager } from '@/mcp'
|
|
@@ -155,6 +163,7 @@ type QueuedPrompt = {
|
|
|
155
163
|
text: string
|
|
156
164
|
delivery: PromptDelivery
|
|
157
165
|
ts: number
|
|
166
|
+
source?: string
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
type SessionState = {
|
|
@@ -172,6 +181,7 @@ type SessionState = {
|
|
|
172
181
|
// generation that ran session.start. A plugin reload mid-connection does
|
|
173
182
|
// not re-target this session's lifecycle hooks.
|
|
174
183
|
runtimeSnapshot: PluginRuntimeState | null
|
|
184
|
+
unsubTurnOutcome: Unsubscribe | null
|
|
175
185
|
dispose: () => Promise<void>
|
|
176
186
|
}
|
|
177
187
|
|
|
@@ -257,7 +267,7 @@ export function createServer({
|
|
|
257
267
|
handoffPending = false
|
|
258
268
|
return null
|
|
259
269
|
}
|
|
260
|
-
handoffInFlight = consumeRestartHandoff(agentDir).catch(() => null)
|
|
270
|
+
handoffInFlight = consumeRestartHandoff(agentDir, { accept: (h) => h.origin.kind === 'tui' }).catch(() => null)
|
|
261
271
|
const result = await handoffInFlight
|
|
262
272
|
handoffPending = false
|
|
263
273
|
handoffInFlight = null
|
|
@@ -497,6 +507,7 @@ export function createServer({
|
|
|
497
507
|
unsubClaim: null,
|
|
498
508
|
activeClaimCode: null,
|
|
499
509
|
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
510
|
+
unsubTurnOutcome: null,
|
|
500
511
|
dispose,
|
|
501
512
|
}
|
|
502
513
|
sessionStates.set(ws, state)
|
|
@@ -505,12 +516,16 @@ export function createServer({
|
|
|
505
516
|
await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
|
|
506
517
|
}
|
|
507
518
|
|
|
519
|
+
if (agentDir !== undefined) {
|
|
520
|
+
state.unsubTurnOutcome = subscribeTurnOutcome(session, agentDir, origin, sessionFileId, logger)
|
|
521
|
+
}
|
|
522
|
+
|
|
508
523
|
liveSessionRegistry?.register({ sessionId: sessionFileId, session })
|
|
509
524
|
forwardSessionEvents(ws, session, logger, sessionFileId)
|
|
510
525
|
|
|
511
526
|
if (stream) {
|
|
512
527
|
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
513
|
-
enqueuePrompt(ws, state, msg, agentDir, logger),
|
|
528
|
+
enqueuePrompt(ws, state, msg, agentDir, logger, stream),
|
|
514
529
|
)
|
|
515
530
|
|
|
516
531
|
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
@@ -543,6 +558,17 @@ export function createServer({
|
|
|
543
558
|
// wired (state.unsubPrompts above) so the kick is enqueued, not
|
|
544
559
|
// dropped on the floor.
|
|
545
560
|
if (resumed !== null && stream) {
|
|
561
|
+
// Arm the one-shot restart-kick suppressor BEFORE publishing the
|
|
562
|
+
// kick: the kick owns the first post-restart turn ("I'm back"),
|
|
563
|
+
// so the first idle after it must not also fire a todo
|
|
564
|
+
// continuation. The flag is consumed by that first idle. Best-
|
|
565
|
+
// effort: a failure here only risks one redundant nudge, which
|
|
566
|
+
// the episode budget still bounds.
|
|
567
|
+
if (agentDir !== undefined) {
|
|
568
|
+
await armRestartKickForOrigin(agentDir, origin).catch((err) =>
|
|
569
|
+
logger.error(`[server] ${sessionFileId}: arm restart-kick suppression failed: ${describeErr(err)}`),
|
|
570
|
+
)
|
|
571
|
+
}
|
|
546
572
|
stream.publish({
|
|
547
573
|
target: { kind: 'session', sessionId: sessionFileId },
|
|
548
574
|
payload: { kind: 'prompt', text: ' ', delivery: 'queue' },
|
|
@@ -798,6 +824,7 @@ export function createServer({
|
|
|
798
824
|
}
|
|
799
825
|
} finally {
|
|
800
826
|
if (state) {
|
|
827
|
+
state.unsubTurnOutcome?.()
|
|
801
828
|
state.session.dispose()
|
|
802
829
|
await state.dispose()
|
|
803
830
|
liveSessionRegistry?.unregister(state.sessionFileId)
|
|
@@ -867,6 +894,31 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
867
894
|
})
|
|
868
895
|
}
|
|
869
896
|
|
|
897
|
+
// Record each completed turn's stopReason for the todo-continuation guard.
|
|
898
|
+
// Ordering-independent by design: this writes the outcome from `message_end`,
|
|
899
|
+
// and the idle path only reads the stored outcome — it never assumes the
|
|
900
|
+
// event arrived before idle fired. An unrecognized stopReason classifies as
|
|
901
|
+
// 'unknown', which the idle path treats as not-safe-to-continue (fail closed).
|
|
902
|
+
function subscribeTurnOutcome(
|
|
903
|
+
session: AgentSession,
|
|
904
|
+
agentDir: string,
|
|
905
|
+
origin: SessionOrigin,
|
|
906
|
+
sessionFileId: string,
|
|
907
|
+
logger: ServerLogger,
|
|
908
|
+
): Unsubscribe {
|
|
909
|
+
return session.subscribe((event) => {
|
|
910
|
+
const usage = extractTurnUsage(event)
|
|
911
|
+
if (usage === null) return
|
|
912
|
+
void recordTurnOutcome({
|
|
913
|
+
agentDir,
|
|
914
|
+
origin,
|
|
915
|
+
turnId: sessionFileId,
|
|
916
|
+
stopReason: usage.stopReason,
|
|
917
|
+
...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
|
|
918
|
+
}).catch((err) => logger.error(`[server] ${sessionFileId}: todo outcome capture failed: ${describeErr(err)}`))
|
|
919
|
+
})
|
|
920
|
+
}
|
|
921
|
+
|
|
870
922
|
function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
|
|
871
923
|
const detected = detectProviderError(message)
|
|
872
924
|
if (detected === null) return
|
|
@@ -895,6 +947,7 @@ function enqueuePrompt(
|
|
|
895
947
|
msg: StreamMessage,
|
|
896
948
|
agentDir: string | undefined,
|
|
897
949
|
logger: ServerLogger,
|
|
950
|
+
stream: Stream | undefined,
|
|
898
951
|
): void {
|
|
899
952
|
const payload = msg.payload as { kind?: string; text?: string; delivery?: PromptDelivery }
|
|
900
953
|
if (payload?.kind !== 'prompt' || typeof payload.text !== 'string') return
|
|
@@ -904,14 +957,16 @@ function enqueuePrompt(
|
|
|
904
957
|
send(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) })
|
|
905
958
|
})
|
|
906
959
|
}
|
|
960
|
+
const source = (msg.meta as { source?: unknown } | undefined)?.source
|
|
907
961
|
state.drainQueue.push({
|
|
908
962
|
streamMessageId: msg.id,
|
|
909
963
|
text: payload.text,
|
|
910
964
|
delivery,
|
|
911
965
|
ts: msg.ts,
|
|
966
|
+
...(typeof source === 'string' ? { source } : {}),
|
|
912
967
|
})
|
|
913
968
|
pushQueueState(ws, state)
|
|
914
|
-
void drain(ws, state, agentDir, logger)
|
|
969
|
+
void drain(ws, state, agentDir, logger, stream)
|
|
915
970
|
}
|
|
916
971
|
|
|
917
972
|
// `session.idle` semantically means "the agent finished a prompt and is now
|
|
@@ -948,7 +1003,13 @@ function makeTurnHookCallers(
|
|
|
948
1003
|
}
|
|
949
1004
|
}
|
|
950
1005
|
|
|
951
|
-
async function drain(
|
|
1006
|
+
async function drain(
|
|
1007
|
+
ws: Ws,
|
|
1008
|
+
state: SessionState,
|
|
1009
|
+
agentDir: string | undefined,
|
|
1010
|
+
logger: ServerLogger,
|
|
1011
|
+
stream: Stream | undefined,
|
|
1012
|
+
): Promise<void> {
|
|
952
1013
|
if (state.draining) return
|
|
953
1014
|
state.draining = true
|
|
954
1015
|
const fireIdle = makeIdleHookCaller(state)
|
|
@@ -960,6 +1021,14 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
|
|
|
960
1021
|
pushQueueState(ws, state)
|
|
961
1022
|
send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
|
|
962
1023
|
|
|
1024
|
+
if (agentDir !== undefined) {
|
|
1025
|
+
await recordTurnStart({
|
|
1026
|
+
agentDir,
|
|
1027
|
+
origin: state.origin,
|
|
1028
|
+
isRealUserTurn: item.source !== TODO_CONTINUATION_SOURCE,
|
|
1029
|
+
}).catch((err) => logger.error(`[server] ${state.sessionFileId}: todo turn-start failed: ${describeErr(err)}`))
|
|
1030
|
+
}
|
|
1031
|
+
|
|
963
1032
|
await fireTurnStart(item.text)
|
|
964
1033
|
try {
|
|
965
1034
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
|
|
@@ -971,12 +1040,57 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
|
|
|
971
1040
|
}
|
|
972
1041
|
await fireTurnEnd()
|
|
973
1042
|
await fireIdle()
|
|
1043
|
+
|
|
1044
|
+
// Idle-continuation runs INSIDE the loop and enqueues directly onto
|
|
1045
|
+
// drainQueue (not via stream.publish). Publishing would re-enter drain()
|
|
1046
|
+
// through the session subscriber while `state.draining` is still true, so
|
|
1047
|
+
// the nested call would no-op and the continuation would stall until some
|
|
1048
|
+
// unrelated event woke the loop again. Enqueuing here lets the same `while`
|
|
1049
|
+
// consume it on the next iteration. Only fires when the queue is otherwise
|
|
1050
|
+
// empty so a real user turn is never preempted by a continuation.
|
|
1051
|
+
if (state.drainQueue.length === 0) {
|
|
1052
|
+
await maybeContinueTodos(state, agentDir, logger)
|
|
1053
|
+
}
|
|
974
1054
|
}
|
|
975
1055
|
} finally {
|
|
976
1056
|
state.draining = false
|
|
977
1057
|
}
|
|
978
1058
|
}
|
|
979
1059
|
|
|
1060
|
+
// If incomplete todos remain and all guards pass, push a single continuation
|
|
1061
|
+
// prompt directly onto this session's drainQueue, tagged TODO_CONTINUATION_SOURCE
|
|
1062
|
+
// so the next drain iteration treats it as an injected (non-user) turn that does
|
|
1063
|
+
// not reset the episode budget. The enclosing drain loop consumes it; this never
|
|
1064
|
+
// calls drain() itself.
|
|
1065
|
+
async function maybeContinueTodos(
|
|
1066
|
+
state: SessionState,
|
|
1067
|
+
agentDir: string | undefined,
|
|
1068
|
+
logger: ServerLogger,
|
|
1069
|
+
): Promise<void> {
|
|
1070
|
+
if (agentDir === undefined) return
|
|
1071
|
+
try {
|
|
1072
|
+
await runIdleContinuation({
|
|
1073
|
+
agentDir,
|
|
1074
|
+
origin: state.origin,
|
|
1075
|
+
deliver: (text) => {
|
|
1076
|
+
state.drainQueue.push({
|
|
1077
|
+
streamMessageId: `todo-continuation-${crypto.randomUUID()}` as StreamMessageId,
|
|
1078
|
+
text,
|
|
1079
|
+
delivery: 'queue',
|
|
1080
|
+
ts: Date.now(),
|
|
1081
|
+
source: TODO_CONTINUATION_SOURCE,
|
|
1082
|
+
})
|
|
1083
|
+
},
|
|
1084
|
+
})
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
logger.error(`[server] ${state.sessionFileId}: todo continuation failed: ${describeErr(err)}`)
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function describeErr(err: unknown): string {
|
|
1091
|
+
return err instanceof Error ? err.message : String(err)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
980
1094
|
function pushQueueState(ws: Ws, state: SessionState): void {
|
|
981
1095
|
const pending: QueueStateItem[] = state.drainQueue.map((q) => ({
|
|
982
1096
|
id: q.streamMessageId,
|