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
@@ -0,0 +1,66 @@
1
+ // Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
2
+ // Owns the AbortController lifecycle and the bare-ESC debounce timer,
3
+ // independent of process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
4
+ // Extracted for testability: the lifecycle bug we want to pin is "armForStream's
5
+ // signal must remain valid across pause()/resume() cycles" — verifying that without
6
+ // a real TTY requires this seam.
7
+
8
+ export type EscChunkResult = { sigint: boolean }
9
+
10
+ export type EscController = {
11
+ armForStream: () => AbortSignal
12
+ onChunk: (chunk: Buffer) => EscChunkResult
13
+ clearPending: () => void
14
+ dispose: () => void
15
+ }
16
+
17
+ export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
18
+ let currentCtrl: AbortController | null = null
19
+ let pendingEsc: ReturnType<typeof setTimeout> | null = null
20
+
21
+ const clearPending = (): void => {
22
+ if (pendingEsc !== null) {
23
+ clearTimeout(pendingEsc)
24
+ pendingEsc = null
25
+ }
26
+ }
27
+
28
+ return {
29
+ armForStream: () => {
30
+ clearPending()
31
+ currentCtrl = new AbortController()
32
+ return currentCtrl.signal
33
+ },
34
+ onChunk: (chunk) => {
35
+ if (chunk.length === 0) return { sigint: false }
36
+ if (chunk[0] === 0x03) {
37
+ // Ctrl-C in raw mode arrives as a byte (terminal driver does not generate
38
+ // SIGINT). Surface to the caller so it can re-issue SIGINT via the OS;
39
+ // we deliberately keep the AbortController lifecycle separate from SIGINT.
40
+ return { sigint: true }
41
+ }
42
+ if (chunk.length === 1 && chunk[0] === 0x1b) {
43
+ // Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
44
+ // sequences from arrow keys, mouse, paste) cancels the pending fire.
45
+ // Snapshot currentCtrl so a late-firing timer can't abort a controller
46
+ // created by a subsequent armForStream() call.
47
+ clearPending()
48
+ const ctrl = currentCtrl
49
+ pendingEsc = setTimeout(() => {
50
+ pendingEsc = null
51
+ ctrl?.abort()
52
+ }, debounceMs)
53
+ return { sigint: false }
54
+ }
55
+ // Any other byte arriving within the ESC window is the second byte of a CSI
56
+ // sequence; cancel the pending abort.
57
+ clearPending()
58
+ return { sigint: false }
59
+ },
60
+ clearPending,
61
+ dispose: () => {
62
+ clearPending()
63
+ currentCtrl = null
64
+ },
65
+ }
66
+ }
@@ -2,15 +2,18 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspect, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
+ import { createEscController } from './inspect-controller'
8
9
  import { cancel, c, errorLine, isCancel } from './ui'
9
10
 
11
+ const ESC_LISTEN_DELAY_MS = 50
12
+
10
13
  export const inspectCommand = defineCommand({
11
14
  meta: {
12
15
  name: 'inspect',
13
- description: 'replay a session transcript and tail live activity (host stage)',
16
+ description: 'observe a session: replay the transcript, then tail live activity (host stage)',
14
17
  },
15
18
  args: {
16
19
  session: {
@@ -32,12 +35,6 @@ export const inspectCommand = defineCommand({
32
35
  description: 'emit one JSON event per line; requires an explicit session id',
33
36
  default: false,
34
37
  },
35
- follow: {
36
- type: 'boolean',
37
- description:
38
- 'tail live activity after replay (default: true when the container is running); pass --no-follow to replay-then-exit',
39
- default: true,
40
- },
41
38
  },
42
39
  async run({ args }) {
43
40
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -45,26 +42,39 @@ export const inspectCommand = defineCommand({
45
42
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
46
43
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
47
44
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
48
- const follow = args.follow !== false
49
45
 
50
46
  const isJson = args.json === true
51
- const liveSource = !follow || isJson ? undefined : await buildLiveSource(cwd)
47
+ const liveSource = isJson ? undefined : await buildLiveSource(cwd)
52
48
  const signal = installSigintAbort()
49
+ const escListener = isJson ? null : createEscListener()
50
+ const liveHint = escListener === null ? undefined : escHintLine(color)
53
51
 
54
- const result = await runInspect({
52
+ const result = await runInspectLoop({
55
53
  agentDir: cwd,
56
54
  ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
57
55
  ...(filterArg !== undefined ? { filter: filterArg } : {}),
58
56
  ...(sinceArg !== undefined ? { since: sinceArg } : {}),
59
57
  json: isJson,
60
58
  color,
61
- selectSession: clackSelect,
59
+ selectSession: (sessions, selectOpts) => {
60
+ escListener?.pause()
61
+ return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
62
+ escListener?.resume()
63
+ })
64
+ },
62
65
  ...(liveSource !== undefined ? { liveSource } : {}),
63
66
  signal,
67
+ newEscSignal: () => {
68
+ if (escListener === null) return new AbortController().signal
69
+ return escListener.armForStream()
70
+ },
71
+ ...(liveHint !== undefined ? { liveHint } : {}),
64
72
  stdout: (line) => process.stdout.write(`${line}\n`),
65
73
  stderr: (line) => process.stderr.write(`${line}\n`),
66
74
  })
67
75
 
76
+ escListener?.stop()
77
+
68
78
  if (!result.ok) {
69
79
  process.stderr.write(`${errorLine(result.reason)}\n`)
70
80
  process.exit(result.exitCode)
@@ -104,6 +114,74 @@ function installSigintAbort(): AbortSignal {
104
114
  return ctrl.signal
105
115
  }
106
116
 
117
+ type EscListener = {
118
+ armForStream: () => AbortSignal
119
+ pause: () => void
120
+ resume: () => void
121
+ stop: () => void
122
+ }
123
+
124
+ function createEscListener(): EscListener | null {
125
+ const stdin = process.stdin
126
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
127
+
128
+ const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
129
+ let active = false
130
+
131
+ const onData = (chunk: Buffer): void => {
132
+ const { sigint } = ctrl.onChunk(chunk)
133
+ if (sigint) process.kill(process.pid, 'SIGINT')
134
+ }
135
+
136
+ const start = (): void => {
137
+ if (active) return
138
+ active = true
139
+ stdin.setRawMode(true)
140
+ stdin.resume()
141
+ stdin.on('data', onData)
142
+ }
143
+ const stop = (): void => {
144
+ if (!active) return
145
+ active = false
146
+ stdin.off('data', onData)
147
+ try {
148
+ stdin.setRawMode(false)
149
+ } catch {
150
+ /* terminal already torn down */
151
+ }
152
+ stdin.pause()
153
+ ctrl.clearPending()
154
+ }
155
+
156
+ return {
157
+ armForStream: () => {
158
+ const signal = ctrl.armForStream()
159
+ start()
160
+ return signal
161
+ },
162
+ pause: () => {
163
+ stop()
164
+ },
165
+ resume: () => {
166
+ // Resume the listener WITHOUT replacing the AbortController.
167
+ // The signal returned by armForStream() is held by the live source
168
+ // through streamSession's combinedSignal; replacing the controller
169
+ // here would orphan that signal so a subsequent ESC press could
170
+ // not abort the live tail.
171
+ start()
172
+ },
173
+ stop: () => {
174
+ ctrl.dispose()
175
+ stop()
176
+ },
177
+ }
178
+ }
179
+
180
+ function escHintLine(color: boolean): string {
181
+ const text = '(press esc to return to session list)'
182
+ return color ? `\u001b[2m${text}\u001b[0m` : text
183
+ }
184
+
107
185
  function useColor(): boolean {
108
186
  if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
109
187
  if (process.env.FORCE_COLOR === '0') return false
@@ -111,8 +189,15 @@ function useColor(): boolean {
111
189
  return Boolean(process.stdout.isTTY)
112
190
  }
113
191
 
114
- async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary | null> {
192
+ async function clackSelect(
193
+ sessions: SessionSummary[],
194
+ initialSessionId: string | undefined,
195
+ ): Promise<SessionSummary | null> {
115
196
  const { select } = await import('@clack/prompts')
197
+ const preferred =
198
+ initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
199
+ ? initialSessionId
200
+ : sessions[0]?.sessionId
116
201
  const picked = await select<string>({
117
202
  message: `Pick a session to inspect (showing ${sessions.length})`,
118
203
  options: sessions.map((s) => ({
@@ -120,7 +205,7 @@ async function clackSelect(sessions: SessionSummary[]): Promise<SessionSummary |
120
205
  label: formatRowLabel(s),
121
206
  ...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
122
207
  })),
123
- initialValue: sessions[0]?.sessionId,
208
+ initialValue: preferred,
124
209
  })
125
210
  if (isCancel(picked)) {
126
211
  cancel('Cancelled.')
package/src/cli/role.ts CHANGED
@@ -11,7 +11,7 @@ const claimSub = defineCommand({
11
11
  meta: {
12
12
  name: 'claim',
13
13
  description:
14
- 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you DM that code back to the bot to prove control of the channel account.',
14
+ 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you send that code back to the bot from any chat (DM, group, channel) to prove control of the channel account. The resulting match rule grants the role to your author identity across every chat on that platform.',
15
15
  },
16
16
  args: {
17
17
  as: {
@@ -57,7 +57,7 @@ const claimSub = defineCommand({
57
57
  s.stop('Ready.')
58
58
  const expiresInSec = Math.max(0, Math.round((payload.expiresAt - Date.now()) / 1000))
59
59
  const lines = [
60
- `Send this message to your bot as a DM:`,
60
+ `Send this message to your bot from any chat (DM, group, or channel):`,
61
61
  '',
62
62
  ` ${c.bold(payload.code)}`,
63
63
  '',
package/src/cli/run.ts CHANGED
@@ -49,21 +49,40 @@ export const run = defineCommand({
49
49
  initialPrompt: args.prompt,
50
50
  })
51
51
 
52
- const onSignal = () => {
53
- stop()
54
- process.exit(0)
52
+ const exit = (code: number): void => {
53
+ process.exit(code)
54
+ }
55
+ const onSignal = (): void => {
56
+ void shutdown({ stop, exit })
55
57
  }
56
58
  process.once('SIGINT', onSignal)
57
59
  process.once('SIGTERM', onSignal)
58
60
 
59
61
  if (tuiPromise) {
60
62
  await tuiPromise
61
- stop()
62
- process.exit(0)
63
+ await shutdown({ stop, exit })
63
64
  }
64
65
  },
65
66
  })
66
67
 
68
+ // Awaits `stop()` BEFORE exiting so async teardown side-effects (channel
69
+ // adapter teardown, in particular GitHub webhook deregistration) actually
70
+ // complete. The previous code called `stop()` without awaiting and then
71
+ // `process.exit(0)` synchronously, so the in-process DELETE /repos/.../hooks/
72
+ // requests never went out and webhooks survived `typeclaw stop` (which
73
+ // `docker stop`s the container → SIGTERM).
74
+ export async function shutdown(deps: {
75
+ stop: () => void | Promise<void>
76
+ exit: (code: number) => void
77
+ }): Promise<void> {
78
+ try {
79
+ await deps.stop()
80
+ deps.exit(0)
81
+ } catch {
82
+ deps.exit(1)
83
+ }
84
+ }
85
+
67
86
  function resolveAttachTui({
68
87
  tui,
69
88
  noTui,
package/src/cli/tui.ts CHANGED
@@ -25,16 +25,40 @@ export const tui = defineCommand({
25
25
  },
26
26
  },
27
27
  async run({ args }) {
28
- const url = args.url ?? (await defaultUrl())
29
- const tui = createTui({
30
- url,
31
- ...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
32
- expectedVersion: CLI_VERSION,
33
- onVersionMismatch: (info) => {
34
- process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
35
- },
36
- })
37
- await tui.run()
28
+ const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
29
+
30
+ let initialPrompt: string | undefined = args.prompt
31
+ let attempt = 0
32
+ const RECONNECT_MAX_ATTEMPTS = 30
33
+ const RECONNECT_BACKOFF_MS = 1_000
34
+
35
+ while (true) {
36
+ const url = await resolveUrl()
37
+ const tui = createTui({
38
+ url,
39
+ ...(initialPrompt !== undefined ? { initialPrompt } : {}),
40
+ expectedVersion: CLI_VERSION,
41
+ onVersionMismatch: (info) => {
42
+ process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
43
+ },
44
+ })
45
+ const outcome = await tui.run()
46
+ if (!outcome.lostConnection) return
47
+ // The TUI lost its WS post-handshake (container restart, network blip,
48
+ // hostd hiccup). Re-resolve the URL because the host port can change
49
+ // across container lifecycles (see resolveHostPort), then reconnect.
50
+ // The initial prompt is intentionally cleared after the first cycle:
51
+ // on a reconnect, the agent is resuming the same session — replaying
52
+ // the prompt would re-send it to the LLM.
53
+ initialPrompt = undefined
54
+ attempt += 1
55
+ if (attempt > RECONNECT_MAX_ATTEMPTS) {
56
+ console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
57
+ process.exit(1)
58
+ }
59
+ process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
60
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
61
+ }
38
62
  },
39
63
  })
40
64