typeclaw 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- 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 +1 -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/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/router.ts +213 -32
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- 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 +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -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/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
|
}
|
|
@@ -60,6 +60,23 @@ export async function validateApiKey(
|
|
|
60
60
|
return { kind: 'skipped', reason: 'network-error', detail: 'unexpected response shape' }
|
|
61
61
|
}
|
|
62
62
|
if (res.status === 401 || res.status === 403) {
|
|
63
|
+
// Fireworks issues two key classes that probe the same /v1/models
|
|
64
|
+
// endpoint differently:
|
|
65
|
+
// * Standard keys (fw_...) → 200 with the models list
|
|
66
|
+
// * Fire Pass keys (fpk_...) → 403 with {"error":{"code":"FORBIDDEN",
|
|
67
|
+
// "message":"Fire Pass API keys are not authorized for this route."}}
|
|
68
|
+
// The 403 *proves* authentication succeeded — the route is just out of
|
|
69
|
+
// scope for the key. Fire Pass keys do work at chat-completions, which
|
|
70
|
+
// is exactly the surface typeclaw needs (the only Fireworks model wired
|
|
71
|
+
// here is the Fire Pass router `kimi-k2p6-turbo`). Treating that 403
|
|
72
|
+
// as `rejected` is the bug; recognize the marker and accept the key.
|
|
73
|
+
// Genuinely bad keys still come back as 401 UNAUTHORIZED, untouched.
|
|
74
|
+
if (providerId === 'fireworks' && res.status === 403) {
|
|
75
|
+
const body = await readCapped(res, MAX_BODY_BYTES)
|
|
76
|
+
if (body !== null && isFireworksFirePassForbidden(body)) {
|
|
77
|
+
return { kind: 'ok' }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
63
80
|
return { kind: 'rejected', status: res.status }
|
|
64
81
|
}
|
|
65
82
|
return { kind: 'skipped', reason: 'network-error', detail: `HTTP ${res.status}` }
|
|
@@ -74,6 +91,20 @@ export async function validateApiKey(
|
|
|
74
91
|
|
|
75
92
|
const MAX_BODY_BYTES = 4096
|
|
76
93
|
|
|
94
|
+
function isFireworksFirePassForbidden(body: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(body) as { error?: { code?: unknown; message?: unknown } }
|
|
97
|
+
const err = parsed.error
|
|
98
|
+
if (!err || typeof err !== 'object') return false
|
|
99
|
+
if (err.code === 'FORBIDDEN' && typeof err.message === 'string' && err.message.includes('Fire Pass')) {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
async function isModelsListShape(res: Response): Promise<boolean> {
|
|
78
109
|
const text = await readCapped(res, MAX_BODY_BYTES)
|
|
79
110
|
if (text === null) return false
|
package/src/inspect/index.ts
CHANGED
|
@@ -37,7 +37,11 @@ export type RunInspectOptions = {
|
|
|
37
37
|
liveHint?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export type
|
|
40
|
+
export type SelectSessionOptions = {
|
|
41
|
+
initialSessionId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SelectSession = (sessions: SessionSummary[], opts?: SelectSessionOptions) => Promise<SessionSummary | null>
|
|
41
45
|
|
|
42
46
|
export type LiveSourceFactory = (opts: {
|
|
43
47
|
sessionId: string
|
package/src/inspect/loop.ts
CHANGED
|
@@ -6,9 +6,20 @@ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
|
|
|
6
6
|
|
|
7
7
|
export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
|
|
8
8
|
let sessionArg = opts.sessionIdOrPrefix
|
|
9
|
+
// Remember the last session the user picked from the interactive picker so
|
|
10
|
+
// an ESC-back-to-picker re-opens with that row pre-selected. The picker
|
|
11
|
+
// receives this through the `initialSessionId` hint on its second arg.
|
|
12
|
+
let lastPickedId: string | undefined
|
|
13
|
+
const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
|
|
14
|
+
const hint = selectOpts?.initialSessionId ?? lastPickedId
|
|
15
|
+
const picked = await opts.selectSession(sessions, hint !== undefined ? { initialSessionId: hint } : {})
|
|
16
|
+
if (picked !== null) lastPickedId = picked.sessionId
|
|
17
|
+
return picked
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
while (true) {
|
|
10
21
|
const escSignal = opts.newEscSignal()
|
|
11
|
-
const callOpts: RunInspectOptions = { ...opts, escSignal }
|
|
22
|
+
const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
|
|
12
23
|
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
13
24
|
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
14
25
|
|
package/src/inspect/replay.ts
CHANGED
|
@@ -66,7 +66,7 @@ function* eventsFromEntry(
|
|
|
66
66
|
if (!isMessageEntry(entry)) return
|
|
67
67
|
const message = entry.message
|
|
68
68
|
const role = message.role
|
|
69
|
-
const ts =
|
|
69
|
+
const ts = entryTimestampMs(entry, message)
|
|
70
70
|
if (role === 'user') {
|
|
71
71
|
const text = readTextContent(message.content)
|
|
72
72
|
if (text !== null) yield { cat: 'user', ts, text }
|
|
@@ -219,6 +219,20 @@ function readUsage(value: unknown): {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
function entryTimestampMs(
|
|
223
|
+
entry: { type: 'message'; message: { role: string; [k: string]: unknown } },
|
|
224
|
+
message: { role: string; [k: string]: unknown },
|
|
225
|
+
): number {
|
|
226
|
+
return timestampMs(readField(entry, 'timestamp')) ?? timestampMs(readField(message, 'timestamp')) ?? 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function timestampMs(value: unknown): number | null {
|
|
230
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
231
|
+
if (typeof value !== 'string' || value === '') return null
|
|
232
|
+
const parsed = Date.parse(value)
|
|
233
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
function* readThinkingEvents(content: unknown, ts: number): Iterable<InspectEvent> {
|
|
223
237
|
if (!Array.isArray(content)) return
|
|
224
238
|
for (const block of content) {
|