typeclaw 0.28.2 → 0.29.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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -5
  3. package/src/agent/loop-guard.ts +112 -26
  4. package/src/agent/plugin-tools.ts +102 -41
  5. package/src/agent/session-origin.ts +3 -3
  6. package/src/agent/subagents.ts +7 -0
  7. package/src/agent/system-prompt.ts +29 -4
  8. package/src/agent/tools/channel-send.ts +1 -1
  9. package/src/agent/tools/spawn-subagent.ts +21 -0
  10. package/src/agent/tools/subagent-output.ts +7 -3
  11. package/src/agent/tools/wikipedia.ts +1 -1
  12. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  13. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  14. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  15. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  16. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  17. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  18. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  19. package/src/bundled-plugins/operator/operator.ts +2 -0
  20. package/src/bundled-plugins/planner/index.ts +11 -0
  21. package/src/bundled-plugins/planner/planner.ts +282 -0
  22. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  23. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  24. package/src/bundled-plugins/researcher/index.ts +11 -0
  25. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  26. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  27. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  28. package/src/bundled-plugins/reviewer/reviewer.ts +26 -8
  29. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  30. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  31. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  32. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  33. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  34. package/src/bundled-plugins/scout/scout.ts +2 -0
  35. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  36. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  37. package/src/channels/adapters/discord-bot.ts +38 -11
  38. package/src/channels/adapters/github/inbound.ts +68 -4
  39. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  40. package/src/channels/adapters/kakaotalk.ts +2 -2
  41. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  42. package/src/channels/adapters/slack-bot.ts +3 -0
  43. package/src/channels/adapters/telegram-bot.ts +3 -0
  44. package/src/channels/engagement.ts +12 -7
  45. package/src/channels/router.ts +32 -9
  46. package/src/channels/schema.ts +1 -1
  47. package/src/channels/types.ts +6 -0
  48. package/src/cli/init.ts +13 -2
  49. package/src/cli/ui.ts +64 -0
  50. package/src/config/config.ts +21 -15
  51. package/src/container/start.ts +5 -1
  52. package/src/init/dockerfile.ts +19 -56
  53. package/src/init/hatching.ts +1 -1
  54. package/src/init/index.ts +5 -1
  55. package/src/run/bundled-plugins.ts +4 -0
  56. package/src/server/index.ts +24 -5
  57. package/src/shared/host-locale.ts +27 -0
  58. package/src/shared/protocol.ts +1 -1
  59. package/src/shared/wordmark.ts +19 -0
  60. package/src/skills/typeclaw-config/SKILL.md +32 -32
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  62. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  63. package/src/tui/banner.ts +19 -0
  64. package/src/tui/format.ts +34 -0
  65. package/src/tui/index.ts +121 -22
  66. package/src/tui/theme.ts +26 -1
  67. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  68. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  69. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  70. package/typeclaw.schema.json +15 -7
package/src/tui/index.ts CHANGED
@@ -1,13 +1,25 @@
1
- import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
1
+ import {
2
+ Editor,
3
+ Key,
4
+ Loader,
5
+ Markdown,
6
+ matchesKey,
7
+ ProcessTerminal,
8
+ type Terminal,
9
+ Text,
10
+ TUI,
11
+ } from '@mariozechner/pi-tui'
2
12
 
3
13
  import { parseCommand } from '@/commands'
4
14
 
15
+ import { formatBanner } from './banner'
5
16
  import { createClient as createClientDefault, type Client } from './client'
6
17
  import {
18
+ formatAssistantHeader,
7
19
  formatQueuePanel,
8
- formatTimestamp,
9
20
  formatToolEnd,
10
21
  formatToolStart,
22
+ formatUsageSummary,
11
23
  formatUserPromptHistory,
12
24
  withTimestamp,
13
25
  } from './format'
@@ -118,29 +130,58 @@ export function createTui({
118
130
  if (handshake === null) return { reason: 'connectFailed' }
119
131
 
120
132
  const { sessionId, serverVersion } = handshake
121
- status.setText(colors.dim(`session: ${sessionId}`))
133
+ // The banner card already carries session id, version, and url, so it
134
+ // supersedes the old one-line session status in place (status is child[0],
135
+ // pinned above all scrollback).
136
+ status.setText(formatBanner({ sessionId, displayUrl, ...(serverVersion !== undefined ? { serverVersion } : {}) }))
122
137
  tui.requestRender()
123
138
 
124
139
  const editor = new Editor(tui, editorTheme, { paddingX: 0 })
140
+ const statusBar = new Text('', 0, 0)
125
141
  let replyInFlight = false
126
142
  let onReplyDone: (() => void) | null = null
127
143
  let currentAssistant: Markdown | null = null
128
144
  let currentAssistantText = ''
129
145
  let queuePanel: Text | null = null
146
+ let thinkingLoader: Loader | null = null
147
+ let reloadLoader: Loader | null = null
148
+ let restartLoader: Loader | null = null
149
+ let usageLabel: string | null = null
150
+ let connectionLabel = 'connected'
151
+
152
+ const shortSessionId = sessionId.length > 14 ? `${sessionId.slice(0, 14)}…` : sessionId
153
+ const refreshStatusBar = () => {
154
+ const parts = [
155
+ colors.dim(connectionLabel),
156
+ colors.dim(`session ${shortSessionId}`),
157
+ serverVersion !== undefined ? colors.dim(`v${serverVersion}`) : null,
158
+ usageLabel !== null ? colors.accent(usageLabel) : null,
159
+ ].filter((part): part is string => part !== null)
160
+ statusBar.setText(parts.join(colors.dim(' · ')))
161
+ }
162
+ refreshStatusBar()
130
163
 
131
164
  // Pi-tui's Container.addChild appends to the end of the children array.
132
- // The editor must remain the LAST child at all times so it stays pinned
133
- // to the bottom of the viewport, with chat history scrolling above it.
134
- // The queue panel, when present, sits immediately ABOVE the editor (so
135
- // the layout is [...history, queuePanel?, editor]). Any new history entry
136
- // is inserted by stripping the queue panel + editor from the tail,
137
- // appending the entry, then re-appending them in order.
138
- const appendHistory = (component: Text | Markdown) => {
165
+ // The bottom tail is pinned as [...history, queuePanel?, editor, statusBar]:
166
+ // the editor stays above the persistent status bar, and the queue panel,
167
+ // when present, sits just above the editor. Any new history entry is
168
+ // inserted by stripping that tail, appending the entry, then re-appending
169
+ // the tail in order so nothing ever renders below the status bar.
170
+ const reattachTail = () => {
171
+ if (queuePanel) tui.addChild(queuePanel)
172
+ tui.addChild(editor)
173
+ tui.addChild(statusBar)
174
+ }
175
+ const detachTail = () => {
139
176
  if (queuePanel) tui.removeChild(queuePanel)
140
177
  tui.removeChild(editor)
178
+ tui.removeChild(statusBar)
179
+ }
180
+
181
+ const appendHistory = (component: Text | Markdown) => {
182
+ detachTail()
141
183
  tui.addChild(component)
142
- if (queuePanel) tui.addChild(queuePanel)
143
- tui.addChild(editor)
184
+ reattachTail()
144
185
  }
145
186
 
146
187
  const updateQueuePanel = (pending: ReadonlyArray<{ id: string; text: string; ts: number }>) => {
@@ -156,14 +197,36 @@ export function createTui({
156
197
  if (queuePanel) {
157
198
  queuePanel.setText(text)
158
199
  } else {
159
- queuePanel = new Text(text, 0, 0)
160
200
  tui.removeChild(editor)
201
+ tui.removeChild(statusBar)
202
+ queuePanel = new Text(text, 0, 0)
161
203
  tui.addChild(queuePanel)
162
204
  tui.addChild(editor)
205
+ tui.addChild(statusBar)
163
206
  }
164
207
  tui.requestRender()
165
208
  }
166
209
 
210
+ const showThinking = () => {
211
+ if (thinkingLoader !== null) return
212
+ const loader = new Loader(tui, colors.accent, colors.dim, 'thinking…')
213
+ thinkingLoader = loader
214
+ appendHistory(loader)
215
+ loader.start()
216
+ tui.requestRender()
217
+ }
218
+ const hideThinking = () => {
219
+ if (thinkingLoader === null) return
220
+ thinkingLoader.stop()
221
+ tui.removeChild(thinkingLoader)
222
+ thinkingLoader = null
223
+ }
224
+ const stopAllLoaders = () => {
225
+ thinkingLoader?.stop()
226
+ reloadLoader?.stop()
227
+ restartLoader?.stop()
228
+ }
229
+
167
230
  // Reset between text segments so a new Markdown block is created after
168
231
  // any non-text event (tool calls). Otherwise text_delta after a tool call
169
232
  // would append to the previous Markdown and visually push the tool lines
@@ -174,19 +237,20 @@ export function createTui({
174
237
  }
175
238
 
176
239
  const finishAssistantTurn = () => {
240
+ hideThinking()
177
241
  sealAssistantBlock()
178
242
  replyInFlight = false
179
243
  onReplyDone?.()
180
244
  onReplyDone = null
181
245
  }
182
246
 
183
- // A Markdown block can't carry an ANSI timestamp prefix (it'd be parsed as
184
- // markdown), so the assistant turn's timestamp is a separate dim Text line
185
- // emitted just above the block when it's first created — stamped with the
186
- // first delta's server `ts`.
247
+ // A Markdown block can't carry an ANSI header prefix (it'd be parsed as
248
+ // markdown), so the assistant turn's boxed header (label + timestamp) is a
249
+ // separate Text line emitted just above the block when it's first created —
250
+ // stamped with the first delta's server `ts`.
187
251
  const ensureAssistantBlock = (ts: number | undefined): Markdown => {
188
252
  if (currentAssistant) return currentAssistant
189
- appendHistory(new Text(formatTimestamp(ts), 0, 0))
253
+ appendHistory(new Text(formatAssistantHeader(ts), 0, 0))
190
254
  const md = new Markdown('', 0, 0, markdownTheme)
191
255
  currentAssistant = md
192
256
  currentAssistantText = ''
@@ -198,10 +262,12 @@ export function createTui({
198
262
  switch (msg.type) {
199
263
  case 'prompt_started': {
200
264
  appendHistory(new Text(withTimestamp(msg.ts, formatUserPromptHistory(msg.text)), 0, 0))
265
+ if (replyInFlight) showThinking()
201
266
  tui.requestRender()
202
267
  break
203
268
  }
204
269
  case 'text_delta': {
270
+ hideThinking()
205
271
  const block = ensureAssistantBlock(msg.ts)
206
272
  currentAssistantText += msg.delta
207
273
  block.setText(currentAssistantText)
@@ -209,6 +275,7 @@ export function createTui({
209
275
  break
210
276
  }
211
277
  case 'tool_start': {
278
+ hideThinking()
212
279
  sealAssistantBlock()
213
280
  appendHistory(new Text(withTimestamp(msg.ts, formatToolStart(msg.name, msg.args)), 0, 0))
214
281
  tui.requestRender()
@@ -223,6 +290,10 @@ export function createTui({
223
290
  break
224
291
  }
225
292
  case 'done': {
293
+ if (msg.usage !== undefined) {
294
+ usageLabel = formatUsageSummary(msg.usage)
295
+ refreshStatusBar()
296
+ }
226
297
  finishAssistantTurn()
227
298
  tui.requestRender()
228
299
  break
@@ -238,6 +309,11 @@ export function createTui({
238
309
  break
239
310
  }
240
311
  case 'reload_result': {
312
+ if (reloadLoader !== null) {
313
+ reloadLoader.stop()
314
+ tui.removeChild(reloadLoader)
315
+ reloadLoader = null
316
+ }
241
317
  for (const result of msg.results) {
242
318
  const text = result.ok
243
319
  ? `${colors.green('●')} ${colors.bold(`[${result.scope}]`)} ${result.summary}`
@@ -248,6 +324,11 @@ export function createTui({
248
324
  break
249
325
  }
250
326
  case 'restart_result': {
327
+ if (restartLoader !== null) {
328
+ restartLoader.stop()
329
+ tui.removeChild(restartLoader)
330
+ restartLoader = null
331
+ }
251
332
  const text =
252
333
  msg.status === 'accepted'
253
334
  ? colors.green(colors.dim(msg.message ?? 'restart scheduled; reconnecting when the new container is up'))
@@ -271,6 +352,9 @@ export function createTui({
271
352
  }
272
353
 
273
354
  client.onClose(() => {
355
+ stopAllLoaders()
356
+ connectionLabel = 'disconnected'
357
+ refreshStatusBar()
274
358
  appendHistory(new Text(colors.dim('disconnected'), 0, 0))
275
359
  tui.requestRender()
276
360
  // A user-initiated detach/exit already closed the WS deliberately and
@@ -293,14 +377,27 @@ export function createTui({
293
377
  }
294
378
  if (command === 'reload') {
295
379
  client.send({ type: 'reload' })
296
- appendHistory(new Text(colors.dim('reloading...'), 0, 0))
380
+ if (reloadLoader === null) {
381
+ const loader = new Loader(tui, colors.accent, colors.dim, 'reloading…')
382
+ reloadLoader = loader
383
+ appendHistory(loader)
384
+ loader.start()
385
+ }
297
386
  tui.requestRender()
298
387
  return true
299
388
  }
300
389
  client.send({ type: 'restart' })
301
- appendHistory(
302
- new Text(colors.yellow(colors.dim('restart requested... reconnecting when the new container is up')), 0, 0),
303
- )
390
+ if (restartLoader === null) {
391
+ const loader = new Loader(
392
+ tui,
393
+ colors.yellow,
394
+ colors.dim,
395
+ 'restart requested… reconnecting when the new container is up',
396
+ )
397
+ restartLoader = loader
398
+ appendHistory(loader)
399
+ loader.start()
400
+ }
304
401
  tui.requestRender()
305
402
  return true
306
403
  }
@@ -322,6 +419,7 @@ export function createTui({
322
419
  // settles 'lostConnection'. settle() is idempotent, so the first call wins —
323
420
  // settling the deliberate outcome first keeps the later onClose a no-op.
324
421
  const teardown = (): void => {
422
+ stopAllLoaders()
325
423
  tui.stop()
326
424
  client.close()
327
425
  }
@@ -365,6 +463,7 @@ export function createTui({
365
463
  void send(text)
366
464
  }
367
465
  tui.addChild(editor)
466
+ tui.addChild(statusBar)
368
467
  tui.setFocus(editor)
369
468
  tui.requestRender()
370
469
 
package/src/tui/theme.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { EditorTheme, MarkdownTheme } from '@mariozechner/pi-tui'
2
2
 
3
3
  const wrap = (code: string) => (text: string) => `\x1b[${code}m${text}\x1b[0m`
4
+ const wrapRgb = (r: number, g: number, b: number) => (text: string) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`
4
5
 
5
6
  const dim = wrap('2')
6
7
  const bold = wrap('1')
@@ -9,8 +10,32 @@ const green = wrap('32')
9
10
  const yellow = wrap('33')
10
11
  const cyan = wrap('36')
11
12
  const gray = wrap('90')
13
+ const brightGreen = wrap('92')
14
+ const brightCyan = wrap('96')
15
+ const brightMagenta = wrap('95')
12
16
 
13
- export const colors = { dim, bold, red, green, yellow, cyan, gray }
17
+ // Sampled from the typeey mascot. True navy (#182A5B) is too dark to read on
18
+ // dark terminals, so `accent` is a lifted cornflower of the same hue; amber is
19
+ // the mascot's beak/feet highlight.
20
+ const cornflower = wrapRgb(0x5b, 0x7f, 0xd4)
21
+ const amber = wrapRgb(0xe7, 0x8f, 0x37)
22
+ const accent = cornflower
23
+
24
+ export const colors = {
25
+ dim,
26
+ bold,
27
+ red,
28
+ green,
29
+ yellow,
30
+ cyan,
31
+ gray,
32
+ brightGreen,
33
+ brightCyan,
34
+ brightMagenta,
35
+ cornflower,
36
+ amber,
37
+ accent,
38
+ }
14
39
 
15
40
  export const editorTheme: EditorTheme = {
16
41
  borderColor: dim,
@@ -2,6 +2,7 @@ import type { Unsubscribe } from '@/stream'
2
2
 
3
3
  import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
4
4
  import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
5
+ import { isBinaryNotFound, MISSING_BINARY_DETAIL } from './cloudflared-binary'
5
6
 
6
7
  const DEFAULT_BINARY = 'cloudflared'
7
8
  const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
@@ -79,10 +80,20 @@ export function createCloudflareNamedProvider(options: CloudflareNamedProviderOp
79
80
 
80
81
  state.status = 'starting'
81
82
  state.detail = 'starting cloudflared'
82
- const spawned = Bun.spawn([binary, 'tunnel', '--no-autoupdate', 'run', '--token', token], {
83
- stdout: 'ignore',
84
- stderr: 'pipe',
85
- })
83
+ let spawned: Bun.Subprocess<'ignore', 'ignore', 'pipe'>
84
+ try {
85
+ spawned = Bun.spawn([binary, 'tunnel', '--no-autoupdate', 'run', '--token', token], {
86
+ stdout: 'ignore',
87
+ stderr: 'pipe',
88
+ })
89
+ } catch (err) {
90
+ if (isBinaryNotFound(err)) {
91
+ state.status = 'permanently-failed'
92
+ state.detail = MISSING_BINARY_DETAIL
93
+ return
94
+ }
95
+ throw err
96
+ }
86
97
  proc = spawned
87
98
 
88
99
  // Mark healthy on the FIRST stderr line. cloudflared with a valid token
@@ -3,6 +3,7 @@ import type { Unsubscribe } from '@/stream'
3
3
  import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
4
4
  import { extractQuickTunnelUrl } from '../quick-url-parser'
5
5
  import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
6
+ import { isBinaryNotFound, MISSING_BINARY_DETAIL } from './cloudflared-binary'
6
7
 
7
8
  const DEFAULT_BINARY = 'cloudflared'
8
9
  const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
@@ -61,10 +62,20 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
61
62
  attemptEmittedUrl = false
62
63
  state.status = 'starting'
63
64
  state.detail = 'starting cloudflared'
64
- const spawned = Bun.spawn(
65
- [binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
66
- { stdout: 'ignore', stderr: 'pipe' },
67
- )
65
+ let spawned: Bun.Subprocess<'ignore', 'ignore', 'pipe'>
66
+ try {
67
+ spawned = Bun.spawn(
68
+ [binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
69
+ { stdout: 'ignore', stderr: 'pipe' },
70
+ )
71
+ } catch (err) {
72
+ if (isBinaryNotFound(err)) {
73
+ state.status = 'permanently-failed'
74
+ state.detail = MISSING_BINARY_DETAIL
75
+ return
76
+ }
77
+ throw err
78
+ }
68
79
  proc = spawned
69
80
 
70
81
  void pumpStderr(spawned.stderr, logs, (line) => {
@@ -0,0 +1,11 @@
1
+ // Bun.spawn throws synchronously with `code === 'ENOENT'` when the binary is
2
+ // absent from $PATH. Both Cloudflare providers translate that into a
3
+ // permanently-failed state instead of letting the raw spawn error bubble up
4
+ // as a generic "start failed" log — the actionable fix is enabling
5
+ // docker.file.cloudflared and rebuilding, not waiting through restart backoff.
6
+ export const MISSING_BINARY_DETAIL =
7
+ 'cloudflared binary not found in image; set docker.file.cloudflared: true in typeclaw.json and run typeclaw restart'
8
+
9
+ export function isBinaryNotFound(err: unknown): boolean {
10
+ return err instanceof Error && 'code' in err && (err as { code?: unknown }).code === 'ENOENT'
11
+ }
@@ -1121,8 +1121,8 @@
1121
1121
  "gh": true,
1122
1122
  "python": true,
1123
1123
  "tmux": true,
1124
- "cjkFonts": true,
1125
- "cloudflared": true,
1124
+ "cjkFonts": "auto",
1125
+ "cloudflared": false,
1126
1126
  "xvfb": true,
1127
1127
  "claudeCode": false,
1128
1128
  "codexCli": false,
@@ -1137,8 +1137,8 @@
1137
1137
  "gh": true,
1138
1138
  "python": true,
1139
1139
  "tmux": true,
1140
- "cjkFonts": true,
1141
- "cloudflared": true,
1140
+ "cjkFonts": "auto",
1141
+ "cloudflared": false,
1142
1142
  "xvfb": true,
1143
1143
  "claudeCode": false,
1144
1144
  "codexCli": false,
@@ -1187,11 +1187,19 @@
1187
1187
  ]
1188
1188
  },
1189
1189
  "cjkFonts": {
1190
- "default": true,
1191
- "type": "boolean"
1190
+ "default": "auto",
1191
+ "anyOf": [
1192
+ {
1193
+ "type": "boolean"
1194
+ },
1195
+ {
1196
+ "type": "string",
1197
+ "const": "auto"
1198
+ }
1199
+ ]
1192
1200
  },
1193
1201
  "cloudflared": {
1194
- "default": true,
1202
+ "default": false,
1195
1203
  "type": "boolean"
1196
1204
  },
1197
1205
  "xvfb": {