typeclaw 0.28.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -13,7 +13,39 @@ import type { AgentSession } from './index'
13
13
  // not by this helper.
14
14
 
15
15
  export type DetectedProviderError = {
16
+ // Raw provider text. Safe for logs and operator-only surfaces (TUI,
17
+ // `typeclaw logs`), but NOT for channels — see `safeMessage`.
16
18
  message: string
19
+ // Redacted, user-facing variant for public/multi-user channels. Known-safe
20
+ // operational classes (rate/usage limit, billing/quota) map to a canonical
21
+ // sentence; everything else (malformed-response SDK dumps, unknown failures)
22
+ // collapses to a generic notice so provider response bodies, URLs, or tokens
23
+ // can never leak to a channel.
24
+ safeMessage: string
25
+ }
26
+
27
+ const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can check `typeclaw logs` for details.'
28
+
29
+ // Each entry pairs a narrow matcher against the raw provider text with the
30
+ // canonical, leak-free sentence shown in channels. Matchers are intentionally
31
+ // specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
32
+ // text, so adding a new class is opt-in and never widens what we expose.
33
+ const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
34
+ {
35
+ match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
36
+ safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
37
+ },
38
+ {
39
+ match: /\b(billing|quota|insufficient.*(credit|fund|balance)|payment|account is not active)\b/i,
40
+ safe: 'The upstream LLM provider rejected the request for a billing/quota reason. Operators can check `typeclaw logs` for details.',
41
+ },
42
+ ]
43
+
44
+ function toSafeMessage(raw: string): string {
45
+ for (const { match, safe } of SAFE_CLASSES) {
46
+ if (match.test(raw)) return safe
47
+ }
48
+ return GENERIC_SAFE_NOTICE
17
49
  }
18
50
 
19
51
  export function detectProviderError(message: unknown): DetectedProviderError | null {
@@ -25,7 +57,7 @@ export function detectProviderError(message: unknown): DetectedProviderError | n
25
57
  // ignore aborts (no surface to render them on).
26
58
  if (m.stopReason !== 'error') return null
27
59
  const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
28
- return { message: text }
60
+ return { message: text, safeMessage: toSafeMessage(text) }
29
61
  }
30
62
 
31
63
  export type ProviderErrorListener = (error: DetectedProviderError) => void
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
4
  import { checkFalseReceipt } from '@/channels/github-false-receipt'
5
+ import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
5
6
  import {
6
7
  containsKimiToolDelimiter,
7
8
  isNoReplySignal,
@@ -159,6 +160,27 @@ export function createChannelReplyTool({
159
160
  }
160
161
  const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
161
162
 
163
+ // Re-review stranding guard: block a thread close-out / verdict ack while
164
+ // the bot still holds its own CHANGES_REQUESTED on this PR, so it can't
165
+ // silently leave the PR blocked (PR #644). Runs before the resolve so a
166
+ // blocked close-out never mutates the thread.
167
+ const rereview = await evaluateRereviewGuard({
168
+ adapter: origin.adapter,
169
+ workspace: origin.workspace,
170
+ chat: origin.chat,
171
+ thread: origin.thread,
172
+ text,
173
+ wantsResolve: params.resolve_review_thread === true,
174
+ getReviewState: (req) => router.getReviewState(req),
175
+ })
176
+ if (rereview.block) {
177
+ logger.warn(formatChannelToolFailure('channel_reply', rereview.reason))
178
+ return {
179
+ content: [{ type: 'text' as const, text: `channel_reply denied: ${rereview.reason}` }],
180
+ details: { ok: false, error: rereview.reason },
181
+ }
182
+ }
183
+
162
184
  // Resolve BEFORE posting: a successful channel_reply ends the turn, so a
163
185
  // resolve attempted "after" the ack would never run (the exact bug this
164
186
  // flag fixes). Resolve-failure blocks the reply so the agent never posts
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
4
  import { checkFalseReceipt } from '@/channels/github-false-receipt'
5
+ import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
5
6
  import { recordResolvedThread } from '@/channels/github-review-turn-ledger'
6
7
  import {
7
8
  containsKimiToolDelimiter,
@@ -176,6 +177,26 @@ export function createChannelSendTool({
176
177
  }
177
178
  const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
178
179
 
180
+ // Re-review stranding guard (mirrors channel_reply): block a thread
181
+ // close-out / verdict ack while the bot still holds its own
182
+ // CHANGES_REQUESTED on this PR, before the resolve mutates the thread.
183
+ const rereview = await evaluateRereviewGuard({
184
+ adapter,
185
+ workspace: params.workspace,
186
+ chat: params.chat,
187
+ thread: params.thread ?? null,
188
+ text: bodyText,
189
+ wantsResolve,
190
+ getReviewState: (req) => router.getReviewState(req),
191
+ })
192
+ if (rereview.block) {
193
+ logger.warn(formatChannelToolFailure('channel_send', rereview.reason))
194
+ return {
195
+ content: [{ type: 'text' as const, text: `channel_send denied: ${rereview.reason}` }],
196
+ details: { ok: false, error: rereview.reason },
197
+ }
198
+ }
199
+
179
200
  // Resolve BEFORE posting (mirrors channel_reply): a failed resolve must
180
201
  // block the acknowledgement so the bot never posts "addressed — resolving"
181
202
  // next to a still-open thread. The router enforces that only the bot's own
@@ -21,6 +21,7 @@ import {
21
21
  parseListHooksPermissionStatus,
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
+ import { createGithubReviewStateResolver } from './review-state'
24
25
  import { createGithubReviewThreadResolver } from './review-thread-resolver'
25
26
  import { createTeamMembershipChecker } from './team-membership'
26
27
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
@@ -143,6 +144,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
143
144
  selfLogin: () => selfLogin,
144
145
  fetchImpl,
145
146
  })
147
+ const reviewStateResolver = createGithubReviewStateResolver({
148
+ token: authToken,
149
+ selfLogin: () => selfLogin,
150
+ approve: () => options.configRef().review.approve,
151
+ fetchImpl,
152
+ })
146
153
  const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
147
154
  // GitHub addresses by `@login`, not the numeric id, so `username` carries
148
155
  // the login the model should type; the id is kept for completeness.
@@ -195,6 +202,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
195
202
  options.router.registerChannelNameResolver('github', channelNameResolver)
196
203
  options.router.registerSelfIdentity('github', selfIdentityResolver)
197
204
  options.router.registerReviewThreadResolver('github', reviewThreadResolver)
205
+ options.router.registerReviewStateResolver('github', reviewStateResolver)
198
206
  options.router.registerFetchAttachment('github', fetchAttachment)
199
207
  try {
200
208
  server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
@@ -210,6 +218,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
210
218
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
211
219
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
212
220
  options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
221
+ options.router.unregisterReviewStateResolver('github', reviewStateResolver)
213
222
  options.router.unregisterFetchAttachment('github', fetchAttachment)
214
223
  await auth.dispose()
215
224
  delete process.env.GH_TOKEN
@@ -334,6 +343,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
334
343
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
335
344
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
336
345
  options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
346
+ options.router.unregisterReviewStateResolver('github', reviewStateResolver)
337
347
  options.router.unregisterFetchAttachment('github', fetchAttachment)
338
348
  await server?.stop()
339
349
  // Detach hooks AFTER closing the listener so any in-flight deliveries
@@ -0,0 +1,137 @@
1
+ import type { ReviewStateResolver, ReviewStateResult } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ // Answers the re-review stranding guard's question: is the bot's latest
7
+ // EFFECTIVE formal review on this PR a sticky CHANGES_REQUESTED? GitHub clears a
8
+ // same-reviewer CHANGES_REQUESTED only with a later APPROVED or DISMISSED from
9
+ // the same reviewer — a later COMMENTED review does NOT clear it (the PR #644
10
+ // trap). So we walk the bot's own reviews in chronological order, ignore
11
+ // COMMENTED/PENDING, and read the last decisive one.
12
+ export function createGithubReviewStateResolver(deps: {
13
+ token: (context?: GithubAuthContext) => Promise<string>
14
+ selfLogin: () => string | null
15
+ approve: () => boolean
16
+ fetchImpl?: typeof fetch
17
+ }): ReviewStateResolver {
18
+ const fetchImpl = deps.fetchImpl ?? fetch
19
+ return async (req): Promise<ReviewStateResult> => {
20
+ const approve = deps.approve()
21
+ if (req.adapter !== 'github') {
22
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
23
+ }
24
+ const target = parseTarget(req.workspace, req.chat)
25
+ if (target === null) {
26
+ return { ok: false, error: `unparseable github PR target (chat=${req.chat})`, code: 'transient' }
27
+ }
28
+ const selfLogin = deps.selfLogin()
29
+ if (selfLogin === null) {
30
+ return { ok: false, error: 'github self-identity not resolved; cannot read review state', code: 'transient' }
31
+ }
32
+
33
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
34
+ const reviews = await fetchSelfReviews(fetchImpl, token, target, selfLogin)
35
+ if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
36
+
37
+ const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
38
+ return { ok: true, selfBlocking: lastDecisive === 'CHANGES_REQUESTED', approve }
39
+ }
40
+ }
41
+
42
+ type Target = { owner: string; repo: string; prNumber: number }
43
+
44
+ function parseTarget(workspace: string, chat: string): Target | null {
45
+ const [owner, repo, ...rest] = workspace.split('/')
46
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
47
+ const m = /^pr:(\d+)$/.exec(chat)
48
+ if (m === null) return null
49
+ const prNumber = Number(m[1])
50
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return null
51
+ return { owner, repo, prNumber }
52
+ }
53
+
54
+ type SelfReviewsResult =
55
+ | { ok: true; states: string[] }
56
+ | { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
57
+
58
+ async function fetchSelfReviews(
59
+ fetchImpl: typeof fetch,
60
+ token: string,
61
+ target: Target,
62
+ selfLogin: string,
63
+ ): Promise<SelfReviewsResult> {
64
+ const states: string[] = []
65
+ let url: string | null =
66
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${target.prNumber}/reviews?per_page=100`
67
+ while (url !== null) {
68
+ let response: Response
69
+ try {
70
+ response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
71
+ } catch (err) {
72
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
73
+ }
74
+ if (!response.ok) {
75
+ const text = await response.text().catch(() => '')
76
+ return {
77
+ ok: false,
78
+ error: `GitHub reviews ${response.status}${text !== '' ? `: ${text}` : ''}`,
79
+ code: classifyStatus(response.status),
80
+ }
81
+ }
82
+ const page = (await response.json().catch(() => null)) as ReviewRow[] | null
83
+ if (page === null) return { ok: false, error: 'GitHub reviews returned non-JSON', code: 'transient' }
84
+ for (const row of page) {
85
+ if (typeof row.state !== 'string') continue
86
+ const login = row.user?.login ?? null
87
+ if (login === null) continue
88
+ const isBot = row.user?.type === 'Bot'
89
+ if (!isSelfReviewer(login, isBot, selfLogin)) continue
90
+ states.push(row.state)
91
+ }
92
+ url = nextLink(response.headers.get('link'))
93
+ }
94
+ return { ok: true, states }
95
+ }
96
+
97
+ // A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
98
+ // these three states decide the block. COMMENTED and PENDING are non-deciding
99
+ // noise that must NOT shadow an earlier CHANGES_REQUESTED.
100
+ const DECISIVE = new Set(['CHANGES_REQUESTED', 'APPROVED', 'DISMISSED'])
101
+
102
+ function isDecisive(state: string): boolean {
103
+ return DECISIVE.has(state)
104
+ }
105
+
106
+ // A GitHub App's own login differs across REST (`slug[bot]`) and GraphQL (bare
107
+ // `slug`). The REST reviews endpoint returns `slug[bot]` for the App, but the
108
+ // suffix-strip must be gated on the reviewer actually being a Bot: a human User
109
+ // can own the bare slug as a login, and stripping `[bot]` off the App's
110
+ // selfLogin to match a human would wrongly attribute their review to the bot.
111
+ const BOT_LOGIN_SUFFIX = '[bot]'
112
+
113
+ function isSelfReviewer(login: string, isBot: boolean, selfLogin: string): boolean {
114
+ if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
115
+ return login === selfLogin
116
+ }
117
+
118
+ function normalizeBotLogin(login: string): string {
119
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
120
+ }
121
+
122
+ function nextLink(linkHeader: string | null): string | null {
123
+ if (linkHeader === null) return null
124
+ for (const part of linkHeader.split(',')) {
125
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
126
+ if (m !== null) return m[1] ?? null
127
+ }
128
+ return null
129
+ }
130
+
131
+ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'transient' {
132
+ if (status === 401 || status === 403) return 'permission-denied'
133
+ if (status === 404) return 'not-found'
134
+ return 'transient'
135
+ }
136
+
137
+ type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }
@@ -0,0 +1,76 @@
1
+ import { classifyReviewClaim } from './github-review-claim'
2
+ import type { ReviewStateResult } from './types'
3
+
4
+ // The re-review stranding guard. A bot that resolves a review thread (or posts a
5
+ // close-out ack) while it still holds its own sticky CHANGES_REQUESTED leaves the
6
+ // PR blocked forever — the resolve/ack carries no review state, so GitHub's
7
+ // reviewDecision never clears (PR #644). This guard blocks that close-out and
8
+ // tells the model to land a formal APPROVE / dismissal first.
9
+ //
10
+ // It is the same enforcement seam as the false-receipt guard and the
11
+ // resolve-thread author check: BLOCK and instruct, never act on the model's
12
+ // behalf — the runtime cannot prove a semantic approval from "one thread closed".
13
+
14
+ export type RereviewGuardInput = {
15
+ adapter: string
16
+ chat: string
17
+ thread: string | null
18
+ text: string | undefined
19
+ wantsResolve: boolean
20
+ getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
21
+ workspace: string
22
+ }
23
+
24
+ export type RereviewGuardDecision = { block: false } | { block: true; reason: string }
25
+
26
+ const ALLOW: RereviewGuardDecision = { block: false }
27
+
28
+ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<RereviewGuardDecision> {
29
+ if (input.adapter !== 'github') return ALLOW
30
+ if (!/^pr:\d+$/.test(input.chat)) return ALLOW
31
+ // No `thread === null` exemption: a top-level PR comment carries no thread but
32
+ // a close-out ack in it ("Verified — that closes it") strands the block just
33
+ // as a thread reply would. Only the resolve ACTION needs a thread; the
34
+ // text-claim path fires regardless (caught by isCloseoutAttempt below).
35
+ if (!isCloseoutAttempt(input.wantsResolve, input.thread, input.text)) return ALLOW
36
+
37
+ const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
38
+
39
+ // Fail closed: an unverifiable review state is treated as a live block, so the
40
+ // bot never strands a re-review on a transient API failure.
41
+ if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
42
+ if (!state.selfBlocking) return ALLOW
43
+
44
+ return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
45
+ }
46
+
47
+ // Trigger when the model asks to resolve a thread (only meaningful with a
48
+ // thread), OR when its reply reads as a close-out/verdict claim — the latter
49
+ // strands the block whether or not it sits in a thread, so it fires for any PR
50
+ // chat. Plain discussion replies (ignore/warn) do not fire.
51
+ function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
52
+ if (wantsResolve && thread !== null) return true
53
+ const claim = classifyReviewClaim(text ?? '')
54
+ return claim === 'block-resolve' || claim === 'block-approve'
55
+ }
56
+
57
+ function unverifiableReason(error: string): string {
58
+ return (
59
+ 'Could not verify whether your prior CHANGES_REQUESTED on this PR is still live ' +
60
+ `(${error}). Refusing to close out the thread while the block state is unknown — ` +
61
+ 'retry once the GitHub API is reachable, or land a formal review verdict first.'
62
+ )
63
+ }
64
+
65
+ const STICKY_BLOCK_APPROVE_ENABLED =
66
+ 'You still hold a CHANGES_REQUESTED on this PR. Resolving the thread (or posting a close-out ack) ' +
67
+ 'does NOT clear it — only a fresh formal review does. Submit `APPROVE` via ' +
68
+ '`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: APPROVE) if the blockers are fixed, ' +
69
+ 'or `REQUEST_CHANGES` if not, THEN resolve the thread / reply.'
70
+
71
+ const STICKY_BLOCK_APPROVE_DISABLED =
72
+ 'You still hold a CHANGES_REQUESTED on this PR and resolving the thread does NOT clear it. ' +
73
+ 'Approval is disabled for this agent (channels.github.review.approve: false), so you cannot APPROVE — ' +
74
+ 'dismiss your prior review via ' +
75
+ '`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
76
+ 'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
@@ -24,15 +24,16 @@ const BLOCK_REQUEST_CHANGES: readonly RegExp[] = [
24
24
  /\bthis is blocked\b/,
25
25
  ]
26
26
 
27
- // Only consulted by the caller when thread!=null (a review thread). Bare
28
- // "resolved" is intentionally NOT here it collides with the warn-tier "looks
29
- // resolved?"; resolve claims must carry a definite marker (marked/that/this/
30
- // thanks) or a verify clause.
27
+ // Bare "resolved" is intentionally NOT here it collides with the warn-tier
28
+ // "looks resolved?"; resolve claims must carry a definite marker (marked/that/
29
+ // this/thanks) or a verify clause. "that/this closes it" is the canonical PR
30
+ // #644 incident phrasing and must classify as a close-out claim.
31
31
  const BLOCK_RESOLVE: readonly RegExp[] = [
32
32
  /\bmarked resolved\b/,
33
33
  /\bthread resolved\b/,
34
34
  /\bthat resolves it\b/,
35
35
  /\bthis resolves it\b/,
36
+ /\b(that|this) closes it\b/,
36
37
  /\bclosing this out\b/,
37
38
  /\bconfirmed fixed\b/,
38
39
  // verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
@@ -75,6 +75,9 @@ import type {
75
75
  ReactionRequest,
76
76
  ReactionResult,
77
77
  ResolvedChannelNames,
78
+ ReviewStateRequest,
79
+ ReviewStateResolver,
80
+ ReviewStateResult,
78
81
  ReviewThreadResolveRequest,
79
82
  ReviewThreadResolveResult,
80
83
  ReviewThreadResolver,
@@ -178,6 +181,44 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
178
181
  // including reasoning). Deliberately NOT lowered in `providers.ts`, where
179
182
  // `maxTokens` is the model's true capability that compaction math reads.
180
183
  export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
184
+ // Ceiling on automatic re-prompts for a turn that ended with NO user-facing
185
+ // reply AND no attempted send — the pure "the model burned its budget thinking
186
+ // and produced nothing" failure. The canonical trigger is Fireworks'
187
+ // kimi-k2p6-turbo spiraling into a long reasoning loop on an ambiguous request
188
+ // until it hits CHANNEL_MAX_OUTPUT_TOKENS (`stopReason: 'length'`); the same
189
+ // path also catches a provider/router `aborted` leaf that left no recoverable
190
+ // prose. Each retry injects EMPTY_TURN_RETRY_NUDGE as a reminder-only turn (no
191
+ // new inbound) so `drain()` re-runs `session.prompt()` against the same branch.
192
+ // Bounded because a genuinely stuck model would otherwise re-loop forever; on
193
+ // exhaustion the user gets EMPTY_TURN_FALLBACK_TEXT instead of dead air. Reset
194
+ // at turn start alongside `turnSeq`. Deliberately NOT applied to turns that
195
+ // ATTEMPTED a send this turn (skip-locked or policy-denied) — those already
196
+ // thrashed the send path, so a re-prompt would just re-thrash; they skip
197
+ // straight to the fallback. See validateChannelTurn's candidate===null branch.
198
+ export const MAX_EMPTY_TURN_RETRIES = 2
199
+ // Reminder-only nudge injected before an empty-turn retry. Uses the repo's
200
+ // SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
201
+ // reply to the notice itself. Neutral by design: it asks for a direct reply
202
+ // without prescribing length or tone, matching the chosen "just retry" posture.
203
+ export const EMPTY_TURN_RETRY_NUDGE = [
204
+ '---',
205
+ '**[SYSTEM MESSAGE — not from a human]**',
206
+ '',
207
+ 'Your previous turn ended without sending any reply to the channel. This is',
208
+ 'an automated signal from the channel router, not a message from anyone in',
209
+ 'the chat. **Do not acknowledge or reply to this notice itself.**',
210
+ '',
211
+ 'Respond to the last user message now with a direct answer via your channel',
212
+ 'reply tool. If you genuinely have nothing to say, reply with `NO_REPLY`.',
213
+ '',
214
+ '---',
215
+ ].join('\n')
216
+ // Posted to the channel (via the `source:'system'` one-shot bypass) when an
217
+ // empty turn cannot be recovered AND retries are exhausted (or are skipped
218
+ // because the turn thrashed the send path). Replaces the historical silent
219
+ // drop so the human is never left staring at dead air after a degenerate turn.
220
+ export const EMPTY_TURN_FALLBACK_TEXT =
221
+ "⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
181
222
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
182
223
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
183
224
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -481,6 +522,14 @@ type LiveSession = {
481
522
  // livelock the byte-identical loop-guard misses. Reset at turn start and
482
523
  // cleared per-target on a successful delivery to that target.
483
524
  policyDeniedToolSendsThisTurn: Map<string, number>
525
+ // Count of automatic empty-turn re-prompts already spent on the CURRENT
526
+ // logical turn, bounded by `MAX_EMPTY_TURN_RETRIES`. A "logical turn" spans
527
+ // the original user batch plus any router-injected retry nudges, so this is
528
+ // reset only when a real user/reminder batch starts a fresh turn — NOT on the
529
+ // reminder-only iterations the retry itself queues. `validateChannelTurn`
530
+ // increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
531
+ // retry-vs-fallback. See the candidate===null branch.
532
+ emptyTurnRetries: number
484
533
  // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
485
534
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
486
535
  // if it matches the just-completed turn, recovery is skipped entirely
@@ -635,6 +684,12 @@ export type ChannelRouter = {
635
684
  registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
636
685
  unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
637
686
  resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
687
+ // Re-review stranding guard support: answers whether the bot still holds a
688
+ // blocking CHANGES_REQUESTED on a PR. Opt-in per adapter like the thread
689
+ // resolver; `getReviewState` answers `unsupported` when none is registered.
690
+ registerReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
691
+ unregisterReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
692
+ getReviewState: (req: ReviewStateRequest) => Promise<ReviewStateResult>
638
693
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
639
694
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
640
695
  // Execute a command by name against an existing live session, bypassing
@@ -905,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
905
960
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
906
961
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
907
962
  const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
963
+ const reviewStateResolvers = new Map<ChannelKey['adapter'], ReviewStateResolver>()
908
964
  const stickyLedger = new StickyLedger()
909
965
  // The /help handler reads the live registry to enumerate commands, so it
910
966
  // forward-references `commands`. Safe at runtime — the handler only runs on
@@ -1352,6 +1408,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1352
1408
  successfulSendsAtTurnStart: 0,
1353
1409
  inFlightToolSends: new Map(),
1354
1410
  policyDeniedToolSendsThisTurn: new Map(),
1411
+ emptyTurnRetries: 0,
1355
1412
  skippedTurn: null,
1356
1413
  skipLockedSendTurn: null,
1357
1414
  pendingQuoteCandidate: null,
@@ -1367,6 +1424,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1367
1424
  }
1368
1425
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1369
1426
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1427
+ // A provider soft-error (rate/usage limit, billing, malformed response)
1428
+ // ends the turn with no assistant text, so the human otherwise sees
1429
+ // silence. Surface the REDACTED `safeMessage` (never the raw provider
1430
+ // text, which can carry response bodies / URLs / tokens) via a 'system'
1431
+ // send — the same one-shot bypass path validateChannelTurn uses, so it
1432
+ // lands regardless of per-turn send caps and skips the duplicate guard.
1433
+ void send(
1434
+ {
1435
+ adapter: live.key.adapter,
1436
+ workspace: live.key.workspace,
1437
+ chat: live.key.chat,
1438
+ thread: live.key.thread,
1439
+ text: `⚠️ ${err.safeMessage}`,
1440
+ },
1441
+ { source: 'system' },
1442
+ )
1443
+ .then((result) => {
1444
+ if (!result.ok) {
1445
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
1446
+ }
1447
+ })
1448
+ .catch((sendErr) => {
1449
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
1450
+ })
1370
1451
  })
1371
1452
  live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1372
1453
  const usage = extractTurnUsage(event)
@@ -1796,6 +1877,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1796
1877
  live.consecutiveSends.clear()
1797
1878
  live.lastSentText.clear()
1798
1879
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1880
+ // A real user batch starts a fresh logical turn → restore the full
1881
+ // empty-turn retry budget. Reset here (batch.length > 0) and NOT in
1882
+ // the per-prompt block below, so the reminder-only iterations the
1883
+ // retry itself queues do not refill the budget and loop forever.
1884
+ live.emptyTurnRetries = 0
1799
1885
  } else if (live.lastTurnAuthorId !== null) {
1800
1886
  live.currentTurnEngageReactions = []
1801
1887
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
@@ -2574,6 +2660,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2574
2660
  )
2575
2661
  }
2576
2662
 
2663
+ const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2664
+ reviewStateResolvers.set(adapter, resolver)
2665
+ }
2666
+
2667
+ const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2668
+ if (reviewStateResolvers.get(adapter) === resolver) {
2669
+ reviewStateResolvers.delete(adapter)
2670
+ }
2671
+ }
2672
+
2673
+ const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
2674
+ const resolver = reviewStateResolvers.get(req.adapter)
2675
+ if (resolver === undefined) {
2676
+ return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
2677
+ }
2678
+ return await resolver(req).catch(
2679
+ (err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
2680
+ )
2681
+ }
2682
+
2577
2683
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2578
2684
  const live = liveSessions.get(channelKeyId(args))
2579
2685
  if (live === undefined) return null
@@ -2844,15 +2950,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2844
2950
  }
2845
2951
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2846
2952
 
2953
+ const postEmptyTurnFallback = async (cause: string): Promise<void> => {
2954
+ logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
2955
+ const result = await send(
2956
+ {
2957
+ adapter: live.key.adapter,
2958
+ workspace: live.key.workspace,
2959
+ chat: live.key.chat,
2960
+ thread: live.key.thread,
2961
+ text: EMPTY_TURN_FALLBACK_TEXT,
2962
+ },
2963
+ { source: 'system' },
2964
+ )
2965
+ if (!result.ok) {
2966
+ logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
2967
+ }
2968
+ }
2969
+
2847
2970
  const candidate = recoverableAssistantText(live.session)
2848
2971
  if (candidate === null) {
2849
- // Observability: previously a silent bail-out. The most common cause is a
2850
- // turn that ends mid-loop with NO assistant message at all (leaf is a
2851
- // session header / model_change / similar non-message entry, or a session
2852
- // that just started). Logged at debug-level info so operators can grep for
2853
- // unexpected silent turns; not warn-level because legitimate empty-state
2854
- // sessions hit this on every TUI-only check before the first user prompt.
2855
- logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
2972
+ // No recoverable assistant prose: the turn ended with no usable reply.
2973
+ // Two distinct shapes, handled differently (Option B):
2974
+ //
2975
+ // 1. The model THRASHED the send path this turn it tried to send but
2976
+ // every attempt was denied (skip-locked, or policy-denied/duplicate/
2977
+ // cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
2978
+ // Re-prompting would just re-thrash, so skip retry and post the
2979
+ // user-facing fallback once.
2980
+ //
2981
+ // 2. The PURE reasoning-loop — no send was ever attempted; the model
2982
+ // burned its budget thinking and produced nothing (the canonical
2983
+ // kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
2984
+ // to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
2985
+ // back. The nudge is injected as a reminder-only turn so drain()'s
2986
+ // while-loop re-runs session.prompt() against the same branch.
2987
+ //
2988
+ // The legitimate empty-state case (a TUI-only check before any user
2989
+ // prompt, no inbound this turn) is excluded: no batch means no real turn
2990
+ // to retry or apologize for — keep the historical silent bail there.
2991
+ const attemptedSendThisTurn =
2992
+ live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
2993
+
2994
+ // Only a TRUNCATED assistant leaf (length/error/aborted) from a real
2995
+ // conversational turn is a degeneration worth retrying. A cold/empty turn
2996
+ // (no inbound author, or no assistant message at all) keeps the historical
2997
+ // silent bail — re-prompting it would manufacture replies to nothing.
2998
+ if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
2999
+ logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
3000
+ return
3001
+ }
3002
+ if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3003
+ live.emptyTurnRetries++
3004
+ logger.warn(
3005
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
3006
+ )
3007
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3008
+ return
3009
+ }
3010
+ await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
2856
3011
  return
2857
3012
  }
2858
3013
 
@@ -3398,6 +3553,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3398
3553
  registerReviewThreadResolver,
3399
3554
  unregisterReviewThreadResolver,
3400
3555
  resolveReviewThread,
3556
+ registerReviewStateResolver,
3557
+ unregisterReviewStateResolver,
3558
+ getReviewState,
3401
3559
  lookupInboundAttachment,
3402
3560
  listInboundAttachmentIds,
3403
3561
  executeCommand,
@@ -4109,6 +4267,20 @@ function recoverableAssistantText(
4109
4267
  return null
4110
4268
  }
4111
4269
 
4270
+ // True only when the leaf is an assistant message that was CUT OFF mid-output:
4271
+ // `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
4272
+ // `aborted`. This is the precise signature of "the model was producing but got
4273
+ // truncated", as distinct from a turn that produced no assistant message at all
4274
+ // (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
4275
+ // NOT something to re-prompt. The empty-turn retry guard keys off this so it
4276
+ // fires for real degenerations and stays silent for cold sessions.
4277
+ function assistantLeafTruncated(session: AgentSession): boolean {
4278
+ const leaf = session.sessionManager.getLeafEntry()
4279
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
4280
+ const stop = leaf.message.stopReason
4281
+ return stop === 'length' || stop === 'error' || stop === 'aborted'
4282
+ }
4283
+
4112
4284
  function visibleAssistantText(message: AssistantMessage): string {
4113
4285
  return message.content
4114
4286
  .filter((block) => block.type === 'text')
@@ -382,6 +382,37 @@ export type ReviewThreadResolveResult =
382
382
  // support review threads never register one; the router answers `unsupported`.
383
383
  export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
384
384
 
385
+ // A query for "does the bot still owe this PR a verdict?" — i.e. is the bot's
386
+ // latest formal review on the PR a sticky CHANGES_REQUESTED that no later
387
+ // APPROVE/dismissal has cleared. Used by the re-review stranding guard to stop
388
+ // the bot from resolving a thread / posting a close-out ack while it still
389
+ // holds a blocking review (the PR #644 failure: thread resolved + chat ack, but
390
+ // reviewDecision stuck at CHANGES_REQUESTED because neither carries review
391
+ // state). `workspace` is the repo slug `owner/name`; `chat` is `pr:<N>`.
392
+ export type ReviewStateRequest = {
393
+ adapter: AdapterId
394
+ workspace: string
395
+ chat: string
396
+ }
397
+
398
+ // `selfBlocking` is the answer the guard acts on: true means the bot's latest
399
+ // effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
400
+ // ignored — they never clear the sticky block, GitHub's own rule). `approve`
401
+ // mirrors `channels.github.review.approve` so the guard's denial text can tell
402
+ // the model whether to land a fresh APPROVE or to DISMISS its prior review.
403
+ //
404
+ // On `ok: false` the caller MUST fail closed: an unverifiable review state is
405
+ // treated like a live block, so the bot never claims close-out when the runtime
406
+ // could not confirm the platform-side verdict.
407
+ export type ReviewStateResult =
408
+ | { ok: true; selfBlocking: boolean; approve: boolean }
409
+ | { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
410
+
411
+ // Registered per-adapter on the ChannelRouter, last-write-wins like the
412
+ // review-thread resolver. Adapters that never register one make `getReviewState`
413
+ // answer `unsupported`.
414
+ export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
415
+
385
416
  export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
386
417
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
387
418
  }
@@ -83,6 +83,7 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
83
83
  // transcripts; render per event once live.
84
84
  let live = false
85
85
  const onEvent = (event: InspectEvent): void => {
86
+ append(new Text(formatEventTime(event.ts), 0, 0))
86
87
  append(componentFor(event))
87
88
  if (live) tui.requestRender()
88
89
  }
@@ -167,6 +168,15 @@ function compact(payload: unknown): string {
167
168
  return s.length > 200 ? `${s.slice(0, 200)}…` : s
168
169
  }
169
170
 
171
+ function formatEventTime(ts: number): string {
172
+ if (ts === 0) return colors.dim('--:--:--')
173
+ const d = new Date(ts)
174
+ const hh = String(d.getHours()).padStart(2, '0')
175
+ const mm = String(d.getMinutes()).padStart(2, '0')
176
+ const ss = String(d.getSeconds()).padStart(2, '0')
177
+ return colors.dim(`${hh}:${mm}:${ss}`)
178
+ }
179
+
170
180
  function header(summary: SessionSummary): string {
171
181
  const id = shortSessionId(summary.sessionId)
172
182
  const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
@@ -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 {
@@ -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 }
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