@turmind/halo 1.5.0-alpha → 1.5.2-alpha

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.js CHANGED
@@ -512,7 +512,6 @@ function generalSection() {
512
512
  { key: "compact.keep_messages", type: "int", globalOnly: true, description: "Recent messages kept intact during compaction", description_zh: "\u538B\u7F29\u65F6\u4FDD\u7559\u6700\u540E\u591A\u5C11\u6761\u6D88\u606F\u4E0D\u52A8", default: "5" },
513
513
  { key: "compact.max_summary_input", type: "int", globalOnly: true, description: "Max chars fed into local truncation fallback", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u7684\u603B\u8F93\u5165\u5B57\u7B26\u4E0A\u9650", default: "15000" },
514
514
  { key: "compact.max_message_slice", type: "int", globalOnly: true, description: "Max chars kept per old message during local truncation", description_zh: "\u672C\u5730\u622A\u65AD\u515C\u5E95\u65F6\u6BCF\u6761\u65E7\u6D88\u606F\u4FDD\u7559\u7684\u6700\u5927\u5B57\u7B26\u6570", default: "800" },
515
- { key: "compact.summarize_timeout_sec", type: "int", globalOnly: true, description: "LLM summarization timeout (seconds)", description_zh: "LLM \u6458\u8981\u8D85\u65F6\u65F6\u95F4\uFF08\u79D2\uFF09", default: "300" },
516
515
  // sandbox (Linux bwrap / macOS sandbox-exec) — global-only: these define the security
517
516
  // boundary agents run inside; a workspace overriding them could lift
518
517
  // its own sandbox constraints.
@@ -1149,9 +1148,10 @@ var init_config = __esm({
1149
1148
  // connection (server accepted but never sends bytes / sends no RST) hangs
1150
1149
  // the await indefinitely. Bounds each callModel; on expiry the request
1151
1150
  // aborts and the agent loop's retry takes over. Streaming providers reset
1152
- // the timer on every chunk, so for them it is an idle timeout — a long but
1153
- // live stream is never cut off.
1154
- modelRequest: systemInt("HALO_MODEL_TIMEOUT", "timeout.model_request", 30 * 6e4),
1151
+ // the timer on any data the stream delivers (pings, tool-argument
1152
+ // fragments, SSE comments included), so for them it is an idle timeout — a
1153
+ // long but live stream is never cut off.
1154
+ modelRequest: systemInt("HALO_MODEL_TIMEOUT", "timeout.model_request", 10 * 6e4),
1155
1155
  sessionGrace: systemInt("HALO_SESSION_GRACE", "timeout.session_grace", 5 * 6e4),
1156
1156
  terminalGrace: systemInt("HALO_TERMINAL_GRACE", "timeout.terminal_grace", 5 * 6e4)
1157
1157
  },
@@ -1188,8 +1188,7 @@ var init_config = __esm({
1188
1188
  compact: {
1189
1189
  keep_messages: settingsInt("general.compact.keep_messages", 5),
1190
1190
  max_summary_input: settingsInt("general.compact.max_summary_input", 15e3),
1191
- max_message_slice: settingsInt("general.compact.max_message_slice", 800),
1192
- summarize_timeout_sec: settingsInt("general.compact.summarize_timeout_sec", 300)
1191
+ max_message_slice: settingsInt("general.compact.max_message_slice", 800)
1193
1192
  },
1194
1193
  auth: {
1195
1194
  tokenMaxAge: 14 * 24 * 60 * 60,
@@ -1431,8 +1430,10 @@ var init_model_error = __esm({
1431
1430
  });
1432
1431
 
1433
1432
  // ../server/dist/agents/compact.js
1434
- function splitForCompact(messages2) {
1433
+ function compactCut(messages2) {
1435
1434
  const keepCount = config.compact.keep_messages;
1435
+ if (messages2.length <= keepCount)
1436
+ return 0;
1436
1437
  let cut = Math.max(0, messages2.length - keepCount);
1437
1438
  while (cut < messages2.length) {
1438
1439
  const m = messages2[cut];
@@ -1443,7 +1444,7 @@ function splitForCompact(messages2) {
1443
1444
  }
1444
1445
  break;
1445
1446
  }
1446
- return { recentMsgs: messages2.slice(cut), olderMsgs: messages2.slice(0, cut) };
1447
+ return cut;
1447
1448
  }
1448
1449
  function messageText(m) {
1449
1450
  const content = m.content;
@@ -1453,12 +1454,11 @@ function messageText(m) {
1453
1454
  return String(content ?? "");
1454
1455
  }
1455
1456
  function localCompactMessages(messages2) {
1456
- if (!messages2 || messages2.length <= config.compact.keep_messages) {
1457
- return { compacted: false, messages: messages2 };
1458
- }
1459
- const { recentMsgs, olderMsgs } = splitForCompact(messages2);
1460
- if (olderMsgs.length === 0)
1457
+ const cut = messages2 ? compactCut(messages2) : 0;
1458
+ if (cut === 0)
1461
1459
  return { compacted: false, messages: messages2 };
1460
+ const recentMsgs = messages2.slice(cut);
1461
+ const olderMsgs = messages2.slice(0, cut);
1462
1462
  const maxSlice = config.compact.max_message_slice;
1463
1463
  const lines = [];
1464
1464
  for (const m of olderMsgs) {
@@ -1621,7 +1621,7 @@ function toolResultImages(content) {
1621
1621
  }
1622
1622
  return images.length > 0 ? [{ type: "text", text: "[Images from the tool results above]" }, ...images] : [];
1623
1623
  }
1624
- var TOOL_ERROR_MARKER, TOOL_WARN_MARKER, MODEL_TIMEOUT_ERROR, INTERRUPTED_REPLY_MARKER, AgentLoop;
1624
+ var TOOL_ERROR_MARKER, TOOL_WARN_MARKER, MODEL_TIMEOUT_ERROR, INTERRUPTED_REPLY_MARKER, ACTIVITY_DELTA, AgentLoop;
1625
1625
  var init_agent_loop = __esm({
1626
1626
  "../server/dist/agents/agent-loop.js"() {
1627
1627
  "use strict";
@@ -1630,6 +1630,7 @@ var init_agent_loop = __esm({
1630
1630
  TOOL_WARN_MARKER = "__TOOL_WARN__";
1631
1631
  MODEL_TIMEOUT_ERROR = "Model request timed out";
1632
1632
  INTERRUPTED_REPLY_MARKER = "\n\n[reply interrupted by the user here \u2014 the text above is what was shown before the cut]";
1633
+ ACTIVITY_DELTA = { type: "activity", text: "" };
1633
1634
  AgentLoop = class {
1634
1635
  tools;
1635
1636
  messages = [];
@@ -1676,8 +1677,10 @@ var init_agent_loop = __esm({
1676
1677
  }
1677
1678
  };
1678
1679
  const call = this.callModel(callSignal, (d) => {
1679
- deltas.push(d);
1680
1680
  timer.refresh();
1681
+ if (d.type === "activity")
1682
+ return;
1683
+ deltas.push({ type: d.type, text: d.text });
1681
1684
  notify();
1682
1685
  });
1683
1686
  call.then(() => {
@@ -2618,7 +2621,7 @@ var init_init = __esm({
2618
2621
  path8.resolve(__dirname, "..", "bundled-docs"),
2619
2622
  path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
2620
2623
  ];
2621
- TEMPLATE_VERSION = 74;
2624
+ TEMPLATE_VERSION = 76;
2622
2625
  VERSION_FILE = ".template-version";
2623
2626
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
2624
2627
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -3587,7 +3590,8 @@ async function deliverGoalRound(host, worker) {
3587
3590
  return;
3588
3591
  let report = worker.finalOutput || worker.output || "(no output)";
3589
3592
  if (worker.turnError) {
3590
- report = `[WORKER ABORTED: this round was terminated by an unrecoverable error, NOT a normal wrap-up. Error: ${worker.turnError}. The text below is a partial trace of the aborted turn \u2014 judge this as an interrupted round, do not score it as a completed result. Re-dispatch with query_session to let the worker resume.]
3593
+ const next = worker.turnErrorKind === "account" ? `This is a model account / credential / balance / permission problem: retrying or re-dispatching will fail the same way. Fix the model configuration first (or tell the user to fix it), and do not resume the worker with query_session until then.` : `Re-dispatch with query_session to let the worker resume.`;
3594
+ report = `[WORKER ABORTED: this round was terminated by an unrecoverable error, NOT a normal wrap-up. Error: ${worker.turnError}. The text below is a partial trace of the aborted turn \u2014 judge this as an interrupted round, do not score it as a completed result. ${next}]
3591
3595
 
3592
3596
  ${report}`;
3593
3597
  }
@@ -4907,6 +4911,10 @@ function execStop(ctx) {
4907
4911
  const active = findActiveSessionId(ctx.sm, ctx.userId, ctx.sessionPrefix, ctx.activeOverrides, ctx.accessLevel);
4908
4912
  if (!active)
4909
4913
  return { text: t("stop.no_session", ctx.lang) };
4914
+ if (ctx.sm.isSessionCompacting(active) && !ctx.sm.isSessionRunning(active)) {
4915
+ ctx.sm.cancelCompact(active);
4916
+ return { text: t("stop.done", ctx.lang) };
4917
+ }
4910
4918
  if (!ctx.sm.isSessionRunning(active))
4911
4919
  return { text: t("stop.already_idle", ctx.lang) };
4912
4920
  ctx.sm.stopSession(active).catch(() => {
@@ -4917,6 +4925,10 @@ function execInterrupt(ctx) {
4917
4925
  const active = findActiveSessionId(ctx.sm, ctx.userId, ctx.sessionPrefix, ctx.activeOverrides, ctx.accessLevel);
4918
4926
  if (!active)
4919
4927
  return { text: t("interrupt.no_session", ctx.lang) };
4928
+ if (ctx.sm.isSessionCompacting(active) && !ctx.sm.isSessionRunning(active)) {
4929
+ ctx.sm.cancelCompact(active);
4930
+ return { text: t("interrupt.done", ctx.lang) };
4931
+ }
4920
4932
  if (!ctx.sm.isSessionRunning(active))
4921
4933
  return { text: t("interrupt.already_idle", ctx.lang) };
4922
4934
  ctx.sm.interruptSession(active);
@@ -5617,6 +5629,14 @@ function writeReplyTo(db, sessionId, to) {
5617
5629
  function clearReplyTo(db, sessionId) {
5618
5630
  db.update(agentSessions).set({ replyTo: null }).where(eq3(agentSessions.id, sessionId)).run();
5619
5631
  }
5632
+ function listActiveChildren(db, sessionId) {
5633
+ return db.select({
5634
+ agentName: agentSessions.agentName,
5635
+ description: agentSessions.description,
5636
+ createdAt: agentSessions.createdAt,
5637
+ updatedAt: agentSessions.updatedAt
5638
+ }).from(agentSessions).where(and3(eq3(agentSessions.parentId, sessionId), isNull2(agentSessions.stoppedAt), isNull2(agentSessions.archivedAt))).all();
5639
+ }
5620
5640
  async function deliverRelayReport(host, session) {
5621
5641
  if (session.parentId !== null)
5622
5642
  return;
@@ -5624,8 +5644,7 @@ async function deliverRelayReport(host, session) {
5624
5644
  const to = readReplyTo(db, session.id);
5625
5645
  if (!to)
5626
5646
  return;
5627
- const activeChildren = db.select({ id: agentSessions.id }).from(agentSessions).where(and3(eq3(agentSessions.parentId, session.id), isNull2(agentSessions.stoppedAt), isNull2(agentSessions.archivedAt))).all();
5628
- if (activeChildren.length > 0 || session.messageQueue.length > 0)
5647
+ if (listActiveChildren(db, session.id).length > 0 || session.messageQueue.length > 0)
5629
5648
  return;
5630
5649
  const registry2 = getRelayRegistry();
5631
5650
  if (!registry2) {
@@ -5634,7 +5653,8 @@ async function deliverRelayReport(host, session) {
5634
5653
  }
5635
5654
  let report = session.finalOutput || session.output || "(no output)";
5636
5655
  if (session.turnError) {
5637
- report = `[RELAY TARGET ABORTED: the last turn was terminated by an unrecoverable error, NOT completed. Error: ${session.turnError}. The text below is a partial trace \u2014 do not treat it as a finished result. Re-send with relay_send to let it resume.]
5656
+ const next = session.turnErrorKind === "account" ? "This is a model account / credential / balance / permission problem in the target workspace: retrying or re-sending will fail the same way. Fix its model configuration first (or tell the user to fix it), and do not resume it with relay_send until then." : "Re-send with relay_send to let it resume.";
5657
+ report = `[RELAY TARGET ABORTED: the last turn was terminated by an unrecoverable error, NOT completed. Error: ${session.turnError}. The text below is a partial trace \u2014 do not treat it as a finished result. ${next}]
5638
5658
 
5639
5659
  ${report}`;
5640
5660
  }
@@ -6027,6 +6047,14 @@ function claimWorkspaceRuntime(workspaceRoot) {
6027
6047
  return false;
6028
6048
  }
6029
6049
  }
6050
+ function releaseWorkspaceRuntime(workspaceRoot) {
6051
+ const lockPath = path21.join(workspaceRoot, ".halo", RUNTIME_LOCK_FILE);
6052
+ try {
6053
+ if (parseInt(fs19.readFileSync(lockPath, "utf-8").trim(), 10) === process.pid)
6054
+ fs19.unlinkSync(lockPath);
6055
+ } catch {
6056
+ }
6057
+ }
6030
6058
  var RUNTIME_LOCK_FILE;
6031
6059
  var init_workspace_runtime_lock = __esm({
6032
6060
  "../server/dist/agents/workspace-runtime-lock.js"() {
@@ -6750,7 +6778,7 @@ function createSaveSnapshot(state) {
6750
6778
  tempMsg.contentBlocks = [...state.turnContentBlocks];
6751
6779
  return [...state.messageLog, tempMsg];
6752
6780
  }
6753
- function initSubSessionLog(state, taskId, agentId, agentName, text6) {
6781
+ function initSubSessionLog(state, taskId, agentId, agentName, text6, loadHistory2) {
6754
6782
  const existing = state.subSessionLogs.get(taskId);
6755
6783
  if (existing) {
6756
6784
  existing.agentId = agentId;
@@ -6762,12 +6790,15 @@ function initSubSessionLog(state, taskId, agentId, agentName, text6) {
6762
6790
  }
6763
6791
  return;
6764
6792
  }
6793
+ const messageLog = loadHistory2?.(taskId) ?? [];
6794
+ if (text6)
6795
+ messageLog.push({ id: genId(), type: "user", role: "user", content: text6, timestamp: Date.now(), agentName });
6765
6796
  state.subSessionLogs.set(taskId, {
6766
6797
  agentId,
6767
6798
  agentName,
6768
6799
  description: text6,
6769
6800
  streamingAgent: agentName,
6770
- messageLog: text6 ? [{ id: genId(), type: "user", role: "user", content: text6, timestamp: Date.now(), agentName }] : [],
6801
+ messageLog,
6771
6802
  streamBuffer: "",
6772
6803
  turnToolCalls: [],
6773
6804
  turnContentBlocks: [],
@@ -6800,12 +6831,12 @@ function buildUsageMsg(event, agentName, taskId, turnId) {
6800
6831
  turnId
6801
6832
  };
6802
6833
  }
6803
- function applyEvent(state, event) {
6834
+ function applyEvent(state, event, loadHistory2) {
6804
6835
  const agentName = event.agentName ?? "default";
6805
6836
  const taskId = event.taskId;
6806
- const result = { shouldSave: false, isComplete: false };
6837
+ const result = { taskId };
6807
6838
  if (taskId && event.type !== "agent_start" && event.type !== "agent_done" && !state.subSessionLogs.has(taskId)) {
6808
- initSubSessionLog(state, taskId, event.agentId ?? "", agentName, "");
6839
+ initSubSessionLog(state, taskId, event.agentId ?? "", agentName, "", loadHistory2);
6809
6840
  }
6810
6841
  switch (event.type) {
6811
6842
  // Streaming chunks land in the turn blocks as they arrive; the whole
@@ -6841,7 +6872,7 @@ function applyEvent(state, event) {
6841
6872
  });
6842
6873
  }
6843
6874
  if (taskId)
6844
- initSubSessionLog(state, taskId, event.agentId ?? "", agentName, event.fullText ?? event.text ?? "");
6875
+ initSubSessionLog(state, taskId, event.agentId ?? "", agentName, event.fullText ?? event.text ?? "", loadHistory2);
6845
6876
  break;
6846
6877
  case "agent_done":
6847
6878
  if (!taskId) {
@@ -6860,7 +6891,7 @@ function applyEvent(state, event) {
6860
6891
  const sub = state.subSessionLogs.get(taskId);
6861
6892
  if (sub)
6862
6893
  flushAssistantMessage(sub);
6863
- result.subSessionDone = taskId;
6894
+ result.persist = "flush";
6864
6895
  }
6865
6896
  break;
6866
6897
  case "tool_call": {
@@ -6878,7 +6909,7 @@ function applyEvent(state, event) {
6878
6909
  toolInput: event.toolInput
6879
6910
  };
6880
6911
  addToolCall(getTarget(state, taskId), entry, msg);
6881
- result.shouldSave = true;
6912
+ result.persist = "debounce";
6882
6913
  break;
6883
6914
  }
6884
6915
  case "tool_result": {
@@ -6896,7 +6927,7 @@ function applyEvent(state, event) {
6896
6927
  toolOutput: event.toolResult,
6897
6928
  durationMs: event.durationMs
6898
6929
  });
6899
- result.shouldSave = true;
6930
+ result.persist = "debounce";
6900
6931
  break;
6901
6932
  }
6902
6933
  case "followup_start":
@@ -6914,10 +6945,9 @@ function applyEvent(state, event) {
6914
6945
  if (sub) {
6915
6946
  sub.messageLog.push(buildUsageMsg(event, agentName, taskId, sub.currentTurnId));
6916
6947
  sub.currentTurnId = randomUUID();
6917
- result.subSessionSave = taskId;
6918
6948
  }
6919
6949
  }
6920
- result.shouldSave = true;
6950
+ result.persist = "debounce";
6921
6951
  break;
6922
6952
  }
6923
6953
  case "complete":
@@ -6925,8 +6955,7 @@ function applyEvent(state, event) {
6925
6955
  state.turnToolCalls = [];
6926
6956
  state.turnContentBlocks = [];
6927
6957
  state.currentTurnId = randomUUID();
6928
- result.shouldSave = true;
6929
- result.isComplete = true;
6958
+ result.persist = "flush";
6930
6959
  break;
6931
6960
  case "context": {
6932
6961
  const ctxMsg = {
@@ -6943,12 +6972,11 @@ function applyEvent(state, event) {
6943
6972
  state.messageLog.push(ctxMsg);
6944
6973
  } else {
6945
6974
  if (!state.subSessionLogs.has(taskId)) {
6946
- initSubSessionLog(state, taskId, event.agentId ?? "", agentName, "");
6975
+ initSubSessionLog(state, taskId, event.agentId ?? "", agentName, "", loadHistory2);
6947
6976
  }
6948
6977
  state.subSessionLogs.get(taskId).messageLog.push(ctxMsg);
6949
- result.subSessionSave = taskId;
6950
6978
  }
6951
- result.shouldSave = true;
6979
+ result.persist = "debounce";
6952
6980
  break;
6953
6981
  }
6954
6982
  case "queued_message":
@@ -6965,8 +6993,7 @@ function applyEvent(state, event) {
6965
6993
  timestamp: Date.now(),
6966
6994
  agentName: "System"
6967
6995
  });
6968
- if (taskId)
6969
- result.subSessionSave = taskId;
6996
+ result.persist = "debounce";
6970
6997
  break;
6971
6998
  }
6972
6999
  case "error":
@@ -6979,6 +7006,8 @@ function applyEvent(state, event) {
6979
7006
  agentName,
6980
7007
  taskId
6981
7008
  });
7009
+ result.taskId = void 0;
7010
+ result.persist = "debounce";
6982
7011
  break;
6983
7012
  case "compacted":
6984
7013
  if (!taskId)
@@ -6994,9 +7023,7 @@ function applyEvent(state, event) {
6994
7023
  content: event.text ?? "",
6995
7024
  timestamp: Date.now()
6996
7025
  });
6997
- if (taskId)
6998
- result.subSessionSave = taskId;
6999
- result.shouldSave = true;
7026
+ result.persist = "debounce";
7000
7027
  break;
7001
7028
  }
7002
7029
  }
@@ -7042,14 +7069,16 @@ var init_session_ui_store = __esm({
7042
7069
  uiStates = /* @__PURE__ */ new Map();
7043
7070
  /** Project path for each root session, needed for disk persistence */
7044
7071
  uiStateProjectPaths = /* @__PURE__ */ new Map();
7045
- /** Debounced persist timers — prevents flooding disk with writes during rapid tool loops */
7072
+ /** Debounced persist timers — prevents flooding disk with writes during rapid
7073
+ * tool loops. Keyed by root id for a root's log, by taskId (`root>…`) for a
7074
+ * sub-session's own file. */
7046
7075
  persistTimers = /* @__PURE__ */ new Map();
7047
7076
  /** Last activity (event reduced / view built) per uiStates key — the idle
7048
7077
  * sweep's eviction clock. Invariant: every uiStates key has an entry. */
7049
7078
  uiStateTouched = /* @__PURE__ */ new Map();
7050
7079
  /** Roots whose in-memory UIState holds local mutations (event reduced in,
7051
7080
  * notification appended, log replaced) **not yet persisted** — marked on
7052
- * every mutation, cleared once persistUIState lands the snapshot. A state
7081
+ * every mutation, cleared once persistLog lands the snapshot. A state
7053
7082
  * not in this set adds nothing to what's on disk: either it's a pure disk
7054
7083
  * seed (built to *view* a session another process may be driving — a cron
7055
7084
  * `halo cli` child shares the workspace's session files and keeps appending
@@ -7147,51 +7176,58 @@ var init_session_ui_store = __esm({
7147
7176
  this.eventHandler?.(event);
7148
7177
  }
7149
7178
  /**
7150
- * Fold an event into the root session's UIState and persist to disk if the
7151
- * reducer signals `shouldSave`. Builds the state lazily from disk if
7152
- * needed.
7179
+ * Fold an event into the root session's UIState and persist the log it
7180
+ * changed — the root's or a sub-session's, same rule (see applyEvent).
7181
+ * Builds the state lazily from disk if needed.
7153
7182
  */
7154
7183
  reduceIntoUIState(rootId, event) {
7155
7184
  try {
7156
7185
  const state = this.ensureUIState(rootId);
7157
- this.uiStateDirty.add(rootId);
7158
- const result = applyEvent(state, event);
7159
- if (result.subSessionSave) {
7160
- this.persistSubSession(state, rootId, result.subSessionSave);
7161
- }
7162
- if (result.subSessionDone) {
7163
- this.persistSubSession(state, rootId, result.subSessionDone);
7164
- state.subSessionLogs.delete(result.subSessionDone);
7165
- }
7166
- if (result.shouldSave) {
7167
- if (result.isComplete) {
7168
- this.flushPersist(rootId, state);
7169
- } else {
7170
- this.debouncedPersist(rootId, state);
7171
- }
7172
- }
7186
+ const { taskId, persist } = applyEvent(state, event, this.loadSubHistory);
7187
+ if (!taskId)
7188
+ this.uiStateDirty.add(rootId);
7189
+ const id = taskId ?? rootId;
7190
+ if (persist === "flush")
7191
+ this.flushPersist(id, state);
7192
+ else if (persist === "debounce")
7193
+ this.debouncedPersist(id, state);
7194
+ if (taskId && event.type === "agent_done")
7195
+ state.subSessionLogs.delete(taskId);
7173
7196
  } catch (err) {
7174
7197
  console.error(`[SessionUIStore] reduceIntoUIState failed for ${rootId}: ${err instanceof Error ? err.message : String(err)}`);
7175
7198
  }
7176
7199
  }
7177
- /** Debounced persist — coalesces rapid tool_call/tool_result saves into one write */
7178
- debouncedPersist(rootId, state) {
7179
- const existing = this.persistTimers.get(rootId);
7200
+ /** A new sub-session log starts from the sub's own file, the way
7201
+ * ensureUIState seeds a root — so persistLog can overwrite it whole. */
7202
+ loadSubHistory = (taskId) => {
7203
+ const projectPath = this.uiStateProjectPaths.get(this.findRootSessionId(taskId)) ?? this.host.workspaceRoot;
7204
+ return loadSessionMessages(taskId, projectPath, this.resolveAgentId(taskId));
7205
+ };
7206
+ /** Debounced persist — coalesces rapid tool_call/tool_result saves into one
7207
+ * write. `id` is the log's own session id (root, or a sub's `root>…`
7208
+ * taskId), which keys the timer and names the file. */
7209
+ debouncedPersist(id, state) {
7210
+ const existing = this.persistTimers.get(id);
7180
7211
  if (existing)
7181
7212
  clearTimeout(existing);
7182
- this.persistTimers.set(rootId, setTimeout(() => {
7183
- this.persistTimers.delete(rootId);
7184
- this.persistUIState(rootId, state);
7213
+ this.persistTimers.set(id, setTimeout(() => {
7214
+ this.persistTimers.delete(id);
7215
+ this.persistLog(id, state);
7185
7216
  }, 500));
7186
7217
  }
7187
- /** Flush pending persist immediately (on complete/disconnect) */
7188
- flushPersist(rootId, state) {
7189
- const existing = this.persistTimers.get(rootId);
7218
+ /** Flush pending persist immediately (turn end, interrupt, release, drop). */
7219
+ flushPersist(id, state) {
7220
+ const existing = this.persistTimers.get(id);
7190
7221
  if (existing) {
7191
7222
  clearTimeout(existing);
7192
- this.persistTimers.delete(rootId);
7223
+ this.persistTimers.delete(id);
7193
7224
  }
7194
- this.persistUIState(rootId, state);
7225
+ this.persistLog(id, state);
7226
+ }
7227
+ /** Sub-sessions of `rootId` with a debounced write still pending. */
7228
+ pendingSubPersists(rootId) {
7229
+ const prefix = `${rootId}>`;
7230
+ return [...this.persistTimers.keys()].filter((key) => key.startsWith(prefix));
7195
7231
  }
7196
7232
  /**
7197
7233
  * Ensure UIState for a root session. On first access for a session that
@@ -7224,17 +7260,40 @@ var init_session_ui_store = __esm({
7224
7260
  this.uiStates.set(rootId, state);
7225
7261
  return state;
7226
7262
  }
7227
- /** Persist UI state (root messages + token counts) to disk.
7228
- * `archive` is passed ONLY by archiveOldMessages (the commit step of an
7229
- * archive write): `count` is the new segment count and `userDelta` the main
7230
- * user turns leaving the active file. Every other call leaves both on-disk
7231
- * values alone. */
7232
- persistUIState(rootId, state, archive) {
7263
+ /** Persist one UI log to its own file, overwriting it whole: the root's
7264
+ * (`id` = root id: messages + token counts) or a sub-session's (`id` =
7265
+ * taskId: `state.subSessionLogs[id]`, which holds the sub's full history —
7266
+ * see loadSubHistory — plus the in-flight turn as a temp assistant, the
7267
+ * same snapshot the root writes). `archive` is passed ONLY by
7268
+ * archiveOldMessages (the commit step of an archive write): `count` is the
7269
+ * new segment count and `userDelta` the main user turns leaving the active
7270
+ * file. Every other call leaves both on-disk values alone. */
7271
+ persistLog(id, state, archive) {
7272
+ const rootId = this.findRootSessionId(id);
7273
+ const sub = id === rootId ? void 0 : state.subSessionLogs.get(id);
7274
+ if (id !== rootId && (!sub || this.host.isSessionDeleted(rootId)))
7275
+ return;
7233
7276
  try {
7234
7277
  const projectPath = this.uiStateProjectPaths.get(rootId) ?? this.host.workspaceRoot;
7235
- const snapshot2 = createSaveSnapshot(state);
7278
+ const snapshot2 = createSaveSnapshot(sub ?? state);
7236
7279
  if (snapshot2.length === 0)
7237
7280
  return;
7281
+ if (sub) {
7282
+ const parts = id.split(">");
7283
+ this.host.persistSessionFile({
7284
+ sessionId: id,
7285
+ projectPath,
7286
+ messages: snapshot2,
7287
+ contextTokens: 0,
7288
+ outputTokens: 0,
7289
+ agentId: this.resolveAgentId(id),
7290
+ agentName: sub.agentName,
7291
+ source: "delegated",
7292
+ description: sub.description,
7293
+ parentSessionId: parts.slice(0, -1).join(">")
7294
+ });
7295
+ return;
7296
+ }
7238
7297
  const inMem = this.host.getSession(rootId);
7239
7298
  const row = inMem ? null : this.db.select().from(agentSessions).where(eq6(agentSessions.id, rootId)).get();
7240
7299
  this.host.persistSessionFile({
@@ -7253,7 +7312,7 @@ var init_session_ui_store = __esm({
7253
7312
  });
7254
7313
  this.uiStateDirty.delete(rootId);
7255
7314
  } catch (err) {
7256
- console.error(`[SessionUIStore] persistUIState failed for ${rootId}: ${err instanceof Error ? err.message : String(err)}`);
7315
+ console.error(`[SessionUIStore] persistLog failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
7257
7316
  }
7258
7317
  }
7259
7318
  /**
@@ -7300,7 +7359,7 @@ var init_session_ui_store = __esm({
7300
7359
  const n = readArchiveCount(dir, seg) + 1;
7301
7360
  writeArchiveSegment(dir, seg, n, older);
7302
7361
  state.messageLog = kept;
7303
- this.persistUIState(rootId, state, { count: n, userDelta: countMainUserMessages(older) });
7362
+ this.persistLog(rootId, state, { count: n, userDelta: countMainUserMessages(older) });
7304
7363
  if (readArchiveCount(dir, seg) < n) {
7305
7364
  state.messageLog = [...older, ...kept];
7306
7365
  console.warn(`[SessionUIStore] archive commit for ${rootId} segment ${n} did not land \u2014 rolled back`);
@@ -7313,37 +7372,6 @@ var init_session_ui_store = __esm({
7313
7372
  return 0;
7314
7373
  }
7315
7374
  }
7316
- /** Persist a sub-session's UI log to its own disk file */
7317
- persistSubSession(state, rootId, taskId) {
7318
- if (this.host.isSessionDeleted(rootId))
7319
- return;
7320
- const sub = state.subSessionLogs.get(taskId);
7321
- if (!sub || sub.messageLog.length === 0)
7322
- return;
7323
- try {
7324
- const projectPath = this.uiStateProjectPaths.get(rootId) ?? this.host.workspaceRoot;
7325
- const agentId = this.resolveAgentId(taskId);
7326
- const existing = loadSessionMessages(taskId, projectPath, agentId);
7327
- const seen = new Set(existing.map((m) => m.id).filter(Boolean));
7328
- const merged = [...existing, ...sub.messageLog.filter((m) => !m.id || !seen.has(m.id))];
7329
- const parts = taskId.split(">");
7330
- const directParentId = parts.length > 1 ? parts.slice(0, -1).join(">") : rootId;
7331
- this.host.persistSessionFile({
7332
- sessionId: taskId,
7333
- projectPath,
7334
- messages: merged,
7335
- contextTokens: 0,
7336
- outputTokens: 0,
7337
- agentId,
7338
- agentName: sub.agentName,
7339
- source: "delegated",
7340
- description: sub.description,
7341
- parentSessionId: directParentId
7342
- });
7343
- } catch (err) {
7344
- console.error(`[SessionUIStore] persistSubSession failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
7345
- }
7346
- }
7347
7375
  /** Process-local cache of taskId → authoritative agentId, so the hot persist
7348
7376
  * path doesn't hit the db on every sub-session write. */
7349
7377
  agentIdCache = /* @__PURE__ */ new Map();
@@ -7412,7 +7440,7 @@ var init_session_ui_store = __esm({
7412
7440
  agentName
7413
7441
  });
7414
7442
  this.uiStateDirty.add(rootId);
7415
- this.persistUIState(rootId, state);
7443
+ this.persistLog(rootId, state);
7416
7444
  }
7417
7445
  /**
7418
7446
  * Replace the message log entirely (used for compact, which rewrites the
@@ -7426,7 +7454,7 @@ var init_session_ui_store = __esm({
7426
7454
  state.turnToolCalls = [];
7427
7455
  state.turnContentBlocks = [];
7428
7456
  this.uiStateDirty.add(rootId);
7429
- this.persistUIState(rootId, state);
7457
+ this.persistLog(rootId, state);
7430
7458
  }
7431
7459
  /**
7432
7460
  * Drop the in-memory UIState for a session (e.g. after stopSession / clear).
@@ -7442,8 +7470,12 @@ var init_session_ui_store = __esm({
7442
7470
  */
7443
7471
  dropUIState(sessionId) {
7444
7472
  const state = this.uiStates.get(sessionId);
7445
- if (state && this.persistTimers.has(sessionId))
7446
- this.flushPersist(sessionId, state);
7473
+ if (state && sessionId === this.findRootSessionId(sessionId)) {
7474
+ if (this.persistTimers.has(sessionId))
7475
+ this.flushPersist(sessionId, state);
7476
+ for (const taskId of this.pendingSubPersists(sessionId))
7477
+ this.flushPersist(taskId, state);
7478
+ }
7447
7479
  this.uiStates.delete(sessionId);
7448
7480
  this.uiStateProjectPaths.delete(sessionId);
7449
7481
  this.uiStateTouched.delete(sessionId);
@@ -7477,25 +7509,45 @@ var init_session_ui_store = __esm({
7477
7509
  }
7478
7510
  // ── Operations the manager's session-lifecycle methods delegate here ──
7479
7511
  // These exist so the manager never reaches into the maps above directly.
7480
- /** Flush any pending debounced persist for a session's root before release,
7481
- * so an interrupted run's last UI batch lands on disk. No-op if not loaded. */
7512
+ /** Flush the tree's unpersisted UI logs before release, so an interrupted
7513
+ * run's last UI batch lands on disk. No-op if not loaded. The root's file
7514
+ * is rewritten only when its log holds changes (dirty): a root turn end
7515
+ * already flushed at `complete`, and a sub's release must not rewrite the
7516
+ * root's (large) file for nothing. */
7482
7517
  flushSession(sessionId) {
7483
7518
  const rootId = this.findRootSessionId(sessionId);
7484
7519
  const state = this.uiStates.get(rootId);
7485
- if (state)
7520
+ if (!state)
7521
+ return;
7522
+ if (this.uiStateDirty.has(rootId))
7486
7523
  this.flushPersist(rootId, state);
7524
+ for (const taskId of this.pendingSubPersists(rootId))
7525
+ this.flushPersist(taskId, state);
7487
7526
  }
7488
- /** Flush a sub-session's UI log to its own file. Needed after synthetic
7489
- * interrupted tool_results on interrupt/stop: applyEvent's tool_result case
7490
- * never sets `subSessionSave`, and a stopped sub gets no later event
7491
- * (usage/agent_done) to trigger the usual persist — without this the marker
7492
- * would live only in memory and vanish on restart. No-op if the root state
7493
- * isn't loaded. */
7527
+ /** Server shutdown: land every loaded tree's unpersisted UI logs — a dirty
7528
+ * root plus every live sub log (pending timers would never fire, and a
7529
+ * sub's streamed text since its last write rides no timer at all). Same
7530
+ * writes flushSession makes, minus the "pending timer" filter on subs. */
7531
+ flushAll() {
7532
+ for (const [rootId, state] of this.uiStates) {
7533
+ if (rootId !== this.findRootSessionId(rootId))
7534
+ continue;
7535
+ if (this.uiStateDirty.has(rootId))
7536
+ this.flushPersist(rootId, state);
7537
+ for (const taskId of state.subSessionLogs.keys())
7538
+ this.flushPersist(taskId, state);
7539
+ }
7540
+ }
7541
+ /** Flush a sub-session's UI log to its own file now. Called after the
7542
+ * synthetic interrupted tool_results on interrupt/stop: those only arm the
7543
+ * 500ms debounce, and the interrupt is a turn boundary — the marker should
7544
+ * land immediately rather than ride a timer. No-op if the root state isn't
7545
+ * loaded. */
7494
7546
  flushSubSession(taskId) {
7495
7547
  const rootId = this.findRootSessionId(taskId);
7496
7548
  const state = this.uiStates.get(rootId);
7497
7549
  if (state)
7498
- this.persistSubSession(state, rootId, taskId);
7550
+ this.flushPersist(taskId, state);
7499
7551
  }
7500
7552
  /**
7501
7553
  * Prepare a session's UIState for a view request. When the session isn't
@@ -7504,9 +7556,10 @@ var init_session_ui_store = __esm({
7504
7556
  * session evolving elsewhere would freeze on the first snapshot.
7505
7557
  */
7506
7558
  prepareForView(sessionId, selfDriven) {
7507
- if (!selfDriven) {
7508
- this.uiStates.delete(sessionId);
7509
- this.uiStateDirty.delete(sessionId);
7559
+ const isRoot = sessionId === this.findRootSessionId(sessionId);
7560
+ const liveTree = isRoot && this.host.hasActiveWorkInTree(sessionId);
7561
+ if (!isRoot || !selfDriven && !liveTree) {
7562
+ this.dropUIState(sessionId);
7510
7563
  }
7511
7564
  this.uiStateProjectPaths.set(sessionId, this.host.workspaceRoot);
7512
7565
  return this.ensureUIState(sessionId);
@@ -7518,10 +7571,12 @@ var init_session_ui_store = __esm({
7518
7571
  this.uiStateProjectPaths.delete(id);
7519
7572
  this.uiStateTouched.delete(id);
7520
7573
  this.uiStateDirty.delete(id);
7521
- const timer = this.persistTimers.get(id);
7522
- if (timer) {
7523
- clearTimeout(timer);
7524
- this.persistTimers.delete(id);
7574
+ for (const key of [id, ...this.pendingSubPersists(id)]) {
7575
+ const timer = this.persistTimers.get(key);
7576
+ if (timer) {
7577
+ clearTimeout(timer);
7578
+ this.persistTimers.delete(key);
7579
+ }
7525
7580
  }
7526
7581
  this.eventListeners.delete(id);
7527
7582
  this.agentIdCache.delete(id);
@@ -7762,7 +7817,7 @@ var init_session_query_store = __esm({
7762
7817
  });
7763
7818
 
7764
7819
  // ../server/dist/agents/sse.js
7765
- async function* readSseJson(body) {
7820
+ async function* readSseJson(body, onChunk) {
7766
7821
  const reader = body.getReader();
7767
7822
  const decoder = new TextDecoder();
7768
7823
  let buffer = "";
@@ -7771,6 +7826,7 @@ async function* readSseJson(body) {
7771
7826
  const { done, value } = await reader.read();
7772
7827
  if (done)
7773
7828
  break;
7829
+ onChunk?.();
7774
7830
  buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
7775
7831
  let sep2;
7776
7832
  while ((sep2 = buffer.indexOf("\n\n")) !== -1) {
@@ -7828,7 +7884,7 @@ async function fetchAnthropicStream(opts) {
7828
7884
  if (!res.body)
7829
7885
  throw new Error(`[${tag}] empty response body`);
7830
7886
  const acc = new AnthropicStreamAccumulator(startTime, opts.onDelta);
7831
- for await (const ev of readSseJson(res.body)) {
7887
+ for await (const ev of readSseJson(res.body, () => opts.onDelta?.(ACTIVITY_DELTA))) {
7832
7888
  if (ev.type === "error") {
7833
7889
  throw new Error(`[${tag}] ${SSE_ERROR_STATUS[ev.error?.type ?? ""] ?? "?"} ${ev.error?.type ?? "?"}: ${ev.error?.message ?? ""}`);
7834
7890
  }
@@ -7842,6 +7898,7 @@ var AnthropicStreamAccumulator, SSE_ERROR_STATUS;
7842
7898
  var init_anthropic_stream = __esm({
7843
7899
  "../server/dist/agents/anthropic-stream.js"() {
7844
7900
  "use strict";
7901
+ init_agent_loop();
7845
7902
  init_sse();
7846
7903
  AnthropicStreamAccumulator = class {
7847
7904
  startTime;
@@ -8041,6 +8098,7 @@ var init_bedrock_agent = __esm({
8041
8098
  const acc = new AnthropicStreamAccumulator(startTime, onDelta);
8042
8099
  const decoder = new TextDecoder();
8043
8100
  for await (const chunk of response.body ?? []) {
8101
+ onDelta?.(ACTIVITY_DELTA);
8044
8102
  if (chunk.chunk?.bytes)
8045
8103
  acc.push(JSON.parse(decoder.decode(chunk.chunk.bytes)));
8046
8104
  }
@@ -8129,7 +8187,7 @@ async function fetchChatCompletionStream(opts) {
8129
8187
  if (!res.body)
8130
8188
  throw new Error(`[${tag}] empty response body`);
8131
8189
  const acc = new ChatCompletionAccumulator(startTime, opts.onDelta);
8132
- for await (const chunk of readSseJson(res.body)) {
8190
+ for await (const chunk of readSseJson(res.body, () => opts.onDelta?.(ACTIVITY_DELTA))) {
8133
8191
  if (chunk.error) {
8134
8192
  throw new Error(`[${tag}] API error in stream: ${JSON.stringify(chunk.error).slice(0, 300)}`);
8135
8193
  }
@@ -8143,6 +8201,7 @@ var ChatCompletionAccumulator;
8143
8201
  var init_openai_chat_stream = __esm({
8144
8202
  "../server/dist/agents/openai-chat-stream.js"() {
8145
8203
  "use strict";
8204
+ init_agent_loop();
8146
8205
  init_sse();
8147
8206
  ChatCompletionAccumulator = class {
8148
8207
  startTime;
@@ -9518,7 +9577,7 @@ async function readResponsesStream(res, onDelta, startTime, signal) {
9518
9577
  throw new Error("[MantleAgent] empty response body");
9519
9578
  let final;
9520
9579
  let firstDeltaAt;
9521
- for await (const ev of readSseJson(res.body)) {
9580
+ for await (const ev of readSseJson(res.body, () => onDelta?.(ACTIVITY_DELTA))) {
9522
9581
  switch (ev.type) {
9523
9582
  case "response.output_text.delta":
9524
9583
  firstDeltaAt ??= Date.now();
@@ -12310,7 +12369,7 @@ async function initObservability() {
12310
12369
  return true;
12311
12370
  }
12312
12371
  const { registerSdk: registerSdk2 } = await Promise.resolve().then(() => (init_otel_sdk(), otel_sdk_exports));
12313
- providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.0-alpha");
12372
+ providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.2-alpha");
12314
12373
  console.log(`[Observability] OTLP export \u2192 ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT} (service.name=${process.env.OTEL_SERVICE_NAME}, capture_content=${config.observability.captureContent})`);
12315
12374
  return true;
12316
12375
  }
@@ -12676,6 +12735,11 @@ function generateSessionId() {
12676
12735
  const rand2 = Math.random().toString(36).slice(2, 8);
12677
12736
  return `sid_${ts}_${rand2}`;
12678
12737
  }
12738
+ function interimDoor(parentId, batch) {
12739
+ if (parentId !== null)
12740
+ return batch.some((q) => q.sourceSessionId === parentId) ? "parent" : null;
12741
+ return batch.some((q) => q.sourceSessionId === void 0 && q.text.includes(RELAY_CHANNEL_PREFIX)) ? "relay" : null;
12742
+ }
12679
12743
  var CONTINUE_TASK_KICK, SessionManager;
12680
12744
  var init_session_manager = __esm({
12681
12745
  "../server/dist/agents/session-manager.js"() {
@@ -12962,6 +13026,7 @@ var init_session_manager = __esm({
12962
13026
  streamedThisCall: false,
12963
13027
  finalOutput: "",
12964
13028
  turnError: null,
13029
+ turnErrorKind: null,
12965
13030
  promise: null,
12966
13031
  abortController: null,
12967
13032
  messageQueue: [],
@@ -12976,6 +13041,7 @@ var init_session_manager = __esm({
12976
13041
  isCompacting: false,
12977
13042
  compactAbortController: null,
12978
13043
  compactedThisTurn: false,
13044
+ foldAfterCompact: null,
12979
13045
  systemPrompt,
12980
13046
  thinkingEffort,
12981
13047
  workingDir,
@@ -13289,6 +13355,10 @@ var init_session_manager = __esm({
13289
13355
  getUIState(rootSessionId) {
13290
13356
  return this.uiStore.getUIState(rootSessionId);
13291
13357
  }
13358
+ /** Server shutdown: land every loaded tree's unpersisted UI logs. */
13359
+ flushAll() {
13360
+ this.uiStore.flushAll();
13361
+ }
13292
13362
  appendUserMessage(sessionId, text6, opts) {
13293
13363
  this.uiStore.appendUserMessage(sessionId, text6, opts);
13294
13364
  }
@@ -13437,6 +13507,7 @@ var init_session_manager = __esm({
13437
13507
  session.lastActivityAt = null;
13438
13508
  session.finalOutput = "";
13439
13509
  session.turnError = null;
13510
+ session.turnErrorKind = null;
13440
13511
  const stamp = `[${(/* @__PURE__ */ new Date()).toISOString()}]`;
13441
13512
  const stamped = (text6) => text6 ? `${stamp} ${text6}` : stamp;
13442
13513
  if (typeof message === "string") {
@@ -13508,6 +13579,7 @@ var init_session_manager = __esm({
13508
13579
  console.error(`[SessionManager] Session ${session.id} account error: ${msg}`);
13509
13580
  this.emitEvent(session.id, { type: "error", error: msg, agentName: session.agentName, taskId: session.parentId ? session.id : void 0 });
13510
13581
  session.turnError = msg;
13582
+ session.turnErrorKind = kind;
13511
13583
  resultText = `Error: ${msg}`;
13512
13584
  break;
13513
13585
  }
@@ -13583,6 +13655,7 @@ var init_session_manager = __esm({
13583
13655
  console.error(`[SessionManager] Session ${session.id} error (attempt ${attempt + 1}/${maxRetries})${errDetail ? ` [${errDetail}]` : ""}: ${msg}`);
13584
13656
  this.emitEvent(session.id, { type: "error", error: msg, agentName: session.agentName, taskId: session.parentId ? session.id : void 0 });
13585
13657
  session.turnError = errName ? `${errName}: ${msg}` : msg;
13658
+ session.turnErrorKind = kind;
13586
13659
  resultText = `Error: ${errName ? `${errName}: ` : ""}${msg}`;
13587
13660
  break;
13588
13661
  }
@@ -13605,6 +13678,7 @@ var init_session_manager = __esm({
13605
13678
  throw new Error(`Session ${sessionId} not found`);
13606
13679
  const hasFirst = typeof message === "string" ? message !== "" : message.length > 0;
13607
13680
  console.debug(`[SessionManager] runSession ${sessionId} (agent: ${session.agentId}, parent: ${session.parentId ?? "none"}) \u2014 hasFirst: ${hasFirst}`);
13681
+ const lastTurn = { batch: [], waitingOnChildren: false };
13608
13682
  const runFn = async () => {
13609
13683
  let result = "";
13610
13684
  if (hasFirst) {
@@ -13613,14 +13687,16 @@ var init_session_manager = __esm({
13613
13687
  session.interruptRequested = false;
13614
13688
  session.resumedAfterInterrupt = false;
13615
13689
  session.selfKick = false;
13690
+ lastTurn.batch = [{ text: rawMessageText({ role: "user", content: message }) }];
13691
+ lastTurn.waitingOnChildren = interimDoor(session.parentId, lastTurn.batch) !== null && listActiveChildren(this.db, session.id).length > 0;
13616
13692
  result = await this.runAgentTurn(session, message);
13617
13693
  console.debug(`[SessionManager] runSession ${sessionId} first turn done \u2014 result: ${result.slice(0, 150)}`);
13618
13694
  if (session.messageQueue.length > 0 && session.finalOutput !== "" && !session.turnError) {
13619
- this.sendOpeningInterim(session);
13695
+ this.sendOpeningInterim(session, lastTurn.batch);
13620
13696
  }
13621
13697
  }
13622
13698
  if (session.messageQueue.length > 0) {
13623
- await this.drainQueue(session);
13699
+ await this.drainQueue(session, lastTurn);
13624
13700
  result = session.output || result;
13625
13701
  }
13626
13702
  return result;
@@ -13635,6 +13711,7 @@ var init_session_manager = __esm({
13635
13711
  if (session.parentId === null) {
13636
13712
  this.emitEvent(session.id, { type: "complete" });
13637
13713
  }
13714
+ this.sendDeferredInterim(session, lastTurn);
13638
13715
  this.tryReportToParent(session);
13639
13716
  deliverGoalRound(this, session).catch((err) => {
13640
13717
  console.error(`[GoalMode] deliverGoalRound failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
@@ -13668,7 +13745,7 @@ var init_session_manager = __esm({
13668
13745
  tryReportToParent(session) {
13669
13746
  if (!session.parentId)
13670
13747
  return;
13671
- const activeChildren = this.db.select({ id: agentSessions.id }).from(agentSessions).where(and6(eq9(agentSessions.parentId, session.id), isNull4(agentSessions.stoppedAt), isNull4(agentSessions.archivedAt))).all();
13748
+ const activeChildren = listActiveChildren(this.db, session.id);
13672
13749
  if (activeChildren.length > 0) {
13673
13750
  console.debug(`[SessionManager] tryReportToParent: ${session.id} has ${activeChildren.length} active children, skipping`);
13674
13751
  return;
@@ -13676,7 +13753,8 @@ var init_session_manager = __esm({
13676
13753
  this.db.update(agentSessions).set({ stoppedAt: Date.now() }).where(eq9(agentSessions.id, session.id)).run();
13677
13754
  let result = session.finalOutput || session.output || "(no output)";
13678
13755
  if (session.turnError) {
13679
- result = `[SUB-AGENT ABORTED: the last turn was terminated by an unrecoverable error, NOT completed. Error: ${session.turnError}. The text below is a partial trace of the aborted turn \u2014 do not treat it as a finished result. Re-dispatch with query_session("${session.id}", ...) to let it resume.]
13756
+ const next = session.turnErrorKind === "account" ? `This is a model account / credential / balance / permission problem: retrying or re-dispatching will fail the same way. Fix the model configuration first (or tell the user to fix it), and do not resume it with query_session("${session.id}", ...) until then.` : `Re-dispatch with query_session("${session.id}", ...) to let it resume.`;
13757
+ result = `[SUB-AGENT ABORTED: the last turn was terminated by an unrecoverable error, NOT completed. Error: ${session.turnError}. The text below is a partial trace of the aborted turn \u2014 do not treat it as a finished result. ${next}]
13680
13758
 
13681
13759
  ${result}`;
13682
13760
  }
@@ -13727,12 +13805,7 @@ ${result}`;
13727
13805
  return { llm: "", ui: "" };
13728
13806
  const now = Date.now();
13729
13807
  const nowIso = new Date(now).toISOString();
13730
- const stillRunning = this.db.select({
13731
- agentName: agentSessions.agentName,
13732
- description: agentSessions.description,
13733
- createdAt: agentSessions.createdAt,
13734
- updatedAt: agentSessions.updatedAt
13735
- }).from(agentSessions).where(and6(eq9(agentSessions.parentId, session.id), isNull4(agentSessions.stoppedAt), isNull4(agentSessions.archivedAt))).all();
13808
+ const stillRunning = listActiveChildren(this.db, session.id);
13736
13809
  if (stillRunning.length === 0 && session.messageQueue.length === 0) {
13737
13810
  const done = "All sub-agents you dispatched have completed. This is the final report \u2014 you may now consolidate and wrap up.";
13738
13811
  return { llm: `
@@ -13762,7 +13835,7 @@ Still running:
13762
13835
  * entries were traced by the channel before sendUserMessage — so drain does
13763
13836
  * NOT re-emit a `user` event. It DOES emit one `queued_message` per merged
13764
13837
  * batch (root only) to split the assistant bubble for the new turn. */
13765
- async drainQueue(session) {
13838
+ async drainQueue(session, lastTurn) {
13766
13839
  while (session.messageQueue.length > 0) {
13767
13840
  const batch = session.messageQueue.splice(0);
13768
13841
  session.resumedAfterInterrupt = session.interruptRequested;
@@ -13784,6 +13857,8 @@ ${q.text}` : q.text).join("\n\n");
13784
13857
  }
13785
13858
  const kickNote = kickFlagReset ? "\n\n[System] The continue_task flag you set last turn was reset by this interrupt. If the interrupted task is still unfinished after you answer, call continue_task again." : "";
13786
13859
  const input = this.buildInput(merged + suffix.llm + kickNote, images.length > 0 ? images : void 0, session.supportsImage);
13860
+ lastTurn.batch = batch;
13861
+ lastTurn.waitingOnChildren = interimDoor(session.parentId, batch) !== null && listActiveChildren(this.db, session.id).length > 0;
13787
13862
  try {
13788
13863
  await this.runAgentTurn(session, input);
13789
13864
  } catch (err) {
@@ -13809,7 +13884,8 @@ ${q.text}` : q.text).join("\n\n");
13809
13884
  }
13810
13885
  }
13811
13886
  }
13812
- /** Interim report for a drained turn. A turn followed by another turn (kick /
13887
+ /** Interim report for a drained turn (sendDeferredInterim sends through here
13888
+ * too). A turn followed by another turn (kick /
13813
13889
  * queued message) is otherwise lost to whoever asked: the end-of-run reports
13814
13890
  * (tryReportToParent / deliverRelayReport) read only the LAST turn's output,
13815
13891
  * and the next runAgentTurn resets it. So when the batch just answered came
@@ -13821,25 +13897,44 @@ ${q.text}` : q.text).join("\n\n");
13821
13897
  * an interim would wake G mid-round, and a fresh query_session from G would
13822
13898
  * interrupt W again. */
13823
13899
  sendInterimReport(session, batch) {
13824
- const fromParent = session.parentId !== null && batch.some((q) => q.sourceSessionId === session.parentId);
13825
- const fromRelay = session.parentId === null && batch.some((q) => q.sourceSessionId === void 0 && q.text.includes(RELAY_CHANNEL_PREFIX));
13826
- if (fromParent)
13827
- this.deliverInterim(session, "parent");
13828
- else if (fromRelay)
13829
- this.deliverInterim(session, "relay");
13900
+ const to = interimDoor(session.parentId, batch);
13901
+ if (to)
13902
+ this.deliverInterim(session, to);
13830
13903
  }
13831
13904
  /** Interim report for the OPENING turn (runSession's `hasFirst` turn): when it
13832
13905
  * ends naturally while the queue already holds a follow-up, the drain resets
13833
- * output and the wrap-up is lost the same way. No batch to read the door from
13834
- * here — the opening message is the dispatch itself — so the owed party is
13835
- * whoever this session reports to at the end: its parent, or a relay caller
13836
- * (reply_to row). Goal workers have neither, so they get nothing. */
13837
- sendOpeningInterim(session) {
13906
+ * output and the wrap-up is lost the same way. A sub-agent's opening message
13907
+ * is its parent's dispatch, so it always owes the parent. A root owes its
13908
+ * relay caller only when the opening message itself is relay-prefixed (same
13909
+ * door as the drain path): a pending reply_to alone is not enough — a local
13910
+ * user chatting into a root that still owes a relay report would otherwise
13911
+ * have their answer forwarded to the caller. Goal workers have neither. */
13912
+ sendOpeningInterim(session, batch) {
13838
13913
  if (session.parentId !== null)
13839
13914
  this.deliverInterim(session, "parent");
13840
- else if (readReplyTo(this.db, session.id) !== null)
13915
+ else if (interimDoor(null, batch) === "relay" && readReplyTo(this.db, session.id) !== null)
13841
13916
  this.deliverInterim(session, "relay");
13842
13917
  }
13918
+ /** Interim report for the run's LAST turn while a still-running child holds
13919
+ * the end-of-run report back. The loss it fixes: tryReportToParent /
13920
+ * deliverRelayReport fire only once the subtree is quiet, and read only the
13921
+ * last turn. A session waiting on a sub-agent is asked by its caller
13922
+ * (relay_send / the parent's query_session) and answers; the run ends with the
13923
+ * child still running, so both skip, and the child's report later wakes a new
13924
+ * run whose first turn resets output — the answer reached no one. Sends only
13925
+ * when that turn answered the owed party while already waiting
13926
+ * (waitingOnChildren — door first, db lookup only past it) and the subtree is
13927
+ * STILL busy. Runs in runSession's finally right before those two, which read
13928
+ * the same db gate synchronously: children running → they skip and this sends;
13929
+ * none → they report this turn as the final and this doesn't. Never overlaps
13930
+ * sendInterimReport's drain call: a turn another turn follows is never the last. */
13931
+ sendDeferredInterim(session, lastTurn) {
13932
+ if (!lastTurn.waitingOnChildren || session.finalOutput === "" || session.turnError)
13933
+ return;
13934
+ if (listActiveChildren(this.db, session.id).length === 0)
13935
+ return;
13936
+ this.sendInterimReport(session, lastTurn.batch);
13937
+ }
13843
13938
  /** Body = the WHOLE turn's text (`output`, not `finalOutput`): in a turn that
13844
13939
  * answered and then called continue_task the answer rides the tool_use
13845
13940
  * response, whose text is `final: false` and never reaches finalOutput. */
@@ -13959,6 +14054,12 @@ ${body}`;
13959
14054
  * isn't a non-empty array, so a string-content turn would be silently
13960
14055
  * deleted by the repair that runs right after this. */
13961
14056
  foldIntoAgentMessages(session, text6) {
14057
+ if (session.isCompacting) {
14058
+ session.foldAfterCompact = session.foldAfterCompact ? `${session.foldAfterCompact}
14059
+
14060
+ ${text6}` : text6;
14061
+ return;
14062
+ }
13962
14063
  const msgs = session.agent.messages;
13963
14064
  const last = msgs[msgs.length - 1];
13964
14065
  if (last?.role === "user" && Array.isArray(last.content)) {
@@ -13997,8 +14098,10 @@ ${q.text}` : q.text);
13997
14098
  } catch {
13998
14099
  }
13999
14100
  }
14000
- session.agent.messages = repairConversationMessages(session.agent.messages, `[Session:${id}]`);
14001
- this.releaseSession(id);
14101
+ if (!session.isCompacting) {
14102
+ session.agent.messages = repairConversationMessages(session.agent.messages, `[Session:${id}]`);
14103
+ this.releaseSession(id);
14104
+ }
14002
14105
  }
14003
14106
  this.db.update(agentSessions).set({ stoppedAt: now }).where(eq9(agentSessions.id, id)).run();
14004
14107
  }
@@ -14110,7 +14213,8 @@ ${cleaned}` : cleaned;
14110
14213
  * Send a user message to a session (WS handler → agent).
14111
14214
  * If idle: runs the message directly.
14112
14215
  * If busy: enqueues and requests graceful interrupt.
14113
- * If compacting: enqueues for post-compact processing.
14216
+ * If compacting: enqueues for post-compact processing (plus a graceful
14217
+ * interrupt when the compact runs inside a live turn).
14114
14218
  * Returns 'running' | 'queued' to inform the caller.
14115
14219
  */
14116
14220
  async sendUserMessage(sessionId, message, images, accessLevel) {
@@ -14137,6 +14241,8 @@ ${cleaned}` : cleaned;
14137
14241
  }
14138
14242
  if (session.isCompacting) {
14139
14243
  session.messageQueue.push({ text: message, images });
14244
+ if (session.abortController !== null)
14245
+ session.interruptRequested = true;
14140
14246
  console.debug(`[SessionManager] sendUserMessage: ${sessionId} compacting \u2014 message queued`);
14141
14247
  return "queued";
14142
14248
  }
@@ -14246,7 +14352,8 @@ ${cleaned}` : cleaned;
14246
14352
  const overThreshold = session.lastContextTokens > session.contextConfig.maxTokens * session.contextConfig.compressAt;
14247
14353
  if (!overThreshold)
14248
14354
  return;
14249
- if (this.compactCut(session.agent.messages) === 0)
14355
+ const cut = compactCut(session.agent.messages);
14356
+ if (cut === 0)
14250
14357
  return;
14251
14358
  const taskId = session.parentId ? session.id : void 0;
14252
14359
  try {
@@ -14262,14 +14369,43 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14262
14369
  this.emitEvent(session.id, { type: "compacted", totalTokens: result.estimatedTokens, taskId });
14263
14370
  console.debug(`[SessionManager] Auto-compact ${session.id}: ${result.olderCount} messages compacted`);
14264
14371
  } else {
14265
- this.emitEvent(session.id, { type: "system", text: "Compaction skipped \u2014 no summary produced", taskId });
14372
+ this.localCompactFallback(session, cut, "no summary produced", taskId);
14266
14373
  }
14267
14374
  } catch (err) {
14268
- console.debug(`[SessionManager] Auto-compact ${session.id} failed: ${err instanceof Error ? err.message : String(err)}`);
14269
- this.emitEvent(session.id, { type: "system", text: "Compaction failed \u2014 context unchanged", taskId });
14375
+ const msg = err instanceof Error ? err.message : String(err);
14376
+ console.debug(`[SessionManager] Auto-compact ${session.id} failed: ${msg}`);
14377
+ this.localCompactFallback(session, cut, msg, taskId);
14270
14378
  } finally {
14271
- session.isCompacting = false;
14272
- }
14379
+ this.clearCompacting(session);
14380
+ }
14381
+ }
14382
+ /** Auto-compact fallback when the LLM summary failed (threw / idle-timed out
14383
+ * / came back empty). selfCompactSession has already rolled the history
14384
+ * back; leaving it as-is kept the session over threshold, so every later
14385
+ * model call re-ran the same failing summarize. Do the no-LLM compact
14386
+ * instead, with the LLM path's tail micro-compact + token estimate. */
14387
+ localCompactFallback(session, cut, reason, taskId) {
14388
+ const local = localCompactMessages(session.agent.messages);
14389
+ if (local.compacted) {
14390
+ const [summaryMsg, ...tail] = local.messages;
14391
+ const tailMicro = microCompactMessages(tail, 1);
14392
+ session.agent.messages = [summaryMsg, ...tailMicro.compacted ? tailMicro.messages : tail];
14393
+ }
14394
+ session.lastContextTokens = estimateMessageTokens(session.agent.messages) + estimateMessageTokens([{ role: "user", content: session.systemPrompt }]);
14395
+ const why = reason.length > 80 ? `${reason.slice(0, 80)}\u2026` : reason;
14396
+ this.emitEvent(session.id, { type: "system", text: `Auto-compacted ${cut} older messages (local fallback \u2014 LLM summary failed: ${why})`, taskId, compactEnd: true });
14397
+ }
14398
+ /** Leave the compact-in-flight state (auto: maybeAutoCompact's finally;
14399
+ * manual: endCompact), then land a queue fold a Stop parked meanwhile
14400
+ * (see foldIntoAgentMessages) on the post-compact history. */
14401
+ clearCompacting(session) {
14402
+ session.isCompacting = false;
14403
+ session.compactAbortController = null;
14404
+ const parked = session.foldAfterCompact;
14405
+ if (!parked)
14406
+ return;
14407
+ session.foldAfterCompact = null;
14408
+ this.foldIntoAgentMessages(session, parked);
14273
14409
  }
14274
14410
  /** Check if a session is compacting */
14275
14411
  isSessionCompacting(sessionId) {
@@ -14351,8 +14487,7 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14351
14487
  const session = this.sessions.get(sessionId);
14352
14488
  if (!session)
14353
14489
  return;
14354
- session.isCompacting = false;
14355
- session.compactAbortController = null;
14490
+ this.clearCompacting(session);
14356
14491
  if (session.messageQueue.length > 0 && session.promise === null) {
14357
14492
  console.debug(`[SessionManager] Compact ${sessionId} ended \u2014 draining ${session.messageQueue.length} queued message(s)`);
14358
14493
  this.runSession(sessionId, "").catch((err) => {
@@ -14370,38 +14505,6 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14370
14505
  session.compactAbortController = null;
14371
14506
  }
14372
14507
  }
14373
- /**
14374
- * Compute the compaction cut point: messages[0..cut) get summarized,
14375
- * messages[cut..] are kept. Returns 0 when there is nothing to compact
14376
- * (too few messages) — the "compaction is feasible" predicate.
14377
- *
14378
- * Single source of truth shared by selfCompactSession AND the
14379
- * pre-preflight feasibility gates in maybeAutoCompact / compactSession:
14380
- * gate and actual compact must run the same computation on the same
14381
- * `session.agent.messages` reference or they drift ("gate said yes,
14382
- * compact said no" re-creates the orphan-preflight bug).
14383
- *
14384
- * Note the tail loop only ever moves `cut` UP (a tail of user tool_result
14385
- * messages extends the summarized region — they're protocol continuations
14386
- * of an assistant turn, not standalone turns), so cut === 0 is exactly
14387
- * the `messages.length <= keepCount` case.
14388
- */
14389
- compactCut(messages2) {
14390
- const keepCount = config.compact.keep_messages;
14391
- if (messages2.length <= keepCount)
14392
- return 0;
14393
- let cut = Math.max(0, messages2.length - keepCount);
14394
- while (cut < messages2.length) {
14395
- const m = messages2[cut];
14396
- const firstBlock = Array.isArray(m.content) ? m.content[0] : void 0;
14397
- if (m.role === "user" && firstBlock?.type === "tool_result") {
14398
- cut++;
14399
- continue;
14400
- }
14401
- break;
14402
- }
14403
- return cut;
14404
- }
14405
14508
  /**
14406
14509
  * Self-compact: let the current agent summarize its own conversation.
14407
14510
  * The agent already has full context cached, so this is fast and lossless.
@@ -14412,7 +14515,7 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14412
14515
  if (!session)
14413
14516
  return null;
14414
14517
  const messages2 = session.agent.messages;
14415
- const cut = this.compactCut(messages2);
14518
+ const cut = compactCut(messages2);
14416
14519
  if (cut === 0)
14417
14520
  return null;
14418
14521
  session.compactedThisTurn = true;
@@ -14427,29 +14530,24 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14427
14530
  "Write in the SAME LANGUAGE as the conversation.",
14428
14531
  "Output ONLY the summary, no preamble."
14429
14532
  ].join("\n");
14430
- const timeoutMs = config.compact.summarize_timeout_sec * 1e3;
14431
- const timeoutCtrl = new AbortController();
14432
- const timer = setTimeout(() => timeoutCtrl.abort(abortReason("compact-timeout")), timeoutMs);
14433
14533
  const cleanRecent = messages2.slice(cut).map((m) => structuredClone(m));
14434
14534
  const preRunLen = messages2.length;
14435
14535
  const preRunTail = structuredClone(messages2[preRunLen - 1]);
14436
14536
  let rebuilt = false;
14437
14537
  try {
14438
14538
  let summaryText = "";
14439
- if (signal)
14440
- signal.addEventListener("abort", () => timeoutCtrl.abort(abortReason("compact-cancelled")), { once: true });
14441
- const iter = session.agent.run(compactInstruction, {
14442
- cancelSignal: timeoutCtrl.signal
14443
- });
14539
+ const iter = session.agent.run(compactInstruction, { cancelSignal: signal });
14444
14540
  for await (const event of iter) {
14445
- if (signal?.aborted || timeoutCtrl.signal.aborted)
14541
+ if (signal?.aborted)
14542
+ break;
14543
+ if (event.type === "tool_call") {
14544
+ summaryText = "";
14446
14545
  break;
14546
+ }
14447
14547
  if (event.type === "text") {
14448
14548
  summaryText += event.text ?? "";
14449
14549
  }
14450
14550
  }
14451
- if (timeoutCtrl.signal.aborted)
14452
- throw new Error(`Self-compact timed out after ${timeoutMs / 1e3}s`);
14453
14551
  if (signal?.aborted)
14454
14552
  throw new Error("Self-compact cancelled");
14455
14553
  if (!summaryText.trim())
@@ -14468,7 +14566,6 @@ ${summaryText}` }]
14468
14566
  const estimatedTokens = estimateMessageTokens(session.agent.messages) + estimateMessageTokens([{ role: "user", content: session.systemPrompt }]);
14469
14567
  return { summary: summaryText, olderCount, estimatedTokens };
14470
14568
  } finally {
14471
- clearTimeout(timer);
14472
14569
  if (!rebuilt) {
14473
14570
  session.agent.messages.length = preRunLen;
14474
14571
  session.agent.messages[preRunLen - 1] = preRunTail;
@@ -14510,7 +14607,7 @@ ${summaryText}` }]
14510
14607
  if (session.isCompacting)
14511
14608
  return "already";
14512
14609
  const rawCount = session.agent.messages.length;
14513
- if (this.compactCut(session.agent.messages) === 0)
14610
+ if (compactCut(session.agent.messages) === 0)
14514
14611
  return "nothing";
14515
14612
  const ac = await this.beginCompact(sessionId);
14516
14613
  if (!ac)
@@ -14537,9 +14634,9 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14537
14634
  return "compacted";
14538
14635
  } catch (err) {
14539
14636
  const msg = err instanceof Error ? err.message : String(err);
14540
- if (msg === "Self-compact cancelled") {
14637
+ if (ac.signal.aborted) {
14541
14638
  this.emitEvent(sessionId, { type: "system", text: "Compact cancelled" });
14542
- return "nothing";
14639
+ return "cancelled";
14543
14640
  }
14544
14641
  this.emitEvent(sessionId, { type: "system", text: "Compaction failed \u2014 context unchanged", taskId });
14545
14642
  throw err;
@@ -14843,10 +14940,14 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14843
14940
  * message), and requests a SOFT interrupt so a busy turn yields after its
14844
14941
  * current tool — same effect as sendUserMessage's busy branch, minus the
14845
14942
  * idle/run path (the caller already established the session is busy or
14846
- * compacting). When compacting there is no live turn to interrupt, so the
14847
- * flag is NOT set (mirrors sendUserMessage's compacting branch): endCompact's
14848
- * drain would snapshot it into resumedAfterInterrupt and let continue_task
14849
- * arm a kick in a turn that interrupted nothing. endCompact drains the queue. */
14943
+ * compacting). A compact outside a live model loop (manual /compact, or the
14944
+ * turn-end auto-compact — abortController already null) has nothing to
14945
+ * interrupt, so the flag is NOT set there (mirrors sendUserMessage's
14946
+ * compacting branch): the drain would snapshot it into resumedAfterInterrupt
14947
+ * and let continue_task arm a kick in a turn that interrupted nothing. A
14948
+ * mid-turn auto-compact (beforeCallModel) does interrupt a live turn — the
14949
+ * flag is set, so the turn yields after its next tool instead of running on
14950
+ * to its natural end with the message still queued. */
14850
14951
  async enqueueUserMessage(sessionId, text6, images) {
14851
14952
  const session = this.sessions.get(sessionId);
14852
14953
  if (!session)
@@ -14856,7 +14957,7 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14856
14957
  this.emitEvent(sessionId, { type: "system", text: `\u26A0 ${w}` });
14857
14958
  }
14858
14959
  session.messageQueue.push({ text: scoped.text, images });
14859
- if (!session.isCompacting)
14960
+ if (!session.isCompacting || session.abortController !== null)
14860
14961
  session.interruptRequested = true;
14861
14962
  console.debug(`[SessionManager] User message enqueued for ${sessionId} (${session.messageQueue.length} in queue)`);
14862
14963
  }
@@ -14885,7 +14986,9 @@ ${q.text}` : q.text);
14885
14986
  this.markPendingToolCallsInterrupted(sessionId);
14886
14987
  }
14887
14988
  this.cancelCompact(sessionId);
14888
- session.agent.messages = repairConversationMessages(session.agent.messages, `[Session:${sessionId}]`);
14989
+ if (!session.isCompacting) {
14990
+ session.agent.messages = repairConversationMessages(session.agent.messages, `[Session:${sessionId}]`);
14991
+ }
14889
14992
  }
14890
14993
  };
14891
14994
  }
@@ -15153,6 +15256,7 @@ async function createHarness(opts) {
15153
15256
  }
15154
15257
  }
15155
15258
  activeOverrides2.set(USER_ID, sessionId);
15259
+ const sessionAgentId = sm.getSessionById(sessionId)?.agentId ?? agentId;
15156
15260
  const disabledSet = getDisabledSet(sm.getDb(), "agent");
15157
15261
  const agents = await scanAvailableAgents(workspace, disabledSet);
15158
15262
  const agentEntry = agents.find((a) => a.id === agentId);
@@ -15160,7 +15264,7 @@ async function createHarness(opts) {
15160
15264
  const unsubPersistent = sm.registerEventListener(sessionId, (event) => {
15161
15265
  for (const h of eventHandlers) h(event);
15162
15266
  });
15163
- return { sm, workspace, sessionId, accessLevel, supportsImage, unsubPersistent };
15267
+ return { sm, workspace, sessionId, agentId: sessionAgentId, accessLevel, supportsImage, unsubPersistent };
15164
15268
  }
15165
15269
  let state = await bindWorkspace(initialPath, opts.sessionId, opts.newSession ?? false);
15166
15270
  async function send(message, images) {
@@ -15227,6 +15331,7 @@ async function createHarness(opts) {
15227
15331
  state = {
15228
15332
  ...state,
15229
15333
  sessionId: result.switchTo,
15334
+ agentId: state.sm.getSessionById(result.switchTo)?.agentId ?? agentId,
15230
15335
  unsubPersistent: state.sm.registerEventListener(result.switchTo, (event) => {
15231
15336
  for (const h of eventHandlers) h(event);
15232
15337
  })
@@ -15307,6 +15412,9 @@ async function createHarness(opts) {
15307
15412
  get workspace() {
15308
15413
  return state.workspace;
15309
15414
  },
15415
+ get agentId() {
15416
+ return state.agentId;
15417
+ },
15310
15418
  get supportsImage() {
15311
15419
  return state.supportsImage;
15312
15420
  },
@@ -16265,8 +16373,8 @@ function formatUsageLine(event) {
16265
16373
  parts.push(`${ANSI.dim}think off${ANSI.reset}`);
16266
16374
  }
16267
16375
  if (event.modelId) {
16268
- const shortModel3 = event.modelId.replace(/^global\.anthropic\./, "");
16269
- parts.push(`${ANSI.blue}${shortModel3}${ANSI.reset}`);
16376
+ const shortModel2 = event.modelId.replace(/^global\.anthropic\./, "");
16377
+ parts.push(`${ANSI.blue}${shortModel2}${ANSI.reset}`);
16270
16378
  }
16271
16379
  return `${ANSI.dim}[${ANSI.reset} ${parts.join(" ")} ${ANSI.dim}]${ANSI.reset}`;
16272
16380
  }
@@ -16506,22 +16614,15 @@ var init_history = __esm({
16506
16614
  // src/tui/components/status-bar.tsx
16507
16615
  import { Box as Box4, Text as Text4 } from "ink";
16508
16616
  import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
16509
- function shortModel(modelId) {
16510
- return modelId.replace(/^global\.anthropic\./, "").replace(/^anthropic\./, "").replace(/-\d{8}$/, "");
16511
- }
16512
16617
  function shortPath(p) {
16513
16618
  const home = process.env.HOME ?? "";
16514
16619
  if (home && p.startsWith(home)) return "~" + p.slice(home.length);
16515
16620
  return p;
16516
16621
  }
16517
- function StatusBar({ agentName, modelId, contextPercent, workspace, sessionId, verbose }) {
16622
+ function StatusBar({ agentId, contextPercent, workspace, sessionId, verbose }) {
16518
16623
  return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", justifyContent: "space-between", paddingX: 1, children: [
16519
16624
  /* @__PURE__ */ jsxs3(Box4, { flexDirection: "row", gap: 1, children: [
16520
- /* @__PURE__ */ jsx4(Text4, { color: "green", children: agentName }),
16521
- modelId ? /* @__PURE__ */ jsxs3(Fragment, { children: [
16522
- /* @__PURE__ */ jsx4(Text4, { color: "gray", dimColor: true, children: "\xB7" }),
16523
- /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: shortModel(modelId) })
16524
- ] }) : null,
16625
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: agentId }),
16525
16626
  contextPercent != null ? /* @__PURE__ */ jsxs3(Fragment, { children: [
16526
16627
  /* @__PURE__ */ jsx4(Text4, { color: "gray", dimColor: true, children: "\xB7" }),
16527
16628
  /* @__PURE__ */ jsx4(Text4, { color: contextPercent > 80 ? "red" : contextPercent > 50 ? "yellow" : "green", children: `\u25EF ${contextPercent}%` })
@@ -17228,10 +17329,8 @@ function initialState(verbose) {
17228
17329
  liveThinking: null,
17229
17330
  spinnerLabel: null,
17230
17331
  running: false,
17231
- modelId: null,
17232
17332
  contextTokens: null,
17233
17333
  agentNameByTaskId: /* @__PURE__ */ new Map(),
17234
- rootAgentName: "agent",
17235
17334
  subAgents: /* @__PURE__ */ new Map(),
17236
17335
  verbose,
17237
17336
  pendingToolInput: null,
@@ -17325,7 +17424,7 @@ function messagesToLogLines(messages2) {
17325
17424
  if (u.thinkingEffort && u.thinkingEffort !== "off") {
17326
17425
  parts.push(`${ANSI2.magenta}think ${u.thinkingEffort}${ANSI2.reset}`);
17327
17426
  }
17328
- if (m.modelId) parts.push(`${ANSI2.blue}${shortModel2(m.modelId)}${ANSI2.reset}`);
17427
+ if (m.modelId) parts.push(`${ANSI2.blue}${shortModel(m.modelId)}${ANSI2.reset}`);
17329
17428
  lines.push({ text: `${tsStr} ${ANSI2.dim}\xB7${ANSI2.reset} ${parts.join(" ")}` });
17330
17429
  continue;
17331
17430
  }
@@ -17396,7 +17495,7 @@ function fmtK2(n) {
17396
17495
  function fmtMs2(ms) {
17397
17496
  return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${ms}ms`;
17398
17497
  }
17399
- function shortModel2(id) {
17498
+ function shortModel(id) {
17400
17499
  return id.replace(/^global\.anthropic\./, "").replace(/^anthropic\./, "").replace(/-\d{8}$/, "");
17401
17500
  }
17402
17501
  function formatTs(ms) {
@@ -17433,10 +17532,8 @@ function reducer(state, action) {
17433
17532
  liveThinking: null,
17434
17533
  spinnerLabel: null,
17435
17534
  running: false,
17436
- modelId: null,
17437
17535
  contextTokens: null,
17438
17536
  agentNameByTaskId: /* @__PURE__ */ new Map(),
17439
- rootAgentName: "agent",
17440
17537
  subAgents: /* @__PURE__ */ new Map(),
17441
17538
  pendingToolInput: null,
17442
17539
  pendingToolArg: null,
@@ -17476,9 +17573,6 @@ function reducer(state, action) {
17476
17573
  }]
17477
17574
  };
17478
17575
  }
17479
- if (!e.taskId && e.agentName) {
17480
- next = { ...next, rootAgentName: e.agentName };
17481
- }
17482
17576
  return next;
17483
17577
  }
17484
17578
  case "agent_done": {
@@ -17590,7 +17684,6 @@ function reducer(state, action) {
17590
17684
  blocks,
17591
17685
  liveText: "",
17592
17686
  liveThinking: null,
17593
- modelId: e.modelId ?? state.modelId,
17594
17687
  contextTokens: ctx > 0 ? ctx : state.contextTokens
17595
17688
  };
17596
17689
  }
@@ -17898,8 +17991,7 @@ ${lines.join("\n")}` });
17898
17991
  /* @__PURE__ */ jsx11(
17899
17992
  StatusBar,
17900
17993
  {
17901
- agentName: state.rootAgentName,
17902
- modelId: state.modelId,
17994
+ agentId: harness.agentId,
17903
17995
  contextPercent: ctxPercent,
17904
17996
  workspace: harness.workspace,
17905
17997
  sessionId: harness.sessionId,
@@ -22275,6 +22367,10 @@ function createWebChannel(deps) {
22275
22367
  const sessionId = opts?.sessionId ?? getActiveSessionId(sm, account.accountId);
22276
22368
  if (!sessionId)
22277
22369
  return false;
22370
+ if (sm.isSessionCompacting(sessionId) && !sm.isSessionRunning(sessionId)) {
22371
+ sm.cancelCompact(sessionId);
22372
+ return true;
22373
+ }
22278
22374
  if (!sm.isSessionRunning(sessionId))
22279
22375
  return false;
22280
22376
  await sm.stopSession(sessionId);
@@ -23860,6 +23956,9 @@ function sendWsNotification(event, state, turnId, ctx) {
23860
23956
  sendJson(ctx.ws, { type: "compact:started" });
23861
23957
  }
23862
23958
  sendJson(ctx.ws, { type: "chat:system", text: event.text ?? "", taskId, agentName, sessionId });
23959
+ if (event.compactEnd && !taskId) {
23960
+ sendJson(ctx.ws, { type: "compact:done" });
23961
+ }
23863
23962
  break;
23864
23963
  case "error":
23865
23964
  sendJson(ctx.ws, { type: "error", error: event.error, agentName, taskId, sessionId });
@@ -24599,7 +24698,8 @@ ${msg.message}` : markers.join("\n");
24599
24698
  rememberChat(msg);
24600
24699
  await sm.enqueueUserMessage(sid, msg.message, msg.images);
24601
24700
  ackChat(msg);
24602
- sendJson(ws, { type: "chat:queued", reason: "compact", message: "Context compacting, message queued \u2014 will process after compact completes." });
24701
+ const queuedText = sm.isSessionRunning(sid) ? "Context compacting, message queued \u2014 will process after the compact and the current step finish." : "Context compacting, message queued \u2014 will process after compact completes.";
24702
+ sendJson(ws, { type: "chat:queued", reason: "compact", message: queuedText });
24603
24703
  return;
24604
24704
  }
24605
24705
  if (sm.isSessionRunning(sid)) {
@@ -24627,7 +24727,7 @@ ${msg.message}` : markers.join("\n");
24627
24727
  const sid = client2.sessionId;
24628
24728
  if (!sm || !sid)
24629
24729
  return;
24630
- if (sm.isSessionCompacting(sid)) {
24730
+ if (sm.isSessionCompacting(sid) && !sm.isSessionRunning(sid)) {
24631
24731
  sm.cancelCompact(sid);
24632
24732
  sendJson(ws, { type: "chat:stopped", sessionId: client2.sessionId });
24633
24733
  return;
@@ -24642,7 +24742,7 @@ ${msg.message}` : markers.join("\n");
24642
24742
  const sid = client2.sessionId;
24643
24743
  if (!sm || !sid)
24644
24744
  return;
24645
- if (sm.isSessionCompacting(sid)) {
24745
+ if (sm.isSessionCompacting(sid) && !sm.isSessionRunning(sid)) {
24646
24746
  sm.cancelCompact(sid);
24647
24747
  sendJson(ws, { type: "chat:stopped", sessionId: client2.sessionId });
24648
24748
  return;
@@ -32857,6 +32957,14 @@ async function gracefulShutdown(signal) {
32857
32957
  if (server && typeof server.close === "function") {
32858
32958
  server.close();
32859
32959
  }
32960
+ for (const { workspacePath, sm } of registry.list()) {
32961
+ try {
32962
+ sm.flushAll();
32963
+ } catch (err) {
32964
+ console.error(`[Server] flushAll failed for ${workspacePath}: ${err instanceof Error ? err.message : String(err)}`);
32965
+ }
32966
+ releaseWorkspaceRuntime(workspacePath);
32967
+ }
32860
32968
  console.log("[Server] Shutdown complete");
32861
32969
  await shutdownObservability();
32862
32970
  process.exit(0);
@@ -32907,7 +33015,7 @@ var init_dist2 = __esm({
32907
33015
  init_git_ssh();
32908
33016
  __dirname3 = path72.dirname(fileURLToPath3(import.meta.url));
32909
33017
  PORT = config.server.port;
32910
- HALO_VERSION = "1.5.0-alpha";
33018
+ HALO_VERSION = "1.5.2-alpha";
32911
33019
  PROJECT_ROOT = findProjectRoot();
32912
33020
  HALO_HOME4 = path72.join(homedir24(), ".halo");
32913
33021
  GIT_SHA = readGitSha();
@@ -33838,7 +33946,7 @@ import path73 from "node:path";
33838
33946
  import fs67 from "node:fs";
33839
33947
  import { homedir as homedir25 } from "node:os";
33840
33948
  import { parseArgs as parseArgs2 } from "node:util";
33841
- var VERSION = "1.5.0-alpha";
33949
+ var VERSION = "1.5.2-alpha";
33842
33950
  var HELP_TOP = `Usage: halo <command> [options]
33843
33951
 
33844
33952
  Commands: