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
package/src/init/index.ts CHANGED
@@ -21,7 +21,7 @@ import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
21
21
  import { buildDockerfile, DOCKERFILE } from './dockerfile'
22
22
  import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } from './github-webhook-install'
23
23
  import { buildGitignore, GITIGNORE_FILE } from './gitignore'
24
- import { HATCHING_PROMPT } from './hatching'
24
+ import { buildHatchingPrompt } from './hatching'
25
25
  import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
26
26
  import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
27
27
  import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
@@ -33,6 +33,8 @@ export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } f
33
33
 
34
34
  export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
35
35
 
36
+ export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
37
+
36
38
  const CONFIG_FILE = 'typeclaw.json'
37
39
  const CRON_FILE = 'cron.json'
38
40
  const PACKAGE_FILE = 'package.json'
@@ -78,13 +80,24 @@ export type KakaotalkAuthResult = { ok: true } | { ok: false; reason: string }
78
80
  export type GithubInitCredentials = {
79
81
  webhookSecret: string
80
82
  tunnelProvider: GithubTunnelProvider
83
+ // Set when `tunnelProvider === 'external'`. The user-supplied https URL
84
+ // that GitHub POSTs to and that lands in `channels.github.webhookUrl`.
81
85
  webhookUrl?: string
82
86
  webhookPort?: number
87
+ // Set when `tunnelProvider === 'cloudflare-named'`. The Public Hostname
88
+ // configured in the Cloudflare dashboard; also used as the webhook URL for
89
+ // eager registration (GitHub POSTs through the named tunnel to the in-
90
+ // container webhook server). Kept distinct from `webhookUrl` so the
91
+ // wizard's branching stays readable and the resulting `tunnels[].hostname`
92
+ // ends up in the right field rather than being smuggled through
93
+ // `externalUrl`.
94
+ hostname?: string
95
+ tokenEnv?: string
83
96
  repos: string[]
84
97
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
85
98
  }
86
99
 
87
- export type GithubTunnelProvider = 'cloudflare-quick' | 'external' | 'none'
100
+ export type GithubTunnelProvider = 'cloudflare-quick' | 'cloudflare-named' | 'external' | 'none'
88
101
 
89
102
  export type InitStepEvent =
90
103
  | { step: 'preflight'; phase: 'start' }
@@ -414,9 +427,10 @@ export async function defaultRunHatching({
414
427
  await runClaim({ url, configuredChannels })
415
428
  }
416
429
 
430
+ const typeclawJsonContent = await readTypeclawJsonRaw(cwd)
417
431
  const tui = tuiFactory({
418
432
  url: buildTuiUrl(hostPort, launch.tuiToken),
419
- initialPrompt: HATCHING_PROMPT,
433
+ initialPrompt: buildHatchingPrompt(typeclawJsonContent !== undefined ? { typeclawJsonContent } : undefined),
420
434
  })
421
435
  await tui.run()
422
436
  return { ok: true }
@@ -425,6 +439,17 @@ export async function defaultRunHatching({
425
439
  }
426
440
  }
427
441
 
442
+ // Read the raw bytes of `typeclaw.json` to inline into the hatching prompt.
443
+ // Returns `undefined` on any failure so the agent falls back to reading the
444
+ // file itself — hatching must not abort just because we couldn't pre-fetch.
445
+ async function readTypeclawJsonRaw(cwd: string): Promise<string | undefined> {
446
+ try {
447
+ return await readFile(join(cwd, CONFIG_FILE), 'utf8')
448
+ } catch {
449
+ return undefined
450
+ }
451
+ }
452
+
428
453
  export type ClaimRunner = (options: { url: string; configuredChannels: readonly ChannelKind[] }) => Promise<void>
429
454
 
430
455
  const defaultRunClaim: ClaimRunner = async ({ url, configuredChannels }) => {
@@ -785,6 +810,37 @@ export async function readExistingProviderApiKey(root: string, providerId: Known
785
810
  return new SecretsBackend(join(root, 'secrets.json')).tryReadProviderApiKeySync(providerId)
786
811
  }
787
812
 
813
+ // Detects whether the requested provider has usable OAuth credentials already
814
+ // written to `secrets.json#providers.<oauthProviderId>`. Used by the init
815
+ // wizard's auto-resume path: when the user picks an OAuth-capable provider
816
+ // and credentials already exist on disk from a prior partial run, skip the
817
+ // browser login entirely instead of dragging them through a second OAuth
818
+ // flow.
819
+ //
820
+ // Mirrors `readExistingProviderApiKey`'s contract — returns `false` when:
821
+ // - The provider has no OAuth support (`oauthProviderId === null`)
822
+ // - The file doesn't exist
823
+ // - The slot exists but has the wrong shape (api-key instead of oauth, or
824
+ // missing access_token)
825
+ // - The token is empty / whitespace
826
+ //
827
+ // We do NOT validate the token's freshness here. A stale access_token still
828
+ // counts as "exists" — pi-ai's secrets store handles refresh on first use,
829
+ // and surfacing an "expired token" check at init-time would require a
830
+ // network call we'd rather not run during a wizard. The runtime will fall
831
+ // back to OAuth login on use if refresh fails; that's a separate UX path.
832
+ export async function hasExistingOAuthCredentials(root: string, providerId: KnownProviderId): Promise<boolean> {
833
+ const provider = KNOWN_PROVIDERS[providerId]
834
+ if (provider.oauthProviderId === null) return false
835
+ const backend = new SecretsBackend(join(root, 'secrets.json'))
836
+ const providers = backend.tryReadProvidersSync()
837
+ const credential = providers[provider.oauthProviderId]
838
+ if (credential === undefined) return false
839
+ if (credential.type !== 'oauth') return false
840
+ const accessToken = (credential as { access_token?: unknown }).access_token
841
+ return typeof accessToken === 'string' && accessToken.trim().length > 0
842
+ }
843
+
788
844
  // Detects whether the requested channel already has usable credentials in
789
845
  // `secrets.json#channels`, so the init wizard can offer to reuse them
790
846
  // instead of re-prompting for tokens. Mirrors `readExistingProviderApiKey`:
@@ -937,6 +993,8 @@ export type AddChannelOptions = {
937
993
  tunnelProvider: GithubTunnelProvider
938
994
  webhookUrl?: string
939
995
  webhookPort?: number
996
+ hostname?: string
997
+ tokenEnv?: string
940
998
  repos: string[]
941
999
  auth: { type: 'pat'; pat: string } | { type: 'app'; appId: number; privateKey: string; installationId?: number }
942
1000
  fetchImpl?: typeof fetch
@@ -1000,11 +1058,18 @@ async function maybeInstallGithubWebhooks(
1000
1058
  options: Extract<AddChannelOptions, { channel: 'github' }>,
1001
1059
  emit: (event: AddChannelStepEvent) => void,
1002
1060
  ): Promise<void> {
1003
- if (options.webhookUrl === undefined) return
1061
+ // For `external` and `cloudflare-named` we know the public URL up front
1062
+ // (user-supplied `webhookUrl` or dashboard-configured `hostname`), so we
1063
+ // can register the webhook on GitHub's side eagerly. For `cloudflare-quick`
1064
+ // the URL only exists once cloudflared has emitted it on stderr inside the
1065
+ // container, which hasn't happened yet at host-stage init/channel-add
1066
+ // time — registration is deferred to the adapter's first `start()`.
1067
+ const eagerUrl = resolveEagerWebhookUrl(options)
1068
+ if (eagerUrl === undefined) return
1004
1069
  if (options.repos.length === 0) return
1005
1070
  emit({ step: 'github-webhooks', phase: 'start' })
1006
1071
  const result = await installGithubWebhooksEagerly({
1007
- webhookUrl: options.webhookUrl,
1072
+ webhookUrl: eagerUrl,
1008
1073
  webhookSecret: options.webhookSecret,
1009
1074
  repos: options.repos,
1010
1075
  auth: options.auth,
@@ -1014,6 +1079,12 @@ async function maybeInstallGithubWebhooks(
1014
1079
  emit({ step: 'github-webhooks', phase: 'done', result })
1015
1080
  }
1016
1081
 
1082
+ function resolveEagerWebhookUrl(options: Extract<AddChannelOptions, { channel: 'github' }>): string | undefined {
1083
+ if (options.tunnelProvider === 'external') return options.webhookUrl
1084
+ if (options.tunnelProvider === 'cloudflare-named') return options.hostname
1085
+ return undefined
1086
+ }
1087
+
1017
1088
  function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
1018
1089
  switch (options.channel) {
1019
1090
  case 'discord-bot':
@@ -1112,24 +1183,20 @@ function mergeGithubTunnelConfig(
1112
1183
  if (options.tunnelProvider === 'external' && options.webhookUrl === undefined) {
1113
1184
  throw new Error('GitHub external tunnel requires webhookUrl')
1114
1185
  }
1186
+ if (options.tunnelProvider === 'cloudflare-named') {
1187
+ if (options.hostname === undefined || options.hostname.trim() === '') {
1188
+ throw new Error('GitHub cloudflare-named tunnel requires hostname')
1189
+ }
1190
+ if (options.tokenEnv === undefined || options.tokenEnv.trim() === '') {
1191
+ throw new Error('GitHub cloudflare-named tunnel requires tokenEnv')
1192
+ }
1193
+ }
1115
1194
 
1116
1195
  const existingTunnels = Array.isArray(parsed.tunnels) ? parsed.tunnels : []
1117
- const tunnel =
1118
- options.tunnelProvider === 'external'
1119
- ? {
1120
- name: 'github-webhook',
1121
- provider: 'external',
1122
- externalUrl: options.webhookUrl,
1123
- for: { kind: 'channel', name: 'github' },
1124
- }
1125
- : {
1126
- name: 'github-webhook',
1127
- provider: 'cloudflare-quick',
1128
- for: { kind: 'channel', name: 'github' },
1129
- }
1196
+ const tunnel = buildGithubTunnelEntry(options)
1130
1197
  parsed.tunnels = [...existingTunnels, tunnel]
1131
1198
 
1132
- if (options.tunnelProvider === 'cloudflare-quick') {
1199
+ if (options.tunnelProvider === 'cloudflare-quick' || options.tunnelProvider === 'cloudflare-named') {
1133
1200
  const docker = isObjectRecord(parsed.docker) ? { ...parsed.docker } : {}
1134
1201
  const file = isObjectRecord(docker.file) ? { ...docker.file } : {}
1135
1202
  file.cloudflared = true
@@ -1138,6 +1205,34 @@ function mergeGithubTunnelConfig(
1138
1205
  }
1139
1206
  }
1140
1207
 
1208
+ function buildGithubTunnelEntry(options: Extract<AddChannelOptions, { channel: 'github' }>): Record<string, unknown> {
1209
+ switch (options.tunnelProvider) {
1210
+ case 'external':
1211
+ return {
1212
+ name: 'github-webhook',
1213
+ provider: 'external',
1214
+ externalUrl: options.webhookUrl,
1215
+ for: { kind: 'channel', name: 'github' },
1216
+ }
1217
+ case 'cloudflare-quick':
1218
+ return {
1219
+ name: 'github-webhook',
1220
+ provider: 'cloudflare-quick',
1221
+ for: { kind: 'channel', name: 'github' },
1222
+ }
1223
+ case 'cloudflare-named':
1224
+ return {
1225
+ name: 'github-webhook',
1226
+ provider: 'cloudflare-named',
1227
+ for: { kind: 'channel', name: 'github' },
1228
+ hostname: options.hostname,
1229
+ tokenEnv: options.tokenEnv,
1230
+ }
1231
+ case 'none':
1232
+ throw new Error('buildGithubTunnelEntry called with tunnelProvider=none')
1233
+ }
1234
+ }
1235
+
1141
1236
  // Init-side counterpart of runAddChannel's github branch. Same three writes
1142
1237
  // (typeclaw.json#channels.github, secrets.json#channels.github, roles.member
1143
1238
  // .match[]) but with overwrite semantics on the secrets/config side so a
@@ -1376,21 +1471,23 @@ export async function setChannelSecrets(
1376
1471
  })
1377
1472
  }
1378
1473
 
1379
- // Discriminated union of what GitHub credentials the user wants to rotate.
1380
- // The three secrets (PAT/private-key, webhook secret) rotate independently,
1474
+ // Discriminated union of what GitHub credentials the user wants to update.
1475
+ // The three secrets (PAT/private-key, webhook secret) update independently,
1381
1476
  // so the CLI lets the user pick which one(s) to touch in a single call.
1382
- // `auth.type` must match the existing on-disk auth type — flipping between
1383
- // PAT and App auth is a structural change, not a credential rotation, and
1384
- // belongs in a future `channel migrate-auth` or hand-edit of secrets.json.
1477
+ // `auth.type` may differ from the on-disk auth type — switching between PAT
1478
+ // and App auth replaces the entire auth block (no field carryover from the
1479
+ // previous auth type, since the two shapes share no fields beyond `type`).
1385
1480
  export type GithubCredentialPatch = {
1386
1481
  webhookSecret?: string
1387
1482
  auth?: { type: 'pat'; pat: string } | { type: 'app'; privateKey: string; appId?: number; installationId?: number }
1388
1483
  }
1389
1484
 
1390
- // Rotate one or more credential fields on an already-configured GitHub
1485
+ // Update one or more credential fields on an already-configured GitHub
1391
1486
  // channel. Like setChannelSecrets, refuses when secrets.json has no
1392
- // existing github entry. Additionally refuses when the requested auth.type
1393
- // doesn't match the on-disk type see `GithubCredentialPatch` above.
1487
+ // existing github entry. Supports both same-type rotation (preserves env
1488
+ // bindings, carries appId/installationId forward when not supplied) and
1489
+ // auth-type switching (replaces the entire auth block — see
1490
+ // `GithubCredentialPatch` above).
1394
1491
  export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch): Promise<SetChannelTokensResult> {
1395
1492
  if (!existsSync(join(cwd, CONFIG_FILE))) {
1396
1493
  return {
@@ -1416,27 +1513,22 @@ export async function setGithubSecrets(cwd: string, patch: GithubCredentialPatch
1416
1513
  if (patch.auth !== undefined) {
1417
1514
  const existingAuth = block.auth
1418
1515
  const existingAuthType = readGithubAuthTypeFromObject(existingAuth)
1419
- if (existingAuthType !== patch.auth.type) {
1420
- return {
1421
- result: {
1422
- ok: false,
1423
- reason: `github auth type mismatch: secrets.json currently uses "${existingAuthType ?? 'unknown'}" auth, but you tried to rotate a "${patch.auth.type}" credential. Edit secrets.json by hand to migrate between PAT and App auth.`,
1424
- },
1425
- }
1426
- }
1516
+ const isSameType = existingAuthType === patch.auth.type
1427
1517
  if (patch.auth.type === 'pat') {
1428
- const previousToken = isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
1518
+ const previousToken =
1519
+ isSameType && isObjectRecord(existingAuth) ? (existingAuth as { token?: unknown }).token : undefined
1429
1520
  block.auth = { type: 'pat', token: rotatedSecret(previousToken, patch.auth.pat) }
1430
1521
  } else {
1431
- const existingApp = isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1522
+ const existingApp = isSameType && isObjectRecord(existingAuth) ? (existingAuth as Record<string, unknown>) : {}
1432
1523
  const appId = patch.auth.appId ?? (existingApp.appId as number | undefined)
1433
1524
  const installationId = patch.auth.installationId ?? (existingApp.installationId as number | undefined)
1434
1525
  if (typeof appId !== 'number') {
1435
1526
  return {
1436
1527
  result: {
1437
1528
  ok: false,
1438
- reason:
1439
- 'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.',
1529
+ reason: isSameType
1530
+ ? 'github App auth requires appId, but it is missing from secrets.json. Re-run `typeclaw channel add github` to re-establish the App auth block.'
1531
+ : 'github App auth requires appId when switching from PAT to App auth.',
1440
1532
  },
1441
1533
  }
1442
1534
  }
@@ -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
@@ -16,6 +16,8 @@ export { replayJsonl } from './replay'
16
16
  export { streamLive } from './live'
17
17
  export { parseDuration, parseFilter } from './types'
18
18
  export type { InspectCategory, InspectEvent, InspectFilter } from './types'
19
+ export { runInspectLoop } from './loop'
20
+ export type { RunInspectLoopOptions } from './loop'
19
21
 
20
22
  export type RunInspectOptions = {
21
23
  agentDir: string
@@ -29,9 +31,17 @@ export type RunInspectOptions = {
29
31
  stderr: (line: string) => void
30
32
  liveSource?: LiveSourceFactory
31
33
  signal?: AbortSignal
34
+ // Aborting escSignal (and only escSignal) returns escToPicker=true so a
35
+ // caller-side loop can re-open the picker; signal still means process exit.
36
+ escSignal?: AbortSignal
37
+ liveHint?: string
32
38
  }
33
39
 
34
- export type SelectSession = (sessions: SessionSummary[]) => Promise<SessionSummary | null>
40
+ export type SelectSessionOptions = {
41
+ initialSessionId?: string
42
+ }
43
+
44
+ export type SelectSession = (sessions: SessionSummary[], opts?: SelectSessionOptions) => Promise<SessionSummary | null>
35
45
 
36
46
  export type LiveSourceFactory = (opts: {
37
47
  sessionId: string
@@ -40,7 +50,9 @@ export type LiveSourceFactory = (opts: {
40
50
  onSubscribed?: (sessionLive: boolean) => void
41
51
  }) => AsyncIterable<InspectEvent>
42
52
 
43
- export type RunInspectResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
53
+ export type RunInspectResult =
54
+ | { ok: true; exitCode: 0; escToPicker?: boolean }
55
+ | { ok: false; exitCode: number; reason: string }
44
56
 
45
57
  export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
46
58
  const filterResult = parseFilter(opts.filter)
@@ -59,7 +71,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
59
71
  const summary = await chooseSession(opts, sessionsDir, sinceMs)
60
72
  if (!summary.ok) return summary
61
73
 
62
- await streamSession({
74
+ const streamResult = await streamSession({
63
75
  summary: summary.summary,
64
76
  filter,
65
77
  sinceMs,
@@ -69,7 +81,10 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
69
81
  stderr: opts.stderr,
70
82
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
71
83
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
84
+ ...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
85
+ ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
72
86
  })
87
+ if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
73
88
  return { ok: true, exitCode: 0 }
74
89
  }
75
90
 
@@ -132,7 +147,9 @@ async function streamSession(opts: {
132
147
  stderr: (line: string) => void
133
148
  liveSource?: LiveSourceFactory
134
149
  signal?: AbortSignal
135
- }): Promise<void> {
150
+ escSignal?: AbortSignal
151
+ liveHint?: string
152
+ }): Promise<{ escToPicker: boolean }> {
136
153
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
137
154
  const emit = (event: InspectEvent): void => {
138
155
  if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
@@ -144,20 +161,26 @@ async function streamSession(opts: {
144
161
  }
145
162
  }
146
163
 
164
+ const escAborted = (): boolean => opts.escSignal?.aborted === true
165
+
147
166
  for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
167
+ if (escAborted()) return { escToPicker: true }
148
168
  emit(event)
149
169
  }
150
170
 
151
171
  if (opts.liveSource === undefined) {
152
172
  if (!opts.json) opts.stdout('─── end of transcript ───')
153
- return
173
+ return { escToPicker: escAborted() }
154
174
  }
155
175
 
176
+ if (escAborted()) return { escToPicker: true }
177
+
178
+ const combinedSignal = combineSignals(opts.signal, opts.escSignal)
156
179
  let sessionLive = false
157
180
  const liveIter = opts.liveSource({
158
181
  sessionId: opts.summary.sessionId,
159
182
  ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
160
- ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
183
+ ...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
161
184
  onSubscribed: (live) => {
162
185
  sessionLive = live
163
186
  },
@@ -170,6 +193,9 @@ async function streamSession(opts: {
170
193
  opts.stdout(
171
194
  divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
172
195
  )
196
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
197
+ opts.stdout(divider(opts.color, opts.liveHint))
198
+ }
173
199
  liveAnnounced = true
174
200
  }
175
201
  emit(event)
@@ -178,6 +204,21 @@ async function streamSession(opts: {
178
204
  opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
179
205
  }
180
206
  if (!opts.json) opts.stdout('─── end of transcript ───')
207
+ return { escToPicker: escAborted() && opts.signal?.aborted !== true }
208
+ }
209
+
210
+ function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
211
+ if (a === undefined) return b
212
+ if (b === undefined) return a
213
+ if (a.aborted) return a
214
+ if (b.aborted) return b
215
+ const ctrl = new AbortController()
216
+ const onAbort = (): void => {
217
+ ctrl.abort()
218
+ }
219
+ a.addEventListener('abort', onAbort, { once: true })
220
+ b.addEventListener('abort', onAbort, { once: true })
221
+ return ctrl.signal
181
222
  }
182
223
 
183
224
  function divider(color: boolean, text: string): string {
@@ -0,0 +1,31 @@
1
+ import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
2
+
3
+ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
+ newEscSignal: () => AbortSignal
5
+ }
6
+
7
+ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
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
+
20
+ while (true) {
21
+ const escSignal = opts.newEscSignal()
22
+ const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
23
+ if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
24
+ else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
25
+
26
+ const result = await runInspect(callOpts)
27
+ if (!result.ok) return result
28
+ if (result.escToPicker !== true) return result
29
+ sessionArg = undefined
30
+ }
31
+ }
@@ -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 = numberOr(readField(message, 'timestamp'), 0)
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) {
@@ -29,21 +29,27 @@ export type BuiltinRoleSpec = {
29
29
  readonly permissions: readonly string[]
30
30
  }
31
31
 
32
- // Owner carries low + medium tier strings explicitly AND the wildcard
33
- // sentinel. The sentinel expands to plugin-contributed `security.bypass.*`
34
- // strings minus the security plugin's `ownerWildcardExclusions` (today:
35
- // `security.bypass.high` plus high-tier per-guard strings). Net effect:
36
- // owner auto-bypasses every low- and medium-tier guard, and high-tier
37
- // guards require per-call ack from owner too (the audience-leak rule —
38
- // owner-in-public-channel must not silently post credentials).
32
+ // Role-to-tier defaults form a strict tower:
33
+ // owner → bypass.low + bypass.medium + bypass.high
34
+ // trusted bypass.low + bypass.medium
35
+ // member → bypass.low
36
+ // guest → no bypass
39
37
  //
40
- // Trusted carries only `security.bypass.low`. Trusted does NOT carry the
41
- // pre-PR per-guard grants (`bypassSecretExfilBash`, `bypassGitExfil`):
42
- // those guards are medium/high under the audience-leak axis and per-guard
43
- // grants would re-introduce exactly the bypass holes the tier system
44
- // exists to prevent. Operators who want the pre-PR ergonomics can add the
45
- // per-guard strings explicitly to `roles.trusted.permissions[]` in
46
- // typeclaw.json that path stays alive forever.
38
+ // `canBypass` in the bundled security plugin checks the specific tier
39
+ // string for the guard's severity, so each role must carry every tier
40
+ // string at or below its cap (tiers do not cascade implicitly).
41
+ //
42
+ // Owner also carries the wildcard sentinel: the sentinel expands to every
43
+ // plugin-contributed `security.bypass.*` string minus
44
+ // `ownerWildcardExclusions`. The bundled security plugin no longer excludes
45
+ // high-tier strings (owner is meant to bypass them by default under this
46
+ // model), so the sentinel covers per-guard high-tier strings too.
47
+ //
48
+ // Tradeoff: this gives owner audience-leak bypass without per-call ack.
49
+ // The owner-in-public-channel risk is now load-bearing on the operator
50
+ // scoping `roles.owner.match[]` tightly. Default match is TUI-only, where
51
+ // a human is present; configs that widen owner to a channel author should
52
+ // understand they have re-opened audience-leak for that author.
47
53
  export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
48
54
  owner: {
49
55
  match: [{ kind: 'tui' }],
@@ -57,6 +63,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
57
63
  CORE_PERMISSIONS.subagentSpawnOperator,
58
64
  'security.bypass.low',
59
65
  'security.bypass.medium',
66
+ 'security.bypass.high',
60
67
  OWNER_SECURITY_WILDCARD,
61
68
  ],
62
69
  },
@@ -70,6 +77,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
70
77
  CORE_PERMISSIONS.subagentOutput,
71
78
  CORE_PERMISSIONS.subagentSpawnOperator,
72
79
  'security.bypass.low',
80
+ 'security.bypass.medium',
73
81
  ],
74
82
  },
75
83
  member: {
@@ -79,6 +87,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
79
87
  CORE_PERMISSIONS.subagentSpawn,
80
88
  CORE_PERMISSIONS.subagentCancel,
81
89
  CORE_PERMISSIONS.subagentOutput,
90
+ 'security.bypass.low',
82
91
  ],
83
92
  },
84
93
  guest: {
@@ -88,13 +97,12 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
88
97
  }
89
98
 
90
99
  // Expands the owner wildcard sentinel against plugin-contributed
91
- // `security.bypass.*` strings. `wildcardExclusions` is an optional set of
92
- // permission strings the sentinel must NOT expand to used by the
93
- // bundled security plugin to exclude `security.bypass.high` AND the
94
- // per-guard strings for high-tier guards, so the wildcard does not
95
- // auto-grant audience-leak bypass to owner. Explicit operator grants of
96
- // those strings in `roles.owner.permissions[]` still take effect (they
97
- // flow through the non-sentinel branch).
100
+ // `security.bypass.*` strings. `wildcardExclusions` lets plugins opt
101
+ // specific strings OUT of the wildcard expansion. The bundled security
102
+ // plugin no longer excludes any high-tier strings — owner bypasses every
103
+ // security tier by default under the current role-tower model. The
104
+ // parameter is preserved for third-party plugins that want a different
105
+ // shape (e.g. a future audit-only plugin that never auto-flows to owner).
98
106
  export function expandOwnerWildcard(
99
107
  ownerPermissions: readonly string[],
100
108
  pluginContributed: readonly string[],