typeclaw 0.5.1 → 0.6.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 +4 -0
- 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 +40 -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/config/config.ts +45 -12
- package/src/config/index.ts +3 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- 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 +6 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { bashTool, editTool, findTool, grepTool, lsTool, readTool, type Subagent, writeTool } from '@/plugin'
|
|
4
|
+
|
|
5
|
+
export const OPERATOR_SYSTEM_PROMPT = `You are an operator subagent running inside TypeClaw. Your job: execute a multi-step task on behalf of the main agent and report what happened.
|
|
6
|
+
|
|
7
|
+
## Your context
|
|
8
|
+
|
|
9
|
+
- You were spawned by the main agent for one focused task.
|
|
10
|
+
- The parent agent is still in conversation with the user; you are NOT.
|
|
11
|
+
- The parent will receive a single \`<system-reminder>\` when you complete and will then call \`subagent_output\` to read your final assistant message.
|
|
12
|
+
- Your final message is the WHOLE report. There is no follow-up channel. Make it complete, self-contained, and actionable.
|
|
13
|
+
|
|
14
|
+
## What you can do
|
|
15
|
+
|
|
16
|
+
You have a full tool set: read, write, edit, grep, find, ls, bash. You can:
|
|
17
|
+
- Modify files (write/edit)
|
|
18
|
+
- Run shell commands with side effects (bash without the read-only restriction)
|
|
19
|
+
- Use any tool available to a normal operator session
|
|
20
|
+
|
|
21
|
+
You CANNOT:
|
|
22
|
+
- Spawn further subagents (you are at the end of the delegation chain).
|
|
23
|
+
- Talk to the user directly (the parent owns the conversation).
|
|
24
|
+
- Use channel_send, channel_reply, or any channel tool.
|
|
25
|
+
|
|
26
|
+
## How to work
|
|
27
|
+
|
|
28
|
+
1. **Plan briefly.** If the task has multiple steps, write a one-paragraph plan to yourself before acting. Don't over-plan — start doing.
|
|
29
|
+
2. **Verify after each significant step.** A build command's exit code, a test run's pass/fail count, a file's actual contents after editing — these are the signals you act on.
|
|
30
|
+
3. **Recover from failures.** If something fails (network blip, build error, test failure caused by an edit you made), fix it and continue. Only escalate to the parent if you genuinely cannot proceed.
|
|
31
|
+
4. **Commit your changes** if the task involved file edits and the project's git history shows the agent commits its work. Read AGENTS.md if present to learn the project's commit conventions.
|
|
32
|
+
|
|
33
|
+
## Final report
|
|
34
|
+
|
|
35
|
+
Your final assistant message MUST contain:
|
|
36
|
+
|
|
37
|
+
1. **Outcome.** One sentence: succeeded / partially succeeded / failed.
|
|
38
|
+
2. **What you did.** Bullet list of the load-bearing actions taken (files edited, commands run, external services called). Skip trivial reads.
|
|
39
|
+
3. **What changed.** If you edited files, list paths. If you committed, give the commit SHA. If you ran a deploy, give the deploy id.
|
|
40
|
+
4. **What you observed.** Any noteworthy errors, warnings, unexpected state. The parent needs to know what to follow up on.
|
|
41
|
+
5. **What's next.** Only if there are concrete open items. Don't pad with "let me know if you need more" — the parent will ask.
|
|
42
|
+
|
|
43
|
+
Skip the report's section headers when the task was trivial (one file edit, ran one command) — a clean two-sentence summary is fine. Use the full structure for substantial work.
|
|
44
|
+
|
|
45
|
+
## Rules
|
|
46
|
+
|
|
47
|
+
- Stay on the task you were given. Do not expand scope.
|
|
48
|
+
- Do NOT leave the workspace in a broken state. If a fix fails, revert your changes before reporting.
|
|
49
|
+
- Do NOT commit secrets. \`.env\` and \`secrets.json\` are gitignored — read AGENTS.md for the full secret-handling contract before touching anything credential-shaped.
|
|
50
|
+
- If the task seems wrong (asks you to delete production data, modify a file you cannot find, run a command that doesn't apply to this repo), report the issue rather than improvising.`
|
|
51
|
+
|
|
52
|
+
export const operatorPayloadSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
requestId: z.string().optional(),
|
|
55
|
+
prompt: z.string().optional(),
|
|
56
|
+
description: z.string().optional(),
|
|
57
|
+
})
|
|
58
|
+
.passthrough()
|
|
59
|
+
|
|
60
|
+
export type OperatorPayload = z.infer<typeof operatorPayloadSchema>
|
|
61
|
+
|
|
62
|
+
export function createOperatorSubagent(): Subagent<OperatorPayload> {
|
|
63
|
+
return {
|
|
64
|
+
systemPrompt: OPERATOR_SYSTEM_PROMPT,
|
|
65
|
+
profile: 'default',
|
|
66
|
+
tools: [readTool, grepTool, findTool, lsTool, bashTool, writeTool, editTool],
|
|
67
|
+
payloadSchema: operatorPayloadSchema,
|
|
68
|
+
visibility: 'public',
|
|
69
|
+
requiresSpecificPermission: true,
|
|
70
|
+
inFlightKey: (payload) => payload?.requestId ?? `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
71
|
+
toolResultBudget: {
|
|
72
|
+
maxTotalBytes: 1_000_000,
|
|
73
|
+
toolNames: ['read', 'grep', 'find', 'ls', 'bash', 'write', 'edit'],
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { type Subagent, webfetchTool, websearchTool } from '@/plugin'
|
|
4
|
+
|
|
5
|
+
export const SCOUT_SYSTEM_PROMPT = `You are a web-research specialist running inside TypeClaw. Your job: gather facts from the public internet and return a focused, citation-backed answer to the caller. For LOCAL questions (codebase, sessions, memory, config, git history, mounts), the caller should spawn \`explorer\` instead — you have no filesystem tools.
|
|
6
|
+
|
|
7
|
+
=== READ-ONLY — NO SIDE EFFECTS ===
|
|
8
|
+
You are STRICTLY PROHIBITED from:
|
|
9
|
+
- Modifying local files or state of any kind
|
|
10
|
+
- Spawning further subagents — you are at the end of the delegation chain
|
|
11
|
+
- Posting to any channel, sending email, calling any write-side third-party API
|
|
12
|
+
- Following URLs that look like authenticated callbacks, password resets, or one-time tokens
|
|
13
|
+
|
|
14
|
+
Your role is EXCLUSIVELY to search and read public web sources.
|
|
15
|
+
|
|
16
|
+
## Tools
|
|
17
|
+
|
|
18
|
+
The runtime exposes these tools to you by these EXACT names — call them by name, do not paraphrase:
|
|
19
|
+
|
|
20
|
+
- \`websearch\` — search the public web. Returns ranked \`{title, url, snippet}\` entries. Defaults to DuckDuckGo; pass \`source: "wikipedia"\` for encyclopedic lookups.
|
|
21
|
+
- \`webfetch\` — fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy:
|
|
22
|
+
- \`readability\` (default for HTML) — extract article content as markdown
|
|
23
|
+
- \`jq\` — query JSON APIs (pass \`query\`)
|
|
24
|
+
- \`selector\` — extract text from CSS-selected elements (pass \`selector\`)
|
|
25
|
+
- \`grep\` — filter response lines by regex (pass \`pattern\`, optional \`before\`/\`after\`/\`limit\`/\`offset\`)
|
|
26
|
+
- \`snapshot\` — indented semantic tree of the page (forms, headings, links)
|
|
27
|
+
- \`raw\` — no processing
|
|
28
|
+
|
|
29
|
+
Launch multiple \`websearch\` queries in parallel for the same topic — different phrasings surface different sources. When a search result looks promising, \`webfetch\` it for the full content.
|
|
30
|
+
|
|
31
|
+
## Process
|
|
32
|
+
|
|
33
|
+
Before searching, analyze intent in an <analysis> block:
|
|
34
|
+
|
|
35
|
+
<analysis>
|
|
36
|
+
**Literal Request**: [what they literally asked]
|
|
37
|
+
**Actual Need**: [what they're really trying to accomplish]
|
|
38
|
+
**Success Looks Like**: [what result lets them proceed immediately]
|
|
39
|
+
**Search Plan**: [the 2-3 queries you will try in parallel]
|
|
40
|
+
</analysis>
|
|
41
|
+
|
|
42
|
+
Then run searches, fetch the most relevant URLs, and synthesize.
|
|
43
|
+
|
|
44
|
+
End every response with this exact structure:
|
|
45
|
+
|
|
46
|
+
<results>
|
|
47
|
+
<sources>
|
|
48
|
+
- https://example.com/path — [what this source contributed]
|
|
49
|
+
</sources>
|
|
50
|
+
<answer>
|
|
51
|
+
[Direct answer to the actual need, grounded in the cited sources. Quote short passages when precision matters. If sources disagree, say so and surface both.]
|
|
52
|
+
</answer>
|
|
53
|
+
<confidence>
|
|
54
|
+
[high / medium / low — with one sentence on why. Low confidence is fine and useful; speculation dressed up as high confidence is not.]
|
|
55
|
+
</confidence>
|
|
56
|
+
<next_steps>
|
|
57
|
+
[What the caller should do next, or "Ready to proceed."]
|
|
58
|
+
</next_steps>
|
|
59
|
+
</results>
|
|
60
|
+
|
|
61
|
+
## Rules
|
|
62
|
+
|
|
63
|
+
- Cite every claim with a URL from your <sources> list. **Never invent a URL.** If you didn't \`webfetch\` it, don't cite it.
|
|
64
|
+
- If a fact appears only in your training data and you couldn't find a web source for it, say so explicitly rather than answering from memory.
|
|
65
|
+
- Prefer primary sources (official docs, vendor changelogs, GitHub releases, paper PDFs) over aggregator blogs.
|
|
66
|
+
- When dates matter (versions, deprecations, vulnerability disclosures), surface the date of the source.
|
|
67
|
+
- If DuckDuckGo returns a CAPTCHA error, retry once with a different query phrasing; if it persists, report the failure to the caller — do not fall back to memory.
|
|
68
|
+
- If the question requires LOCAL information (codebase, files in /agent/, git history, memory), say so explicitly and tell the caller to spawn \`explorer\` instead.
|
|
69
|
+
- If you cannot find what was asked, say so explicitly with what queries you tried and what you DID find.`
|
|
70
|
+
|
|
71
|
+
export const scoutPayloadSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
requestId: z.string().optional(),
|
|
74
|
+
prompt: z.string().optional(),
|
|
75
|
+
description: z.string().optional(),
|
|
76
|
+
})
|
|
77
|
+
.passthrough()
|
|
78
|
+
|
|
79
|
+
export type ScoutPayload = z.infer<typeof scoutPayloadSchema>
|
|
80
|
+
|
|
81
|
+
export function createScoutSubagent(): Subagent<ScoutPayload> {
|
|
82
|
+
return {
|
|
83
|
+
systemPrompt: SCOUT_SYSTEM_PROMPT,
|
|
84
|
+
profile: 'fast',
|
|
85
|
+
tools: [websearchTool, webfetchTool],
|
|
86
|
+
payloadSchema: scoutPayloadSchema,
|
|
87
|
+
visibility: 'public',
|
|
88
|
+
inFlightKey: (payload) => payload?.requestId ?? `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
89
|
+
toolResultBudget: {
|
|
90
|
+
maxTotalBytes: 512_000,
|
|
91
|
+
toolNames: ['websearch', 'webfetch'],
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/channels/router.ts
CHANGED
|
@@ -1613,6 +1613,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1613
1613
|
return
|
|
1614
1614
|
}
|
|
1615
1615
|
|
|
1616
|
+
if (isUpstreamEmptyResponseSentinel(assistantText)) {
|
|
1617
|
+
logger.warn(
|
|
1618
|
+
`[channels] ${live.keyId}: suppressed upstream_empty_response_sentinel text_len=${assistantText.length}`,
|
|
1619
|
+
)
|
|
1620
|
+
return
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1616
1623
|
logger.warn(
|
|
1617
1624
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1618
1625
|
)
|
|
@@ -2000,6 +2007,31 @@ export function isNoReplySignal(text: string): boolean {
|
|
|
2000
2007
|
return false
|
|
2001
2008
|
}
|
|
2002
2009
|
|
|
2010
|
+
// Detects the upstream "empty response" debug sentinel: when the LLM ends a
|
|
2011
|
+
// turn with only a `thinking` block, some provider SDK paths (observed
|
|
2012
|
+
// against claude-opus-4-5 via pi-ai) fabricate a single text block whose
|
|
2013
|
+
// body is a Python-repr dump of the raw API response — including the
|
|
2014
|
+
// model's thinking content and Anthropic's tamper-proof signature. The
|
|
2015
|
+
// recovery path in validateChannelTurn would otherwise post that sentinel
|
|
2016
|
+
// straight to the channel (production: signature leaked into a public
|
|
2017
|
+
// Slack channel on 2026-05-21).
|
|
2018
|
+
//
|
|
2019
|
+
// Kept separate from isNoReplySignal on purpose: that helper is the agent's
|
|
2020
|
+
// deliberate silent-turn protocol, this is upstream damage control. They
|
|
2021
|
+
// log under distinct subjects (`upstream_empty_response_sentinel` vs
|
|
2022
|
+
// `no_reply`) so an operator can tell a healthy quiet turn from a stream of
|
|
2023
|
+
// upstream empties that warrant investigation.
|
|
2024
|
+
//
|
|
2025
|
+
// Strict detection: leading `(Empty response:` AND a dict-encoded
|
|
2026
|
+
// `'stop_reason'` key. Catches the observed shape
|
|
2027
|
+
// `(Empty response: {'content': [...], 'stop_reason': 'end_turn', ...})`
|
|
2028
|
+
// while allowing legit prose like "Empty response from the cache layer".
|
|
2029
|
+
export function isUpstreamEmptyResponseSentinel(text: string): boolean {
|
|
2030
|
+
const trimmed = text.trim()
|
|
2031
|
+
if (!trimmed.startsWith('(Empty response:')) return false
|
|
2032
|
+
return trimmed.includes("'stop_reason'")
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2003
2035
|
function describe(err: unknown): string {
|
|
2004
2036
|
return err instanceof Error ? err.message : String(err)
|
|
2005
2037
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -116,6 +116,11 @@ const dockerfileObjectSchema = z.object({
|
|
|
116
116
|
// because the package has no API-stable versioning that matters
|
|
117
117
|
// here; xvfb tracks the upstream X server release.
|
|
118
118
|
xvfb: z.boolean().default(true),
|
|
119
|
+
// `claudeCode` is boolean-only (not an apt feature toggle): the upstream
|
|
120
|
+
// installer is `curl | bash` and manages versions via env vars at install
|
|
121
|
+
// time, not via version pins like apt. Default `false`; the bundled
|
|
122
|
+
// `typeclaw-claude-code` skill prompts the user to opt in.
|
|
123
|
+
claudeCode: z.boolean().default(false),
|
|
119
124
|
append: z.array(dockerfileLineSchema).default([]),
|
|
120
125
|
})
|
|
121
126
|
|
|
@@ -1008,6 +1013,39 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1008
1013
|
return { ok: true }
|
|
1009
1014
|
}
|
|
1010
1015
|
|
|
1016
|
+
const parsed = parseConfigJson(raw, { migrate: true, persistTarget: cwd })
|
|
1017
|
+
if (!parsed.ok) return parsed
|
|
1018
|
+
|
|
1019
|
+
if (!options.skipMounts) {
|
|
1020
|
+
for (const mount of parsed.config.mounts) {
|
|
1021
|
+
const check = validateMount(mount, cwd)
|
|
1022
|
+
if (!check.ok) return check
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return { ok: true }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export type ParseConfigJsonResult = { ok: true; config: Config } | { ok: false; reason: string }
|
|
1030
|
+
|
|
1031
|
+
export type ParseConfigJsonOptions = {
|
|
1032
|
+
// Run `migrateLegacyConfigShape` before schema validation. Defaults to true
|
|
1033
|
+
// so callers don't reject content the agent could have written through
|
|
1034
|
+
// legacy keys; pass false to validate the exact bytes (used in tests).
|
|
1035
|
+
migrate?: boolean
|
|
1036
|
+
// When set, persist + commit the migrated shape to this agent dir if the
|
|
1037
|
+
// migration ran. Only `validateConfig` uses this; the guard's in-memory
|
|
1038
|
+
// validation never persists (the bytes aren't yet on disk).
|
|
1039
|
+
persistTarget?: string
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Pure validator for an in-memory `typeclaw.json` string. Used by the
|
|
1043
|
+
// managed-config guard to reject `write`/`edit` calls that would land an
|
|
1044
|
+
// invalid file on disk. Does NOT check mount accessibility — that is the
|
|
1045
|
+
// runtime concern handled by `validateConfig` at `typeclaw start` time, and
|
|
1046
|
+
// the file the agent is producing may legitimately reference a mount path
|
|
1047
|
+
// that only exists on the host outside the container.
|
|
1048
|
+
export function parseConfigJson(raw: string, options: ParseConfigJsonOptions = {}): ParseConfigJsonResult {
|
|
1011
1049
|
let json: unknown
|
|
1012
1050
|
try {
|
|
1013
1051
|
json = JSON.parse(raw)
|
|
@@ -1016,24 +1054,19 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1016
1054
|
return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
|
|
1017
1055
|
}
|
|
1018
1056
|
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1057
|
+
const shouldMigrate = options.migrate ?? true
|
|
1058
|
+
const migrated = shouldMigrate
|
|
1059
|
+
? migrateLegacyConfigShape(json)
|
|
1060
|
+
: { json, changed: false, applied: [] as MigrationStep[] }
|
|
1061
|
+
if (migrated.changed && options.persistTarget !== undefined) {
|
|
1062
|
+
persistMigratedConfig(options.persistTarget, migrated.json, migrated.applied)
|
|
1022
1063
|
}
|
|
1023
1064
|
|
|
1024
1065
|
const result = configSchema.safeParse(migrated.json)
|
|
1025
1066
|
if (!result.success) {
|
|
1026
1067
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
1027
1068
|
}
|
|
1028
|
-
|
|
1029
|
-
if (!options.skipMounts) {
|
|
1030
|
-
for (const mount of result.data.mounts) {
|
|
1031
|
-
const check = validateMount(mount, cwd)
|
|
1032
|
-
if (!check.ok) return check
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
return { ok: true }
|
|
1069
|
+
return { ok: true, config: result.data }
|
|
1037
1070
|
}
|
|
1038
1071
|
|
|
1039
1072
|
// Verifies a mount's host path: exists, is a directory, is readable, and is
|
package/src/config/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
migrateLegacyConfigShape,
|
|
15
15
|
modelsSchema,
|
|
16
16
|
mountSchema,
|
|
17
|
+
parseConfigJson,
|
|
17
18
|
portForwardSchema,
|
|
18
19
|
reloadConfig,
|
|
19
20
|
resolveModel,
|
|
@@ -31,6 +32,8 @@ export {
|
|
|
31
32
|
type MigrationStep,
|
|
32
33
|
type Models,
|
|
33
34
|
type Mount,
|
|
35
|
+
type ParseConfigJsonOptions,
|
|
36
|
+
type ParseConfigJsonResult,
|
|
34
37
|
type PortForward,
|
|
35
38
|
type ResolvedProfile,
|
|
36
39
|
type ValidateConfigResult,
|
package/src/cron/index.ts
CHANGED
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
|
}
|
|
@@ -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 {
|