typeclaw 0.34.1 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +3 -1
  2. package/src/agent/plugin-tools.ts +71 -13
  3. package/src/agent/provider-error.ts +10 -0
  4. package/src/agent/session-origin.ts +26 -0
  5. package/src/agent/tools/channel-disengage.ts +13 -9
  6. package/src/bundled-plugins/github-cli-auth/gh-command.ts +124 -6
  7. package/src/bundled-plugins/github-cli-auth/git-command.ts +172 -26
  8. package/src/bundled-plugins/github-cli-auth/index.ts +46 -7
  9. package/src/bundled-plugins/github-cli-auth/token-class.ts +13 -0
  10. package/src/bundled-plugins/security/policies/prompt-injection.ts +33 -2
  11. package/src/channels/adapters/github/inbound.ts +41 -3
  12. package/src/channels/adapters/slack-bot.ts +17 -9
  13. package/src/channels/continuation-willingness.ts +331 -0
  14. package/src/channels/github-review-claim.ts +105 -0
  15. package/src/channels/github-token-bridge.ts +7 -0
  16. package/src/channels/router.ts +103 -24
  17. package/src/cli/channel.ts +102 -11
  18. package/src/cli/qr.ts +130 -0
  19. package/src/config/config.ts +98 -2
  20. package/src/container/start.ts +12 -0
  21. package/src/init/dockerfile.ts +64 -0
  22. package/src/init/line-auth.ts +8 -3
  23. package/src/inspect/live.ts +128 -13
  24. package/src/plugin/context.ts +5 -1
  25. package/src/plugin/manager.ts +2 -0
  26. package/src/plugin/types.ts +1 -0
  27. package/src/run/index.ts +1 -0
  28. package/src/sandbox/availability.ts +87 -19
  29. package/src/sandbox/build.ts +27 -0
  30. package/src/sandbox/index.ts +10 -0
  31. package/src/sandbox/package-install.ts +23 -0
  32. package/src/sandbox/policy.ts +31 -0
  33. package/src/sandbox/symlinks.ts +34 -0
  34. package/src/sandbox/writable-zones.ts +164 -4
  35. package/src/server/index.ts +5 -1
  36. package/src/shared/protocol.ts +22 -11
  37. package/src/skills/typeclaw-channel-github/SKILL.md +4 -2
  38. package/src/skills/typeclaw-github-contributing/SKILL.md +124 -0
  39. package/typeclaw.schema.json +32 -1
@@ -1,6 +1,6 @@
1
1
  import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
- import { isAbsolute, join, resolve } from 'node:path'
3
+ import { isAbsolute, join, posix, resolve } from 'node:path'
4
4
 
5
5
  import type { Model } from '@mariozechner/pi-ai'
6
6
  import { z } from 'zod'
@@ -361,11 +361,98 @@ export type NetworkConfig = z.infer<typeof networkSchema>
361
361
  // mount actually works (bare-metal Linux, Docker Desktop — NOT OrbStack, which
362
362
  // rejects the mount even with the cap; there the runtime falls back to
363
363
  // 'proc-bind' regardless). The cost is the CAP_SYS_ADMIN grant on the container.
364
+
365
+ // `sandbox.writablePaths` re-exposes operator-chosen subtrees of the agent
366
+ // folder as WRITABLE inside the per-tool bwrap sandbox, on top of the built-in
367
+ // free-write zones (workspace, public, mounts, .git). It exists for tools that
368
+ // insist on writing a fixed config dir a low-trust role would otherwise hit
369
+ // EROFS on (e.g. a CLI that rewrites `<agentDir>/.foo-cli/config.json`).
370
+ //
371
+ // Each entry is AGENT-ROOT-RELATIVE — it resolves under /agent and may not
372
+ // escape it. Absolute container paths are rejected at parse time: a blanket RW
373
+ // bind outside /agent would punch a hole through the agent trust boundary that
374
+ // the rest of the sandbox model assumes can't happen. `..` segments and
375
+ // null bytes are rejected for the same reason. Targets that don't exist, aren't
376
+ // directories, are symlinks, or land on a security-sensitive path
377
+ // (.git, .env, secrets.json, sessions, memory, .typeclaw, node_modules, the
378
+ // agent root itself) are dropped at resolve time, NOT parse time — existence is
379
+ // a runtime property and the drop keeps a stale config from aborting the
380
+ // sandbox. See resolveWritableZones in src/sandbox/writable-zones.ts.
381
+ export const relativeAgentPathSchema = z
382
+ .string()
383
+ .min(1)
384
+ .refine((value) => !isAbsolute(value), 'must be relative to the agent root, not an absolute path')
385
+ .refine((value) => !value.includes('\0'), 'must not contain a null byte')
386
+ .refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
387
+
388
+ // `sandbox.symlinks` is the one-entry abstraction for the common case of a CLI
389
+ // that reads its config from a fixed path the sandbox can't write: it (1) creates
390
+ // the symlink `from -> /agent/<to>` and (2) makes `<to>` a writable zone (same
391
+ // machinery as `writablePaths` — every `to` is folded into the writable set).
392
+ //
393
+ // `from` is the symlink LOCATION and is fully configurable: an absolute container
394
+ // path (e.g. `/root/.metabase-cli`) or a `~/`-prefixed path expanded against the
395
+ // stage's HOME. Two stages create it: the entrypoint shim creates it at the real
396
+ // container HOME (/root) for trusted/owner roles whose bash runs UNSANDBOXED, and
397
+ // the per-tool bwrap sandbox emits a `--symlink` at the sandbox HOME (/tmp) for
398
+ // low-trust roles — because `$HOME` differs between the two stages, a `~/` from
399
+ // resolves to a different absolute path in each, which is exactly what each
400
+ // consumer needs. The entrypoint refuses to clobber an existing non-symlink.
401
+ //
402
+ // `from` SECURITY: it must not contain a null byte, must not be the root `/`, and
403
+ // must not point INTO /agent (a self-referential loop). Kernel/virtual paths
404
+ // (/proc, /sys, /dev, /run) are rejected — symlinking over them is never a real
405
+ // config need and risks masking the runtime's view of them. `/etc/...` is allowed
406
+ // (a legitimate use case) because the entrypoint's no-clobber guard already stops
407
+ // it from overwriting an existing system file. `to` reuses relativeAgentPathSchema.
408
+ //
409
+ // `..` is rejected OUTRIGHT, before the /agent and kernel-root bans run. Those
410
+ // bans previously inspected the RAW string, which both consumers later normalize
411
+ // against $HOME — so a `~/../agent/workspace/.foo` (→ /agent/...) or
412
+ // `~/../proc/x` (→ /proc/...) slipped past a startsWith('/agent') /
413
+ // startsWith('/proc') check on the un-normalized text. A traversal segment is the
414
+ // ONLY way the post-$HOME effective path can re-enter a banned root, so banning
415
+ // `..` makes the raw-string bans equivalent to checking the effective path —
416
+ // stage-independent, no need to expand $HOME at parse time. The bans then run on
417
+ // the POSIX-normalized form so an absolute `from` like `/var/../proc` is caught
418
+ // even though it has no leading `/proc` literal.
419
+ const FORBIDDEN_SYMLINK_FROM_ROOTS = ['/proc', '/sys', '/dev', '/run'] as const
420
+ function normalizedSymlinkFrom(value: string): string {
421
+ // The `~/` prefix is not a real path component; normalize only the remainder
422
+ // so a `~/a/b` stays `~/a/b` while `/var/../proc` collapses to `/proc`.
423
+ if (value.startsWith('~/')) return `~/${posix.normalize(value.slice(2))}`
424
+ return posix.normalize(value)
425
+ }
426
+ export const symlinkFromSchema = z
427
+ .string()
428
+ .min(1)
429
+ .refine((value) => !value.includes('\0'), 'must not contain a null byte')
430
+ .refine((value) => value.startsWith('~/') || isAbsolute(value), 'must be an absolute path or start with ~/')
431
+ .refine((value) => !value.split(/[/\\]+/).includes('..'), "must not contain a '..' segment")
432
+ .refine((value) => normalizedSymlinkFrom(value) !== '/', 'must not be the filesystem root')
433
+ .refine((value) => {
434
+ const normalized = normalizedSymlinkFrom(value)
435
+ return !normalized.startsWith('/agent/') && normalized !== '/agent'
436
+ }, 'must not point into /agent (the symlink would loop back into the agent folder)')
437
+ .refine((value) => {
438
+ const normalized = normalizedSymlinkFrom(value)
439
+ return !FORBIDDEN_SYMLINK_FROM_ROOTS.some((root) => normalized === root || normalized.startsWith(`${root}/`))
440
+ }, 'must not point at a kernel/virtual path (/proc, /sys, /dev, /run)')
441
+
442
+ export const symlinkSchema = z.object({
443
+ from: symlinkFromSchema,
444
+ to: relativeAgentPathSchema,
445
+ })
446
+
447
+ export type SandboxSymlink = z.infer<typeof symlinkSchema>
448
+
364
449
  export const sandboxSchema = z
365
450
  .object({
366
451
  realProc: z.boolean().default(false),
452
+ writablePaths: z.array(relativeAgentPathSchema).default([]),
453
+ symlinks: z.array(symlinkSchema).default([]),
367
454
  })
368
- .default({ realProc: false })
455
+ .default({ realProc: false, writablePaths: [], symlinks: [] })
369
456
 
370
457
  export type SandboxConfig = z.infer<typeof sandboxSchema>
371
458
 
@@ -592,6 +679,15 @@ export function expandMountPath(input: string, cwd: string): string {
592
679
  return isAbsolute(input) ? input : resolve(cwd, input)
593
680
  }
594
681
 
682
+ // The full set of agent-relative dirs the sandbox should make writable: the
683
+ // explicit `sandbox.writablePaths` plus every `sandbox.symlinks[].to` (so an
684
+ // operator declaring a symlink doesn't also have to list its target). Order is
685
+ // stable (writablePaths first) and duplicates are harmless — resolveWritableZones
686
+ // dedupes after resolving each to an absolute path.
687
+ export function getSandboxWritablePathSpecs(cfg: Pick<Config, 'sandbox'>): string[] {
688
+ return [...cfg.sandbox.writablePaths, ...cfg.sandbox.symlinks.map((link) => link.to)]
689
+ }
690
+
595
691
  // Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
596
692
  // citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
597
693
  // hardcoded fallbacks. Missing file → schema defaults; malformed file → ALSO
@@ -533,6 +533,18 @@ export async function planStart({
533
533
  runArgs.push('--cap-add=SYS_ADMIN')
534
534
  }
535
535
 
536
+ // sandbox.symlinks: the entrypoint shim creates `from -> /agent/<to>` at the
537
+ // real container HOME for the UNSANDBOXED (trusted/owner) bash path. The
538
+ // low-trust path is handled separately by the per-tool bwrap --symlink op
539
+ // (src/sandbox/build.ts). Passed as base64-encoded JSON because `from`/`to`
540
+ // are arbitrary operator strings — base64 sidesteps every shell-metachar and
541
+ // env-quoting hazard; the shim decodes + JSON-parses it with bun. Omitted when
542
+ // empty so the common case adds no env clutter and the shim's loop never runs.
543
+ if (cfg.sandbox.symlinks.length > 0) {
544
+ const encoded = Buffer.from(JSON.stringify(cfg.sandbox.symlinks), 'utf8').toString('base64')
545
+ runArgs.push('-e', `TYPECLAW_SANDBOX_SYMLINKS=${encoded}`)
546
+ }
547
+
536
548
  if (hostdControl) {
537
549
  runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
538
550
  }
@@ -385,6 +385,68 @@ link_persistent_home_files() {
385
385
  ln -sfn "$persist_root/${CLAUDE_CREDENTIALS_RELATIVE_PATH}" "$claude_config_dir/${CLAUDE_CREDENTIALS_FILE_NAME}"
386
386
  }
387
387
 
388
+ # link_configured_symlinks creates the operator's \`sandbox.symlinks\` at the real
389
+ # container $HOME for the UNSANDBOXED (trusted/owner) bash path; low-trust bash
390
+ # gets an equivalent in-jail symlink from the per-tool bwrap builder instead
391
+ # (src/sandbox/build.ts). TYPECLAW_SANDBOX_SYMLINKS is base64-encoded JSON of
392
+ # [{from,to}] (set by start.ts only when non-empty). The whole job is done in
393
+ # \`bun -e\` rather than POSIX shell because \`from\`/\`to\` are arbitrary operator
394
+ # strings: a real JSON parser + Node fs API sidesteps every word-splitting,
395
+ # glob, and metachar hazard that shell string-handling of untrusted paths
396
+ # carries. Contract enforced here (belt to the config-parse validation in
397
+ # config.ts): \`from\` is expanded against $HOME for a leading \`~/\`, \`to\` resolves
398
+ # under /agent and may not escape it, and an existing NON-symlink at \`from\` is
399
+ # refused (never clobbered) — a dangling/symlink \`from\` is replaced idempotently
400
+ # with \`ln -sfn\` semantics (force + no-deref). Failures are logged and skipped
401
+ # per-entry so one bad symlink never blocks container boot.
402
+ #
403
+ # TYPECLAW_AGENT_DIR defaults to /agent (the bind-mount path) and is overridable
404
+ # only by the shim's behavioral tests, which point it at a tmpdir — same escape
405
+ # hatch as TYPECLAW_PERSIST_HOME_ROOT in link_persistent_home_files. Production
406
+ # never sets it.
407
+ link_configured_symlinks() {
408
+ [ -n "\${TYPECLAW_SANDBOX_SYMLINKS:-}" ] || return 0
409
+ TYPECLAW_SANDBOX_SYMLINKS="$TYPECLAW_SANDBOX_SYMLINKS" HOME="$HOME" \\
410
+ TYPECLAW_AGENT_DIR="\${TYPECLAW_AGENT_DIR:-/agent}" bun -e '
411
+ import { lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
412
+ import { dirname, join, normalize, resolve } from "node:path";
413
+ const home = process.env.HOME || "/root";
414
+ const agentDir = process.env.TYPECLAW_AGENT_DIR || "/agent";
415
+ let specs;
416
+ try {
417
+ specs = JSON.parse(Buffer.from(process.env.TYPECLAW_SANDBOX_SYMLINKS, "base64").toString("utf8"));
418
+ } catch (e) {
419
+ console.error("typeclaw-entrypoint: could not parse TYPECLAW_SANDBOX_SYMLINKS:", String(e));
420
+ process.exit(0);
421
+ }
422
+ for (const spec of Array.isArray(specs) ? specs : []) {
423
+ const from = String(spec?.from ?? "");
424
+ const to = String(spec?.to ?? "");
425
+ if (from === "" || to === "") continue;
426
+ const fromAbs = from.startsWith("~/") ? join(home, from.slice(2)) : normalize(from);
427
+ const target = resolve(agentDir, to);
428
+ if (target !== agentDir && !target.startsWith(agentDir + "/")) {
429
+ console.error("typeclaw-entrypoint: skip symlink, target escapes /agent:", to);
430
+ continue;
431
+ }
432
+ try {
433
+ mkdirSync(target, { recursive: true });
434
+ mkdirSync(dirname(fromAbs), { recursive: true });
435
+ let existing;
436
+ try { existing = lstatSync(fromAbs); } catch { existing = undefined; }
437
+ if (existing && !existing.isSymbolicLink()) {
438
+ console.error("typeclaw-entrypoint: refusing to clobber existing non-symlink at", fromAbs);
439
+ continue;
440
+ }
441
+ if (existing) rmSync(fromAbs);
442
+ symlinkSync(target, fromAbs);
443
+ } catch (e) {
444
+ console.error("typeclaw-entrypoint: failed to create symlink", fromAbs, "->", target, ":", String(e));
445
+ }
446
+ }
447
+ ' || true
448
+ }
449
+
388
450
  start_xvfb() {
389
451
  if ! command -v Xvfb >/dev/null 2>&1; then
390
452
  return 0
@@ -419,6 +481,7 @@ start_xvfb() {
419
481
 
420
482
  if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
421
483
  link_persistent_home_files
484
+ link_configured_symlinks
422
485
  start_xvfb
423
486
  exec bun run typeclaw "$@"
424
487
  fi
@@ -465,6 +528,7 @@ ip6tables -A OUTPUT -o lo -j ACCEPT
465
528
  ${ipv6Rules.join('\n')}
466
529
 
467
530
  link_persistent_home_files
531
+ link_configured_symlinks
468
532
  start_xvfb
469
533
  exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
470
534
  `
@@ -7,7 +7,7 @@ import { SecretsLineCredentialStore } from '@/secrets/line-store'
7
7
  export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
8
8
 
9
9
  export type LineLoginCallbacks = {
10
- onQRUrl?: (url: string) => void
10
+ onQRUrl?: (url: string) => void | Promise<void>
11
11
  onPincode: (pin: string) => void
12
12
  }
13
13
 
@@ -33,7 +33,10 @@ export type LineLoginInput =
33
33
  // Structural subset of the upstream LineClient the bootstrap drives. Declared
34
34
  // here so tests can inject a fake without standing up the real LOCO client.
35
35
  export type LineLoginClient = {
36
- loginWithQR(options: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void }): Promise<LineLoginResult>
36
+ loginWithQR(options: {
37
+ onQRUrl: (url: string) => void | Promise<void>
38
+ onPincode: (pin: string) => void
39
+ }): Promise<LineLoginResult>
37
40
  loginWithEmail(options: {
38
41
  email: string
39
42
  password: string
@@ -58,7 +61,9 @@ export async function runLineBootstrap(input: LineLoginInput): Promise<LineBoots
58
61
  const result =
59
62
  input.method === 'qr'
60
63
  ? await client.loginWithQR({
61
- onQRUrl: (url) => input.callbacks.onQRUrl?.(url),
64
+ onQRUrl: async (url) => {
65
+ await input.callbacks.onQRUrl?.(url)
66
+ },
62
67
  onPincode: input.callbacks.onPincode,
63
68
  })
64
69
  : await client.loginWithEmail({
@@ -11,9 +11,15 @@ export type StreamLiveOptions = {
11
11
  onSubscribed?: (live: boolean) => void
12
12
  onError?: (message: string) => void
13
13
  connectTimeoutMs?: number
14
+ heartbeatIntervalMs?: number
15
+ pongTimeoutMs?: number
16
+ bufferedAmountCeiling?: number
14
17
  }
15
18
 
16
19
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
20
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000
21
+ const DEFAULT_PONG_TIMEOUT_MS = 30_000
22
+ const DEFAULT_BUFFERED_AMOUNT_CEILING = 1_048_576
17
23
 
18
24
  export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
19
25
  const WS = opts.WebSocketImpl ?? WebSocket
@@ -26,6 +32,17 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
26
32
  const accumulators = new Map<string, string>()
27
33
  const thinkingAccumulators = new Map<string, string>()
28
34
 
35
+ let heartbeat: ReturnType<typeof setInterval> | null = null
36
+ let awaitingPongSince: number | null = null
37
+ let supportsPing = false
38
+
39
+ const stopHeartbeat = (): void => {
40
+ if (heartbeat !== null) {
41
+ clearInterval(heartbeat)
42
+ heartbeat = null
43
+ }
44
+ }
45
+
29
46
  const wake = (): void => {
30
47
  if (resolveNext !== null) {
31
48
  const fn = resolveNext
@@ -43,13 +60,19 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
43
60
  return
44
61
  }
45
62
  if (msg.type === 'subscribed') {
63
+ supportsPing = msg.supportsPing === true
46
64
  opts.onSubscribed?.(msg.sessionLive)
47
65
  return
48
66
  }
67
+ if (msg.type === 'pong') {
68
+ awaitingPongSince = null
69
+ return
70
+ }
49
71
  if (msg.type === 'error') {
50
72
  opts.onError?.(msg.message)
51
73
  pendingError = msg.message
52
74
  closed = true
75
+ stopHeartbeat()
53
76
  try {
54
77
  ws.close()
55
78
  } catch {
@@ -84,6 +107,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
84
107
  })
85
108
  ws.addEventListener('close', () => {
86
109
  closed = true
110
+ stopHeartbeat()
87
111
  wake()
88
112
  })
89
113
 
@@ -99,6 +123,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
99
123
  'abort',
100
124
  () => {
101
125
  closed = true
126
+ stopHeartbeat()
102
127
  try {
103
128
  ws.close()
104
129
  } catch {
@@ -134,25 +159,115 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
134
159
  }
135
160
  ws.send(JSON.stringify(subscribe))
136
161
 
137
- while (true) {
138
- if (buffer.length > 0) {
139
- const next = buffer.shift()!
140
- yield next
141
- continue
162
+ startHeartbeat({
163
+ ws,
164
+ intervalMs: opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
165
+ pongTimeoutMs: opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS,
166
+ bufferedAmountCeiling: opts.bufferedAmountCeiling ?? DEFAULT_BUFFERED_AMOUNT_CEILING,
167
+ supportsPing: () => supportsPing,
168
+ isAwaitingPongSince: () => awaitingPongSince,
169
+ setAwaitingPongSince: (at) => {
170
+ awaitingPongSince = at
171
+ },
172
+ setTimer: (timer) => {
173
+ heartbeat = timer
174
+ },
175
+ onDead: () => {
176
+ closed = true
177
+ stopHeartbeat()
178
+ try {
179
+ ws.close()
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ wake()
184
+ },
185
+ })
186
+
187
+ try {
188
+ while (true) {
189
+ if (buffer.length > 0) {
190
+ const next = buffer.shift()!
191
+ yield next
192
+ continue
193
+ }
194
+ if (closed) {
195
+ if (pendingError !== null) throw new Error(pendingError)
196
+ return
197
+ }
198
+ const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
199
+ resolveNext = resolve
200
+ })
201
+ if (event !== null) yield event
202
+ if (done) {
203
+ if (pendingError !== null) throw new Error(pendingError)
204
+ return
205
+ }
206
+ }
207
+ } finally {
208
+ // Also fired when the consumer abandons the generator (break from a
209
+ // `for await` calls .return()): close the socket so it can't outlive the
210
+ // viewer, not just the heartbeat timer.
211
+ stopHeartbeat()
212
+ closed = true
213
+ try {
214
+ ws.close()
215
+ } catch {
216
+ /* ignore */
142
217
  }
143
- if (closed) {
144
- if (pendingError !== null) throw new Error(pendingError)
218
+ }
219
+ }
220
+
221
+ type HeartbeatOptions = {
222
+ ws: WebSocket
223
+ intervalMs: number
224
+ pongTimeoutMs: number
225
+ bufferedAmountCeiling: number
226
+ // Read live: the `subscribed` reply that sets it arrives after the timer is
227
+ // armed, so a snapshot taken at startHeartbeat time would always be false.
228
+ supportsPing: () => boolean
229
+ isAwaitingPongSince: () => number | null
230
+ setAwaitingPongSince: (at: number | null) => void
231
+ setTimer: (timer: ReturnType<typeof setInterval>) => void
232
+ onDead: () => void
233
+ }
234
+
235
+ // Steady-state liveness watchdog. The connect gate only bounds the OPENING
236
+ // phase; once subscribed, a wedged socket (send queue not draining, no
237
+ // 'close'/'error') would park the read loop forever. The interval fires on the
238
+ // event-loop timer queue independent of the dead socket, so it always runs.
239
+ // Two death signals, both treated as a clean close (return, never throw) so the
240
+ // viewer recovers to the picker:
241
+ // 1. bufferedAmount past a ceiling — our writes are not draining. Always on:
242
+ // it needs no server cooperation, so it works against any server version.
243
+ // 2. a ping with no pong within the deadline — round-trip liveness lost,
244
+ // which also covers idle tails (a quiet-but-healthy tail still pongs). Only
245
+ // armed when the server advertised supportsPing; a pre-heartbeat server
246
+ // answers an unknown ping with error+close, so probing it would kill the
247
+ // tail. Such a server degrades to bufferedAmount-only detection.
248
+ function startHeartbeat(opts: HeartbeatOptions): void {
249
+ let pingId = 0
250
+ const tick = (): void => {
251
+ if (opts.ws.bufferedAmount >= opts.bufferedAmountCeiling) {
252
+ opts.onDead()
145
253
  return
146
254
  }
147
- const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
148
- resolveNext = resolve
149
- })
150
- if (event !== null) yield event
151
- if (done) {
152
- if (pendingError !== null) throw new Error(pendingError)
255
+ if (!opts.supportsPing()) return
256
+ const awaiting = opts.isAwaitingPongSince()
257
+ if (awaiting !== null) {
258
+ if (Date.now() - awaiting >= opts.pongTimeoutMs) opts.onDead()
153
259
  return
154
260
  }
261
+ pingId += 1
262
+ const ping: InspectClientMessage = { type: 'ping', id: pingId }
263
+ try {
264
+ opts.ws.send(JSON.stringify(ping))
265
+ opts.setAwaitingPongSince(Date.now())
266
+ } catch {
267
+ opts.onDead()
268
+ }
155
269
  }
270
+ opts.setTimer(setInterval(tick, opts.intervalMs))
156
271
  }
157
272
 
158
273
  function frameToEvent(
@@ -13,6 +13,7 @@ export type CreatePluginContextOptions<TConfig> = {
13
13
  logger: PluginLogger
14
14
  permissions: PermissionService
15
15
  resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
16
+ hasGithubAppTokenResolver?: () => boolean
16
17
  spawnSubagent: SpawnSubagentFn
17
18
  isBooted: () => boolean
18
19
  }
@@ -30,7 +31,10 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
30
31
  config: opts.config,
31
32
  logger: opts.logger,
32
33
  permissions: opts.permissions,
33
- github: { resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable },
34
+ github: {
35
+ resolveTokenForRepo: opts.resolveGithubTokenForRepo ?? githubTokenUnavailable,
36
+ hasAppTokenResolver: opts.hasGithubAppTokenResolver ?? (() => false),
37
+ },
34
38
  spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
35
39
  if (!opts.isBooted()) {
36
40
  throw new Error(
@@ -22,6 +22,7 @@ export type LoadPluginsOptions = {
22
22
  loadEntry?: LoadPluginEntryFn
23
23
  roles?: RolesConfig
24
24
  resolveGithubTokenForRepo?: ResolveGithubTokenForRepo
25
+ hasGithubAppTokenResolver?: () => boolean
25
26
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
26
27
  // before user-declared `entries` so a config block named after a bundled
27
28
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -104,6 +105,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
104
105
  logger,
105
106
  permissions,
106
107
  resolveGithubTokenForRepo: opts.resolveGithubTokenForRepo,
108
+ hasGithubAppTokenResolver: opts.hasGithubAppTokenResolver,
107
109
  spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
108
110
  isBooted: () => booted,
109
111
  })
@@ -280,6 +280,7 @@ export type PluginContext<TConfig = never> = {
280
280
 
281
281
  export type PluginGithubServices = {
282
282
  resolveTokenForRepo: ResolveGithubTokenForRepo
283
+ hasAppTokenResolver: () => boolean
283
284
  }
284
285
 
285
286
  export type PluginExports = {
package/src/run/index.ts CHANGED
@@ -167,6 +167,7 @@ export async function startAgent({
167
167
  configsByName: pluginConfigsByName,
168
168
  bundled: BUNDLED_PLUGINS,
169
169
  resolveGithubTokenForRepo: githubTokenBridge.resolveTokenForRepo,
170
+ hasGithubAppTokenResolver: githubTokenBridge.hasAppTokenResolver,
170
171
  ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
171
172
  })
172
173