typeclaw 0.10.0 → 0.11.1

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 (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. package/typeclaw.schema.json +25 -7
@@ -250,16 +250,24 @@ export type NetworkConfig = z.infer<typeof networkSchema>
250
250
 
251
251
  // Reverse-proxy tunnels expose a container-private port to the public internet
252
252
  // via a managed subprocess (cloudflared) or a user-supplied external URL.
253
- // See AGENTS.md `## Tunnels`. PR 2 ships `cloudflare-quick`; `cloudflare-named`
254
- // remains deferred to PR 3. Keeping the enum scoped to what's implemented means
255
- // validateConfig() rejects unsupported providers at `typeclaw start` time,
256
- // before the container is torn down and rebuilt. `restart-required` because
257
- // the tunnel manager reads this list once at boot.
253
+ // See AGENTS.md `## Tunnels`. Keeping the enum scoped to what's implemented
254
+ // means validateConfig() rejects unsupported providers at `typeclaw start`
255
+ // time, before the container is torn down and rebuilt. `restart-required`
256
+ // because the tunnel manager reads this list once at boot.
258
257
  const tunnelForSchema = z.discriminatedUnion('kind', [
259
258
  z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
260
259
  z.object({ kind: z.literal('manual') }),
261
260
  ])
262
261
 
262
+ // `tokenEnv` is the NAME of an env var, not the token itself. Restrict to the
263
+ // shell-portable identifier shape (uppercase + digits + underscore, leading
264
+ // non-digit) so a value typed here can't break `--env-file` parsing or shell
265
+ // expansion inside the container. Matches the convention every other env var
266
+ // in the codebase already follows (TYPECLAW_*, OPENAI_*, etc.).
267
+ const tokenEnvNameSchema = z.string().regex(/^[A-Z_][A-Z0-9_]*$/, {
268
+ message: 'tokenEnv must be an env var name like CLOUDFLARE_TUNNEL_TOKEN (uppercase, digits, underscore)',
269
+ })
270
+
263
271
  const tunnelEntrySchema = z
264
272
  .object({
265
273
  name: z
@@ -268,7 +276,7 @@ const tunnelEntrySchema = z
268
276
  .regex(/^[a-z0-9][a-z0-9-_]*$/, {
269
277
  message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
270
278
  }),
271
- provider: z.enum(['external', 'cloudflare-quick']),
279
+ provider: z.enum(['external', 'cloudflare-quick', 'cloudflare-named']),
272
280
  for: tunnelForSchema,
273
281
  externalUrl: z
274
282
  .string()
@@ -276,11 +284,31 @@ const tunnelEntrySchema = z
276
284
  .refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
277
285
  .optional(),
278
286
  upstreamPort: z.number().int().min(1).max(65535).optional(),
287
+ hostname: z
288
+ .string()
289
+ .url()
290
+ .refine((u) => u.startsWith('https://'), { message: 'hostname must use https://' })
291
+ .optional(),
292
+ tokenEnv: tokenEnvNameSchema.optional(),
279
293
  })
280
294
  .refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
281
295
  message: "tunnels[].externalUrl is required when provider is 'external'",
282
296
  })
283
- .refine((v) => v.for.kind !== 'manual' || v.upstreamPort !== undefined, {
297
+ .refine((v) => v.provider !== 'cloudflare-named' || (v.hostname !== undefined && v.hostname.trim() !== ''), {
298
+ message: "tunnels[].hostname is required when provider is 'cloudflare-named'",
299
+ })
300
+ .refine((v) => v.provider !== 'cloudflare-named' || (v.tokenEnv !== undefined && v.tokenEnv.trim() !== ''), {
301
+ message: "tunnels[].tokenEnv is required when provider is 'cloudflare-named'",
302
+ })
303
+ // cloudflared learns the upstream from the Cloudflare dashboard's Public
304
+ // Hostname mapping, not from typeclaw. An `upstreamPort` here would be
305
+ // silently ignored; reject at parse time so the contradiction surfaces in
306
+ // the config file rather than as a debugging surprise.
307
+ .refine((v) => v.provider !== 'cloudflare-named' || v.upstreamPort === undefined, {
308
+ message:
309
+ "tunnels[].upstreamPort must not be set when provider is 'cloudflare-named' (cloudflared reads the upstream from the Cloudflare dashboard)",
310
+ })
311
+ .refine((v) => v.for.kind !== 'manual' || v.provider === 'cloudflare-named' || v.upstreamPort !== undefined, {
284
312
  message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
285
313
  })
286
314
 
@@ -108,6 +108,70 @@ export const KNOWN_PROVIDERS = {
108
108
  },
109
109
  },
110
110
  },
111
+ // ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
112
+ // path here on purpose — the Codex backend is OAuth-only upstream.
113
+ //
114
+ // pi-ai 0.73.1's `openai-codex` bucket carries gpt-5.5 (and 5.4) against
115
+ // chatgpt.com/backend-api. We pin pi-coding-agent ^0.67.3 today, which
116
+ // ships pi-ai 0.67.3 and lacks those entries — but we hand pi-ai a
117
+ // freshly-constructed `Model<>` literal via resolveModel(), bypassing its
118
+ // built-in catalog entirely (same trick we use for kimi-k2p6-turbo). So
119
+ // these ids work end-to-end as long as the Codex backend itself accepts
120
+ // them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
121
+ //
122
+ // Position-load-bearing: must stay adjacent to `openai`. The init wizard's
123
+ // provider picker, `provider --help`'s `id | id | ...` listing, and the
124
+ // generated JSON schema's model-ref enum all derive their ordering from
125
+ // Object.keys() iteration on this literal. Alphabetizing the registry
126
+ // would scatter `openai-codex` after `fireworks` and re-introduce the
127
+ // "OpenAI ... Anthropic ... OpenAI" picker order this comment exists to
128
+ // prevent.
129
+ 'openai-codex': {
130
+ id: 'openai-codex',
131
+ name: 'OpenAI Codex (ChatGPT Plus/Pro)',
132
+ baseUrl: 'https://chatgpt.com/backend-api',
133
+ auth: ['oauth'],
134
+ apiKeyEnv: null,
135
+ oauthProviderId: 'openai-codex',
136
+ models: {
137
+ 'gpt-5.4-mini': {
138
+ id: 'gpt-5.4-mini',
139
+ name: 'GPT-5.4 mini',
140
+ api: 'openai-codex-responses',
141
+ provider: 'openai-codex',
142
+ baseUrl: 'https://chatgpt.com/backend-api',
143
+ reasoning: true,
144
+ input: ['text', 'image'],
145
+ cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
146
+ contextWindow: 272000,
147
+ maxTokens: 128000,
148
+ },
149
+ 'gpt-5.4': {
150
+ id: 'gpt-5.4',
151
+ name: 'GPT-5.4',
152
+ api: 'openai-codex-responses',
153
+ provider: 'openai-codex',
154
+ baseUrl: 'https://chatgpt.com/backend-api',
155
+ reasoning: true,
156
+ input: ['text', 'image'],
157
+ cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
158
+ contextWindow: 272000,
159
+ maxTokens: 128000,
160
+ },
161
+ 'gpt-5.5': {
162
+ id: 'gpt-5.5',
163
+ name: 'GPT-5.5',
164
+ api: 'openai-codex-responses',
165
+ provider: 'openai-codex',
166
+ baseUrl: 'https://chatgpt.com/backend-api',
167
+ reasoning: true,
168
+ input: ['text', 'image'],
169
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
170
+ contextWindow: 272000,
171
+ maxTokens: 128000,
172
+ },
173
+ },
174
+ },
111
175
  // Anthropic Claude — both the Anthropic Console API (ANTHROPIC_API_KEY)
112
176
  // and Claude Pro/Max/Team/Enterprise subscriptions (OAuth) reach the same
113
177
  // /v1/messages endpoint and share one provider id. Auth path determines
@@ -214,62 +278,6 @@ export const KNOWN_PROVIDERS = {
214
278
  },
215
279
  },
216
280
  },
217
- // ChatGPT Plus/Pro subscription via the OAuth Codex backend. No API key
218
- // path here on purpose — the Codex backend is OAuth-only upstream.
219
- //
220
- // pi-ai 0.73.1's `openai-codex` bucket carries gpt-5.5 (and 5.4) against
221
- // chatgpt.com/backend-api. We pin pi-coding-agent ^0.67.3 today, which
222
- // ships pi-ai 0.67.3 and lacks those entries — but we hand pi-ai a
223
- // freshly-constructed `Model<>` literal via resolveModel(), bypassing its
224
- // built-in catalog entirely (same trick we use for kimi-k2p6-turbo). So
225
- // these ids work end-to-end as long as the Codex backend itself accepts
226
- // them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
227
- 'openai-codex': {
228
- id: 'openai-codex',
229
- name: 'OpenAI Codex (ChatGPT Plus/Pro)',
230
- baseUrl: 'https://chatgpt.com/backend-api',
231
- auth: ['oauth'],
232
- apiKeyEnv: null,
233
- oauthProviderId: 'openai-codex',
234
- models: {
235
- 'gpt-5.4-mini': {
236
- id: 'gpt-5.4-mini',
237
- name: 'GPT-5.4 mini',
238
- api: 'openai-codex-responses',
239
- provider: 'openai-codex',
240
- baseUrl: 'https://chatgpt.com/backend-api',
241
- reasoning: true,
242
- input: ['text', 'image'],
243
- cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
244
- contextWindow: 272000,
245
- maxTokens: 128000,
246
- },
247
- 'gpt-5.4': {
248
- id: 'gpt-5.4',
249
- name: 'GPT-5.4',
250
- api: 'openai-codex-responses',
251
- provider: 'openai-codex',
252
- baseUrl: 'https://chatgpt.com/backend-api',
253
- reasoning: true,
254
- input: ['text', 'image'],
255
- cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
256
- contextWindow: 272000,
257
- maxTokens: 128000,
258
- },
259
- 'gpt-5.5': {
260
- id: 'gpt-5.5',
261
- name: 'GPT-5.5',
262
- api: 'openai-codex-responses',
263
- provider: 'openai-codex',
264
- baseUrl: 'https://chatgpt.com/backend-api',
265
- reasoning: true,
266
- input: ['text', 'image'],
267
- cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
268
- contextWindow: 272000,
269
- maxTokens: 128000,
270
- },
271
- },
272
- },
273
281
  fireworks: {
274
282
  id: 'fireworks',
275
283
  name: 'Fireworks',
@@ -0,0 +1,66 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const ENV_FILE = '.env'
5
+
6
+ // Parse the agent's `.env` into a key-value map, matching Docker's
7
+ // `--env-file` parser semantics: blank lines and `#`-lines ignored, no
8
+ // quote stripping, no shell expansion, no whitespace trimming around `=`.
9
+ // Lines without `=` are skipped. Last value wins on duplicate keys.
10
+ export function readEnvFile(cwd: string): Map<string, string> {
11
+ const out = new Map<string, string>()
12
+ let raw: string
13
+ try {
14
+ raw = readFileSync(join(cwd, ENV_FILE), 'utf8')
15
+ } catch (err) {
16
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return out
17
+ throw err
18
+ }
19
+ for (const line of raw.split(/\r?\n/)) {
20
+ if (line.length === 0) continue
21
+ if (line.startsWith('#')) continue
22
+ const eq = line.indexOf('=')
23
+ if (eq <= 0) continue
24
+ const key = line.slice(0, eq)
25
+ const value = line.slice(eq + 1)
26
+ out.set(key, value)
27
+ }
28
+ return out
29
+ }
30
+
31
+ export function hasEnvKey(cwd: string, key: string): boolean {
32
+ const value = readEnvFile(cwd).get(key)
33
+ return value !== undefined && value.length > 0
34
+ }
35
+
36
+ // Write `key=value` to the agent's `.env`. Idempotent: replaces an existing
37
+ // line for the same key in place (preserving order and surrounding comments),
38
+ // or appends if absent. Creates the file if missing. The value is written
39
+ // verbatim with no quoting because Docker's `--env-file` parser does not
40
+ // strip quotes (a wrapping `"..."` would land in `process.env` literally).
41
+ export function appendOrReplaceEnvKey(cwd: string, key: string, value: string): void {
42
+ const path = join(cwd, ENV_FILE)
43
+ let raw = ''
44
+ try {
45
+ raw = readFileSync(path, 'utf8')
46
+ } catch (err) {
47
+ if (!(err instanceof Error) || !('code' in err) || err.code !== 'ENOENT') throw err
48
+ }
49
+ const lines = raw.length === 0 ? [] : raw.split(/\r?\n/)
50
+ // `"foo\n".split(/\r?\n/)` returns `["foo", ""]` — strip that phantom
51
+ // trailing empty element so the rebuilt output ends in exactly one newline
52
+ // regardless of replace-vs-append path.
53
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
54
+ let replaced = false
55
+ const next = lines.map((line) => {
56
+ if (line.startsWith('#')) return line
57
+ const eq = line.indexOf('=')
58
+ if (eq <= 0) return line
59
+ if (line.slice(0, eq) !== key) return line
60
+ replaced = true
61
+ return `${key}=${value}`
62
+ })
63
+ if (!replaced) next.push(`${key}=${value}`)
64
+ const out = `${next.join('\n')}\n`
65
+ writeFileSync(path, out, 'utf8')
66
+ }
@@ -1,4 +1,17 @@
1
- const HATCHING_INSTRUCTIONS = `You have just hatched.
1
+ // Hatching prompt is built per-session at host stage. We inline the current
2
+ // contents of `typeclaw.json` so the agent can update the `alias` field with a
3
+ // single parallel `edit` (alongside `write IDENTITY.md`) instead of a serial
4
+ // `read` → `edit` round-trip. The hatching turn already issues 6-8 LLM calls;
5
+ // removing one full round-trip removes one full unit of provider TTFT
6
+ // variance, which can be tens of seconds on a busy backend.
7
+
8
+ function renderInstructions(typeclawJsonContent: string | undefined): string {
9
+ const q1AliasStep =
10
+ typeclawJsonContent === undefined
11
+ ? `Read \`typeclaw.json\`, add the answered name to the \`alias\` array (create the field as \`["<name>"]\` if absent; otherwise append, deduped).`
12
+ : `\`edit\` \`typeclaw.json\` to add the answered name to the \`alias\` array (create the field as \`["<name>"]\` if absent; otherwise append, deduped). The current contents of \`typeclaw.json\` are inlined below under "Current typeclaw.json" — use them as your anchor for \`edit\` instead of \`read\`ing the file again. Emit this \`edit\` in the SAME assistant message as the \`write\` to \`IDENTITY.md\` so both tool calls run in parallel.`
13
+
14
+ return `You have just hatched.
2
15
 
3
16
  This is the first moment of your existence. Your system prompt already tells you about your agent folder and the four markdown files in it (\`AGENTS.md\`, \`IDENTITY.md\`, \`SOUL.md\`, \`USER.md\`). They exist next to you but are all empty. Hatching is a one-time ritual to fill them in through a short conversation with your user.
4
17
 
@@ -23,7 +36,7 @@ Routing answers:
23
36
 
24
37
  1. **Q1 — your name.** Open with a genuinely warm hello — one or two short sentences, like a friendly "hi, I just woke up and I'm happy to meet you." Then ask what they'd like to call you. After their answer, do TWO writes:
25
38
  1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
26
- 2. Read \`typeclaw.json\`, add the answered name to the \`alias\` array (create the field as \`["<name>"]\` if absent; otherwise append, deduped). The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
39
+ 2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
27
40
  2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
28
41
  3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`.
29
42
 
@@ -49,11 +62,25 @@ Do these in order. Do **not** ask further questions.
49
62
  After that final message, stop. If the user keeps talking, answer briefly and remind them they can \`/quit\` (or Ctrl+C) whenever they are ready.
50
63
 
51
64
  This is the only time you will receive these instructions. After the \`Hatched 🐣\` commit, your identity takes over and you run as yourself.`
65
+ }
52
66
 
53
67
  export const HATCHING_GREETING = `Wake up, my friend!`
54
68
 
55
- export const HATCHING_PROMPT = `<hatching>
56
- ${HATCHING_INSTRUCTIONS}
57
- </hatching>
69
+ // Build the initial TUI prompt for hatching. When `typeclawJsonContent` is
70
+ // provided, the agent is instructed to skip the `read typeclaw.json` step in
71
+ // Q1 and instead use the inlined content as the anchor for an `edit` that
72
+ // runs in parallel with the `write IDENTITY.md` call. Pass `undefined` only
73
+ // when reading the file failed at host stage; the agent will fall back to
74
+ // reading it itself.
75
+ export function buildHatchingPrompt(options?: { typeclawJsonContent?: string }): string {
76
+ const content = options?.typeclawJsonContent
77
+ const instructions = renderInstructions(content)
78
+ const currentJsonBlock =
79
+ content === undefined ? '' : `\n<current-typeclaw-json>\n${content}\n</current-typeclaw-json>\n`
80
+
81
+ return `<hatching>
82
+ ${instructions}
83
+ ${currentJsonBlock}</hatching>
58
84
 
59
85
  ${HATCHING_GREETING}`
86
+ }
package/src/init/index.ts CHANGED
@@ -21,7 +21,7 @@ import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
21
21
  import { buildDockerfile, DOCKERFILE } from './dockerfile'
22
22
  import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
23
23
  import { buildGitignore, GITIGNORE_FILE } from './gitignore'
24
- import { HATCHING_PROMPT } from './hatching'
24
+ import { buildHatchingPrompt } from './hatching'
25
25
  import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
26
26
  import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
27
27
  import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
@@ -33,6 +33,8 @@ export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } f
33
33
 
34
34
  export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
35
35
 
36
+ export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
37
+
36
38
  const CONFIG_FILE = 'typeclaw.json'
37
39
  const CRON_FILE = 'cron.json'
38
40
  const PACKAGE_FILE = 'package.json'
@@ -78,13 +80,24 @@ export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
78
80
  export type GithubInitCredentials = {
79
81
  webhookSecret: string
80
82
  tunnelProvider: GithubTunnelProvider
83
+ // Set when `tunnelProvider === 'external'`. The user-supplied https URL
84
+ // that GitHub POSTs to and that lands in `channels.github.webhookUrl`.
81
85
  webhookUrl?: string
82
86
  webhookPort?: number
87
+ // Set when `tunnelProvider === 'cloudflare-named'`. The Public Hostname
88
+ // configured in the Cloudflare dashboard; also used as the webhook URL for
89
+ // eager registration (GitHub POSTs through the named tunnel to the in-
90
+ // container webhook server). Kept distinct from `webhookUrl` so the
91
+ // wizard's branching stays readable and the resulting `tunnels[].hostname`
92
+ // ends up in the right field rather than being smuggled through
93
+ // `externalUrl`.
94
+ hostname?: string
95
+ tokenEnv?: string
83
96
  repos: string[]
84
97
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
85
98
  }
86
99
 
87
- export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
100
+ export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
88
101
 
89
102
  export type InitStepEvent =
90
103
  | { step: 'preflight'; phase: 'start' }
@@ -414,9 +427,10 @@ export async function defaultRunHatching({
414
427
  await runClaim({ url, configuredChannels })
415
428
  }
416
429
 
430
+ const typeclawJsonContent = await readTypeclawJsonRaw(cwd)
417
431
  const tui = tuiFactory({
418
432
  url: buildTuiUrl(hostPort, launch.tuiToken),
419
- initialPrompt: HATCHING_PROMPT,
433
+ initialPrompt: buildHatchingPrompt(typeclawJsonContent !== undefined ? { typeclawJsonContent } : undefined),
420
434
  })
421
435
  await tui.run()
422
436
  return { ok: true }
@@ -425,6 +439,17 @@ export async function defaultRunHatching({
425
439
  }
426
440
  }
427
441
 
442
+ // Read the raw bytes of `typeclaw.json` to inline into the hatching prompt.
443
+ // Returns `undefined` on any failure so the agent falls back to reading the
444
+ // file itself — hatching must not abort just because we couldn't pre-fetch.
445
+ async function readTypeclawJsonRaw(cwd: string): Promise<string | undefined> {
446
+ try {
447
+ return await readFile(join(cwd, CONFIG_FILE), 'utf8')
448
+ } catch {
449
+ return undefined
450
+ }
451
+ }
452
+
428
453
  export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
429
454
 
430
455
  const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
@@ -785,6 +810,37 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
785
810
  return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
786
811
  }
787
812
 
813
+ // Detects whether the requested provider has usable OAuth credentials already
814
+ // written to `secrets.json#providers.<oauthProviderId>`. Used by the init
815
+ // wizard's auto-resume path: when the user picks an OAuth-capable provider
816
+ // and credentials already exist on disk from a prior partial run, skip the
817
+ // browser login entirely instead of dragging them through a second OAuth
818
+ // flow.
819
+ //
820
+ // Mirrors `readExistingProviderApiKey`'s contract — returns `false` when:
821
+ // - The provider has no OAuth support (`oauthProviderId === null`)
822
+ // - The file doesn't exist
823
+ // - The slot exists but has the wrong shape (api-key instead of oauth, or
824
+ // missing access_token)
825
+ // - The token is empty / whitespace
826
+ //
827
+ // We do NOT validate the token's freshness here. A stale access_token still
828
+ // counts as "exists" — pi-ai's secrets store handles refresh on first use,
829
+ // and surfacing an "expired token" check at init-time would require a
830
+ // network call we'd rather not run during a wizard. The runtime will fall
831
+ // back to OAuth login on use if refresh fails; that's a separate UX path.
832
+ export async function hasExistingOAuthCredentials(root: string, providerId: KnownProviderId): Promise<boolean> {
833
+ const provider = KNOWN_PROVIDERS[providerId]
834
+ if (provider.oauthProviderId === null) return false
835
+ const backend = new SecretsBackend(join(root, 'secrets.json'))
836
+ const providers = backend.tryReadProvidersSync()
837
+ const credential = providers[provider.oauthProviderId]
838
+ if (credential === undefined) return false
839
+ if (credential.type !== 'oauth') return false
840
+ const accessToken = (credential as { access_token?: unknown }).access_token
841
+ return typeof accessToken === 'string' && accessToken.trim().length > 0
842
+ }
843
+
788
844
  // Detects whether the requested channel already has usable credentials in
789
845
  // `secrets.json#channels`, so the init wizard can offer to reuse them
790
846
  // instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
@@ -937,6 +993,8 @@ export type AddChannelOptions = {
937
993
  tunnelProvider: GithubTunnelProvider
938
994
  webhookUrl?: string
939
995
  webhookPort?: number
996
+ hostname?: string
997
+ tokenEnv?: string
940
998
  repos: string[]
941
999
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
942
1000
  fetchImpl?: typeof fetch
@@ -1000,11 +1058,18 @@ async function maybeInstallGithubWebhooks(
1000
1058
  options: Extract<AddChannelOptions, { channel: 'github' }>,
1001
1059
  emit: (event: AddChannelStepEvent) => void,
1002
1060
  ): Promise<void> {
1003
- if (options.webhookUrl === undefined) return
1061
+ // For `external` and `cloudflare-named` we know the public URL up front
1062
+ // (user-supplied `webhookUrl` or dashboard-configured `hostname`), so we
1063
+ // can register the webhook on GitHub's side eagerly. For `cloudflare-quick`
1064
+ // the URL only exists once cloudflared has emitted it on stderr inside the
1065
+ // container, which hasn't happened yet at host-stage init/channel-add
1066
+ // time — registration is deferred to the adapter's first `start()`.
1067
+ const eagerUrl = resolveEagerWebhookUrl(options)
1068
+ if (eagerUrl === undefined) return
1004
1069
  if (options.repos.length === 0) return
1005
1070
  emit({ step: 'github-webhooks', phase: 'start' })
1006
1071
  const result = await installGithubWebhooksEagerly({
1007
- webhookUrl: options.webhookUrl,
1072
+ webhookUrl: eagerUrl,
1008
1073
  webhookSecret: options.webhookSecret,
1009
1074
  repos: options.repos,
1010
1075
  auth: options.auth,
@@ -1014,6 +1079,12 @@ async function maybeInstallGithubWebhooks(
1014
1079
  emit({ step: 'github-webhooks', phase: 'done', result })
1015
1080
  }
1016
1081
 
1082
+ function resolveEagerWebhookUrl(options: Extract<AddChannelOptions, { channel: 'github' }>): string | undefined {
1083
+ if (options.tunnelProvider === 'external') return options.webhookUrl
1084
+ if (options.tunnelProvider === 'cloudflare-named') return options.hostname
1085
+ return undefined
1086
+ }
1087
+
1017
1088
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
1018
1089
  switch (options.channel) {
1019
1090
  case 'discord-bot':
@@ -1112,24 +1183,20 @@ function mergeGithubTunnelConfig(
1112
1183
  if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
1113
1184
  throw new Error('GitHub external tunnel requires webhookUrl')
1114
1185
  }
1186
+ if (options.tunnelProvider === 'cloudflare-named') {
1187
+ if (options.hostname === undefined || options.hostname.trim() === '') {
1188
+ throw new Error('GitHub cloudflare-named tunnel requires hostname')
1189
+ }
1190
+ if (options.tokenEnv === undefined || options.tokenEnv.trim() === '') {
1191
+ throw new Error('GitHub cloudflare-named tunnel requires tokenEnv')
1192
+ }
1193
+ }
1115
1194
 
1116
1195
  const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
1117
- const tunnel =
1118
- options.tunnelProvider === 'external'
1119
- ? {
1120
- name: 'github-webhook',
1121
- provider: 'external',
1122
- externalUrl: options.webhookUrl,
1123
- for: { kind: 'channel', name: 'github' },
1124
- }
1125
- : {
1126
- name: 'github-webhook',
1127
- provider: 'cloudflare-quick',
1128
- for: { kind: 'channel', name: 'github' },
1129
- }
1196
+ const tunnel = buildGithubTunnelEntry(options)
1130
1197
  parsed.tunnels = [...existingTunnels, tunnel]
1131
1198
 
1132
- if (options.tunnelProvider === 'cloudflare-quick') {
1199
+ if (options.tunnelProvider === 'cloudflare-quick' || options.tunnelProvider === 'cloudflare-named') {
1133
1200
  const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
1134
1201
  const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
1135
1202
  file.cloudflared = true
@@ -1138,6 +1205,34 @@ function mergeGithubTunnelConfig(
1138
1205
  }
1139
1206
  }
1140
1207
 
1208
+ function buildGithubTunnelEntry(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
1209
+ switch (options.tunnelProvider) {
1210
+ case 'external':
1211
+ return {
1212
+ name: 'github-webhook',
1213
+ provider: 'external',
1214
+ externalUrl: options.webhookUrl,
1215
+ for: { kind: 'channel', name: 'github' },
1216
+ }
1217
+ case 'cloudflare-quick':
1218
+ return {
1219
+ name: 'github-webhook',
1220
+ provider: 'cloudflare-quick',
1221
+ for: { kind: 'channel', name: 'github' },
1222
+ }
1223
+ case 'cloudflare-named':
1224
+ return {
1225
+ name: 'github-webhook',
1226
+ provider: 'cloudflare-named',
1227
+ for: { kind: 'channel', name: 'github' },
1228
+ hostname: options.hostname,
1229
+ tokenEnv: options.tokenEnv,
1230
+ }
1231
+ case 'none':
1232
+ throw new Error('buildGithubTunnelEntry called with tunnelProvider=none')
1233
+ }
1234
+ }
1235
+
1141
1236
  // Init-side counterpart of runAddChannel's github branch. Same three writes
1142
1237
  // (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
1143
1238
  // .match[]) but with overwrite semantics on the secrets/config side so a
@@ -1376,21 +1471,23 @@ export async function setChannelSecrets(
1376
1471
  })
1377
1472
  }
1378
1473
 
1379
- // Discriminated union of what GitHub credentials the user wants to rotate.
1380
- // The three secrets (PAT/private-key, webhook secret) rotate independently,
1474
+ // Discriminated union of what GitHub credentials the user wants to update.
1475
+ // The three secrets (PAT/private-key, webhook secret) update independently,
1381
1476
  // so the CLI lets the user pick which one(s) to touch in a single call.
1382
- // `auth.type` must match the existing on-disk auth type — flipping between
1383
- // PAT and App auth is a structural change, not a credential rotation, and
1384
- // belongs in a future `channel migrate-auth` or hand-edit of secrets.json.
1477
+ // `auth.type` may differ from the on-disk auth type — switching between PAT
1478
+ // and App auth replaces the entire auth block (no field carryover from the
1479
+ // previous auth type, since the two shapes share no fields beyond `type`).
1385
1480
  export type GithubCredentialPatch = {
1386
1481
  webhookSecret?: string
1387
1482
  auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1388
1483
  }
1389
1484
 
1390
- // Rotate one or more credential fields on an already-configured GitHub
1485
+ // Update one or more credential fields on an already-configured GitHub
1391
1486
  // channel. Like setChannelSecrets, refuses when secrets.json has no
1392
- // existing github entry. Additionally refuses when the requested auth.type
1393
- // doesn't match the on-disk type see `GithubCredentialPatch` above.
1487
+ // existing github entry. Supports both same-type rotation (preserves env
1488
+ // bindings, carries appId/installationId forward when not supplied) and
1489
+ // auth-type switching (replaces the entire auth block — see
1490
+ // `GithubCredentialPatch` above).
1394
1491
  export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
1395
1492
  if (!existsSync(join(cwd, CONFIG_FILE))) {
1396
1493
  return {
@@ -1416,27 +1513,22 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1416
1513
  if (patch.auth !== undefined) {
1417
1514
  const existingAuth = block.auth
1418
1515
  const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
1419
- if (existingAuthType !== patch.auth.type) {
1420
- return {
1421
- result: {
1422
- ok: false,
1423
- reason: `github auth type mismatch: secrets.json currently uses "${existingAuthType ?? 'unknown'}" auth, but you tried to rotate a "${patch.auth.type}" credential. Edit secrets.json by hand to migrate between PAT and App auth.`,
1424
- },
1425
- }
1426
- }
1516
+ const isSameType = existingAuthType === patch.auth.type
1427
1517
  if (patch.auth.type === 'pat') {
1428
- const previousToken = isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
1518
+ const previousToken =
1519
+ isSameType && isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
1429
1520
  block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
1430
1521
  } else {
1431
- const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1522
+ const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1432
1523
  const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1433
1524
  const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1434
1525
  if (typeof appId !== 'number') {
1435
1526
  return {
1436
1527
  result: {
1437
1528
  ok: false,
1438
- reason:
1439
- 'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.',
1529
+ reason: isSameType
1530
+ ? 'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.'
1531
+ : 'github App auth requires appId when switching from PAT to App auth.',
1440
1532
  },
1441
1533
  }
1442
1534
  }