From 58d543471295eab1f2e27c1f89f634a7a25ab3bf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:50:24 -0700 Subject: [PATCH 1/7] Collapse compaction excerpt into the summarizer module summary-excerpt.ts was a 100-line companion to summarizer.ts with a single caller. summarizer.ts now owns the budgeted archive excerpt directly. --- src/session/summarizer.ts | 97 +++++++++++++++++++++++++-- src/session/summary-excerpt.test.ts | 2 +- src/session/summary-excerpt.ts | 100 ---------------------------- 3 files changed, 94 insertions(+), 105 deletions(-) delete mode 100644 src/session/summary-excerpt.ts diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index f6162797f..48bacfae7 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -20,14 +20,103 @@ import { } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; -import { - buildArchiveSummaryExcerpt, - type SummaryExcerptArchive, -} from "./summary-excerpt.js"; +import type { CompactionArchive } from "./compaction-archive.js"; +import type { + ArchiveKind, + ArchiveOccurrence, +} from "./compaction-archive-schema.js"; +import { formatArchiveRef } from "./archive-uri.js"; import { readSourceCredentialMaterial } from "../config/source-credentials.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); +export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; + +const KIND_PRIORITY: readonly ArchiveKind[] = [ + "user_message", + "attachment", + "assistant_text", + "tool_args", + "tool_failure", + "tool_result", + "overflow_blob", +]; + +export type SummaryExcerptArchive = Pick< + CompactionArchive, + "listOccurrences" | "readAuthorizedPayload" +>; + +function heading(occ: ArchiveOccurrence): string { + const parts = [`### ${occ.kind} ${formatArchiveRef(occ.occurrenceId)}`]; + if (occ.callId !== undefined) parts.push(`call=${occ.callId}`); + if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); + if (occ.gap === true) parts.push("[gap]"); + return parts.join(" "); +} + +/** + * Build a budgeted, kind-prioritized excerpt for the compaction summary call. + * Empty archives return "" so the caller can fall back to the live transcript. + */ +export async function buildArchiveSummaryExcerpt( + archive: SummaryExcerptArchive, + budgetChars = SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS, +): Promise { + const occurrences = await archive.listOccurrences(); + if (occurrences.length === 0) return ""; + + const byKind = new Map(); + for (const occ of occurrences) { + const list = byKind.get(occ.kind); + if (list !== undefined) list.push(occ); + else byKind.set(occ.kind, [occ]); + } + + const sections: string[] = []; + let used = 0; + let omitted = 0; + + for (const kind of KIND_PRIORITY) { + const group = byKind.get(kind); + if (group === undefined) continue; + for (const occ of group) { + const remaining = budgetChars - used; + if (remaining <= 0) { + omitted++; + continue; + } + + let body: string | undefined; + if (occ.gap === true) { + body = "(payload not stored)"; + } else { + try { + body = await archive.readAuthorizedPayload(occ.occurrenceId); + } catch { + omitted++; + continue; + } + } + + const section = `${heading(occ)}\n${body}`; + const separator = sections.length > 0 ? 2 : 0; + if (section.length + separator > remaining) { + omitted++; + continue; + } + sections.push(section); + used += section.length + separator; + } + } + + const excerpt = sections.join("\n\n"); + if (omitted === 0) return excerpt; + const note = `${omitted} occurrence${omitted === 1 ? "" : "s"} omitted`; + if (excerpt.length === 0) return note; + return `${excerpt}\n\n${note}`; +} + // What the agent was doing when compaction fired. Lets the summary preserve // the workflow contract ("we are at step 3/7 of /build") rather than dropping // it into the compacted region. diff --git a/src/session/summary-excerpt.test.ts b/src/session/summary-excerpt.test.ts index 08070e8be..079c6669b 100644 --- a/src/session/summary-excerpt.test.ts +++ b/src/session/summary-excerpt.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { ArchiveOccurrence } from "./compaction-archive-schema.js"; -import { buildArchiveSummaryExcerpt } from "./summary-excerpt.js"; +import { buildArchiveSummaryExcerpt } from "./summarizer.js"; function occ( partial: Pick & diff --git a/src/session/summary-excerpt.ts b/src/session/summary-excerpt.ts deleted file mode 100644 index fd2946ccc..000000000 --- a/src/session/summary-excerpt.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Token-budgeted compaction excerpt from the evidence archive. -// -// The live transcript is a clipped view. The archive holds the authorized -// payloads compaction is about to drop, so the summary call should read those -// rather than 400-character stubs. Budget is the control: later kinds yield -// when earlier ones fill the window. Gap rows contribute metadata only. - -import type { CompactionArchive } from "./compaction-archive.js"; -import type { - ArchiveKind, - ArchiveOccurrence, -} from "./compaction-archive-schema.js"; -import { formatArchiveRef } from "./archive-uri.js"; - -export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; - -const KIND_PRIORITY: readonly ArchiveKind[] = [ - "user_message", - "attachment", - "assistant_text", - "tool_args", - "tool_failure", - "tool_result", - "overflow_blob", -]; - -export type SummaryExcerptArchive = Pick< - CompactionArchive, - "listOccurrences" | "readAuthorizedPayload" ->; - -function heading(occ: ArchiveOccurrence): string { - const parts = [`### ${occ.kind} ${formatArchiveRef(occ.occurrenceId)}`]; - if (occ.callId !== undefined) parts.push(`call=${occ.callId}`); - if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`); - if (occ.gap === true) parts.push("[gap]"); - return parts.join(" "); -} - -/** - * Build a budgeted, kind-prioritized excerpt for the compaction summary call. - * Empty archives return "" so the caller can fall back to the live transcript. - */ -export async function buildArchiveSummaryExcerpt( - archive: SummaryExcerptArchive, - budgetChars = SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS, -): Promise { - const occurrences = await archive.listOccurrences(); - if (occurrences.length === 0) return ""; - - const byKind = new Map(); - for (const occ of occurrences) { - const list = byKind.get(occ.kind); - if (list !== undefined) list.push(occ); - else byKind.set(occ.kind, [occ]); - } - - const sections: string[] = []; - let used = 0; - let omitted = 0; - - for (const kind of KIND_PRIORITY) { - const group = byKind.get(kind); - if (group === undefined) continue; - for (const occ of group) { - const remaining = budgetChars - used; - if (remaining <= 0) { - omitted++; - continue; - } - - let body: string | undefined; - if (occ.gap === true) { - body = "(payload not stored)"; - } else { - try { - body = await archive.readAuthorizedPayload(occ.occurrenceId); - } catch { - omitted++; - continue; - } - } - - const section = `${heading(occ)}\n${body}`; - const separator = sections.length > 0 ? 2 : 0; - if (section.length + separator > remaining) { - omitted++; - continue; - } - sections.push(section); - used += section.length + separator; - } - } - - const excerpt = sections.join("\n\n"); - if (omitted === 0) return excerpt; - const note = `${omitted} occurrence${omitted === 1 ? "" : "s"} omitted`; - if (excerpt.length === 0) return note; - return `${excerpt}\n\n${note}`; -} From 974e96d29d8160ae24f5e46978c589046ab03ce5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:51:38 -0700 Subject: [PATCH 2/7] Fold the delivery guard into the queued delivery module deliver-agent-message.ts had one entry point and one caller family; queued-delivery.ts now owns the guard, the result type, and the operator copy. --- src/tui/deliver-agent-message.test.ts | 2 +- src/tui/deliver-agent-message.ts | 95 --------------------------- src/tui/queued-delivery-hop.test.ts | 2 +- src/tui/queued-delivery.test.ts | 2 +- src/tui/queued-delivery.ts | 94 +++++++++++++++++++++++++- src/tui/runner/session.ts | 2 +- src/tui/runner/state.ts | 2 +- src/tui/runner/submit.ts | 2 +- src/tui/runtime-bridge.ts | 4 +- 9 files changed, 101 insertions(+), 104 deletions(-) delete mode 100644 src/tui/deliver-agent-message.ts diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts index aed319015..3ca1322b4 100644 --- a/src/tui/deliver-agent-message.test.ts +++ b/src/tui/deliver-agent-message.test.ts @@ -3,7 +3,7 @@ import { AgentClosedError } from "@intx/agent"; import { deliverAgentMessage, deliveryResultNotice, -} from "./deliver-agent-message.js"; +} from "./queued-delivery.js"; describe("deliverAgentMessage", () => { test("reports session-unavailable without calling deliver when rebuild failed", async () => { diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts deleted file mode 100644 index 7b2b8545b..000000000 --- a/src/tui/deliver-agent-message.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Guards a queued/steer deliver against a mid-rebuild or closed agent. The shell - * paints the delivered row and pops the queue item before this runs, so the - * caller must settle ownership from the structured result — a swallowed failure - * here means the transcript claims delivery for a message that never reached - * the agent. - */ -import { AgentClosedError } from "@intx/agent"; - -export type AgentDeliveryNotDeliveredReason = - | "agent-closed" - | "session-unavailable" - | "superseded" - | "preparation-failed"; - -export type AgentDeliveryResult = - | { readonly status: "accepted" } - | { - readonly status: "not-delivered"; - readonly reason: AgentDeliveryNotDeliveredReason; - readonly detail: string; - } - | { - readonly status: "uncertain"; - readonly detail: string; - }; - -export interface DeliverAgentMessageDeps { - getFatalBuildError: () => Error | null; - deliverToLiveAgent: () => void; -} - -export async function deliverAgentMessage( - deps: DeliverAgentMessageDeps, -): Promise { - const fatal = deps.getFatalBuildError(); - if (fatal !== null) { - return { - status: "not-delivered", - reason: "session-unavailable", - detail: fatal.message, - }; - } - try { - deps.deliverToLiveAgent(); - return { status: "accepted" }; - } catch (err) { - if (err instanceof AgentClosedError) { - return { - status: "not-delivered", - reason: "agent-closed", - detail: err.message, - }; - } - return { - status: "uncertain", - detail: err instanceof Error ? err.message : String(err), - }; - } -} - -/** Operator-facing copy for a settled delivery that did not accept. */ -export function deliveryResultNotice( - result: Exclude, - disposition: "restored" | "deferred" | "none" = "none", -): string { - if (result.status === "uncertain") { - const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`; - return appendDisposition(base, disposition); - } - if (result.reason === "agent-closed") { - if (disposition === "restored") { - return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it."; - } - if (disposition === "deferred") { - return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it."; - } - return "Message not delivered because the agent closed."; - } - const base = `Message not delivered: ${result.detail}`; - return appendDisposition(base, disposition); -} - -function appendDisposition( - base: string, - disposition: "restored" | "deferred" | "none", -): string { - if (disposition === "restored") { - return `${base} It is back in the prompt; press Enter to send it.`; - } - if (disposition === "deferred") { - return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`; - } - return base; -} diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index 3b0af62c3..f1d00d1f3 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -14,7 +14,7 @@ import { } from "./queued-delivery.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; import { badgeCount, type QueueItem } from "./session-queue"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; +import type { AgentDeliveryResult } from "./queued-delivery.js"; function lastHopPort(bridgeRef: { current: SessionBridge | undefined }) { const sends: string[] = []; diff --git a/src/tui/queued-delivery.test.ts b/src/tui/queued-delivery.test.ts index 456340b70..637bcebea 100644 --- a/src/tui/queued-delivery.test.ts +++ b/src/tui/queued-delivery.test.ts @@ -7,9 +7,9 @@ import { createLiveSteerDeliver, routeQueuedDelivery, SESSION_IDENTITY_ABORT_REASON, + type AgentDeliveryResult, } from "./queued-delivery.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; const image: PendingImageAttachment = { id: "img-1", diff --git a/src/tui/queued-delivery.ts b/src/tui/queued-delivery.ts index 1b0428e58..e4d41056a 100644 --- a/src/tui/queued-delivery.ts +++ b/src/tui/queued-delivery.ts @@ -7,12 +7,104 @@ * parent tool.boundary. Leftover steers at idle, idle-with-fleet, or * post-interrupt share the send path (sendQueue, inFlight, token refresh). */ +import { AgentClosedError } from "@intx/agent"; import type { PendingImageAttachment } from "./image-attachments.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; import type { ProductHostDeliver } from "./product-host.js"; import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; +export type AgentDeliveryNotDeliveredReason = + | "agent-closed" + | "session-unavailable" + | "superseded" + | "preparation-failed"; + +export type AgentDeliveryResult = + | { readonly status: "accepted" } + | { + readonly status: "not-delivered"; + readonly reason: AgentDeliveryNotDeliveredReason; + readonly detail: string; + } + | { + readonly status: "uncertain"; + readonly detail: string; + }; + +export interface DeliverAgentMessageDeps { + getFatalBuildError: () => Error | null; + deliverToLiveAgent: () => void; +} + +/** + * Guards a queued/steer deliver against a mid-rebuild or closed agent. The + * shell paints the delivered row and pops the queue item before this runs, so + * the caller must settle ownership from the structured result. + */ +export async function deliverAgentMessage( + deps: DeliverAgentMessageDeps, +): Promise { + const fatal = deps.getFatalBuildError(); + if (fatal !== null) { + return { + status: "not-delivered", + reason: "session-unavailable", + detail: fatal.message, + }; + } + try { + deps.deliverToLiveAgent(); + return { status: "accepted" }; + } catch (err) { + if (err instanceof AgentClosedError) { + return { + status: "not-delivered", + reason: "agent-closed", + detail: err.message, + }; + } + return { + status: "uncertain", + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Operator-facing copy for a settled delivery that did not accept. */ +export function deliveryResultNotice( + result: Exclude, + disposition: "restored" | "deferred" | "none" = "none", +): string { + if (result.status === "uncertain") { + const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`; + return appendDisposition(base, disposition); + } + if (result.reason === "agent-closed") { + if (disposition === "restored") { + return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it."; + } + if (disposition === "deferred") { + return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it."; + } + return "Message not delivered because the agent closed."; + } + const base = `Message not delivered: ${result.detail}`; + return appendDisposition(base, disposition); +} + +function appendDisposition( + base: string, + disposition: "restored" | "deferred" | "none", +): string { + if (disposition === "restored") { + return `${base} It is back in the prompt; press Enter to send it.`; + } + if (disposition === "deferred") { + return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`; + } + return base; +} + export type DeliverySettle = (result: AgentDeliveryResult) => void; type MaybeAsyncDeliveryResult = | Promise diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 200e4e578..c1c2a35df 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -94,7 +94,7 @@ import { deliverAgentMessage, deliveryResultNotice, type AgentDeliveryResult, -} from "../deliver-agent-message.js"; +} from "../queued-delivery.js"; import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; import { getTelemetry, liveTelemetry } from "../../telemetry/singleton.js"; import { diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 49a7de7ec..7a4dbf253 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -32,7 +32,7 @@ import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; import type { ScopedApproval } from "../../permission/admin.js"; import type { ConnectedMcpServer, RunState } from "../../session/state.js"; import type { PendingImageAttachment } from "../image-attachments.js"; -import type { AgentDeliveryResult } from "../deliver-agent-message.js"; +import type { AgentDeliveryResult } from "../queued-delivery.js"; import type { SubmitOutcome } from "./submit.js"; import type { mountRunnerHost } from "./host.js"; import { EventEmitter } from "node:events"; diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index ac380b341..8ee402025 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -35,8 +35,8 @@ import { createLeftoverSend, createLiveSteerDeliver, routeQueuedDelivery, + type AgentDeliveryResult, } from "../queued-delivery.js"; -import type { AgentDeliveryResult } from "../deliver-agent-message.js"; import type { InferenceAttemptIdentity } from "./state.js"; import { tuiSendFailureMessage } from "./send-failure-message.js"; import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 6e7d9f2e5..c13b0c281 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -71,8 +71,8 @@ import { import { deliveryResultNotice, type AgentDeliveryResult, -} from "./deliver-agent-message.js"; -import type { DeliverySettle } from "./queued-delivery.js"; + type DeliverySettle, +} from "./queued-delivery.js"; import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import { From ecbda8dd1be53a3e2b1e2420ab0913454a2249ff Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:53:39 -0700 Subject: [PATCH 3/7] Fold turn label and send-failure classification into chrome state session-chrome.ts duplicated the chrome zone model's vocabulary: TurnStatus, ACTIVITY_STATES, and the ticker's leak-prevention contract now live in chrome-state.ts alongside the zone content they describe. All importers follow; no behavior change. --- src/tui/chrome-state.ts | 279 ++++++++++++++++++++ src/tui/runner/submit.ts | 2 +- src/tui/runtime-bridge.test.ts | 2 +- src/tui/runtime-bridge.ts | 2 +- src/tui/session-chrome.test.ts | 2 +- src/tui/session-chrome.ts | 279 -------------------- src/tui/shell/chrome.ts | 2 +- src/tui/shell/internals.ts | 2 +- src/tui/stall-watchdog.ts | 2 +- src/tui/turn-monitor.test.ts | 2 +- src/tui/turn-state.ts | 2 +- tests/unit/telemetry-product-events.test.ts | 2 +- 12 files changed, 289 insertions(+), 289 deletions(-) delete mode 100644 src/tui/session-chrome.ts diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 1e906ab5f..cdf552ccc 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -38,12 +38,16 @@ import { laneState, DEFAULT_STALL_MS, type AgentProgressSession, + type FleetProgress, type LaneState, } from "./agent-progress.js"; import { AGENTS_PANEL_MAX_VISIBLE, TASKS_PANEL_MAX_VISIBLE, } from "./geometry/zones.js"; +import { CREDENTIAL_FAILURE_USER_MESSAGE } from "../inference-error-message.js"; +import type { Telemetry } from "../telemetry/index.js"; +import type { RampPhase } from "./ramp.js"; /** * How long a finished agent row (done / failed / cancelled / interrupted) stays @@ -671,3 +675,278 @@ function mapSessionAgents( }; }); } + +// --------------------------------------------------------------------------- +// Turn progress label + agent-send failure classification (pure) +// --------------------------------------------------------------------------- +// Single-phase turn progress label plus agent-send failure classification. +// Pure: no renderer or session deps, so the shell can paint the phase without +// duplicating the state machine that produces it. + +/** Agent lifecycle status the progress label reads (mirrors the stream state). */ +export type TurnStatus = + | "idle" + | "running" + | "done" + | "failed" + | "blocked" + | "stopping" + | "stopped"; + +export interface TurnLabelInput { + readonly isProcessing: boolean; + readonly status: TurnStatus; + readonly currentToolName: string | null; + readonly streamingType: "text" | "thinking" | "tool" | null; + /** Clock for cycling live-activity words. Missing means the first word. */ + readonly nowMs?: number; + /** + * Session is still occupied even if this parent turn has settled — + * live fleet occupancy or a pending dry-fleet continuation. + */ + readonly sessionActive?: boolean; +} + +/** + * Closed set the status ticker is allowed to render. Every path through + * `resolveTurnLabel` returns one of these — never a tool identifier, MCP + * server name, or plugin name. This is what the leak-prevention test checks + * membership against, so it must stay the single source of truth for "what + * can appear in the ticker." + */ +export const ACTIVITY_STATES = [ + "working", + "warping", + "buzzing", + "grinding", + "thinking", + "doing", + "cooking", + "creating", + "imagining", + "inventing", + "planning", + "researching", + "building", + "waiting", + "stalled", + "stopping", +] as const; + +export type ActivityState = (typeof ACTIVITY_STATES)[number]; + +/** Words the lockup cycles while the session is live and not gated. */ +export const LIVE_ACTIVITY_WORDS = [ + "working", + "warping", + "buzzing", + "grinding", + "thinking", + "doing", + "cooking", + "creating", + "imagining", + "inventing", +] as const; + +/** + * Execution → activity-state mapping, kept in this one place with an + * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders + * a generic "working" state instead of leaking its identifier — no ticker + * change is required to add a tool correctly. + */ +const TOOL_ACTIVITY_STATES: Readonly> = { + read_file: "researching", + search_files: "researching", + grep: "researching", + list_dir: "researching", + web_search: "researching", + web_fetch: "researching", + write_file: "building", + edit_file: "building", + run_shell: "building", + delete_file: "building", + manage_tasks: "planning", + task: "planning", + tool_search: "researching", + search_agents: "researching", + ask_operator: "waiting", + submit_output: "working", +}; + +function activityStateForTool(name: string | null): ActivityState { + if (name === null) return "working"; + return TOOL_ACTIVITY_STATES[name] ?? "working"; +} + +/** How long each live-activity word holds before the next. */ +export const LIVE_WORD_MS = 4_000; + +function liveActivityWord(nowMs: number): ActivityState { + const index = Math.floor(nowMs / LIVE_WORD_MS) % LIVE_ACTIVITY_WORDS.length; + return LIVE_ACTIVITY_WORDS[index] ?? "working"; +} + +function sessionIsLive( + input: TurnLabelInput, + fleet: FleetProgress | null, +): boolean { + if (input.isProcessing) return true; + if (input.sessionActive === true) return true; + return fleet !== null && fleet.running > 0; +} + +/** + * Single session-phase label accompanying the density ramp. Lowercase and + * unpunctuated — the ramp's color and motion carry the state, so the word only + * has to name it. Returns undefined when idle so the phase segment disappears. + * + * `isStalled` is the caller's own `isStalledForDisplay` result (see + * stall-watchdog.ts) — this function does not re-derive staleness, it only + * ranks "stalled" against the other phases so the ticker and the ramp never + * disagree about which runs look stuck. Required, not defaulted: a caller + * that forgets to pass it is exactly the bug this state exists to prevent — + * a wedged run silently painted as ordinary work. + */ +export function resolveTurnLabel( + input: TurnLabelInput, + isStalled: boolean, + fleet: FleetProgress | null, +): ActivityState | undefined { + const occupied = sessionIsLive(input, fleet); + if (input.status === "blocked" && (input.isProcessing || occupied)) { + return "waiting"; + } + // Stopping is this parent turn aborting. A settled parent with live + // lanes is still occupied — don't let a leftover stopping status blank + // the lockup or freeze it on "stopping". + if ( + input.isProcessing && + (input.status === "stopping" || input.status === "stopped") + ) { + return "stopping"; + } + if (!occupied) return undefined; + void isStalled; + void activityStateForTool(input.currentToolName); + return liveActivityWord(input.nowMs ?? 0); +} + +/** + * Which ramp the turn paints: frozen-orange (blocked), solid-green (done), + * blinking-orange (stalled), or animating bronze (working). `isStalled` is + * the caller's own `shouldNoticeStall` result — this function does not + * re-derive staleness, it only orders it against the other phases. + */ +export function resolveRampPhase( + input: TurnLabelInput, + isStalled: boolean, + fleet: FleetProgress | null, +): RampPhase { + if (input.status === "blocked") return "blocked"; + // Occupied session (live lanes or a pending continuation) stays the working + // ramp even if this parent turn already settled as done. + if ( + sessionIsLive(input, fleet) && + (input.sessionActive === true || (fleet !== null && fleet.running > 0)) + ) { + void isStalled; + return "working"; + } + if (input.status === "done") return "done"; + void isStalled; + return "working"; +} + +export type SendFailureKind = "abort" | "auth" | "error"; + +/** First-party auth_provider values only — never free-text provider labels. */ +export type AuthProviderId = "codex" | "xai" | "anthropic" | "other"; + +export interface ClassifiedSendFailure { + readonly kind: SendFailureKind; + readonly authProvider: AuthProviderId | null; +} + +// Phrase matchers for message-only classification (stream carries bare strings). +// Codex/xAI constructors always emit the profile phrases below. +const CODEX_AUTH_MESSAGE = /\bcodex profile\b/i; +const XAI_AUTH_MESSAGE = /\bxai profile\b/i; +// Anthropic API-key rejections: authentication_error type, invalid x-api-key, +// or invalid api key phrasing in 401 bodies. +const ANTHROPIC_AUTH_MESSAGE = + /\b(?:anthropic|claude)\b.*\b(?:auth|unauthorized|api[\s_-]?key|x-api-key)\b|\bauthentication_error\b|\binvalid[\s_-]?x?-?api[\s_-]?key\b/i; +// Generic credential rejection when the provider cannot be named safely. +const GENERIC_AUTH_MESSAGE = + /\b(?:401|403)\b|\bunauthorized\b|\binvalid[\s_-]?api[\s_-]?key\b|\bauthentication\b.*\bfail/i; + +function authProviderFromMessage(message: string): AuthProviderId | null { + if (CODEX_AUTH_MESSAGE.test(message)) return "codex"; + if (XAI_AUTH_MESSAGE.test(message)) return "xai"; + if (ANTHROPIC_AUTH_MESSAGE.test(message)) return "anthropic"; + if (GENERIC_AUTH_MESSAGE.test(message)) return "other"; + return null; +} + +/** Classify agent.send() rejection so the TUI can settle UI state consistently. */ +export function classifyAgentSendFailure( + err: unknown, + aborted: boolean, + isCodexAuth: (e: unknown) => boolean, + isXaiAuth: (e: unknown) => boolean, +): ClassifiedSendFailure { + if (aborted) return { kind: "abort", authProvider: null }; + if (isCodexAuth(err)) return { kind: "auth", authProvider: "codex" }; + if (isXaiAuth(err)) return { kind: "auth", authProvider: "xai" }; + const message = err instanceof Error ? err.message : String(err); + const authProvider = authProviderFromMessage(message); + if (authProvider !== null) return { kind: "auth", authProvider }; + return { kind: "error", authProvider: null }; +} + +export function shouldSettleUiAfterSendFailure(kind: SendFailureKind): boolean { + return kind === "auth" || kind === "error"; +} + +/** Report which provider rejected the stored credentials; silent otherwise. */ +export function captureAuthFailure( + telemetry: Telemetry, + failure: ClassifiedSendFailure, +): void { + if (failure.kind !== "auth" || failure.authProvider === null) return; + telemetry.capture("auth_failure", { auth_provider: failure.authProvider }); +} + +/** Same classification as `classifyAgentSendFailure`, from the message alone. */ +export function classifySendFailureMessage( + message: string, +): ClassifiedSendFailure { + const authProvider = authProviderFromMessage(message); + if (authProvider !== null) return { kind: "auth", authProvider }; + return { kind: "error", authProvider: null }; +} + +const AUTH_FAILURE_TEXT: Record = { + codex: "your chatgpt sign-in expired — /model to sign in again", + xai: "your x.ai sign-in expired — /model to sign in again", + anthropic: + "your anthropic api key was rejected — /model to update credentials", + other: "provider credentials were rejected — /model to sign in again", +}; + +/** + * Transcript body for a failed send. A recognised failure says what happened + * and what to press; anything else keeps the raw message rather than swallowing + * the only detail the operator has. + */ +export function sendFailureText(message: string): string { + // Classified inference.error lines are already operator-facing. Rematching + // them against raw-provider auth patterns rewrites intentional copy + // (e.g. "Authentication failed — log in again." → generic other). + if (message === CREDENTIAL_FAILURE_USER_MESSAGE) return message; + const failure = classifySendFailureMessage(message); + if (failure.kind === "auth" && failure.authProvider !== null) { + return AUTH_FAILURE_TEXT[failure.authProvider]; + } + return message; +} diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index 8ee402025..6946bad1b 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -25,7 +25,7 @@ import { captureAuthFailure, classifyAgentSendFailure, shouldSettleUiAfterSendFailure, -} from "../session-chrome.js"; +} from "../chrome-state.js"; import { ingestOperatorPrompt } from "../prompt-attachments.js"; import { imageAttachmentFromPath, diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 62c752101..9071683d3 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -18,7 +18,7 @@ import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import { withTestRenderer } from "./harness"; import { badgeCount } from "./session-queue"; -import { LIVE_ACTIVITY_WORDS } from "./session-chrome"; +import { LIVE_ACTIVITY_WORDS } from "./chrome-state"; describe("mapReactorLike", () => { test("operator-originated message.received → user", () => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index c13b0c281..ccb9c1c8b 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -40,7 +40,7 @@ import { resolveRampPhase, resolveTurnLabel, sendFailureText, -} from "./session-chrome.js"; +} from "./chrome-state.js"; import { shouldAutoRetryQuota } from "./quota-retry.js"; import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import { diff --git a/src/tui/session-chrome.test.ts b/src/tui/session-chrome.test.ts index 6cd508317..8fda14bfd 100644 --- a/src/tui/session-chrome.test.ts +++ b/src/tui/session-chrome.test.ts @@ -10,7 +10,7 @@ import { resolveTurnLabel, sendFailureText, shouldSettleUiAfterSendFailure, -} from "./session-chrome.js"; +} from "./chrome-state.js"; // The load-bearing guarantee: whatever tool identifier, MCP server name, or // plugin name the runtime hands us, the rendered ticker string must land in diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts deleted file mode 100644 index e23ff383d..000000000 --- a/src/tui/session-chrome.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Single-phase turn progress label plus agent-send failure classification. - * - * Pure: no renderer or session deps, so the shell can paint the phase without - * duplicating the state machine that produces it. - */ - -import { CREDENTIAL_FAILURE_USER_MESSAGE } from "../inference-error-message.js"; -import type { Telemetry } from "../telemetry/index.js"; -import type { FleetProgress } from "./agent-progress.js"; -import type { RampPhase } from "./ramp.js"; - -/** Agent lifecycle status the progress label reads (mirrors the stream state). */ -export type TurnStatus = - | "idle" - | "running" - | "done" - | "failed" - | "blocked" - | "stopping" - | "stopped"; - -export interface TurnLabelInput { - readonly isProcessing: boolean; - readonly status: TurnStatus; - readonly currentToolName: string | null; - readonly streamingType: "text" | "thinking" | "tool" | null; - /** Clock for cycling live-activity words. Missing means the first word. */ - readonly nowMs?: number; - /** - * Session is still occupied even if this parent turn has settled — - * live fleet occupancy or a pending dry-fleet continuation. - */ - readonly sessionActive?: boolean; -} - -/** - * Closed set the status ticker is allowed to render. Every path through - * `resolveTurnLabel` returns one of these — never a tool identifier, MCP - * server name, or plugin name. This is what the leak-prevention test checks - * membership against, so it must stay the single source of truth for "what - * can appear in the ticker." - */ -export const ACTIVITY_STATES = [ - "working", - "warping", - "buzzing", - "grinding", - "thinking", - "doing", - "cooking", - "creating", - "imagining", - "inventing", - "planning", - "researching", - "building", - "waiting", - "stalled", - "stopping", -] as const; - -export type ActivityState = (typeof ACTIVITY_STATES)[number]; - -/** Words the lockup cycles while the session is live and not gated. */ -export const LIVE_ACTIVITY_WORDS = [ - "working", - "warping", - "buzzing", - "grinding", - "thinking", - "doing", - "cooking", - "creating", - "imagining", - "inventing", -] as const; - -/** - * Execution → activity-state mapping, kept in this one place with an - * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders - * a generic "working" state instead of leaking its identifier — no ticker - * change is required to add a tool correctly. - */ -const TOOL_ACTIVITY_STATES: Readonly> = { - read_file: "researching", - search_files: "researching", - grep: "researching", - list_dir: "researching", - web_search: "researching", - web_fetch: "researching", - write_file: "building", - edit_file: "building", - run_shell: "building", - delete_file: "building", - manage_tasks: "planning", - task: "planning", - tool_search: "researching", - search_agents: "researching", - ask_operator: "waiting", - submit_output: "working", -}; - -function activityStateForTool(name: string | null): ActivityState { - if (name === null) return "working"; - return TOOL_ACTIVITY_STATES[name] ?? "working"; -} - -/** How long each live-activity word holds before the next. */ -export const LIVE_WORD_MS = 4_000; - -function liveActivityWord(nowMs: number): ActivityState { - const index = Math.floor(nowMs / LIVE_WORD_MS) % LIVE_ACTIVITY_WORDS.length; - return LIVE_ACTIVITY_WORDS[index] ?? "working"; -} - -function sessionIsLive( - input: TurnLabelInput, - fleet: FleetProgress | null, -): boolean { - if (input.isProcessing) return true; - if (input.sessionActive === true) return true; - return fleet !== null && fleet.running > 0; -} - -/** - * Single session-phase label accompanying the density ramp. Lowercase and - * unpunctuated — the ramp's color and motion carry the state, so the word only - * has to name it. Returns undefined when idle so the phase segment disappears. - * - * `isStalled` is the caller's own `isStalledForDisplay` result (see - * stall-watchdog.ts) — this function does not re-derive staleness, it only - * ranks "stalled" against the other phases so the ticker and the ramp never - * disagree about which runs look stuck. Required, not defaulted: a caller - * that forgets to pass it is exactly the bug this state exists to prevent — - * a wedged run silently painted as ordinary work. - */ -export function resolveTurnLabel( - input: TurnLabelInput, - isStalled: boolean, - fleet: FleetProgress | null, -): ActivityState | undefined { - const occupied = sessionIsLive(input, fleet); - if (input.status === "blocked" && (input.isProcessing || occupied)) { - return "waiting"; - } - // Stopping is this parent turn aborting. A settled parent with live - // lanes is still occupied — don't let a leftover stopping status blank - // the lockup or freeze it on "stopping". - if ( - input.isProcessing && - (input.status === "stopping" || input.status === "stopped") - ) { - return "stopping"; - } - if (!occupied) return undefined; - void isStalled; - void activityStateForTool(input.currentToolName); - return liveActivityWord(input.nowMs ?? 0); -} - -/** - * Which ramp the turn paints: frozen-orange (blocked), solid-green (done), - * blinking-orange (stalled), or animating bronze (working). `isStalled` is - * the caller's own `shouldNoticeStall` result — this function does not - * re-derive staleness, it only orders it against the other phases. - */ -export function resolveRampPhase( - input: TurnLabelInput, - isStalled: boolean, - fleet: FleetProgress | null, -): RampPhase { - if (input.status === "blocked") return "blocked"; - // Occupied session (live lanes or a pending continuation) stays the working - // ramp even if this parent turn already settled as done. - if ( - sessionIsLive(input, fleet) && - (input.sessionActive === true || (fleet !== null && fleet.running > 0)) - ) { - void isStalled; - return "working"; - } - if (input.status === "done") return "done"; - void isStalled; - return "working"; -} - -export type SendFailureKind = "abort" | "auth" | "error"; - -/** First-party auth_provider values only — never free-text provider labels. */ -export type AuthProviderId = "codex" | "xai" | "anthropic" | "other"; - -export interface ClassifiedSendFailure { - readonly kind: SendFailureKind; - readonly authProvider: AuthProviderId | null; -} - -// Phrase matchers for message-only classification (stream carries bare strings). -// Codex/xAI constructors always emit the profile phrases below. -const CODEX_AUTH_MESSAGE = /\bcodex profile\b/i; -const XAI_AUTH_MESSAGE = /\bxai profile\b/i; -// Anthropic API-key rejections: authentication_error type, invalid x-api-key, -// or invalid api key phrasing in 401 bodies. -const ANTHROPIC_AUTH_MESSAGE = - /\b(?:anthropic|claude)\b.*\b(?:auth|unauthorized|api[\s_-]?key|x-api-key)\b|\bauthentication_error\b|\binvalid[\s_-]?x?-?api[\s_-]?key\b/i; -// Generic credential rejection when the provider cannot be named safely. -const GENERIC_AUTH_MESSAGE = - /\b(?:401|403)\b|\bunauthorized\b|\binvalid[\s_-]?api[\s_-]?key\b|\bauthentication\b.*\bfail/i; - -function authProviderFromMessage(message: string): AuthProviderId | null { - if (CODEX_AUTH_MESSAGE.test(message)) return "codex"; - if (XAI_AUTH_MESSAGE.test(message)) return "xai"; - if (ANTHROPIC_AUTH_MESSAGE.test(message)) return "anthropic"; - if (GENERIC_AUTH_MESSAGE.test(message)) return "other"; - return null; -} - -/** Classify agent.send() rejection so the TUI can settle UI state consistently. */ -export function classifyAgentSendFailure( - err: unknown, - aborted: boolean, - isCodexAuth: (e: unknown) => boolean, - isXaiAuth: (e: unknown) => boolean, -): ClassifiedSendFailure { - if (aborted) return { kind: "abort", authProvider: null }; - if (isCodexAuth(err)) return { kind: "auth", authProvider: "codex" }; - if (isXaiAuth(err)) return { kind: "auth", authProvider: "xai" }; - const message = err instanceof Error ? err.message : String(err); - const authProvider = authProviderFromMessage(message); - if (authProvider !== null) return { kind: "auth", authProvider }; - return { kind: "error", authProvider: null }; -} - -export function shouldSettleUiAfterSendFailure(kind: SendFailureKind): boolean { - return kind === "auth" || kind === "error"; -} - -/** Report which provider rejected the stored credentials; silent otherwise. */ -export function captureAuthFailure( - telemetry: Telemetry, - failure: ClassifiedSendFailure, -): void { - if (failure.kind !== "auth" || failure.authProvider === null) return; - telemetry.capture("auth_failure", { auth_provider: failure.authProvider }); -} - -/** Same classification as `classifyAgentSendFailure`, from the message alone. */ -export function classifySendFailureMessage( - message: string, -): ClassifiedSendFailure { - const authProvider = authProviderFromMessage(message); - if (authProvider !== null) return { kind: "auth", authProvider }; - return { kind: "error", authProvider: null }; -} - -const AUTH_FAILURE_TEXT: Record = { - codex: "your chatgpt sign-in expired — /model to sign in again", - xai: "your x.ai sign-in expired — /model to sign in again", - anthropic: - "your anthropic api key was rejected — /model to update credentials", - other: "provider credentials were rejected — /model to sign in again", -}; - -/** - * Transcript body for a failed send. A recognised failure says what happened - * and what to press; anything else keeps the raw message rather than swallowing - * the only detail the operator has. - */ -export function sendFailureText(message: string): string { - // Classified inference.error lines are already operator-facing. Rematching - // them against raw-provider auth patterns rewrites intentional copy - // (e.g. "Authentication failed — log in again." → generic other). - if (message === CREDENTIAL_FAILURE_USER_MESSAGE) return message; - const failure = classifySendFailureMessage(message); - if (failure.kind === "auth" && failure.authProvider !== null) { - return AUTH_FAILURE_TEXT[failure.authProvider]; - } - return message; -} diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index c0d69cadf..795f13696 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { clampBoardRows, + type ActivityState, type AgentPanelRow, type ChromeZoneContent, type TaskPanelRow, @@ -33,7 +34,6 @@ import { type LockupInput, } from "../lockup.js"; import type { RampPhase, StallAge } from "../ramp.js"; -import type { ActivityState } from "../session-chrome.js"; import { BORDER, composeAttentionLabel, diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 04ca94784..08b07ecde 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -19,7 +19,7 @@ import { type SentHistoryBrowse } from "../sent-message-history.js"; import { type PromptRecognitionSource } from "../prompt-recognition.js"; import { type PromptInput } from "../prompt-input.js"; import type { RampPhase, StallAge } from "../ramp.js"; -import type { ActivityState } from "../session-chrome.js"; +import type { ActivityState } from "../chrome-state.js"; import { type CostContextMeter } from "../prompt-border.js"; import { type FocusState } from "../focus/index.js"; import { diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index dbbd35f84..6370592b5 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -1,4 +1,4 @@ -import type { TurnStatus } from "./session-chrome.js"; +import type { TurnStatus } from "./chrome-state.js"; // How long the run can be continuously awaiting a response with no new content // before the watchdog fires and aborts the in-flight request. diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index e1361d771..63049d2ab 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -10,7 +10,7 @@ import { noticeText } from "./shell/chrome.js"; import { createAppShell } from "./shell/index.js"; import { withTestRenderer } from "./harness.js"; import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; -import { LIVE_WORD_MS } from "./session-chrome.js"; +import { LIVE_WORD_MS } from "./chrome-state.js"; import { STALL_NOTICE_MESSAGE, STALL_RECOVERY_MESSAGE, diff --git a/src/tui/turn-state.ts b/src/tui/turn-state.ts index de139f821..ca6687c6a 100644 --- a/src/tui/turn-state.ts +++ b/src/tui/turn-state.ts @@ -12,7 +12,7 @@ import { type } from "arktype"; import { isReactorErrorFatal } from "../agent/reactor-events.js"; -import type { TurnStatus } from "./session-chrome.js"; +import type { TurnStatus } from "./chrome-state.js"; // Bound on the accumulated stream text kept for the current cycle. Comfortably // larger than what the cross-cycle fingerprint comparison needs. diff --git a/tests/unit/telemetry-product-events.test.ts b/tests/unit/telemetry-product-events.test.ts index 857229369..6bd18ed5d 100644 --- a/tests/unit/telemetry-product-events.test.ts +++ b/tests/unit/telemetry-product-events.test.ts @@ -50,7 +50,7 @@ import { import { captureAuthFailure, classifyAgentSendFailure, -} from "../../src/tui/session-chrome.js"; +} from "../../src/tui/chrome-state.js"; interface BatchBody { batch: { event: string; properties: Record }[]; From 1526f6fdf13ddd225a86912c50f49c9a03c9d8f6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:54:33 -0700 Subject: [PATCH 4/7] Fold archive occurrence refs into the compaction archive module archive-uri.ts owned the archive:// addressing scheme apart from the store that honors it. The ref helpers now live on compaction-archive.ts verbatim; plugins and session importers follow. No behavior change. --- .../evidence-archive-search-plugin.test.ts | 2 +- src/plugins/evidence-archive-search-plugin.ts | 2 +- src/plugins/path-escape-plugin.ts | 2 +- src/session/archive-uri.test.ts | 2 +- src/session/archive-uri.ts | 26 -------------- src/session/compaction-archive.ts | 36 +++++++++++++++++++ src/session/summarizer.ts | 6 ++-- 7 files changed, 44 insertions(+), 32 deletions(-) delete mode 100644 src/session/archive-uri.ts diff --git a/src/plugins/evidence-archive-search-plugin.test.ts b/src/plugins/evidence-archive-search-plugin.test.ts index b91fb6fb9..6ffc5adca 100644 --- a/src/plugins/evidence-archive-search-plugin.test.ts +++ b/src/plugins/evidence-archive-search-plugin.test.ts @@ -8,9 +8,9 @@ import { advertiseArchiveSurface, evidenceArchiveSearchPlugin, } from "./evidence-archive-search-plugin.js"; -import { formatArchiveRef } from "../session/archive-uri.js"; import { createCompactionArchive, + formatArchiveRef, type CompactionArchive, } from "../session/compaction-archive.js"; import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "../agent/tool-search.js"; diff --git a/src/plugins/evidence-archive-search-plugin.ts b/src/plugins/evidence-archive-search-plugin.ts index c3e1b182d..8c3747b09 100644 --- a/src/plugins/evidence-archive-search-plugin.ts +++ b/src/plugins/evidence-archive-search-plugin.ts @@ -11,7 +11,7 @@ import { formatArchiveRef, isArchiveLike, parseArchiveTarget, -} from "../session/archive-uri.js"; +} from "../session/compaction-archive.js"; const SEARCH_DEFAULT_MAX = 1000; const GREP_DEFAULT_MAX = 500; diff --git a/src/plugins/path-escape-plugin.ts b/src/plugins/path-escape-plugin.ts index 4b3070c1f..3574e17eb 100644 --- a/src/plugins/path-escape-plugin.ts +++ b/src/plugins/path-escape-plugin.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import type { ToolPlugin } from "@intx/tools-posix"; import { isToolOutputLike } from "../util/tool-output-uri.js"; -import { isArchiveLike } from "../session/archive-uri.js"; +import { isArchiveLike } from "../session/compaction-archive.js"; import { resolveWorkspacePath } from "../permission/path-restriction.js"; import type { RootsProvider } from "../permission/worktree-roots.js"; diff --git a/src/session/archive-uri.test.ts b/src/session/archive-uri.test.ts index e68e2e276..1cfa4bb13 100644 --- a/src/session/archive-uri.test.ts +++ b/src/session/archive-uri.test.ts @@ -5,7 +5,7 @@ import { isArchiveLike, parseArchiveRef, parseArchiveTarget, -} from "./archive-uri.js"; +} from "./compaction-archive.js"; describe("archive URI", () => { test("formats and parses archive:/// occurrence refs", () => { diff --git a/src/session/archive-uri.ts b/src/session/archive-uri.ts deleted file mode 100644 index 11e9c1569..000000000 --- a/src/session/archive-uri.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const ARCHIVE_URI_PREFIX = "archive:"; -const ARCHIVE_URI_CANONICAL = "archive:///"; - -export function formatArchiveRef(occurrenceId: string): string { - return `${ARCHIVE_URI_CANONICAL}${occurrenceId}`; -} - -export function isArchiveLike(path: string): boolean { - return path.startsWith(ARCHIVE_URI_PREFIX); -} - -/** Accept archive:///occ-… and common slashes; return the occurrence id or undefined. */ -export function parseArchiveRef(value: string): string | undefined { - return parseArchiveTarget(value)?.occurrenceId; -} - -/** Root `archive:///` has no occurrenceId; a ref includes one. */ -export function parseArchiveTarget( - value: string, -): { occurrenceId?: string } | undefined { - if (!isArchiveLike(value)) return undefined; - const rest = value.slice(ARCHIVE_URI_PREFIX.length).replace(/^\/+/, ""); - const occurrenceId = rest.split(/[/?#]/)[0] ?? ""; - if (occurrenceId.length === 0) return {}; - return { occurrenceId }; -} diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index bad0a8c64..c3bca9d20 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -925,3 +925,39 @@ export function wrapCompactorWithCompletenessGate( }, }; } + +// --------------------------------------------------------------------------- +// Archive occurrence refs (pure addressing) +// --------------------------------------------------------------------------- +// The archive's own addressing scheme: `archive:///` refs name +// where an occurrence's payload bytes live. Kept on the archive module so the +// URI scheme and the store that honors it cannot drift apart. + +/** Prefix for every archive target, including the bare `archive:///` root. */ +export const ARCHIVE_URI_PREFIX = "archive:"; +const ARCHIVE_URI_CANONICAL = "archive:///"; + +/** Render the canonical ref for an occurrence id. */ +export function formatArchiveRef(occurrenceId: string): string { + return `${ARCHIVE_URI_CANONICAL}${occurrenceId}`; +} + +export function isArchiveLike(path: string): boolean { + return path.startsWith(ARCHIVE_URI_PREFIX); +} + +/** Accept archive:///occ-… and common slashes; return the occurrence id or undefined. */ +export function parseArchiveRef(value: string): string | undefined { + return parseArchiveTarget(value)?.occurrenceId; +} + +/** Root `archive:///` has no occurrenceId; a ref includes one. */ +export function parseArchiveTarget( + value: string, +): { occurrenceId?: string } | undefined { + if (!isArchiveLike(value)) return undefined; + const rest = value.slice(ARCHIVE_URI_PREFIX.length).replace(/^\/+/, ""); + const occurrenceId = rest.split(/[/?#]/)[0] ?? ""; + if (occurrenceId.length === 0) return {}; + return { occurrenceId }; +} diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 48bacfae7..9a1d84686 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -20,12 +20,14 @@ import { } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; -import type { CompactionArchive } from "./compaction-archive.js"; +import { + formatArchiveRef, + type CompactionArchive, +} from "./compaction-archive.js"; import type { ArchiveKind, ArchiveOccurrence, } from "./compaction-archive-schema.js"; -import { formatArchiveRef } from "./archive-uri.js"; import { readSourceCredentialMaterial } from "../config/source-credentials.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); From 5195bb15679dd2bdf8d094e49934583ecefc30b3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:55:02 -0700 Subject: [PATCH 5/7] Update architecture notes for the collapsed session seams IMPLEMENTATION.md lists the excerpt and archive refs on their owning modules; TELEMETRY.md points at chrome-state.ts. --- docs/IMPLEMENTATION.md | 5 ++--- docs/TELEMETRY.md | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 21d226238..20281cff1 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -79,9 +79,8 @@ src/ index.ts Session lifecycle state.ts RunState JSON save/load compactor.ts Context compactor - summarizer.ts Model-backed structured compaction summary (fails closed) - summary-excerpt.ts Token-budgeted archive excerpt for the summary call - compaction-archive.ts Primary-only authorized evidence archive (post-policy capture) + summarizer.ts Model-backed structured compaction summary (fails closed) + token-budgeted archive excerpt + compaction-archive.ts Primary-only authorized evidence archive (post-policy capture) + archive:// occurrence refs compaction-archive-schema.ts Archive occurrence / completeness certificate schemas run-sink.ts Run-level event sink stream-consumer.ts Async stream consumer with error handling diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 5f266b273..a52860588 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -107,7 +107,7 @@ recorded. `auth_provider` is a separate property for that reason: it names which provider's sign-in was rejected (`codex`, `xai`, `anthropic`, `other`), -chosen from a fixed first-party set in `src/tui/session-chrome.ts`. No +chosen from a fixed first-party set in `src/tui/chrome-state.ts`. No part of the provider's rejection message is sent. The mapping is `src/telemetry/classify.ts`, and the tests that feed each From ce40de29d4fc52e01fa4908a7ef9d7dbf57a61da Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 01:04:13 -0700 Subject: [PATCH 6/7] Revert delivery guard fold to avoid sibling branch collision Revert 974e96d29: sibling cl-7949-tui-micro-inlines renames queued-delivery.ts, so this branch keeps deliver-agent-message.ts separate to merge cleanly. --- src/tui/deliver-agent-message.test.ts | 2 +- src/tui/deliver-agent-message.ts | 95 +++++++++++++++++++++++++++ src/tui/queued-delivery-hop.test.ts | 2 +- src/tui/queued-delivery.test.ts | 2 +- src/tui/queued-delivery.ts | 94 +------------------------- src/tui/runner/session.ts | 2 +- src/tui/runner/state.ts | 2 +- src/tui/runner/submit.ts | 2 +- src/tui/runtime-bridge.ts | 4 +- 9 files changed, 104 insertions(+), 101 deletions(-) create mode 100644 src/tui/deliver-agent-message.ts diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts index 3ca1322b4..aed319015 100644 --- a/src/tui/deliver-agent-message.test.ts +++ b/src/tui/deliver-agent-message.test.ts @@ -3,7 +3,7 @@ import { AgentClosedError } from "@intx/agent"; import { deliverAgentMessage, deliveryResultNotice, -} from "./queued-delivery.js"; +} from "./deliver-agent-message.js"; describe("deliverAgentMessage", () => { test("reports session-unavailable without calling deliver when rebuild failed", async () => { diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts new file mode 100644 index 000000000..7b2b8545b --- /dev/null +++ b/src/tui/deliver-agent-message.ts @@ -0,0 +1,95 @@ +/** + * Guards a queued/steer deliver against a mid-rebuild or closed agent. The shell + * paints the delivered row and pops the queue item before this runs, so the + * caller must settle ownership from the structured result — a swallowed failure + * here means the transcript claims delivery for a message that never reached + * the agent. + */ +import { AgentClosedError } from "@intx/agent"; + +export type AgentDeliveryNotDeliveredReason = + | "agent-closed" + | "session-unavailable" + | "superseded" + | "preparation-failed"; + +export type AgentDeliveryResult = + | { readonly status: "accepted" } + | { + readonly status: "not-delivered"; + readonly reason: AgentDeliveryNotDeliveredReason; + readonly detail: string; + } + | { + readonly status: "uncertain"; + readonly detail: string; + }; + +export interface DeliverAgentMessageDeps { + getFatalBuildError: () => Error | null; + deliverToLiveAgent: () => void; +} + +export async function deliverAgentMessage( + deps: DeliverAgentMessageDeps, +): Promise { + const fatal = deps.getFatalBuildError(); + if (fatal !== null) { + return { + status: "not-delivered", + reason: "session-unavailable", + detail: fatal.message, + }; + } + try { + deps.deliverToLiveAgent(); + return { status: "accepted" }; + } catch (err) { + if (err instanceof AgentClosedError) { + return { + status: "not-delivered", + reason: "agent-closed", + detail: err.message, + }; + } + return { + status: "uncertain", + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Operator-facing copy for a settled delivery that did not accept. */ +export function deliveryResultNotice( + result: Exclude, + disposition: "restored" | "deferred" | "none" = "none", +): string { + if (result.status === "uncertain") { + const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`; + return appendDisposition(base, disposition); + } + if (result.reason === "agent-closed") { + if (disposition === "restored") { + return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it."; + } + if (disposition === "deferred") { + return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it."; + } + return "Message not delivered because the agent closed."; + } + const base = `Message not delivered: ${result.detail}`; + return appendDisposition(base, disposition); +} + +function appendDisposition( + base: string, + disposition: "restored" | "deferred" | "none", +): string { + if (disposition === "restored") { + return `${base} It is back in the prompt; press Enter to send it.`; + } + if (disposition === "deferred") { + return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`; + } + return base; +} diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index f1d00d1f3..3b0af62c3 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -14,7 +14,7 @@ import { } from "./queued-delivery.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; import { badgeCount, type QueueItem } from "./session-queue"; -import type { AgentDeliveryResult } from "./queued-delivery.js"; +import type { AgentDeliveryResult } from "./deliver-agent-message.js"; function lastHopPort(bridgeRef: { current: SessionBridge | undefined }) { const sends: string[] = []; diff --git a/src/tui/queued-delivery.test.ts b/src/tui/queued-delivery.test.ts index 637bcebea..456340b70 100644 --- a/src/tui/queued-delivery.test.ts +++ b/src/tui/queued-delivery.test.ts @@ -7,9 +7,9 @@ import { createLiveSteerDeliver, routeQueuedDelivery, SESSION_IDENTITY_ABORT_REASON, - type AgentDeliveryResult, } from "./queued-delivery.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; +import type { AgentDeliveryResult } from "./deliver-agent-message.js"; const image: PendingImageAttachment = { id: "img-1", diff --git a/src/tui/queued-delivery.ts b/src/tui/queued-delivery.ts index e4d41056a..1b0428e58 100644 --- a/src/tui/queued-delivery.ts +++ b/src/tui/queued-delivery.ts @@ -7,104 +7,12 @@ * parent tool.boundary. Leftover steers at idle, idle-with-fleet, or * post-interrupt share the send path (sendQueue, inFlight, token refresh). */ -import { AgentClosedError } from "@intx/agent"; import type { PendingImageAttachment } from "./image-attachments.js"; +import type { AgentDeliveryResult } from "./deliver-agent-message.js"; import type { ProductHostDeliver } from "./product-host.js"; import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; -export type AgentDeliveryNotDeliveredReason = - | "agent-closed" - | "session-unavailable" - | "superseded" - | "preparation-failed"; - -export type AgentDeliveryResult = - | { readonly status: "accepted" } - | { - readonly status: "not-delivered"; - readonly reason: AgentDeliveryNotDeliveredReason; - readonly detail: string; - } - | { - readonly status: "uncertain"; - readonly detail: string; - }; - -export interface DeliverAgentMessageDeps { - getFatalBuildError: () => Error | null; - deliverToLiveAgent: () => void; -} - -/** - * Guards a queued/steer deliver against a mid-rebuild or closed agent. The - * shell paints the delivered row and pops the queue item before this runs, so - * the caller must settle ownership from the structured result. - */ -export async function deliverAgentMessage( - deps: DeliverAgentMessageDeps, -): Promise { - const fatal = deps.getFatalBuildError(); - if (fatal !== null) { - return { - status: "not-delivered", - reason: "session-unavailable", - detail: fatal.message, - }; - } - try { - deps.deliverToLiveAgent(); - return { status: "accepted" }; - } catch (err) { - if (err instanceof AgentClosedError) { - return { - status: "not-delivered", - reason: "agent-closed", - detail: err.message, - }; - } - return { - status: "uncertain", - detail: err instanceof Error ? err.message : String(err), - }; - } -} - -/** Operator-facing copy for a settled delivery that did not accept. */ -export function deliveryResultNotice( - result: Exclude, - disposition: "restored" | "deferred" | "none" = "none", -): string { - if (result.status === "uncertain") { - const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`; - return appendDisposition(base, disposition); - } - if (result.reason === "agent-closed") { - if (disposition === "restored") { - return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it."; - } - if (disposition === "deferred") { - return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it."; - } - return "Message not delivered because the agent closed."; - } - const base = `Message not delivered: ${result.detail}`; - return appendDisposition(base, disposition); -} - -function appendDisposition( - base: string, - disposition: "restored" | "deferred" | "none", -): string { - if (disposition === "restored") { - return `${base} It is back in the prompt; press Enter to send it.`; - } - if (disposition === "deferred") { - return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`; - } - return base; -} - export type DeliverySettle = (result: AgentDeliveryResult) => void; type MaybeAsyncDeliveryResult = | Promise diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index c1c2a35df..200e4e578 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -94,7 +94,7 @@ import { deliverAgentMessage, deliveryResultNotice, type AgentDeliveryResult, -} from "../queued-delivery.js"; +} from "../deliver-agent-message.js"; import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; import { getTelemetry, liveTelemetry } from "../../telemetry/singleton.js"; import { diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 7a4dbf253..49a7de7ec 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -32,7 +32,7 @@ import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; import type { ScopedApproval } from "../../permission/admin.js"; import type { ConnectedMcpServer, RunState } from "../../session/state.js"; import type { PendingImageAttachment } from "../image-attachments.js"; -import type { AgentDeliveryResult } from "../queued-delivery.js"; +import type { AgentDeliveryResult } from "../deliver-agent-message.js"; import type { SubmitOutcome } from "./submit.js"; import type { mountRunnerHost } from "./host.js"; import { EventEmitter } from "node:events"; diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index 6946bad1b..4cdc6767b 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -35,8 +35,8 @@ import { createLeftoverSend, createLiveSteerDeliver, routeQueuedDelivery, - type AgentDeliveryResult, } from "../queued-delivery.js"; +import type { AgentDeliveryResult } from "../deliver-agent-message.js"; import type { InferenceAttemptIdentity } from "./state.js"; import { tuiSendFailureMessage } from "./send-failure-message.js"; import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index ccb9c1c8b..d111e8107 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -71,8 +71,8 @@ import { import { deliveryResultNotice, type AgentDeliveryResult, - type DeliverySettle, -} from "./queued-delivery.js"; +} from "./deliver-agent-message.js"; +import type { DeliverySettle } from "./queued-delivery.js"; import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import { From 4a633bb27e0272085435a97c8a7347bff4b433f2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 01:04:32 -0700 Subject: [PATCH 7/7] Rename orphan tests to their owning modules and restore dropped headers session-chrome, summary-excerpt, and archive-uri tests now ride with chrome-state, summarizer, and compaction-archive; the excerpt rationale header returns to summarizer.ts. --- ...{archive-uri.test.ts => compaction-archive-refs.test.ts} | 0 .../{summary-excerpt.test.ts => summarizer-excerpt.test.ts} | 0 src/session/summarizer.ts | 6 ++++++ .../{session-chrome.test.ts => chrome-state-turn.test.ts} | 0 4 files changed, 6 insertions(+) rename src/session/{archive-uri.test.ts => compaction-archive-refs.test.ts} (100%) rename src/session/{summary-excerpt.test.ts => summarizer-excerpt.test.ts} (100%) rename src/tui/{session-chrome.test.ts => chrome-state-turn.test.ts} (100%) diff --git a/src/session/archive-uri.test.ts b/src/session/compaction-archive-refs.test.ts similarity index 100% rename from src/session/archive-uri.test.ts rename to src/session/compaction-archive-refs.test.ts diff --git a/src/session/summary-excerpt.test.ts b/src/session/summarizer-excerpt.test.ts similarity index 100% rename from src/session/summary-excerpt.test.ts rename to src/session/summarizer-excerpt.test.ts diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 9a1d84686..540e496b2 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -32,6 +32,12 @@ import { readSourceCredentialMaterial } from "../config/source-credentials.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); +// Token-budgeted compaction excerpt from the evidence archive. +// +// The live transcript is a clipped view. The archive holds the authorized +// payloads compaction is about to drop, so the summary call should read those +// rather than 400-character stubs. Budget is the control: later kinds yield +// when earlier ones fill the window. Gap rows contribute metadata only. export const SUMMARY_EXCERPT_DEFAULT_BUDGET_CHARS = 80_000; const KIND_PRIORITY: readonly ArchiveKind[] = [ diff --git a/src/tui/session-chrome.test.ts b/src/tui/chrome-state-turn.test.ts similarity index 100% rename from src/tui/session-chrome.test.ts rename to src/tui/chrome-state-turn.test.ts