typeclaw 0.5.1 → 0.7.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 +34 -84
- package/package.json +1 -1
- package/src/agent/index.ts +80 -8
- package/src/agent/live-subagents.ts +215 -0
- package/src/agent/plugin-tools.ts +60 -20
- package/src/agent/session-origin.ts +15 -0
- package/src/agent/subagents.ts +140 -3
- package/src/agent/system-prompt.ts +42 -0
- package/src/agent/tools/channel-reply.ts +24 -1
- package/src/agent/tools/channel-send.ts +26 -1
- package/src/agent/tools/spawn-subagent.ts +283 -0
- package/src/agent/tools/subagent-cancel.ts +96 -0
- package/src/agent/tools/subagent-output.ts +192 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
- package/src/bundled-plugins/explorer/explorer.ts +103 -0
- package/src/bundled-plugins/explorer/index.ts +11 -0
- package/src/bundled-plugins/guard/index.ts +12 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
- package/src/bundled-plugins/guard/policy.ts +1 -0
- package/src/bundled-plugins/operator/index.ts +11 -0
- package/src/bundled-plugins/operator/operator.ts +76 -0
- package/src/bundled-plugins/scout/index.ts +11 -0
- package/src/bundled-plugins/scout/scout.ts +94 -0
- package/src/channels/router.ts +32 -0
- package/src/cli/init.ts +8 -1
- package/src/cli/oauth-callbacks.ts +64 -34
- package/src/cli/provider.ts +9 -4
- package/src/config/config.ts +73 -16
- package/src/config/index.ts +3 -0
- package/src/config/providers.ts +106 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- package/src/init/models-dev.ts +1 -0
- package/src/permissions/builtins.ts +23 -2
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/types.ts +15 -22
- package/src/run/bundled-plugins.ts +6 -0
- package/src/run/channel-session-factory.ts +19 -0
- package/src/run/index.ts +56 -6
- package/src/server/index.ts +103 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
- package/src/skills/typeclaw-config/SKILL.md +29 -26
- package/typeclaw.schema.json +12 -0
package/src/cron/schema.ts
CHANGED
|
@@ -151,6 +151,26 @@ function describeCronStep(step: CronMigrationStep): string {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
export type ParseCronJsonOptions = ParseCronOptions & {
|
|
155
|
+
// Apply `migrateLegacyCronShape` before schema validation. Defaults to true
|
|
156
|
+
// so the guard accepts the same legacy shapes `loadCron` would auto-migrate
|
|
157
|
+
// on disk; pass false to validate the exact bytes (used in tests).
|
|
158
|
+
migrate?: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}): ParseCronResult {
|
|
162
|
+
let json: unknown
|
|
163
|
+
try {
|
|
164
|
+
json = JSON.parse(raw)
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const shouldMigrate = options.migrate ?? true
|
|
170
|
+
const migrated = shouldMigrate ? migrateLegacyCronShape(json) : { json, changed: false, applied: [] }
|
|
171
|
+
return parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
|
|
155
175
|
const parsed = cronFileSchema.safeParse(raw)
|
|
156
176
|
if (!parsed.success) {
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -377,6 +377,33 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
|
|
|
377
377
|
&& chmod +x ${TYPECLAW_ENTRYPOINT_PATH}`
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
// Claude Code's official installer is `curl | bash`, not apt — can't live
|
|
381
|
+
// in APT_FEATURES. Layer placed after the toggle apt install (so curl + ca-
|
|
382
|
+
// certificates from the baseline are guaranteed present) and before the
|
|
383
|
+
// entrypoint shim (which is always last). Omitted entirely when disabled.
|
|
384
|
+
//
|
|
385
|
+
// The Anthropic installer drops `claude` at `$HOME/.local/bin/claude` and
|
|
386
|
+
// emits a "~/.local/bin is not in your PATH" warning on every install on
|
|
387
|
+
// bun:1-slim (PATH out of the box is `/usr/local/sbin:/usr/local/bin:/usr/
|
|
388
|
+
// sbin:/usr/bin:/sbin:/bin:/usr/local/bun-node-fallback-bin`, no
|
|
389
|
+
// `~/.local/bin`). Without intervention, every `which claude` from the
|
|
390
|
+
// agent (and from the typeclaw-claude-code skill's verification step)
|
|
391
|
+
// returns empty. Symlink into `/usr/local/bin/` — already on PATH, matches
|
|
392
|
+
// what `cloudflared` does, survives `/root/.local/bin` getting rewritten
|
|
393
|
+
// by the installer's "update" path. The symlink resolves to the
|
|
394
|
+
// `~/.local/bin/claude` shim, which itself dereferences to the versioned
|
|
395
|
+
// binary under `~/.local/share/claude/versions/<ver>/`, so upgrades via
|
|
396
|
+
// `claude update` keep working without re-running this layer.
|
|
397
|
+
function renderClaudeCodeInstallLayer(enabled: boolean): string {
|
|
398
|
+
if (!enabled) return ''
|
|
399
|
+
return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
|
|
400
|
+
# typeclaw.json#docker.file.claudeCode. The skill \`typeclaw-claude-code\`
|
|
401
|
+
# documents the auth + usage flow.
|
|
402
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash \\
|
|
403
|
+
&& ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
|
|
404
|
+
&& claude --version > /dev/null`
|
|
405
|
+
}
|
|
406
|
+
|
|
380
407
|
// Shared-library runtime deps Chrome for Testing needs to launch on amd64
|
|
381
408
|
// Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
|
|
382
409
|
// --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
|
|
@@ -454,10 +481,11 @@ export function buildDockerfile(
|
|
|
454
481
|
const customLines = renderCustomDockerfileLines(config.append)
|
|
455
482
|
const baseImageVersion = options.baseImageVersion ?? null
|
|
456
483
|
|
|
484
|
+
const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
|
|
457
485
|
const fromAndHeavyLayers =
|
|
458
486
|
baseImageVersion !== null
|
|
459
|
-
? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
|
|
460
|
-
: renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer)
|
|
487
|
+
? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
|
|
488
|
+
: renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
|
|
461
489
|
|
|
462
490
|
return `${BUILDKIT_HEADER}
|
|
463
491
|
# AUTOGENERATED by typeclaw — do not edit.
|
|
@@ -504,15 +532,18 @@ function renderVersionedHead(
|
|
|
504
532
|
ghKeyringLayer: string,
|
|
505
533
|
toggleAptArgs: string[],
|
|
506
534
|
cloudflaredLayer: string,
|
|
535
|
+
claudeCodeLayer: string,
|
|
507
536
|
): string {
|
|
508
537
|
const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
|
|
538
|
+
const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
|
|
539
|
+
const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
|
|
509
540
|
return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
|
|
510
541
|
|
|
511
542
|
WORKDIR /agent
|
|
512
543
|
|
|
513
544
|
ARG TARGETARCH
|
|
514
545
|
|
|
515
|
-
${ghKeyringLayer}${toggleAptLayer}${
|
|
546
|
+
${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
|
|
516
547
|
|
|
517
548
|
`
|
|
518
549
|
}
|
|
@@ -521,8 +552,15 @@ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredLayer}${renderEntrypointShimLayer
|
|
|
521
552
|
// dev-mode runs (typeclaw installed via file: / link: spec) where the
|
|
522
553
|
// matching :version GHCR tag does not yet exist, and by the test suite to
|
|
523
554
|
// keep coverage of the full-stack layers independent of GHCR availability.
|
|
524
|
-
function renderInlineHead(
|
|
555
|
+
function renderInlineHead(
|
|
556
|
+
ghKeyringLayer: string,
|
|
557
|
+
toggleAptArgs: string[],
|
|
558
|
+
cloudflaredLayer: string,
|
|
559
|
+
claudeCodeLayer: string,
|
|
560
|
+
): string {
|
|
525
561
|
const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
|
|
562
|
+
const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
|
|
563
|
+
const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
|
|
526
564
|
return `${FROM_AND_WORKDIR}
|
|
527
565
|
|
|
528
566
|
# Layers are ordered most-stable first to maximize Docker layer cache hits on
|
|
@@ -565,7 +603,7 @@ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
|
|
|
565
603
|
|
|
566
604
|
${LAYER_5_CHROME_FOR_TESTING}
|
|
567
605
|
|
|
568
|
-
${
|
|
606
|
+
${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
|
|
569
607
|
|
|
570
608
|
`
|
|
571
609
|
}
|
|
@@ -833,6 +871,7 @@ function defaultConfig(): DockerfileConfig {
|
|
|
833
871
|
cjkFonts: true,
|
|
834
872
|
cloudflared: true,
|
|
835
873
|
xvfb: true,
|
|
874
|
+
claudeCode: false,
|
|
836
875
|
append: [],
|
|
837
876
|
}
|
|
838
877
|
}
|
package/src/init/models-dev.ts
CHANGED
|
@@ -13,6 +13,7 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
13
13
|
// (Codex is a backend, not a separate provider in their taxonomy). Curated
|
|
14
14
|
// entries are surfaced regardless of upstream membership.
|
|
15
15
|
'openai-codex': 'openai',
|
|
16
|
+
anthropic: 'anthropic',
|
|
16
17
|
fireworks: 'fireworks-ai',
|
|
17
18
|
zai: 'zai',
|
|
18
19
|
// zai-coding (GLM Coding Plan) is a billing surface, not a separate model
|
|
@@ -12,6 +12,10 @@ export const CORE_PERMISSIONS = {
|
|
|
12
12
|
channelRespond: 'channel.respond',
|
|
13
13
|
cronSchedule: 'cron.schedule',
|
|
14
14
|
cronModify: 'cron.modify',
|
|
15
|
+
subagentSpawn: 'subagent.spawn',
|
|
16
|
+
subagentCancel: 'subagent.cancel',
|
|
17
|
+
subagentOutput: 'subagent.output',
|
|
18
|
+
subagentSpawnOperator: 'subagent.spawn.operator',
|
|
15
19
|
} as const
|
|
16
20
|
|
|
17
21
|
// Sentinel that `expandOwnerWildcard` swaps for the concrete union of
|
|
@@ -47,6 +51,10 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
47
51
|
CORE_PERMISSIONS.channelRespond,
|
|
48
52
|
CORE_PERMISSIONS.cronSchedule,
|
|
49
53
|
CORE_PERMISSIONS.cronModify,
|
|
54
|
+
CORE_PERMISSIONS.subagentSpawn,
|
|
55
|
+
CORE_PERMISSIONS.subagentCancel,
|
|
56
|
+
CORE_PERMISSIONS.subagentOutput,
|
|
57
|
+
CORE_PERMISSIONS.subagentSpawnOperator,
|
|
50
58
|
'security.bypass.low',
|
|
51
59
|
'security.bypass.medium',
|
|
52
60
|
OWNER_SECURITY_WILDCARD,
|
|
@@ -54,11 +62,24 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
54
62
|
},
|
|
55
63
|
trusted: {
|
|
56
64
|
match: [],
|
|
57
|
-
permissions: [
|
|
65
|
+
permissions: [
|
|
66
|
+
CORE_PERMISSIONS.channelRespond,
|
|
67
|
+
CORE_PERMISSIONS.cronSchedule,
|
|
68
|
+
CORE_PERMISSIONS.subagentSpawn,
|
|
69
|
+
CORE_PERMISSIONS.subagentCancel,
|
|
70
|
+
CORE_PERMISSIONS.subagentOutput,
|
|
71
|
+
CORE_PERMISSIONS.subagentSpawnOperator,
|
|
72
|
+
'security.bypass.low',
|
|
73
|
+
],
|
|
58
74
|
},
|
|
59
75
|
member: {
|
|
60
76
|
match: [],
|
|
61
|
-
permissions: [
|
|
77
|
+
permissions: [
|
|
78
|
+
CORE_PERMISSIONS.channelRespond,
|
|
79
|
+
CORE_PERMISSIONS.subagentSpawn,
|
|
80
|
+
CORE_PERMISSIONS.subagentCancel,
|
|
81
|
+
CORE_PERMISSIONS.subagentOutput,
|
|
82
|
+
],
|
|
62
83
|
},
|
|
63
84
|
guest: {
|
|
64
85
|
match: [],
|
package/src/plugin/define.ts
CHANGED
|
@@ -78,3 +78,5 @@ export const writeTool: BuiltinToolRef = { __builtinTool: 'write' }
|
|
|
78
78
|
export const grepTool: BuiltinToolRef = { __builtinTool: 'grep' }
|
|
79
79
|
export const findTool: BuiltinToolRef = { __builtinTool: 'find' }
|
|
80
80
|
export const lsTool: BuiltinToolRef = { __builtinTool: 'ls' }
|
|
81
|
+
export const websearchTool: BuiltinToolRef = { __builtinTool: 'websearch' }
|
|
82
|
+
export const webfetchTool: BuiltinToolRef = { __builtinTool: 'webfetch' }
|
package/src/plugin/index.ts
CHANGED
package/src/plugin/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
4
|
-
import type {
|
|
4
|
+
import type { SubagentShared } from '@/agent/subagents'
|
|
5
5
|
import type { PermissionService } from '@/permissions'
|
|
6
6
|
|
|
7
7
|
export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
|
|
@@ -40,35 +40,28 @@ export type SubagentContext<P = unknown> = {
|
|
|
40
40
|
|
|
41
41
|
export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
// The plugin-author-facing subagent declaration. Differs from
|
|
44
|
+
// `@/agent/subagents`'s `Subagent` only in the shape of `tools`/`customTools`:
|
|
45
|
+
// plugins reference builtin tools via tagged `BuiltinToolRef` strings (the
|
|
46
|
+
// stable plugin API) and contribute their own `Tool<any>[]`; the runtime
|
|
47
|
+
// resolves those refs to pi-coding-agent's wrapped tool shapes before the
|
|
48
|
+
// session sees them. Every other field is inherited from `SubagentShared`
|
|
49
|
+
// so a new shared field surfaces on both types in one edit. See
|
|
50
|
+
// `SubagentShared`'s doc-comment for the regression history.
|
|
51
|
+
//
|
|
52
|
+
// `inFlightKey` lives here only (not on the shared shape) because it is
|
|
53
|
+
// consumed exclusively by the `SubagentConsumer` via the
|
|
54
|
+
// `pluginSubagentByName` map, which holds the original plugin reference —
|
|
55
|
+
// the registry-flowing shim never needs to carry it.
|
|
56
|
+
export type Subagent<P = unknown> = SubagentShared<P> & {
|
|
52
57
|
tools?: BuiltinToolRef[]
|
|
53
58
|
customTools?: Tool<any>[]
|
|
54
|
-
payloadSchema?: z.ZodType<P>
|
|
55
|
-
handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
|
|
56
59
|
// Coalescing key for the SubagentConsumer's in-flight set. Default is the
|
|
57
60
|
// subagent name alone (only one instance of the subagent runs at a time).
|
|
58
61
|
// Override to allow per-payload concurrency, e.g. memory-logger keyed by
|
|
59
62
|
// parentSessionId so different parent sessions run in parallel while
|
|
60
63
|
// duplicate runs against the same session deduplicate.
|
|
61
64
|
inFlightKey?: (payload: P) => string
|
|
62
|
-
// Defensive ceiling on cumulative bytes of tool-result text per subagent
|
|
63
|
-
// run, applied to the named tools only. Once exceeded, subsequent calls to
|
|
64
|
-
// those tools short-circuit with a fixed message instructing the agent to
|
|
65
|
-
// stop reading. See `src/agent/tool-result-budget.ts` for the full
|
|
66
|
-
// rationale; the short version is: a single broken tool (e.g. find_entry
|
|
67
|
-
// failing because of a schema mismatch) can cause an agent to fall back to
|
|
68
|
-
// chunked reads of huge files, ballooning subagent token cost. The budget
|
|
69
|
-
// bounds the blast radius without changing per-call semantics for healthy
|
|
70
|
-
// runs.
|
|
71
|
-
toolResultBudget?: ToolResultBudget
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
// Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
|
|
2
2
|
import backupPlugin from '@/bundled-plugins/backup'
|
|
3
|
+
import explorerPlugin from '@/bundled-plugins/explorer'
|
|
3
4
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
4
5
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
6
|
+
import operatorPlugin from '@/bundled-plugins/operator'
|
|
7
|
+
import scoutPlugin from '@/bundled-plugins/scout'
|
|
5
8
|
import securityPlugin from '@/bundled-plugins/security'
|
|
6
9
|
import toolResultCapPlugin from '@/bundled-plugins/tool-result-cap'
|
|
7
10
|
import type { ResolvedPlugin } from '@/plugin'
|
|
@@ -36,4 +39,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
36
39
|
{ name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
|
|
37
40
|
{ name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
|
|
38
41
|
{ name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
|
|
42
|
+
{ name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
|
|
43
|
+
{ name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
|
|
44
|
+
{ name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
|
|
39
45
|
]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import { createSession as defaultCreateSession } from '@/agent'
|
|
4
|
+
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
5
|
+
import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagents'
|
|
4
6
|
import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
|
|
5
7
|
import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
|
|
6
8
|
import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
|
|
@@ -48,6 +50,18 @@ export type BuildChannelSessionFactoryDeps = {
|
|
|
48
50
|
// can assert exactly which CreateSessionOptions the factory builds without
|
|
49
51
|
// needing a live LLM, plugin runtime, or session manager on disk.
|
|
50
52
|
createSession?: typeof defaultCreateSession
|
|
53
|
+
// Subagent orchestration plumbing. All three (or none) are forwarded to
|
|
54
|
+
// createSession so the TUI/channel session exposes spawn_subagent,
|
|
55
|
+
// subagent_output, subagent_cancel. Subagent sessions never receive these
|
|
56
|
+
// — that branch is gated by pluginSubagent in createSessionWithDispose.
|
|
57
|
+
//
|
|
58
|
+
// `getCreateSessionForSubagent` is late-bound to break the construction
|
|
59
|
+
// cycle: channelManager owns the channel-session factory, which needs
|
|
60
|
+
// createSessionForSubagent, which needs channelManager.router. Same shape
|
|
61
|
+
// as `getChannelRouter` above.
|
|
62
|
+
liveSubagentRegistry?: LiveSubagentRegistry
|
|
63
|
+
subagentRegistry?: SubagentRegistry
|
|
64
|
+
getCreateSessionForSubagent?: () => CreateSessionForSubagent
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
// Tight basename validation so a tampered or corrupt channels/sessions.json
|
|
@@ -108,6 +122,11 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
|
|
|
108
122
|
...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
|
|
109
123
|
...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
|
|
110
124
|
...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
|
|
125
|
+
...(deps.liveSubagentRegistry !== undefined ? { liveSubagentRegistry: deps.liveSubagentRegistry } : {}),
|
|
126
|
+
...(deps.subagentRegistry !== undefined ? { subagentRegistry: deps.subagentRegistry } : {}),
|
|
127
|
+
...(deps.getCreateSessionForSubagent !== undefined
|
|
128
|
+
? { createSessionForSubagent: deps.getCreateSessionForSubagent() }
|
|
129
|
+
: {}),
|
|
111
130
|
})
|
|
112
131
|
|
|
113
132
|
return {
|
package/src/run/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import { createSession, createSessionWithDispose } from '@/agent'
|
|
4
|
+
import { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
4
5
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
5
6
|
import {
|
|
6
7
|
createSubagentConsumer,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
type Subagent as InternalSubagent,
|
|
10
11
|
type SubagentConsumer,
|
|
11
12
|
type SubagentRegistry,
|
|
13
|
+
type SubagentShared,
|
|
12
14
|
} from '@/agent/subagents'
|
|
13
15
|
import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
14
16
|
import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
|
|
@@ -176,6 +178,8 @@ export async function startAgent({
|
|
|
176
178
|
},
|
|
177
179
|
})
|
|
178
180
|
|
|
181
|
+
const liveSubagentRegistry = new LiveSubagentRegistry()
|
|
182
|
+
|
|
179
183
|
const channelManager = createChannelManagerFor({
|
|
180
184
|
agentDir: cwd,
|
|
181
185
|
channelsConfigRef: () => getConfig().channels,
|
|
@@ -191,6 +195,9 @@ export async function startAgent({
|
|
|
191
195
|
getChannelRouter: () => channelManager.router,
|
|
192
196
|
rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
|
|
193
197
|
permissions: pluginsLoaded.permissions,
|
|
198
|
+
liveSubagentRegistry,
|
|
199
|
+
subagentRegistry: pluginRuntime.get().subagents,
|
|
200
|
+
getCreateSessionForSubagent: () => createSessionForSubagent,
|
|
194
201
|
...containerNameOpt,
|
|
195
202
|
...runtimeVersionOpt,
|
|
196
203
|
}),
|
|
@@ -347,6 +354,9 @@ export async function startAgent({
|
|
|
347
354
|
},
|
|
348
355
|
}
|
|
349
356
|
: {}),
|
|
357
|
+
liveSubagentRegistry,
|
|
358
|
+
subagentRegistry: pluginRuntime.get().subagents,
|
|
359
|
+
createSessionForSubagent,
|
|
350
360
|
...containerNameOpt,
|
|
351
361
|
...runtimeVersionOpt,
|
|
352
362
|
})
|
|
@@ -465,6 +475,8 @@ export async function startAgent({
|
|
|
465
475
|
claimController,
|
|
466
476
|
commandRunnerFactory,
|
|
467
477
|
tunnelManager,
|
|
478
|
+
liveSubagentRegistry,
|
|
479
|
+
createSessionForSubagent,
|
|
468
480
|
...containerNameOpt,
|
|
469
481
|
...runtimeVersionOpt,
|
|
470
482
|
...tuiTokenOpt,
|
|
@@ -593,7 +605,15 @@ function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFa
|
|
|
593
605
|
return ({ file, onFire }) => createScheduler({ jobs: [...file.jobs, ...internalJobs()], onFire })
|
|
594
606
|
}
|
|
595
607
|
|
|
596
|
-
|
|
608
|
+
// Exported for the regression test in `merge-subagents.test.ts`. The shim
|
|
609
|
+
// layer between the plugin-author-facing `Subagent` (`@/plugin/types`) and
|
|
610
|
+
// the runtime-internal `Subagent` (`@/agent/subagents`) is the load-bearing
|
|
611
|
+
// translation point for visibility, payload-schema, and permission gating —
|
|
612
|
+
// fields that flow through the `SubagentRegistry` without going through the
|
|
613
|
+
// `pluginSubagentByShim` recovery path. Previous regressions silently
|
|
614
|
+
// dropped fields here, hiding every public bundled subagent (scout,
|
|
615
|
+
// explorer, operator) from the `spawn_subagent` tool surface.
|
|
616
|
+
export function mergeSubagents(pluginRegistry: PluginRegistry): {
|
|
597
617
|
registry: SubagentRegistry
|
|
598
618
|
pluginSubagentByShim: WeakMap<InternalSubagent<any>, PluginSubagentEntry>
|
|
599
619
|
pluginSubagentByName: Map<string, PluginSubagentEntry>
|
|
@@ -620,10 +640,40 @@ function mergeSubagents(pluginRegistry: PluginRegistry): {
|
|
|
620
640
|
return { registry: merged, pluginSubagentByShim, pluginSubagentByName }
|
|
621
641
|
}
|
|
622
642
|
|
|
643
|
+
// Compile-time proof that every plugin-only key on `@/plugin`'s `Subagent`
|
|
644
|
+
// (i.e. every key NOT inherited from `SubagentShared`) has been classified
|
|
645
|
+
// for the shim. When a future maintainer introduces a new field on plugin-side
|
|
646
|
+
// `Subagent` that isn't on `SubagentShared`, the `satisfies` clause on
|
|
647
|
+
// `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` below fails at compile time until the
|
|
648
|
+
// new key is listed there — and the destructuring in `pluginSubagentShim`
|
|
649
|
+
// is updated to discard it. Without this guard, the shim's rest-spread
|
|
650
|
+
// would silently leak future plugin-only fields into the internal registry —
|
|
651
|
+
// the opposite-direction drift from the bug this PR fixes for shared fields.
|
|
652
|
+
type PluginOnlySubagentKeys = Exclude<keyof import('@/plugin').Subagent<any>, keyof SubagentShared<any>>
|
|
653
|
+
const PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM = {
|
|
654
|
+
tools: true,
|
|
655
|
+
customTools: true,
|
|
656
|
+
inFlightKey: true,
|
|
657
|
+
} satisfies Record<PluginOnlySubagentKeys, true>
|
|
658
|
+
// Reference the table so it's not dead code. The value is a runtime no-op;
|
|
659
|
+
// the load-bearing work is the `satisfies` clause above which forces
|
|
660
|
+
// exhaustive classification of plugin-only keys at compile time.
|
|
661
|
+
void PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM
|
|
662
|
+
|
|
623
663
|
function pluginSubagentShim(subagent: import('@/plugin').Subagent<any>): InternalSubagent<any> {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
664
|
+
// The two diverging fields (`tools` is `BuiltinToolRef[]` plugin-side vs
|
|
665
|
+
// `AgentSessionTools` internal-side; `customTools` similarly differs) are
|
|
666
|
+
// resolved later in `createSessionForSubagent` via the
|
|
667
|
+
// `pluginSubagentByShim` lookup, which recovers the original plugin
|
|
668
|
+
// reference. `inFlightKey` is consumed only by the SubagentConsumer via
|
|
669
|
+
// `pluginSubagentByName`, not through this shim's registry path. Every
|
|
670
|
+
// other plugin-side field lives on `SubagentShared` and is structurally
|
|
671
|
+
// assignable to the internal `Subagent`, so a rest-spread carries them
|
|
672
|
+
// verbatim — including `visibility` and `requiresSpecificPermission`,
|
|
673
|
+
// whose silent drop in the previous shim made every plugin-contributed
|
|
674
|
+
// public subagent (scout, explorer, operator) invisible to the
|
|
675
|
+
// `spawn_subagent` tool. The list of keys removed here is enforced
|
|
676
|
+
// exhaustive at compile time by `PLUGIN_ONLY_KEYS_DROPPED_BY_SHIM` above.
|
|
677
|
+
const { tools: _tools, customTools: _customTools, inFlightKey: _inFlightKey, ...shared } = subagent
|
|
678
|
+
return shared
|
|
629
679
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
type CreateSessionResult,
|
|
8
8
|
} from '@/agent'
|
|
9
9
|
import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
10
|
+
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
10
11
|
import { detectProviderError } from '@/agent/provider-error'
|
|
11
12
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
13
|
+
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
12
14
|
import type { ChannelRouter } from '@/channels/router'
|
|
13
15
|
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
14
16
|
import type { HookBus } from '@/plugin'
|
|
@@ -79,6 +81,27 @@ export type ServerOptions = {
|
|
|
79
81
|
// without it the four `exec_command`-family messages are answered with
|
|
80
82
|
// `command_error` so the host CLI sees a clean failure.
|
|
81
83
|
commandRunnerFactory?: (outbound: CommandOutbound) => CommandRunner
|
|
84
|
+
// Subagent orchestration plumbing for TUI sessions. Both fields must be
|
|
85
|
+
// present together for the spawn_subagent / subagent_output / subagent_cancel
|
|
86
|
+
// tools to surface; `createSession` gates registration on all three of
|
|
87
|
+
// (liveSubagentRegistry, subagentRegistry, createSessionForSubagent), and
|
|
88
|
+
// we derive subagentRegistry per WS connection from the same `pluginRuntime`
|
|
89
|
+
// snapshot that already feeds `plugins.registry` — so a reload landing
|
|
90
|
+
// mid-connection keeps using the snapshot the session opened with, matching
|
|
91
|
+
// the existing per-session lifecycle invariant.
|
|
92
|
+
//
|
|
93
|
+
// `createSessionForSubagent` is passed eagerly (not late-bound) because the
|
|
94
|
+
// TUI server is constructed AFTER the channel manager in `startAgent`,
|
|
95
|
+
// breaking the construction cycle that forces the channel session factory's
|
|
96
|
+
// `getCreateSessionForSubagent` late-binding.
|
|
97
|
+
//
|
|
98
|
+
// Channel and cron sessions get the same plumbing through
|
|
99
|
+
// `buildChannelSessionFactory` / `createSessionForCron` (see src/run/). The
|
|
100
|
+
// three top-level callers must stay aligned; otherwise the agent's tool
|
|
101
|
+
// surface diverges across origin kinds — exactly the gap PR #281 flagged
|
|
102
|
+
// as out-of-scope follow-up.
|
|
103
|
+
liveSubagentRegistry?: LiveSubagentRegistry
|
|
104
|
+
createSessionForSubagent?: CreateSessionForSubagent
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
const consoleLogger: ServerLogger = {
|
|
@@ -176,6 +199,8 @@ export function createServer({
|
|
|
176
199
|
logger = consoleLogger,
|
|
177
200
|
claimController,
|
|
178
201
|
commandRunnerFactory,
|
|
202
|
+
liveSubagentRegistry,
|
|
203
|
+
createSessionForSubagent,
|
|
179
204
|
}: ServerOptions) {
|
|
180
205
|
const sessionStates = new WeakMap<Ws, SessionState>()
|
|
181
206
|
const callIdToWs = new Map<string, AnyOwnerWs>()
|
|
@@ -351,6 +376,15 @@ export function createServer({
|
|
|
351
376
|
}
|
|
352
377
|
: undefined
|
|
353
378
|
const origin: SessionOrigin = { kind: 'tui', sessionId: sessionFileId }
|
|
379
|
+
// Derive subagentRegistry from the same runtimeSnapshot that
|
|
380
|
+
// populates `plugins.registry`. createSession gates the orchestration
|
|
381
|
+
// tools on (liveRegistry, subagentRegistry, createSessionForSubagent,
|
|
382
|
+
// agentDir) being all-present; threading the registry alongside the
|
|
383
|
+
// two server-owned fields gives the gate a complete tuple for TUI
|
|
384
|
+
// sessions whenever the host plumbed in plugin runtime + subagent
|
|
385
|
+
// wiring (production), while keeping every existing test that omits
|
|
386
|
+
// either side at exactly its current tool surface.
|
|
387
|
+
const subagentRegistry = runtimeSnapshot?.subagents
|
|
354
388
|
const result = await createSession({
|
|
355
389
|
reloadRegistry,
|
|
356
390
|
sessionManager,
|
|
@@ -360,6 +394,9 @@ export function createServer({
|
|
|
360
394
|
...(pluginsWiring ? { plugins: pluginsWiring } : {}),
|
|
361
395
|
...(containerName !== undefined ? { containerName } : {}),
|
|
362
396
|
...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
|
|
397
|
+
...(liveSubagentRegistry !== undefined ? { liveSubagentRegistry } : {}),
|
|
398
|
+
...(subagentRegistry !== undefined ? { subagentRegistry } : {}),
|
|
399
|
+
...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
|
|
363
400
|
})
|
|
364
401
|
const session = 'session' in result ? result.session : result
|
|
365
402
|
const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
|
|
@@ -392,6 +429,7 @@ export function createServer({
|
|
|
392
429
|
)
|
|
393
430
|
|
|
394
431
|
state.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
432
|
+
routeSubagentCompletionReminder(state, msg, stream)
|
|
395
433
|
const payload: ServerMessage = {
|
|
396
434
|
type: 'notification',
|
|
397
435
|
payload: msg.payload,
|
|
@@ -712,6 +750,71 @@ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, s
|
|
|
712
750
|
send(ws, { type: 'error', message: detected.message })
|
|
713
751
|
}
|
|
714
752
|
|
|
753
|
+
function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage, stream: Stream): void {
|
|
754
|
+
const payload = msg.payload
|
|
755
|
+
if (payload === null || typeof payload !== 'object') return
|
|
756
|
+
const p = payload as {
|
|
757
|
+
kind?: unknown
|
|
758
|
+
taskId?: unknown
|
|
759
|
+
subagent?: unknown
|
|
760
|
+
parentSessionId?: unknown
|
|
761
|
+
ok?: unknown
|
|
762
|
+
durationMs?: unknown
|
|
763
|
+
error?: unknown
|
|
764
|
+
}
|
|
765
|
+
if (p.kind !== 'subagent.completed') return
|
|
766
|
+
if (typeof p.parentSessionId !== 'string' || p.parentSessionId !== state.sessionFileId) return
|
|
767
|
+
|
|
768
|
+
const subagent = typeof p.subagent === 'string' ? p.subagent : 'subagent'
|
|
769
|
+
const taskId = typeof p.taskId === 'string' ? p.taskId : '<unknown>'
|
|
770
|
+
const ok = p.ok === true
|
|
771
|
+
const durationMs = typeof p.durationMs === 'number' ? p.durationMs : 0
|
|
772
|
+
const error = typeof p.error === 'string' ? p.error : undefined
|
|
773
|
+
|
|
774
|
+
const idle = state.drainQueue.length === 0 && !state.draining
|
|
775
|
+
const delivery = idle ? 'interrupt' : 'queue'
|
|
776
|
+
const text = renderCompletionReminder({ subagent, taskId, ok, durationMs, error })
|
|
777
|
+
stream.publish({
|
|
778
|
+
target: { kind: 'session', sessionId: state.sessionFileId },
|
|
779
|
+
payload: { kind: 'prompt', text, delivery },
|
|
780
|
+
meta: { source: 'subagent-completion' },
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function renderCompletionReminder(args: {
|
|
785
|
+
subagent: string
|
|
786
|
+
taskId: string
|
|
787
|
+
ok: boolean
|
|
788
|
+
durationMs: number
|
|
789
|
+
error?: string
|
|
790
|
+
}): string {
|
|
791
|
+
const durationStr = formatReminderDuration(args.durationMs)
|
|
792
|
+
if (args.ok) {
|
|
793
|
+
return (
|
|
794
|
+
`<system-reminder>\n` +
|
|
795
|
+
`Subagent \`${args.subagent}\` (${args.taskId}) completed in ${durationStr}. ` +
|
|
796
|
+
`Use subagent_output to fetch the result.\n` +
|
|
797
|
+
`</system-reminder>`
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
const err = args.error ?? 'unknown error'
|
|
801
|
+
return (
|
|
802
|
+
`<system-reminder>\n` +
|
|
803
|
+
`Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
|
|
804
|
+
`Use subagent_output to inspect.\n` +
|
|
805
|
+
`</system-reminder>`
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function formatReminderDuration(ms: number): string {
|
|
810
|
+
if (ms < 1000) return `${ms}ms`
|
|
811
|
+
const totalSec = Math.floor(ms / 1000)
|
|
812
|
+
if (totalSec < 60) return `${totalSec}s`
|
|
813
|
+
const min = Math.floor(totalSec / 60)
|
|
814
|
+
const sec = totalSec % 60
|
|
815
|
+
return `${min}m${sec}s`
|
|
816
|
+
}
|
|
817
|
+
|
|
715
818
|
function enqueuePrompt(
|
|
716
819
|
ws: Ws,
|
|
717
820
|
state: SessionState,
|