typeclaw 0.28.0 → 0.28.2

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.
@@ -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,
@@ -1365,8 +1422,44 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1365
1422
  unsubTypingActivity: null,
1366
1423
  unsubTodoOutcome: null,
1367
1424
  }
1425
+ // Tracks the `turnSeq` a provider-error notice was last POSTED for, so the
1426
+ // channel surfaces at most one notice per turn. The upstream SDK retries
1427
+ // internally, and each retry emits its own `message_end` with
1428
+ // `stopReason: 'error'` — without this gate a single failing turn posts N
1429
+ // identical "⚠️ upstream provider failed" notices (one per retry). Logs
1430
+ // still record every attempt; only the user-facing notice is deduped.
1431
+ let lastProviderErrorNoticeTurn: number | undefined
1368
1432
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1369
1433
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1434
+ // Suppress duplicate notices for the SAME turn (retry storm). Set the
1435
+ // marker BEFORE the async send so a synchronous burst of retry events
1436
+ // can't each slip past the check and enqueue their own notice.
1437
+ if (lastProviderErrorNoticeTurn === live.turnSeq) return
1438
+ lastProviderErrorNoticeTurn = live.turnSeq
1439
+ // A provider soft-error (rate/usage limit, billing, malformed response)
1440
+ // ends the turn with no assistant text, so the human otherwise sees
1441
+ // silence. Surface the REDACTED `safeMessage` (never the raw provider
1442
+ // text, which can carry response bodies / URLs / tokens) via a 'system'
1443
+ // send — the same one-shot bypass path validateChannelTurn uses, so it
1444
+ // lands regardless of per-turn send caps and skips the duplicate guard.
1445
+ void send(
1446
+ {
1447
+ adapter: live.key.adapter,
1448
+ workspace: live.key.workspace,
1449
+ chat: live.key.chat,
1450
+ thread: live.key.thread,
1451
+ text: `⚠️ ${err.safeMessage}`,
1452
+ },
1453
+ { source: 'system' },
1454
+ )
1455
+ .then((result) => {
1456
+ if (!result.ok) {
1457
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
1458
+ }
1459
+ })
1460
+ .catch((sendErr) => {
1461
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
1462
+ })
1370
1463
  })
1371
1464
  live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1372
1465
  const usage = extractTurnUsage(event)
@@ -1796,6 +1889,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1796
1889
  live.consecutiveSends.clear()
1797
1890
  live.lastSentText.clear()
1798
1891
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1892
+ // A real user batch starts a fresh logical turn → restore the full
1893
+ // empty-turn retry budget. Reset here (batch.length > 0) and NOT in
1894
+ // the per-prompt block below, so the reminder-only iterations the
1895
+ // retry itself queues do not refill the budget and loop forever.
1896
+ live.emptyTurnRetries = 0
1799
1897
  } else if (live.lastTurnAuthorId !== null) {
1800
1898
  live.currentTurnEngageReactions = []
1801
1899
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
@@ -2574,6 +2672,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2574
2672
  )
2575
2673
  }
2576
2674
 
2675
+ const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2676
+ reviewStateResolvers.set(adapter, resolver)
2677
+ }
2678
+
2679
+ const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
2680
+ if (reviewStateResolvers.get(adapter) === resolver) {
2681
+ reviewStateResolvers.delete(adapter)
2682
+ }
2683
+ }
2684
+
2685
+ const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
2686
+ const resolver = reviewStateResolvers.get(req.adapter)
2687
+ if (resolver === undefined) {
2688
+ return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
2689
+ }
2690
+ return await resolver(req).catch(
2691
+ (err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
2692
+ )
2693
+ }
2694
+
2577
2695
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2578
2696
  const live = liveSessions.get(channelKeyId(args))
2579
2697
  if (live === undefined) return null
@@ -2844,15 +2962,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2844
2962
  }
2845
2963
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2846
2964
 
2965
+ const postEmptyTurnFallback = async (cause: string): Promise<void> => {
2966
+ logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
2967
+ const result = await send(
2968
+ {
2969
+ adapter: live.key.adapter,
2970
+ workspace: live.key.workspace,
2971
+ chat: live.key.chat,
2972
+ thread: live.key.thread,
2973
+ text: EMPTY_TURN_FALLBACK_TEXT,
2974
+ },
2975
+ { source: 'system' },
2976
+ )
2977
+ if (!result.ok) {
2978
+ logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
2979
+ }
2980
+ }
2981
+
2847
2982
  const candidate = recoverableAssistantText(live.session)
2848
2983
  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`)
2984
+ // No recoverable assistant prose: the turn ended with no usable reply.
2985
+ // Two distinct shapes, handled differently (Option B):
2986
+ //
2987
+ // 1. The model THRASHED the send path this turn it tried to send but
2988
+ // every attempt was denied (skip-locked, or policy-denied/duplicate/
2989
+ // cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
2990
+ // Re-prompting would just re-thrash, so skip retry and post the
2991
+ // user-facing fallback once.
2992
+ //
2993
+ // 2. The PURE reasoning-loop — no send was ever attempted; the model
2994
+ // burned its budget thinking and produced nothing (the canonical
2995
+ // kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
2996
+ // to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
2997
+ // back. The nudge is injected as a reminder-only turn so drain()'s
2998
+ // while-loop re-runs session.prompt() against the same branch.
2999
+ //
3000
+ // The legitimate empty-state case (a TUI-only check before any user
3001
+ // prompt, no inbound this turn) is excluded: no batch means no real turn
3002
+ // to retry or apologize for — keep the historical silent bail there.
3003
+ const attemptedSendThisTurn =
3004
+ live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
3005
+
3006
+ // Only a TRUNCATED assistant leaf (length/error/aborted) from a real
3007
+ // conversational turn is a degeneration worth retrying. A cold/empty turn
3008
+ // (no inbound author, or no assistant message at all) keeps the historical
3009
+ // silent bail — re-prompting it would manufacture replies to nothing.
3010
+ if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
3011
+ logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
3012
+ return
3013
+ }
3014
+ if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3015
+ live.emptyTurnRetries++
3016
+ logger.warn(
3017
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
3018
+ )
3019
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3020
+ return
3021
+ }
3022
+ await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
2856
3023
  return
2857
3024
  }
2858
3025
 
@@ -3398,6 +3565,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3398
3565
  registerReviewThreadResolver,
3399
3566
  unregisterReviewThreadResolver,
3400
3567
  resolveReviewThread,
3568
+ registerReviewStateResolver,
3569
+ unregisterReviewStateResolver,
3570
+ getReviewState,
3401
3571
  lookupInboundAttachment,
3402
3572
  listInboundAttachmentIds,
3403
3573
  executeCommand,
@@ -4109,6 +4279,20 @@ function recoverableAssistantText(
4109
4279
  return null
4110
4280
  }
4111
4281
 
4282
+ // True only when the leaf is an assistant message that was CUT OFF mid-output:
4283
+ // `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
4284
+ // `aborted`. This is the precise signature of "the model was producing but got
4285
+ // truncated", as distinct from a turn that produced no assistant message at all
4286
+ // (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
4287
+ // NOT something to re-prompt. The empty-turn retry guard keys off this so it
4288
+ // fires for real degenerations and stays silent for cold sessions.
4289
+ function assistantLeafTruncated(session: AgentSession): boolean {
4290
+ const leaf = session.sessionManager.getLeafEntry()
4291
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
4292
+ const stop = leaf.message.stopReason
4293
+ return stop === 'length' || stop === 'error' || stop === 'aborted'
4294
+ }
4295
+
4112
4296
  function visibleAssistantText(message: AssistantMessage): string {
4113
4297
  return message.content
4114
4298
  .filter((block) => block.type === 'text')
@@ -192,8 +192,9 @@ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
192
192
  // prefix is implied by this field living under the review config); `off` is the
193
193
  // disable sentinel, matching the `engagement.stickiness: 'off'` convention:
194
194
  // - 'review_requested' — review only when the bot is requested (default)
195
- // - 'opened' — review every non-draft PR as soon as it opens; draft
196
- // PRs are skipped until an explicit review_requested
195
+ // - 'opened' — review every non-draft PR as soon as it opens; a draft
196
+ // PR wakes no session and is reviewed once it turns ready
197
+ // (ready_for_review) or the bot is explicitly requested
197
198
  // - 'off' — disable code review entirely
198
199
  export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
199
200
 
@@ -382,6 +382,42 @@ 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 for re-reviews: true means the
399
+ // bot's latest effective formal review is its own CHANGES_REQUESTED (COMMENTED
400
+ // reviews are ignored — they never clear the sticky block, GitHub's own rule).
401
+ // `reviewDecision` is GitHub's aggregate PR review status when GraphQL can
402
+ // provide it; REVIEW_REQUIRED means an approval-shaped flat comment would still
403
+ // leave the PR awaiting a formal review. `approve` mirrors
404
+ // `channels.github.review.approve` so the guard's denial text can tell the model
405
+ // whether to land a fresh APPROVE or to DISMISS its prior review.
406
+ //
407
+ // On `ok: false` the caller MUST fail closed: an unverifiable review state is
408
+ // treated like a live block, so the bot never claims close-out when the runtime
409
+ // could not confirm the platform-side verdict.
410
+ export type ReviewStateResult =
411
+ | { ok: true; selfBlocking: boolean; approve: boolean; reviewDecision?: GithubReviewDecision }
412
+ | { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
413
+
414
+ export type GithubReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
415
+
416
+ // Registered per-adapter on the ChannelRouter, last-write-wins like the
417
+ // review-thread resolver. Adapters that never register one make `getReviewState`
418
+ // answer `unsupported`.
419
+ export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
420
+
385
421
  export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
386
422
  return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
387
423
  }
@@ -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 }
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
189
189
 
190
190
  A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
191
191
 
192
- - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
192
+ - `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **Post the `<summary>` verbatim — do not pad it back into a play-by-play.** The reviewer's contract already makes the summary a terse, author-facing verdict justification (no process narration, no "I loaded the X skill", no recap of what the PR does); your job is to forward it, not re-expand it. **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
193
193
  - `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
194
194
  - `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
195
195
 
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