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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +3 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +25 -7
package/src/config/config.ts
CHANGED
|
@@ -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`.
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
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.
|
|
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
|
|
package/src/config/providers.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/init/hatching.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1380
|
-
// The three secrets (PAT/private-key, webhook secret)
|
|
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`
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
1393
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|