@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.cjs CHANGED
@@ -1114,6 +1114,7 @@ function rememberedGateDigit(gate) {
1114
1114
  // src/services/questions/codexScreen.ts
1115
1115
  var CODEX_PROMPT_READY_TEXT = "Ready";
1116
1116
  var CODEX_BUSY_STATUS_RE = /\b(?:Starting|Working)\b/;
1117
+ var CODEX_WORKING_STATUS_RE = /\bWorking\b/;
1117
1118
  var CODEX_MCP_BOOT_RE = /Booting MCP|Starting MCP servers/i;
1118
1119
  var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
1119
1120
  var CODEX_HOOKS_GATE_REGEX = /hooks need review/i;
@@ -1150,12 +1151,15 @@ function detectCodexBlockingPrompt(lines) {
1150
1151
  function codexStatusBarLine(lines) {
1151
1152
  return [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1152
1153
  }
1153
- function codexScreenBlocksComposer(lines) {
1154
+ function codexScreenPreTurn(lines) {
1154
1155
  const screenText = lines.join("\n");
1155
1156
  if (CODEX_HOOKS_GATE_REGEX.test(screenText) || CODEX_TRUST_GATE_REGEX.test(screenText)) {
1156
1157
  return true;
1157
1158
  }
1158
- if (CODEX_MCP_BOOT_RE.test(screenText)) return true;
1159
+ return CODEX_MCP_BOOT_RE.test(screenText);
1160
+ }
1161
+ function codexScreenBlocksComposer(lines) {
1162
+ if (codexScreenPreTurn(lines)) return true;
1159
1163
  return CODEX_BUSY_STATUS_RE.test(codexStatusBarLine(lines));
1160
1164
  }
1161
1165
  function codexScreenShowsReady(lines) {
@@ -1202,6 +1206,20 @@ function gateCard(gate, lines) {
1202
1206
  };
1203
1207
  }
1204
1208
 
1209
+ // src/services/questions/parseAgentPhase.ts
1210
+ function codexPhase(lines) {
1211
+ if (codexScreenPreTurn(lines)) return null;
1212
+ return CODEX_WORKING_STATUS_RE.test(codexStatusBarLine(lines)) ? "working" : null;
1213
+ }
1214
+ function claudePhase(_lines) {
1215
+ return null;
1216
+ }
1217
+ function parseAgentPhase(lines, provider) {
1218
+ if (provider === CODEX_CLI_PROVIDER) return codexPhase(lines);
1219
+ if (provider === CLAUDE_CODE_PROVIDER) return claudePhase(lines);
1220
+ return null;
1221
+ }
1222
+
1205
1223
  // src/utils/debounce.ts
1206
1224
  function debounce(fn, waitMs) {
1207
1225
  let timer = null;
@@ -1246,6 +1264,7 @@ var CodexPtyRunner = class {
1246
1264
  sessions = /* @__PURE__ */ new Map();
1247
1265
  onOutput;
1248
1266
  onStatusChange;
1267
+ onPhaseChange;
1249
1268
  onReady;
1250
1269
  // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
1251
1270
  // as question cards; null dismisses the card once the gate leaves the screen.
@@ -1294,6 +1313,7 @@ var CodexPtyRunner = class {
1294
1313
  constructor(options = {}) {
1295
1314
  this.onOutput = options.onOutput;
1296
1315
  this.onStatusChange = options.onStatusChange;
1316
+ this.onPhaseChange = options.onPhaseChange;
1297
1317
  this.onReady = options.onReady;
1298
1318
  this.onPermissionChange = options.onPermissionChange;
1299
1319
  this.onLiveQuestion = options.onLiveQuestion;
@@ -1861,6 +1881,9 @@ var CodexPtyRunner = class {
1861
1881
  );
1862
1882
  return;
1863
1883
  }
1884
+ if (session.status === "running") {
1885
+ this.setPhase(sessionId, session, parseAgentPhase(lines, CODEX_CLI_PROVIDER));
1886
+ }
1864
1887
  const gate = CODEX_HOOKS_GATE_REGEX.test(screenText) ? "hooks" : CODEX_TRUST_GATE_REGEX.test(screenText) ? "trust" : null;
1865
1888
  if (gate) {
1866
1889
  this.handleGate(sessionId, session, gate, lines);
@@ -1975,11 +1998,25 @@ var CodexPtyRunner = class {
1975
1998
  });
1976
1999
  this.onPermissionChange?.(sessionId, card);
1977
2000
  }
2001
+ /**
2002
+ * Record the agent's phase and notify only on a real change. The guard is
2003
+ * load-bearing, not an optimisation: detectScreenState runs on every chunk,
2004
+ * so an unguarded setter would fire the WS frame behind this several times a
2005
+ * second for a whole turn while reporting the same value. Same contract as
2006
+ * PTYManager.setPhase — the two runners deliberately stay separate classes.
2007
+ */
2008
+ setPhase(sessionId, session, phase) {
2009
+ const next = phase ?? null;
2010
+ if ((session.subStatus ?? null) === next) return;
2011
+ session.subStatus = next;
2012
+ this.onPhaseChange?.(sessionId, next);
2013
+ }
1978
2014
  markReady(sessionId, session, source, reason) {
1979
2015
  session.lastActivityAt = /* @__PURE__ */ new Date();
1980
2016
  session.status = "waiting_input";
1981
2017
  session.statusSource = source;
1982
2018
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
2019
+ this.setPhase(sessionId, session, null);
1983
2020
  this.log.info(`[codex.ready] ${sessionId.slice(0, 8)} ${reason}`, {
1984
2021
  event: "codex.ready",
1985
2022
  sessionId,
@@ -2071,12 +2108,16 @@ function toPublicSession(s) {
2071
2108
  ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
2072
2109
  ...s.statusSource != null && { statusSource: s.statusSource },
2073
2110
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
2074
- ...s.filePath != null && { filePath: s.filePath }
2111
+ ...s.filePath != null && { filePath: s.filePath },
2112
+ // Unconditional — see PTYManager's toPublicSession: a streamer re-adopting
2113
+ // a surviving pty-host's sessions mid-turn has no other source for the
2114
+ // phase, and the host's change guard will not re-emit it.
2115
+ subStatus: s.subStatus ?? null
2075
2116
  };
2076
2117
  }
2077
2118
 
2078
2119
  // src/pty-host/protocol.ts
2079
- var PTY_HOST_PROTOCOL_VERSION = 2;
2120
+ var PTY_HOST_PROTOCOL_VERSION = 3;
2080
2121
  function isHostEvent(message) {
2081
2122
  return "type" in message && message.type === "event";
2082
2123
  }
@@ -2314,6 +2355,9 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2314
2355
  case "permission-change":
2315
2356
  this.options.onPermissionChange?.(event.sessionId, event.gate);
2316
2357
  break;
2358
+ case "phase-change":
2359
+ this.options.onPhaseChange?.(event.sessionId, event.phase);
2360
+ break;
2317
2361
  case "live-question":
2318
2362
  this.options.onLiveQuestion?.(event.sessionId, event.questions);
2319
2363
  break;
@@ -2712,6 +2756,7 @@ var PTYManager = class {
2712
2756
  onStatusChange;
2713
2757
  onReady;
2714
2758
  onPermissionChange;
2759
+ onPhaseChange;
2715
2760
  onLiveQuestion;
2716
2761
  onLiveQuestionGone;
2717
2762
  onUserMessage;
@@ -2773,6 +2818,7 @@ var PTYManager = class {
2773
2818
  this.onStatusChange = options.onStatusChange;
2774
2819
  this.onReady = options.onReady;
2775
2820
  this.onPermissionChange = options.onPermissionChange;
2821
+ this.onPhaseChange = options.onPhaseChange;
2776
2822
  this.onLiveQuestion = options.onLiveQuestion;
2777
2823
  this.onLiveQuestionGone = options.onLiveQuestionGone;
2778
2824
  this.onUserMessage = options.onUserMessage;
@@ -3279,6 +3325,10 @@ var PTYManager = class {
3279
3325
  }
3280
3326
  this.lastDetectAt.set(sessionId, nowMs);
3281
3327
  const lines = await this.getOutputLines(sessionId, 60);
3328
+ const phaseSession = this.sessions.get(sessionId);
3329
+ if (phaseSession?.status === "running") {
3330
+ this.setPhase(sessionId, phaseSession, parseAgentPhase(lines, CLAUDE_CODE_PROVIDER));
3331
+ }
3282
3332
  const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
3283
3333
  if (oscPermission || hasAskFooter || askFooterOnScreen) {
3284
3334
  this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
@@ -3422,12 +3472,27 @@ var PTYManager = class {
3422
3472
  }
3423
3473
  // Transition a session from "running" to "waiting_input", clear pendingReady,
3424
3474
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
3475
+ /**
3476
+ * Record the agent's phase and notify only on a real change.
3477
+ *
3478
+ * The change guard is load-bearing, not an optimisation: the scrape pass runs
3479
+ * on every chunk, so an unguarded setter would fire the callback — and the
3480
+ * WS frame behind it — several times a second for the entire duration of a
3481
+ * turn while reporting the same value.
3482
+ */
3483
+ setPhase(sessionId, session, phase) {
3484
+ const next = phase ?? null;
3485
+ if ((session.subStatus ?? null) === next) return;
3486
+ session.subStatus = next;
3487
+ this.onPhaseChange?.(sessionId, next);
3488
+ }
3425
3489
  markReady(sessionId, session, source, reason) {
3426
3490
  this.clearReadyFallback(sessionId);
3427
3491
  session.lastActivityAt = /* @__PURE__ */ new Date();
3428
3492
  session.status = "waiting_input";
3429
3493
  session.statusSource = source;
3430
3494
  session.statusUpdatedAt = /* @__PURE__ */ new Date();
3495
+ this.setPhase(sessionId, session, null);
3431
3496
  const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
3432
3497
  this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
3433
3498
  event: "pty.ready",
@@ -3491,7 +3556,12 @@ function toPublicSession2(s) {
3491
3556
  ...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
3492
3557
  ...s.filePath != null && { filePath: s.filePath },
3493
3558
  ...s.sessionName != null && { sessionName: s.sessionName },
3494
- ...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
3559
+ ...s.firstMessageText != null && { firstMessageText: s.firstMessageText },
3560
+ // Unconditional: this shape crosses the pty-host boundary, and a streamer
3561
+ // re-adopting a surviving host's sessions mid-turn has no other source for
3562
+ // the phase — no snapshot or replay event carries it, and setPhase's change
3563
+ // guard means the host will never re-emit it for the rest of that turn.
3564
+ subStatus: s.subStatus ?? null
3495
3565
  };
3496
3566
  }
3497
3567
 
@@ -7955,6 +8025,12 @@ function conversationToResumableSession(c) {
7955
8025
  branch: c.branch ?? void 0,
7956
8026
  lastOutput: "",
7957
8027
  elapsedMs: 0,
8028
+ // No PTY behind a cached conversation, so there is no phase — emitted
8029
+ // explicitly for the same reason as in managedToResponse/discoveredToResponse:
8030
+ // the client merges session frames, so an absent key keeps the previous
8031
+ // value and the indicator latches. `GET /api/sessions/:id` serves this
8032
+ // shape whenever the id is a conversation rather than a live session.
8033
+ subStatus: null,
7958
8034
  promptCount: c.messageCount,
7959
8035
  startedAt: c.lastActivity,
7960
8036
  completedAt: null,
@@ -11911,6 +11987,15 @@ function createLiveSessionOptions(deps) {
11911
11987
  seq
11912
11988
  });
11913
11989
  },
11990
+ onPhaseChange: (sessionId, phase) => {
11991
+ deps.sessionStore.updateManaged(sessionId, { subStatus: phase });
11992
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(sessionId) ?? [], {
11993
+ type: "session_phase",
11994
+ sessionId,
11995
+ phase,
11996
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
11997
+ });
11998
+ },
11914
11999
  onUserMessage: (sessionId, text, ts) => {
11915
12000
  deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(sessionId) ?? [], {
11916
12001
  type: "user_message",
@@ -13940,6 +14025,7 @@ var SessionStore = class {
13940
14025
  const session = this.managed.get(sessionId);
13941
14026
  if (!session) return null;
13942
14027
  Object.assign(session, updates);
14028
+ if (updates.status != null && updates.status !== "running") session.subStatus = null;
13943
14029
  return session;
13944
14030
  }
13945
14031
  removeManaged(sessionId) {
@@ -14120,6 +14206,13 @@ function managedToResponse(s, ptyAttached) {
14120
14206
  promptCount: s.promptCount,
14121
14207
  startedAt: s.startedAt.toISOString(),
14122
14208
  completedAt: s.completedAt?.toISOString() ?? null,
14209
+ // Unconditional, like completedAt above — do NOT move this into the
14210
+ // `...(x != null && { x })` block below. That guard uses loose `!=`, which
14211
+ // catches null as well as undefined, and would turn an explicit "no phase"
14212
+ // back into an absent key. The client merges session frames, so an absent
14213
+ // key keeps the previous value and the indicator latches on a finished
14214
+ // turn — the tb-mobile PR #647 bug, arriving through the serialiser.
14215
+ subStatus: s.subStatus ?? null,
14123
14216
  ptyAttached,
14124
14217
  ...s.projectId != null && { projectId: s.projectId },
14125
14218
  ...s.sessionName != null && { sessionName: s.sessionName },
@@ -14170,6 +14263,10 @@ function discoveredToResponse(d, conversationId) {
14170
14263
  // cannot see the process's prompt state.
14171
14264
  lifecycle: "detached",
14172
14265
  lifecycleSource: "probe",
14266
+ // No PTY here, so nothing to scrape and no phase to report. Emitted
14267
+ // explicitly rather than omitted, for the same reason as in
14268
+ // managedToResponse: absence must never be a third state on the wire.
14269
+ subStatus: null,
14173
14270
  projectPath: d.projectPath,
14174
14271
  projectName: d.projectName,
14175
14272
  branch: d.branch,