typeclaw 0.27.0 → 0.28.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 (51) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/provider-error.ts +33 -1
  6. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  7. package/src/agent/tools/channel-react.ts +9 -3
  8. package/src/agent/tools/channel-reply.ts +52 -1
  9. package/src/agent/tools/channel-send.ts +115 -1
  10. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  12. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  14. package/src/bundled-plugins/memory/README.md +3 -21
  15. package/src/bundled-plugins/memory/index.ts +1 -149
  16. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  17. package/src/channels/adapters/github/inbound.ts +103 -0
  18. package/src/channels/adapters/github/index.ts +10 -0
  19. package/src/channels/adapters/github/review-state.ts +137 -0
  20. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  21. package/src/channels/github-false-receipt.ts +87 -0
  22. package/src/channels/github-rereview-guard.ts +76 -0
  23. package/src/channels/github-review-claim.ts +92 -0
  24. package/src/channels/github-review-turn-ledger.ts +71 -0
  25. package/src/channels/persistence.ts +4 -102
  26. package/src/channels/router.ts +181 -7
  27. package/src/channels/schema.ts +20 -5
  28. package/src/channels/types.ts +31 -0
  29. package/src/cli/channel.ts +2 -1
  30. package/src/cli/init.ts +2 -1
  31. package/src/config/config.ts +19 -288
  32. package/src/container/start.ts +0 -2
  33. package/src/cron/index.ts +3 -44
  34. package/src/cron/schema.ts +2 -96
  35. package/src/init/gitignore.ts +1 -2
  36. package/src/inspect/transcript-view.ts +10 -0
  37. package/src/secrets/defaults.ts +1 -18
  38. package/src/secrets/index.ts +0 -2
  39. package/src/secrets/schema.ts +4 -90
  40. package/src/secrets/storage.ts +0 -2
  41. package/src/server/index.ts +11 -5
  42. package/src/shared/protocol.ts +18 -6
  43. package/src/skills/typeclaw-config/SKILL.md +9 -11
  44. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  45. package/src/tui/format.ts +13 -0
  46. package/src/tui/index.ts +21 -7
  47. package/typeclaw.schema.json +1 -0
  48. package/src/agent/tools/normalize-ref.ts +0 -11
  49. package/src/bundled-plugins/memory/migration.ts +0 -633
  50. package/src/secrets/migrate-kakaotalk.ts +0 -82
  51. package/src/secrets/migrate.ts +0 -96
@@ -6,8 +6,6 @@ export { type Secret } from './resolve'
6
6
 
7
7
  export { hydrateChannelEnvFromSecrets } from './hydrate'
8
8
 
9
- export { migrateKakaotalkCredentials } from './migrate-kakaotalk'
10
-
11
9
  export {
12
10
  type ExportCodexAuthFileResult,
13
11
  exportCodexAuthFileForAgent,
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
- import { CHANNEL_ENV_TO_FIELD } from './defaults'
4
- import { secretFieldSchema, type Secret } from './resolve'
3
+ import { secretFieldSchema } from './resolve'
5
4
 
6
5
  // providers.<id> for api-key credentials: the `key` field is a Secret (string
7
6
  // shorthand or `{ value?, env? }` object). resolveSecret turns this into a
@@ -115,9 +114,7 @@ export const channelsSchema = z
115
114
  .catchall(z.unknown())
116
115
 
117
116
  // version 2 = providers.* with Secret-typed api-key.key + per-adapter
118
- // channel field shapes. version 1 = the previous shape (flat `llm.*`, channel
119
- // slots keyed by env-var name). Legacy v1 input is upgraded transparently by
120
- // parseSecretsFile; the first write persists v2.
117
+ // channel field shapes.
121
118
  export const SECRETS_FILE_VERSION = 2
122
119
 
123
120
  export const secretsFileSchema = z.object({
@@ -140,98 +137,15 @@ export type SecretsFile = z.infer<typeof secretsFileSchema>
140
137
 
141
138
  export type ParseSecretsResult = { ok: true; file: SecretsFile } | { ok: false; reason: string }
142
139
 
143
- // parseSecretsFile recognises three shapes, in priority order:
144
- // 1. The v2 envelope (current): { version: 2, providers, channels }
145
- // 2. The v1 envelope (legacy): { version: 1, llm, channels } where channel
146
- // slots are keyed by env-var name. Both `llm` and `channels` get
147
- // reshaped — llm -> providers, env-keyed channel slots -> field-keyed.
148
- // 3. The pre-envelope flat shape (very legacy): Record<string, AuthCredential>
149
- // at top level. Treated as { version: 2, providers: <flat>, channels: {} }
150
- // so existing OAuth users transparently upgrade.
151
- //
152
- // Every legacy upgrade produces a v2-shaped SecretsFile in memory; the next
153
- // write persists v2 to disk. The legacy branches stay forever as a quiet
154
- // compatibility seam — only the v2 form is documented.
140
+ // parseSecretsFile accepts only the current v2 envelope:
141
+ // { version: 2, providers, channels }.
155
142
  export function parseSecretsFile(raw: unknown): ParseSecretsResult {
156
143
  const v2 = secretsFileSchema.safeParse(raw)
157
144
  if (v2.success) return { ok: true, file: v2.data }
158
145
 
159
- const v1 = legacyV1Schema.safeParse(raw)
160
- if (v1.success) return { ok: true, file: upgradeV1ToV2(v1.data) }
161
-
162
- const flat = legacyFlatProviderSchema.safeParse(raw)
163
- if (flat.success) {
164
- return { ok: true, file: upgradeV1ToV2({ version: 1, llm: flat.data, channels: {} }) }
165
- }
166
-
167
146
  return { ok: false, reason: v2.error.issues.map(formatIssue).join('; ') }
168
147
  }
169
148
 
170
- // Legacy v1 schema: `llm` (flat string-key) and `channels` (env-var-keyed
171
- // flat map per adapter). Used only for upgrade reads; never written.
172
- const legacyV1ApiKeySchema = z.object({
173
- type: z.literal('api_key'),
174
- key: z.string().min(1),
175
- })
176
-
177
- const legacyV1OAuthSchema = z
178
- .object({
179
- type: z.literal('oauth'),
180
- })
181
- .catchall(z.unknown())
182
-
183
- const legacyV1CredentialSchema = z.discriminatedUnion('type', [legacyV1ApiKeySchema, legacyV1OAuthSchema])
184
-
185
- const legacyV1LlmSchema = z.record(z.string(), legacyV1CredentialSchema)
186
-
187
- const legacyV1ChannelsSchema = z.record(z.string(), z.record(z.string(), z.string()))
188
-
189
- const legacyV1Schema = z.object({
190
- $schema: z.string().optional(),
191
- version: z.literal(1),
192
- llm: legacyV1LlmSchema.default({}),
193
- channels: legacyV1ChannelsSchema.default({}),
194
- })
195
-
196
- const legacyFlatProviderSchema = z.record(z.string(), legacyV1CredentialSchema)
197
-
198
- function upgradeV1ToV2(legacy: z.infer<typeof legacyV1Schema>): SecretsFile {
199
- const providers: Providers = {}
200
- for (const [providerId, cred] of Object.entries(legacy.llm)) {
201
- if (cred.type === 'api_key') {
202
- providers[providerId] = { type: 'api_key', key: { value: cred.key } }
203
- } else {
204
- providers[providerId] = cred
205
- }
206
- }
207
-
208
- const channels: Channels = {}
209
- for (const [adapterId, envKeyedSlot] of Object.entries(legacy.channels)) {
210
- const upgradedSlot: Record<string, Secret> = {}
211
- for (const [envKey, value] of Object.entries(envKeyedSlot)) {
212
- const mapping = CHANNEL_ENV_TO_FIELD[envKey]
213
- if (mapping && mapping.adapterId === adapterId) {
214
- upgradedSlot[mapping.fieldName] = { value }
215
- } else {
216
- // Unknown env-var-name key on a known adapter, or an adapter we don't
217
- // recognise: pass through verbatim under the original key. Better to
218
- // preserve user data than drop it; the catchall on channelsSchema
219
- // makes this safe.
220
- upgradedSlot[envKey] = { value }
221
- }
222
- }
223
- channels[adapterId] = upgradedSlot
224
- }
225
-
226
- const result: SecretsFile = {
227
- version: SECRETS_FILE_VERSION,
228
- providers,
229
- channels,
230
- }
231
- if (legacy.$schema !== undefined) result.$schema = legacy.$schema
232
- return result
233
- }
234
-
235
149
  function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
236
150
  const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
237
151
  return `${path}: ${issue.message}`
@@ -9,7 +9,6 @@ import {
9
9
  import lockfile from 'proper-lockfile'
10
10
 
11
11
  import { providerKeyDefaultEnv } from './defaults'
12
- import { migrateLegacyAuthJson } from './migrate'
13
12
  import { resolveSecret, type Secret } from './resolve'
14
13
  import {
15
14
  type Channels,
@@ -375,7 +374,6 @@ export class SecretsBackend implements AuthStorageBackend {
375
374
  }
376
375
 
377
376
  export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
378
- migrateLegacyAuthJson(dirname(secretsPath))
379
377
  return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
380
378
  }
381
379
 
@@ -199,8 +199,18 @@ export function safeWsSend(ws: { send: (data: string) => void }, msg: ServerMess
199
199
  }
200
200
  }
201
201
 
202
+ const TIMESTAMPED_SERVER_MESSAGES: ReadonlySet<ServerMessage['type']> = new Set([
203
+ 'text_delta',
204
+ 'tool_start',
205
+ 'tool_end',
206
+ 'done',
207
+ 'error',
208
+ 'prompt_started',
209
+ ])
210
+
202
211
  function send(ws: Ws, msg: ServerMessage): boolean {
203
- return safeWsSend(ws, msg)
212
+ const stamped = TIMESTAMPED_SERVER_MESSAGES.has(msg.type) ? { ...msg, ts: Date.now() } : msg
213
+ return safeWsSend(ws, stamped)
204
214
  }
205
215
 
206
216
  function sendTunnelLog(ws: TunnelLogsWs, msg: TunnelLogsServerMessage): boolean {
@@ -1181,10 +1191,6 @@ async function handleCronList(
1181
1191
  // jobs from a newer registry.
1182
1192
  const snapshot = pluginRuntime?.get()
1183
1193
  const loadResult = await loadCron(agentDir, {
1184
- // Read-only path: do not rewrite cron.json or commit the
1185
- // migration just because the user (or the agent) asked to see
1186
- // the schedule. Boot/reload still own the persistent migration.
1187
- persistMigrations: false,
1188
1194
  ...(snapshot !== undefined ? { subagents: snapshot.subagents } : {}),
1189
1195
  })
1190
1196
  if (!loadResult.ok) {
@@ -202,22 +202,34 @@ export type ClaimErrorPayload = {
202
202
  reason: string
203
203
  }
204
204
 
205
+ // `ts` (ms since epoch) is the server send time, stamped centrally in `send()`,
206
+ // for the variants the TUI renders into scrollback. Optional on the wire so an
207
+ // old CLI parses a new server's frames; control frames the TUI never timestamps
208
+ // (queue_state, doctor, tunnel, claim, command_*) omit it by design.
205
209
  export type ServerMessage =
206
210
  // serverVersion is optional so an old CLI talking to a new server still
207
211
  // parses cleanly. The server impl always emits it; consumers that care
208
212
  // about host/agent skew (the TUI command in particular) read it to warn
209
213
  // the user when their CLI is on a different version than the container.
210
214
  | { type: 'connected'; sessionId: string; serverVersion?: string }
211
- | { type: 'text_delta'; delta: string }
212
- | { type: 'tool_start'; toolCallId: string; name: string; args: unknown }
213
- | { type: 'tool_end'; toolCallId: string; name: string; error: boolean; result: unknown; durationMs: number }
214
- | { type: 'done' }
215
- | { type: 'error'; message: string }
215
+ | { type: 'text_delta'; delta: string; ts?: number }
216
+ | { type: 'tool_start'; toolCallId: string; name: string; args: unknown; ts?: number }
217
+ | {
218
+ type: 'tool_end'
219
+ toolCallId: string
220
+ name: string
221
+ error: boolean
222
+ result: unknown
223
+ durationMs: number
224
+ ts?: number
225
+ }
226
+ | { type: 'done'; ts?: number }
227
+ | { type: 'error'; message: string; ts?: number }
216
228
  | { type: 'reload_result'; results: ReloadResultPayload[] }
217
229
  | { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
218
230
  | { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
219
231
  | { type: 'queue_state'; pending: QueueStateItem[] }
220
- | { type: 'prompt_started'; messageId: string; text: string }
232
+ | { type: 'prompt_started'; messageId: string; text: string; ts?: number }
221
233
  | { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
222
234
  | { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
223
235
  | { type: 'cron_list_result'; requestId: CronListRequestId; result: CronListResultPayload }
@@ -54,7 +54,7 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
54
54
 
55
55
  > **Top-level keys not in this table are not "ignored unknowns" anymore** — they are reserved for **plugin config blocks**. The schema's `catchall(z.unknown())` preserves them, and the plugin loader hands each block to its owning plugin's `configSchema` for validation. The bundled memory plugin owns `memory` at the top level — see the `typeclaw-memory` skill for that block's semantics. Do not write a top-level key unless you know which plugin owns it.
56
56
 
57
- Within the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are migrated to `docker.file` / `git.ignore` automatically the first time the CLI loads the file — see **Legacy migration** below. Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
57
+ Within the well-known ten (`$schema`, `port`, `models`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are no longer migrated use `docker.file` and `git.ignore` directly (the legacy keys are silently ignored). Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
58
58
 
59
59
  A scaffolded `typeclaw.json` looks like:
60
60
 
@@ -436,7 +436,7 @@ The toggle-driven apt install benefits from BuildKit `--mount=type=cache` on `/v
436
436
 
437
437
  `typeclaw start` rewrites the agent folder's `.gitignore` from a template baked into the typeclaw CLI on **every** invocation, then auto-commits it when the agent folder is a git repo and the file changed. The template protects two categories: truly-ignored paths (`secrets.json`, `.env`, `.env.local`, `auth.json`, `node_modules/`, `workspace/`, `mounts/`, `channels/`, `Dockerfile`, `.DS_Store`) and system-managed runtime state (`sessions/`, `memory/`) that TypeClaw, not the agent, commits on its own schedule. Editing `.gitignore` by hand is temporary; the next `typeclaw start` overwrites it.
438
438
 
439
- The `git.ignore.append` field (introduced when the legacy top-level `gitignore` key was nested under the `git` namespace for future extensibility — see **Legacy migration**) is the supported escape hatch for additional local ignore patterns. It is an array of strings, each treated as a single `.gitignore` line. The CLI splices them into the autogenerated `.gitignore` before TypeClaw's protected rules, prefixed with a `# Custom entries from typeclaw.json#git.ignore.append.` comment.
439
+ The `git.ignore.append` field is the supported escape hatch for additional local ignore patterns. It is an array of strings, each treated as a single `.gitignore` line. The CLI splices them into the autogenerated `.gitignore` before TypeClaw's protected rules, prefixed with a `# Custom entries from typeclaw.json#git.ignore.append.` comment.
440
440
 
441
441
  ### Field
442
442
 
@@ -487,19 +487,17 @@ channels/
487
487
 
488
488
  ## Legacy migration
489
489
 
490
- Pre-namespace `typeclaw.json` files carried `dockerfile` and `gitignore` as top-level keys. The current schema nests them under `docker.file` and `git.ignore` so the `docker` and `git` namespaces stay free for future siblings (`docker.compose`, `git.attributes`, etc.) without a second rename.
490
+ The only migration step that still runs is dropping a seeded `channels.github.eventAllowlist` field if present, it is removed and the file is rewritten with a descriptive commit subject.
491
491
 
492
- The first time `validateConfig` or `loadConfigSync` reads a legacy file:
492
+ All other legacy shapes are no longer migrated:
493
493
 
494
- 1. The in-memory JSON is rewritten: top-level `dockerfile` `docker.file`, top-level `gitignore` `git.ignore`.
495
- 2. The same rewrite is persisted back to `typeclaw.json` on disk (pretty-printed, trailing newline). Best-effort: a read-only filesystem just retries next start.
496
- 3. If the file already has a `docker` or `git` block AND the legacy key, the new shape wins — the legacy duplicate is dropped silently. The new shape would have shadowed the legacy at parse time anyway.
494
+ - **Top-level `dockerfile` and `gitignore` keys** are silently ignored on parse. Use `docker.file` and `git.ignore` directly. If a file still carries these keys, update it by hand.
495
+ - **`channels.<adapter>.allow[]`** is silently ignored on parse and NOT translated to `roles.member.match[]`. Define `roles.member.match[]` directly.
497
496
 
498
497
  What this means for you:
499
498
 
500
- - **Do not write top-level `dockerfile` or `gitignore` keys** when editing `typeclaw.json`. They'll be migrated away on the next CLI invocation; meanwhile the file is briefly in an inconsistent shape.
499
+ - **Do not write top-level `dockerfile` or `gitignore` keys** when editing `typeclaw.json`. They are ignored; the intended fields are `docker.file` and `git.ignore`.
501
500
  - **Old documentation or examples that still mention `typeclaw.json#dockerfile.append` are stale.** The current path is `typeclaw.json#docker.file.append`. Same for `git.ignore.append`.
502
- - **An auto-commit may appear** the next time `typeclaw start` runs against a freshly-migrated agent folder. The diff is mechanical (top-level rename → nested) — surface it to the user as a one-time migration, not a behavior change.
503
501
 
504
502
  ## Plugin config blocks
505
503
 
@@ -550,7 +548,7 @@ Do **not** edit `typeclaw.json` to a model the registry doesn't know, even if th
550
548
  - `slack-bot: { botToken: <Secret>, appToken: <Secret> }`
551
549
  - `telegram-bot: { token: <Secret> }`
552
550
 
553
- (Pre-v2 agent folders carry the older `llm` slice and channel-env-var-keyed shape; they are upgraded transparently on first read. Pre-rename folders may even carry the file as `auth.json`; it is renamed to `secrets.json` on the next boot.)
551
+ (Only the `v2` envelope is accepted. Pre-v2 shapes and `auth.json` are no longer auto-upgraded they are rejected with an error. `auth.json` stays gitignored as a safety net for old folders, but it is not read.)
554
552
 
555
553
  - **`./.env`** (env-var overrides): plain `KEY=value` lines, loaded by Docker via `--env-file` at container start. When set, an env var **wins** over the file value (see resolution rules below). Useful for CI, transient rotations, or any tooling outside typeclaw that reads from the environment. The canonical env-var names per provider:
556
554
  - `OPENAI_API_KEY` — for any `openai/...` model.
@@ -630,7 +628,7 @@ Never echo, log, or commit values from `secrets.json` or `.env`. Both are gitign
630
628
  - If `alias` is set: array of strings, each non-empty after trimming surrounding whitespace
631
629
  - If `channels.<adapter>.engagement.trigger` is set: array of `"mention"`, `"reply"`, `"dm"` (any subset, including empty)
632
630
  - If `channels.<adapter>.engagement.stickiness` is set: either the literal `"off"` or `{ "perReply": { "window": <int 1..86400000> } }`
633
- - `channels.<adapter>.allow` (legacy) is silently dropped on parse; `migrateLegacyConfigShape` lifts it into `roles.member.match` on load. See the `typeclaw-permissions` skill.
631
+ - `channels.<adapter>.allow` (legacy) is silently ignored on parse and NOT translated to `roles.member.match`. Define `roles.member.match[]` directly. See the `typeclaw-permissions` skill.
634
632
  - If `portForward` is set: `allow` is either `"*"` or an array of integers (1–65535); `deny`, if present, is an array of integers and **only valid when `allow` is `"*"`** (the schema rejects `deny` paired with a number-array `allow`)
635
633
  - If `docker.file.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
636
634
  - If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `python`, `cjkFonts`, `cloudflared`, `claudeCode`, and `codexCli` are boolean only
@@ -81,7 +81,7 @@ Things the DSL rejects (the parser emits actionable errors at boot, but you shou
81
81
  - `slack:*/*` — `*/*` is redundant; use `slack:*` for "any Slack chat".
82
82
  - `slack:*/C0ABCDE` — workspace-less chat ID is impossible; pick a workspace.
83
83
  - `slack:T0123/*` — workspace-only is enough; drop the trailing `/*`.
84
- - `team:T0123`, `guild:G123`, `tg:42` — these are legacy prefixes from the old `channels.<adapter>.allow[]` field. They are auto-migrated on load but **don't write them in new code** — use `slack:T0123`, `discord:G123`, `telegram:42` directly.
84
+ - `team:T0123`, `guild:G123`, `tg:42` — these are legacy prefixes that are no longer supported. The parser rejects them with a hint to use the canonical form: `slack:T0123`, `discord:G123`, `telegram:42`.
85
85
  - `autor:U_ME` — typo of `author:`. The parser will suggest the fix at boot.
86
86
 
87
87
  ## Permission strings you will see
package/src/tui/format.ts CHANGED
@@ -24,6 +24,19 @@ export function formatUserPromptHistory(text: string): string {
24
24
  .join('\n')
25
25
  }
26
26
 
27
+ export function formatTimestamp(ts: number | undefined): string {
28
+ if (ts === undefined || ts === 0) return colors.dim('--:--:--')
29
+ const d = new Date(ts)
30
+ const hh = String(d.getHours()).padStart(2, '0')
31
+ const mm = String(d.getMinutes()).padStart(2, '0')
32
+ const ss = String(d.getSeconds()).padStart(2, '0')
33
+ return colors.dim(`${hh}:${mm}:${ss}`)
34
+ }
35
+
36
+ export function withTimestamp(ts: number | undefined, body: string): string {
37
+ return `${formatTimestamp(ts)} ${body}`
38
+ }
39
+
27
40
  function stripHiddenBlocks(text: string): string {
28
41
  return text.replace(/<hatching>[\s\S]*?<\/hatching>\s*/g, '').trimStart()
29
42
  }
package/src/tui/index.ts CHANGED
@@ -3,7 +3,14 @@ import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text
3
3
  import { parseCommand } from '@/commands'
4
4
 
5
5
  import { createClient as createClientDefault, type Client } from './client'
6
- import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
6
+ import {
7
+ formatQueuePanel,
8
+ formatTimestamp,
9
+ formatToolEnd,
10
+ formatToolStart,
11
+ formatUserPromptHistory,
12
+ withTimestamp,
13
+ } from './format'
7
14
  import { colors, editorTheme, markdownTheme } from './theme'
8
15
 
9
16
  export type ClientFactory = (url: string) => Promise<Client>
@@ -173,8 +180,13 @@ export function createTui({
173
180
  onReplyDone = null
174
181
  }
175
182
 
176
- const ensureAssistantBlock = (): Markdown => {
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`.
187
+ const ensureAssistantBlock = (ts: number | undefined): Markdown => {
177
188
  if (currentAssistant) return currentAssistant
189
+ appendHistory(new Text(formatTimestamp(ts), 0, 0))
178
190
  const md = new Markdown('', 0, 0, markdownTheme)
179
191
  currentAssistant = md
180
192
  currentAssistantText = ''
@@ -185,12 +197,12 @@ export function createTui({
185
197
  client.onMessage((msg) => {
186
198
  switch (msg.type) {
187
199
  case 'prompt_started': {
188
- appendHistory(new Text(formatUserPromptHistory(msg.text), 0, 0))
200
+ appendHistory(new Text(withTimestamp(msg.ts, formatUserPromptHistory(msg.text)), 0, 0))
189
201
  tui.requestRender()
190
202
  break
191
203
  }
192
204
  case 'text_delta': {
193
- const block = ensureAssistantBlock()
205
+ const block = ensureAssistantBlock(msg.ts)
194
206
  currentAssistantText += msg.delta
195
207
  block.setText(currentAssistantText)
196
208
  tui.requestRender()
@@ -198,13 +210,15 @@ export function createTui({
198
210
  }
199
211
  case 'tool_start': {
200
212
  sealAssistantBlock()
201
- appendHistory(new Text(formatToolStart(msg.name, msg.args), 0, 0))
213
+ appendHistory(new Text(withTimestamp(msg.ts, formatToolStart(msg.name, msg.args)), 0, 0))
202
214
  tui.requestRender()
203
215
  break
204
216
  }
205
217
  case 'tool_end': {
206
218
  sealAssistantBlock()
207
- appendHistory(new Text(formatToolEnd(msg.name, msg.error, msg.result, msg.durationMs), 0, 0))
219
+ appendHistory(
220
+ new Text(withTimestamp(msg.ts, formatToolEnd(msg.name, msg.error, msg.result, msg.durationMs)), 0, 0),
221
+ )
208
222
  tui.requestRender()
209
223
  break
210
224
  }
@@ -214,7 +228,7 @@ export function createTui({
214
228
  break
215
229
  }
216
230
  case 'error': {
217
- appendHistory(new Text(colors.red(`error: ${msg.message}`), 0, 0))
231
+ appendHistory(new Text(withTimestamp(msg.ts, colors.red(`error: ${msg.message}`)), 0, 0))
218
232
  finishAssistantTurn()
219
233
  tui.requestRender()
220
234
  break
@@ -532,6 +532,7 @@
532
532
  "pull_request.ready_for_review",
533
533
  "pull_request.review_requested",
534
534
  "pull_request.review_request_removed",
535
+ "pull_request.synchronize",
535
536
  "discussion.created",
536
537
  "pull_request_review.submitted"
537
538
  ],
@@ -1,11 +0,0 @@
1
- export function normalizeRef(ref: string): string {
2
- const trimmed = ref.trim()
3
- // New classifiers store bare Slack file ids; legacy persisted refs (and
4
- // anything still hitting the lookup path from older contextBuffer state)
5
- // may carry the old prompt-visible `id=Fxxxx` prefix. Strip it here so
6
- // both attachment-fetching tools route the same ref through the adapter
7
- // callback — without this, `channel_fetch_attachment` would silently
8
- // succeed on a legacy ref while `look_at_channel_attachment` would fail.
9
- if (trimmed.startsWith('id=')) return trimmed.slice(3)
10
- return trimmed
11
- }