@threadbase-sh/streamer 1.53.1 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -169,6 +169,24 @@ declare function isProviderName(value: unknown): value is ProviderName;
169
169
  declare function isProviderResumable(_provider: string | null | undefined, availabilityResumable: boolean): boolean;
170
170
 
171
171
  type SessionStatus = "running" | "waiting_input" | "idle";
172
+ /**
173
+ * Phase axis *inside* `status === "running"` — what the agent is doing during a
174
+ * turn. Deliberately a separate field rather than new SessionStatus members:
175
+ * VALID_STATUSES rejects unknown values and the store drops sessions outside
176
+ * the requested set, so a new status string would make those sessions vanish
177
+ * from already-shipped apps. Additive fields are safe; additive values in a
178
+ * union a shipped client filters on are not.
179
+ *
180
+ * The full set is defined here even though Codex only ever emits `working`
181
+ * (its status bar is binary — Ready/Working, and claiming otherwise would be
182
+ * invention). Defining it up front keeps a two-valued provider from fixing the
183
+ * field's shape before Claude's richer footer lands. Consumers must ignore an
184
+ * unrecognised value rather than coerce it.
185
+ *
186
+ * This union lives in exactly one place. Two independently-maintained copies of
187
+ * a TUI-derived grammar have already drifted once (tb-mobile PR #647).
188
+ */
189
+ type AgentPhase = "thinking" | "streaming" | "hooks" | "acting" | "working";
172
190
  /**
173
191
  * Process-lifetime axis for a managed session (C1 durable session runtime).
174
192
  * Orthogonal to SessionStatus — see SessionResponse.lifecycle for why the two
@@ -233,6 +251,20 @@ interface ManagedSession {
233
251
  */
234
252
  statusSource?: StatusSource$1;
235
253
  statusUpdatedAt?: Date;
254
+ /**
255
+ * Agent phase within a running turn, scraped from the rendered screen.
256
+ * Optional internally (every existing construction site predates it), but
257
+ * managedToResponse emits it unconditionally as `?? null` — on the wire
258
+ * absence must never be a third state, because the client merges session
259
+ * frames and a merge cannot express a removed key.
260
+ *
261
+ * Cleared in markReady() for the running -> waiting_input turn end, which is
262
+ * the only exit a runner observes on screen. Every other way out of `running`
263
+ * — handleExit, putOnHold, failStartup — is enforced by SessionStore
264
+ * .updateManaged() instead: a phase exists only while the status is
265
+ * `running`, so leaving it clears the field.
266
+ */
267
+ subStatus?: AgentPhase | null;
236
268
  filePath?: string;
237
269
  resumedFromConversationId?: string;
238
270
  /**
@@ -339,6 +371,28 @@ type WSMessage = {
339
371
  stage?: Stage | string;
340
372
  stalledSinceMs?: number;
341
373
  reworkAttempt?: number;
374
+ }
375
+ /**
376
+ * Agent phase changed within a running turn. Scoped to that session's
377
+ * subscribers, like terminal_output and user_message.
378
+ *
379
+ * A minimal frame rather than a SessionResponse copy, deliberately:
380
+ * managedToResponse recomputes `elapsedMs` from `new Date()` on every call
381
+ * for a live session, so a session copy would differ on every tick whether
382
+ * or not the phase changed — and a client that merges frames would get a
383
+ * fresh object identity several times a second, re-rendering every consumer
384
+ * for the whole turn.
385
+ *
386
+ * `phase` is always present and is `null` when there is no phase. Absence
387
+ * must never carry meaning: clients merge session state, and a merge cannot
388
+ * express a removed key, so an omitted field would keep its previous value
389
+ * and the indicator would latch on a finished turn.
390
+ */
391
+ | {
392
+ type: "session_phase";
393
+ sessionId: string;
394
+ phase: AgentPhase | null;
395
+ updatedAt: string;
342
396
  } | {
343
397
  type: "session_list";
344
398
  sessions: readonly SessionResponse[];
@@ -504,6 +558,20 @@ interface SessionResponse {
504
558
  * Live sessions only.
505
559
  */
506
560
  permissionMode?: string;
561
+ /**
562
+ * Agent phase within a running turn, scraped from the rendered PTY screen.
563
+ *
564
+ * NOT optional, and always serialised — `null` when there is no phase. A
565
+ * client that merges session frames (`{...prev, ...next}`) cannot express a
566
+ * removed key, so an omitted field would keep its previous value and the
567
+ * indicator would latch on a finished turn. That is the bug tb-mobile PR #647
568
+ * shipped; absence must never carry meaning here.
569
+ *
570
+ * Consequently this must NOT be moved into the `...(x != null && { x })`
571
+ * guard block in managedToResponse: `!= null` catches null and undefined
572
+ * alike and would convert an explicit clear back into absence.
573
+ */
574
+ subStatus: AgentPhase | null;
507
575
  account?: string;
508
576
  messageCount?: number;
509
577
  preview?: string;
@@ -630,6 +698,24 @@ interface PTYManagerOptions {
630
698
  options: PermissionOption[];
631
699
  cursor?: number;
632
700
  } | null) => void;
701
+ /**
702
+ * Fired when the agent's phase within a running turn changes, including to
703
+ * `null` at turn end. Additive; absent in tests that omit it.
704
+ *
705
+ * Deliberately NOT routed through onStatusChange, even though that callback
706
+ * already exists and is already relayed across the pty-host boundary. Its
707
+ * handler writes a DB row per invocation with no same-status guard, refreshes
708
+ * the scanner index, broadcasts globally, and pokes the APNs and push
709
+ * notifiers — machinery built for a handful of transitions per session, not
710
+ * for a signal that can fire every SCRAPE_THROTTLE_MS.
711
+ *
712
+ * The server must broadcast this to that session's subscribers only
713
+ * (wsHub.broadcastToClients), as a minimal frame rather than a SessionResponse
714
+ * copy: managedToResponse recomputes elapsedMs on every call, so a session
715
+ * copy would differ every tick and re-render every client consumer of that
716
+ * session for the whole turn.
717
+ */
718
+ onPhaseChange?: (sessionId: string, phase: AgentPhase | null) => void;
633
719
  onLiveQuestion?: (sessionId: string, questions: AskQuestion[]) => void;
634
720
  onLiveQuestionGone?: (sessionId: string) => void;
635
721
  onUserMessage?: (sessionId: string, text: string, ts: number) => void;
@@ -1942,6 +2028,7 @@ declare class PTYManager implements SessionRunner {
1942
2028
  private onStatusChange;
1943
2029
  private onReady;
1944
2030
  private onPermissionChange;
2031
+ private onPhaseChange;
1945
2032
  private onLiveQuestion;
1946
2033
  private onLiveQuestionGone;
1947
2034
  private onUserMessage;
@@ -1986,6 +2073,15 @@ declare class PTYManager implements SessionRunner {
1986
2073
  private recheckReadyFromScreen;
1987
2074
  private armReadyFallback;
1988
2075
  private clearReadyFallback;
2076
+ /**
2077
+ * Record the agent's phase and notify only on a real change.
2078
+ *
2079
+ * The change guard is load-bearing, not an optimisation: the scrape pass runs
2080
+ * on every chunk, so an unguarded setter would fire the callback — and the
2081
+ * WS frame behind it — several times a second for the entire duration of a
2082
+ * turn while reporting the same value.
2083
+ */
2084
+ private setPhase;
1989
2085
  private markReady;
1990
2086
  private handleExit;
1991
2087
  }
@@ -2375,4 +2471,4 @@ declare class StreamerServer {
2375
2471
  private applyLiveSessionSetting;
2376
2472
  }
2377
2473
 
2378
- export { type AgentClient, type AgentClientOpts, type AgentConfig, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type CacheAlertResolveAction, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProcessLiveness, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type ServerWarmingUpResponse, type ServerWarmupState, type SessionActivity, type SessionCursor, type SessionLifecycle, type SessionListPage, type SessionListQuery, type SessionOwnership, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartForkSessionOptions, type StartFreshSessionOptions, type StartSessionOptions, type StatusConfidence, type StatusSource$1 as StatusSource, StreamerServer, type UserMessage, WSHub, type WSMessage, confidenceForSource, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };
2474
+ export { type AgentClient, type AgentClientOpts, type AgentConfig, type AgentPhase, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type CacheAlertResolveAction, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProcessLiveness, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type ServerWarmingUpResponse, type ServerWarmupState, type SessionActivity, type SessionCursor, type SessionLifecycle, type SessionListPage, type SessionListQuery, type SessionOwnership, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartForkSessionOptions, type StartFreshSessionOptions, type StartSessionOptions, type StatusConfidence, type StatusSource$1 as StatusSource, StreamerServer, type UserMessage, WSHub, type WSMessage, confidenceForSource, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };
package/dist/index.d.ts CHANGED
@@ -169,6 +169,24 @@ declare function isProviderName(value: unknown): value is ProviderName;
169
169
  declare function isProviderResumable(_provider: string | null | undefined, availabilityResumable: boolean): boolean;
170
170
 
171
171
  type SessionStatus = "running" | "waiting_input" | "idle";
172
+ /**
173
+ * Phase axis *inside* `status === "running"` — what the agent is doing during a
174
+ * turn. Deliberately a separate field rather than new SessionStatus members:
175
+ * VALID_STATUSES rejects unknown values and the store drops sessions outside
176
+ * the requested set, so a new status string would make those sessions vanish
177
+ * from already-shipped apps. Additive fields are safe; additive values in a
178
+ * union a shipped client filters on are not.
179
+ *
180
+ * The full set is defined here even though Codex only ever emits `working`
181
+ * (its status bar is binary — Ready/Working, and claiming otherwise would be
182
+ * invention). Defining it up front keeps a two-valued provider from fixing the
183
+ * field's shape before Claude's richer footer lands. Consumers must ignore an
184
+ * unrecognised value rather than coerce it.
185
+ *
186
+ * This union lives in exactly one place. Two independently-maintained copies of
187
+ * a TUI-derived grammar have already drifted once (tb-mobile PR #647).
188
+ */
189
+ type AgentPhase = "thinking" | "streaming" | "hooks" | "acting" | "working";
172
190
  /**
173
191
  * Process-lifetime axis for a managed session (C1 durable session runtime).
174
192
  * Orthogonal to SessionStatus — see SessionResponse.lifecycle for why the two
@@ -233,6 +251,20 @@ interface ManagedSession {
233
251
  */
234
252
  statusSource?: StatusSource$1;
235
253
  statusUpdatedAt?: Date;
254
+ /**
255
+ * Agent phase within a running turn, scraped from the rendered screen.
256
+ * Optional internally (every existing construction site predates it), but
257
+ * managedToResponse emits it unconditionally as `?? null` — on the wire
258
+ * absence must never be a third state, because the client merges session
259
+ * frames and a merge cannot express a removed key.
260
+ *
261
+ * Cleared in markReady() for the running -> waiting_input turn end, which is
262
+ * the only exit a runner observes on screen. Every other way out of `running`
263
+ * — handleExit, putOnHold, failStartup — is enforced by SessionStore
264
+ * .updateManaged() instead: a phase exists only while the status is
265
+ * `running`, so leaving it clears the field.
266
+ */
267
+ subStatus?: AgentPhase | null;
236
268
  filePath?: string;
237
269
  resumedFromConversationId?: string;
238
270
  /**
@@ -339,6 +371,28 @@ type WSMessage = {
339
371
  stage?: Stage | string;
340
372
  stalledSinceMs?: number;
341
373
  reworkAttempt?: number;
374
+ }
375
+ /**
376
+ * Agent phase changed within a running turn. Scoped to that session's
377
+ * subscribers, like terminal_output and user_message.
378
+ *
379
+ * A minimal frame rather than a SessionResponse copy, deliberately:
380
+ * managedToResponse recomputes `elapsedMs` from `new Date()` on every call
381
+ * for a live session, so a session copy would differ on every tick whether
382
+ * or not the phase changed — and a client that merges frames would get a
383
+ * fresh object identity several times a second, re-rendering every consumer
384
+ * for the whole turn.
385
+ *
386
+ * `phase` is always present and is `null` when there is no phase. Absence
387
+ * must never carry meaning: clients merge session state, and a merge cannot
388
+ * express a removed key, so an omitted field would keep its previous value
389
+ * and the indicator would latch on a finished turn.
390
+ */
391
+ | {
392
+ type: "session_phase";
393
+ sessionId: string;
394
+ phase: AgentPhase | null;
395
+ updatedAt: string;
342
396
  } | {
343
397
  type: "session_list";
344
398
  sessions: readonly SessionResponse[];
@@ -504,6 +558,20 @@ interface SessionResponse {
504
558
  * Live sessions only.
505
559
  */
506
560
  permissionMode?: string;
561
+ /**
562
+ * Agent phase within a running turn, scraped from the rendered PTY screen.
563
+ *
564
+ * NOT optional, and always serialised — `null` when there is no phase. A
565
+ * client that merges session frames (`{...prev, ...next}`) cannot express a
566
+ * removed key, so an omitted field would keep its previous value and the
567
+ * indicator would latch on a finished turn. That is the bug tb-mobile PR #647
568
+ * shipped; absence must never carry meaning here.
569
+ *
570
+ * Consequently this must NOT be moved into the `...(x != null && { x })`
571
+ * guard block in managedToResponse: `!= null` catches null and undefined
572
+ * alike and would convert an explicit clear back into absence.
573
+ */
574
+ subStatus: AgentPhase | null;
507
575
  account?: string;
508
576
  messageCount?: number;
509
577
  preview?: string;
@@ -630,6 +698,24 @@ interface PTYManagerOptions {
630
698
  options: PermissionOption[];
631
699
  cursor?: number;
632
700
  } | null) => void;
701
+ /**
702
+ * Fired when the agent's phase within a running turn changes, including to
703
+ * `null` at turn end. Additive; absent in tests that omit it.
704
+ *
705
+ * Deliberately NOT routed through onStatusChange, even though that callback
706
+ * already exists and is already relayed across the pty-host boundary. Its
707
+ * handler writes a DB row per invocation with no same-status guard, refreshes
708
+ * the scanner index, broadcasts globally, and pokes the APNs and push
709
+ * notifiers — machinery built for a handful of transitions per session, not
710
+ * for a signal that can fire every SCRAPE_THROTTLE_MS.
711
+ *
712
+ * The server must broadcast this to that session's subscribers only
713
+ * (wsHub.broadcastToClients), as a minimal frame rather than a SessionResponse
714
+ * copy: managedToResponse recomputes elapsedMs on every call, so a session
715
+ * copy would differ every tick and re-render every client consumer of that
716
+ * session for the whole turn.
717
+ */
718
+ onPhaseChange?: (sessionId: string, phase: AgentPhase | null) => void;
633
719
  onLiveQuestion?: (sessionId: string, questions: AskQuestion[]) => void;
634
720
  onLiveQuestionGone?: (sessionId: string) => void;
635
721
  onUserMessage?: (sessionId: string, text: string, ts: number) => void;
@@ -1942,6 +2028,7 @@ declare class PTYManager implements SessionRunner {
1942
2028
  private onStatusChange;
1943
2029
  private onReady;
1944
2030
  private onPermissionChange;
2031
+ private onPhaseChange;
1945
2032
  private onLiveQuestion;
1946
2033
  private onLiveQuestionGone;
1947
2034
  private onUserMessage;
@@ -1986,6 +2073,15 @@ declare class PTYManager implements SessionRunner {
1986
2073
  private recheckReadyFromScreen;
1987
2074
  private armReadyFallback;
1988
2075
  private clearReadyFallback;
2076
+ /**
2077
+ * Record the agent's phase and notify only on a real change.
2078
+ *
2079
+ * The change guard is load-bearing, not an optimisation: the scrape pass runs
2080
+ * on every chunk, so an unguarded setter would fire the callback — and the
2081
+ * WS frame behind it — several times a second for the entire duration of a
2082
+ * turn while reporting the same value.
2083
+ */
2084
+ private setPhase;
1989
2085
  private markReady;
1990
2086
  private handleExit;
1991
2087
  }
@@ -2375,4 +2471,4 @@ declare class StreamerServer {
2375
2471
  private applyLiveSessionSetting;
2376
2472
  }
2377
2473
 
2378
- export { type AgentClient, type AgentClientOpts, type AgentConfig, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type CacheAlertResolveAction, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProcessLiveness, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type ServerWarmingUpResponse, type ServerWarmupState, type SessionActivity, type SessionCursor, type SessionLifecycle, type SessionListPage, type SessionListQuery, type SessionOwnership, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartForkSessionOptions, type StartFreshSessionOptions, type StartSessionOptions, type StatusConfidence, type StatusSource$1 as StatusSource, StreamerServer, type UserMessage, WSHub, type WSMessage, confidenceForSource, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };
2474
+ export { type AgentClient, type AgentClientOpts, type AgentConfig, type AgentPhase, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type CacheAlertResolveAction, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProcessLiveness, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type ServerWarmingUpResponse, type ServerWarmupState, type SessionActivity, type SessionCursor, type SessionLifecycle, type SessionListPage, type SessionListQuery, type SessionOwnership, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartForkSessionOptions, type StartFreshSessionOptions, type StartSessionOptions, type StatusConfidence, type StatusSource$1 as StatusSource, StreamerServer, type UserMessage, WSHub, type WSMessage, confidenceForSource, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };
package/dist/index.js CHANGED
@@ -1061,6 +1061,7 @@ function rememberedGateDigit(gate) {
1061
1061
  // src/services/questions/codexScreen.ts
1062
1062
  var CODEX_PROMPT_READY_TEXT = "Ready";
1063
1063
  var CODEX_BUSY_STATUS_RE = /\b(?:Starting|Working)\b/;
1064
+ var CODEX_WORKING_STATUS_RE = /\bWorking\b/;
1064
1065
  var CODEX_MCP_BOOT_RE = /Booting MCP|Starting MCP servers/i;
1065
1066
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
1066
1067
  var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
@@ -1097,12 +1098,15 @@ function detectCodexBlockingPrompt(lines) {
1097
1098
  function codexStatusBarLine(lines) {
1098
1099
  return [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1099
1100
  }
1100
- function codexScreenBlocksComposer(lines) {
1101
+ function codexScreenPreTurn(lines) {
1101
1102
  const screenText = lines.join("\n");
1102
1103
  if (CODEX_HOOKS_GATE_REGEX.test(screenText) || CODEX_TRUST_GATE_REGEX.test(screenText)) {
1103
1104
  return true;
1104
1105
  }
1105
- if (CODEX_MCP_BOOT_RE.test(screenText)) return true;
1106
+ return CODEX_MCP_BOOT_RE.test(screenText);
1107
+ }
1108
+ function codexScreenBlocksComposer(lines) {
1109
+ if (codexScreenPreTurn(lines)) return true;
1106
1110
  return CODEX_BUSY_STATUS_RE.test(codexStatusBarLine(lines));
1107
1111
  }
1108
1112
  function codexScreenShowsReady(lines) {
@@ -1149,6 +1153,20 @@ function gateCard(gate, lines) {
1149
1153
  };
1150
1154
  }
1151
1155
 
1156
+ // src/services/questions/parseAgentPhase.ts
1157
+ function codexPhase(lines) {
1158
+ if (codexScreenPreTurn(lines)) return null;
1159
+ return CODEX_WORKING_STATUS_RE.test(codexStatusBarLine(lines)) ? "working" : null;
1160
+ }
1161
+ function claudePhase(_lines) {
1162
+ return null;
1163
+ }
1164
+ function parseAgentPhase(lines, provider) {
1165
+ if (provider === CODEX_CLI_PROVIDER) return codexPhase(lines);
1166
+ if (provider === CLAUDE_CODE_PROVIDER) return claudePhase(lines);
1167
+ return null;
1168
+ }
1169
+
1152
1170
  // src/utils/debounce.ts
1153
1171
  function debounce(fn, waitMs) {
1154
1172
  let timer = null;
@@ -1193,6 +1211,7 @@ var CodexPtyRunner = class {
1193
1211
  sessions = /* @__PURE__ */ new Map();
1194
1212
  onOutput;
1195
1213
  onStatusChange;
1214
+ onPhaseChange;
1196
1215
  onReady;
1197
1216
  // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
1198
1217
  // as question cards; null dismisses the card once the gate leaves the screen.
@@ -1241,6 +1260,7 @@ var CodexPtyRunner = class {
1241
1260
  constructor(options = {}) {
1242
1261
  this.onOutput = options.onOutput;
1243
1262
  this.onStatusChange = options.onStatusChange;
1263
+ this.onPhaseChange = options.onPhaseChange;
1244
1264
  this.onReady = options.onReady;
1245
1265
  this.onPermissionChange = options.onPermissionChange;
1246
1266
  this.onLiveQuestion = options.onLiveQuestion;
@@ -1808,6 +1828,9 @@ var CodexPtyRunner = class {
1808
1828
  );
1809
1829
  return;
1810
1830
  }
1831
+ if (session.status === "running") {
1832
+ this.setPhase(sessionId, session, parseAgentPhase(lines, CODEX_CLI_PROVIDER));
1833
+ }
1811
1834
  const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1812
1835
  if (gate) {
1813
1836
  this.handleGate(sessionId, session, gate, lines);
@@ -1922,11 +1945,25 @@ var CodexPtyRunner = class {
1922
1945
  });
1923
1946
  this.onPermissionChange?.(sessionId, card);
1924
1947
  }
1948
+ /**
1949
+ * Record the agent's phase and notify only on a real change. The guard is
1950
+ * load-bearing, not an optimisation: detectScreenState runs on every chunk,
1951
+ * so an unguarded setter would fire the WS frame behind this several times a
1952
+ * second for a whole turn while reporting the same value. Same contract as
1953
+ * PTYManager.setPhase — the two runners deliberately stay separate classes.
1954
+ */
1955
+ setPhase(sessionId, session, phase) {
1956
+ const next = phase ?? null;
1957
+ if ((session.subStatus ?? null) === next) return;
1958
+ session.subStatus = next;
1959
+ this.onPhaseChange?.(sessionId, next);
1960
+ }
1925
1961
  markReady(sessionId, session, source, reason) {
1926
1962
  session.lastActivityAt = /* @__PURE__ */ new Date();
1927
1963
  session.status = "waiting_input";
1928
1964
  session.statusSource = source;
1929
1965
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
1966
+ this.setPhase(sessionId, session, null);
1930
1967
  this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1931
1968
  event: "codex.ready",
1932
1969
  sessionId,
@@ -2018,12 +2055,16 @@ function toPublicSession(s) {
2018
2055
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2019
2056
  ...s.statusSource != null && { statusSource: s.statusSource },
2020
2057
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2021
- ...s.filePath != null && { filePath: s.filePath }
2058
+ ...s.filePath != null && { filePath: s.filePath },
2059
+ // Unconditional — see PTYManager's toPublicSession: a streamer re-adopting
2060
+ // a surviving pty-host's sessions mid-turn has no other source for the
2061
+ // phase, and the host's change guard will not re-emit it.
2062
+ subStatus: s.subStatus ?? null
2022
2063
  };
2023
2064
  }
2024
2065
 
2025
2066
  // src/pty-host/protocol.ts
2026
- var PTY_HOST_PROTOCOL_VERSION = 2;
2067
+ var PTY_HOST_PROTOCOL_VERSION = 3;
2027
2068
  function isHostEvent(message) {
2028
2069
  return "type" in message && message.type === "event";
2029
2070
  }
@@ -2261,6 +2302,9 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2261
2302
  case "permission-change":
2262
2303
  this.options.onPermissionChange?.(event.sessionId, event.gate);
2263
2304
  break;
2305
+ case "phase-change":
2306
+ this.options.onPhaseChange?.(event.sessionId, event.phase);
2307
+ break;
2264
2308
  case "live-question":
2265
2309
  this.options.onLiveQuestion?.(event.sessionId, event.questions);
2266
2310
  break;
@@ -2659,6 +2703,7 @@ var PTYManager = class {
2659
2703
  onStatusChange;
2660
2704
  onReady;
2661
2705
  onPermissionChange;
2706
+ onPhaseChange;
2662
2707
  onLiveQuestion;
2663
2708
  onLiveQuestionGone;
2664
2709
  onUserMessage;
@@ -2720,6 +2765,7 @@ var PTYManager = class {
2720
2765
  this.onStatusChange = options.onStatusChange;
2721
2766
  this.onReady = options.onReady;
2722
2767
  this.onPermissionChange = options.onPermissionChange;
2768
+ this.onPhaseChange = options.onPhaseChange;
2723
2769
  this.onLiveQuestion = options.onLiveQuestion;
2724
2770
  this.onLiveQuestionGone = options.onLiveQuestionGone;
2725
2771
  this.onUserMessage = options.onUserMessage;
@@ -3226,6 +3272,10 @@ var PTYManager = class {
3226
3272
  }
3227
3273
  this.lastDetectAt.set(sessionId, nowMs);
3228
3274
  const lines = await this.getOutputLines(sessionId, 60);
3275
+ const phaseSession = this.sessions.get(sessionId);
3276
+ if (phaseSession?.status === "running") {
3277
+ this.setPhase(sessionId, phaseSession, parseAgentPhase(lines, CLAUDE_CODE_PROVIDER));
3278
+ }
3229
3279
  const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
3230
3280
  if (oscPermission || hasAskFooter || askFooterOnScreen) {
3231
3281
  this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
@@ -3369,12 +3419,27 @@ var PTYManager = class {
3369
3419
  }
3370
3420
  // Transition a session from "running" to "waiting_input", clear pendingReady,
3371
3421
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
3422
+ /**
3423
+ * Record the agent's phase and notify only on a real change.
3424
+ *
3425
+ * The change guard is load-bearing, not an optimisation: the scrape pass runs
3426
+ * on every chunk, so an unguarded setter would fire the callback — and the
3427
+ * WS frame behind it — several times a second for the entire duration of a
3428
+ * turn while reporting the same value.
3429
+ */
3430
+ setPhase(sessionId, session, phase) {
3431
+ const next = phase ?? null;
3432
+ if ((session.subStatus ?? null) === next) return;
3433
+ session.subStatus = next;
3434
+ this.onPhaseChange?.(sessionId, next);
3435
+ }
3372
3436
  markReady(sessionId, session, source, reason) {
3373
3437
  this.clearReadyFallback(sessionId);
3374
3438
  session.lastActivityAt = /* @__PURE__ */ new Date();
3375
3439
  session.status = "waiting_input";
3376
3440
  session.statusSource = source;
3377
3441
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
3442
+ this.setPhase(sessionId, session, null);
3378
3443
  const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
3379
3444
  this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
3380
3445
  event: "pty.ready",
@@ -3438,7 +3503,12 @@ function toPublicSession2(s) {
3438
3503
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
3439
3504
  ...s.filePath != null && { filePath: s.filePath },
3440
3505
  ...s.sessionName != null && { sessionName: s.sessionName },
3441
- ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
3506
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText },
3507
+ // Unconditional: this shape crosses the pty-host boundary, and a streamer
3508
+ // re-adopting a surviving host's sessions mid-turn has no other source for
3509
+ // the phase — no snapshot or replay event carries it, and setPhase's change
3510
+ // guard means the host will never re-emit it for the rest of that turn.
3511
+ subStatus: s.subStatus ?? null
3442
3512
  };
3443
3513
  }
3444
3514
 
@@ -7910,6 +7980,12 @@ function conversationToResumableSession(c) {
7910
7980
  branch: c.branch ?? void 0,
7911
7981
  lastOutput: "",
7912
7982
  elapsedMs: 0,
7983
+ // No PTY behind a cached conversation, so there is no phase — emitted
7984
+ // explicitly for the same reason as in managedToResponse/discoveredToResponse:
7985
+ // the client merges session frames, so an absent key keeps the previous
7986
+ // value and the indicator latches. `GET /api/sessions/:id` serves this
7987
+ // shape whenever the id is a conversation rather than a live session.
7988
+ subStatus: null,
7913
7989
  promptCount: c.messageCount,
7914
7990
  startedAt: c.lastActivity,
7915
7991
  completedAt: null,
@@ -11868,6 +11944,15 @@ function createLiveSessionOptions(deps) {
11868
11944
  seq
11869
11945
  });
11870
11946
  },
11947
+ onPhaseChange: (sessionId, phase) => {
11948
+ deps.sessionStore.updateManaged(sessionId, { subStatus: phase });
11949
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(sessionId) ?? [], {
11950
+ type: "session_phase",
11951
+ sessionId,
11952
+ phase,
11953
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
11954
+ });
11955
+ },
11871
11956
  onUserMessage: (sessionId, text, ts) => {
11872
11957
  deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(sessionId) ?? [], {
11873
11958
  type: "user_message",
@@ -13897,6 +13982,7 @@ var SessionStore = class {
13897
13982
  const session = this.managed.get(sessionId);
13898
13983
  if (!session) return null;
13899
13984
  Object.assign(session, updates);
13985
+ if (updates.status != null && updates.status !== "running") session.subStatus = null;
13900
13986
  return session;
13901
13987
  }
13902
13988
  removeManaged(sessionId) {
@@ -14077,6 +14163,13 @@ function managedToResponse(s, ptyAttached) {
14077
14163
  promptCount: s.promptCount,
14078
14164
  startedAt: s.startedAt.toISOString(),
14079
14165
  completedAt: s.completedAt?.toISOString() ?? null,
14166
+ // Unconditional, like completedAt above — do NOT move this into the
14167
+ // `...(x != null && { x })` block below. That guard uses loose `!=`, which
14168
+ // catches null as well as undefined, and would turn an explicit "no phase"
14169
+ // back into an absent key. The client merges session frames, so an absent
14170
+ // key keeps the previous value and the indicator latches on a finished
14171
+ // turn — the tb-mobile PR #647 bug, arriving through the serialiser.
14172
+ subStatus: s.subStatus ?? null,
14080
14173
  ptyAttached,
14081
14174
  ...s.projectId != null && { projectId: s.projectId },
14082
14175
  ...s.sessionName != null && { sessionName: s.sessionName },
@@ -14127,6 +14220,10 @@ function discoveredToResponse(d, conversationId) {
14127
14220
  // cannot see the process's prompt state.
14128
14221
  lifecycle: "detached",
14129
14222
  lifecycleSource: "probe",
14223
+ // No PTY here, so nothing to scrape and no phase to report. Emitted
14224
+ // explicitly rather than omitted, for the same reason as in
14225
+ // managedToResponse: absence must never be a third state on the wire.
14226
+ subStatus: null,
14130
14227
  projectPath: d.projectPath,
14131
14228
  projectName: d.projectName,
14132
14229
  branch: d.branch,