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
|
@@ -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/cli/init.ts
CHANGED
|
@@ -374,7 +374,14 @@ export const defaultWizardPrompts: WizardPrompts = {
|
|
|
374
374
|
hasExistingChannelSecrets,
|
|
375
375
|
askReuseExistingChannel,
|
|
376
376
|
runChannelFlow,
|
|
377
|
-
runOAuthLogin: (provider, cwd, model) =>
|
|
377
|
+
runOAuthLogin: async (provider, cwd, model) => {
|
|
378
|
+
const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
|
|
379
|
+
try {
|
|
380
|
+
return await makeOAuthLoginRunner(callbacks)({ cwd, model })
|
|
381
|
+
} finally {
|
|
382
|
+
dispose()
|
|
383
|
+
}
|
|
384
|
+
},
|
|
378
385
|
askOAuthFailureRecovery,
|
|
379
386
|
}
|
|
380
387
|
|
|
@@ -9,41 +9,71 @@ import type { OAuthCallbacks } from '@/init/oauth-login'
|
|
|
9
9
|
// concurrent `onManualCodeInput` prompt for users whose browser is on a
|
|
10
10
|
// different host than the CLI. See src/init/oauth-login.ts for the contract
|
|
11
11
|
// on each callback and why onManualCodeInput is required for cross-device.
|
|
12
|
-
|
|
12
|
+
//
|
|
13
|
+
// Returns `{ callbacks, dispose }` rather than bare callbacks because of a
|
|
14
|
+
// pi-ai contract gap: pi-ai races `onManualCodeInput()` against the local
|
|
15
|
+
// callback server (packages/ai/src/utils/oauth/anthropic.ts:210-253). When
|
|
16
|
+
// the browser wins the race, pi-ai sets `result.code` and falls through to
|
|
17
|
+
// token exchange WITHOUT calling `server.cancelWait()` on the manual side —
|
|
18
|
+
// the manual `text()` prompt is left dangling in clack's render pipeline,
|
|
19
|
+
// re-appearing after every subsequent log line. Without the dispose hook,
|
|
20
|
+
// the user sees "Logged in to {Provider}" immediately followed by the stale
|
|
21
|
+
// "paste the redirect URL here" prompt that's now meaningless. Each call
|
|
22
|
+
// site (init/provider) MUST call `dispose()` in a finally after the OAuth
|
|
23
|
+
// runner returns so the orphaned prompt aborts cleanly; clack honors the
|
|
24
|
+
// signal by resolving the prompt with cancel state, the cancel branch
|
|
25
|
+
// throws inside our callback, and pi-ai's outer `.catch()` swallows it
|
|
26
|
+
// (since it stops awaiting the manual promise on the winning-browser path).
|
|
27
|
+
export type OAuthCallbackHandle = {
|
|
28
|
+
callbacks: OAuthCallbacks
|
|
29
|
+
dispose: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildOAuthCallbacks(providerName: string): OAuthCallbackHandle {
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
const { signal } = controller
|
|
13
35
|
return {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
dispose: () => controller.abort(),
|
|
37
|
+
callbacks: {
|
|
38
|
+
onAuth: (url, instructions) => {
|
|
39
|
+
// Don't put the URL inside note(): clack wraps long lines with the box
|
|
40
|
+
// border `│` on each wrapped segment, which corrupts the URL when the
|
|
41
|
+
// user copy-pastes it. Keep instructional text in the box, but print
|
|
42
|
+
// the URL itself as a bare console.log line that any terminal will
|
|
43
|
+
// hyperlink intact.
|
|
44
|
+
const preamble = [
|
|
45
|
+
`Open this URL in your browser to sign in to ${providerName}.`,
|
|
46
|
+
'',
|
|
47
|
+
'If your browser shows "this site can\'t be reached" after you sign in,',
|
|
48
|
+
'copy the full address from the top of the browser and paste it below.',
|
|
49
|
+
]
|
|
50
|
+
if (instructions) preamble.push('', instructions)
|
|
51
|
+
note(preamble.join('\n'), 'Browser login')
|
|
52
|
+
console.log(url)
|
|
53
|
+
console.log('')
|
|
54
|
+
},
|
|
55
|
+
onProgress: (message) => {
|
|
56
|
+
log.info(message)
|
|
57
|
+
},
|
|
58
|
+
onPrompt: async (message, placeholder) => {
|
|
59
|
+
const value = await text({
|
|
60
|
+
message,
|
|
61
|
+
signal,
|
|
62
|
+
...(placeholder !== undefined ? { placeholder } : {}),
|
|
63
|
+
})
|
|
64
|
+
if (isCancel(value)) return null
|
|
65
|
+
return value
|
|
66
|
+
},
|
|
67
|
+
onManualCodeInput: async () => {
|
|
68
|
+
const value = await text({
|
|
69
|
+
message:
|
|
70
|
+
'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
|
|
71
|
+
placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
|
|
72
|
+
signal,
|
|
73
|
+
})
|
|
74
|
+
if (isCancel(value)) throw new Error('Login cancelled by user')
|
|
75
|
+
return value
|
|
76
|
+
},
|
|
47
77
|
},
|
|
48
78
|
}
|
|
49
79
|
}
|
package/src/cli/provider.ts
CHANGED
|
@@ -367,10 +367,15 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
|
|
|
367
367
|
}
|
|
368
368
|
const modelRef = `${providerId}/${ref}` as const
|
|
369
369
|
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
370
|
+
const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
|
|
371
|
+
try {
|
|
372
|
+
const runner = makeOAuthLoginRunner(callbacks)
|
|
373
|
+
const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
|
|
374
|
+
if (!result.ok) return { ok: false, reason: result.reason }
|
|
375
|
+
return { ok: true }
|
|
376
|
+
} finally {
|
|
377
|
+
dispose()
|
|
378
|
+
}
|
|
374
379
|
}
|
|
375
380
|
|
|
376
381
|
function authHint(id: KnownProviderId): string {
|
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
|
|
|
@@ -415,15 +420,39 @@ export function expandMountPath(input: string, cwd: string): string {
|
|
|
415
420
|
|
|
416
421
|
// Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
|
|
417
422
|
// citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
|
|
418
|
-
// hardcoded fallbacks. Missing file → schema defaults; malformed file →
|
|
419
|
-
//
|
|
420
|
-
//
|
|
423
|
+
// hardcoded fallbacks. Missing file → schema defaults; malformed file → ALSO
|
|
424
|
+
// schema defaults plus a stderr warning.
|
|
425
|
+
//
|
|
426
|
+
// Why soft-fail and not throw: every CLI command — including diagnostic ones
|
|
427
|
+
// (`typeclaw status`, `typeclaw doctor`, `typeclaw logs`, `typeclaw stop`,
|
|
428
|
+
// `typeclaw usage`, `typeclaw tui`) — pays this eager-load cost through its
|
|
429
|
+
// import graph, regardless of whether the command actually reads config. A
|
|
430
|
+
// hard throw here turns every read-only diagnostic into a crash exactly when
|
|
431
|
+
// the user needs the diagnostic to figure out what's wrong with their config.
|
|
432
|
+
// `validateConfig` (called by `start`/`restart`/`reload`/host-side mutations)
|
|
433
|
+
// is the strict gate for destructive paths; that's where malformed-config
|
|
434
|
+
// errors should surface, not at module-import time.
|
|
421
435
|
//
|
|
422
436
|
// `config` is a module-import-time snapshot. Container-stage code that must
|
|
423
437
|
// observe `typeclaw run` reloads should call `getConfig()` instead, which
|
|
424
438
|
// returns the current swapped-in value. Host-stage CLI processes are
|
|
425
439
|
// short-lived, so they keep using `config` directly.
|
|
426
|
-
export const config: Config =
|
|
440
|
+
export const config: Config = loadConfigSyncOrDefaults(process.cwd())
|
|
441
|
+
|
|
442
|
+
export function loadConfigSyncOrDefaults(cwd: string, options: { warn?: (message: string) => void } = {}): Config {
|
|
443
|
+
try {
|
|
444
|
+
return loadConfigSync(cwd)
|
|
445
|
+
} catch (error) {
|
|
446
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
447
|
+
const warn = options.warn ?? ((message: string) => process.stderr.write(message))
|
|
448
|
+
warn(
|
|
449
|
+
`warning: ${detail}\n` +
|
|
450
|
+
`warning: continuing with default config so diagnostic commands still work; ` +
|
|
451
|
+
`run \`typeclaw doctor\` or fix ${CONFIG_FILE} before \`typeclaw start\`/\`restart\`/\`reload\`.\n`,
|
|
452
|
+
)
|
|
453
|
+
return configSchema.parse({})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
427
456
|
|
|
428
457
|
let current: Config = config
|
|
429
458
|
|
|
@@ -1008,6 +1037,39 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1008
1037
|
return { ok: true }
|
|
1009
1038
|
}
|
|
1010
1039
|
|
|
1040
|
+
const parsed = parseConfigJson(raw, { migrate: true, persistTarget: cwd })
|
|
1041
|
+
if (!parsed.ok) return parsed
|
|
1042
|
+
|
|
1043
|
+
if (!options.skipMounts) {
|
|
1044
|
+
for (const mount of parsed.config.mounts) {
|
|
1045
|
+
const check = validateMount(mount, cwd)
|
|
1046
|
+
if (!check.ok) return check
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return { ok: true }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export type ParseConfigJsonResult = { ok: true; config: Config } | { ok: false; reason: string }
|
|
1054
|
+
|
|
1055
|
+
export type ParseConfigJsonOptions = {
|
|
1056
|
+
// Run `migrateLegacyConfigShape` before schema validation. Defaults to true
|
|
1057
|
+
// so callers don't reject content the agent could have written through
|
|
1058
|
+
// legacy keys; pass false to validate the exact bytes (used in tests).
|
|
1059
|
+
migrate?: boolean
|
|
1060
|
+
// When set, persist + commit the migrated shape to this agent dir if the
|
|
1061
|
+
// migration ran. Only `validateConfig` uses this; the guard's in-memory
|
|
1062
|
+
// validation never persists (the bytes aren't yet on disk).
|
|
1063
|
+
persistTarget?: string
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Pure validator for an in-memory `typeclaw.json` string. Used by the
|
|
1067
|
+
// managed-config guard to reject `write`/`edit` calls that would land an
|
|
1068
|
+
// invalid file on disk. Does NOT check mount accessibility — that is the
|
|
1069
|
+
// runtime concern handled by `validateConfig` at `typeclaw start` time, and
|
|
1070
|
+
// the file the agent is producing may legitimately reference a mount path
|
|
1071
|
+
// that only exists on the host outside the container.
|
|
1072
|
+
export function parseConfigJson(raw: string, options: ParseConfigJsonOptions = {}): ParseConfigJsonResult {
|
|
1011
1073
|
let json: unknown
|
|
1012
1074
|
try {
|
|
1013
1075
|
json = JSON.parse(raw)
|
|
@@ -1016,24 +1078,19 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
|
|
|
1016
1078
|
return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
|
|
1017
1079
|
}
|
|
1018
1080
|
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1081
|
+
const shouldMigrate = options.migrate ?? true
|
|
1082
|
+
const migrated = shouldMigrate
|
|
1083
|
+
? migrateLegacyConfigShape(json)
|
|
1084
|
+
: { json, changed: false, applied: [] as MigrationStep[] }
|
|
1085
|
+
if (migrated.changed && options.persistTarget !== undefined) {
|
|
1086
|
+
persistMigratedConfig(options.persistTarget, migrated.json, migrated.applied)
|
|
1022
1087
|
}
|
|
1023
1088
|
|
|
1024
1089
|
const result = configSchema.safeParse(migrated.json)
|
|
1025
1090
|
if (!result.success) {
|
|
1026
1091
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
1027
1092
|
}
|
|
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 }
|
|
1093
|
+
return { ok: true, config: result.data }
|
|
1037
1094
|
}
|
|
1038
1095
|
|
|
1039
1096
|
// 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/config/providers.ts
CHANGED
|
@@ -108,6 +108,112 @@ export const KNOWN_PROVIDERS = {
|
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
110
|
},
|
|
111
|
+
// Anthropic Claude — both the Anthropic Console API (ANTHROPIC_API_KEY)
|
|
112
|
+
// and Claude Pro/Max/Team/Enterprise subscriptions (OAuth) reach the same
|
|
113
|
+
// /v1/messages endpoint and share one provider id. Auth path determines
|
|
114
|
+
// which headers pi-ai's `anthropic-messages` transport injects: API key
|
|
115
|
+
// sends a plain `x-api-key`; OAuth sends Bearer + Claude Code identity
|
|
116
|
+
// (anthropic-beta: claude-code-20250219,oauth-2025-04-20 +
|
|
117
|
+
// user-agent: claude-cli/<version>), which is exactly the surface a
|
|
118
|
+
// subscriber's `claude setup-token` credential authorizes. The OAuth dance
|
|
119
|
+
// itself is authorization-code + PKCE against `claude.ai/oauth/authorize`
|
|
120
|
+
// with a localhost callback server (not device-code); the existing
|
|
121
|
+
// `typeclaw-claude-code` skill documents the user-side flow for getting
|
|
122
|
+
// a subscription credential onto the agent when the in-container browser
|
|
123
|
+
// callback can't reach the user's machine.
|
|
124
|
+
//
|
|
125
|
+
// anthropic is the FIRST provider in the registry where both auth modes
|
|
126
|
+
// coexist on one entry. The runtime in src/agent/auth.ts has a load-bearing
|
|
127
|
+
// resolution rule: when secrets.json#providers.anthropic carries an OAuth
|
|
128
|
+
// credential, `ANTHROPIC_API_KEY` in .env is IGNORED (OAuth-on-disk wins
|
|
129
|
+
// because env-wins only applies to api-key-shaped credentials). For
|
|
130
|
+
// api-key-only providers this is invisible; for anthropic it surfaces as
|
|
131
|
+
// "I added the env var but the agent still uses OAuth." The mitigation is
|
|
132
|
+
// to remove the OAuth credential explicitly (`typeclaw provider remove
|
|
133
|
+
// anthropic`) before relying on the env-var path. Same rule applies to any
|
|
134
|
+
// future dual-auth provider — keep the surprise in mind when expanding.
|
|
135
|
+
//
|
|
136
|
+
// Model lineup is the current GA tier as of 2026-04-16: Opus 4.7 (top,
|
|
137
|
+
// released Apr 16 2026), Sonnet 4.6 (mid, Feb 5 2026), Haiku 4.5 (fast,
|
|
138
|
+
// Oct 1 2025). Anthropic's own model overview lists these three as the
|
|
139
|
+
// current recommended set and flags earlier Opus/Sonnet variants with
|
|
140
|
+
// "Consider migrating to current models." Opus 4 / Sonnet 4 are deprecated
|
|
141
|
+
// (retirement: Jun 15 2026); the 4.5/4.6 alternates remain Active but are
|
|
142
|
+
// not the recommended path.
|
|
143
|
+
//
|
|
144
|
+
// ID semantics differ across the lineup and matter for forward-compat:
|
|
145
|
+
// - `claude-haiku-4-5` is a 4.5-generation CONVENIENCE ALIAS that
|
|
146
|
+
// resolves to the latest dated snapshot (currently `-20251001`). Per
|
|
147
|
+
// Anthropic's model-id docs, pre-4.6 dateless ids are evergreen
|
|
148
|
+
// pointers — Anthropic can ship a new dated snapshot under the same
|
|
149
|
+
// alias and we pick it up automatically.
|
|
150
|
+
// - `claude-sonnet-4-6` and `claude-opus-4-7` are 4.6+-generation PINNED
|
|
151
|
+
// SNAPSHOTS, not aliases. Anthropic explicitly says "the dateless ID is
|
|
152
|
+
// the canonical model ID for that release. It maps to a single, fixed
|
|
153
|
+
// model snapshot." A future Sonnet 4.6.1 (if it ever exists) would ship
|
|
154
|
+
// under a new id, NOT silently replace `claude-sonnet-4-6`.
|
|
155
|
+
// Consequence for refresh discipline: bumping Haiku is a no-op (alias
|
|
156
|
+
// catches the latest); bumping Sonnet/Opus to a future 4.7+ family is a
|
|
157
|
+
// real edit here. Don't assume `claude-opus-4-7` will silently advance.
|
|
158
|
+
//
|
|
159
|
+
// Opus 4.7 specifics that affect cost accounting:
|
|
160
|
+
// - New tokenizer: same input maps to 1.0-1.3x more tokens than prior
|
|
161
|
+
// generations depending on content type. Per-token price is unchanged
|
|
162
|
+
// vs Opus 4.6, but total cost on identical workloads can rise meaningfully.
|
|
163
|
+
// - 1M token context window (vs 200k on Haiku) and 128k max output (vs
|
|
164
|
+
// 64k on Sonnet/Haiku). 1M context is at standard pricing — no surcharge.
|
|
165
|
+
// - New `xhigh` effort level between `high` and `max` (pi-ai 0.67.x may
|
|
166
|
+
// not surface this knob yet; check before relying on it).
|
|
167
|
+
//
|
|
168
|
+
// Pricing mirrors Anthropic's official table as of 2026-05; cacheWrite is
|
|
169
|
+
// the 5m-TTL rate (1.25x input). 1h TTL is ~2x input (not modeled here —
|
|
170
|
+
// pi-ai's `cacheWrite` field captures the default 5m rate only).
|
|
171
|
+
anthropic: {
|
|
172
|
+
id: 'anthropic',
|
|
173
|
+
name: 'Anthropic',
|
|
174
|
+
baseUrl: 'https://api.anthropic.com',
|
|
175
|
+
auth: ['api-key', 'oauth'],
|
|
176
|
+
apiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
177
|
+
oauthProviderId: 'anthropic',
|
|
178
|
+
models: {
|
|
179
|
+
'claude-haiku-4-5': {
|
|
180
|
+
id: 'claude-haiku-4-5',
|
|
181
|
+
name: 'Claude Haiku 4.5',
|
|
182
|
+
api: 'anthropic-messages',
|
|
183
|
+
provider: 'anthropic',
|
|
184
|
+
baseUrl: 'https://api.anthropic.com',
|
|
185
|
+
reasoning: true,
|
|
186
|
+
input: ['text', 'image'],
|
|
187
|
+
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
188
|
+
contextWindow: 200000,
|
|
189
|
+
maxTokens: 64000,
|
|
190
|
+
},
|
|
191
|
+
'claude-sonnet-4-6': {
|
|
192
|
+
id: 'claude-sonnet-4-6',
|
|
193
|
+
name: 'Claude Sonnet 4.6',
|
|
194
|
+
api: 'anthropic-messages',
|
|
195
|
+
provider: 'anthropic',
|
|
196
|
+
baseUrl: 'https://api.anthropic.com',
|
|
197
|
+
reasoning: true,
|
|
198
|
+
input: ['text', 'image'],
|
|
199
|
+
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
200
|
+
contextWindow: 1000000,
|
|
201
|
+
maxTokens: 64000,
|
|
202
|
+
},
|
|
203
|
+
'claude-opus-4-7': {
|
|
204
|
+
id: 'claude-opus-4-7',
|
|
205
|
+
name: 'Claude Opus 4.7',
|
|
206
|
+
api: 'anthropic-messages',
|
|
207
|
+
provider: 'anthropic',
|
|
208
|
+
baseUrl: 'https://api.anthropic.com',
|
|
209
|
+
reasoning: true,
|
|
210
|
+
input: ['text', 'image'],
|
|
211
|
+
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
212
|
+
contextWindow: 1000000,
|
|
213
|
+
maxTokens: 128000,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
111
217
|
// ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
|
|
112
218
|
// path here on purpose — the Codex backend is OAuth-only upstream.
|
|
113
219
|
//
|