typeclaw 0.23.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/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/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +10 -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 +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +445 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +68 -0
- package/src/cli/inspect-controller.ts +7 -0
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +22 -0
- 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/typeclaw.schema.json +10 -0
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
createAgentSession,
|
|
7
|
+
createCodingTools,
|
|
7
8
|
DefaultResourceLoader,
|
|
8
9
|
defineTool as definePiTool,
|
|
9
10
|
SessionManager,
|
|
@@ -45,11 +46,13 @@ import {
|
|
|
45
46
|
zodToToolParameters,
|
|
46
47
|
} from './plugin-tools'
|
|
47
48
|
import { createReloadTool } from './reload-tool'
|
|
49
|
+
import type { RestartHandoffOrigin } from './restart-handoff'
|
|
48
50
|
import { loadSelf } from './self'
|
|
49
51
|
import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
|
|
50
52
|
import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
|
|
51
53
|
import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
|
|
52
54
|
import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
|
|
55
|
+
import { attachToolNotFoundNudge } from './tool-not-found-nudge'
|
|
53
56
|
import {
|
|
54
57
|
createBudgetState,
|
|
55
58
|
type ToolResultBudget,
|
|
@@ -68,6 +71,7 @@ import { createSpawnSubagentTool } from './tools/spawn-subagent'
|
|
|
68
71
|
import { createStreamSnapshotTool } from './tools/stream-snapshot'
|
|
69
72
|
import { createSubagentCancelTool } from './tools/subagent-cancel'
|
|
70
73
|
import { createSubagentOutputTool } from './tools/subagent-output'
|
|
74
|
+
import { createTodoTools } from './tools/todo'
|
|
71
75
|
import { webfetchTool } from './tools/webfetch'
|
|
72
76
|
import { websearchTool } from './tools/websearch'
|
|
73
77
|
|
|
@@ -79,6 +83,13 @@ export { renderTurnRoleAnchor, renderTurnTimeAnchor } from './system-prompt'
|
|
|
79
83
|
|
|
80
84
|
type AgentSessionTools = NonNullable<Parameters<typeof createAgentSession>[0]>['tools']
|
|
81
85
|
|
|
86
|
+
// pi's default active built-in tools when a session declares no `tools:` filter
|
|
87
|
+
// (pi `createAgentSession` falls back to `defaultActiveToolNames`, which is the
|
|
88
|
+
// name set of `codingTools`). Derived from pi's own `createCodingTools()` rather
|
|
89
|
+
// than hardcoded so the list can't silently drift if pi adds/removes/renames a
|
|
90
|
+
// default builtin; `default-pi-builtins match pi's coding tool set` pins it.
|
|
91
|
+
const DEFAULT_PI_BUILTIN_TOOL_NAMES = createCodingTools(process.cwd()).map((t) => t.name)
|
|
92
|
+
|
|
82
93
|
export type PluginSessionWiring = {
|
|
83
94
|
registry: PluginRegistry
|
|
84
95
|
hooks: HookBus
|
|
@@ -248,6 +259,13 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
248
259
|
const getOrigin: () => SessionOrigin | undefined =
|
|
249
260
|
options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
|
|
250
261
|
|
|
262
|
+
// Holds the session's signal-only abort once `createAgentSession` resolves.
|
|
263
|
+
// Tools are wrapped BEFORE the session exists, so the loop guard reaches the
|
|
264
|
+
// abort through this lazily-resolved getter. See `fireLoopAbort` in
|
|
265
|
+
// plugin-tools.ts for why aborting (not throwing) is what stops the loop.
|
|
266
|
+
const abortHolder: { abort?: () => void } = {}
|
|
267
|
+
const getAbort: () => (() => void) | undefined = () => abortHolder.abort
|
|
268
|
+
|
|
251
269
|
// Subagent built-in tool refs are dual-routed (see BUILTIN_TOOL_DEFINITION
|
|
252
270
|
// dual-map in plugin-tools.ts): pi-side coding tools go to `tools:` so they
|
|
253
271
|
// become the strict base set, typeclaw-side web tools go to `customTools:`.
|
|
@@ -259,8 +277,8 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
259
277
|
? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
|
|
260
278
|
: { agentTools: [], toolDefinitions: [] }
|
|
261
279
|
const pluginCustomTools = options.pluginSubagent
|
|
262
|
-
? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
|
|
263
|
-
: wrapRegistryTools(options.plugins, getOrigin)
|
|
280
|
+
? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin, getAbort)
|
|
281
|
+
: wrapRegistryTools(options.plugins, getOrigin, getAbort)
|
|
264
282
|
|
|
265
283
|
// Per-run budget state for the tool-result byte ceiling. Allocated once per
|
|
266
284
|
// session creation and threaded into every wrapped tool so they share the
|
|
@@ -276,7 +294,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
276
294
|
|
|
277
295
|
const effectiveTools =
|
|
278
296
|
options.tools ?? (options.pluginSubagent ? (resolvedSubagentBuiltins.agentTools as AgentSessionTools) : undefined)
|
|
279
|
-
const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin)
|
|
297
|
+
const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin, getAbort)
|
|
280
298
|
const tools =
|
|
281
299
|
sessionBudget && sessionBudgetState && hookWrappedTools
|
|
282
300
|
? (hookWrappedTools.map((t) =>
|
|
@@ -348,6 +366,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
348
366
|
permissions: options.permissions,
|
|
349
367
|
reloadRoles: options.reloadRoles,
|
|
350
368
|
}),
|
|
369
|
+
...buildTodoTools(options.plugins?.agentDir, getOrigin),
|
|
351
370
|
]
|
|
352
371
|
// Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
|
|
353
372
|
// find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
|
|
@@ -361,10 +380,11 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
361
380
|
sessionId: options.plugins.sessionId,
|
|
362
381
|
hooks: options.plugins.hooks,
|
|
363
382
|
getOrigin,
|
|
383
|
+
getAbort,
|
|
364
384
|
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
365
385
|
})
|
|
366
386
|
: []
|
|
367
|
-
const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin)
|
|
387
|
+
const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin, getAbort)
|
|
368
388
|
const customToolsPreBudget = [...wrappedCustomSystemTools, ...pluginCustomTools, ...builtinPiToolOverrides]
|
|
369
389
|
const customTools =
|
|
370
390
|
sessionBudget && sessionBudgetState
|
|
@@ -385,25 +405,41 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
385
405
|
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
386
406
|
})
|
|
387
407
|
|
|
408
|
+
abortHolder.abort = () => {
|
|
409
|
+
if (session.agent.signal?.aborted !== true) session.agent.abort()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The names the session actually exposes to the model: pi's active base set
|
|
413
|
+
// (the caller's `tools:` filter, or pi's default builtins when unset) union
|
|
414
|
+
// the typeclaw/plugin custom tools. Deliberately EXCLUDES
|
|
415
|
+
// `builtinPiToolOverrides` — those replace builtin implementations by name,
|
|
416
|
+
// they are not additional callable names. This is the single source of truth
|
|
417
|
+
// for both the active-set re-narrowing below and the tool-not-found nudge
|
|
418
|
+
// vocabulary, so the two never drift (a divergence would make the nudge miss
|
|
419
|
+
// real tools or suggest tools the session deliberately did not expose).
|
|
420
|
+
const intendedActiveToolNames = [
|
|
421
|
+
...new Set([
|
|
422
|
+
...(tools !== undefined ? tools.map((t) => t.name) : DEFAULT_PI_BUILTIN_TOOL_NAMES),
|
|
423
|
+
...[...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name),
|
|
424
|
+
]),
|
|
425
|
+
]
|
|
426
|
+
|
|
388
427
|
// Re-narrow the active tool set after `createAgentSession`. pi 0.67.3's
|
|
389
428
|
// `_refreshToolRegistry` runs with `includeAllExtensionTools: true` and
|
|
390
429
|
// pushes every customTool name into the active set, which would widen
|
|
391
430
|
// a subagent's declared `[edit]` to all 7 builtin overrides plus every
|
|
392
|
-
// typeclaw custom tool.
|
|
393
|
-
// would have gotten WITHOUT the builtin overrides: pi's `initialActiveToolNames`
|
|
394
|
-
// (derived from `tools:`) union the names from typeclaw/plugin customTools.
|
|
395
|
-
// `builtinPiToolOverrides` are implementation overrides, never additions.
|
|
431
|
+
// typeclaw custom tool.
|
|
396
432
|
if (builtinPiToolOverrides.length > 0) {
|
|
397
|
-
|
|
398
|
-
const customToolActiveNames = [...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name)
|
|
399
|
-
const intendedActive = [...new Set([...baseActiveNames, ...customToolActiveNames])]
|
|
400
|
-
session.setActiveToolsByName(intendedActive)
|
|
433
|
+
session.setActiveToolsByName(intendedActiveToolNames)
|
|
401
434
|
}
|
|
402
435
|
|
|
403
436
|
const unsubRestart = subscribeRestartNotice(options.stream, sessionManager)
|
|
404
437
|
|
|
438
|
+
const unsubToolNudge = attachToolNotFoundNudge(session, intendedActiveToolNames)
|
|
439
|
+
|
|
405
440
|
const dispose = async () => {
|
|
406
441
|
unsubRestart?.()
|
|
442
|
+
unsubToolNudge()
|
|
407
443
|
if (materializedSkills) await materializedSkills.dispose()
|
|
408
444
|
}
|
|
409
445
|
return { session, dispose }
|
|
@@ -411,22 +447,39 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
411
447
|
|
|
412
448
|
// Decides whether the restart tool should write the cross-restart handoff
|
|
413
449
|
// file (`<agentDir>/.typeclaw/restart-pending.json`) and supplies the agentDir
|
|
414
|
-
// + session file path it needs to do so. Returns an empty
|
|
415
|
-
// "no handoff" — for
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
450
|
+
// + session file path + origin metadata it needs to do so. Returns an empty
|
|
451
|
+
// object — meaning "no handoff" — for cron/subagent/system origins (no
|
|
452
|
+
// attended session the next boot could resume) and for in-memory sessions
|
|
453
|
+
// (no file to reopen).
|
|
454
|
+
//
|
|
455
|
+
// TUI and channel origins both resume: a TUI restart reattaches to the
|
|
456
|
+
// reconnecting client (websocket open handler), a channel restart reopens the
|
|
457
|
+
// originating chat session on the channel router's boot path. The `origin`
|
|
458
|
+
// discriminator in the handoff is what routes the next boot to the correct
|
|
459
|
+
// subsystem.
|
|
421
460
|
export function buildRestartHandoffWiring(
|
|
422
461
|
options: { origin?: SessionOrigin; plugins?: { agentDir: string } },
|
|
423
462
|
sessionManager: SessionManager,
|
|
424
|
-
): { agentDir?: string; originatingSessionFile?: string } {
|
|
425
|
-
|
|
463
|
+
): { agentDir?: string; originatingSessionFile?: string; handoffOrigin?: RestartHandoffOrigin } {
|
|
464
|
+
const origin = options.origin
|
|
465
|
+
if (origin === undefined) return {}
|
|
466
|
+
const handoffOrigin = restartHandoffOriginFor(origin)
|
|
467
|
+
if (handoffOrigin === null) return {}
|
|
426
468
|
const agentDir = options.plugins?.agentDir
|
|
427
469
|
const sessionFile = sessionManager.getSessionFile()
|
|
428
470
|
if (agentDir === undefined || sessionFile === undefined) return {}
|
|
429
|
-
return { agentDir, originatingSessionFile: sessionFile }
|
|
471
|
+
return { agentDir, originatingSessionFile: sessionFile, handoffOrigin }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function restartHandoffOriginFor(origin: SessionOrigin): RestartHandoffOrigin | null {
|
|
475
|
+
if (origin.kind === 'tui') return { kind: 'tui' }
|
|
476
|
+
if (origin.kind === 'channel') {
|
|
477
|
+
return {
|
|
478
|
+
kind: 'channel',
|
|
479
|
+
key: { adapter: origin.adapter, workspace: origin.workspace, chat: origin.chat, thread: origin.thread },
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return null
|
|
430
483
|
}
|
|
431
484
|
|
|
432
485
|
// Subscribes the given session to the in-process broadcast that the `restart`
|
|
@@ -662,9 +715,18 @@ export function buildRoleGrantTools(opts: {
|
|
|
662
715
|
]
|
|
663
716
|
}
|
|
664
717
|
|
|
718
|
+
export function buildTodoTools(
|
|
719
|
+
agentDir: string | undefined,
|
|
720
|
+
getOrigin: () => SessionOrigin | undefined,
|
|
721
|
+
): ToolDefinition[] {
|
|
722
|
+
if (agentDir === undefined) return []
|
|
723
|
+
return createTodoTools({ agentDir, getOrigin })
|
|
724
|
+
}
|
|
725
|
+
|
|
665
726
|
function wrapRegistryTools(
|
|
666
727
|
plugins: PluginSessionWiring | undefined,
|
|
667
728
|
getOrigin: () => SessionOrigin | undefined,
|
|
729
|
+
getAbort: () => (() => void) | undefined,
|
|
668
730
|
): ToolDefinition[] {
|
|
669
731
|
if (!plugins) return []
|
|
670
732
|
return plugins.registry.tools.map((t: PluginRegisteredTool) =>
|
|
@@ -676,6 +738,7 @@ function wrapRegistryTools(
|
|
|
676
738
|
logger: t.logger,
|
|
677
739
|
hooks: plugins.hooks,
|
|
678
740
|
getOrigin,
|
|
741
|
+
getAbort,
|
|
679
742
|
}),
|
|
680
743
|
)
|
|
681
744
|
}
|
|
@@ -684,6 +747,7 @@ function wrapSystemAgentTools(
|
|
|
684
747
|
tools: AgentSessionTools | undefined,
|
|
685
748
|
plugins: PluginSessionWiring | undefined,
|
|
686
749
|
getOrigin: () => SessionOrigin | undefined,
|
|
750
|
+
getAbort: () => (() => void) | undefined,
|
|
687
751
|
): AgentSessionTools | undefined {
|
|
688
752
|
if (!tools || !hasToolHooks(plugins)) return tools
|
|
689
753
|
return tools.map((tool) =>
|
|
@@ -692,6 +756,7 @@ function wrapSystemAgentTools(
|
|
|
692
756
|
sessionId: plugins.sessionId,
|
|
693
757
|
hooks: plugins.hooks,
|
|
694
758
|
getOrigin,
|
|
759
|
+
getAbort,
|
|
695
760
|
}),
|
|
696
761
|
)
|
|
697
762
|
}
|
|
@@ -700,6 +765,7 @@ function wrapSystemTools(
|
|
|
700
765
|
tools: ToolDefinition[],
|
|
701
766
|
plugins: PluginSessionWiring | undefined,
|
|
702
767
|
getOrigin: () => SessionOrigin | undefined,
|
|
768
|
+
getAbort: () => (() => void) | undefined,
|
|
703
769
|
): ToolDefinition[] {
|
|
704
770
|
if (!hasToolHooks(plugins)) return tools
|
|
705
771
|
return tools.map((tool) =>
|
|
@@ -708,6 +774,7 @@ function wrapSystemTools(
|
|
|
708
774
|
sessionId: plugins.sessionId,
|
|
709
775
|
hooks: plugins.hooks,
|
|
710
776
|
getOrigin,
|
|
777
|
+
getAbort,
|
|
711
778
|
}),
|
|
712
779
|
)
|
|
713
780
|
}
|
|
@@ -721,6 +788,7 @@ function wrapSubagentCustomTools(
|
|
|
721
788
|
selection: PluginSubagentSelection,
|
|
722
789
|
plugins: PluginSessionWiring | undefined,
|
|
723
790
|
getOrigin: () => SessionOrigin | undefined,
|
|
791
|
+
getAbort: () => (() => void) | undefined,
|
|
724
792
|
): ToolDefinition[] {
|
|
725
793
|
if (!selection.customTools || !plugins) return []
|
|
726
794
|
const logger = makePluginLogger(selection.pluginName)
|
|
@@ -733,6 +801,7 @@ function wrapSubagentCustomTools(
|
|
|
733
801
|
logger,
|
|
734
802
|
hooks: plugins.hooks,
|
|
735
803
|
getOrigin,
|
|
804
|
+
getAbort,
|
|
736
805
|
}),
|
|
737
806
|
)
|
|
738
807
|
}
|
|
@@ -33,7 +33,13 @@ import type {
|
|
|
33
33
|
ToolContext,
|
|
34
34
|
ToolResult,
|
|
35
35
|
} from '@/plugin'
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
buildSandboxedCommand,
|
|
38
|
+
ensureBwrapAvailable,
|
|
39
|
+
resolveHiddenPaths,
|
|
40
|
+
resolveWritableZones,
|
|
41
|
+
subtractMasked,
|
|
42
|
+
} from '@/sandbox'
|
|
37
43
|
|
|
38
44
|
import { createLoopGuard, type LoopGuard } from './loop-guard'
|
|
39
45
|
import { checkImageReadRedirect } from './multimodal/read-redirect'
|
|
@@ -163,6 +169,10 @@ export type WrapToolOptions = {
|
|
|
163
169
|
// origin mutates per turn surface the current-turn `lastInboundAuthorId`
|
|
164
170
|
// to `tool.before`. Sessions with a fixed origin can pass `() => origin`.
|
|
165
171
|
getOrigin?: () => SessionOrigin | undefined
|
|
172
|
+
// Resolves the current turn's abort handle. Resolved lazily (not at wrap
|
|
173
|
+
// time) because tools are wrapped BEFORE `createAgentSession` returns the
|
|
174
|
+
// session whose `agent.abort` this points at. See `fireLoopAbort`.
|
|
175
|
+
getAbort?: () => (() => void) | undefined
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
export type WrapSystemToolOptions = {
|
|
@@ -170,6 +180,7 @@ export type WrapSystemToolOptions = {
|
|
|
170
180
|
sessionId: string
|
|
171
181
|
hooks: HookBus
|
|
172
182
|
getOrigin?: () => SessionOrigin | undefined
|
|
183
|
+
getAbort?: () => (() => void) | undefined
|
|
173
184
|
// When present, the bash builtin is rewritten through the per-tool bwrap
|
|
174
185
|
// sandbox with role-derived path masks. Absent (or no masks for the role)
|
|
175
186
|
// runs bash unchanged — preserving today's behavior for trusted+ and for
|
|
@@ -228,6 +239,7 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
|
|
|
228
239
|
|
|
229
240
|
const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
|
|
230
241
|
if (loopDecision.kind === 'block') {
|
|
242
|
+
fireLoopAbort(opts.getAbort)
|
|
231
243
|
return errorResult(loopDecision.message)
|
|
232
244
|
}
|
|
233
245
|
|
|
@@ -287,6 +299,7 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
|
|
|
287
299
|
}
|
|
288
300
|
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
289
301
|
if (loopDecision.kind === 'block') {
|
|
302
|
+
fireLoopAbort(opts.getAbort)
|
|
290
303
|
throw new Error(loopDecision.message)
|
|
291
304
|
}
|
|
292
305
|
const guardResult = await runFinalWriteGuards({
|
|
@@ -349,6 +362,7 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
349
362
|
}
|
|
350
363
|
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
351
364
|
if (loopDecision.kind === 'block') {
|
|
365
|
+
fireLoopAbort(opts.getAbort)
|
|
352
366
|
throw new Error(loopDecision.message)
|
|
353
367
|
}
|
|
354
368
|
const guardResult = await runFinalWriteGuards({
|
|
@@ -426,6 +440,7 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
|
|
|
426
440
|
delete mutableArgs[TYPECLAW_INTERNAL_BASH_ENV]
|
|
427
441
|
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
428
442
|
if (loopDecision.kind === 'block') {
|
|
443
|
+
fireLoopAbort(opts.getAbort)
|
|
429
444
|
throw new Error(loopDecision.message)
|
|
430
445
|
}
|
|
431
446
|
const guardResult = await runFinalWriteGuards({
|
|
@@ -499,12 +514,21 @@ async function applyBashSandbox(
|
|
|
499
514
|
if (dirs.length === 0 && files.length === 0) return
|
|
500
515
|
|
|
501
516
|
await ensureBwrapAvailable()
|
|
517
|
+
// Write-confined jail for low-trust roles: bind the whole project read-only,
|
|
518
|
+
// hide private/secret paths, then re-expose only the free-write scratch zones
|
|
519
|
+
// RW. Anything else under agentDir (.git/, node_modules/, agentDir root) is
|
|
520
|
+
// EROFS, so bash cannot sidestep the non-workspace-write guard. Trusted/owner
|
|
521
|
+
// never reach here (their masks are empty) and keep full unsandboxed access.
|
|
522
|
+
// subtractMasked drops any writable zone masked for this role so an RW bind
|
|
523
|
+
// never re-exposes a hidden path (e.g. a guest's masked workspace/).
|
|
524
|
+
const writable = subtractMasked(await resolveWritableZones(agentDir), { dirs, files })
|
|
502
525
|
// bwrap does --clearenv, so the overlay must be re-introduced via env.set or
|
|
503
526
|
// it would never reach the sandboxed process (the non-sandboxed spawnHook
|
|
504
527
|
// path does not run when the command is rewritten to a bwrap invocation).
|
|
505
528
|
const { commandString } = buildSandboxedCommand(command, {
|
|
506
|
-
mounts: [{ type: 'bind', source: agentDir, dest: agentDir }],
|
|
529
|
+
mounts: [{ type: 'ro-bind', source: agentDir, dest: agentDir }],
|
|
507
530
|
masks: { dirs, files },
|
|
531
|
+
writable,
|
|
508
532
|
network: 'inherit',
|
|
509
533
|
cwd: agentDir,
|
|
510
534
|
...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
|
|
@@ -525,6 +549,18 @@ export function __resetSharedLoopGuardForTests(): void {
|
|
|
525
549
|
sharedLoopGuard = createLoopGuard()
|
|
526
550
|
}
|
|
527
551
|
|
|
552
|
+
// A loop-guard `block` verdict returned/thrown from a tool's execute() is
|
|
553
|
+
// caught by pi-agent-core and surfaced to the model as an `isError` result,
|
|
554
|
+
// which the model simply retries — the loop never ends. Aborting the run's
|
|
555
|
+
// AbortSignal is the only thing that actually stops the in-flight turn (the
|
|
556
|
+
// next assistant stream sees the aborted signal and ends with stopReason
|
|
557
|
+
// 'aborted'). We use the signal-only `agent.abort`, never `session.abort`,
|
|
558
|
+
// which would deadlock awaiting the very run this tool call belongs to. See
|
|
559
|
+
// the matching pattern in src/channels/router.ts (policy-denied send cap).
|
|
560
|
+
function fireLoopAbort(getAbort: (() => (() => void) | undefined) | undefined): void {
|
|
561
|
+
getAbort?.()?.()
|
|
562
|
+
}
|
|
563
|
+
|
|
528
564
|
function errorResult(message: string) {
|
|
529
565
|
return {
|
|
530
566
|
content: [{ type: 'text' as const, text: message }],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
|
|
3
|
-
import { writeRestartHandoff } from '@/agent/restart-handoff'
|
|
3
|
+
import { type RestartHandoffOrigin, writeRestartHandoff } from '@/agent/restart-handoff'
|
|
4
4
|
import { send, sendHttp } from '@/hostd/client'
|
|
5
5
|
import { containerSocketPath } from '@/hostd/paths'
|
|
6
6
|
import type { Stream } from '@/stream'
|
|
@@ -30,6 +30,11 @@ export type RequestContainerRestartOptions = {
|
|
|
30
30
|
agentDir?: string
|
|
31
31
|
originatingSessionId?: string
|
|
32
32
|
originatingSessionFile?: string
|
|
33
|
+
// Origin metadata persisted into the handoff so the next boot routes the
|
|
34
|
+
// resume to the right subsystem (tui → websocket open; channel → router
|
|
35
|
+
// startup). Required alongside agentDir + originatingSessionFile for the
|
|
36
|
+
// handoff to be written; omitting it skips the handoff entirely.
|
|
37
|
+
handoffOrigin?: RestartHandoffOrigin
|
|
33
38
|
restartedAt?: string
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -48,6 +53,7 @@ export async function requestContainerRestart({
|
|
|
48
53
|
agentDir,
|
|
49
54
|
originatingSessionId,
|
|
50
55
|
originatingSessionFile,
|
|
56
|
+
handoffOrigin,
|
|
51
57
|
restartedAt,
|
|
52
58
|
}: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
|
|
53
59
|
const request = { kind: 'restart' as const, containerName, build: build === true }
|
|
@@ -84,13 +90,19 @@ export async function requestContainerRestart({
|
|
|
84
90
|
// only; a missing one just cold-starts the rebooted container without the
|
|
85
91
|
// "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
|
|
86
92
|
// guard here too so this contract survives the writer being changed later.
|
|
87
|
-
if (
|
|
93
|
+
if (
|
|
94
|
+
agentDir !== undefined &&
|
|
95
|
+
originatingSessionId !== undefined &&
|
|
96
|
+
originatingSessionFile !== undefined &&
|
|
97
|
+
handoffOrigin !== undefined
|
|
98
|
+
) {
|
|
88
99
|
try {
|
|
89
100
|
await writeRestartHandoff(agentDir, {
|
|
90
|
-
schemaVersion:
|
|
101
|
+
schemaVersion: 2,
|
|
91
102
|
restartedAt: restartTimestamp,
|
|
92
103
|
originatingSessionId,
|
|
93
104
|
originatingSessionFile: basename(originatingSessionFile),
|
|
105
|
+
origin: handoffOrigin,
|
|
94
106
|
})
|
|
95
107
|
} catch {
|
|
96
108
|
// intentional swallow — see the post-ACK rationale above
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import type { AdapterId } from '@/channels/schema'
|
|
5
|
+
|
|
4
6
|
import { restartHandoffPath } from './paths'
|
|
5
7
|
|
|
6
8
|
export { restartHandoffPath } from './paths'
|
|
7
9
|
|
|
8
10
|
export const RESTART_HANDOFF_TTL_MS = 60_000
|
|
9
11
|
|
|
12
|
+
// The channel coordinates needed to reopen and wake the originating session
|
|
13
|
+
// on the channel side after a restart. Mirrors ChannelKey (src/channels/types)
|
|
14
|
+
// but is duplicated here so the handoff module does not depend on the channel
|
|
15
|
+
// subsystem's full type surface — only the four routing coordinates travel in
|
|
16
|
+
// the handoff file.
|
|
17
|
+
export type RestartHandoffChannelKey = {
|
|
18
|
+
adapter: AdapterId
|
|
19
|
+
workspace: string
|
|
20
|
+
chat: string
|
|
21
|
+
thread: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Discriminates which subsystem owns resuming the originating session on boot.
|
|
25
|
+
// A TUI handoff is claimed by the websocket `open` handler (it needs a
|
|
26
|
+
// reconnecting client); a channel handoff is claimed by channel startup (the
|
|
27
|
+
// router reopens the session and wakes it without any client). Splitting the
|
|
28
|
+
// claim by kind is what stops the first TUI reconnect from deleting a
|
|
29
|
+
// channel-origin handoff before channel boot can see it.
|
|
30
|
+
export type RestartHandoffOrigin = { kind: 'tui' } | { kind: 'channel'; key: RestartHandoffChannelKey }
|
|
31
|
+
|
|
10
32
|
export type RestartHandoff = {
|
|
11
|
-
schemaVersion:
|
|
33
|
+
schemaVersion: 2
|
|
12
34
|
restartedAt: string
|
|
13
35
|
originatingSessionId: string
|
|
14
36
|
originatingSessionFile: string
|
|
37
|
+
origin: RestartHandoffOrigin
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
// Atomic write via `.tmp` + rename so a crash mid-write never leaves the
|
|
@@ -38,13 +61,21 @@ export async function writeRestartHandoff(agentDir: string, handoff: RestartHand
|
|
|
38
61
|
// Otherwise a stale file would linger until the NEXT restart wrote a fresh
|
|
39
62
|
// one, and the boot consumer would re-read the stale entry every time.
|
|
40
63
|
//
|
|
64
|
+
// `accept` lets a caller claim only the handoffs it owns: the TUI path passes
|
|
65
|
+
// a tui-only predicate, channel boot passes a channel-only predicate. When the
|
|
66
|
+
// predicate REJECTS an otherwise-valid handoff, the file is restored so the
|
|
67
|
+
// rightful owner can still claim it (best-effort; a restore failure degrades
|
|
68
|
+
// to the same cold-start as a missing handoff). When `accept` is omitted, any
|
|
69
|
+
// valid handoff is consumed (preserves the original single-consumer behavior
|
|
70
|
+
// for callers that do not need kind-aware claiming).
|
|
71
|
+
//
|
|
41
72
|
// Returns the parsed handoff iff the file existed, was valid JSON of the
|
|
42
|
-
// expected shape,
|
|
43
|
-
// `now` and `ttlMs` are
|
|
44
|
-
// without sleeping.
|
|
73
|
+
// expected shape, was within `ttlMs` of `now`, and (if `accept` is given)
|
|
74
|
+
// passed the predicate. Otherwise returns null. `now` and `ttlMs` are
|
|
75
|
+
// injectable so tests can drive the recency gate without sleeping.
|
|
45
76
|
export async function consumeRestartHandoff(
|
|
46
77
|
agentDir: string,
|
|
47
|
-
options: { now?: number; ttlMs?: number } = {},
|
|
78
|
+
options: { now?: number; ttlMs?: number; accept?: (handoff: RestartHandoff) => boolean } = {},
|
|
48
79
|
): Promise<RestartHandoff | null> {
|
|
49
80
|
const path = restartHandoffPath(agentDir)
|
|
50
81
|
const now = options.now ?? Date.now()
|
|
@@ -57,14 +88,35 @@ export async function consumeRestartHandoff(
|
|
|
57
88
|
return null
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
await rm(path, { force: true }).catch(() => undefined)
|
|
61
|
-
|
|
62
91
|
const handoff = parseHandoff(raw)
|
|
63
|
-
|
|
92
|
+
|
|
93
|
+
// Peek before delete: a handoff we will NOT claim (malformed, expired, or
|
|
94
|
+
// rejected by `accept`) is left untouched on disk so the rightful consumer
|
|
95
|
+
// can still find it. The previous delete-then-restore opened a window where
|
|
96
|
+
// a concurrent rightful consumer saw no file; never deleting an unclaimed
|
|
97
|
+
// handoff closes that window entirely.
|
|
98
|
+
if (handoff === null) {
|
|
99
|
+
await rm(path, { force: true }).catch(() => undefined)
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
64
102
|
|
|
65
103
|
const restartedAtMs = Date.parse(handoff.restartedAt)
|
|
66
|
-
if (Number.isNaN(restartedAtMs))
|
|
67
|
-
|
|
104
|
+
if (Number.isNaN(restartedAtMs) || now - restartedAtMs > ttlMs) {
|
|
105
|
+
await rm(path, { force: true }).catch(() => undefined)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (options.accept !== undefined && !options.accept(handoff)) return null
|
|
110
|
+
|
|
111
|
+
// Claim by deleting. A non-forced unlink distinguishes "we removed it" from
|
|
112
|
+
// "it was already gone": if another consumer of the same kind claimed it
|
|
113
|
+
// first, the unlink throws ENOENT and we return null, so the handoff is
|
|
114
|
+
// honored exactly once even under concurrent same-kind consumers.
|
|
115
|
+
try {
|
|
116
|
+
await rm(path)
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
68
120
|
|
|
69
121
|
return handoff
|
|
70
122
|
}
|
|
@@ -78,14 +130,60 @@ function parseHandoff(raw: string): RestartHandoff | null {
|
|
|
78
130
|
}
|
|
79
131
|
if (parsed === null || typeof parsed !== 'object') return null
|
|
80
132
|
const obj = parsed as Record<string, unknown>
|
|
81
|
-
|
|
133
|
+
|
|
82
134
|
if (typeof obj.restartedAt !== 'string') return null
|
|
83
135
|
if (typeof obj.originatingSessionId !== 'string' || obj.originatingSessionId === '') return null
|
|
84
136
|
if (typeof obj.originatingSessionFile !== 'string' || obj.originatingSessionFile === '') return null
|
|
137
|
+
|
|
138
|
+
// v1 handoffs predate the origin discriminator and were only ever written by
|
|
139
|
+
// TUI sessions (channel/cron origins wrote no handoff). Read them forward as
|
|
140
|
+
// a tui origin so an in-flight restart that straddles an upgrade still
|
|
141
|
+
// produces the "I'm back" turn.
|
|
142
|
+
if (obj.schemaVersion === 1) {
|
|
143
|
+
return {
|
|
144
|
+
schemaVersion: 2,
|
|
145
|
+
restartedAt: obj.restartedAt,
|
|
146
|
+
originatingSessionId: obj.originatingSessionId,
|
|
147
|
+
originatingSessionFile: obj.originatingSessionFile,
|
|
148
|
+
origin: { kind: 'tui' },
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (obj.schemaVersion !== 2) return null
|
|
153
|
+
const origin = parseOrigin(obj.origin)
|
|
154
|
+
if (origin === null) return null
|
|
85
155
|
return {
|
|
86
|
-
schemaVersion:
|
|
156
|
+
schemaVersion: 2,
|
|
87
157
|
restartedAt: obj.restartedAt,
|
|
88
158
|
originatingSessionId: obj.originatingSessionId,
|
|
89
159
|
originatingSessionFile: obj.originatingSessionFile,
|
|
160
|
+
origin,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseOrigin(raw: unknown): RestartHandoffOrigin | null {
|
|
165
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
166
|
+
const obj = raw as Record<string, unknown>
|
|
167
|
+
if (obj.kind === 'tui') return { kind: 'tui' }
|
|
168
|
+
if (obj.kind === 'channel') {
|
|
169
|
+
const key = parseChannelKey(obj.key)
|
|
170
|
+
if (key === null) return null
|
|
171
|
+
return { kind: 'channel', key }
|
|
172
|
+
}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseChannelKey(raw: unknown): RestartHandoffChannelKey | null {
|
|
177
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
178
|
+
const obj = raw as Record<string, unknown>
|
|
179
|
+
if (typeof obj.adapter !== 'string' || obj.adapter === '') return null
|
|
180
|
+
if (typeof obj.workspace !== 'string') return null
|
|
181
|
+
if (typeof obj.chat !== 'string') return null
|
|
182
|
+
if (obj.thread !== null && typeof obj.thread !== 'string') return null
|
|
183
|
+
return {
|
|
184
|
+
adapter: obj.adapter as AdapterId,
|
|
185
|
+
workspace: obj.workspace,
|
|
186
|
+
chat: obj.chat,
|
|
187
|
+
thread: obj.thread,
|
|
90
188
|
}
|
|
91
189
|
}
|
|
@@ -43,7 +43,9 @@ export function renderSubagentCompletionReminder(args: CompletionReminderArgs):
|
|
|
43
43
|
return (
|
|
44
44
|
`<system-reminder>\n` +
|
|
45
45
|
`Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
|
|
46
|
-
`Use subagent_output to inspect
|
|
46
|
+
`Use subagent_output to inspect. If this work was tracked in your todo list, ` +
|
|
47
|
+
`keep the item pending (or add a recovery item) via todo_write so it is not ` +
|
|
48
|
+
`dropped.${channelTail}\n` +
|
|
47
49
|
`</system-reminder>`
|
|
48
50
|
)
|
|
49
51
|
}
|