typeclaw 0.9.2 → 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.
Files changed (76) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +46 -11
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/index.ts +19 -17
  14. package/src/bundled-plugins/security/permissions.ts +9 -8
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  16. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  17. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  18. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  19. package/src/channels/adapters/github/auth-app.ts +53 -9
  20. package/src/channels/adapters/github/auth-pat.ts +4 -1
  21. package/src/channels/adapters/github/auth.ts +10 -0
  22. package/src/channels/adapters/github/event-permissions.ts +83 -0
  23. package/src/channels/adapters/github/inbound.ts +126 -1
  24. package/src/channels/adapters/github/index.ts +60 -66
  25. package/src/channels/adapters/github/outbound.ts +65 -17
  26. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  27. package/src/channels/adapters/github/team-membership.ts +56 -0
  28. package/src/channels/router.ts +313 -10
  29. package/src/channels/schema.ts +22 -0
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +135 -38
  32. package/src/cli/cron.ts +1 -1
  33. package/src/cli/init.ts +133 -86
  34. package/src/cli/inspect-controller.ts +66 -0
  35. package/src/cli/inspect.ts +99 -14
  36. package/src/cli/role.ts +2 -2
  37. package/src/cli/run.ts +24 -5
  38. package/src/cli/tui.ts +34 -10
  39. package/src/cli/tunnel.ts +453 -14
  40. package/src/config/config.ts +35 -7
  41. package/src/config/providers.ts +82 -56
  42. package/src/cron/bridge.ts +25 -4
  43. package/src/hostd/daemon.ts +44 -24
  44. package/src/hostd/portbroker-manager.ts +19 -3
  45. package/src/init/dockerfile.ts +52 -0
  46. package/src/init/env-file.ts +66 -0
  47. package/src/init/gitignore.ts +8 -0
  48. package/src/init/hatching.ts +32 -5
  49. package/src/init/index.ts +131 -39
  50. package/src/init/validate-api-key.ts +31 -0
  51. package/src/inspect/index.ts +47 -6
  52. package/src/inspect/loop.ts +31 -0
  53. package/src/inspect/replay.ts +15 -1
  54. package/src/permissions/builtins.ts +29 -21
  55. package/src/permissions/permissions.ts +32 -5
  56. package/src/role-claim/code.ts +9 -9
  57. package/src/role-claim/controller.ts +3 -2
  58. package/src/role-claim/match-rule.ts +14 -19
  59. package/src/role-claim/pending.ts +2 -2
  60. package/src/run/codex-fetch-observer.ts +377 -0
  61. package/src/run/index.ts +12 -2
  62. package/src/server/index.ts +59 -1
  63. package/src/shared/protocol.ts +1 -1
  64. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  65. package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
  66. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
  67. package/src/skills/typeclaw-config/SKILL.md +7 -1
  68. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  69. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  70. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  71. package/src/tui/index.ts +17 -5
  72. package/src/tunnels/index.ts +1 -0
  73. package/src/tunnels/manager.ts +18 -0
  74. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  75. package/src/tunnels/types.ts +17 -1
  76. package/typeclaw.schema.json +120 -7
@@ -30,6 +30,27 @@ Choose Cloudflare Quick when the user wants the easiest path:
30
30
 
31
31
  For GitHub channel setup, `typeclaw channel add github` can write a channel-owned Cloudflare Quick tunnel named `github-webhook` and set `docker.file.cloudflared: true`. The first `typeclaw start` or `restart` after that rebuilds the image with `cloudflared` installed.
32
32
 
33
+ ### Cloudflare Named Tunnel
34
+
35
+ Choose Cloudflare Named when the user has a domain on Cloudflare and wants a stable URL:
36
+
37
+ - Cloudflare account required (free), plus a domain already in their account's `Websites` list.
38
+ - The user creates the tunnel in the Zero Trust dashboard (`Networks → Tunnels → Create`), copies the token, and configures a Public Hostname pointing at `localhost:<port>` (where `<port>` is the in-container upstream).
39
+ - The URL is whatever subdomain on their domain they configured (e.g. `https://agent.example.com`).
40
+ - The URL never rotates. It's bound to the tunnel in the dashboard, not the process.
41
+ - `cloudflared` runs inside the container with `cloudflared tunnel run --token <jwt>`. The token comes from `.env`.
42
+
43
+ Use `provider: "cloudflare-named"` with `hostname: "https://..."` and `tokenEnv: "CLOUDFLARE_TUNNEL_TOKEN"` (or another env var name set in `.env`). The user must:
44
+
45
+ 1. Create the tunnel in the Cloudflare dashboard.
46
+ 2. Add at least one Public Hostname mapping `<sub>.<their-domain>` → `localhost:<port>`. A tunnel without a Public Hostname is a no-op — `cloudflared` registers but has nothing to route.
47
+ 3. Put the dashboard-printed token in `.env` under the env var named in `tokenEnv`.
48
+ 4. `typeclaw restart` to pick up the new tunnel and the cloudflared layer.
49
+
50
+ The `hostname` field in `typeclaw.json` is informational — typeclaw uses it for `tunnel-url-changed` events and CLI display, but `cloudflared` reads the actual hostname→upstream mapping from the dashboard. If the user changes the hostname in the dashboard, they must also update `tunnels[].hostname` in `typeclaw.json` or downstream consumers (GitHub webhook registration) will keep using the stale URL.
51
+
52
+ `upstreamPort` is not used for `cloudflare-named` — the dashboard's Public Hostname mapping captures it. The schema rejects `upstreamPort` on named tunnels to surface drift early.
53
+
33
54
  ### External URL
34
55
 
35
56
  Choose External when the user already has their own reverse proxy or tunnel:
@@ -90,7 +111,18 @@ Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to w
90
111
 
91
112
  ### `cloudflared` is not installed
92
113
 
93
- The Cloudflare Quick provider requires `docker.file.cloudflared: true`. If it is missing, add it to `typeclaw.json` or re-run the GitHub channel setup choosing Cloudflare Quick, then run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
114
+ Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`. If it is missing, `typeclaw tunnel add` writes it automatically; otherwise add it to `typeclaw.json` by hand and run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
115
+
116
+ ### Named tunnel says "permanently-failed" with `tokenEnv` in the detail
117
+
118
+ The env var named in `tunnels[].tokenEnv` is not set or is empty in the agent's `.env`. The provider intentionally does not retry this case — fix `.env`, then `typeclaw restart`. `cloudflared` is never spawned with a missing token.
119
+
120
+ ### Named tunnel is healthy but no traffic flows
121
+
122
+ Two likely causes:
123
+
124
+ 1. The Cloudflare dashboard's Public Hostname tab for this tunnel is empty. A tunnel with no public hostname is a no-op — `cloudflared` registers and waits, but Cloudflare has nothing to route to it. `curl https://<hostname>` returns Cloudflare error 530 or 1033.
125
+ 2. The Public Hostname's upstream `localhost:<port>` does not match the in-container service port. typeclaw cannot detect this drift; the user must align the dashboard and the container.
94
126
 
95
127
  ### Quick tunnel URL changed
96
128
 
package/src/tui/index.ts CHANGED
@@ -44,6 +44,14 @@ export type TuiOptions = {
44
44
  onVersionMismatch?: (info: VersionMismatch) => void
45
45
  }
46
46
 
47
+ // Outcome of a single `run()` cycle. The CLI's reconnect loop reads this to
48
+ // decide whether to spin again or exit. `lostConnection` is true when the
49
+ // WS closed AFTER the connected handshake without a deliberate /quit or
50
+ // Ctrl+C — exactly the case a self-restart produces, and the only one
51
+ // where a fresh connect can recover the session. Quit / Ctrl+C / pre-
52
+ // handshake errors all resolve with `lostConnection: false`.
53
+ export type TuiRunOutcome = { lostConnection: boolean }
54
+
47
55
  export function createTui({
48
56
  url,
49
57
  initialPrompt,
@@ -54,7 +62,7 @@ export function createTui({
54
62
  expectedVersion,
55
63
  onVersionMismatch,
56
64
  }: TuiOptions) {
57
- async function run(): Promise<void> {
65
+ async function run(): Promise<TuiRunOutcome> {
58
66
  const terminal = createTerminal()
59
67
  const tui = new TUI(terminal)
60
68
  const displayUrl = redactUrl(url)
@@ -80,6 +88,8 @@ export function createTui({
80
88
  exit(1)
81
89
  throw err
82
90
  })
91
+
92
+ let userInitiatedShutdown = false
83
93
  const { sessionId, serverVersion } = handshake
84
94
  status.setText(colors.dim(`session: ${sessionId}`))
85
95
  tui.requestRender()
@@ -196,11 +206,11 @@ export function createTui({
196
206
  }
197
207
  })
198
208
 
199
- const closed = new Promise<void>((resolve) => {
209
+ const closed = new Promise<boolean>((resolve) => {
200
210
  client.onClose(() => {
201
211
  appendHistory(new Text(colors.dim('disconnected'), 0, 0))
202
212
  tui.requestRender()
203
- resolve()
213
+ resolve(!userInitiatedShutdown)
204
214
  })
205
215
  })
206
216
 
@@ -223,6 +233,7 @@ export function createTui({
223
233
  })
224
234
 
225
235
  const shutdown = (code: number) => {
236
+ userInitiatedShutdown = true
226
237
  tui.stop()
227
238
  client.close()
228
239
  exit(code)
@@ -268,13 +279,14 @@ export function createTui({
268
279
  // instead of leaking the command into the agent's chat context.
269
280
  if (isQuitCommand(initialPrompt)) {
270
281
  shutdown(0)
271
- return
282
+ return { lostConnection: false }
272
283
  }
273
284
  await send(initialPrompt)
274
285
  }
275
286
 
276
- await closed
287
+ const lostConnection = await closed
277
288
  tui.stop()
289
+ return { lostConnection }
278
290
  }
279
291
 
280
292
  return { run }
@@ -1,4 +1,5 @@
1
1
  export { createTunnelManager, type TunnelManager, type TunnelManagerOptions, type TunnelManagerLogger } from './manager'
2
+ export { createCloudflareNamedProvider, type CloudflareNamedProviderOptions } from './providers/cloudflare-named'
2
3
  export { createCloudflareQuickProvider, type CloudflareQuickProviderOptions } from './providers/cloudflare-quick'
3
4
  export {
4
5
  type TunnelConfig,
@@ -1,5 +1,6 @@
1
1
  import type { Stream } from '@/stream'
2
2
 
3
+ import { createCloudflareNamedProvider } from './providers/cloudflare-named'
3
4
  import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
4
5
  import { createExternalProvider } from './providers/external'
5
6
  import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
@@ -15,6 +16,11 @@ export type TunnelManagerOptions = {
15
16
  stream: Stream
16
17
  resolveChannelUpstreamPort?: (channelName: string) => number | null
17
18
  cloudflareQuickBinary?: string
19
+ cloudflareNamedBinary?: string
20
+ // Reads an env var by name. Defaults to `process.env[name]` in production.
21
+ // Parameterized so tests can drive the named-provider token path without
22
+ // poking global env and so the manager stays a pure function of its inputs.
23
+ resolveEnv?: (name: string) => string | undefined
18
24
  logger?: TunnelManagerLogger
19
25
  }
20
26
 
@@ -36,6 +42,7 @@ const consoleLogger: TunnelManagerLogger = {
36
42
  export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
37
43
  const logger = options.logger ?? consoleLogger
38
44
  const handles = new Map<string, TunnelProviderHandle>()
45
+ const resolveEnv = options.resolveEnv ?? ((name: string) => process.env[name])
39
46
 
40
47
  for (const config of options.tunnels) {
41
48
  const handle = buildProvider(
@@ -43,6 +50,8 @@ export function createTunnelManager(options: TunnelManagerOptions): TunnelManage
43
50
  options.resolveChannelUpstreamPort,
44
51
  (url) => publishUrlChange(options.stream, config, url, logger),
45
52
  options.cloudflareQuickBinary,
53
+ options.cloudflareNamedBinary,
54
+ resolveEnv,
46
55
  )
47
56
  handles.set(config.name, handle)
48
57
  }
@@ -92,6 +101,8 @@ function buildProvider(
92
101
  resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
93
102
  onUrlChange: (url: string) => void,
94
103
  cloudflareQuickBinary: string | undefined,
104
+ cloudflareNamedBinary: string | undefined,
105
+ resolveEnv: (name: string) => string | undefined,
95
106
  ): TunnelProviderHandle {
96
107
  switch (config.provider) {
97
108
  case 'external':
@@ -103,6 +114,13 @@ function buildProvider(
103
114
  onUrlChange,
104
115
  binary: cloudflareQuickBinary,
105
116
  })
117
+ case 'cloudflare-named':
118
+ return createCloudflareNamedProvider({
119
+ config,
120
+ onUrlChange,
121
+ resolveToken: () => (config.tokenEnv !== undefined ? resolveEnv(config.tokenEnv) : undefined),
122
+ binary: cloudflareNamedBinary,
123
+ })
106
124
  }
107
125
  }
108
126
 
@@ -0,0 +1,224 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
4
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
5
+
6
+ const DEFAULT_BINARY = 'cloudflared'
7
+ const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
8
+ const DEFAULT_MAX_CONSECUTIVE_CRASHES = 10
9
+ const DEFAULT_STOP_GRACE_MS = 5_000
10
+
11
+ export type CloudflareNamedProviderOptions = {
12
+ config: TunnelConfig
13
+ onUrlChange: (url: string) => void
14
+ // Token resolver. Production wiring reads `process.env[config.tokenEnv]`;
15
+ // the resolver is parameterized so tests can inject a value without poking
16
+ // global env. Returning `undefined` (or empty string) at any call fails the
17
+ // start with a clear error pointing at the env-var name.
18
+ resolveToken: () => string | undefined
19
+ binary?: string
20
+ restartBackoffMs?: number[]
21
+ maxConsecutiveCrashes?: number
22
+ stopGraceMs?: number
23
+ }
24
+
25
+ export type CloudflareNamedProviderHandle = TunnelProviderHandle & {
26
+ tail: () => string[]
27
+ subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
28
+ }
29
+
30
+ export function createCloudflareNamedProvider(options: CloudflareNamedProviderOptions): CloudflareNamedProviderHandle {
31
+ const { config, onUrlChange, resolveToken } = options
32
+ if (config.provider !== 'cloudflare-named') {
33
+ throw new Error(`createCloudflareNamedProvider: provider must be 'cloudflare-named', got '${config.provider}'`)
34
+ }
35
+ const hostname = config.hostname
36
+ if (hostname === undefined || hostname.trim() === '') {
37
+ throw new Error(`tunnel '${config.name}' (cloudflare-named): hostname is required`)
38
+ }
39
+ const tokenEnv = config.tokenEnv
40
+ if (tokenEnv === undefined || tokenEnv.trim() === '') {
41
+ throw new Error(`tunnel '${config.name}' (cloudflare-named): tokenEnv is required`)
42
+ }
43
+
44
+ const binary = options.binary ?? DEFAULT_BINARY
45
+ const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
46
+ const maxConsecutiveCrashes = options.maxConsecutiveCrashes ?? DEFAULT_MAX_CONSECUTIVE_CRASHES
47
+ const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
48
+ const logs = createLogRing()
49
+ const state: TunnelState = {
50
+ name: config.name,
51
+ provider: 'cloudflare-named',
52
+ for: config.for,
53
+ url: null,
54
+ status: 'stopped',
55
+ lastUrlAt: null,
56
+ detail: '',
57
+ }
58
+
59
+ let started = false
60
+ let stopping = false
61
+ let proc: ReturnType<typeof Bun.spawn> | null = null
62
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
63
+ let consecutiveCrashes = 0
64
+
65
+ async function launch(): Promise<void> {
66
+ if (!started || stopping) return
67
+
68
+ const token = resolveToken()
69
+ if (token === undefined || token.trim() === '') {
70
+ // Bad config rather than a transient process crash: the user-facing fix
71
+ // is editing `.env`, not waiting for backoff. Flip straight to
72
+ // permanently-failed so `tunnel status` makes the cause obvious and we
73
+ // don't waste retries spawning a cloudflared we know will reject the
74
+ // missing token.
75
+ state.status = 'permanently-failed'
76
+ state.detail = `env var ${tokenEnv} is unset or empty; set it in .env and restart`
77
+ return
78
+ }
79
+
80
+ state.status = 'starting'
81
+ state.detail = 'starting cloudflared'
82
+ const spawned = Bun.spawn([binary, 'tunnel', '--no-autoupdate', 'run', '--token', token], {
83
+ stdout: 'ignore',
84
+ stderr: 'pipe',
85
+ })
86
+ proc = spawned
87
+
88
+ // Mark healthy on the FIRST stderr line. cloudflared with a valid token
89
+ // prints registration progress to stderr within ~1s of start; a process
90
+ // that exits before printing anything is almost certainly a token/network
91
+ // failure. Healthy != "traffic flowing" — only Cloudflare's edge knows
92
+ // that — but it's the strongest signal available locally and matches the
93
+ // quick provider's "saw something on stderr" health model.
94
+ //
95
+ // Deliberately does NOT reset `consecutiveCrashes`. A process that prints
96
+ // one line of stderr then crashes is a tight crash loop (bad token,
97
+ // network down, cloudflared bug); the counter must trip the cap. The
98
+ // counter resets on operator action (`stop()` then `start()` again) or
99
+ // on `typeclaw restart`, not on stderr noise.
100
+ let sawFirstLine = false
101
+ void pumpStderr(spawned.stderr, logs, () => {
102
+ if (sawFirstLine) return
103
+ sawFirstLine = true
104
+ state.status = 'healthy'
105
+ state.detail = 'cloudflared started'
106
+ })
107
+
108
+ void spawned.exited.then((code) => {
109
+ if (proc !== spawned) return
110
+ proc = null
111
+ if (!started || stopping) return
112
+ handleExit(code)
113
+ })
114
+ }
115
+
116
+ function handleExit(code: number): void {
117
+ consecutiveCrashes += 1
118
+ if (consecutiveCrashes >= maxConsecutiveCrashes) {
119
+ state.status = 'permanently-failed'
120
+ state.detail = `cloudflared exited ${code}; retry cap reached after ${consecutiveCrashes} consecutive crashes`
121
+ return
122
+ }
123
+
124
+ state.status = 'unhealthy'
125
+ state.detail = `cloudflared exited ${code}; restarting`
126
+ const delay = restartBackoffMs[Math.min(consecutiveCrashes - 1, restartBackoffMs.length - 1)] ?? 30_000
127
+ retryTimer = setTimeout(() => {
128
+ retryTimer = null
129
+ void launch()
130
+ }, delay)
131
+ }
132
+
133
+ return {
134
+ async start(): Promise<void> {
135
+ if (started) return
136
+ started = true
137
+ stopping = false
138
+ consecutiveCrashes = 0
139
+ // The URL is known from config, not from cloudflared. Emit it
140
+ // synchronously so subscribers (channel adapters, tunnel-bridge) wire
141
+ // up immediately, regardless of whether cloudflared comes up healthy.
142
+ // For named tunnels, the URL is bound to the dashboard config — even
143
+ // if the local process is unhealthy, the hostname is the right value
144
+ // to surface in `tunnel-url-changed` events.
145
+ state.url = hostname
146
+ state.lastUrlAt = Date.now()
147
+ onUrlChange(hostname)
148
+ await launch()
149
+ },
150
+ async stop(): Promise<void> {
151
+ if (!started && proc === null) return
152
+ started = false
153
+ stopping = true
154
+ if (retryTimer !== null) {
155
+ clearTimeout(retryTimer)
156
+ retryTimer = null
157
+ }
158
+
159
+ const running = proc
160
+ proc = null
161
+ if (running !== null) {
162
+ running.kill('SIGTERM')
163
+ await Promise.race([
164
+ running.exited,
165
+ sleep(stopGraceMs).then(() => {
166
+ running.kill('SIGKILL')
167
+ return running.exited
168
+ }),
169
+ ])
170
+ }
171
+
172
+ stopping = false
173
+ state.status = 'stopped'
174
+ state.detail = ''
175
+ },
176
+ snapshot(): TunnelState {
177
+ return { ...state }
178
+ },
179
+ tail(): string[] {
180
+ return logs.snapshot()
181
+ },
182
+ subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
183
+ return logs.subscribe(cb)
184
+ },
185
+ }
186
+ }
187
+
188
+ async function pumpStderr(
189
+ stream: ReadableStream<Uint8Array> | null,
190
+ logs: LogRing,
191
+ onLine: (line: string) => void,
192
+ ): Promise<void> {
193
+ if (stream === null) return
194
+ const reader = stream.getReader()
195
+ const decoder = new TextDecoder()
196
+ let buffered = ''
197
+ try {
198
+ while (true) {
199
+ const { done, value } = await reader.read()
200
+ if (done) break
201
+ buffered += decoder.decode(value, { stream: true })
202
+ let newlineIndex = buffered.indexOf('\n')
203
+ while (newlineIndex !== -1) {
204
+ const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
205
+ logs.append(line)
206
+ onLine(line)
207
+ buffered = buffered.slice(newlineIndex + 1)
208
+ newlineIndex = buffered.indexOf('\n')
209
+ }
210
+ }
211
+ buffered += decoder.decode()
212
+ if (buffered !== '') {
213
+ const line = buffered.replace(/\r$/, '')
214
+ logs.append(line)
215
+ onLine(line)
216
+ }
217
+ } finally {
218
+ reader.releaseLock()
219
+ }
220
+ }
221
+
222
+ function sleep(ms: number): Promise<void> {
223
+ return new Promise((resolve) => setTimeout(resolve, ms))
224
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Unsubscribe } from '@/stream'
2
2
 
3
- export type TunnelProvider = 'external' | 'cloudflare-quick'
3
+ export type TunnelProvider = 'external' | 'cloudflare-quick' | 'cloudflare-named'
4
4
 
5
5
  export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
6
6
 
@@ -10,6 +10,22 @@ export type TunnelConfig = {
10
10
  for: TunnelFor
11
11
  externalUrl?: string
12
12
  upstreamPort?: number
13
+ // cloudflare-named only: the public hostname configured in the Cloudflare
14
+ // dashboard (e.g. `https://agent.example.com`). typeclaw uses it verbatim
15
+ // for `tunnel-url-changed` events and CLI display; cloudflared itself
16
+ // learns the hostname → upstream mapping from the dashboard at runtime, so
17
+ // the value here must mirror what the user typed in `Public Hostname`. If
18
+ // the two drift, traffic stops flowing but typeclaw still reports the
19
+ // stale URL — there is no programmatic way to detect this without hitting
20
+ // Cloudflare's API, which we deliberately don't do.
21
+ hostname?: string
22
+ // cloudflare-named only: name of an env var (set in the agent's `.env`)
23
+ // that holds the tunnel token printed by the Cloudflare dashboard when the
24
+ // tunnel was created. The token itself never lives in typeclaw.json — only
25
+ // the env-var name does. The container reads `process.env[tokenEnv]` at
26
+ // tunnel start. Missing/empty values fail the start with a clear message
27
+ // pointing at the env var name.
28
+ tokenEnv?: string
13
29
  }
14
30
 
15
31
  export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
@@ -26,12 +26,12 @@
26
26
  "openai/gpt-5.4-mini",
27
27
  "openai/gpt-5.4",
28
28
  "openai/gpt-5.5",
29
- "anthropic/claude-haiku-4-5",
30
- "anthropic/claude-sonnet-4-6",
31
- "anthropic/claude-opus-4-7",
32
29
  "openai-codex/gpt-5.4-mini",
33
30
  "openai-codex/gpt-5.4",
34
31
  "openai-codex/gpt-5.5",
32
+ "anthropic/claude-haiku-4-5",
33
+ "anthropic/claude-sonnet-4-6",
34
+ "anthropic/claude-opus-4-7",
35
35
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
36
36
  "zai/glm-4.5-air",
37
37
  "zai/glm-4.6",
@@ -53,12 +53,12 @@
53
53
  "openai/gpt-5.4-mini",
54
54
  "openai/gpt-5.4",
55
55
  "openai/gpt-5.5",
56
- "anthropic/claude-haiku-4-5",
57
- "anthropic/claude-sonnet-4-6",
58
- "anthropic/claude-opus-4-7",
59
56
  "openai-codex/gpt-5.4-mini",
60
57
  "openai-codex/gpt-5.4",
61
58
  "openai-codex/gpt-5.5",
59
+ "anthropic/claude-haiku-4-5",
60
+ "anthropic/claude-sonnet-4-6",
61
+ "anthropic/claude-opus-4-7",
62
62
  "fireworks/accounts/fireworks/routers/kimi-k2p6-turbo",
63
63
  "zai/glm-4.5-air",
64
64
  "zai/glm-4.6",
@@ -260,6 +260,25 @@
260
260
  "enabled": {
261
261
  "default": true,
262
262
  "type": "boolean"
263
+ },
264
+ "quotedReply": {
265
+ "default": {
266
+ "enabled": true,
267
+ "queueDelayMs": 10000
268
+ },
269
+ "type": "object",
270
+ "properties": {
271
+ "enabled": {
272
+ "default": true,
273
+ "type": "boolean"
274
+ },
275
+ "queueDelayMs": {
276
+ "default": 10000,
277
+ "type": "integer",
278
+ "minimum": 0,
279
+ "maximum": 9007199254740991
280
+ }
281
+ }
263
282
  }
264
283
  }
265
284
  },
@@ -402,6 +421,25 @@
402
421
  "default": true,
403
422
  "type": "boolean"
404
423
  },
424
+ "quotedReply": {
425
+ "default": {
426
+ "enabled": true,
427
+ "queueDelayMs": 10000
428
+ },
429
+ "type": "object",
430
+ "properties": {
431
+ "enabled": {
432
+ "default": true,
433
+ "type": "boolean"
434
+ },
435
+ "queueDelayMs": {
436
+ "default": 10000,
437
+ "type": "integer",
438
+ "minimum": 0,
439
+ "maximum": 9007199254740991
440
+ }
441
+ }
442
+ },
405
443
  "webhookUrl": {
406
444
  "type": "string",
407
445
  "format": "uri"
@@ -419,6 +457,8 @@
419
457
  "discussion_comment.created",
420
458
  "issues.opened",
421
459
  "pull_request.opened",
460
+ "pull_request.review_requested",
461
+ "pull_request.review_request_removed",
422
462
  "discussion.created",
423
463
  "pull_request_review.submitted"
424
464
  ],
@@ -574,6 +614,25 @@
574
614
  "enabled": {
575
615
  "default": true,
576
616
  "type": "boolean"
617
+ },
618
+ "quotedReply": {
619
+ "default": {
620
+ "enabled": true,
621
+ "queueDelayMs": 10000
622
+ },
623
+ "type": "object",
624
+ "properties": {
625
+ "enabled": {
626
+ "default": true,
627
+ "type": "boolean"
628
+ },
629
+ "queueDelayMs": {
630
+ "default": 10000,
631
+ "type": "integer",
632
+ "minimum": 0,
633
+ "maximum": 9007199254740991
634
+ }
635
+ }
577
636
  }
578
637
  }
579
638
  },
@@ -715,6 +774,25 @@
715
774
  "enabled": {
716
775
  "default": true,
717
776
  "type": "boolean"
777
+ },
778
+ "quotedReply": {
779
+ "default": {
780
+ "enabled": true,
781
+ "queueDelayMs": 10000
782
+ },
783
+ "type": "object",
784
+ "properties": {
785
+ "enabled": {
786
+ "default": true,
787
+ "type": "boolean"
788
+ },
789
+ "queueDelayMs": {
790
+ "default": 10000,
791
+ "type": "integer",
792
+ "minimum": 0,
793
+ "maximum": 9007199254740991
794
+ }
795
+ }
718
796
  }
719
797
  }
720
798
  },
@@ -856,6 +934,25 @@
856
934
  "enabled": {
857
935
  "default": true,
858
936
  "type": "boolean"
937
+ },
938
+ "quotedReply": {
939
+ "default": {
940
+ "enabled": true,
941
+ "queueDelayMs": 10000
942
+ },
943
+ "type": "object",
944
+ "properties": {
945
+ "enabled": {
946
+ "default": true,
947
+ "type": "boolean"
948
+ },
949
+ "queueDelayMs": {
950
+ "default": 10000,
951
+ "type": "integer",
952
+ "minimum": 0,
953
+ "maximum": 9007199254740991
954
+ }
955
+ }
859
956
  }
860
957
  }
861
958
  }
@@ -1091,7 +1188,8 @@
1091
1188
  "type": "string",
1092
1189
  "enum": [
1093
1190
  "external",
1094
- "cloudflare-quick"
1191
+ "cloudflare-quick",
1192
+ "cloudflare-named"
1095
1193
  ]
1096
1194
  },
1097
1195
  "for": {
@@ -1135,6 +1233,14 @@
1135
1233
  "type": "integer",
1136
1234
  "minimum": 1,
1137
1235
  "maximum": 65535
1236
+ },
1237
+ "hostname": {
1238
+ "type": "string",
1239
+ "format": "uri"
1240
+ },
1241
+ "tokenEnv": {
1242
+ "type": "string",
1243
+ "pattern": "^[A-Z_][A-Z0-9_]*$"
1138
1244
  }
1139
1245
  },
1140
1246
  "required": [
@@ -1183,6 +1289,7 @@
1183
1289
  "idleMs": 60000,
1184
1290
  "bufferBytes": 500000,
1185
1291
  "injectionBudgetBytes": 16384,
1292
+ "minIdleDeltaLines": 3,
1186
1293
  "spawnTimeoutMs": 50000,
1187
1294
  "retrievalSpawnTimeoutMs": 30000
1188
1295
  },
@@ -1206,6 +1313,12 @@
1206
1313
  "minimum": 4096,
1207
1314
  "maximum": 9007199254740991
1208
1315
  },
1316
+ "minIdleDeltaLines": {
1317
+ "default": 3,
1318
+ "type": "integer",
1319
+ "minimum": 0,
1320
+ "maximum": 9007199254740991
1321
+ },
1209
1322
  "spawnTimeoutMs": {
1210
1323
  "default": 50000,
1211
1324
  "type": "integer",