@turmind/halo 1.5.2-alpha → 1.5.4

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.
Files changed (53) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{803-7128009ee0e1c10e.js → 133-33aa091fba853037.js} +1 -1
  3. package/admin-out/_next/static/chunks/{989-7420ee650083df43.js → 989-92e9683ae8d39750.js} +1 -1
  4. package/admin-out/_next/static/chunks/app/page-7a635969198fc62b.js +1 -0
  5. package/admin-out/_next/static/css/4ea5f674146e54d8.css +3 -0
  6. package/admin-out/index.html +1 -1
  7. package/admin-out/index.txt +5 -5
  8. package/bundled-docs/design/agent.md +3 -3
  9. package/bundled-docs/design/architecture.md +3 -3
  10. package/bundled-docs/design/background-dispatch.md +52 -45
  11. package/bundled-docs/design/express-self.md +1 -1
  12. package/bundled-docs/design/prompt-system.md +2 -2
  13. package/bundled-docs/design/session.md +22 -14
  14. package/bundled-docs/design/storage.md +3 -3
  15. package/bundled-docs/design/ws.md +30 -25
  16. package/bundled-docs/dev/add-model-provider.md +2 -2
  17. package/bundled-docs/dev/api.md +37 -18
  18. package/bundled-docs/dev/env.md +2 -2
  19. package/bundled-docs/guide/agents.md +2 -2
  20. package/bundled-docs/guide/chat.md +16 -9
  21. package/bundled-docs/guide/cli.md +11 -1
  22. package/bundled-docs/guide/sessions.md +7 -7
  23. package/bundled-docs/guide/testing-agents-and-skills.md +5 -6
  24. package/bundled-docs/requirements/chat.md +21 -8
  25. package/bundled-docs/requirements/command.md +1 -1
  26. package/bundled-docs/requirements/explorer.md +1 -1
  27. package/bundled-docs/requirements/session.md +10 -7
  28. package/bundled-docs/requirements/terminal.md +2 -0
  29. package/dist/index.js +222 -211
  30. package/package.json +1 -1
  31. package/templates/agents/__apply_agent__/agent.yaml +2 -2
  32. package/templates/agents/__evo_agent__/agent.yaml +2 -2
  33. package/templates/agents/__score__/agent.yaml +2 -2
  34. package/templates/agents/deep-executor/agent.yaml +2 -2
  35. package/templates/agents/default/agent.yaml +2 -2
  36. package/templates/agents/executor/agent.yaml +2 -2
  37. package/templates/agents/goal/agent.yaml +2 -2
  38. package/templates/models/anthropic.yaml +1 -1
  39. package/templates/models/aws-bedrock-claude-invoke.yaml +10 -10
  40. package/templates/models/aws-bedrock-mantle.yaml +4 -4
  41. package/templates/models/aws-bedrock-openai.yaml +6 -6
  42. package/templates/models/deepseek.yaml +2 -2
  43. package/templates/models/kimi.yaml +1 -1
  44. package/templates/models/mimo-token-plan-china.yaml +4 -4
  45. package/templates/models/minimax.yaml +1 -1
  46. package/templates/models/qwen.yaml +2 -2
  47. package/templates/models/zhipu.yaml +4 -4
  48. package/templates/prompts/root/DELEGATION.md +17 -7
  49. package/templates/skills/agent/SKILL.md +2 -2
  50. package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +0 -1
  51. package/admin-out/_next/static/css/b8ffb5202b5a6619.css +0 -3
  52. /package/admin-out/_next/static/{DLRpRENVwZ-EHTRGc9AzB → lm8Bz3ugTSORyuSXfyx56}/_buildManifest.js +0 -0
  53. /package/admin-out/_next/static/{DLRpRENVwZ-EHTRGc9AzB → lm8Bz3ugTSORyuSXfyx56}/_ssgManifest.js +0 -0
package/dist/index.js CHANGED
@@ -508,7 +508,7 @@ function generalSection() {
508
508
  { key: "session.max_queue_size", type: "int", globalOnly: true, description: "Maximum queued messages per session", description_zh: "\u6BCF\u4E2A\u4F1A\u8BDD\u6700\u5927\u6392\u961F\u6D88\u606F\u6570", default: "256" },
509
509
  { key: "session.max_nesting_depth", type: "int", globalOnly: true, description: "Maximum session nesting depth for agent delegation", description_zh: "Agent \u59D4\u6D3E\u7684\u6700\u5927\u4F1A\u8BDD\u5D4C\u5957\u6DF1\u5EA6", default: "16" },
510
510
  // compact
511
- { key: "compact.compress_at", type: "float", globalOnly: true, description: "Auto-compact threshold as a fraction of max context (e.g. 0.8 = compact when 80% full)", description_zh: "\u81EA\u52A8\u538B\u7F29\u9608\u503C\uFF0C\u6700\u5927\u4E0A\u4E0B\u6587\u7684\u6BD4\u4F8B\uFF08\u5982 0.8 \u8868\u793A\u7528\u6EE1 80% \u65F6\u538B\u7F29\uFF09", default: "0.8" },
511
+ { key: "compact.compress_at", type: "float", globalOnly: true, description: "Auto-compact threshold as a fraction of max context (e.g. 0.9 = compact when 90% full)", description_zh: "\u81EA\u52A8\u538B\u7F29\u9608\u503C\uFF0C\u6700\u5927\u4E0A\u4E0B\u6587\u7684\u6BD4\u4F8B\uFF08\u5982 0.9 \u8868\u793A\u7528\u6EE1 90% \u65F6\u538B\u7F29\uFF09", default: "0.9" },
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" },
@@ -1102,7 +1102,7 @@ var init_config = __esm({
1102
1102
  // value is a fraction of `maxContextTokens`; auto-compact triggers when
1103
1103
  // the running context exceeds `maxContextTokens * compressAt`.
1104
1104
  model: {
1105
- maxContextTokens: envInt("HALO_MAX_CONTEXT_TOKENS", 2e5),
1105
+ maxContextTokens: envInt("HALO_MAX_CONTEXT_TOKENS", 272e3),
1106
1106
  get compressAt() {
1107
1107
  const raw = settingsValue("general.compact.compress_at");
1108
1108
  if (raw !== void 0) {
@@ -1110,7 +1110,7 @@ var init_config = __esm({
1110
1110
  if (Number.isFinite(n) && n > 0 && n <= 1)
1111
1111
  return n;
1112
1112
  }
1113
- return 0.8;
1113
+ return 0.9;
1114
1114
  }
1115
1115
  },
1116
1116
  /** System language preference. `general.language` in settings.yaml stores
@@ -2621,7 +2621,7 @@ var init_init = __esm({
2621
2621
  path8.resolve(__dirname, "..", "bundled-docs"),
2622
2622
  path8.resolve(__dirname, "..", "..", "..", ".halo", "docs")
2623
2623
  ];
2624
- TEMPLATE_VERSION = 76;
2624
+ TEMPLATE_VERSION = 78;
2625
2625
  VERSION_FILE = ".template-version";
2626
2626
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
2627
2627
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -7265,9 +7265,10 @@ var init_session_ui_store = __esm({
7265
7265
  * taskId: `state.subSessionLogs[id]`, which holds the sub's full history —
7266
7266
  * see loadSubHistory — plus the in-flight turn as a temp assistant, the
7267
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. */
7268
+ * archiveOldMessages (the commit step of an archive write, root or sub
7269
+ * alike): `count` is the new segment count and `userDelta` the main user
7270
+ * turns leaving the active file. Every other call leaves both on-disk
7271
+ * values alone. */
7271
7272
  persistLog(id, state, archive) {
7272
7273
  const rootId = this.findRootSessionId(id);
7273
7274
  const sub = id === rootId ? void 0 : state.subSessionLogs.get(id);
@@ -7290,7 +7291,9 @@ var init_session_ui_store = __esm({
7290
7291
  agentName: sub.agentName,
7291
7292
  source: "delegated",
7292
7293
  description: sub.description,
7293
- parentSessionId: parts.slice(0, -1).join(">")
7294
+ parentSessionId: parts.slice(0, -1).join(">"),
7295
+ archiveCount: archive?.count,
7296
+ archivedUserDelta: archive?.userDelta
7294
7297
  });
7295
7298
  return;
7296
7299
  }
@@ -7316,10 +7319,14 @@ var init_session_ui_store = __esm({
7316
7319
  }
7317
7320
  }
7318
7321
  /**
7319
- * Move a root session's UI log out into a gzipped archive segment, keeping
7320
- * only the newest main exchange in the active file. Called from the compact
7322
+ * Move a session's UI log out into a gzipped archive segment, keeping only
7323
+ * the newest main exchange in the active file. Called from the compact
7321
7324
  * path — the only place that already accepts "history shrinks here".
7322
7325
  *
7326
+ * Root and sub-session alike: a sub's log lives in its root's UIState
7327
+ * (`subSessionLogs`, keyed by the full `root>…` id) but is written to the
7328
+ * sub's own file, so archiving it never touches the parent's log or file.
7329
+ *
7323
7330
  * Gated on the active file's SIZE (`ARCHIVE_SIZE_THRESHOLD`), not its exchange
7324
7331
  * count: bytes are what make a log slow to load, and per-exchange size varies
7325
7332
  * by orders of magnitude. Under the threshold this returns 0 after a single
@@ -7340,35 +7347,36 @@ var init_session_ui_store = __esm({
7340
7347
  */
7341
7348
  archiveOldMessages(sessionId) {
7342
7349
  const rootId = this.findRootSessionId(sessionId);
7343
- if (this.host.isSessionDeleted(rootId))
7350
+ const isRoot = sessionId === rootId;
7351
+ if (this.host.isSessionDeleted(rootId) || this.host.isSessionDeleted(sessionId))
7352
+ return 0;
7353
+ const state = isRoot ? this.ensureUIState(rootId) : this.uiStates.get(rootId);
7354
+ const log2 = isRoot ? state : state?.subSessionLogs.get(sessionId);
7355
+ if (!state || !log2)
7344
7356
  return 0;
7345
- const state = this.ensureUIState(rootId);
7346
7357
  try {
7347
- const inMem = this.host.getSession(rootId);
7348
- const row = inMem ? null : this.db.select().from(agentSessions).where(eq6(agentSessions.id, rootId)).get();
7349
- const agentId = inMem?.agentId ?? row?.agentId ?? "default";
7350
- const dir = getSessionDir(agentId, this.uiStateProjectPaths.get(rootId) ?? this.host.workspaceRoot);
7351
- const seg = fileSegment(rootId);
7358
+ const dir = getSessionDir(this.resolveAgentId(sessionId), this.uiStateProjectPaths.get(rootId) ?? this.host.workspaceRoot);
7359
+ const seg = fileSegment(sessionId);
7352
7360
  if (activeFileSize(dir, seg) <= ARCHIVE_SIZE_THRESHOLD)
7353
7361
  return 0;
7354
- const cut = archiveSplitIndex(state.messageLog, 1);
7362
+ const cut = archiveSplitIndex(log2.messageLog, 1);
7355
7363
  if (cut === 0)
7356
7364
  return 0;
7357
- const older = state.messageLog.slice(0, cut);
7358
- const kept = state.messageLog.slice(cut);
7365
+ const older = log2.messageLog.slice(0, cut);
7366
+ const kept = log2.messageLog.slice(cut);
7359
7367
  const n = readArchiveCount(dir, seg) + 1;
7360
7368
  writeArchiveSegment(dir, seg, n, older);
7361
- state.messageLog = kept;
7362
- this.persistLog(rootId, state, { count: n, userDelta: countMainUserMessages(older) });
7369
+ log2.messageLog = kept;
7370
+ this.persistLog(sessionId, state, { count: n, userDelta: countMainUserMessages(older) });
7363
7371
  if (readArchiveCount(dir, seg) < n) {
7364
- state.messageLog = [...older, ...kept];
7365
- console.warn(`[SessionUIStore] archive commit for ${rootId} segment ${n} did not land \u2014 rolled back`);
7372
+ log2.messageLog = [...older, ...kept];
7373
+ console.warn(`[SessionUIStore] archive commit for ${sessionId} segment ${n} did not land \u2014 rolled back`);
7366
7374
  return 0;
7367
7375
  }
7368
- console.debug(`[SessionUIStore] archived ${older.length} UI messages of ${rootId} into segment ${n}`);
7376
+ console.debug(`[SessionUIStore] archived ${older.length} UI messages of ${sessionId} into segment ${n}`);
7369
7377
  return older.length;
7370
7378
  } catch (err) {
7371
- console.error(`[SessionUIStore] archiveOldMessages failed for ${rootId}: ${err instanceof Error ? err.message : String(err)}`);
7379
+ console.error(`[SessionUIStore] archiveOldMessages failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
7372
7380
  return 0;
7373
7381
  }
7374
7382
  }
@@ -12061,7 +12069,7 @@ ${roster}`;
12061
12069
  buildModelRuntime(args) {
12062
12070
  const { yamlConfig, modelId, endpoint, providerId, sessionId, systemPrompt, tools } = args;
12063
12071
  const contextConfig = {
12064
- // Priority: explicit agent.yaml config > model registry contextWindow > global default (200K).
12072
+ // Priority: explicit agent.yaml config > model registry contextWindow > global default (272K).
12065
12073
  maxTokens: yamlConfig?.context?.maxTokens ?? resolveContextWindow(modelId) ?? config.model.maxContextTokens,
12066
12074
  compressAt: yamlConfig?.context?.compressAt ?? config.model.compressAt
12067
12075
  };
@@ -12273,7 +12281,7 @@ var init_session_state_store = __esm({
12273
12281
  existing.agentName = session.agentName;
12274
12282
  if (session.parentId)
12275
12283
  existing.parentSessionId = session.parentId;
12276
- if (!existing.title)
12284
+ if (!existing.title && session.parentId)
12277
12285
  existing.title = session.description?.slice(0, 60) || `${session.agentId} session`;
12278
12286
  if (!existing.createdAt)
12279
12287
  existing.createdAt = now;
@@ -12369,7 +12377,7 @@ async function initObservability() {
12369
12377
  return true;
12370
12378
  }
12371
12379
  const { registerSdk: registerSdk2 } = await Promise.resolve().then(() => (init_otel_sdk(), otel_sdk_exports));
12372
- providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.2-alpha");
12380
+ providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.4");
12373
12381
  console.log(`[Observability] OTLP export \u2192 ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT} (service.name=${process.env.OTEL_SERVICE_NAME}, capture_content=${config.observability.captureContent})`);
12374
12382
  return true;
12375
12383
  }
@@ -14561,8 +14569,7 @@ ${summaryText}` }]
14561
14569
  const finalTail = tailMicro.compacted ? tailMicro.messages : cleanRecent;
14562
14570
  session.agent.messages = [summaryMsg, ...finalTail];
14563
14571
  rebuilt = true;
14564
- if (!session.parentId)
14565
- this.uiStore.archiveOldMessages(session.id);
14572
+ this.uiStore.archiveOldMessages(session.id);
14566
14573
  const estimatedTokens = estimateMessageTokens(session.agent.messages) + estimateMessageTokens([{ role: "user", content: session.systemPrompt }]);
14567
14574
  return { summary: summaryText, olderCount, estimatedTokens };
14568
14575
  } finally {
@@ -15222,6 +15229,11 @@ function listSessions(workspace) {
15222
15229
  const { sessions } = sm.listSessions({ limit: 200 });
15223
15230
  return sessions.map((s) => ({ id: s.id, description: s.description, createdAt: s.createdAt }));
15224
15231
  }
15232
+ async function agentSupportsImage(sm, workspace, agentId) {
15233
+ const agents = await scanAvailableAgents(workspace, getDisabledSet(sm.getDb(), "agent"));
15234
+ const entry = agents.find((a) => a.id === agentId);
15235
+ return entry ? modelSupportsImage(entry.model) : false;
15236
+ }
15225
15237
  async function createHarness(opts) {
15226
15238
  const initialPath = path32.resolve(opts.workspace);
15227
15239
  if (!fs26.existsSync(initialPath)) {
@@ -15257,10 +15269,7 @@ async function createHarness(opts) {
15257
15269
  }
15258
15270
  activeOverrides2.set(USER_ID, sessionId);
15259
15271
  const sessionAgentId = sm.getSessionById(sessionId)?.agentId ?? agentId;
15260
- const disabledSet = getDisabledSet(sm.getDb(), "agent");
15261
- const agents = await scanAvailableAgents(workspace, disabledSet);
15262
- const agentEntry = agents.find((a) => a.id === agentId);
15263
- const supportsImage = agentEntry ? modelSupportsImage(agentEntry.model) : false;
15272
+ const supportsImage = await agentSupportsImage(sm, workspace, sessionAgentId);
15264
15273
  const unsubPersistent = sm.registerEventListener(sessionId, (event) => {
15265
15274
  for (const h of eventHandlers) h(event);
15266
15275
  });
@@ -15328,10 +15337,12 @@ async function createHarness(opts) {
15328
15337
  if (result?.switchTo) {
15329
15338
  activeOverrides2.set(USER_ID, result.switchTo);
15330
15339
  state.unsubPersistent();
15340
+ const switchedAgentId = state.sm.getSessionById(result.switchTo)?.agentId ?? agentId;
15331
15341
  state = {
15332
15342
  ...state,
15333
15343
  sessionId: result.switchTo,
15334
- agentId: state.sm.getSessionById(result.switchTo)?.agentId ?? agentId,
15344
+ agentId: switchedAgentId,
15345
+ supportsImage: await agentSupportsImage(state.sm, state.workspace, switchedAgentId),
15335
15346
  unsubPersistent: state.sm.registerEventListener(result.switchTo, (event) => {
15336
15347
  for (const h of eventHandlers) h(event);
15337
15348
  })
@@ -18028,7 +18039,7 @@ var init_app = __esm({
18028
18039
  init_input_box();
18029
18040
  init_log_navigator();
18030
18041
  init_log_viewer();
18031
- MAX_CONTEXT_TOKENS_FALLBACK = 2e5;
18042
+ MAX_CONTEXT_TOKENS_FALLBACK = 272e3;
18032
18043
  blockSeq = 0;
18033
18044
  ANSI2 = {
18034
18045
  reset: "\x1B[0m",
@@ -18099,6 +18110,7 @@ async function runCli(harness, message, opts) {
18099
18110
  let turnAll = "";
18100
18111
  const toolCalls = [];
18101
18112
  let errorText = "";
18113
+ const notices = [];
18102
18114
  let usage = null;
18103
18115
  let lastToolName = "";
18104
18116
  let hadMeta = false;
@@ -18219,6 +18231,16 @@ ${tag}${formatUsageLine(event)}
18219
18231
  `);
18220
18232
  hadMeta = true;
18221
18233
  break;
18234
+ case "system":
18235
+ if (!event.text) break;
18236
+ if (opts.verbose) {
18237
+ stopSpinner();
18238
+ process.stderr.write(`${tag}\x1B[2m[system] ${event.text}\x1B[0m
18239
+ `);
18240
+ hadMeta = true;
18241
+ }
18242
+ if (!event.taskId && !event.agentName) notices.push(event.text);
18243
+ break;
18222
18244
  case "complete":
18223
18245
  stopSpinner();
18224
18246
  break;
@@ -18227,6 +18249,9 @@ ${tag}${formatUsageLine(event)}
18227
18249
  stopSpinner();
18228
18250
  const answer = turnFinal || turnAll;
18229
18251
  if (opts.format === "text") {
18252
+ if (notices.length > 0) {
18253
+ process.stdout.write(notices.map((n) => `\u2139\uFE0F ${n}`).join("\n") + (answer ? "\n\n" : "\n"));
18254
+ }
18230
18255
  if (answer) {
18231
18256
  const out = process.stdout.isTTY ? renderMarkdown(answer) : answer;
18232
18257
  process.stdout.write(out.endsWith("\n") ? out : out + "\n");
@@ -18234,6 +18259,7 @@ ${tag}${formatUsageLine(event)}
18234
18259
  } else {
18235
18260
  const result = {
18236
18261
  text: answer,
18262
+ notices,
18237
18263
  sessionId: harness.sessionId,
18238
18264
  toolCalls,
18239
18265
  usage: usage ? {
@@ -21608,7 +21634,11 @@ function createSessionRoutes(smRegistry) {
21608
21634
  totalOutputTokens,
21609
21635
  // Goal-mode: non-null while this session is the bound worker of an
21610
21636
  // active goal — session lists render a 🎯 badge off this.
21611
- goalSessionId: s.goalSessionId
21637
+ goalSessionId: s.goalSessionId,
21638
+ // Running flag for an Explorer chat tab that hasn't loaded yet (a
21639
+ // loaded tab tracks its own stream). A root with a running sub-session
21640
+ // counts as running.
21641
+ status: s.status
21612
21642
  };
21613
21643
  };
21614
21644
  const enriched = [...rows.map(enrich), ...descendants.map(enrich)];
@@ -23953,11 +23983,11 @@ function sendWsNotification(event, state, turnId, ctx) {
23953
23983
  break;
23954
23984
  case "system":
23955
23985
  if (!taskId && /^Compacting context \(\d+K tokens\)…$/.test(event.text ?? "")) {
23956
- sendJson(ctx.ws, { type: "compact:started" });
23986
+ sendJson(ctx.ws, { type: "compact:started", sessionId });
23957
23987
  }
23958
23988
  sendJson(ctx.ws, { type: "chat:system", text: event.text ?? "", taskId, agentName, sessionId });
23959
23989
  if (event.compactEnd && !taskId) {
23960
- sendJson(ctx.ws, { type: "compact:done" });
23990
+ sendJson(ctx.ws, { type: "compact:done", sessionId });
23961
23991
  }
23962
23992
  break;
23963
23993
  case "error":
@@ -23970,28 +24000,28 @@ function sendWsNotification(event, state, turnId, ctx) {
23970
24000
  break;
23971
24001
  case "compacted":
23972
24002
  if (!taskId) {
23973
- sendJson(ctx.ws, { type: "compact:done" });
23974
- sendJson(ctx.ws, { type: "session:compacted", contextTokens: event.totalTokens ?? state.contextTokens });
24003
+ sendJson(ctx.ws, { type: "compact:done", sessionId });
24004
+ sendJson(ctx.ws, { type: "session:compacted", contextTokens: event.totalTokens ?? state.contextTokens, sessionId });
23975
24005
  }
23976
24006
  break;
23977
24007
  }
23978
24008
  }
23979
- function bufferDetachedNotification(event, pendingEvents) {
24009
+ function bufferDetachedNotification(event, pendingEvents, sessionId) {
23980
24010
  const agentName = event.agentName ?? "default";
23981
24011
  const taskId = event.taskId;
23982
24012
  switch (event.type) {
23983
24013
  case "agent_start":
23984
- pendingEvents.push({ type: "agent:start", agentName, task: event.text, taskId });
24014
+ pendingEvents.push({ type: "agent:start", agentName, task: event.text, taskId, sessionId });
23985
24015
  break;
23986
24016
  case "agent_done":
23987
- pendingEvents.push({ type: "agent:done", agentName, taskId });
24017
+ pendingEvents.push({ type: "agent:done", agentName, taskId, sessionId });
23988
24018
  break;
23989
24019
  case "error":
23990
- pendingEvents.push({ type: "error", error: event.error, agentName, taskId });
24020
+ pendingEvents.push({ type: "error", error: event.error, agentName, taskId, sessionId });
23991
24021
  break;
23992
24022
  case "followup_start":
23993
24023
  case "queued_message":
23994
- pendingEvents.push({ type: "chat:followup", agentName });
24024
+ pendingEvents.push({ type: "chat:followup", agentName, sessionId });
23995
24025
  break;
23996
24026
  case "complete":
23997
24027
  pendingEvents.length = 0;
@@ -24276,10 +24306,10 @@ function setupWebSocketHandler(deps) {
24276
24306
  function getSessionManager(workspacePath) {
24277
24307
  return registry2.getOrCreate(workspacePath);
24278
24308
  }
24279
- function getState(client) {
24280
- if (!client.sessionManager || !client.sessionId)
24309
+ function getState(client, sessionId) {
24310
+ if (!client.sessionManager)
24281
24311
  return null;
24282
- return client.sessionManager.getCachedUIState(client.sessionId);
24312
+ return client.sessionManager.getCachedUIState(sessionId);
24283
24313
  }
24284
24314
  function archiveCountFor(client, sessionId, agentId) {
24285
24315
  if (!agentId || !client.projectId)
@@ -24289,21 +24319,21 @@ function setupWebSocketHandler(deps) {
24289
24319
  return 0;
24290
24320
  return readArchiveCount(getSessionDir(agentId, projectPath), fileSegment(sessionId));
24291
24321
  }
24292
- function saveSession(client) {
24293
- if (!client.sessionId || !client.projectId || !client.sessionManager)
24322
+ function saveSession(client, sessionId) {
24323
+ if (!client.projectId || !client.sessionManager)
24294
24324
  return;
24295
- const state = getState(client);
24325
+ const state = getState(client, sessionId);
24296
24326
  if (!state)
24297
24327
  return;
24298
- if (!client.sessionManager.isUIStateDirty(client.sessionId))
24328
+ if (!client.sessionManager.isUIStateDirty(sessionId))
24299
24329
  return;
24300
24330
  const projectPath = resolveProjectPath2(client.projectId);
24301
24331
  const snapshot2 = createSaveSnapshot(state);
24302
24332
  if (snapshot2.length === 0)
24303
24333
  return;
24304
- const sessionInfo = client.sessionManager.getSessionById(client.sessionId);
24334
+ const sessionInfo = client.sessionManager.getSessionById(sessionId);
24305
24335
  client.sessionManager.persistSessionFile({
24306
- sessionId: client.sessionId,
24336
+ sessionId,
24307
24337
  projectPath,
24308
24338
  messages: snapshot2,
24309
24339
  contextTokens: state.contextTokens,
@@ -24314,10 +24344,8 @@ function setupWebSocketHandler(deps) {
24314
24344
  }
24315
24345
  const wsActiveOverrides = /* @__PURE__ */ new Map();
24316
24346
  let nextCommandUserId = 1;
24317
- function buildSharedCommandContext(client) {
24318
- const sid = client.sessionId ?? "";
24319
- if (sid)
24320
- wsActiveOverrides.set(client.commandUserId, sid);
24347
+ function buildSharedCommandContext(client, sessionId) {
24348
+ wsActiveOverrides.set(client.commandUserId, sessionId);
24321
24349
  return {
24322
24350
  sm: client.sessionManager,
24323
24351
  userId: client.commandUserId,
@@ -24329,19 +24357,8 @@ function setupWebSocketHandler(deps) {
24329
24357
  lang: "en"
24330
24358
  };
24331
24359
  }
24332
- async function handleSessionClear(client, ws, msg) {
24333
- const prevSessionId = msg.sessionId ?? client.sessionId;
24334
- saveSession(client);
24335
- if (prevSessionId && client.sessionId && client.sessionManager) {
24336
- client.unsubscribeEvents?.();
24337
- client.unsubscribeEvents = null;
24338
- client.backgroundSaves.set(prevSessionId, () => saveSession(client));
24339
- }
24340
- client.sessionId = null;
24341
- sendJson(ws, { type: "session:cleared" });
24342
- }
24343
24360
  async function handleSessionDelete(client, ws, msg) {
24344
- const delSessionId = msg.sessionId ?? client.sessionId;
24361
+ const delSessionId = frameSessionId(client, msg);
24345
24362
  if (!delSessionId) {
24346
24363
  sendJson(ws, { type: "error", error: "session:delete requires sessionId" });
24347
24364
  return;
@@ -24350,14 +24367,12 @@ function setupWebSocketHandler(deps) {
24350
24367
  const projectPath = requestedProjectId ? resolveProjectPath2(requestedProjectId) : null;
24351
24368
  const sm = projectPath ? getSessionManager(projectPath) : client.sessionManager;
24352
24369
  if (!sm) {
24353
- sendJson(ws, { type: "error", error: "No workspace context for delete" });
24370
+ sendJson(ws, { type: "error", error: "No workspace context for delete", sessionId: delSessionId });
24354
24371
  return;
24355
24372
  }
24356
24373
  await sm.deleteSession(delSessionId);
24357
- if (delSessionId === client.sessionId) {
24358
- client.sessionId = null;
24359
- }
24360
- client.backgroundSaves.delete(delSessionId);
24374
+ client.subscriptions.get(delSessionId)?.();
24375
+ client.subscriptions.delete(delSessionId);
24361
24376
  sendJson(ws, { type: "session:deleted", sessionId: delSessionId });
24362
24377
  }
24363
24378
  async function handleExchangeDelete(client, ws, msg) {
@@ -24370,62 +24385,85 @@ function setupWebSocketHandler(deps) {
24370
24385
  const projectPath = requestedProjectId ? resolveProjectPath2(requestedProjectId) : null;
24371
24386
  const sm = projectPath ? getSessionManager(projectPath) : client.sessionManager;
24372
24387
  if (!sm) {
24373
- sendJson(ws, { type: "error", error: "No workspace context for exchange:delete" });
24388
+ sendJson(ws, { type: "error", error: "No workspace context for exchange:delete", sessionId: targetSessionId });
24374
24389
  return;
24375
24390
  }
24376
24391
  const result = await sm.deleteExchange(targetSessionId, msg.userOrdinal, msg.archiveCount ?? 0);
24377
24392
  if (result === "running") {
24378
- sendJson(ws, { type: "error", error: "Cannot delete while the agent is running" });
24393
+ sendJson(ws, { type: "error", error: "Cannot delete while the agent is running", sessionId: targetSessionId });
24379
24394
  return;
24380
24395
  }
24381
24396
  if (result === "compacting") {
24382
- sendJson(ws, { type: "error", error: "Cannot delete while compacting" });
24397
+ sendJson(ws, { type: "error", error: "Cannot delete while compacting", sessionId: targetSessionId });
24383
24398
  return;
24384
24399
  }
24385
24400
  if (result === "archived") {
24386
- sendJson(ws, { type: "error", code: "archived", error: "This session archived history since it was opened \u2014 reopen it to delete individual turns." });
24401
+ sendJson(ws, { type: "error", code: "archived", error: "This session archived history since it was opened \u2014 reopen it to delete individual turns.", sessionId: targetSessionId });
24387
24402
  return;
24388
24403
  }
24389
24404
  if (result === "not_found" || result === "no_exchange") {
24390
- sendJson(ws, { type: "error", error: "Exchange not found" });
24405
+ sendJson(ws, { type: "error", error: "Exchange not found", sessionId: targetSessionId });
24391
24406
  return;
24392
24407
  }
24393
- if (client.sessionId && client.sessionId === targetSessionId) {
24394
- const state = getState(client);
24408
+ if (client.subscriptions.has(targetSessionId)) {
24409
+ const state = getState(client, targetSessionId);
24395
24410
  const messages2 = state ? [...createSaveSnapshot(state)] : [];
24396
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: client.sessionId } });
24411
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: targetSessionId } });
24397
24412
  }
24398
24413
  }
24399
- function createEventListener(client) {
24414
+ function createEventListener(client, sessionId) {
24400
24415
  return (event, state, turnId) => {
24401
- sendWsNotification(event, state, turnId, {
24402
- ws: client.ws,
24403
- sessionId: client.sessionId
24404
- });
24416
+ sendWsNotification(event, state, turnId, { ws: client.ws, sessionId });
24405
24417
  };
24406
24418
  }
24419
+ function subscribeSession(client, sm, sessionId) {
24420
+ if (client.subscriptions.has(sessionId))
24421
+ return;
24422
+ client.subscriptions.set(sessionId, sm.registerEventListener(sessionId, createEventListener(client, sessionId)));
24423
+ }
24424
+ function frameSessionId(client, msg) {
24425
+ if (msg.sessionId !== void 0)
24426
+ return msg.sessionId;
24427
+ return client.subscriptions.size === 1 ? client.subscriptions.keys().next().value ?? null : null;
24428
+ }
24429
+ function unsubscribeSession(client, sessionId) {
24430
+ const unsubscribe = client.subscriptions.get(sessionId);
24431
+ if (!unsubscribe)
24432
+ return;
24433
+ saveSession(client, sessionId);
24434
+ unsubscribe();
24435
+ client.subscriptions.delete(sessionId);
24436
+ }
24437
+ function setClientProject(client, projectId) {
24438
+ if (client.projectId === projectId)
24439
+ return;
24440
+ for (const sid of [...client.subscriptions.keys()])
24441
+ unsubscribeSession(client, sid);
24442
+ client.projectId = projectId;
24443
+ }
24407
24444
  function reclaimIfAbandoned(client) {
24408
- if (!client.unsubscribeEvents)
24445
+ if (client.subscriptions.size === 0)
24409
24446
  return;
24410
24447
  const closed = client.ws.readyState === client.ws.CLOSED;
24411
24448
  if (!closed && Date.now() - client.lastClientPingAt <= CLIENT_SILENCE_LIMIT_MS)
24412
24449
  return;
24413
- client.unsubscribeEvents();
24414
- client.unsubscribeEvents = null;
24415
- console.debug(`[WS] Released listener for ${closed ? "closed" : "abandoned"} connection session=${client.sessionId}`);
24416
- sendJson(client.ws, { type: "listener:released", sessionId: client.sessionId });
24450
+ const released = [...client.subscriptions.keys()];
24451
+ for (const unsubscribe of client.subscriptions.values())
24452
+ unsubscribe();
24453
+ client.subscriptions.clear();
24454
+ console.debug(`[WS] Released listeners for ${closed ? "closed" : "abandoned"} connection sessions=${released.join(",")}`);
24455
+ for (const sessionId of released)
24456
+ sendJson(client.ws, { type: "listener:released", sessionId });
24417
24457
  }
24418
24458
  wss2.on("connection", (ws) => {
24419
24459
  const terminalManager = new TerminalManager(ws);
24420
24460
  const client = {
24421
24461
  ws,
24422
- sessionId: null,
24462
+ subscriptions: /* @__PURE__ */ new Map(),
24423
24463
  projectId: null,
24424
24464
  sessionManager: null,
24425
24465
  agentId: "default",
24426
24466
  terminalManager,
24427
- backgroundSaves: /* @__PURE__ */ new Map(),
24428
- unsubscribeEvents: null,
24429
24467
  lastClientPingAt: Date.now(),
24430
24468
  commandUserId: `ws-${nextCommandUserId++}`
24431
24469
  };
@@ -24474,14 +24512,18 @@ function setupWebSocketHandler(deps) {
24474
24512
  await handleChat(client, msg);
24475
24513
  break;
24476
24514
  case "chat:stop":
24477
- handleChatStop(client);
24515
+ handleChatStop(client, msg);
24478
24516
  break;
24479
24517
  case "chat:interrupt":
24480
- handleChatInterrupt(client);
24518
+ handleChatInterrupt(client, msg);
24481
24519
  break;
24482
24520
  case "subscribe":
24483
24521
  await handleSubscribe(client, msg);
24484
24522
  break;
24523
+ case "unsubscribe":
24524
+ if (msg.sessionId)
24525
+ unsubscribeSession(client, msg.sessionId);
24526
+ break;
24485
24527
  case "terminal:start":
24486
24528
  handleTerminalStart(client, msg);
24487
24529
  break;
@@ -24495,9 +24537,6 @@ function setupWebSocketHandler(deps) {
24495
24537
  case "terminal:reattach":
24496
24538
  terminalManager.reattachAll(msg.browserId ?? "", msg.workspacePath ?? "");
24497
24539
  break;
24498
- case "session:clear":
24499
- await handleSessionClear(client, ws, msg);
24500
- break;
24501
24540
  case "session:delete":
24502
24541
  await handleSessionDelete(client, ws, msg);
24503
24542
  break;
@@ -24510,51 +24549,48 @@ function setupWebSocketHandler(deps) {
24510
24549
  break;
24511
24550
  }
24512
24551
  const cmdName = msg.type.slice("command:".length);
24513
- await bindOrCreateSession(client, msg);
24552
+ const sid = msg.sessionId === void 0 ? frameSessionId(client, msg) : await bindOrCreateSession(client, msg);
24514
24553
  const sm = client.sessionManager;
24515
- const sid = client.sessionId;
24516
24554
  if (!sm || !sid) {
24517
24555
  sendJson(ws, { type: "error", error: "No active session" });
24518
24556
  break;
24519
24557
  }
24520
24558
  if (cmdName === "session" && (msg.message ?? "").trim().split(/\s+/)[0] === "compact") {
24521
24559
  sm.compactSession(sid, {
24522
- onProgress: (status) => sendJson(ws, { type: `compact:${status}` })
24560
+ onProgress: (status) => sendJson(ws, { type: `compact:${status}`, sessionId: sid })
24523
24561
  }).then((result2) => {
24524
24562
  if (result2 === "no_session")
24525
- sendJson(ws, { type: "error", error: "No active session to compact" });
24563
+ sendJson(ws, { type: "error", error: "No active session to compact", sessionId: sid });
24526
24564
  else if (result2 === "running")
24527
- sendJson(ws, { type: "error", error: "Cannot compact while agent is running" });
24565
+ sendJson(ws, { type: "error", error: "Cannot compact while agent is running", sessionId: sid });
24528
24566
  else if (result2 === "already")
24529
- sendJson(ws, { type: "error", error: "Compact already in progress" });
24567
+ sendJson(ws, { type: "error", error: "Compact already in progress", sessionId: sid });
24530
24568
  else if (result2 === "nothing") {
24531
24569
  const state = sm.getCachedUIState(sid);
24532
- sendJson(ws, { type: "session:compacted", message: "Nothing to compact", contextTokens: state?.contextTokens ?? 0 });
24570
+ sendJson(ws, { type: "session:compacted", message: "Nothing to compact", contextTokens: state?.contextTokens ?? 0, sessionId: sid });
24533
24571
  }
24534
24572
  }).catch((err) => {
24535
- sendJson(ws, { type: "error", error: `Compact failed: ${err instanceof Error ? err.message : String(err)}` });
24573
+ sendJson(ws, { type: "error", error: `Compact failed: ${err instanceof Error ? err.message : String(err)}`, sessionId: sid });
24536
24574
  });
24537
24575
  break;
24538
24576
  }
24539
- const sharedCtx = buildSharedCommandContext(client);
24577
+ const sharedCtx = buildSharedCommandContext(client, sid);
24540
24578
  const result = await dispatchCommand(sharedCtx, `/${cmdName}`, (msg.message ?? "").trim(), { channelName: "ws" });
24541
24579
  if (result) {
24542
- sendJson(ws, { type: "chat:system", text: result.text });
24580
+ sendJson(ws, { type: "chat:system", text: result.text, sessionId: sid });
24543
24581
  if (result.switchTo) {
24544
- client.unsubscribeEvents?.();
24545
- client.sessionId = result.switchTo;
24546
- client.unsubscribeEvents = sm.registerEventListener(result.switchTo, createEventListener(client));
24547
- sendJson(ws, { type: "session:switched", sessionId: result.switchTo });
24582
+ subscribeSession(client, sm, result.switchTo);
24583
+ sendJson(ws, { type: "session:switched", sessionId: result.switchTo, fromSessionId: sid });
24548
24584
  }
24549
24585
  } else {
24550
- sendJson(ws, { type: "error", error: `Unknown command: ${cmdName}` });
24586
+ sendJson(ws, { type: "error", error: `Unknown command: ${cmdName}`, sessionId: sid });
24551
24587
  }
24552
24588
  }
24553
24589
  }
24554
24590
  } catch (err) {
24555
24591
  const errorMessage = err instanceof Error ? err.message : String(err);
24556
24592
  console.debug(`[WS] Error handling message: ${errorMessage}`);
24557
- sendJson(ws, { type: "error", error: errorMessage });
24593
+ sendJson(ws, { type: "error", error: errorMessage, ...msg.sessionId ? { sessionId: msg.sessionId } : {} });
24558
24594
  }
24559
24595
  });
24560
24596
  });
@@ -24565,23 +24601,24 @@ function setupWebSocketHandler(deps) {
24565
24601
  terminalManager.detachAll();
24566
24602
  wsActiveOverrides.delete(client.commandUserId);
24567
24603
  const sm = client.sessionManager;
24568
- const sid = client.sessionId;
24569
- const hasActiveWork = sid && sm && (sm.isSessionRunning(sid) || sm.hasRunningSessions());
24570
- if (client.sessionId && hasActiveWork && sm && sid) {
24571
- console.debug(`[WS] Detaching active session: ${client.sessionId}`);
24572
- client.unsubscribeEvents?.();
24573
- client.unsubscribeEvents = null;
24604
+ for (const [sid, unsubscribeLive] of client.subscriptions) {
24605
+ unsubscribeLive();
24606
+ if (!sm || !sm.hasActiveWorkInTree(sid) || detachedSessions.has(sid)) {
24607
+ saveSession(client, sid);
24608
+ continue;
24609
+ }
24610
+ console.debug(`[WS] Detaching active session: ${sid}`);
24574
24611
  const pendingEvents = [];
24575
24612
  const bgHandler = (_event, _state, _turnId) => {
24576
- bufferDetachedNotification(_event, pendingEvents);
24613
+ bufferDetachedNotification(_event, pendingEvents, sid);
24577
24614
  };
24578
24615
  const unsubscribe = sm.registerEventListener(sid, bgHandler);
24579
24616
  const graceTimer = setTimeout(() => {
24580
- detachedSessions.delete(client.sessionId);
24581
- saveSession(client);
24617
+ detachedSessions.delete(sid);
24618
+ saveSession(client, sid);
24582
24619
  unsubscribe();
24583
24620
  }, config.timeout.sessionGrace);
24584
- detachedSessions.set(client.sessionId, {
24621
+ detachedSessions.set(sid, {
24585
24622
  sessionManager: sm,
24586
24623
  sessionId: sid,
24587
24624
  projectId: client.projectId ?? "",
@@ -24589,15 +24626,8 @@ function setupWebSocketHandler(deps) {
24589
24626
  pendingEvents,
24590
24627
  unsubscribe
24591
24628
  });
24592
- } else {
24593
- client.unsubscribeEvents?.();
24594
- client.unsubscribeEvents = null;
24595
- saveSession(client);
24596
- }
24597
- for (const [sid2, saveFn] of client.backgroundSaves) {
24598
- saveFn();
24599
- client.backgroundSaves.delete(sid2);
24600
24629
  }
24630
+ client.subscriptions.clear();
24601
24631
  watchers.detach(ws);
24602
24632
  console.debug(`[WS] Client disconnected (total: ${clients.size})`);
24603
24633
  }
@@ -24610,7 +24640,7 @@ function setupWebSocketHandler(deps) {
24610
24640
  const projectId = msg.projectId ?? client2.projectId;
24611
24641
  if (!msg.sessionId || !projectId)
24612
24642
  return null;
24613
- client2.projectId = projectId;
24643
+ setClientProject(client2, projectId);
24614
24644
  const agentId = msg.agentId ?? client2.agentId;
24615
24645
  const projectPath = resolveProjectPath2(projectId);
24616
24646
  if (projectPath) {
@@ -24620,19 +24650,17 @@ function setupWebSocketHandler(deps) {
24620
24650
  if (!client2.sessionManager)
24621
24651
  return null;
24622
24652
  const sm = client2.sessionManager;
24623
- const existing = sm.getSessionById(msg.sessionId);
24653
+ const sid = msg.sessionId;
24654
+ const existing = sm.getSessionById(sid);
24624
24655
  if (!existing) {
24625
- client2.unsubscribeEvents?.();
24626
- client2.sessionId = await sm.createSession(agentId, null, "Explorer chat", void 0, msg.sessionId);
24627
- client2.unsubscribeEvents = sm.registerEventListener(client2.sessionId, createEventListener(client2));
24628
- const ctxConfig = await sm.getContextConfig(client2.sessionId);
24629
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: [], sessionId: client2.sessionId, maxContextTokens: ctxConfig.maxTokens, agentId } });
24630
- } else if (client2.sessionId !== msg.sessionId || !client2.unsubscribeEvents) {
24631
- client2.unsubscribeEvents?.();
24632
- client2.sessionId = msg.sessionId;
24633
- client2.unsubscribeEvents = sm.registerEventListener(client2.sessionId, createEventListener(client2));
24634
- }
24635
- return client2.sessionId;
24656
+ await sm.createSession(agentId, null, "Explorer chat", void 0, sid);
24657
+ subscribeSession(client2, sm, sid);
24658
+ const ctxConfig = await sm.getContextConfig(sid);
24659
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: [], sessionId: sid, maxContextTokens: ctxConfig.maxTokens, agentId } });
24660
+ } else {
24661
+ subscribeSession(client2, sm, sid);
24662
+ }
24663
+ return sid;
24636
24664
  }
24637
24665
  function rememberChat(msg) {
24638
24666
  if (msg.clientMsgId)
@@ -24656,17 +24684,16 @@ function setupWebSocketHandler(deps) {
24656
24684
  const projectPath = resolveProjectPath2(msg.projectId);
24657
24685
  let sid = await bindOrCreateSession(client2, msg);
24658
24686
  if (!sid || !client2.sessionManager) {
24659
- sendJson(ws, { type: "error", error: "Cannot resolve project path" });
24687
+ sendJson(ws, { type: "error", error: "Cannot resolve project path", sessionId: msg.sessionId });
24660
24688
  return;
24661
24689
  }
24662
24690
  const sm = client2.sessionManager;
24663
24691
  const goalRouted = resolveGoalRoute(sm.getDb(), sid);
24664
24692
  if (goalRouted !== sid) {
24693
+ const fromSessionId = sid;
24665
24694
  sid = goalRouted;
24666
- client2.unsubscribeEvents?.();
24667
- client2.sessionId = sid;
24668
- client2.unsubscribeEvents = sm.registerEventListener(sid, createEventListener(client2));
24669
- sendJson(ws, { type: "session:switched", sessionId: sid });
24695
+ subscribeSession(client2, sm, sid);
24696
+ sendJson(ws, { type: "session:switched", sessionId: sid, fromSessionId, clientMsgId: msg.clientMsgId });
24670
24697
  }
24671
24698
  let uiMessage = msg.message;
24672
24699
  if (msg.images?.length && projectPath) {
@@ -24699,7 +24726,7 @@ ${msg.message}` : markers.join("\n");
24699
24726
  await sm.enqueueUserMessage(sid, msg.message, msg.images);
24700
24727
  ackChat(msg);
24701
24728
  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 });
24729
+ sendJson(ws, { type: "chat:queued", reason: "compact", message: queuedText, sessionId: sid });
24703
24730
  return;
24704
24731
  }
24705
24732
  if (sm.isSessionRunning(sid)) {
@@ -24717,67 +24744,59 @@ ${msg.message}` : markers.join("\n");
24717
24744
  const accessLevel = msg.accessLevel === void 0 ? void 0 : msg.accessLevel === "full" || getSandboxBackend() === null ? null : msg.accessLevel;
24718
24745
  sm.sendUserMessage(sid, msg.message, msg.images, accessLevel).catch((err) => {
24719
24746
  console.debug(`[WS] Chat error: ${err instanceof Error ? err.message : String(err)}`);
24720
- sendJson(ws, { type: "error", error: err instanceof Error ? err.message : String(err) });
24721
- saveSession(client2);
24747
+ sendJson(ws, { type: "error", error: err instanceof Error ? err.message : String(err), sessionId: sid });
24748
+ saveSession(client2, sid);
24722
24749
  });
24723
24750
  }
24724
- function handleChatStop(client2) {
24725
- console.debug(`[WS] Stop requested for session=${client2.sessionId}`);
24751
+ function handleChatStop(client2, msg) {
24752
+ const sid = frameSessionId(client2, msg);
24753
+ console.debug(`[WS] Stop requested for session=${sid}`);
24726
24754
  const sm = client2.sessionManager;
24727
- const sid = client2.sessionId;
24728
24755
  if (!sm || !sid)
24729
24756
  return;
24730
24757
  if (sm.isSessionCompacting(sid) && !sm.isSessionRunning(sid)) {
24731
24758
  sm.cancelCompact(sid);
24732
- sendJson(ws, { type: "chat:stopped", sessionId: client2.sessionId });
24759
+ sendJson(ws, { type: "chat:stopped", sessionId: sid });
24733
24760
  return;
24734
24761
  }
24735
24762
  sm.stopUserSession(sid);
24736
- sendJson(ws, { type: "chat:stopped", sessionId: client2.sessionId });
24737
- saveSession(client2);
24763
+ sendJson(ws, { type: "chat:stopped", sessionId: sid });
24764
+ saveSession(client2, sid);
24738
24765
  }
24739
- function handleChatInterrupt(client2) {
24740
- console.debug(`[WS] Interrupt requested for session=${client2.sessionId}`);
24766
+ function handleChatInterrupt(client2, msg) {
24767
+ const sid = frameSessionId(client2, msg);
24768
+ console.debug(`[WS] Interrupt requested for session=${sid}`);
24741
24769
  const sm = client2.sessionManager;
24742
- const sid = client2.sessionId;
24743
24770
  if (!sm || !sid)
24744
24771
  return;
24745
24772
  if (sm.isSessionCompacting(sid) && !sm.isSessionRunning(sid)) {
24746
24773
  sm.cancelCompact(sid);
24747
- sendJson(ws, { type: "chat:stopped", sessionId: client2.sessionId });
24774
+ sendJson(ws, { type: "chat:stopped", sessionId: sid });
24748
24775
  return;
24749
24776
  }
24750
24777
  sm.interruptSession(sid);
24751
24778
  }
24752
24779
  async function handleSubscribe(client2, msg) {
24753
- if (client2.sessionId && client2.sessionId !== msg.sessionId) {
24754
- saveSession(client2);
24755
- }
24756
- if (client2.sessionId) {
24757
- client2.unsubscribeEvents?.();
24758
- client2.unsubscribeEvents = null;
24759
- client2.sessionId = null;
24760
- }
24761
- if (msg.sessionId)
24762
- client2.sessionId = msg.sessionId;
24763
24780
  if (msg.projectId)
24764
- client2.projectId = msg.projectId;
24765
- const detached = msg.sessionId ? detachedSessions.get(msg.sessionId) : void 0;
24766
- if (detached && msg.sessionId) {
24781
+ setClientProject(client2, msg.projectId);
24782
+ const sid = msg.sessionId || null;
24783
+ const detached = sid ? detachedSessions.get(sid) : void 0;
24784
+ if (detached && sid) {
24767
24785
  clearTimeout(detached.timer);
24768
- detachedSessions.delete(msg.sessionId);
24769
- console.debug(`[WS] Reattaching detached session: ${msg.sessionId}`);
24786
+ detachedSessions.delete(sid);
24787
+ console.debug(`[WS] Reattaching detached session: ${sid}`);
24788
+ if (detached.projectId)
24789
+ setClientProject(client2, detached.projectId);
24770
24790
  client2.sessionManager = detached.sessionManager;
24771
- client2.sessionId = detached.sessionId;
24772
- client2.projectId = detached.projectId;
24773
- const state2 = getState(client2);
24774
- const ctxConfig = await client2.sessionManager.getContextConfig(client2.sessionId);
24775
- const running = client2.sessionManager.isSessionRunning(client2.sessionId);
24791
+ const sm = detached.sessionManager;
24792
+ const state2 = getState(client2, sid);
24793
+ const ctxConfig = await sm.getContextConfig(sid);
24794
+ const running = sm.isSessionRunning(sid);
24776
24795
  const messages3 = state2 ? running ? [...state2.messageLog] : [...createSaveSnapshot(state2)] : [];
24777
- const detachedSession = client2.sessionManager.getSessionById(client2.sessionId);
24778
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages3, sessionId: msg.sessionId, maxContextTokens: ctxConfig.maxTokens, agentId: detachedSession?.agentId, archiveCount: archiveCountFor(client2, client2.sessionId, detachedSession?.agentId), accessLevel: detachedSession?.accessLevel ?? null } });
24796
+ const detachedSession = sm.getSessionById(sid);
24797
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages3, sessionId: sid, maxContextTokens: ctxConfig.maxTokens, agentId: detachedSession?.agentId, archiveCount: archiveCountFor(client2, sid, detachedSession?.agentId), accessLevel: detachedSession?.accessLevel ?? null } });
24779
24798
  if (state2 && state2.contextTokens > 0) {
24780
- sendJson(ws, { type: "chat:usage", contextTokens: state2.contextTokens, outputTokens: state2.outputTokens });
24799
+ sendJson(ws, { type: "chat:usage", contextTokens: state2.contextTokens, outputTokens: state2.outputTokens, sessionId: sid });
24781
24800
  }
24782
24801
  while (detached.pendingEvents.length > 0) {
24783
24802
  const batch = detached.pendingEvents.splice(0, detached.pendingEvents.length);
@@ -24785,11 +24804,11 @@ ${msg.message}` : markers.join("\n");
24785
24804
  sendJson(ws, evt);
24786
24805
  }
24787
24806
  detached.unsubscribe();
24788
- client2.unsubscribeEvents = client2.sessionManager.registerEventListener(client2.sessionId, createEventListener(client2));
24807
+ subscribeSession(client2, sm, sid);
24789
24808
  if (state2 && running) {
24790
24809
  console.debug(`[WS] Session still running \u2014 synthesizing in-progress state`);
24791
24810
  const agentName = detachedSession?.agentName ?? (state2.streamingAgent || "default");
24792
- const sessionId = client2.sessionId;
24811
+ const sessionId = sid;
24793
24812
  sendJson(ws, { type: "chat:followup", agentName, replay: true, sessionId });
24794
24813
  for (const block of state2.turnContentBlocks) {
24795
24814
  if (block.type === "thinking") {
@@ -24812,36 +24831,28 @@ ${msg.message}` : markers.join("\n");
24812
24831
  client2.sessionManager = getSessionManager(subProjectPath);
24813
24832
  watchers.attach(ws, subProjectPath);
24814
24833
  }
24815
- if (msg.sessionId) {
24816
- const pendingSave = client2.backgroundSaves.get(msg.sessionId);
24817
- if (pendingSave) {
24818
- pendingSave();
24819
- client2.backgroundSaves.delete(msg.sessionId);
24820
- }
24821
- }
24822
24834
  let agentId;
24823
24835
  let accessLevel;
24824
- if (msg.sessionId && client2.sessionManager) {
24825
- const existingSession = client2.sessionManager.getSessionById(msg.sessionId);
24836
+ if (sid && client2.sessionManager) {
24837
+ const existingSession = client2.sessionManager.getSessionById(sid);
24826
24838
  if (existingSession) {
24827
- client2.sessionId = msg.sessionId;
24828
- client2.unsubscribeEvents = client2.sessionManager.registerEventListener(msg.sessionId, createEventListener(client2));
24839
+ subscribeSession(client2, client2.sessionManager, sid);
24829
24840
  agentId = existingSession.agentId;
24830
24841
  accessLevel = existingSession.accessLevel ?? null;
24831
24842
  }
24832
24843
  }
24833
24844
  let maxContextTokens = config.model.maxContextTokens;
24834
- if (msg.sessionId && client2.sessionManager) {
24835
- const view = await client2.sessionManager.getSessionView(msg.sessionId);
24845
+ if (sid && client2.sessionManager) {
24846
+ const view = await client2.sessionManager.getSessionView(sid);
24836
24847
  if (view) {
24837
24848
  maxContextTokens = view.maxContextTokens;
24838
24849
  }
24839
24850
  }
24840
- const state = getState(client2);
24851
+ const state = sid ? getState(client2, sid) : null;
24841
24852
  const messages2 = state ? [...createSaveSnapshot(state)] : [];
24842
- sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: msg.sessionId, maxContextTokens, agentId, archiveCount: msg.sessionId ? archiveCountFor(client2, msg.sessionId, agentId) : 0, accessLevel } });
24853
+ sendJson(ws, { type: "state:snapshot", snapshot: { recentMessages: messages2, sessionId: sid, maxContextTokens, agentId, archiveCount: sid ? archiveCountFor(client2, sid, agentId) : 0, accessLevel } });
24843
24854
  if (state && state.contextTokens > 0) {
24844
- sendJson(ws, { type: "chat:usage", contextTokens: state.contextTokens, outputTokens: state.outputTokens });
24855
+ sendJson(ws, { type: "chat:usage", contextTokens: state.contextTokens, outputTokens: state.outputTokens, sessionId: sid });
24845
24856
  }
24846
24857
  }
24847
24858
  function handleTerminalStart(client2, msg) {
@@ -33015,7 +33026,7 @@ var init_dist2 = __esm({
33015
33026
  init_git_ssh();
33016
33027
  __dirname3 = path72.dirname(fileURLToPath3(import.meta.url));
33017
33028
  PORT = config.server.port;
33018
- HALO_VERSION = "1.5.2-alpha";
33029
+ HALO_VERSION = "1.5.4";
33019
33030
  PROJECT_ROOT = findProjectRoot();
33020
33031
  HALO_HOME4 = path72.join(homedir24(), ".halo");
33021
33032
  GIT_SHA = readGitSha();
@@ -33946,7 +33957,7 @@ import path73 from "node:path";
33946
33957
  import fs67 from "node:fs";
33947
33958
  import { homedir as homedir25 } from "node:os";
33948
33959
  import { parseArgs as parseArgs2 } from "node:util";
33949
- var VERSION = "1.5.2-alpha";
33960
+ var VERSION = "1.5.4";
33950
33961
  var HELP_TOP = `Usage: halo <command> [options]
33951
33962
 
33952
33963
  Commands: