@turmind/halo 1.5.10 → 1.5.11

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 (52) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{388-d5950f264d91625a.js → 941-4719e7343b54b9bd.js} +1 -1
  3. package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +1 -0
  5. package/admin-out/_next/static/css/97f3ac570664d044.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 +7 -5
  9. package/bundled-docs/design/halo-city.md +6 -0
  10. package/bundled-docs/design/prompt-system.md +1 -1
  11. package/bundled-docs/design/relay.md +6 -6
  12. package/bundled-docs/design/session.md +12 -11
  13. package/bundled-docs/design/web.md +33 -5
  14. package/bundled-docs/dev/acp-adapter.md +59 -175
  15. package/bundled-docs/dev/api.md +5 -2
  16. package/bundled-docs/dev/deploy.md +1 -1
  17. package/bundled-docs/dev/desktop-packaging.md +14 -0
  18. package/bundled-docs/dev/env.md +2 -2
  19. package/bundled-docs/dev/tools.md +8 -8
  20. package/bundled-docs/guide/README.md +1 -1
  21. package/bundled-docs/guide/channels/README.md +3 -3
  22. package/bundled-docs/guide/channels/acp.md +67 -42
  23. package/bundled-docs/guide/channels/feishu.md +1 -1
  24. package/bundled-docs/guide/channels/slack.md +1 -1
  25. package/bundled-docs/guide/channels/telegram.md +1 -1
  26. package/bundled-docs/guide/channels/web.md +10 -8
  27. package/bundled-docs/guide/channels/wechat.md +1 -1
  28. package/bundled-docs/guide/channels/wecom.md +1 -1
  29. package/bundled-docs/guide/chat.md +0 -1
  30. package/bundled-docs/guide/cli.md +1 -2
  31. package/bundled-docs/guide/getting-started.md +1 -1
  32. package/bundled-docs/guide/skills.md +1 -2
  33. package/bundled-docs/requirements/chat.md +14 -0
  34. package/bundled-docs/requirements/command.md +1 -2
  35. package/bundled-docs/requirements/explorer.md +1 -0
  36. package/bundled-docs/requirements/terminal.md +15 -0
  37. package/dist/index.js +810 -333
  38. package/package.json +1 -1
  39. package/templates/agents/__score__/AGENT.md +1 -1
  40. package/templates/agents/default/agent.yaml +0 -1
  41. package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
  42. package/templates/skills/skill/SKILL.md +1 -1
  43. package/templates/skills/workspace/stage.py +1 -1
  44. package/admin-out/_next/static/chunks/989-1797287c46139412.js +0 -1
  45. package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +0 -1
  46. package/admin-out/_next/static/css/d53aa54212002364.css +0 -3
  47. package/templates/skills/acp/SKILL.md +0 -243
  48. package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
  49. package/templates/skills/acp/templates/ask.py +0 -177
  50. package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
  51. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → GO2W5JoiC46FIxLjtFAXx}/_buildManifest.js +0 -0
  52. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → GO2W5JoiC46FIxLjtFAXx}/_ssgManifest.js +0 -0
package/dist/index.js CHANGED
@@ -2967,6 +2967,7 @@ Error: tool "${tu.name}" was not run \u2014 the response hit the output token li
2967
2967
  const inputErrors = new Map(result.toolCalls.filter((tc) => tc.inputError).map((tc) => [tc.id, tc.inputError]));
2968
2968
  const toolResults = [];
2969
2969
  let shouldEndTurn = false;
2970
+ let cutId = null;
2970
2971
  try {
2971
2972
  for (const tu of toolUseBlocks) {
2972
2973
  if (options?.cancelSignal?.aborted)
@@ -3006,8 +3007,10 @@ Error: ${err instanceof Error ? err.message : String(err)}`;
3006
3007
  isError = true;
3007
3008
  }
3008
3009
  }
3009
- if (options?.cancelSignal?.aborted)
3010
+ if (options?.cancelSignal?.aborted) {
3011
+ cutId = tu.id;
3010
3012
  return;
3013
+ }
3011
3014
  const uiCap = config.limits.toolResultUiMax;
3012
3015
  const resultTextFull = resultText.length > uiCap ? resultText.slice(0, uiCap) + `
3013
3016
 
@@ -3053,6 +3056,18 @@ Error: ${err instanceof Error ? err.message : String(err)}`;
3053
3056
  };
3054
3057
  }
3055
3058
  } finally {
3059
+ const answered = new Set(toolResults.map((b) => b.tool_use_id));
3060
+ for (const tu of toolUseBlocks) {
3061
+ if (answered.has(tu.id) || tu.id === cutId)
3062
+ continue;
3063
+ toolResults.push({
3064
+ type: "tool_result",
3065
+ tool_use_id: tu.id,
3066
+ content: `${TOOL_ERROR_MARKER}
3067
+ Error: tool "${tu.name}" was not run \u2014 the turn was interrupted before this call started. Safe to re-issue if still needed.`,
3068
+ is_error: true
3069
+ });
3070
+ }
3056
3071
  if (toolResults.length > 0) {
3057
3072
  this.messages.push({ role: "user", content: toolResults });
3058
3073
  }
@@ -3780,6 +3795,11 @@ function ensureHaloHome(haloHome2) {
3780
3795
  console.log(`[Init] Removed stale \`${staleId}\` skill (merged into \`web-search\`)`);
3781
3796
  }
3782
3797
  }
3798
+ const staleAcpSkill = path12.join(globalDir2, "skills", "acp");
3799
+ if (fs9.existsSync(staleAcpSkill)) {
3800
+ fs9.rmSync(staleAcpSkill, { recursive: true, force: true });
3801
+ console.log("[Init] Removed stale `acp` skill (outbound ACP client dropped)");
3802
+ }
3783
3803
  const docsSrc = resolveDocsSource();
3784
3804
  if (!docsSrc) {
3785
3805
  console.log(`[Init] No docs source found \u2014 skipping platform docs seed. Searched: ${DOCS_SRC_CANDIDATES.join(", ")}`);
@@ -3846,7 +3866,7 @@ var init_init = __esm({
3846
3866
  path12.resolve(__dirname, "..", "bundled-docs"),
3847
3867
  path12.resolve(__dirname, "..", "..", "..", ".halo", "docs")
3848
3868
  ];
3849
- TEMPLATE_VERSION = 90;
3869
+ TEMPLATE_VERSION = 92;
3850
3870
  VERSION_FILE = ".template-version";
3851
3871
  SKIP_NAMES = /* @__PURE__ */ new Set([".DS_Store", "schema.sql", "__pycache__", ".pytest_cache"]);
3852
3872
  BUILTIN_AGENT_IDS = /* @__PURE__ */ new Set([
@@ -3865,11 +3885,6 @@ var init_init = __esm({
3865
3885
  "skill",
3866
3886
  "workspace",
3867
3887
  "cron",
3868
- // Meta-skill: walks the user through generating a per-remote
3869
- // `ask-<label>` ACP binding skill. The generated bindings live in
3870
- // user-owned skill dirs (workspace or global) and aren't templated;
3871
- // only this generator itself is platform-owned.
3872
- "acp",
3873
3888
  // Always-on file delivery primitive: every channel handler (web /
3874
3889
  // wechat / telegram / slack / feishu) intercepts `MEDIA:<path>` from
3875
3890
  // the agent's reply and uploads it. The skill body teaches the agent
@@ -4043,6 +4058,12 @@ async function callerTeamFor(sm, sessionId) {
4043
4058
  function isSameTree(a, b) {
4044
4059
  return a.split(">")[0] === b.split(">")[0];
4045
4060
  }
4061
+ function isSelfOrAncestor(callerId, targetId) {
4062
+ return callerId === targetId || callerId.startsWith(targetId + ">");
4063
+ }
4064
+ function selfOrAncestorRefusal(verb, targetId) {
4065
+ return `cannot ${verb} session ${targetId}: it is your own session or one of its ancestors, so the ${verb} would wait on the very turn making this call. To end your work, just finish this turn.`;
4066
+ }
4046
4067
  function buildSessionTools(sm, sessionId) {
4047
4068
  const startSessionTool = {
4048
4069
  name: "start_session",
@@ -4192,6 +4213,9 @@ ${params.message}` : params.message;
4192
4213
  if (!isSameTree(params.session_id, sessionId)) {
4193
4214
  return JSON.stringify({ code: 1, error: `session ${params.session_id} not found` });
4194
4215
  }
4216
+ if (isSelfOrAncestor(sessionId, params.session_id)) {
4217
+ return JSON.stringify({ code: 1, error: selfOrAncestorRefusal("stop", params.session_id) });
4218
+ }
4195
4219
  await sm.stopSession(params.session_id);
4196
4220
  return JSON.stringify({ code: 0, message: `Session ${params.session_id} stopped.` });
4197
4221
  } catch (err) {
@@ -4213,6 +4237,9 @@ ${params.message}` : params.message;
4213
4237
  if (!isSameTree(params.session_id, sessionId)) {
4214
4238
  return JSON.stringify({ code: 1, error: `session ${params.session_id} not found` });
4215
4239
  }
4240
+ if (isSelfOrAncestor(sessionId, params.session_id)) {
4241
+ return JSON.stringify({ code: 1, error: selfOrAncestorRefusal("archive", params.session_id) });
4242
+ }
4216
4243
  const count = await sm.archiveSessionTree(params.session_id);
4217
4244
  return JSON.stringify({ code: 0, message: `Archived ${count} session(s).` });
4218
4245
  } catch (err) {
@@ -7130,7 +7157,7 @@ function resolveTarget(workspace) {
7130
7157
  function buildRelayTools(host, callerSessionId) {
7131
7158
  const relaySend = {
7132
7159
  name: "relay_send",
7133
- description: "Dispatch a message to a session in ANOTHER workspace on this server. Creates the session if `session_id` does not exist there (with `agent_id`, or the workspace's default agent). If the session is busy the message is queued and the current step is softly interrupted (finishes its current tool, then reads your message) \u2014 use this for follow-ups and corrections too. Returns immediately; when the target's whole subtree finishes, its wrap-up is delivered to you as a `[Relay report \xB7 \u2026]` message. If a busy target answers your message and then resumes the task it interrupted, that answer arrives first as a `[Relay interim report \xB7 \u2026]` \u2014 the final report still follows. Do not poll \u2014 the report arrives on its own. Returns JSON with code 0 on success.",
7160
+ description: "Dispatch a message to a session in ANOTHER workspace on this server. Creates the session if `session_id` does not exist there (with `agent_id`, or the workspace's default agent). If the session is busy the message is queued and the current step is softly interrupted (finishes its current batch of tool calls, then reads your message) \u2014 use this for follow-ups and corrections too. Returns immediately; when the target's whole subtree finishes, its wrap-up is delivered to you as a `[Relay report \xB7 \u2026]` message. If a busy target answers your message and then resumes the task it interrupted, that answer arrives first as a `[Relay interim report \xB7 \u2026]` \u2014 the final report still follows. Do not poll \u2014 the report arrives on its own. Returns JSON with code 0 on success.",
7134
7161
  inputSchema: {
7135
7162
  type: "object",
7136
7163
  properties: {
@@ -7174,6 +7201,9 @@ function buildRelayTools(host, callerSessionId) {
7174
7201
  if (typeof resolved === "string")
7175
7202
  return resolved;
7176
7203
  const { wsPath, target } = resolved;
7204
+ if (target === host && isSelfOrAncestor(callerSessionId, params.session_id)) {
7205
+ return jsonErr2(`cannot relay to session ${params.session_id}: it is your own session or one of its ancestors, so its report would come back to you. Talk to your parent through your final reply, or use the session tools.`);
7206
+ }
7177
7207
  if (!target.getSessionById(params.session_id)) {
7178
7208
  if (hard)
7179
7209
  return jsonErr2("session not found");
@@ -7208,6 +7238,9 @@ ${params.message}`;
7208
7238
  const { target } = resolved;
7209
7239
  if (!target.getSessionById(params.session_id))
7210
7240
  return jsonErr2("session not found");
7241
+ if (target === host && isSelfOrAncestor(callerSessionId, params.session_id)) {
7242
+ return jsonErr2(selfOrAncestorRefusal("stop", params.session_id));
7243
+ }
7211
7244
  await target.stopSession(params.session_id);
7212
7245
  return JSON.stringify({ code: 0, message: `Session ${params.session_id} stopped.` });
7213
7246
  } catch (err) {
@@ -7276,6 +7309,7 @@ var init_relay = __esm({
7276
7309
  init_schema();
7277
7310
  init_config();
7278
7311
  init_commands2();
7312
+ init_session_tools();
7279
7313
  RELAY_CHANNEL_PREFIX = "[channel: relay | from: ";
7280
7314
  _registry = null;
7281
7315
  RELAY_UNAVAILABLE = "relay is not available in the CLI / TUI \u2014 it only runs inside `halo server`. This is permanent for this runtime, not a temporary outage: do not retry. Tell the user to send this request from the admin UI or an IM / Web channel connected to the server, or to open the target workspace directly with `halo tui -w <workspace path>`.";
@@ -7481,27 +7515,27 @@ var init_workspace = __esm({
7481
7515
  constructor(projectRoot) {
7482
7516
  this.projectRoot = projectRoot;
7483
7517
  }
7484
- async readFile(path76) {
7485
- const fullPath = this.validatePath(path76);
7518
+ async readFile(path77) {
7519
+ const fullPath = this.validatePath(path77);
7486
7520
  return readFile(fullPath, "utf-8");
7487
7521
  }
7488
- async writeFile(path76, content) {
7489
- const fullPath = this.validatePath(path76);
7522
+ async writeFile(path77, content) {
7523
+ const fullPath = this.validatePath(path77);
7490
7524
  const dir = resolve(fullPath, "..");
7491
7525
  await mkdir(dir, { recursive: true });
7492
7526
  await writeFile(fullPath, content, "utf-8");
7493
7527
  }
7494
- validatePath(path76) {
7495
- const fullPath = isAbsolute(path76) ? resolve(path76) : resolve(this.projectRoot, path76);
7528
+ validatePath(path77) {
7529
+ const fullPath = isAbsolute(path77) ? resolve(path77) : resolve(this.projectRoot, path77);
7496
7530
  const root = resolve(this.projectRoot);
7497
7531
  if (fullPath !== root && !fullPath.startsWith(root + sep)) {
7498
- throw new WorkspaceError(`Path "${path76}" is outside the workspace directory "${this.projectRoot}"`);
7532
+ throw new WorkspaceError(`Path "${path77}" is outside the workspace directory "${this.projectRoot}"`);
7499
7533
  }
7500
7534
  try {
7501
7535
  const real = realpathSync2(fullPath);
7502
7536
  const realRoot = realpathSync2(root);
7503
7537
  if (real !== realRoot && !real.startsWith(realRoot + sep)) {
7504
- throw new WorkspaceError(`Path "${path76}" is outside the workspace directory "${this.projectRoot}"`);
7538
+ throw new WorkspaceError(`Path "${path77}" is outside the workspace directory "${this.projectRoot}"`);
7505
7539
  }
7506
7540
  } catch (err) {
7507
7541
  if (err instanceof WorkspaceError)
@@ -7688,12 +7722,12 @@ var init_git_manager = __esm({
7688
7722
  const code = parts[0];
7689
7723
  const status = code[0];
7690
7724
  const isRenameOrCopy = status === "R" || status === "C";
7691
- const path76 = isRenameOrCopy ? parts[2] : parts[1];
7692
- if (!path76 || seen.has(path76))
7725
+ const path77 = isRenameOrCopy ? parts[2] : parts[1];
7726
+ if (!path77 || seen.has(path77))
7693
7727
  continue;
7694
- seen.add(path76);
7728
+ seen.add(path77);
7695
7729
  files.push({
7696
- path: path76,
7730
+ path: path77,
7697
7731
  status,
7698
7732
  from: isRenameOrCopy ? parts[1] : void 0
7699
7733
  });
@@ -7841,9 +7875,9 @@ var init_git_manager = __esm({
7841
7875
  * empty for an added file or the root commit) and `modified` is the version
7842
7876
  * at `commit` (empty for a deletion). The working-tree/staged path above is
7843
7877
  * left entirely untouched (commit === undefined preserves existing behavior). */
7844
- async getFileDiff(path76, staged, from, commit) {
7878
+ async getFileDiff(path77, staged, from, commit) {
7845
7879
  if (commit) {
7846
- const parentPath = from || path76;
7880
+ const parentPath = from || path77;
7847
7881
  let original2 = "";
7848
7882
  try {
7849
7883
  original2 = await this.git.show([`${commit}^:${parentPath}`]);
@@ -7852,13 +7886,13 @@ var init_git_manager = __esm({
7852
7886
  }
7853
7887
  let modified2 = "";
7854
7888
  try {
7855
- modified2 = await this.git.show([`${commit}:${path76}`]);
7889
+ modified2 = await this.git.show([`${commit}:${path77}`]);
7856
7890
  } catch {
7857
7891
  modified2 = "";
7858
7892
  }
7859
7893
  return { original: original2, modified: modified2 };
7860
7894
  }
7861
- const headPath = from || path76;
7895
+ const headPath = from || path77;
7862
7896
  let original = "";
7863
7897
  try {
7864
7898
  original = await this.git.show([`HEAD:${headPath}`]);
@@ -7868,13 +7902,13 @@ var init_git_manager = __esm({
7868
7902
  let modified = "";
7869
7903
  if (staged) {
7870
7904
  try {
7871
- modified = await this.git.show([`:${path76}`]);
7905
+ modified = await this.git.show([`:${path77}`]);
7872
7906
  } catch {
7873
7907
  modified = "";
7874
7908
  }
7875
7909
  } else {
7876
7910
  try {
7877
- modified = await this.workspace.readFile(path76);
7911
+ modified = await this.workspace.readFile(path77);
7878
7912
  } catch {
7879
7913
  modified = "";
7880
7914
  }
@@ -12171,7 +12205,7 @@ async function initObservability() {
12171
12205
  return true;
12172
12206
  }
12173
12207
  const { registerSdk: registerSdk2 } = await Promise.resolve().then(() => (init_otel_sdk(), otel_sdk_exports));
12174
- providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.10");
12208
+ providers = registerSdk2(process.env.OTEL_SERVICE_NAME, "1.5.11");
12175
12209
  console.log(`[Observability] OTLP export \u2192 ${process.env.OTEL_EXPORTER_OTLP_ENDPOINT} (service.name=${process.env.OTEL_SERVICE_NAME}, capture_content=${config.observability.captureContent})`);
12176
12210
  return true;
12177
12211
  }
@@ -12574,7 +12608,7 @@ var init_session_manager = __esm({
12574
12608
  init_session_skill_commands();
12575
12609
  init_session_state_store();
12576
12610
  init_genai_spans();
12577
- CONTINUE_TASK_KICK = `[System] You called continue_task: the task interrupted earlier is not finished. Resume it now from where it stopped. Any tool call marked "[tool execution interrupted \u2014 no result]" did not run \u2014 re-issue it if you still need the result (this overrides the marker's do-not-retry note \u2014 you explicitly asked to continue).`;
12611
+ CONTINUE_TASK_KICK = `[System] You called continue_task: the task interrupted earlier is not finished. Resume it now from where it stopped. Any tool call marked "[tool execution interrupted \u2014 no result]" was cut off mid-run and its result is lost, and any marked "was not run" never started \u2014 re-issue either if you still need the result (this overrides the interrupted marker's do-not-retry note \u2014 you explicitly asked to continue).`;
12578
12612
  SessionManager = class _SessionManager {
12579
12613
  // The fields below are marked `internal:` because they're consumed by
12580
12614
  // helpers in this directory (session-tools.ts). Treat them as
@@ -13319,6 +13353,7 @@ var init_session_manager = __esm({
13319
13353
  message = i === -1 ? [{ type: "text", text: stamp }, ...message] : message.map((b, j) => j === i && b.type === "text" ? { type: "text", text: stamped(b.text) } : b);
13320
13354
  }
13321
13355
  beginTurn(session, message);
13356
+ let batchRan = false;
13322
13357
  for (let attempt = 0; attempt < maxRetries; attempt++) {
13323
13358
  session.abortController = new AbortController();
13324
13359
  const signal = session.abortController.signal;
@@ -13333,6 +13368,10 @@ var init_session_manager = __esm({
13333
13368
  this.trimImages(session);
13334
13369
  await this.maybeAutoCompact(session);
13335
13370
  this.saveAgentState(session);
13371
+ if (session.interruptRequested && batchRan) {
13372
+ console.debug(`[SessionManager] Soft interrupt at batch boundary in ${session.id}`);
13373
+ session.abortController?.abort(abortReason("interrupt"));
13374
+ }
13336
13375
  }
13337
13376
  });
13338
13377
  for await (const event of iter) {
@@ -13341,13 +13380,10 @@ var init_session_manager = __esm({
13341
13380
  if (event.type === "text") {
13342
13381
  resultText += event.text ?? "";
13343
13382
  }
13383
+ if (event.type === "tool_result")
13384
+ batchRan = true;
13344
13385
  onAgentEvent(session, event);
13345
13386
  this.processSessionEvent(session, event);
13346
- if (session.interruptRequested && event.type === "tool_result") {
13347
- console.debug(`[SessionManager] Graceful interrupt after tool result in ${session.id}`);
13348
- session.abortController?.abort(abortReason("interrupt"));
13349
- this.markPendingToolCallsInterrupted(session.id);
13350
- }
13351
13387
  }
13352
13388
  session.abortController = null;
13353
13389
  if (session.interruptRequested) {
@@ -13765,15 +13801,18 @@ ${body}`;
13765
13801
  }
13766
13802
  // ── Session operations ─────────────────────────────────────────────
13767
13803
  /**
13768
- * UI-only repair for an aborted turn: emit a synthetic `tool_result` for
13804
+ * UI-only repair for a hard-aborted turn: emit a synthetic `tool_result` for
13769
13805
  * every pending tool call (tool_call seen, no output yet) in the session's
13770
13806
  * cached UI log. On abort, runAgentTurn's consumer loop breaks on
13771
13807
  * `signal.aborted` BEFORE processing the in-flight tool's real tool_result
13772
- * event, so without this the UI block stays "running" forever. Routing
13773
- * through the normal emitEvent pipeline means ui-log-builder persistence,
13774
- * admin WS push, and TUI rendering all pick it up unchanged. Never touches
13775
- * agent.messages — the model-facing repair is conversation-repair's
13776
- * synthesized [interrupted] tool_result. Idempotent: completed tools
13808
+ * event, so without this the UI block stays "running" forever. The batch
13809
+ * runs serially and setToolResult fills the first pending entry, so the
13810
+ * first pending call is the one that was running (`[interrupted by user]`,
13811
+ * model side: repair's do-not-retry marker) and the rest never started
13812
+ * (`[not run — interrupted]`, model side: agent-loop's "not run" result).
13813
+ * Routing through the normal emitEvent pipeline means ui-log-builder
13814
+ * persistence, admin WS push, and TUI rendering all pick it up unchanged.
13815
+ * Never touches agent.messages. Idempotent: completed tools
13777
13816
  * (output already set) are never overwritten. Call BEFORE awaiting the
13778
13817
  * aborted turn's promise — its finally emits `complete`, which flushes and
13779
13818
  * clears the pending buffers this scans.
@@ -13790,12 +13829,12 @@ ${body}`;
13790
13829
  if (pending.length === 0)
13791
13830
  return;
13792
13831
  const session = this.sessions.get(sessionId);
13793
- for (const tc of pending) {
13832
+ for (const [i, tc] of pending.entries()) {
13794
13833
  this.emitEvent(sessionId, {
13795
13834
  type: "tool_result",
13796
13835
  toolName: tc.name,
13797
13836
  toolUseId: tc.toolUseId,
13798
- toolResult: "[interrupted by user]",
13837
+ toolResult: i === 0 ? "[interrupted by user]" : "[not run \u2014 interrupted]",
13799
13838
  agentName: session?.agentName,
13800
13839
  agentId: session?.agentId,
13801
13840
  taskId: sessionId === rootId ? void 0 : sessionId
@@ -13811,7 +13850,7 @@ ${body}`;
13811
13850
  * single semantic shared by esc (TUI / admin) and the `interrupt_session`
13812
13851
  * tool:
13813
13852
  *
13814
- * - abort the in-flight turn at once (not at the next tool_result),
13853
+ * - abort the in-flight turn at once (not at the next batch boundary),
13815
13854
  * - do NOT discard `messageQueue`: once the aborted turn unwinds,
13816
13855
  * runSession's runFn drains every queued message into ONE merged follow-up
13817
13856
  * turn (drainQueue). An empty queue just goes idle.
@@ -14213,6 +14252,10 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14213
14252
  isSessionCompacting(sessionId) {
14214
14253
  return this.sessions.get(sessionId)?.isCompacting ?? false;
14215
14254
  }
14255
+ /** Any message waiting in the session's queue (a turn / endCompact drains it) */
14256
+ hasQueuedMessages(sessionId) {
14257
+ return (this.sessions.get(sessionId)?.messageQueue.length ?? 0) > 0;
14258
+ }
14216
14259
  /** Check if a session is running */
14217
14260
  isSessionRunning(sessionId) {
14218
14261
  const session = this.sessions.get(sessionId);
@@ -14739,7 +14782,7 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14739
14782
  /** Enqueue a user message directly (admin WS busy/compacting queue path).
14740
14783
  * Pushes onto the single messageQueue with no `sourceSessionId` (a user
14741
14784
  * message), and requests a SOFT interrupt so a busy turn yields after its
14742
- * current tool — same effect as sendUserMessage's busy branch, minus the
14785
+ * current tool batch — same effect as sendUserMessage's busy branch, minus the
14743
14786
  * idle/run path (the caller already established the session is busy or
14744
14787
  * compacting). A compact outside a live model loop (manual /compact, or the
14745
14788
  * turn-end auto-compact — abortController already null) has nothing to
@@ -14747,7 +14790,7 @@ ${result.summary}`, agentName: "user", report: true, taskId });
14747
14790
  * compacting branch): the drain would snapshot it into resumedAfterInterrupt
14748
14791
  * and let continue_task arm a kick in a turn that interrupted nothing. A
14749
14792
  * mid-turn auto-compact (beforeCallModel) does interrupt a live turn — the
14750
- * flag is set, so the turn yields after its next tool instead of running on
14793
+ * flag is set, so the turn yields at its next batch boundary instead of running on
14751
14794
  * to its natural end with the message still queued. */
14752
14795
  async enqueueUserMessage(sessionId, text6, images) {
14753
14796
  const session = this.sessions.get(sessionId);
@@ -16731,8 +16774,8 @@ function applyPathPick(value, ref, pick) {
16731
16774
  const before = value.slice(0, markerStart);
16732
16775
  const kind = ref.kind === "scope" ? "scope" : ref.kind === "image" ? "image" : pick.isImage ? "image" : "file";
16733
16776
  const needsQuote = pick.insertPath.includes(" ");
16734
- const path76 = pick.isDir ? needsQuote ? `"${pick.insertPath}/` : `${pick.insertPath}/` : needsQuote ? `"${pick.insertPath}" ` : `${pick.insertPath} `;
16735
- return `${before}@${kind} ${path76}`;
16777
+ const path77 = pick.isDir ? needsQuote ? `"${pick.insertPath}/` : `${pick.insertPath}/` : needsQuote ? `"${pick.insertPath}" ` : `${pick.insertPath} `;
16778
+ return `${before}@${kind} ${path77}`;
16736
16779
  }
16737
16780
  var init_path_suggest_engine = __esm({
16738
16781
  "src/tui/path-suggest-engine.ts"() {
@@ -17691,7 +17734,9 @@ function App({ harness, verbose }) {
17691
17734
  let cancelled = false;
17692
17735
  harness.getSessionMessages(harness.sessionId).then((msgs) => {
17693
17736
  if (cancelled || !msgs || msgs.length === 0) return;
17694
- const blocks = messagesToBlocks(msgs);
17737
+ const all = messagesToBlocks(msgs);
17738
+ const hidden = all.length - REPLAY_BLOCKS;
17739
+ const blocks = hidden > 0 ? [{ id: nextId(), kind: "system", text: `\u2500\u2500 ${hidden} earlier messages hidden \xB7 ctrl+o for the full log \u2500\u2500` }, ...all.slice(-REPLAY_BLOCKS)] : all;
17695
17740
  if (blocks.length > 0) dispatch6({ type: "load-history", blocks });
17696
17741
  }).catch(() => {
17697
17742
  });
@@ -17954,7 +17999,7 @@ ${lines.join("\n")}` });
17954
17999
  ] })
17955
18000
  ] });
17956
18001
  }
17957
- var MAX_CONTEXT_TOKENS_FALLBACK, blockSeq, ANSI2;
18002
+ var MAX_CONTEXT_TOKENS_FALLBACK, blockSeq, REPLAY_BLOCKS, ANSI2;
17958
18003
  var init_app = __esm({
17959
18004
  "src/tui/app.tsx"() {
17960
18005
  "use strict";
@@ -17970,6 +18015,7 @@ var init_app = __esm({
17970
18015
  init_log_viewer();
17971
18016
  MAX_CONTEXT_TOKENS_FALLBACK = 272e3;
17972
18017
  blockSeq = 0;
18018
+ REPLAY_BLOCKS = 100;
17973
18019
  ANSI2 = {
17974
18020
  reset: "\x1B[0m",
17975
18021
  dim: "\x1B[2m",
@@ -20795,8 +20841,8 @@ function detectOrphans(tree, declared) {
20795
20841
  if (!block || typeof block !== "object")
20796
20842
  continue;
20797
20843
  for (const k of Object.keys(block)) {
20798
- const path76 = `${namespaceId}.${kindWord}.${k}`;
20799
- if (!declaredMap.has(path76)) {
20844
+ const path77 = `${namespaceId}.${kindWord}.${k}`;
20845
+ if (!declaredMap.has(path77)) {
20800
20846
  out.push({ namespaceId, kind: kindWord === "params" ? "param" : "secret", key: k });
20801
20847
  }
20802
20848
  }
@@ -21877,11 +21923,18 @@ var init_session_prefix = __esm({
21877
21923
  });
21878
21924
 
21879
21925
  // ../server/dist/channels/web/handler.js
21926
+ import fs49 from "node:fs";
21880
21927
  function buildWebSessionPrefix(accountId) {
21881
21928
  return sessionPrefix("web", accountId);
21882
21929
  }
21883
21930
  function canAddressSession(account, sessionId) {
21884
- return account.accessLevel === "full" || sessionId.startsWith(buildWebSessionPrefix(account.accountId));
21931
+ const prefix = buildWebSessionPrefix(account.accountId);
21932
+ return account.accessLevel === "full" || sessionId.startsWith(prefix) || isOwnGoalSession(account, sessionId, prefix);
21933
+ }
21934
+ function isOwnGoalSession(account, sessionId, prefix) {
21935
+ if (!sessionId.startsWith("goal_") || !hasWorkspaceHalo(account.workspacePath))
21936
+ return false;
21937
+ return readGoalState(getWorkspaceDb(account.workspacePath).db, sessionId)?.workerSessionId.startsWith(prefix) ?? false;
21885
21938
  }
21886
21939
  function createWebChannel(deps) {
21887
21940
  const { registry: registry2, db } = deps;
@@ -21890,24 +21943,24 @@ function createWebChannel(deps) {
21890
21943
  const prefix = buildWebSessionPrefix(accountId);
21891
21944
  return findActiveSessionId(sm, accountId, prefix, activeOverrides2, "full") ?? void 0;
21892
21945
  }
21893
- function resolveWorkspace(account, override) {
21894
- let path76 = account.workspacePath;
21946
+ function resolveWorkspace(account, override, scaffold = true) {
21947
+ let path77 = account.workspacePath;
21895
21948
  if (override && override !== account.workspacePath) {
21896
21949
  if (account.accessLevel !== "full") {
21897
21950
  return { ok: false, error: "workspace override requires a full-access token" };
21898
21951
  }
21899
- path76 = override;
21952
+ path77 = override;
21900
21953
  }
21901
- const resolved = resolveAccountWorkspace({ ...account, workspacePath: path76 });
21954
+ const resolved = scaffold ? resolveAccountWorkspace({ ...account, workspacePath: path77 }) : fs49.existsSync(path77) ? path77 : null;
21902
21955
  if (!resolved)
21903
21956
  return { ok: false, error: "workspace not found" };
21904
21957
  return { ok: true, path: resolved };
21905
21958
  }
21906
- function resolveRequest(token, workspaceOverride) {
21959
+ function resolveRequest(token, workspaceOverride, scaffold = true) {
21907
21960
  const account = getAccountByToken(db, token);
21908
21961
  if (!account || !account.enabled)
21909
21962
  return { ok: false, error: "Invalid or disabled token" };
21910
- const ws = resolveWorkspace(account, workspaceOverride);
21963
+ const ws = resolveWorkspace(account, workspaceOverride, scaffold);
21911
21964
  if (!ws.ok)
21912
21965
  return ws;
21913
21966
  return { ok: true, account, workspace: ws.path };
@@ -22119,7 +22172,9 @@ function createWebChannel(deps) {
22119
22172
  const state = sm.getUIState(sessionId);
22120
22173
  if (!state)
22121
22174
  return { sessionId, messages: [], running: false };
22122
- const messages2 = createSaveSnapshot(state);
22175
+ const snapshot3 = createSaveSnapshot(state);
22176
+ const since = opts?.since;
22177
+ const messages2 = since === void 0 ? snapshot3 : snapshot3.filter((m) => !m.taskId && m.timestamp >= since);
22123
22178
  const running = sm.isSessionRunning(sessionId);
22124
22179
  return { sessionId, messages: messages2, running };
22125
22180
  }
@@ -22138,6 +22193,21 @@ function createWebChannel(deps) {
22138
22193
  await sm.createSession(agent.id, null, `Web: ${account.label || account.accountId}`, void 0, sessionId, void 0, accessLevel);
22139
22194
  return { ok: true, sessionId };
22140
22195
  }
22196
+ function listSessions2(token, opts) {
22197
+ const req = resolveRequest(token, opts?.workspace, false);
22198
+ if (!req.ok)
22199
+ return req;
22200
+ if (!hasWorkspaceHalo(req.workspace))
22201
+ return { ok: true, workspace: req.workspace, sessions: [], nextCursor: null };
22202
+ const sm = registry2.getOrCreate(req.workspace);
22203
+ const page = sm.listSessions({ rootOnly: true, prefix: buildWebSessionPrefix(req.account.accountId), limit: WEB_LIST_PAGE, cursor: opts?.cursor });
22204
+ return {
22205
+ ok: true,
22206
+ workspace: req.workspace,
22207
+ sessions: page.sessions.map((s) => ({ sessionId: s.id, title: s.title || s.description || null, updatedAt: s.updatedAt })),
22208
+ nextCursor: page.nextCursor
22209
+ };
22210
+ }
22141
22211
  async function* subscribe(token, signal, opts) {
22142
22212
  const req = resolveRequest(token, opts?.workspace);
22143
22213
  if (!req.ok) {
@@ -22151,9 +22221,16 @@ function createWebChannel(deps) {
22151
22221
  return;
22152
22222
  }
22153
22223
  yield sseData({ type: "session", sessionId });
22154
- yield* listenSession(sm, sessionId).events(signal);
22224
+ const listener = listenSession(sm, sessionId);
22225
+ const busy = sm.isSessionRunning(sessionId) || sm.isSessionCompacting(sessionId) && sm.hasQueuedMessages(sessionId);
22226
+ if (!busy) {
22227
+ listener.close();
22228
+ yield sseData({ type: "complete" });
22229
+ return;
22230
+ }
22231
+ yield* listener.events(signal);
22155
22232
  }
22156
- return { handleMessage, handleStop, getHistory, subscribe, createSession };
22233
+ return { handleMessage, handleStop, getHistory, subscribe, createSession, listSessions: listSessions2 };
22157
22234
  }
22158
22235
  function sseData(obj) {
22159
22236
  return `data: ${JSON.stringify(obj)}
@@ -22196,12 +22273,14 @@ function createMediaBuffer() {
22196
22273
  const out = flushAll() + sseData({ type: "thinking", text: event.text ?? "" });
22197
22274
  return out;
22198
22275
  }
22276
+ // `toolUseId` pairs a result with its call (the ACP adapter uses it as
22277
+ // the stable toolCallId); additive — older clients ignore it.
22199
22278
  case "tool_call": {
22200
- const out = flushAll() + sseData({ type: "tool_call", toolName: event.toolName, toolInput: event.toolInput });
22279
+ const out = flushAll() + sseData({ type: "tool_call", toolName: event.toolName, toolUseId: event.toolUseId, toolInput: event.toolInput });
22201
22280
  return out;
22202
22281
  }
22203
22282
  case "tool_result": {
22204
- const out = flushAll() + sseData({ type: "tool_result", toolName: event.toolName, result: event.toolResult?.slice(0, 500) });
22283
+ const out = flushAll() + sseData({ type: "tool_result", toolName: event.toolName, toolUseId: event.toolUseId, result: event.toolResult?.slice(0, 500) });
22205
22284
  return out;
22206
22285
  }
22207
22286
  case "complete": {
@@ -22223,6 +22302,7 @@ function createMediaBuffer() {
22223
22302
  }
22224
22303
  };
22225
22304
  }
22305
+ var WEB_LIST_PAGE;
22226
22306
  var init_handler = __esm({
22227
22307
  "../server/dist/channels/web/handler.js"() {
22228
22308
  "use strict";
@@ -22235,9 +22315,11 @@ var init_handler = __esm({
22235
22315
  init_commands2();
22236
22316
  init_agent_loader();
22237
22317
  init_db();
22318
+ init_init();
22238
22319
  init_goal_mode();
22239
22320
  init_i18n();
22240
22321
  init_session_prefix();
22322
+ WEB_LIST_PAGE = 50;
22241
22323
  }
22242
22324
  });
22243
22325
 
@@ -22297,13 +22379,13 @@ var init_session_types = __esm({
22297
22379
 
22298
22380
  // ../server/dist/routes/halo-city.js
22299
22381
  import { Hono as Hono10 } from "hono";
22300
- import fs49 from "node:fs";
22382
+ import fs50 from "node:fs";
22301
22383
  import path58 from "node:path";
22302
22384
  import Database6 from "better-sqlite3";
22303
22385
  function scanSkills2(dir) {
22304
22386
  let names;
22305
22387
  try {
22306
- names = fs49.readdirSync(dir);
22388
+ names = fs50.readdirSync(dir);
22307
22389
  } catch {
22308
22390
  return [];
22309
22391
  }
@@ -22312,7 +22394,7 @@ function scanSkills2(dir) {
22312
22394
  const skillMd = path58.join(dir, entry, "SKILL.md");
22313
22395
  let stat;
22314
22396
  try {
22315
- stat = fs49.statSync(skillMd);
22397
+ stat = fs50.statSync(skillMd);
22316
22398
  if (!stat.isFile())
22317
22399
  continue;
22318
22400
  } catch {
@@ -22321,7 +22403,7 @@ function scanSkills2(dir) {
22321
22403
  let meta = _skillCache.get(skillMd, stat.mtimeMs);
22322
22404
  if (!meta) {
22323
22405
  try {
22324
- const { name, description, command } = parseSkillFrontmatter(fs49.readFileSync(skillMd, "utf-8"));
22406
+ const { name, description, command } = parseSkillFrontmatter(fs50.readFileSync(skillMd, "utf-8"));
22325
22407
  meta = _skillCache.set(skillMd, stat.mtimeMs, { id: entry, name: name || entry, description: description ?? "", command });
22326
22408
  } catch {
22327
22409
  continue;
@@ -22421,7 +22503,7 @@ function snapshotWorkspace(sm, wsPath, label) {
22421
22503
  function roReader(rawPath) {
22422
22504
  let wsPath;
22423
22505
  try {
22424
- wsPath = fs49.realpathSync(rawPath);
22506
+ wsPath = fs50.realpathSync(rawPath);
22425
22507
  } catch {
22426
22508
  return null;
22427
22509
  }
@@ -22429,7 +22511,7 @@ function roReader(rawPath) {
22429
22511
  if (cached)
22430
22512
  return cached;
22431
22513
  const dbPath = path58.join(wsPath, ".halo", "halo.db");
22432
- if (!fs49.existsSync(dbPath))
22514
+ if (!fs50.existsSync(dbPath))
22433
22515
  return null;
22434
22516
  try {
22435
22517
  const db = new Database6(dbPath, { readonly: true, fileMustExist: true });
@@ -22457,7 +22539,7 @@ function dropRoReader(rawPath) {
22457
22539
  return;
22458
22540
  let wsPath = rawPath;
22459
22541
  try {
22460
- wsPath = fs49.realpathSync(rawPath);
22542
+ wsPath = fs50.realpathSync(rawPath);
22461
22543
  } catch {
22462
22544
  }
22463
22545
  const r = _roReaders.get(wsPath);
@@ -22554,7 +22636,7 @@ function workspaceBirth(wsPath) {
22554
22636
  let b = _birthCache.get(wsPath);
22555
22637
  if (b === void 0) {
22556
22638
  try {
22557
- b = fs49.statSync(wsPath).birthtimeMs || fs49.statSync(wsPath).ctimeMs;
22639
+ b = fs50.statSync(wsPath).birthtimeMs || fs50.statSync(wsPath).ctimeMs;
22558
22640
  } catch {
22559
22641
  b = 0;
22560
22642
  }
@@ -22566,7 +22648,7 @@ function discoverWorkspaces(registry2) {
22566
22648
  const out = /* @__PURE__ */ new Map();
22567
22649
  const add = (p, label) => {
22568
22650
  try {
22569
- const resolved = fs49.realpathSync(p);
22651
+ const resolved = fs50.realpathSync(p);
22570
22652
  if (!out.has(resolved))
22571
22653
  out.set(resolved, label);
22572
22654
  } catch {
@@ -22630,7 +22712,7 @@ function createShowRoutes(registry2) {
22630
22712
  if (!globalView) {
22631
22713
  let allowed = false;
22632
22714
  try {
22633
- allowed = fs49.realpathSync(wsPath) === fs49.realpathSync(account.workspacePath);
22715
+ allowed = fs50.realpathSync(wsPath) === fs50.realpathSync(account.workspacePath);
22634
22716
  } catch {
22635
22717
  }
22636
22718
  if (!allowed || !canAddressSession(account, id))
@@ -22826,7 +22908,7 @@ var init_commands3 = __esm({
22826
22908
  // ../server/dist/routes/agentcore.js
22827
22909
  import { Hono as Hono13 } from "hono";
22828
22910
  import path59 from "node:path";
22829
- import fs50 from "node:fs";
22911
+ import fs51 from "node:fs";
22830
22912
  function sanitizeRuntimeId(runtimeSessionId) {
22831
22913
  return runtimeSessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80);
22832
22914
  }
@@ -22835,7 +22917,7 @@ function haloSessionId(runtimeSessionId) {
22835
22917
  }
22836
22918
  function userWorkspace(base, runtimeSessionId) {
22837
22919
  const dir = path59.join(base, "users", sanitizeRuntimeId(runtimeSessionId));
22838
- fs50.mkdirSync(dir, { recursive: true });
22920
+ fs51.mkdirSync(dir, { recursive: true });
22839
22921
  return dir;
22840
22922
  }
22841
22923
  function acceptChunk(uploads, msg) {
@@ -23372,17 +23454,17 @@ function passwordStrengthError(pw) {
23372
23454
  }
23373
23455
  function authMiddleware() {
23374
23456
  return async (c, next) => {
23375
- const path76 = c.req.path;
23376
- if (PUBLIC_PATHS.some((p) => path76 === p || path76.startsWith(p + "?"))) {
23457
+ const path77 = c.req.path;
23458
+ if (PUBLIC_PATHS.some((p) => path77 === p || path77.startsWith(p + "?"))) {
23377
23459
  return next();
23378
23460
  }
23379
- if (EXTENSION_ASSET_PATH.test(path76)) {
23461
+ if (EXTENSION_ASSET_PATH.test(path77)) {
23380
23462
  return next();
23381
23463
  }
23382
23464
  const token = getCookie(c, COOKIE_NAME);
23383
23465
  const payload = validateToken(token);
23384
23466
  if (!payload) {
23385
- if (!path76.startsWith("/api/")) {
23467
+ if (!path77.startsWith("/api/")) {
23386
23468
  return next();
23387
23469
  }
23388
23470
  return c.json({ error: "Unauthorized" }, 401);
@@ -23644,7 +23726,7 @@ var init_upgrade_router = __esm({
23644
23726
  });
23645
23727
 
23646
23728
  // ../server/dist/ws/file-watcher.js
23647
- import fs51 from "node:fs";
23729
+ import fs52 from "node:fs";
23648
23730
  import path60 from "node:path";
23649
23731
  function enqueueNativeOp(op) {
23650
23732
  const result = nativeOpChain.then(op);
@@ -23754,7 +23836,7 @@ var init_file_watcher = __esm({
23754
23836
  }
23755
23837
  let root = workspaceRoot;
23756
23838
  try {
23757
- root = fs51.realpathSync(workspaceRoot);
23839
+ root = fs52.realpathSync(workspaceRoot);
23758
23840
  } catch {
23759
23841
  }
23760
23842
  if (this.subscription && this.workspaceRoot === root)
@@ -23812,7 +23894,7 @@ var init_file_watcher = __esm({
23812
23894
  } else {
23813
23895
  let isDir = false;
23814
23896
  try {
23815
- isDir = fs51.statSync(absPath).isDirectory();
23897
+ isDir = fs52.statSync(absPath).isDirectory();
23816
23898
  } catch {
23817
23899
  }
23818
23900
  action = isDir ? "addDir" : "add";
@@ -23888,7 +23970,7 @@ var init_file_watcher = __esm({
23888
23970
  });
23889
23971
 
23890
23972
  // ../server/dist/ws/git-dir-watcher.js
23891
- import fs52 from "node:fs";
23973
+ import fs53 from "node:fs";
23892
23974
  import path61 from "node:path";
23893
23975
  var GitDirWatcher;
23894
23976
  var init_git_dir_watcher = __esm({
@@ -23914,7 +23996,7 @@ var init_git_dir_watcher = __esm({
23914
23996
  this.stop();
23915
23997
  this.workspaceRoot = workspaceRoot;
23916
23998
  const gitDir = path61.join(workspaceRoot, ".git");
23917
- if (fs52.existsSync(gitDir)) {
23999
+ if (fs53.existsSync(gitDir)) {
23918
24000
  this.watchGitDir(gitDir);
23919
24001
  } else {
23920
24002
  this.watchParentForGit(workspaceRoot);
@@ -23923,7 +24005,7 @@ var init_git_dir_watcher = __esm({
23923
24005
  /** Watch the `.git` dir for index/HEAD changes (steady state). */
23924
24006
  watchGitDir(gitDir) {
23925
24007
  try {
23926
- this.watcher = fs52.watch(gitDir, { persistent: true, recursive: false }, (_eventType, filename) => {
24008
+ this.watcher = fs53.watch(gitDir, { persistent: true, recursive: false }, (_eventType, filename) => {
23927
24009
  this.handleGitEvent(filename);
23928
24010
  });
23929
24011
  this.watcher.on("error", (err) => {
@@ -23942,7 +24024,7 @@ var init_git_dir_watcher = __esm({
23942
24024
  * Exists solely during the non-repo window; upgraded away on first `.git`. */
23943
24025
  watchParentForGit(workspaceRoot) {
23944
24026
  try {
23945
- this.watcher = fs52.watch(workspaceRoot, { persistent: true, recursive: false }, (_eventType, filename) => {
24027
+ this.watcher = fs53.watch(workspaceRoot, { persistent: true, recursive: false }, (_eventType, filename) => {
23946
24028
  this.handleParentEvent(filename);
23947
24029
  });
23948
24030
  this.watcher.on("error", (err) => {
@@ -23979,7 +24061,7 @@ var init_git_dir_watcher = __esm({
23979
24061
  if (!root)
23980
24062
  return;
23981
24063
  const gitDir = path61.join(root, ".git");
23982
- if (!fs52.existsSync(gitDir))
24064
+ if (!fs53.existsSync(gitDir))
23983
24065
  return;
23984
24066
  this.closeWatcher();
23985
24067
  this.watchGitDir(gitDir);
@@ -25001,9 +25083,9 @@ import Database7 from "better-sqlite3";
25001
25083
  import { drizzle as drizzle5 } from "drizzle-orm/better-sqlite3";
25002
25084
  import { sqliteTable as sqliteTable5, text as text5, integer as integer5 } from "drizzle-orm/sqlite-core";
25003
25085
  import path63 from "node:path";
25004
- import fs53 from "node:fs";
25086
+ import fs54 from "node:fs";
25005
25087
  function createCronDb(globalDir2) {
25006
- fs53.mkdirSync(globalDir2, { recursive: true });
25088
+ fs54.mkdirSync(globalDir2, { recursive: true });
25007
25089
  const dbPath = path63.join(globalDir2, "cron.db");
25008
25090
  const sqlite = new Database7(dbPath);
25009
25091
  sqlite.pragma("journal_mode = WAL");
@@ -25234,7 +25316,7 @@ var init_dispatcher = __esm({
25234
25316
 
25235
25317
  // ../server/dist/cron/runner.js
25236
25318
  import { spawn as spawn3, execFileSync } from "node:child_process";
25237
- import fs54 from "node:fs";
25319
+ import fs55 from "node:fs";
25238
25320
  import path64 from "node:path";
25239
25321
  import { homedir as homedir23 } from "node:os";
25240
25322
  import { Cron } from "croner";
@@ -25311,7 +25393,7 @@ function effectiveSessionId(job) {
25311
25393
  function sessionKeyFor(workspacePath, sessionId) {
25312
25394
  let ws;
25313
25395
  try {
25314
- ws = fs54.realpathSync(workspacePath);
25396
+ ws = fs55.realpathSync(workspacePath);
25315
25397
  } catch {
25316
25398
  ws = path64.resolve(workspacePath);
25317
25399
  }
@@ -25386,7 +25468,7 @@ function sweepOrphanRuns(opts) {
25386
25468
  }
25387
25469
  }
25388
25470
  function startCronDaemon() {
25389
- fs54.mkdirSync(logsDir(), { recursive: true });
25471
+ fs55.mkdirSync(logsDir(), { recursive: true });
25390
25472
  sweepOrphanRuns();
25391
25473
  reloadAll();
25392
25474
  _daemonTimers.push(setInterval(pruneOldLogs, DAY_MS2));
@@ -25566,7 +25648,7 @@ async function runJob(jobId, triggerKind) {
25566
25648
  const runId = newRunId();
25567
25649
  const startedAt = Date.now();
25568
25650
  const logPath = logPathFor(runId);
25569
- fs54.mkdirSync(logsDir(), { recursive: true });
25651
+ fs55.mkdirSync(logsDir(), { recursive: true });
25570
25652
  const sessionId = effectiveSessionId(job);
25571
25653
  const sessionKey = sessionKeyFor(job.workspacePath, sessionId);
25572
25654
  const host = _sessionRegistry?.peek(job.workspacePath);
@@ -25606,7 +25688,7 @@ async function runJob(jobId, triggerKind) {
25606
25688
  }).run();
25607
25689
  broadcast({ type: "cron:run_changed", jobId, runId, status: "running" });
25608
25690
  const targets = parseTargets(job.targets);
25609
- if (!fs54.existsSync(job.workspacePath)) {
25691
+ if (!fs55.existsSync(job.workspacePath)) {
25610
25692
  finalize(runId, jobId, {
25611
25693
  status: "failed",
25612
25694
  output: null,
@@ -25623,7 +25705,7 @@ async function runJob(jobId, triggerKind) {
25623
25705
  args.push("--access", accessLevel);
25624
25706
  const [spawnBin, spawnArgs] = process.platform === "win32" && cli.endsWith(".cmd") ? ["cmd.exe", ["/c", cli, ...args]] : [cli, args];
25625
25707
  const timeoutSec = job.timeoutSec ?? CLI_TIMEOUT_SEC;
25626
- const teeStream = fs54.createWriteStream(logPath, { flags: "a" });
25708
+ const teeStream = fs55.createWriteStream(logPath, { flags: "a" });
25627
25709
  teeStream.write(`=== ${(/* @__PURE__ */ new Date()).toISOString()} cron run ${runId} job=${jobId} trigger=${triggerKind} ===
25628
25710
  `);
25629
25711
  teeStream.write(`agent=${job.agentId} session=${sessionId}${accessLevel ? ` access=${accessLevel}` : ""} workspace=${job.workspacePath} schedule=${job.schedule}
@@ -25812,15 +25894,15 @@ function pruneOldRuns(jobId) {
25812
25894
  }
25813
25895
  function pruneOldLogs() {
25814
25896
  const dir = logsDir();
25815
- if (!fs54.existsSync(dir))
25897
+ if (!fs55.existsSync(dir))
25816
25898
  return;
25817
25899
  const cutoff = Date.now() - LOG_RETENTION_DAYS * DAY_MS2;
25818
- for (const name of fs54.readdirSync(dir)) {
25900
+ for (const name of fs55.readdirSync(dir)) {
25819
25901
  const p = path64.join(dir, name);
25820
25902
  try {
25821
- const stat = fs54.statSync(p);
25903
+ const stat = fs55.statSync(p);
25822
25904
  if (stat.mtimeMs < cutoff)
25823
- fs54.rmSync(p, { force: true });
25905
+ fs55.rmSync(p, { force: true });
25824
25906
  } catch {
25825
25907
  }
25826
25908
  }
@@ -25859,7 +25941,7 @@ var init_runner = __esm({
25859
25941
  // ../server/dist/routes/cron.js
25860
25942
  import { Hono as Hono15 } from "hono";
25861
25943
  import { eq as eq15, desc as desc3, lt as lt6, and as and11 } from "drizzle-orm";
25862
- import fs55 from "node:fs/promises";
25944
+ import fs56 from "node:fs/promises";
25863
25945
  import { Cron as Cron2 } from "croner";
25864
25946
  function newJobId() {
25865
25947
  return `cron-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -26153,7 +26235,7 @@ function createCronRoutes() {
26153
26235
  return c.json({ log: null });
26154
26236
  let log2 = null;
26155
26237
  try {
26156
- log2 = await fs55.readFile(row.logPath, "utf-8");
26238
+ log2 = await fs56.readFile(row.logPath, "utf-8");
26157
26239
  } catch {
26158
26240
  }
26159
26241
  return c.json({ log: log2 });
@@ -26217,7 +26299,7 @@ var init_cron = __esm({
26217
26299
  });
26218
26300
 
26219
26301
  // ../server/dist/extensions/install.js
26220
- import fs56 from "node:fs";
26302
+ import fs57 from "node:fs";
26221
26303
  import path65 from "node:path";
26222
26304
  import { randomBytes as randomBytes2 } from "node:crypto";
26223
26305
  import JSZip2 from "jszip";
@@ -26251,41 +26333,41 @@ async function unpackZip(zipBuf, dest) {
26251
26333
  throw new ExtensionInstallError(`zip entry is a symlink: ${entry.name}`);
26252
26334
  const abs = path65.join(dest, rel);
26253
26335
  if (entry.dir) {
26254
- fs56.mkdirSync(abs, { recursive: true });
26336
+ fs57.mkdirSync(abs, { recursive: true });
26255
26337
  continue;
26256
26338
  }
26257
- fs56.mkdirSync(path65.dirname(abs), { recursive: true });
26258
- fs56.writeFileSync(abs, await entry.async("nodebuffer"));
26339
+ fs57.mkdirSync(path65.dirname(abs), { recursive: true });
26340
+ fs57.writeFileSync(abs, await entry.async("nodebuffer"));
26259
26341
  }
26260
26342
  }
26261
26343
  function stripWrapperDir(dir) {
26262
- if (fs56.existsSync(path65.join(dir, MANIFEST_FILE)))
26344
+ if (fs57.existsSync(path65.join(dir, MANIFEST_FILE)))
26263
26345
  return;
26264
- const children = fs56.readdirSync(dir, { withFileTypes: true });
26346
+ const children = fs57.readdirSync(dir, { withFileTypes: true });
26265
26347
  if (children.length !== 1 || !children[0].isDirectory())
26266
26348
  return;
26267
26349
  const inner = path65.join(dir, children[0].name);
26268
- if (!fs56.existsSync(path65.join(inner, MANIFEST_FILE)))
26350
+ if (!fs57.existsSync(path65.join(inner, MANIFEST_FILE)))
26269
26351
  return;
26270
- for (const name of fs56.readdirSync(inner)) {
26271
- fs56.renameSync(path65.join(inner, name), path65.join(dir, name));
26352
+ for (const name of fs57.readdirSync(inner)) {
26353
+ fs57.renameSync(path65.join(inner, name), path65.join(dir, name));
26272
26354
  }
26273
- fs56.rmdirSync(inner);
26355
+ fs57.rmdirSync(inner);
26274
26356
  }
26275
26357
  function rand() {
26276
26358
  return randomBytes2(3).toString("hex");
26277
26359
  }
26278
26360
  async function installExtensionZip(zipBuf, expectId) {
26279
26361
  const root = extensionsRoot();
26280
- fs56.mkdirSync(root, { recursive: true });
26362
+ fs57.mkdirSync(root, { recursive: true });
26281
26363
  const stage = path65.join(root, `.tmp-unpack-${rand()}`);
26282
- fs56.mkdirSync(stage);
26364
+ fs57.mkdirSync(stage);
26283
26365
  try {
26284
26366
  await unpackZip(zipBuf, stage);
26285
26367
  stripWrapperDir(stage);
26286
26368
  let manifestId;
26287
26369
  try {
26288
- const raw = JSON.parse(fs56.readFileSync(path65.join(stage, MANIFEST_FILE), "utf-8"));
26370
+ const raw = JSON.parse(fs57.readFileSync(path65.join(stage, MANIFEST_FILE), "utf-8"));
26289
26371
  manifestId = typeof raw.id === "string" ? raw.id : "";
26290
26372
  } catch (err) {
26291
26373
  throw new ExtensionInstallError(`${MANIFEST_FILE} missing or unreadable at the zip root: ${err instanceof Error ? err.message : String(err)}`);
@@ -26299,34 +26381,34 @@ async function installExtensionZip(zipBuf, expectId) {
26299
26381
  throw new ExtensionInstallError(`invalid extension: ${verdict.error}`);
26300
26382
  const target = path65.join(root, manifestId);
26301
26383
  const old = path65.join(root, `.old-${manifestId}-${rand()}`);
26302
- const hadOld = fs56.existsSync(target);
26384
+ const hadOld = fs57.existsSync(target);
26303
26385
  if (hadOld)
26304
- fs56.renameSync(target, old);
26386
+ fs57.renameSync(target, old);
26305
26387
  try {
26306
- fs56.renameSync(stage, target);
26388
+ fs57.renameSync(stage, target);
26307
26389
  } catch (err) {
26308
26390
  if (hadOld)
26309
- fs56.renameSync(old, target);
26391
+ fs57.renameSync(old, target);
26310
26392
  throw err;
26311
26393
  }
26312
26394
  if (hadOld)
26313
- fs56.rmSync(old, { recursive: true, force: true });
26395
+ fs57.rmSync(old, { recursive: true, force: true });
26314
26396
  const info = readExtensionDir(target);
26315
26397
  if ("error" in info)
26316
26398
  throw new ExtensionInstallError(`installed but failed re-validation: ${info.error}`);
26317
26399
  console.log(`[Extensions] installed ${info.id}@${info.version}${hadOld ? " (replaced previous)" : ""}`);
26318
26400
  return info;
26319
26401
  } finally {
26320
- fs56.rmSync(stage, { recursive: true, force: true });
26402
+ fs57.rmSync(stage, { recursive: true, force: true });
26321
26403
  }
26322
26404
  }
26323
26405
  function uninstallExtension(id) {
26324
26406
  if (!isExtensionId(id))
26325
26407
  throw new ExtensionInstallError(`"${id}" is not a valid extension id`);
26326
26408
  const dir = path65.join(extensionsRoot(), id);
26327
- if (!fs56.existsSync(dir))
26409
+ if (!fs57.existsSync(dir))
26328
26410
  return false;
26329
- fs56.rmSync(dir, { recursive: true, force: true });
26411
+ fs57.rmSync(dir, { recursive: true, force: true });
26330
26412
  console.log(`[Extensions] removed ${id}`);
26331
26413
  return true;
26332
26414
  }
@@ -26343,7 +26425,7 @@ var init_install2 = __esm({
26343
26425
  });
26344
26426
 
26345
26427
  // ../server/dist/extensions/watcher.js
26346
- import fs57 from "node:fs";
26428
+ import fs58 from "node:fs";
26347
26429
  function scheduleFlush() {
26348
26430
  if (flushTimer)
26349
26431
  return;
@@ -26362,7 +26444,7 @@ function rescanAndBroadcast() {
26362
26444
  }
26363
26445
  function openWatcher(root) {
26364
26446
  try {
26365
- watcher = fs57.watch(root, { persistent: false, recursive: false }, () => scheduleFlush());
26447
+ watcher = fs58.watch(root, { persistent: false, recursive: false }, () => scheduleFlush());
26366
26448
  watcher.on("error", (err) => {
26367
26449
  console.warn(`[Extensions] watch error on ${root}: ${err.message} \u2014 retrying in 1s`);
26368
26450
  closeWatcher();
@@ -26388,7 +26470,7 @@ function start() {
26388
26470
  return;
26389
26471
  const root = extensionsRoot();
26390
26472
  try {
26391
- fs57.mkdirSync(root, { recursive: true });
26473
+ fs58.mkdirSync(root, { recursive: true });
26392
26474
  } catch (err) {
26393
26475
  console.warn(`[Extensions] cannot create ${root}: ${err instanceof Error ? err.message : String(err)}`);
26394
26476
  }
@@ -26423,7 +26505,7 @@ var init_watcher = __esm({
26423
26505
 
26424
26506
  // ../server/dist/routes/extensions.js
26425
26507
  import { Hono as Hono16 } from "hono";
26426
- import fs58 from "node:fs";
26508
+ import fs59 from "node:fs";
26427
26509
  import path66 from "node:path";
26428
26510
  import { Readable as Readable2 } from "node:stream";
26429
26511
  function assetMime(fileName) {
@@ -26442,15 +26524,15 @@ function resolveAsset(id, version, rest) {
26442
26524
  let dir;
26443
26525
  let real;
26444
26526
  try {
26445
- dir = fs58.realpathSync(path66.join(extensionsRoot(), id));
26446
- real = fs58.realpathSync(path66.join(dir, ...segments));
26527
+ dir = fs59.realpathSync(path66.join(extensionsRoot(), id));
26528
+ real = fs59.realpathSync(path66.join(dir, ...segments));
26447
26529
  } catch {
26448
26530
  return null;
26449
26531
  }
26450
26532
  if (real !== dir && !real.startsWith(dir + path66.sep))
26451
26533
  return null;
26452
26534
  try {
26453
- if (!fs58.statSync(real).isFile())
26535
+ if (!fs59.statSync(real).isFile())
26454
26536
  return null;
26455
26537
  } catch {
26456
26538
  return null;
@@ -26512,8 +26594,8 @@ function createExtensionRoutes() {
26512
26594
  const abs = resolveAsset(c.req.param("id"), c.req.param("version"), c.req.param("rest"));
26513
26595
  if (!abs)
26514
26596
  return c.json({ error: "Not found" }, 404);
26515
- const stat = fs58.statSync(abs);
26516
- const nodeStream = fs58.createReadStream(abs);
26597
+ const stat = fs59.statSync(abs);
26598
+ const nodeStream = fs59.createReadStream(abs);
26517
26599
  c.req.raw.signal?.addEventListener("abort", () => nodeStream.destroy(), { once: true });
26518
26600
  return new Response(Readable2.toWeb(nodeStream), {
26519
26601
  headers: {
@@ -26567,7 +26649,7 @@ var init_extensions = __esm({
26567
26649
  });
26568
26650
 
26569
26651
  // ../server/dist/models/watcher.js
26570
- import fs59 from "node:fs";
26652
+ import fs60 from "node:fs";
26571
26653
  function snapshot2() {
26572
26654
  const { effective } = loadProviders();
26573
26655
  return {
@@ -26595,7 +26677,7 @@ function reloadAndBroadcast() {
26595
26677
  }
26596
26678
  function openWatcher2(root) {
26597
26679
  try {
26598
- watcher2 = fs59.watch(root, { persistent: false, recursive: false }, () => scheduleFlush2());
26680
+ watcher2 = fs60.watch(root, { persistent: false, recursive: false }, () => scheduleFlush2());
26599
26681
  watcher2.on("error", (err) => {
26600
26682
  console.warn(`[Models] watch error on ${root}: ${err.message} \u2014 retrying in 1s`);
26601
26683
  closeWatcher2();
@@ -26621,7 +26703,7 @@ function start2() {
26621
26703
  return;
26622
26704
  const root = globalHubModelsDir();
26623
26705
  try {
26624
- fs59.mkdirSync(root, { recursive: true });
26706
+ fs60.mkdirSync(root, { recursive: true });
26625
26707
  } catch (err) {
26626
26708
  console.warn(`[Models] cannot create ${root}: ${err instanceof Error ? err.message : String(err)}`);
26627
26709
  }
@@ -27738,12 +27820,12 @@ var init_handler3 = __esm({
27738
27820
  });
27739
27821
 
27740
27822
  // ../server/dist/routes/channel-accounts.js
27741
- import fs60 from "node:fs";
27823
+ import fs61 from "node:fs";
27742
27824
  function accountListFields(a) {
27743
27825
  return {
27744
27826
  accountId: a.accountId,
27745
27827
  workspacePath: a.workspacePath,
27746
- workspaceMissing: !fs60.existsSync(a.workspacePath),
27828
+ workspaceMissing: !fs61.existsSync(a.workspacePath),
27747
27829
  label: a.label,
27748
27830
  enabled: a.enabled,
27749
27831
  accessLevel: a.accessLevel,
@@ -28385,7 +28467,7 @@ var init_cdn = __esm({
28385
28467
  });
28386
28468
 
28387
28469
  // ../server/dist/channels/wechat/send-media.js
28388
- import fs61 from "node:fs/promises";
28470
+ import fs62 from "node:fs/promises";
28389
28471
  import path68 from "node:path";
28390
28472
  function inferKind(filePath) {
28391
28473
  const cls = classifyMedia(filePath);
@@ -28398,7 +28480,7 @@ function inferKind(filePath) {
28398
28480
  async function sendMediaFile(params) {
28399
28481
  const { baseUrl, token, toUserId, contextToken, contextTokenAt, filePath } = params;
28400
28482
  const kind = params.kind ?? inferKind(filePath);
28401
- const plaintext = await fs61.readFile(filePath);
28483
+ const plaintext = await fs62.readFile(filePath);
28402
28484
  const prep = prepareUpload(plaintext);
28403
28485
  console.log(`[WeChat] sendMediaFile: path=${filePath} kind=${kind} rawsize=${prep.rawsize} filesize=${prep.filesize}`);
28404
28486
  const mediaType = kind === "image" ? UploadMediaType.IMAGE : kind === "video" ? UploadMediaType.VIDEO : UploadMediaType.FILE;
@@ -28599,7 +28681,7 @@ var init_login = __esm({
28599
28681
  });
28600
28682
 
28601
28683
  // ../server/dist/channels/wechat/handler.js
28602
- import fs62 from "node:fs";
28684
+ import fs63 from "node:fs";
28603
28685
  import path69 from "node:path";
28604
28686
  import QRCode from "qrcode";
28605
28687
  function wxRoute(fromUserId, contextToken, contextTokenAt) {
@@ -29030,7 +29112,7 @@ async function handleSlashCommand(args) {
29030
29112
  contextToken,
29031
29113
  filePath: tmpPath
29032
29114
  });
29033
- await fs62.promises.unlink(tmpPath).catch(() => {
29115
+ await fs63.promises.unlink(tmpPath).catch(() => {
29034
29116
  });
29035
29117
  await reply(t("wechat.qr_sent", lang, { level, path: account.workspacePath }));
29036
29118
  waitLogin({ sessionKey: login.sessionKey }).then(async (result2) => {
@@ -29111,7 +29193,7 @@ var init_handler4 = __esm({
29111
29193
  // ../server/dist/routes/wechat.js
29112
29194
  import { Hono as Hono18 } from "hono";
29113
29195
  import path70 from "node:path";
29114
- import fs63 from "node:fs";
29196
+ import fs64 from "node:fs";
29115
29197
  function createWechatRoutes(deps) {
29116
29198
  const { db, channel } = deps;
29117
29199
  const app2 = new Hono18();
@@ -29120,7 +29202,7 @@ function createWechatRoutes(deps) {
29120
29202
  return {
29121
29203
  ...stripSecrets(acc),
29122
29204
  workspacePath: acc.workspacePath,
29123
- workspaceMissing: !fs63.existsSync(acc.workspacePath)
29205
+ workspaceMissing: !fs64.existsSync(acc.workspacePath)
29124
29206
  };
29125
29207
  });
29126
29208
  return c.json({ accounts });
@@ -29349,7 +29431,26 @@ var init_descriptor2 = __esm({
29349
29431
  import { Hono as Hono19 } from "hono";
29350
29432
  import { streamSSE } from "hono/streaming";
29351
29433
  import crypto5 from "node:crypto";
29352
- import fs64 from "node:fs";
29434
+ import fs65 from "node:fs";
29435
+ function streamWithKeepalive(c, chunks) {
29436
+ return streamSSE(c, async (stream) => {
29437
+ const timer = setInterval(() => {
29438
+ void stream.write(": keepalive\n\n");
29439
+ }, SSE_KEEPALIVE_MS);
29440
+ try {
29441
+ for await (const chunk of chunks()) {
29442
+ await stream.write(chunk);
29443
+ }
29444
+ } finally {
29445
+ clearInterval(timer);
29446
+ }
29447
+ });
29448
+ }
29449
+ function epochParam(raw) {
29450
+ if (raw === void 0 || raw === "")
29451
+ return void 0;
29452
+ return /^\d+$/.test(raw) ? Number(raw) : null;
29453
+ }
29353
29454
  function createWebRoutes(deps) {
29354
29455
  const { db, channel } = deps;
29355
29456
  const app2 = new Hono19();
@@ -29443,11 +29544,7 @@ function createWebRoutes(deps) {
29443
29544
  const denied = sessionOverrideError(c, auth.account, opts.sessionId);
29444
29545
  if (denied)
29445
29546
  return denied;
29446
- return streamSSE(c, async (stream) => {
29447
- for await (const chunk of channel.handleMessage(token, body.message ?? "", body.images, opts, c.req.raw.signal)) {
29448
- await stream.write(chunk);
29449
- }
29450
- });
29547
+ return streamWithKeepalive(c, () => channel.handleMessage(token, body.message ?? "", body.images, opts, c.req.raw.signal));
29451
29548
  });
29452
29549
  app2.post("/web/sessions", async (c) => {
29453
29550
  const auth = authToken(c);
@@ -29463,6 +29560,18 @@ function createWebRoutes(deps) {
29463
29560
  return c.json({ error: result.error }, 403);
29464
29561
  return c.json({ sessionId: result.sessionId });
29465
29562
  });
29563
+ app2.get("/web/sessions", (c) => {
29564
+ const auth = authToken(c);
29565
+ if (!auth.ok)
29566
+ return auth.response;
29567
+ const cursor = epochParam(c.req.query("cursor"));
29568
+ if (cursor === null)
29569
+ return c.json({ error: "Invalid cursor" }, 400);
29570
+ const result = channel.listSessions(auth.token, { workspace: readOverrides(c).workspace, cursor });
29571
+ if (!result.ok)
29572
+ return c.json({ error: result.error }, 403);
29573
+ return c.json({ workspace: result.workspace, sessions: result.sessions, nextCursor: result.nextCursor });
29574
+ });
29466
29575
  app2.post("/web/stop", async (c) => {
29467
29576
  const auth = authToken(c);
29468
29577
  if (!auth.ok)
@@ -29482,7 +29591,10 @@ function createWebRoutes(deps) {
29482
29591
  const denied = sessionOverrideError(c, auth.account, overrides.sessionId);
29483
29592
  if (denied)
29484
29593
  return denied;
29485
- const result = channel.getHistory(auth.token, overrides);
29594
+ const since = epochParam(c.req.query("since"));
29595
+ if (since === null)
29596
+ return c.json({ error: "Invalid since" }, 400);
29597
+ const result = channel.getHistory(auth.token, { ...overrides, since });
29486
29598
  if (!result) {
29487
29599
  if (overrides.sessionId)
29488
29600
  return c.json({ error: "session not found" }, 404);
@@ -29500,11 +29612,7 @@ function createWebRoutes(deps) {
29500
29612
  const denied = sessionOverrideError(c, auth.account, opts.sessionId);
29501
29613
  if (denied)
29502
29614
  return denied;
29503
- return streamSSE(c, async (stream) => {
29504
- for await (const chunk of channel.subscribe(auth.token, abortController.signal, opts)) {
29505
- await stream.write(chunk);
29506
- }
29507
- });
29615
+ return streamWithKeepalive(c, () => channel.subscribe(auth.token, abortController.signal, opts));
29508
29616
  });
29509
29617
  app2.get("/web/file", async (c) => {
29510
29618
  const auth = authToken(c);
@@ -29514,30 +29622,30 @@ function createWebRoutes(deps) {
29514
29622
  const filePath = c.req.query("path");
29515
29623
  if (!filePath)
29516
29624
  return c.json({ error: "path required" }, 400);
29517
- const path76 = await import("node:path");
29518
- const resolved = path76.default.resolve(account.workspacePath, filePath);
29519
- const root = path76.default.resolve(account.workspacePath);
29520
- if (resolved !== root && !resolved.startsWith(root + path76.default.sep)) {
29625
+ const path77 = await import("node:path");
29626
+ const resolved = path77.default.resolve(account.workspacePath, filePath);
29627
+ const root = path77.default.resolve(account.workspacePath);
29628
+ if (resolved !== root && !resolved.startsWith(root + path77.default.sep)) {
29521
29629
  return c.json({ error: "path traversal not allowed" }, 403);
29522
29630
  }
29523
29631
  let real;
29524
29632
  let realRoot;
29525
29633
  try {
29526
- real = fs64.realpathSync(resolved);
29527
- realRoot = fs64.realpathSync(root);
29634
+ real = fs65.realpathSync(resolved);
29635
+ realRoot = fs65.realpathSync(root);
29528
29636
  } catch {
29529
29637
  return c.json({ error: "file not found" }, 404);
29530
29638
  }
29531
- if (real !== realRoot && !real.startsWith(realRoot + path76.default.sep)) {
29639
+ if (real !== realRoot && !real.startsWith(realRoot + path77.default.sep)) {
29532
29640
  return c.json({ error: "path traversal not allowed" }, 403);
29533
29641
  }
29534
29642
  if (isHiddenWorkspacePath(real, realRoot)) {
29535
29643
  return c.json({ error: "path is not accessible" }, 403);
29536
29644
  }
29537
- const stat = fs64.statSync(real);
29645
+ const stat = fs65.statSync(real);
29538
29646
  if (stat.isDirectory())
29539
29647
  return c.json({ error: "cannot serve directory" }, 400);
29540
- const ext = path76.default.extname(real).toLowerCase();
29648
+ const ext = path77.default.extname(real).toLowerCase();
29541
29649
  const nonImageMimes = {
29542
29650
  ".mp4": "video/mp4",
29543
29651
  ".webm": "video/webm",
@@ -29545,14 +29653,15 @@ function createWebRoutes(deps) {
29545
29653
  ".txt": "text/plain"
29546
29654
  };
29547
29655
  const contentType = imageMimeFromExt(ext) || nonImageMimes[ext] || "application/octet-stream";
29548
- const fileName = path76.default.basename(resolved);
29549
- const data = fs64.readFileSync(real);
29656
+ const fileName = path77.default.basename(resolved);
29657
+ const data = fs65.readFileSync(real);
29550
29658
  c.header("content-type", contentType);
29551
29659
  c.header("content-disposition", `inline; filename="${fileName}"`);
29552
29660
  return c.body(data);
29553
29661
  });
29554
29662
  return app2;
29555
29663
  }
29664
+ var SSE_KEEPALIVE_MS;
29556
29665
  var init_web = __esm({
29557
29666
  "../server/dist/routes/web.js"() {
29558
29667
  "use strict";
@@ -29564,6 +29673,7 @@ var init_web = __esm({
29564
29673
  init_web_token();
29565
29674
  init_sandbox();
29566
29675
  init_workspace_path();
29676
+ SSE_KEEPALIVE_MS = 15e3;
29567
29677
  }
29568
29678
  });
29569
29679
 
@@ -29766,10 +29876,10 @@ async function authTest(botToken) {
29766
29876
  return slackPost("auth.test", botToken, {});
29767
29877
  }
29768
29878
  async function uploadFile(args) {
29769
- const fs68 = await import("node:fs");
29770
- const path76 = await import("node:path");
29771
- const filename = args.filename ?? path76.basename(args.filePath);
29772
- const stat = fs68.statSync(args.filePath);
29879
+ const fs69 = await import("node:fs");
29880
+ const path77 = await import("node:path");
29881
+ const filename = args.filename ?? path77.basename(args.filePath);
29882
+ const stat = fs69.statSync(args.filePath);
29773
29883
  if (!stat.isFile())
29774
29884
  throw new Error(`[slack:uploadFile] not a regular file: ${args.filePath}`);
29775
29885
  const length = stat.size;
@@ -29787,7 +29897,7 @@ async function uploadFile(args) {
29787
29897
  if (step1Parsed.ok === false || !step1Parsed.upload_url || !step1Parsed.file_id) {
29788
29898
  throw new Error(`[slack:files.getUploadURLExternal] ${step1Parsed.error ?? "unknown"}`);
29789
29899
  }
29790
- const buf = fs68.readFileSync(args.filePath);
29900
+ const buf = fs69.readFileSync(args.filePath);
29791
29901
  const step2 = await fetch(step1Parsed.upload_url, {
29792
29902
  method: "POST",
29793
29903
  body: buf
@@ -30705,10 +30815,10 @@ async function replyMessage(args) {
30705
30815
  return { message_id: parsed.data?.message_id ?? "" };
30706
30816
  }
30707
30817
  async function uploadImage(args) {
30708
- const fs68 = await import("node:fs");
30709
- const path76 = await import("node:path");
30710
- const buf = fs68.readFileSync(args.filePath);
30711
- const filename = path76.basename(args.filePath);
30818
+ const fs69 = await import("node:fs");
30819
+ const path77 = await import("node:path");
30820
+ const buf = fs69.readFileSync(args.filePath);
30821
+ const filename = path77.basename(args.filePath);
30712
30822
  const send = async (token) => {
30713
30823
  const form = new FormData();
30714
30824
  form.append("image_type", "message");
@@ -30724,10 +30834,10 @@ async function uploadImage(args) {
30724
30834
  return { imageKey: parsed.data.image_key };
30725
30835
  }
30726
30836
  async function uploadFile2(args) {
30727
- const fs68 = await import("node:fs");
30728
- const path76 = await import("node:path");
30729
- const buf = fs68.readFileSync(args.filePath);
30730
- const filename = args.filename ?? path76.basename(args.filePath);
30837
+ const fs69 = await import("node:fs");
30838
+ const path77 = await import("node:path");
30839
+ const buf = fs69.readFileSync(args.filePath);
30840
+ const filename = args.filename ?? path77.basename(args.filePath);
30731
30841
  const send = async (token) => {
30732
30842
  const form = new FormData();
30733
30843
  form.append("file_type", args.fileType);
@@ -31554,7 +31664,7 @@ var init_event_adapter5 = __esm({
31554
31664
 
31555
31665
  // ../server/dist/channels/wecom/handler.js
31556
31666
  import path73 from "node:path";
31557
- import fs65 from "node:fs/promises";
31667
+ import fs66 from "node:fs/promises";
31558
31668
  import { WSClient as WSClient2, generateReqId } from "@wecom/aibot-node-sdk";
31559
31669
  function pickConversation(body) {
31560
31670
  const userid = body.from?.userid ?? "unknown";
@@ -31687,7 +31797,7 @@ async function sendWecomMedia(args) {
31687
31797
  const ext = path73.extname(filePath).toLowerCase();
31688
31798
  const cls = classifyMedia(filePath);
31689
31799
  const type = cls === "image" && [".png", ".jpg", ".jpeg", ".gif"].includes(ext) ? "image" : cls === "video" && ext === ".mp4" ? "video" : "file";
31690
- const buf = await fs65.readFile(filePath);
31800
+ const buf = await fs66.readFile(filePath);
31691
31801
  if (buf.length > MAX_WECOM_DOWNLOAD_BYTES)
31692
31802
  throw new Error("file exceeds 20MB");
31693
31803
  const { media_id: mediaId } = await wsClient.uploadMedia(buf, { type, filename: path73.basename(filePath) });
@@ -32145,14 +32255,14 @@ import { serve } from "@hono/node-server";
32145
32255
  import { serveStatic } from "@hono/node-server/serve-static";
32146
32256
  import { WebSocketServer as WebSocketServer2 } from "ws";
32147
32257
  import path74 from "node:path";
32148
- import fs66 from "node:fs";
32258
+ import fs67 from "node:fs";
32149
32259
  import { spawnSync as spawnSync3 } from "node:child_process";
32150
32260
  import { homedir as homedir24 } from "node:os";
32151
32261
  import { fileURLToPath as fileURLToPath3 } from "node:url";
32152
32262
  function findProjectRoot() {
32153
32263
  let dir = process.cwd();
32154
32264
  for (let i = 0; i < 32; i++) {
32155
- if (fs66.existsSync(path74.join(dir, "pnpm-workspace.yaml")))
32265
+ if (fs67.existsSync(path74.join(dir, "pnpm-workspace.yaml")))
32156
32266
  return dir;
32157
32267
  const parent = path74.dirname(dir);
32158
32268
  if (parent === dir)
@@ -32163,15 +32273,15 @@ function findProjectRoot() {
32163
32273
  return process.cwd();
32164
32274
  }
32165
32275
  function acquireSingleInstanceLock(lockFile) {
32166
- fs66.mkdirSync(path74.dirname(lockFile), { recursive: true });
32167
- const fd = fs66.openSync(lockFile, fs66.constants.O_RDWR | fs66.constants.O_CREAT, 420);
32276
+ fs67.mkdirSync(path74.dirname(lockFile), { recursive: true });
32277
+ const fd = fs67.openSync(lockFile, fs67.constants.O_RDWR | fs67.constants.O_CREAT, 420);
32168
32278
  const result = spawnSync3("flock", ["-n", "3"], {
32169
32279
  stdio: ["ignore", "ignore", "ignore", fd]
32170
32280
  });
32171
32281
  if (result.error) {
32172
32282
  const content = readLockPid(fd);
32173
32283
  if (content > 0 && isProcessAlive(content)) {
32174
- fs66.closeSync(fd);
32284
+ fs67.closeSync(fd);
32175
32285
  console.error(`[Server] another halo server is already running (pid ${content}). kill it first.`);
32176
32286
  process.exit(1);
32177
32287
  }
@@ -32180,19 +32290,19 @@ function acquireSingleInstanceLock(lockFile) {
32180
32290
  if (content > 0 && !isProcessAlive(content)) {
32181
32291
  console.warn(`[Server] removing stale server.lock (pid ${content} not running)`);
32182
32292
  } else {
32183
- fs66.closeSync(fd);
32293
+ fs67.closeSync(fd);
32184
32294
  console.error(`[Server] another halo server is already running${content > 0 ? ` (pid ${content})` : ""}. kill it first.`);
32185
32295
  process.exit(1);
32186
32296
  }
32187
32297
  }
32188
- fs66.ftruncateSync(fd);
32189
- fs66.writeSync(fd, String(process.pid));
32190
- fs66.fsyncSync(fd);
32298
+ fs67.ftruncateSync(fd);
32299
+ fs67.writeSync(fd, String(process.pid));
32300
+ fs67.fsyncSync(fd);
32191
32301
  }
32192
32302
  function readLockPid(fd) {
32193
32303
  try {
32194
32304
  const buf = Buffer.alloc(32);
32195
- const n = fs66.readSync(fd, buf, 0, 32, 0);
32305
+ const n = fs67.readSync(fd, buf, 0, 32, 0);
32196
32306
  return parseInt(buf.subarray(0, n).toString("utf-8").trim(), 10) || 0;
32197
32307
  } catch {
32198
32308
  return 0;
@@ -32220,7 +32330,7 @@ function isProcessAlive(pid) {
32220
32330
  }
32221
32331
  }
32222
32332
  function readGitSha() {
32223
- if (!fs66.existsSync(path74.join(PROJECT_ROOT, "pnpm-workspace.yaml")) || !fs66.existsSync(path74.join(PROJECT_ROOT, ".git")))
32333
+ if (!fs67.existsSync(path74.join(PROJECT_ROOT, "pnpm-workspace.yaml")) || !fs67.existsSync(path74.join(PROJECT_ROOT, ".git")))
32224
32334
  return null;
32225
32335
  try {
32226
32336
  const rev = spawnSync3("git", ["rev-parse", "--short", "HEAD"], { cwd: PROJECT_ROOT, encoding: "utf-8" });
@@ -32243,10 +32353,10 @@ function resolveFrontendDir() {
32243
32353
  if (process.env.HALO_FRONTEND_DIR)
32244
32354
  return path74.resolve(process.env.HALO_FRONTEND_DIR);
32245
32355
  const bundled = path74.resolve(__dirname3, "..", "admin-out");
32246
- if (fs66.existsSync(path74.join(bundled, "index.html")))
32356
+ if (fs67.existsSync(path74.join(bundled, "index.html")))
32247
32357
  return bundled;
32248
32358
  const sibling = path74.resolve(__dirname3, "..", "..", "admin", "out");
32249
- if (fs66.existsSync(path74.join(sibling, "index.html")))
32359
+ if (fs67.existsSync(path74.join(sibling, "index.html")))
32250
32360
  return sibling;
32251
32361
  return path74.resolve(PROJECT_ROOT, "packages", "admin", "out");
32252
32362
  }
@@ -32327,11 +32437,11 @@ var init_dist2 = __esm({
32327
32437
  init_git_ssh();
32328
32438
  __dirname3 = path74.dirname(fileURLToPath3(import.meta.url));
32329
32439
  PORT = config.server.port;
32330
- HALO_VERSION = "1.5.10";
32440
+ HALO_VERSION = "1.5.11";
32331
32441
  PROJECT_ROOT = findProjectRoot();
32332
32442
  HALO_HOME4 = path74.join(homedir24(), ".halo");
32333
32443
  GIT_SHA = readGitSha();
32334
- if (!fs66.existsSync(path74.join(HALO_HOME4, "global", ".template-version"))) {
32444
+ if (!fs67.existsSync(path74.join(HALO_HOME4, "global", ".template-version"))) {
32335
32445
  process.stderr.write("\x1B[31m[Server] ~/.halo/global/ not initialized. Run `halo setup` first.\x1B[0m\n");
32336
32446
  process.exit(1);
32337
32447
  }
@@ -32424,7 +32534,7 @@ var init_dist2 = __esm({
32424
32534
  setCronSessionRegistry(registry);
32425
32535
  if (OWNS_RUNTIMES) {
32426
32536
  for (const ws of listRunningWorkspaces()) {
32427
- if (!fs66.existsSync(path74.join(ws, ".halo")))
32537
+ if (!fs67.existsSync(path74.join(ws, ".halo")))
32428
32538
  continue;
32429
32539
  if (!claimWorkspaceRuntime(ws))
32430
32540
  continue;
@@ -32458,7 +32568,7 @@ var init_dist2 = __esm({
32458
32568
  app.get("/*", (c) => {
32459
32569
  const indexPath = path74.join(FRONTEND_DIR, "index.html");
32460
32570
  try {
32461
- const html = fs66.readFileSync(indexPath, "utf-8");
32571
+ const html = fs67.readFileSync(indexPath, "utf-8");
32462
32572
  return c.html(html);
32463
32573
  } catch {
32464
32574
  return c.text("Frontend not built. Run: cd packages/admin && npx next build", 503);
@@ -32619,21 +32729,57 @@ var init_halo_client = __esm({
32619
32729
  throw new Error("halo session create: missing sessionId");
32620
32730
  return data.sessionId;
32621
32731
  }
32622
- /** GET /api/web/history with an explicit sessionId, returns true iff
32623
- * the server has a row for it (used by ACP `session/load`). */
32624
- async sessionExists(workspace, sessionId) {
32625
- const url = new URL(`${this.opts.baseUrl}/api/web/history`);
32626
- url.searchParams.set("workspace", workspace);
32627
- url.searchParams.set("sessionId", sessionId);
32628
- const res = await fetch(url, { headers: this.authHeaders({ "x-token": this.opts.token }) });
32732
+ /** GET /api/web/subscribe as SSE — same frames as `chat`. Ends after a
32733
+ * single `complete` when the session isn't running. */
32734
+ async *subscribe(workspace, sessionId, signal) {
32735
+ const res = await fetch(this.sessionUrl("/api/web/subscribe", workspace, sessionId), {
32736
+ headers: this.authHeaders({ "x-token": this.opts.token }),
32737
+ signal
32738
+ });
32739
+ if (!res.ok || !res.body) {
32740
+ const msg = await safeText(res);
32741
+ throw new Error(`halo subscribe ${res.status}: ${msg}`);
32742
+ }
32743
+ yield* parseSseStream(res.body);
32744
+ }
32745
+ /** GET /api/web/history for an explicit sessionId; null when the server
32746
+ * has no such session (404). `since` (epoch ms) = only root-log rows
32747
+ * stamped at or after it — omitted for the full log. */
32748
+ async history(workspace, sessionId, signal, since) {
32749
+ const url = this.sessionUrl("/api/web/history", workspace, sessionId);
32750
+ if (since !== void 0)
32751
+ url.searchParams.set("since", String(since));
32752
+ const res = await fetch(url, {
32753
+ headers: this.authHeaders({ "x-token": this.opts.token }),
32754
+ signal
32755
+ });
32629
32756
  if (res.status === 404)
32630
- return false;
32757
+ return null;
32631
32758
  if (!res.ok) {
32632
32759
  const msg = await safeText(res);
32633
32760
  throw new Error(`halo history ${res.status}: ${msg}`);
32634
32761
  }
32635
- const data = await res.json();
32636
- return typeof data.sessionId === "string" && data.sessionId === sessionId;
32762
+ return await res.json();
32763
+ }
32764
+ /** GET /api/web/sessions — one page of the token's own root sessions,
32765
+ * newest first. `workspace` is the server-resolved absolute path. */
32766
+ async listSessions(workspace, cursor) {
32767
+ const url = new URL(`${this.opts.baseUrl}/api/web/sessions`);
32768
+ url.searchParams.set("workspace", workspace);
32769
+ if (cursor !== void 0)
32770
+ url.searchParams.set("cursor", String(cursor));
32771
+ const res = await fetch(url, { headers: this.authHeaders({ "x-token": this.opts.token }) });
32772
+ if (!res.ok) {
32773
+ const msg = await safeText(res);
32774
+ throw new Error(`halo session list ${res.status}: ${msg}`);
32775
+ }
32776
+ return await res.json();
32777
+ }
32778
+ sessionUrl(path77, workspace, sessionId) {
32779
+ const url = new URL(`${this.opts.baseUrl}${path77}`);
32780
+ url.searchParams.set("workspace", workspace);
32781
+ url.searchParams.set("sessionId", sessionId);
32782
+ return url;
32637
32783
  }
32638
32784
  /** POST /api/web/stop. */
32639
32785
  async stop(workspace, sessionId) {
@@ -32657,26 +32803,218 @@ var init_halo_client = __esm({
32657
32803
  }
32658
32804
  });
32659
32805
 
32806
+ // ../acp-adapter/dist/acp-updates.js
32807
+ function toolKind(toolName) {
32808
+ return TOOL_KINDS[toolName] ?? "other";
32809
+ }
32810
+ function toolTitle(toolName, input) {
32811
+ const arg = titleArg(input)?.replace(/\s+/g, " ").trim();
32812
+ return arg ? `${toolName}: ${arg.slice(0, TITLE_ARG_MAX)}` : toolName;
32813
+ }
32814
+ function titleArg(input) {
32815
+ if (typeof input === "string") {
32816
+ if (!input.startsWith("{"))
32817
+ return input;
32818
+ try {
32819
+ return titleArg(JSON.parse(input));
32820
+ } catch {
32821
+ return input;
32822
+ }
32823
+ }
32824
+ const value = input;
32825
+ if (!value || typeof value !== "object")
32826
+ return void 0;
32827
+ for (const key of TITLE_ARGS) {
32828
+ const v = value[key];
32829
+ if (typeof v === "string" && v)
32830
+ return v;
32831
+ }
32832
+ return void 0;
32833
+ }
32834
+ function textChunk(kind, text6) {
32835
+ return { sessionUpdate: kind, content: { type: "text", text: text6 } };
32836
+ }
32837
+ function toolResultContent(text6) {
32838
+ return [{ type: "content", content: { type: "text", text: text6 } }];
32839
+ }
32840
+ function replayToolCall(tc, fallbackId) {
32841
+ const output = tc.output?.slice(0, REPLAY_OUTPUT_MAX);
32842
+ return {
32843
+ sessionUpdate: "tool_call",
32844
+ toolCallId: tc.toolUseId || `replay-${fallbackId}`,
32845
+ title: toolTitle(tc.name, tc.input),
32846
+ kind: toolKind(tc.name),
32847
+ status: "completed",
32848
+ rawInput: tc.input,
32849
+ ...output !== void 0 ? { rawOutput: output, content: toolResultContent(output) } : {}
32850
+ };
32851
+ }
32852
+ function replayUpdates(messages2) {
32853
+ const out = [];
32854
+ messages2.forEach((m, i) => {
32855
+ if (m.deleted || m.taskId)
32856
+ return;
32857
+ if (m.role === "user") {
32858
+ if (m.content)
32859
+ out.push(textChunk("user_message_chunk", m.content));
32860
+ return;
32861
+ }
32862
+ if (m.role !== "assistant")
32863
+ return;
32864
+ const msgKey = m.id ?? String(i);
32865
+ if (m.contentBlocks && m.contentBlocks.length > 0) {
32866
+ m.contentBlocks.forEach((b, j) => {
32867
+ if (b.type === "tool_call")
32868
+ out.push(replayToolCall(b.toolCall, `${msgKey}-${j}`));
32869
+ else if (b.text)
32870
+ out.push(textChunk(b.type === "thinking" ? "agent_thought_chunk" : "agent_message_chunk", b.text));
32871
+ });
32872
+ return;
32873
+ }
32874
+ m.toolCalls?.forEach((tc, j) => out.push(replayToolCall(tc, `${msgKey}-${j}`)));
32875
+ if (m.content)
32876
+ out.push(textChunk("agent_message_chunk", m.content));
32877
+ });
32878
+ return out;
32879
+ }
32880
+ function stripMediaLines(text6) {
32881
+ return text6.split(/(?<=\n)/).map((line) => line.replace(MEDIA_MARKER_RE2, "")).join("");
32882
+ }
32883
+ function replyAfterPrompt(messages2, prompt) {
32884
+ let start3 = -1;
32885
+ for (let i = messages2.length - 1; i >= 0; i--) {
32886
+ const m = messages2[i];
32887
+ if (m.role === "user" && (m.content === prompt || m.content.startsWith(prompt + "\n\n"))) {
32888
+ start3 = i;
32889
+ break;
32890
+ }
32891
+ }
32892
+ if (start3 === -1)
32893
+ return "";
32894
+ return stripMediaLines(messages2.slice(start3 + 1).filter((m) => m.role === "assistant" && !m.deleted && !m.taskId).map((m) => m.content).join(""));
32895
+ }
32896
+ var TOOL_KINDS, TITLE_ARGS, TITLE_ARG_MAX, REPLAY_OUTPUT_MAX, MEDIA_MARKER_RE2;
32897
+ var init_acp_updates = __esm({
32898
+ "../acp-adapter/dist/acp-updates.js"() {
32899
+ "use strict";
32900
+ TOOL_KINDS = {
32901
+ file_read: "read",
32902
+ file_write: "edit",
32903
+ file_edit: "edit",
32904
+ shell_exec: "execute",
32905
+ grep: "search",
32906
+ glob: "search",
32907
+ file_list: "search",
32908
+ web_fetch: "fetch"
32909
+ };
32910
+ TITLE_ARGS = ["command", "pattern", "url", "path"];
32911
+ TITLE_ARG_MAX = 80;
32912
+ REPLAY_OUTPUT_MAX = 500;
32913
+ MEDIA_MARKER_RE2 = /^MEDIA:\s*(\S.*?)\s*$/gm;
32914
+ }
32915
+ });
32916
+
32917
+ // ../acp-adapter/dist/version.js
32918
+ var ADAPTER_VERSION;
32919
+ var init_version = __esm({
32920
+ "../acp-adapter/dist/version.js"() {
32921
+ "use strict";
32922
+ ADAPTER_VERSION = "0.1.0";
32923
+ }
32924
+ });
32925
+
32660
32926
  // ../acp-adapter/dist/adapter.js
32927
+ import path75 from "node:path";
32928
+ function composePrompt(blocks) {
32929
+ const segments = [];
32930
+ let run = "";
32931
+ const paragraph = (s) => {
32932
+ if (run)
32933
+ segments.push(run);
32934
+ run = "";
32935
+ segments.push(s);
32936
+ };
32937
+ const images = [];
32938
+ for (const b of blocks) {
32939
+ if (b.type === "text") {
32940
+ run += b.text ?? "";
32941
+ } else if (b.type === "image" && b.data && b.mimeType) {
32942
+ images.push({ data: b.data, mimeType: b.mimeType });
32943
+ } else if (b.type === "resource" && b.resource) {
32944
+ const { uri = "", text: text6 } = b.resource;
32945
+ if (typeof text6 === "string") {
32946
+ const fence = "`".repeat(Math.max(3, longestBacktickRun(text6) + 1));
32947
+ paragraph(`[resource: ${uri}]
32948
+ ${fence}
32949
+ ${text6}
32950
+ ${fence}`);
32951
+ } else {
32952
+ paragraph(`[binary resource omitted: ${uri}]`);
32953
+ }
32954
+ } else if (b.type === "resource_link") {
32955
+ paragraph(`[resource link: ${[b.name, b.uri].filter(Boolean).join(" ")}]`);
32956
+ } else {
32957
+ process.stderr.write(`[acp-adapter] dropping ${b.type} content block (not supported)
32958
+ `);
32959
+ }
32960
+ }
32961
+ if (run)
32962
+ segments.push(run);
32963
+ return { text: segments.join("\n\n"), images };
32964
+ }
32965
+ function longestBacktickRun(s) {
32966
+ let max = 0;
32967
+ for (const m of s.matchAll(/`+/g))
32968
+ max = Math.max(max, m[0].length);
32969
+ return max;
32970
+ }
32971
+ function nonEmpty(v) {
32972
+ return typeof v === "string" && v ? v : void 0;
32973
+ }
32974
+ function errMessage(err) {
32975
+ return err instanceof Error ? err.message : String(err);
32976
+ }
32977
+ function sleep3(ms, signal) {
32978
+ return new Promise((resolve2) => {
32979
+ if (signal.aborted)
32980
+ return resolve2();
32981
+ const done = () => {
32982
+ clearTimeout(timer);
32983
+ signal.removeEventListener("abort", done);
32984
+ resolve2();
32985
+ };
32986
+ const timer = setTimeout(done, ms);
32987
+ signal.addEventListener("abort", done);
32988
+ });
32989
+ }
32661
32990
  function newError(message, code = -32603) {
32662
32991
  const e = new Error(message);
32663
32992
  e.code = code;
32664
32993
  return e;
32665
32994
  }
32666
- var AcpAdapter;
32995
+ var RETRY_DELAYS_MS, CANCEL_GRACE_MS, SETTLE_SKEW_MS, AcpAdapter;
32667
32996
  var init_adapter = __esm({
32668
32997
  "../acp-adapter/dist/adapter.js"() {
32669
32998
  "use strict";
32670
32999
  init_halo_client();
33000
+ init_acp_updates();
33001
+ init_version();
33002
+ RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 16e3];
33003
+ CANCEL_GRACE_MS = 5e3;
33004
+ SETTLE_SKEW_MS = 5 * 6e4;
32671
33005
  AcpAdapter = class {
32672
33006
  conn;
32673
33007
  config;
32674
33008
  client;
33009
+ retryDelaysMs;
33010
+ cancelGraceMs;
32675
33011
  sessions = /* @__PURE__ */ new Map();
32676
- constructor(conn, config3) {
33012
+ constructor(conn, config3, deps = {}) {
32677
33013
  this.conn = conn;
32678
33014
  this.config = config3;
32679
- this.client = new HaloClient({ baseUrl: config3.baseUrl, token: config3.token, headers: config3.headers });
33015
+ this.client = deps.client ?? new HaloClient({ baseUrl: config3.baseUrl, token: config3.token, headers: config3.headers });
33016
+ this.retryDelaysMs = deps.retryDelaysMs ?? RETRY_DELAYS_MS;
33017
+ this.cancelGraceMs = deps.cancelGraceMs ?? CANCEL_GRACE_MS;
32680
33018
  this.registerHandlers();
32681
33019
  }
32682
33020
  registerHandlers() {
@@ -32684,7 +33022,11 @@ var init_adapter = __esm({
32684
33022
  this.conn.onRequest("authenticate", () => this.handleAuthenticate());
32685
33023
  this.conn.onRequest("session/new", (params) => this.handleSessionNew(params));
32686
33024
  this.conn.onRequest("session/load", (params) => this.handleSessionLoad(params));
33025
+ this.conn.onRequest("session/list", (params) => this.handleSessionList(params));
32687
33026
  this.conn.onRequest("session/prompt", (params) => this.handleSessionPrompt(params));
33027
+ this.conn.onNotification("session/cancel", (params) => {
33028
+ this.handleSessionCancel(params);
33029
+ });
32688
33030
  this.conn.onRequest("session/cancel", (params) => this.handleSessionCancel(params));
32689
33031
  }
32690
33032
  /**
@@ -32698,13 +33040,15 @@ var init_adapter = __esm({
32698
33040
  protocolVersion: 1,
32699
33041
  agentCapabilities: {
32700
33042
  loadSession: true,
33043
+ sessionCapabilities: { list: {} },
32701
33044
  promptCapabilities: {
32702
33045
  image: true,
32703
33046
  audio: false,
32704
33047
  embeddedContext: true
32705
33048
  }
32706
33049
  },
32707
- authMethods: []
33050
+ authMethods: [],
33051
+ agentInfo: { name: "halo", title: "Halo", version: ADAPTER_VERSION }
32708
33052
  };
32709
33053
  }
32710
33054
  /** Authentication is satisfied by the adapter's launch flags; ACP
@@ -32730,37 +33074,66 @@ var init_adapter = __esm({
32730
33074
  /**
32731
33075
  * Resume a session whose id the ACP client persisted from an earlier
32732
33076
  * `session/new`. Adapter doesn't store anything on disk — the client
32733
- * is the source of truth for "which sessions are mine". We only:
33077
+ * is the source of truth for "which sessions are mine". We:
32734
33078
  *
32735
- * 1. Verify the session still exists on the halo server (via
32736
- * /api/web/history with the explicit sessionId — server returns
32737
- * 404 when the row is gone).
32738
- * 2. Register it in our in-memory map so the same prompt / cancel
33079
+ * 1. Fetch its history (`/api/web/history` with the explicit id —
33080
+ * 404 when the row is gone → invalid-params error).
33081
+ * 2. Replay the whole conversation as `session/update`s, all of them
33082
+ * before the response (ACP session-setup "Loading Sessions").
33083
+ * 3. Register it in our in-memory map so the same prompt / cancel
32739
33084
  * paths work for it.
32740
33085
  *
32741
- * Returns null on success per ACP spec; throws an invalid-params
32742
- * error if the session can't be found.
33086
+ * `cwd` / `mcpServers` are accepted and ignored: the agent runs in the
33087
+ * server-side workspace and can't reach the client's local MCP servers.
33088
+ * A turn still running server-side is replayed as far as it got; the
33089
+ * live tail isn't attached.
32743
33090
  */
32744
33091
  async handleSessionLoad(params) {
32745
33092
  const p = params;
32746
33093
  if (!p.sessionId)
32747
33094
  throw newError("invalid params: sessionId required", -32602);
32748
- const exists = await this.client.sessionExists(this.config.workspace, p.sessionId);
32749
- if (!exists)
33095
+ const history = await this.client.history(this.config.workspace, p.sessionId);
33096
+ if (!history)
32750
33097
  throw newError(`unknown session: ${p.sessionId}`, -32602);
32751
33098
  if (!this.sessions.has(p.sessionId)) {
32752
33099
  this.sessions.set(p.sessionId, { workspace: this.config.workspace });
32753
33100
  }
32754
- return null;
33101
+ for (const update of replayUpdates(history.messages)) {
33102
+ this.conn.notify("session/update", { sessionId: p.sessionId, update });
33103
+ }
33104
+ return {};
33105
+ }
33106
+ /**
33107
+ * One page of the token's own root sessions (`GET /api/web/sessions`),
33108
+ * newest first. Every session's `cwd` is the server-resolved workspace —
33109
+ * where the agent actually runs — so a `cwd` filter matches only that
33110
+ * path; anything else is an empty page, not an error (spec). The cursor
33111
+ * is the server's `nextCursor` (an updatedAt epoch-ms) as an opaque
33112
+ * string; anything that doesn't parse back is invalid params.
33113
+ */
33114
+ async handleSessionList(params) {
33115
+ const p = params ?? {};
33116
+ const cursor = p.cursor || void 0;
33117
+ if (cursor !== void 0 && !/^\d+$/.test(cursor))
33118
+ throw newError(`invalid cursor: ${cursor}`, -32602);
33119
+ const page = await this.client.listSessions(this.config.workspace, cursor === void 0 ? void 0 : Number(cursor));
33120
+ if (p.cwd !== void 0 && path75.resolve(p.cwd) !== path75.resolve(page.workspace))
33121
+ return { sessions: [] };
33122
+ return {
33123
+ sessions: page.sessions.map((s) => ({
33124
+ sessionId: s.sessionId,
33125
+ cwd: page.workspace,
33126
+ ...s.title ? { title: s.title } : {},
33127
+ updatedAt: new Date(s.updatedAt).toISOString()
33128
+ })),
33129
+ ...page.nextCursor !== null ? { nextCursor: String(page.nextCursor) } : {}
33130
+ };
32755
33131
  }
32756
33132
  /**
32757
33133
  * Forward the user's prompt to halo and stream back updates.
32758
33134
  *
32759
- * ACP `session/prompt.params.prompt` is an array of content blocks
32760
- * (text, image, resource). For v1 we extract `text` blocks only and
32761
- * concatenate; image blocks become halo `images[]` entries.
32762
- * Resource / embedded-context blocks are ignored with a warning to
32763
- * stderr (we don't have a way to ship them to halo yet).
33135
+ * ACP `session/prompt.params.prompt` is an array of content blocks;
33136
+ * `composePrompt` turns it into halo's `message` + `images[]`.
32764
33137
  */
32765
33138
  async handleSessionPrompt(params) {
32766
33139
  const p = params;
@@ -32769,57 +33142,167 @@ var init_adapter = __esm({
32769
33142
  const state = this.sessions.get(p.sessionId);
32770
33143
  if (!state)
32771
33144
  throw newError(`unknown session: ${p.sessionId}`, -32602);
32772
- if (state.promptAbort) {
33145
+ if (state.turn) {
32773
33146
  throw newError("prompt already in progress for this session", -32600);
32774
33147
  }
32775
- const blocks = p.prompt ?? [];
32776
- const text6 = blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
32777
- const images = [];
32778
- for (const b of blocks) {
32779
- if (b.type === "image" && b.data && b.mimeType) {
32780
- images.push({ data: b.data, mimeType: b.mimeType });
32781
- } else if (b.type === "resource" || b.type === "resource_link") {
32782
- process.stderr.write(`[acp-adapter] dropping ${b.type} content block (not yet supported)
32783
- `);
32784
- }
32785
- }
33148
+ const { text: text6, images } = composePrompt(p.prompt ?? []);
32786
33149
  if (!text6 && images.length === 0) {
32787
33150
  throw newError("invalid params: empty prompt", -32602);
32788
33151
  }
32789
- const abort = new AbortController();
32790
- state.promptAbort = abort;
32791
- state.lastToolCall = void 0;
32792
- const sessionId = p.sessionId;
32793
- let stopReason = "end_turn";
33152
+ const turn = {
33153
+ cancel: new AbortController(),
33154
+ http: new AbortController(),
33155
+ haloSessionId: p.sessionId,
33156
+ prompt: text6,
33157
+ startedAt: Date.now(),
33158
+ fallbackIds: 0,
33159
+ streamed: "",
33160
+ contentEvents: 0,
33161
+ announcedTools: /* @__PURE__ */ new Set()
33162
+ };
33163
+ state.turn = turn;
32794
33164
  try {
32795
- for await (const ev of this.client.chat({
33165
+ const stopReason = await this.runTurn(p.sessionId, state, turn, {
32796
33166
  message: text6,
32797
33167
  images: images.length > 0 ? images : void 0,
32798
33168
  workspace: state.workspace,
32799
- sessionId,
33169
+ sessionId: p.sessionId,
32800
33170
  agentId: this.config.agentId
32801
- }, abort.signal)) {
32802
- const action = this.routeEvent(sessionId, state, ev);
32803
- if (action === "cancelled") {
32804
- stopReason = "cancelled";
32805
- break;
32806
- }
32807
- if (action === "end")
32808
- break;
32809
- }
33171
+ });
33172
+ return { stopReason };
33173
+ } finally {
33174
+ turn.http.abort();
33175
+ state.turn = void 0;
33176
+ }
33177
+ }
33178
+ /**
33179
+ * One prompt turn: the /chat stream, then — when the session was busy
33180
+ * (`queued`, the message drains in the running turn) or the stream died
33181
+ * before `complete` — /subscribe until the terminal `complete`. A cancel
33182
+ * at any point resolves `cancelled`, once the stream is drained or the
33183
+ * grace period ran out.
33184
+ */
33185
+ async runTurn(acpSessionId, state, turn, chat) {
33186
+ let result;
33187
+ try {
33188
+ result = await this.pump(acpSessionId, turn, this.client.chat(chat, turn.http.signal));
32810
33189
  } catch (err) {
32811
- const aborted = abort.signal.aborted || err instanceof Error && err.name === "AbortError";
32812
- if (aborted) {
32813
- stopReason = "cancelled";
32814
- } else {
32815
- const msg = err instanceof Error ? err.message : String(err);
32816
- this.sendChunk(sessionId, "agent_message_chunk", `[adapter error] ${msg}`);
33190
+ if (turn.cancel.signal.aborted)
33191
+ return "cancelled";
33192
+ this.sendChunk(acpSessionId, "agent_message_chunk", `[adapter error] ${errMessage(err)}`);
33193
+ return "end_turn";
33194
+ }
33195
+ if (turn.cancel.signal.aborted)
33196
+ return "cancelled";
33197
+ if (result === "end")
33198
+ return "end_turn";
33199
+ if (result === "queued") {
33200
+ result = await this.pumpSafe(acpSessionId, turn, this.client.subscribe(state.workspace, turn.haloSessionId, turn.http.signal));
33201
+ if (turn.cancel.signal.aborted)
33202
+ return "cancelled";
33203
+ if (result !== "dropped")
33204
+ return this.settle(acpSessionId, state, turn);
33205
+ }
33206
+ return this.reconnect(acpSessionId, state, turn);
33207
+ }
33208
+ /**
33209
+ * The stream ended without `complete` and without a cancel (proxy idle
33210
+ * timeout, network blip, server restart). Re-attach via /subscribe with
33211
+ * backoff; a re-attach that delivers frames resets the budget, so a long
33212
+ * task can survive repeated drops. Once a re-attached stream completes
33213
+ * (or subscribe reports the run already over) the reply is settled
33214
+ * against history — text produced while detached is lost from the live
33215
+ * stream.
33216
+ */
33217
+ async reconnect(acpSessionId, state, turn) {
33218
+ let failures = 0;
33219
+ while (failures < this.retryDelaysMs.length) {
33220
+ await sleep3(this.retryDelaysMs[failures], turn.cancel.signal);
33221
+ if (turn.cancel.signal.aborted)
33222
+ return "cancelled";
33223
+ const before = turn.contentEvents;
33224
+ const result = await this.pumpSafe(acpSessionId, turn, this.client.subscribe(state.workspace, turn.haloSessionId, turn.http.signal));
33225
+ if (turn.cancel.signal.aborted)
33226
+ return "cancelled";
33227
+ if (result !== "dropped")
33228
+ return this.settle(acpSessionId, state, turn);
33229
+ failures = turn.contentEvents > before ? 0 : failures + 1;
33230
+ }
33231
+ this.sendChunk(acpSessionId, "agent_message_chunk", "[adapter error] connection lost");
33232
+ return "end_turn";
33233
+ }
33234
+ /** Route a stream's events until a terminal one. A stream that ends or
33235
+ * throws after opening is 'dropped'; one that throws before its first
33236
+ * event rethrows (the request itself failed). */
33237
+ async pump(acpSessionId, turn, stream) {
33238
+ let opened = false;
33239
+ try {
33240
+ for await (const ev of stream) {
33241
+ opened = true;
33242
+ const action = this.routeEvent(acpSessionId, turn, ev);
33243
+ if (action)
33244
+ return action;
32817
33245
  }
32818
- } finally {
32819
- state.promptAbort = void 0;
33246
+ } catch (err) {
33247
+ if (!opened)
33248
+ throw err;
33249
+ if (!turn.http.signal.aborted)
33250
+ process.stderr.write(`[acp-adapter] stream dropped: ${errMessage(err)}
33251
+ `);
33252
+ }
33253
+ return "dropped";
33254
+ }
33255
+ /** `pump` for re-attach attempts, where a failed request is just one
33256
+ * more dropped attempt. */
33257
+ async pumpSafe(acpSessionId, turn, stream) {
33258
+ try {
33259
+ return await this.pump(acpSessionId, turn, stream);
33260
+ } catch (err) {
33261
+ if (!turn.http.signal.aborted)
33262
+ process.stderr.write(`[acp-adapter] subscribe failed: ${errMessage(err)}
33263
+ `);
33264
+ return "dropped";
33265
+ }
33266
+ }
33267
+ /**
33268
+ * After a re-attach: compare what was streamed with the reply the
33269
+ * server logged for this prompt. Streamed text that is a prefix gets
33270
+ * only the missing tail; anything else (a gap mid-reply) gets the whole
33271
+ * reply again behind a marker, so the client never silently shows a
33272
+ * reply with a hole in it.
33273
+ */
33274
+ async settle(acpSessionId, state, turn) {
33275
+ await this.settleFromHistory(acpSessionId, state, turn);
33276
+ return turn.cancel.signal.aborted ? "cancelled" : "end_turn";
33277
+ }
33278
+ async settleFromHistory(acpSessionId, state, turn) {
33279
+ let reply;
33280
+ try {
33281
+ const since = Math.max(0, turn.startedAt - SETTLE_SKEW_MS);
33282
+ const history = await this.client.history(state.workspace, turn.haloSessionId, turn.http.signal, since);
33283
+ if (!history)
33284
+ return;
33285
+ reply = replyAfterPrompt(history.messages, turn.prompt);
33286
+ } catch (err) {
33287
+ if (!turn.http.signal.aborted)
33288
+ process.stderr.write(`[acp-adapter] history after reconnect failed: ${errMessage(err)}
33289
+ `);
33290
+ return;
32820
33291
  }
32821
- return { stopReason };
33292
+ if (!reply || turn.streamed.endsWith(reply))
33293
+ return;
33294
+ const missing = reply.startsWith(turn.streamed) ? reply.slice(turn.streamed.length) : `[reconnected \u2014 full reply]
33295
+ ${reply}`;
33296
+ this.sendChunk(acpSessionId, "agent_message_chunk", missing);
33297
+ turn.streamed = reply;
32822
33298
  }
33299
+ /**
33300
+ * ACP cancel: stop the halo session, then let the prompt keep reading
33301
+ * so the stop's own updates (interrupted tool rows, the final text)
33302
+ * reach the client BEFORE the `cancelled` response (prompt-turn
33303
+ * "Cancellation"). If no `complete` arrives within the grace period the
33304
+ * stream is aborted and the prompt resolves `cancelled` anyway.
33305
+ */
32823
33306
  handleSessionCancel(params) {
32824
33307
  const p = params;
32825
33308
  if (!p.sessionId)
@@ -32827,10 +33310,13 @@ var init_adapter = __esm({
32827
33310
  const state = this.sessions.get(p.sessionId);
32828
33311
  if (!state)
32829
33312
  return null;
32830
- if (state.promptAbort)
32831
- state.promptAbort.abort();
32832
- void this.client.stop(state.workspace, p.sessionId).catch((err) => {
32833
- process.stderr.write(`[acp-adapter] cancel stop failed: ${err instanceof Error ? err.message : String(err)}
33313
+ const turn = state.turn;
33314
+ if (turn && !turn.cancel.signal.aborted) {
33315
+ turn.cancel.abort();
33316
+ setTimeout(() => turn.http.abort(), this.cancelGraceMs).unref();
33317
+ }
33318
+ void this.client.stop(state.workspace, turn?.haloSessionId ?? p.sessionId).catch((err) => {
33319
+ process.stderr.write(`[acp-adapter] cancel stop failed: ${errMessage(err)}
32834
33320
  `);
32835
33321
  });
32836
33322
  return null;
@@ -32838,17 +33324,23 @@ var init_adapter = __esm({
32838
33324
  /**
32839
33325
  * Translate one halo SSE event into an ACP session/update
32840
33326
  * notification. Returns 'end' when the event terminates the prompt
32841
- * (complete / error), 'cancelled' if the stream was cancelled, or null
32842
- * to continue.
33327
+ * (complete / error), 'queued' when the session was busy, or null to
33328
+ * continue.
32843
33329
  */
32844
- routeEvent(acpSessionId, state, ev) {
33330
+ routeEvent(acpSessionId, turn, ev) {
33331
+ if (ev.type !== "session")
33332
+ turn.contentEvents++;
32845
33333
  switch (ev.type) {
32846
33334
  case "session":
33335
+ if (typeof ev.sessionId === "string" && ev.sessionId)
33336
+ turn.haloSessionId = ev.sessionId;
32847
33337
  return null;
32848
33338
  case "stream": {
32849
33339
  const text6 = typeof ev.text === "string" ? ev.text : "";
32850
- if (text6)
33340
+ if (text6) {
33341
+ turn.streamed += text6;
32851
33342
  this.sendChunk(acpSessionId, "agent_message_chunk", text6);
33343
+ }
32852
33344
  return null;
32853
33345
  }
32854
33346
  case "thinking": {
@@ -32859,60 +33351,34 @@ var init_adapter = __esm({
32859
33351
  }
32860
33352
  case "tool_call": {
32861
33353
  const toolName = typeof ev.toolName === "string" ? ev.toolName : "tool";
32862
- const callId = `${acpSessionId}-${toolName}-${Date.now().toString(36)}`;
32863
- state.lastToolCall = { callId, toolName };
32864
- this.conn.notify("session/update", {
32865
- sessionId: acpSessionId,
32866
- update: {
32867
- sessionUpdate: "tool_call",
32868
- toolCallId: callId,
32869
- title: toolName,
32870
- kind: "other",
32871
- status: "in_progress",
32872
- rawInput: ev.toolInput
32873
- }
32874
- });
33354
+ const callId = nonEmpty(ev.toolUseId) ?? `${acpSessionId}-${toolName}-${turn.startedAt.toString(36)}-${++turn.fallbackIds}`;
33355
+ turn.lastToolCall = { callId, toolName };
33356
+ this.announceTool(acpSessionId, turn, callId, toolName, ev.toolInput);
32875
33357
  return null;
32876
33358
  }
32877
33359
  case "tool_result": {
32878
33360
  const evToolName = typeof ev.toolName === "string" ? ev.toolName : void 0;
32879
33361
  const result = typeof ev.result === "string" ? ev.result : "";
32880
- const last = state.lastToolCall;
32881
- if (last && (!evToolName || evToolName === last.toolName)) {
32882
- this.conn.notify("session/update", {
32883
- sessionId: acpSessionId,
32884
- update: {
32885
- sessionUpdate: "tool_call_update",
32886
- toolCallId: last.callId,
32887
- status: "completed",
32888
- content: [{ type: "content", content: { type: "text", text: result } }]
32889
- }
32890
- });
32891
- state.lastToolCall = void 0;
32892
- return null;
33362
+ const last = turn.lastToolCall;
33363
+ let callId = nonEmpty(ev.toolUseId);
33364
+ if (!callId && last && (!evToolName || evToolName === last.toolName))
33365
+ callId = last.callId;
33366
+ if (!callId) {
33367
+ callId = `${acpSessionId}-${evToolName ?? "tool"}-orphan-${turn.startedAt.toString(36)}-${++turn.fallbackIds}`;
32893
33368
  }
32894
- const toolName = evToolName ?? "tool";
32895
- const callId = `${acpSessionId}-${toolName}-orphan-${Date.now().toString(36)}`;
32896
- this.conn.notify("session/update", {
32897
- sessionId: acpSessionId,
32898
- update: {
32899
- sessionUpdate: "tool_call",
32900
- toolCallId: callId,
32901
- title: toolName,
32902
- kind: "other",
32903
- status: "in_progress"
32904
- }
32905
- });
33369
+ if (!turn.announcedTools.has(callId))
33370
+ this.announceTool(acpSessionId, turn, callId, evToolName ?? "tool", void 0);
32906
33371
  this.conn.notify("session/update", {
32907
33372
  sessionId: acpSessionId,
32908
33373
  update: {
32909
33374
  sessionUpdate: "tool_call_update",
32910
33375
  toolCallId: callId,
32911
33376
  status: "completed",
32912
- content: [{ type: "content", content: { type: "text", text: result } }]
33377
+ content: toolResultContent(result)
32913
33378
  }
32914
33379
  });
32915
- state.lastToolCall = void 0;
33380
+ if (last?.callId === callId)
33381
+ turn.lastToolCall = void 0;
32916
33382
  return null;
32917
33383
  }
32918
33384
  case "error": {
@@ -32920,10 +33386,8 @@ var init_adapter = __esm({
32920
33386
  this.sendChunk(acpSessionId, "agent_message_chunk", `[error] ${errText2}`);
32921
33387
  return "end";
32922
33388
  }
32923
- case "queued": {
32924
- this.sendChunk(acpSessionId, "agent_message_chunk", "[queued \u2014 session busy]");
32925
- return "end";
32926
- }
33389
+ case "queued":
33390
+ return "queued";
32927
33391
  case "file": {
32928
33392
  const filePath = typeof ev.path === "string" ? ev.path : "";
32929
33393
  if (filePath)
@@ -32939,15 +33403,28 @@ var init_adapter = __esm({
32939
33403
  return null;
32940
33404
  }
32941
33405
  }
32942
- sendChunk(acpSessionId, kind, text6) {
33406
+ /** `tool_call` notification. No `locations`: paths are server-side,
33407
+ * the client couldn't open them. */
33408
+ announceTool(acpSessionId, turn, callId, toolName, toolInput) {
33409
+ turn.announcedTools.add(callId);
32943
33410
  this.conn.notify("session/update", {
32944
33411
  sessionId: acpSessionId,
32945
33412
  update: {
32946
- sessionUpdate: kind,
32947
- content: { type: "text", text: text6 }
33413
+ sessionUpdate: "tool_call",
33414
+ toolCallId: callId,
33415
+ title: toolTitle(toolName, toolInput),
33416
+ kind: toolKind(toolName),
33417
+ status: "in_progress",
33418
+ ...toolInput !== void 0 ? { rawInput: toolInput } : {}
32948
33419
  }
32949
33420
  });
32950
33421
  }
33422
+ sendChunk(acpSessionId, kind, text6) {
33423
+ this.conn.notify("session/update", {
33424
+ sessionId: acpSessionId,
33425
+ update: textChunk(kind, text6)
33426
+ });
33427
+ }
32951
33428
  };
32952
33429
  }
32953
33430
  });
@@ -33254,11 +33731,11 @@ process.emitWarning = (warning, ...args) => {
33254
33731
 
33255
33732
  // src/index.ts
33256
33733
  init_server_supervisor();
33257
- import path75 from "node:path";
33258
- import fs67 from "node:fs";
33734
+ import path76 from "node:path";
33735
+ import fs68 from "node:fs";
33259
33736
  import { homedir as homedir25 } from "node:os";
33260
33737
  import { parseArgs as parseArgs2 } from "node:util";
33261
- var VERSION = "1.5.10";
33738
+ var VERSION = "1.5.11";
33262
33739
  var HELP_TOP = `Usage: halo <command> [options]
33263
33740
 
33264
33741
  Commands:
@@ -33545,8 +34022,8 @@ async function cmdSetup(argv = []) {
33545
34022
  const { buildBindProviderPlan: buildBindProviderPlan2, KEEP_CURRENT: KEEP_CURRENT2, AWS_CHAIN_PROVIDER: AWS_CHAIN_PROVIDER2 } = await Promise.resolve().then(() => (init_setup_bind_provider(), setup_bind_provider_exports));
33546
34023
  const { promptText: promptText2, promptPassword: promptPassword2, promptSelect: promptSelect2, promptMultiSelect: promptMultiSelect2 } = await Promise.resolve().then(() => (init_setup_prompts(), setup_prompts_exports));
33547
34024
  const nonInteractive = argv.includes("--non-interactive") || argv.includes("-y");
33548
- const haloHome2 = path75.join(homedir25(), ".halo");
33549
- const globalDir2 = path75.join(haloHome2, "global");
34025
+ const haloHome2 = path76.join(homedir25(), ".halo");
34026
+ const globalDir2 = path76.join(haloHome2, "global");
33550
34027
  ensureHaloHome2(haloHome2);
33551
34028
  process.stderr.write(`[setup] ${haloHome2}/ ready
33552
34029
  `);
@@ -33889,7 +34366,7 @@ async function cmdUpgrade() {
33889
34366
  `);
33890
34367
  process.exit(1);
33891
34368
  }
33892
- const broken = verifyNativeModules2(path75.join(rootDir, PKG));
34369
+ const broken = verifyNativeModules2(path76.join(rootDir, PKG));
33893
34370
  if (broken.length > 0) {
33894
34371
  process.stderr.write(
33895
34372
  `
@@ -33920,7 +34397,7 @@ halo: upgrade failed (npm exited ${install.status}). ${hint}
33920
34397
  async function cmdAgents(flags) {
33921
34398
  const { initRuntime: initRuntime2, listAgents: listAgents2 } = await Promise.resolve().then(() => (init_harness(), harness_exports));
33922
34399
  await initRuntime2();
33923
- const workspace = path75.resolve(flags.workspace ?? process.cwd());
34400
+ const workspace = path76.resolve(flags.workspace ?? process.cwd());
33924
34401
  const agents = await listAgents2(workspace);
33925
34402
  if (agents.length === 0) {
33926
34403
  process.stderr.write("No agents found.\n");
@@ -33936,7 +34413,7 @@ async function cmdAgents(flags) {
33936
34413
  async function cmdSessions(flags) {
33937
34414
  const { initRuntime: initRuntime2, listSessions: listSessions2 } = await Promise.resolve().then(() => (init_harness(), harness_exports));
33938
34415
  await initRuntime2();
33939
- const workspace = path75.resolve(flags.workspace ?? process.cwd());
34416
+ const workspace = path76.resolve(flags.workspace ?? process.cwd());
33940
34417
  const sessions = listSessions2(workspace);
33941
34418
  if (sessions.length === 0) {
33942
34419
  process.stderr.write("No sessions found.\n");
@@ -33967,7 +34444,7 @@ async function cmdModels(argv) {
33967
34444
  process.exitCode = 1;
33968
34445
  return;
33969
34446
  }
33970
- const plan = mod.planModelsInstall(path75.resolve(dir));
34447
+ const plan = mod.planModelsInstall(path76.resolve(dir));
33971
34448
  for (const line of mod.formatPlan(plan)) process.stdout.write(`${line}
33972
34449
  `);
33973
34450
  if (plan.needsConfirm && !values.yes) {
@@ -34079,11 +34556,11 @@ ${stdinContent}` : stdinContent;
34079
34556
  harness.destroy();
34080
34557
  }
34081
34558
  }
34082
- var SERVER_LOCK_PATH = path75.join(homedir25(), ".halo", "global", "server.lock");
34083
- var SERVER_LOG_PATH = path75.join(homedir25(), ".halo", "logs", "server.log");
34559
+ var SERVER_LOCK_PATH = path76.join(homedir25(), ".halo", "global", "server.lock");
34560
+ var SERVER_LOG_PATH = path76.join(homedir25(), ".halo", "logs", "server.log");
34084
34561
  function readServerPid() {
34085
34562
  try {
34086
- const txt = fs67.readFileSync(SERVER_LOCK_PATH, "utf-8").trim();
34563
+ const txt = fs68.readFileSync(SERVER_LOCK_PATH, "utf-8").trim();
34087
34564
  const n = parseInt(txt, 10);
34088
34565
  return Number.isFinite(n) && n > 0 ? n : null;
34089
34566
  } catch {
@@ -34170,8 +34647,8 @@ async function cmdServerStart(argv, _unused) {
34170
34647
  return;
34171
34648
  }
34172
34649
  const { spawn: spawn5 } = await import("node:child_process");
34173
- fs67.mkdirSync(path75.dirname(SERVER_LOG_PATH), { recursive: true });
34174
- const out = fs67.openSync(SERVER_LOG_PATH, "a");
34650
+ fs68.mkdirSync(path76.dirname(SERVER_LOG_PATH), { recursive: true });
34651
+ const out = fs68.openSync(SERVER_LOG_PATH, "a");
34175
34652
  const child = spawn5(process.argv[0], [process.argv[1], "server", "start"], {
34176
34653
  detached: true,
34177
34654
  stdio: ["ignore", out, out],
@@ -34272,7 +34749,7 @@ async function cmdServerLogs(argv) {
34272
34749
  lines: { type: "string", short: "n" }
34273
34750
  }
34274
34751
  });
34275
- if (!fs67.existsSync(SERVER_LOG_PATH)) {
34752
+ if (!fs68.existsSync(SERVER_LOG_PATH)) {
34276
34753
  process.stderr.write(`No log file at ${SERVER_LOG_PATH}
34277
34754
  `);
34278
34755
  return;
@@ -34290,7 +34767,7 @@ function exitMissingSetup() {
34290
34767
  process.exit(1);
34291
34768
  }
34292
34769
  function isHaloHomeReady() {
34293
- return fs67.existsSync(path75.join(homedir25(), ".halo", "global", ".template-version"));
34770
+ return fs68.existsSync(path76.join(homedir25(), ".halo", "global", ".template-version"));
34294
34771
  }
34295
34772
  async function main2() {
34296
34773
  const argv = process.argv.slice(2);