typeclaw 0.5.0 → 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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +80 -8
  4. package/src/agent/live-subagents.ts +215 -0
  5. package/src/agent/plugin-tools.ts +60 -20
  6. package/src/agent/session-origin.ts +15 -0
  7. package/src/agent/subagents.ts +140 -3
  8. package/src/agent/system-prompt.ts +40 -0
  9. package/src/agent/tools/channel-reply.ts +24 -1
  10. package/src/agent/tools/channel-send.ts +26 -1
  11. package/src/agent/tools/spawn-subagent.ts +283 -0
  12. package/src/agent/tools/subagent-cancel.ts +96 -0
  13. package/src/agent/tools/subagent-output.ts +192 -0
  14. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
  15. package/src/bundled-plugins/explorer/explorer.ts +103 -0
  16. package/src/bundled-plugins/explorer/index.ts +11 -0
  17. package/src/bundled-plugins/guard/index.ts +12 -1
  18. package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
  19. package/src/bundled-plugins/guard/policy.ts +1 -0
  20. package/src/bundled-plugins/operator/index.ts +11 -0
  21. package/src/bundled-plugins/operator/operator.ts +76 -0
  22. package/src/bundled-plugins/scout/index.ts +11 -0
  23. package/src/bundled-plugins/scout/scout.ts +94 -0
  24. package/src/channels/router.ts +32 -0
  25. package/src/cli/channel.ts +2 -45
  26. package/src/cli/init.ts +2 -45
  27. package/src/cli/model.ts +2 -1
  28. package/src/cli/ui.ts +95 -0
  29. package/src/config/config.ts +45 -12
  30. package/src/config/index.ts +3 -0
  31. package/src/cron/index.ts +3 -0
  32. package/src/cron/schema.ts +20 -0
  33. package/src/init/dockerfile.ts +156 -5
  34. package/src/init/index.ts +33 -0
  35. package/src/permissions/builtins.ts +23 -2
  36. package/src/plugin/define.ts +2 -0
  37. package/src/plugin/index.ts +2 -0
  38. package/src/plugin/types.ts +15 -22
  39. package/src/run/bundled-plugins.ts +6 -0
  40. package/src/run/channel-session-factory.ts +19 -0
  41. package/src/run/index.ts +56 -6
  42. package/src/server/index.ts +103 -0
  43. package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
  44. package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
  45. package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
  46. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
  47. package/src/skills/typeclaw-config/SKILL.md +29 -26
  48. 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,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { createScoutSubagent } from './scout'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ subagents: {
8
+ scout: createScoutSubagent(),
9
+ },
10
+ }),
11
+ })
@@ -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
+ }
@@ -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
  }
@@ -25,7 +25,7 @@ import {
25
25
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
26
26
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
27
27
 
28
- import { c, done, errorLine } from './ui'
28
+ import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
29
29
 
30
30
  const CHANNEL_LABELS: Record<ChannelKind, string> = {
31
31
  'slack-bot': 'Slack',
@@ -834,50 +834,7 @@ async function promptDiscordToken(): Promise<string> {
834
834
  }
835
835
 
836
836
  async function promptSlackTokens(): Promise<{ bot: string; app: string }> {
837
- note(
838
- [
839
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
840
- ' Pick your workspace, then paste this JSON manifest:',
841
- '',
842
- ' {',
843
- ' "display_information": { "name": "TypeClaw" },',
844
- ' "features": {',
845
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
846
- ' },',
847
- ' "oauth_config": {',
848
- ' "scopes": {',
849
- ' "bot": [',
850
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
851
- ' "channels:history", "channels:read",',
852
- ' "groups:history", "groups:read",',
853
- ' "im:history", "im:read",',
854
- ' "mpim:history", "mpim:read"',
855
- ' ]',
856
- ' }',
857
- ' },',
858
- ' "settings": {',
859
- ' "event_subscriptions": {',
860
- ' "bot_events": [',
861
- ' "app_mention",',
862
- ' "message.channels", "message.groups",',
863
- ' "message.im", "message.mpim"',
864
- ' ]',
865
- ' },',
866
- ' "socket_mode_enabled": true',
867
- ' }',
868
- ' }',
869
- '',
870
- '2. Install to Workspace, then OAuth & Permissions →',
871
- ' copy the Bot User OAuth Token (xoxb-...).',
872
- '3. Basic Information → App-Level Tokens → Generate Token and',
873
- ' Scopes, add the connections:write scope, and copy the',
874
- ' token (xapp-...). Socket Mode needs this; the manifest',
875
- ' cannot grant it.',
876
- '4. Invite the bot to any private channel or DM you want it in:',
877
- ' /invite @TypeClaw',
878
- ].join('\n'),
879
- 'Get a Slack bot',
880
- )
837
+ printSlackAppManifestSetup()
881
838
  const bot = await promptSlackBotToken()
882
839
  note(
883
840
  [
package/src/cli/init.ts CHANGED
@@ -32,7 +32,7 @@ import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
32
32
  import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
33
33
 
34
34
  import { buildOAuthCallbacks } from './oauth-callbacks'
35
- import { c, done, errorLine } from './ui'
35
+ import { c, done, errorLine, printSlackAppManifestSetup } from './ui'
36
36
 
37
37
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
38
38
  // aliases both to the same "cancel" action — there's no way to tell them
@@ -1005,50 +1005,7 @@ async function runSlackFlow(): Promise<StepResult<CollectedInputs['channelSecret
1005
1005
  let sub: SubStep = 'bot'
1006
1006
  let botToken: string | undefined
1007
1007
 
1008
- note(
1009
- [
1010
- '1. https://api.slack.com/apps → Create New App → From a manifest.',
1011
- ' Pick your workspace, then paste this JSON manifest:',
1012
- '',
1013
- ' {',
1014
- ' "display_information": { "name": "TypeClaw" },',
1015
- ' "features": {',
1016
- ' "bot_user": { "display_name": "TypeClaw", "always_online": true }',
1017
- ' },',
1018
- ' "oauth_config": {',
1019
- ' "scopes": {',
1020
- ' "bot": [',
1021
- ' "app_mentions:read", "chat:write", "users:read", "files:read",',
1022
- ' "channels:history", "channels:read",',
1023
- ' "groups:history", "groups:read",',
1024
- ' "im:history", "im:read",',
1025
- ' "mpim:history", "mpim:read"',
1026
- ' ]',
1027
- ' }',
1028
- ' },',
1029
- ' "settings": {',
1030
- ' "event_subscriptions": {',
1031
- ' "bot_events": [',
1032
- ' "app_mention",',
1033
- ' "message.channels", "message.groups",',
1034
- ' "message.im", "message.mpim"',
1035
- ' ]',
1036
- ' },',
1037
- ' "socket_mode_enabled": true',
1038
- ' }',
1039
- ' }',
1040
- '',
1041
- '2. Install to Workspace, then OAuth & Permissions →',
1042
- ' copy the Bot User OAuth Token (xoxb-...).',
1043
- '3. Basic Information → App-Level Tokens → Generate Token and',
1044
- ' Scopes, add the connections:write scope, and copy the',
1045
- ' token (xapp-...). Socket Mode needs this; the manifest',
1046
- ' cannot grant it.',
1047
- '4. Invite the bot to any private channel or DM you want it in:',
1048
- ' /invite @TypeClaw',
1049
- ].join('\n'),
1050
- 'Get a Slack bot',
1051
- )
1008
+ printSlackAppManifestSetup()
1052
1009
 
1053
1010
  while (true) {
1054
1011
  if (sub === 'bot') {
package/src/cli/model.ts CHANGED
@@ -25,7 +25,7 @@ const ADD_PROVIDER_SENTINEL = '__add-provider__'
25
25
  const setSub = defineCommand({
26
26
  meta: {
27
27
  name: 'set',
28
- description: 'set or update a model profile (default | fast | vision | <custom>)',
28
+ description: 'set or update a model profile (default | fast | deep | vision | <custom>)',
29
29
  },
30
30
  args: {
31
31
  profile: {
@@ -206,6 +206,7 @@ async function pickProfileName(): Promise<string> {
206
206
  options: [
207
207
  { value: 'default', label: 'default', hint: 'active model for new sessions' },
208
208
  { value: 'fast', label: 'fast', hint: 'optional alias used by some subagents' },
209
+ { value: 'deep', label: 'deep', hint: 'optional alias used by some subagents' },
209
210
  { value: 'vision', label: 'vision', hint: 'optional alias used by some subagents' },
210
211
  ],
211
212
  initialValue: 'default',
package/src/cli/ui.ts CHANGED
@@ -124,3 +124,98 @@ export function errorLine(reason: string): string {
124
124
  export function successLine(message: string): string {
125
125
  return `${c.green('●')} ${message}`
126
126
  }
127
+
128
+ // The exact JSON manifest a user pastes into
129
+ // https://api.slack.com/apps → From a manifest. Kept as a typed object so
130
+ // the file stays a single source of truth and `JSON.stringify` guarantees
131
+ // the rendered text is always valid JSON — no risk of a stray comma or
132
+ // quote slipping in through hand-formatting.
133
+ export const SLACK_APP_MANIFEST = {
134
+ display_information: { name: 'TypeClaw' },
135
+ features: {
136
+ bot_user: { display_name: 'TypeClaw', always_online: true },
137
+ // Enable the Messages tab so users can DM the bot from its app profile,
138
+ // and disable the Home tab — TypeClaw does not publish a custom App Home
139
+ // view, and leaving it enabled would surface an empty default tab.
140
+ app_home: {
141
+ home_tab_enabled: false,
142
+ messages_tab_enabled: true,
143
+ messages_tab_read_only_enabled: false,
144
+ },
145
+ },
146
+ oauth_config: {
147
+ scopes: {
148
+ // Ordered alphabetically so the manifest stays a stable diff target.
149
+ // Read scopes cover every conversation type the agent might observe;
150
+ // write scopes (chat, files, im/mpim/groups, pins, reactions) let the
151
+ // agent post replies, upload attachments, open DMs, pin messages, and
152
+ // react to messages. `channels:join` lets the bot self-join public
153
+ // channels it's invited to discuss in.
154
+ bot: [
155
+ 'app_mentions:read',
156
+ 'channels:history',
157
+ 'channels:join',
158
+ 'channels:read',
159
+ 'chat:write',
160
+ 'emoji:read',
161
+ 'files:read',
162
+ 'files:write',
163
+ 'groups:history',
164
+ 'groups:read',
165
+ 'groups:write',
166
+ 'im:history',
167
+ 'im:read',
168
+ 'im:write',
169
+ 'mpim:history',
170
+ 'mpim:read',
171
+ 'mpim:write',
172
+ 'pins:read',
173
+ 'pins:write',
174
+ 'reactions:read',
175
+ 'reactions:write',
176
+ 'users:read',
177
+ ],
178
+ },
179
+ },
180
+ settings: {
181
+ event_subscriptions: {
182
+ bot_events: ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'],
183
+ },
184
+ socket_mode_enabled: true,
185
+ },
186
+ } as const
187
+
188
+ // Prints the "create a Slack app from a manifest" walkthrough so the JSON
189
+ // payload is **flush-left and copy-pasteable**. Clack's `note()` wraps
190
+ // content inside a box with `│` borders on both sides, and `log.message()`
191
+ // still prefixes every line with a `│ ` guide column — neither survives a
192
+ // click-and-drag copy. This helper splits the walkthrough into three
193
+ // segments: a boxed prose intro, a raw-stdout JSON block, and a boxed
194
+ // follow-up. The JSON block is emitted via `process.stdout.write` so it
195
+ // carries zero terminal decoration.
196
+ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = process.stdout): void {
197
+ note(
198
+ [
199
+ '1. https://api.slack.com/apps → Create New App → From a manifest.',
200
+ ' Pick your workspace, then paste the JSON manifest printed below',
201
+ ` (it is rendered flush-left so you can ${c.bold('click-drag and copy')} cleanly).`,
202
+ ].join('\n'),
203
+ 'Get a Slack bot',
204
+ )
205
+ output.write('\n')
206
+ output.write(`${JSON.stringify(SLACK_APP_MANIFEST, null, 2)}\n`)
207
+ output.write('\n')
208
+ note(
209
+ [
210
+ '2. Install to Workspace, then OAuth & Permissions →',
211
+ ' copy the Bot User OAuth Token (xoxb-...).',
212
+ '3. Basic Information → App-Level Tokens → Generate Token and',
213
+ ' Scopes, add the connections:write scope, and copy the',
214
+ ' token (xapp-...). Socket Mode needs this; the manifest',
215
+ ' cannot grant it.',
216
+ '4. Invite the bot to any private channel or DM you want it in:',
217
+ ' /invite @TypeClaw',
218
+ ].join('\n'),
219
+ 'Finish Slack setup',
220
+ )
221
+ }
@@ -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 migrated = migrateLegacyConfigShape(json)
1020
- if (migrated.changed) {
1021
- persistMigratedConfig(cwd, migrated.json, migrated.applied)
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
@@ -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
@@ -41,6 +41,9 @@ export {
41
41
  type ExecJob,
42
42
  type HandlerJob,
43
43
  migrateLegacyCronShape,
44
+ parseCronJson,
45
+ type ParseCronJsonOptions,
46
+ type ParseCronResult,
44
47
  type ParsedCronJob,
45
48
  type PromptJob,
46
49
  } from './schema'
@@ -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) {