very-happy-cli 0.2.90 → 0.2.92

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 (31) hide show
  1. package/dist/{AcpBackend-CnYZFdd0.cjs → AcpBackend-BP7id8L6.cjs} +1 -1
  2. package/dist/{AcpBackend-BV4wSnJ3.mjs → AcpBackend-DDEsgc1c.mjs} +1 -1
  3. package/dist/{AcpSessionManager-Cyne6Rmg.mjs → AcpSessionManager-C29t6wb_.mjs} +1 -1
  4. package/dist/{AcpSessionManager-BPBdrrME.cjs → AcpSessionManager-Cys1Hsb8.cjs} +1 -1
  5. package/dist/{config-CgXitj0f.cjs → config-B77vhdwT.cjs} +2 -2
  6. package/dist/{config-BEdyyciP.mjs → config-CJQw0z1o.mjs} +2 -2
  7. package/dist/{index-an09rejO.mjs → index-8LDXVYWi.mjs} +409 -51
  8. package/dist/{index-4YmVbx7j.cjs → index-B63Vjt4W.cjs} +425 -67
  9. package/dist/{index-Dfu5uvjk.mjs → index-BP9pfzdZ.mjs} +5 -5
  10. package/dist/{index-B1wTVXjt.cjs → index-L3o5Gz-2.cjs} +4 -4
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-DDpVxk8-.cjs → installTerminalHooks-BBc2nij4.cjs} +2 -2
  14. package/dist/{installTerminalHooks-C-OgZr5k.mjs → installTerminalHooks-DDRrUG_n.mjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +103 -3
  17. package/dist/lib.d.mts +103 -3
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-Du-N0qlK.mjs → mcp-CYJAl5pk.mjs} +2 -2
  20. package/dist/{mcp-B6Nq-RUL.cjs → mcp-DbYXyw1U.cjs} +2 -2
  21. package/dist/{runGemini-ChG7cGMC.cjs → runGemini-BDSWSID6.cjs} +4 -4
  22. package/dist/{runGemini-DBr_TiTL.mjs → runGemini-DHYs_Beq.mjs} +4 -4
  23. package/dist/{runOpenClaw-D2jtVgpC.cjs → runOpenClaw-DC9gb1bd.cjs} +3 -3
  24. package/dist/{runOpenClaw-Cbde8t92.mjs → runOpenClaw-DTaXxvNd.mjs} +3 -3
  25. package/dist/{send-Bjen1TUE.cjs → send-BEdGwmA-.cjs} +2 -2
  26. package/dist/{send-BHliX59m.mjs → send-BQSaVz9r.mjs} +2 -2
  27. package/dist/{spawn-CH1Rq-LW.cjs → spawn-CK1ma_qc.cjs} +2 -2
  28. package/dist/{spawn-JxaPHAoP.mjs → spawn-DS-Uz_Hw.mjs} +2 -2
  29. package/dist/{types-DS3i_5LV.mjs → types-B_8X_rZk.mjs} +572 -93
  30. package/dist/{types-BNGeiP1C.cjs → types-CjlB_fKT.cjs} +573 -93
  31. package/package.json +7 -7
@@ -29,7 +29,7 @@ import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
29
29
  import { Expo } from 'expo-server-sdk';
30
30
 
31
31
  var name = "very-happy-cli";
32
- var version = "0.2.90";
32
+ var version = "0.2.92";
33
33
  var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
34
34
  var author = "Kirill Dubovitskiy";
35
35
  var contributors = [
@@ -142,12 +142,12 @@ var dependencies = {
142
142
  zod: "^4.0.0"
143
143
  };
144
144
  var optionalDependencies = {
145
- "very-happy-tools-arm64-darwin": "0.2.90",
146
- "very-happy-tools-x64-darwin": "0.2.90",
147
- "very-happy-tools-arm64-linux": "0.2.90",
148
- "very-happy-tools-x64-linux": "0.2.90",
149
- "very-happy-tools-arm64-win32": "0.2.90",
150
- "very-happy-tools-x64-win32": "0.2.90"
145
+ "very-happy-tools-arm64-darwin": "0.2.92",
146
+ "very-happy-tools-x64-darwin": "0.2.92",
147
+ "very-happy-tools-arm64-linux": "0.2.92",
148
+ "very-happy-tools-x64-linux": "0.2.92",
149
+ "very-happy-tools-arm64-win32": "0.2.92",
150
+ "very-happy-tools-x64-win32": "0.2.92"
151
151
  };
152
152
  var devDependencies = {
153
153
  "@slopus/happy-wire": "workspace:*",
@@ -639,9 +639,27 @@ const sessionToolCallStartEventSchema = z.object({
639
639
  description: z.string(),
640
640
  args: z.record(z.string(), z.unknown())
641
641
  });
642
+ const sessionSubagentResultSchema = z.object({
643
+ text: z.string(),
644
+ truncated: z.boolean().optional()
645
+ });
646
+ const sessionSubagentUsageSchema = z.object({
647
+ toolUses: z.number().optional(),
648
+ totalTokens: z.number().optional(),
649
+ durationMs: z.number().optional()
650
+ });
642
651
  const sessionToolCallEndEventSchema = z.object({
643
652
  t: z.literal("tool-call-end"),
644
- call: z.string()
653
+ call: z.string(),
654
+ // Foreground Agent/Task completion: the sub-agent's final report + run
655
+ // totals (SDK `tool_use_result`). Absent for ordinary tools and for the
656
+ // background stub ("Async agent launched").
657
+ result: sessionSubagentResultSchema.extend({
658
+ isError: z.boolean().optional(),
659
+ stats: sessionSubagentUsageSchema.extend({
660
+ toolStats: z.record(z.string(), z.unknown()).optional()
661
+ }).optional()
662
+ }).optional()
645
663
  });
646
664
  const sessionFileEventSchema = z.object({
647
665
  t: z.literal("file"),
@@ -660,7 +678,10 @@ const sessionTurnStartEventSchema = z.object({
660
678
  });
661
679
  const sessionStartEventSchema = z.object({
662
680
  t: z.literal("start"),
663
- title: z.string().optional()
681
+ title: z.string().optional(),
682
+ // B-260-P2: identity from SDK task_started / Agent tool input.
683
+ description: z.string().optional(),
684
+ subagentType: z.string().optional()
664
685
  });
665
686
  const sessionTurnEndStatusSchema = z.enum(["completed", "failed", "cancelled"]);
666
687
  const sessionTurnUsageSchema = z.object({
@@ -683,7 +704,21 @@ const sessionTurnEndEventSchema = z.object({
683
704
  usage: sessionTurnUsageSchema.optional()
684
705
  });
685
706
  const sessionStopEventSchema = z.object({
686
- t: z.literal("stop")
707
+ t: z.literal("stop"),
708
+ // B-260-P2: from SDK task_notification. A second `stop` for the same
709
+ // subagent may follow carrying `result` once the notification user message
710
+ // (which holds the <result> block) arrives; consumers fold by subagent id.
711
+ status: z.enum(["completed", "failed", "stopped"]).optional(),
712
+ result: sessionSubagentResultSchema.optional(),
713
+ usage: sessionSubagentUsageSchema.optional()
714
+ });
715
+ const sessionProgressEventSchema = z.object({
716
+ t: z.literal("progress"),
717
+ toolUses: z.number(),
718
+ lastTool: z.string().optional(),
719
+ totalTokens: z.number().optional(),
720
+ durationMs: z.number().optional(),
721
+ summary: z.string().optional()
687
722
  });
688
723
  const sessionEventSchema = z.discriminatedUnion("t", [
689
724
  sessionTextEventSchema,
@@ -694,7 +729,8 @@ const sessionEventSchema = z.discriminatedUnion("t", [
694
729
  sessionTurnStartEventSchema,
695
730
  sessionStartEventSchema,
696
731
  sessionTurnEndEventSchema,
697
- sessionStopEventSchema
732
+ sessionStopEventSchema,
733
+ sessionProgressEventSchema
698
734
  ]);
699
735
  const sessionEnvelopeSchema = z.object({
700
736
  id: z.string(),
@@ -725,7 +761,14 @@ const sessionEnvelopeSchema = z.object({
725
761
  path: ["role"]
726
762
  });
727
763
  }
728
- if ((envelope.ev.t === "start" || envelope.ev.t === "stop") && envelope.role !== "agent") {
764
+ if (envelope.ev.t === "progress" && !envelope.subagent) {
765
+ ctx.addIssue({
766
+ code: z.ZodIssueCode.custom,
767
+ message: "progress events must carry a subagent",
768
+ path: ["subagent"]
769
+ });
770
+ }
771
+ if ((envelope.ev.t === "start" || envelope.ev.t === "stop" || envelope.ev.t === "progress") && envelope.role !== "agent") {
729
772
  ctx.addIssue({
730
773
  code: z.ZodIssueCode.custom,
731
774
  message: `${envelope.ev.t} events must use role "agent"`,
@@ -815,7 +858,11 @@ const UpdateSessionBodySchema = z.object({
815
858
  t: z.literal("update-session"),
816
859
  id: z.string(),
817
860
  metadata: VersionedEncryptedValueSchema.nullish(),
818
- agentState: VersionedNullableEncryptedValueSchema.nullish()
861
+ agentState: VersionedNullableEncryptedValueSchema.nullish(),
862
+ /** B-265: server-owned archive intent (ms epoch, null = not archived).
863
+ * Present only on the archive / unarchive transitions; absent elsewhere.
864
+ * Old clients strip it (default z.object) and fall back to `active`. */
865
+ archivedAt: z.number().nullable().optional()
819
866
  });
820
867
  const VersionedMachineEncryptedValueSchema = z.object({
821
868
  version: z.number(),
@@ -954,7 +1001,9 @@ const WebTerminalListItemSchema = z$1.object({
954
1001
  mirrorSessionId: z$1.string().optional(),
955
1002
  /** B-150: auto-restored after a restart (ms epoch) — the directory and the
956
1003
  * conversation carried over, the processes did not. Cleared once opened. */
957
- restoredAt: z$1.number().optional()
1004
+ restoredAt: z$1.number().optional(),
1005
+ /** B-265: @vh_title_manual is set (user renamed it). */
1006
+ manual: z$1.boolean().optional()
958
1007
  });
959
1008
  const ClosedTerminalRecordSchema = z$1.object({
960
1009
  id: z$1.string(),
@@ -968,6 +1017,9 @@ const ClosedTerminalRecordSchema = z$1.object({
968
1017
  /** B-149: 'daemon-gap' = died while no daemon was watching (restart/reboot);
969
1018
  * absent means an ordinary observed close. */
970
1019
  reason: z$1.enum(["closed", "daemon-gap"]).optional(),
1020
+ /** B-265: tags + manual-rename flag at close time (restored verbatim). */
1021
+ tags: z$1.array(z$1.string()).optional(),
1022
+ manual: z$1.boolean().optional(),
971
1023
  closedAt: z$1.number()
972
1024
  });
973
1025
  z$1.object({
@@ -1012,6 +1064,15 @@ z$1.object({
1012
1064
  * ignore the field; old daemons never write it (web renders nothing).
1013
1065
  */
1014
1066
  closedTerminals: z$1.array(ClosedTerminalRecordSchema).optional(),
1067
+ /**
1068
+ * B-265 capability flag: this daemon answers `restore-terminal`. Same trust
1069
+ * rule as webTerminals — only `detectedAt >= startedAt` counts, because a
1070
+ * downgraded daemon spreads the stale field forward on connect.
1071
+ */
1072
+ terminalRestore: z$1.object({
1073
+ rpcAvailable: z$1.boolean(),
1074
+ detectedAt: z$1.number()
1075
+ }).optional(),
1015
1076
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1016
1077
  cliUpdate: CliUpdateStateSchema.optional()
1017
1078
  });
@@ -2149,6 +2210,69 @@ function shouldReconnect() {
2149
2210
  return true;
2150
2211
  }
2151
2212
 
2213
+ const SUBAGENT_RESULT_MAX_CHARS = 16384;
2214
+ const SUBAGENT_PROGRESS_MIN_INTERVAL_MS = 5e3;
2215
+ function capSubagentText(text, max = SUBAGENT_RESULT_MAX_CHARS) {
2216
+ const trimmed = text.trim();
2217
+ if (trimmed.length <= max) return { text: trimmed };
2218
+ return { text: trimmed.slice(0, max), truncated: true };
2219
+ }
2220
+ function toolUseResultToText(toolUseResult) {
2221
+ if (!toolUseResult || typeof toolUseResult !== "object") return null;
2222
+ const content = toolUseResult.content;
2223
+ if (typeof content === "string") return content;
2224
+ if (!Array.isArray(content)) return null;
2225
+ const parts = [];
2226
+ for (const block of content) {
2227
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
2228
+ parts.push(block.text);
2229
+ }
2230
+ }
2231
+ return parts.length > 0 ? parts.join("\n") : null;
2232
+ }
2233
+ function toolUseResultStatus(toolUseResult) {
2234
+ if (!toolUseResult || typeof toolUseResult !== "object") return "unknown";
2235
+ const status = toolUseResult.status;
2236
+ if (status === "async_launched") return "async_launched";
2237
+ if (status === "completed") return "completed";
2238
+ return "unknown";
2239
+ }
2240
+ function toolUseResultStats(toolUseResult) {
2241
+ if (!toolUseResult || typeof toolUseResult !== "object") return void 0;
2242
+ const r = toolUseResult;
2243
+ const stats = {};
2244
+ if (typeof r.totalToolUseCount === "number") stats.toolUses = r.totalToolUseCount;
2245
+ if (typeof r.totalTokens === "number") stats.totalTokens = r.totalTokens;
2246
+ if (typeof r.totalDurationMs === "number") stats.durationMs = r.totalDurationMs;
2247
+ if (r.toolStats && typeof r.toolStats === "object") stats.toolStats = r.toolStats;
2248
+ return Object.keys(stats).length > 0 ? stats : void 0;
2249
+ }
2250
+ function parseTaskNotificationText(text) {
2251
+ if (!/<task-notification>/i.test(text)) return null;
2252
+ const pick = (tag) => text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "i"))?.[1] ?? null;
2253
+ const rawStatus = pick("status")?.trim().toLowerCase() ?? null;
2254
+ const status = rawStatus === "completed" || rawStatus === "failed" || rawStatus === "stopped" ? rawStatus : null;
2255
+ let resultText = pick("result");
2256
+ let result = null;
2257
+ if (resultText !== null) {
2258
+ resultText = resultText.replace(/^\s*\[harness:[^\]]*\]\s*/i, "").replace(/<usage>[\s\S]*?<\/usage>/gi, "").trim();
2259
+ if (resultText.length > 0) result = capSubagentText(resultText);
2260
+ }
2261
+ return {
2262
+ toolUseId: pick("tool-use-id")?.trim() || null,
2263
+ taskId: pick("task-id")?.trim() || null,
2264
+ status,
2265
+ summary: pick("summary")?.trim() || null,
2266
+ result
2267
+ };
2268
+ }
2269
+ function shouldEmitProgress(previous, toolUses, now) {
2270
+ if (!previous) return true;
2271
+ if (previous.lastToolUses === toolUses) return false;
2272
+ return now - previous.lastAt >= SUBAGENT_PROGRESS_MIN_INTERVAL_MS;
2273
+ }
2274
+
2275
+ const SUBAGENT_BUFFER_MAX_MESSAGES = 100;
2152
2276
  function isSubagentTool(name) {
2153
2277
  return name === "Task" || name === "Agent";
2154
2278
  }
@@ -2217,6 +2341,7 @@ function bufferSubagentMessage(state, subagent, message) {
2217
2341
  const buffer = getBufferedSubagentMessages(state);
2218
2342
  const queue = buffer.get(subagent) ?? [];
2219
2343
  queue.push(message);
2344
+ while (queue.length > SUBAGENT_BUFFER_MAX_MESSAGES) queue.shift();
2220
2345
  buffer.set(subagent, queue);
2221
2346
  }
2222
2347
  function consumeBufferedSubagentMessages(state, subagent) {
@@ -2237,6 +2362,31 @@ function getActiveSubagents(state) {
2237
2362
  }
2238
2363
  return state.activeSubagents;
2239
2364
  }
2365
+ function getAgentToolCalls(state) {
2366
+ if (!state.agentToolCalls) state.agentToolCalls = /* @__PURE__ */ new Set();
2367
+ return state.agentToolCalls;
2368
+ }
2369
+ function getSubagentMeta(state) {
2370
+ if (!state.subagentMeta) state.subagentMeta = /* @__PURE__ */ new Map();
2371
+ return state.subagentMeta;
2372
+ }
2373
+ function getStubSubagents(state) {
2374
+ if (!state.stubSubagents) state.stubSubagents = /* @__PURE__ */ new Set();
2375
+ return state.stubSubagents;
2376
+ }
2377
+ function getProgressThrottle(state) {
2378
+ if (!state.progressThrottle) state.progressThrottle = /* @__PURE__ */ new Map();
2379
+ return state.progressThrottle;
2380
+ }
2381
+ function nowOf(state) {
2382
+ return state.now ? state.now() : Date.now();
2383
+ }
2384
+ function rememberSubagentMeta(state, subagent, meta) {
2385
+ const existing = getSubagentMeta(state).get(subagent) ?? {};
2386
+ const description = typeof meta.description === "string" && meta.description.trim() ? meta.description.trim() : existing.description;
2387
+ const subagentType = typeof meta.subagentType === "string" && meta.subagentType.trim() ? meta.subagentType.trim() : existing.subagentType;
2388
+ getSubagentMeta(state).set(subagent, { ...description ? { description } : {}, ...subagentType ? { subagentType } : {} });
2389
+ }
2240
2390
  function pickAssistantUsage(message) {
2241
2391
  if (message.type !== "assistant") return void 0;
2242
2392
  const usage = message.message?.usage;
@@ -2413,13 +2563,26 @@ function maybeEmitSubagentStart(state, turn, subagent, envelopes) {
2413
2563
  return;
2414
2564
  }
2415
2565
  const title = getSubagentTitles(state).get(subagent);
2566
+ const meta = getSubagentMeta(state).get(subagent);
2416
2567
  envelopes.push(createEnvelope("agent", {
2417
2568
  t: "start",
2418
- ...title ? { title } : {}
2569
+ ...title ? { title } : {},
2570
+ ...meta?.description ? { description: meta.description } : {},
2571
+ ...meta?.subagentType ? { subagentType: meta.subagentType } : {}
2419
2572
  }, { turn, subagent }));
2420
2573
  started.add(subagent);
2421
2574
  getActiveSubagents(state).add(subagent);
2422
2575
  }
2576
+ function emitSubagentStopWithStatus(state, turn, subagent, envelopes, payload) {
2577
+ envelopes.push(createEnvelope("agent", {
2578
+ t: "stop",
2579
+ ...payload.status ? { status: payload.status } : {},
2580
+ ...payload.result ? { result: payload.result } : {},
2581
+ ...payload.usage ? { usage: payload.usage } : {}
2582
+ }, { ...turn ? { turn } : {}, subagent }));
2583
+ getActiveSubagents(state).delete(subagent);
2584
+ getStartedSubagents(state).delete(subagent);
2585
+ }
2423
2586
  function maybeEmitSubagentStop(state, turn, subagent, envelopes) {
2424
2587
  const active = getActiveSubagents(state);
2425
2588
  if (!active.has(subagent)) {
@@ -2431,8 +2594,6 @@ function maybeEmitSubagentStop(state, turn, subagent, envelopes) {
2431
2594
  function clearSubagentTracking(state) {
2432
2595
  getUuidToProviderSubagent(state).clear();
2433
2596
  getTaskPromptToSubagents(state).clear();
2434
- getProviderSubagentToSessionSubagent(state).clear();
2435
- getSubagentTitles(state).clear();
2436
2597
  getBufferedSubagentMessages(state).clear();
2437
2598
  getHiddenParentToolCalls(state).clear();
2438
2599
  getStartedSubagents(state).clear();
@@ -2493,6 +2654,58 @@ function closeClaudeTurnWithStatus(state, status, meta) {
2493
2654
  function mapClaudeLogMessageToSessionEnvelopes(message, state) {
2494
2655
  return mapClaudeLogMessageToSessionEnvelopesInternal(message, state);
2495
2656
  }
2657
+ function mapTaskLifecycleSystemMessage(message, state, envelopes) {
2658
+ const raw = message;
2659
+ const subtype = typeof raw.subtype === "string" ? raw.subtype : "";
2660
+ if (!subtype.startsWith("task_")) return;
2661
+ if (raw.skip_transcript === true) return;
2662
+ const toolUseId = typeof raw.tool_use_id === "string" && raw.tool_use_id.length > 0 ? raw.tool_use_id : null;
2663
+ if (!toolUseId || !getAgentToolCalls(state).has(toolUseId)) return;
2664
+ const subagent = ensureSessionSubagentIdForProviderSubagent(state, toolUseId);
2665
+ const usage = raw.usage && typeof raw.usage === "object" ? {
2666
+ ...typeof raw.usage.tool_uses === "number" ? { toolUses: raw.usage.tool_uses } : {},
2667
+ ...typeof raw.usage.total_tokens === "number" ? { totalTokens: raw.usage.total_tokens } : {},
2668
+ ...typeof raw.usage.duration_ms === "number" ? { durationMs: raw.usage.duration_ms } : {}
2669
+ } : void 0;
2670
+ if (subtype === "task_started") {
2671
+ rememberSubagentMeta(state, subagent, { description: raw.description, subagentType: raw.subagent_type });
2672
+ const turn = ensureTurn(state, envelopes);
2673
+ getStartedSubagents(state).delete(subagent);
2674
+ maybeEmitSubagentStart(state, turn, subagent, envelopes);
2675
+ return;
2676
+ }
2677
+ if (subtype === "task_progress") {
2678
+ const toolUses = typeof raw.usage?.tool_uses === "number" ? raw.usage.tool_uses : 0;
2679
+ const throttle = getProgressThrottle(state);
2680
+ const now = nowOf(state);
2681
+ if (!shouldEmitProgress(throttle.get(subagent), toolUses, now)) return;
2682
+ throttle.set(subagent, { lastAt: now, lastToolUses: toolUses });
2683
+ const turn = ensureTurn(state, envelopes);
2684
+ maybeEmitSubagentStart(state, turn, subagent, envelopes);
2685
+ envelopes.push(createEnvelope("agent", {
2686
+ t: "progress",
2687
+ toolUses,
2688
+ ...typeof raw.last_tool_name === "string" && raw.last_tool_name ? { lastTool: raw.last_tool_name } : {},
2689
+ ...usage?.totalTokens !== void 0 ? { totalTokens: usage.totalTokens } : {},
2690
+ ...usage?.durationMs !== void 0 ? { durationMs: usage.durationMs } : {},
2691
+ ...typeof raw.summary === "string" && raw.summary.trim() ? { summary: raw.summary.trim() } : {}
2692
+ }, { turn, subagent }));
2693
+ return;
2694
+ }
2695
+ if (subtype === "task_notification") {
2696
+ const status = raw.status === "completed" || raw.status === "failed" || raw.status === "stopped" ? raw.status : "completed";
2697
+ emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagent, envelopes, {
2698
+ status,
2699
+ ...usage ? { usage } : {}
2700
+ });
2701
+ getStubSubagents(state).delete(subagent);
2702
+ getProgressThrottle(state).delete(subagent);
2703
+ return;
2704
+ }
2705
+ if (subtype === "task_updated" && raw.patch?.is_backgrounded === true) {
2706
+ getStubSubagents(state).add(subagent);
2707
+ }
2708
+ }
2496
2709
  function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2497
2710
  const envelopes = [];
2498
2711
  const claudeUuid = pickUuid(message);
@@ -2513,6 +2726,7 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2513
2726
  };
2514
2727
  }
2515
2728
  if (message.type === "system") {
2729
+ mapTaskLifecycleSystemMessage(message, state, envelopes);
2516
2730
  return {
2517
2731
  currentTurnId: state.currentTurnId,
2518
2732
  envelopes
@@ -2554,6 +2768,9 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2554
2768
  queueTaskPromptSubagent(state, prompt, call);
2555
2769
  }
2556
2770
  setSubagentTitle(state, sessionSubagentForCall, pickTaskTitle(block.input) ?? prompt);
2771
+ getAgentToolCalls(state).add(call);
2772
+ const input = block.input ?? {};
2773
+ rememberSubagentMeta(state, sessionSubagentForCall, { description: input.description, subagentType: input.subagent_type });
2557
2774
  }
2558
2775
  if (shouldHideParentToolCall(name)) {
2559
2776
  getHiddenParentToolCalls(state).add(call);
@@ -2598,6 +2815,18 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2598
2815
  maybeEmitSubagentStart(state, turnId2, subagent, envelopes);
2599
2816
  envelopes.push(createEnvelope("agent", { t: "text", text: message.message.content }, { turn: turnId2, subagent, claudeUuid }));
2600
2817
  } else {
2818
+ const origin = message.origin;
2819
+ if (origin?.kind === "task-notification" || /<task-notification>/i.test(message.message.content)) {
2820
+ const fields = parseTaskNotificationText(message.message.content);
2821
+ const providerId = fields?.toolUseId ?? null;
2822
+ const subagentForNotification = providerId && getAgentToolCalls(state).has(providerId) ? getSessionSubagentIdForProviderSubagent(state, providerId) : void 0;
2823
+ if (fields && subagentForNotification && (fields.result || fields.status)) {
2824
+ emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagentForNotification, envelopes, {
2825
+ ...fields.status ? { status: fields.status } : {},
2826
+ ...fields.result ? { result: fields.result } : {}
2827
+ });
2828
+ }
2829
+ }
2601
2830
  closeTurn(state, "completed", envelopes);
2602
2831
  envelopes.push(createEnvelope("user", { t: "text", text: message.message.content }, { claudeUuid }));
2603
2832
  }
@@ -2628,6 +2857,31 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2628
2857
  getHiddenParentToolCalls(state).delete(block.tool_use_id);
2629
2858
  continue;
2630
2859
  }
2860
+ if (sessionSubagentForToolResult && getAgentToolCalls(state).has(block.tool_use_id)) {
2861
+ const toolUseResult = message.tool_use_result;
2862
+ const resultStatus = toolUseResultStatus(toolUseResult);
2863
+ if (resultStatus === "async_launched") {
2864
+ getStubSubagents(state).add(sessionSubagentForToolResult);
2865
+ envelopes.push(createEnvelope("agent", { t: "tool-call-end", call: block.tool_use_id }, { turn: turnId, subagent }));
2866
+ continue;
2867
+ }
2868
+ if (resultStatus === "completed") {
2869
+ const text = toolUseResultToText(toolUseResult);
2870
+ const stats = toolUseResultStats(toolUseResult);
2871
+ const result = text ? capSubagentText(text) : void 0;
2872
+ emitSubagentStopWithStatus(state, turnId, sessionSubagentForToolResult, envelopes, {
2873
+ status: "completed",
2874
+ ...result ? { result } : {},
2875
+ ...stats ? { usage: { toolUses: stats.toolUses, totalTokens: stats.totalTokens, durationMs: stats.durationMs } } : {}
2876
+ });
2877
+ envelopes.push(createEnvelope("agent", {
2878
+ t: "tool-call-end",
2879
+ call: block.tool_use_id,
2880
+ ...result ? { result: { ...result, ...stats ? { stats } : {} } } : {}
2881
+ }, { turn: turnId, subagent }));
2882
+ continue;
2883
+ }
2884
+ }
2631
2885
  if (sessionSubagentForToolResult) {
2632
2886
  maybeEmitSubagentStop(state, turnId, sessionSubagentForToolResult, envelopes);
2633
2887
  }
@@ -2845,10 +3099,19 @@ class ApiSessionClient extends EventEmitter {
2845
3099
  directInboundLocalIds = /* @__PURE__ */ new Set();
2846
3100
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2847
3101
  handoverInFlight = null;
2848
- constructor(token, session) {
3102
+ /** B-265 reconnect: the message cursor was seeded from the server, so
3103
+ * nothing before it is history to replay — but socket fast-path routing
3104
+ * must wait for the first fetch, otherwise a message that lands between
3105
+ * the seed and the first fetch is routed twice (socket + fetch). */
3106
+ awaitingInitialFetch = false;
3107
+ constructor(token, session, opts) {
2849
3108
  super();
2850
3109
  this.token = token;
2851
3110
  this.sessionId = session.id;
3111
+ if (opts?.initialSeq !== void 0 && opts.initialSeq >= 0) {
3112
+ this.lastSeq = opts.initialSeq;
3113
+ this.awaitingInitialFetch = true;
3114
+ }
2852
3115
  this.metadata = session.metadata;
2853
3116
  this.metadataVersion = session.metadataVersion;
2854
3117
  this.agentState = session.agentState;
@@ -2908,7 +3171,7 @@ class ApiSessionClient extends EventEmitter {
2908
3171
  }
2909
3172
  if (data.body.t === "new-message") {
2910
3173
  const messageSeq = data.body.message?.seq;
2911
- if (this.lastSeq === 0) {
3174
+ if (this.lastSeq === 0 || this.awaitingInitialFetch) {
2912
3175
  this.receiveSync.invalidate();
2913
3176
  return;
2914
3177
  }
@@ -3204,6 +3467,9 @@ class ApiSessionClient extends EventEmitter {
3204
3467
  if (message.localId && this.directInboundLocalIds.delete(message.localId)) {
3205
3468
  continue;
3206
3469
  }
3470
+ if (message.localId && this.routedInboundLocalIds.has(message.localId)) {
3471
+ continue;
3472
+ }
3207
3473
  if (message.content?.t !== "encrypted") {
3208
3474
  continue;
3209
3475
  }
@@ -3233,6 +3499,7 @@ class ApiSessionClient extends EventEmitter {
3233
3499
  break;
3234
3500
  }
3235
3501
  }
3502
+ this.awaitingInitialFetch = false;
3236
3503
  }
3237
3504
  static MAX_OUTBOX_BATCH_SIZE = 50;
3238
3505
  async flushOutbox() {
@@ -4480,6 +4747,101 @@ function registerTodoHandlers(manager) {
4480
4747
  });
4481
4748
  }
4482
4749
 
4750
+ function sanitizeTagList(value) {
4751
+ if (!Array.isArray(value)) return void 0;
4752
+ const out = [];
4753
+ for (const tag of value) {
4754
+ if (typeof tag !== "string") continue;
4755
+ const clean = tag.trim();
4756
+ if (clean && !out.includes(clean)) out.push(clean);
4757
+ if (out.length >= 64) break;
4758
+ }
4759
+ return out;
4760
+ }
4761
+ const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
4762
+ const LIVE_SNAPSHOT_MAX = 100;
4763
+ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
4764
+ const out = /* @__PURE__ */ new Map();
4765
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
4766
+ const rows = [];
4767
+ for (const [id, value] of Object.entries(raw)) {
4768
+ if (!id || !value || typeof value !== "object") continue;
4769
+ const { title, cwd, seenAt, tags, manual } = value;
4770
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
4771
+ if (now - seenAt >= ttlMs) continue;
4772
+ const tagList = sanitizeTagList(tags);
4773
+ rows.push([id, {
4774
+ title: typeof title === "string" && title.trim() ? title : void 0,
4775
+ cwd: typeof cwd === "string" && cwd ? cwd : void 0,
4776
+ ...tagList !== void 0 ? { tags: tagList } : {},
4777
+ ...manual === true ? { manual: true } : {},
4778
+ seenAt
4779
+ }]);
4780
+ }
4781
+ rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
4782
+ for (const [id, info] of rows.slice(0, max)) out.set(id, info);
4783
+ return out;
4784
+ }
4785
+ function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
4786
+ const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
4787
+ const out = {};
4788
+ for (const [id, info] of rows) out[id] = info;
4789
+ return out;
4790
+ }
4791
+ function liveSnapshotChanged(prev, next) {
4792
+ if (prev.size !== next.size) return true;
4793
+ for (const [id, info] of next) {
4794
+ const before = prev.get(id);
4795
+ if (!before) return true;
4796
+ if (before.title !== info.title || before.cwd !== info.cwd) return true;
4797
+ if (!!before.manual !== !!info.manual) return true;
4798
+ if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
4799
+ }
4800
+ return false;
4801
+ }
4802
+ const CLAUDE_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
4803
+ function isClaudeSessionId(value) {
4804
+ return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
4805
+ }
4806
+ function pickMirrorForTerminal(sessions, terminalId) {
4807
+ if (!terminalId) return null;
4808
+ let best = null;
4809
+ for (const [id, session] of Object.entries(sessions || {})) {
4810
+ const meta = session?.metadata;
4811
+ if (!meta || meta.terminalId !== terminalId) continue;
4812
+ const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
4813
+ if (best && savedAt <= best.savedAt) continue;
4814
+ best = {
4815
+ id,
4816
+ savedAt,
4817
+ claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
4818
+ };
4819
+ }
4820
+ return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
4821
+ }
4822
+
4823
+ const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
4824
+ function planTerminalRestore(record, facts) {
4825
+ if (facts.tmuxAlive) return { kind: "already-live" };
4826
+ if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
4827
+ const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
4828
+ return {
4829
+ kind: "create",
4830
+ terminalId: record.id,
4831
+ cwd: record.cwd,
4832
+ title: record.title,
4833
+ manual: record.manual === true,
4834
+ tags: record.tags,
4835
+ ...resumable ? { command: `claude --resume ${record.claudeSessionId}` } : {}
4836
+ };
4837
+ }
4838
+
4839
+ function getProjectPath(workingDirectory) {
4840
+ const projectId = resolve$1(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
4841
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
4842
+ return join(claudeConfigDir, "projects", projectId);
4843
+ }
4844
+
4483
4845
  var xtermHeadless = {};
4484
4846
 
4485
4847
  var hasRequiredXtermHeadless;
@@ -9530,63 +9892,6 @@ function sendTerminalNotification(opts) {
9530
9892
  });
9531
9893
  }
9532
9894
 
9533
- const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
9534
- const LIVE_SNAPSHOT_MAX = 100;
9535
- function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
9536
- const out = /* @__PURE__ */ new Map();
9537
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
9538
- const rows = [];
9539
- for (const [id, value] of Object.entries(raw)) {
9540
- if (!id || !value || typeof value !== "object") continue;
9541
- const { title, cwd, seenAt } = value;
9542
- if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
9543
- if (now - seenAt >= ttlMs) continue;
9544
- rows.push([id, {
9545
- title: typeof title === "string" && title.trim() ? title : void 0,
9546
- cwd: typeof cwd === "string" && cwd ? cwd : void 0,
9547
- seenAt
9548
- }]);
9549
- }
9550
- rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
9551
- for (const [id, info] of rows.slice(0, max)) out.set(id, info);
9552
- return out;
9553
- }
9554
- function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
9555
- const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
9556
- const out = {};
9557
- for (const [id, info] of rows) out[id] = info;
9558
- return out;
9559
- }
9560
- function liveSnapshotChanged(prev, next) {
9561
- if (prev.size !== next.size) return true;
9562
- for (const [id, info] of next) {
9563
- const before = prev.get(id);
9564
- if (!before) return true;
9565
- if (before.title !== info.title || before.cwd !== info.cwd) return true;
9566
- }
9567
- return false;
9568
- }
9569
- const CLAUDE_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
9570
- function isClaudeSessionId(value) {
9571
- return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
9572
- }
9573
- function pickMirrorForTerminal(sessions, terminalId) {
9574
- if (!terminalId) return null;
9575
- let best = null;
9576
- for (const [id, session] of Object.entries(sessions || {})) {
9577
- const meta = session?.metadata;
9578
- if (!meta || meta.terminalId !== terminalId) continue;
9579
- const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
9580
- if (best && savedAt <= best.savedAt) continue;
9581
- best = {
9582
- id,
9583
- savedAt,
9584
- claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
9585
- };
9586
- }
9587
- return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
9588
- }
9589
-
9590
9895
  const AUTO_RESTORE_DEFAULT_MAX = 6;
9591
9896
  const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
9592
9897
  const AUTO_RESTORE_HARD_MAX = 20;
@@ -9645,6 +9950,8 @@ function selectAutoRestore(candidates, opts) {
9645
9950
  }
9646
9951
  plans.push({
9647
9952
  terminalId: c.id,
9953
+ ...c.tags !== void 0 ? { tags: c.tags } : {},
9954
+ ...c.manual ? { manual: true } : {},
9648
9955
  cwd: c.cwd,
9649
9956
  title: c.title,
9650
9957
  claudeSessionId: c.claudeSessionId,
@@ -9693,10 +10000,11 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9693
10000
  const out = [];
9694
10001
  for (const item of raw) {
9695
10002
  if (!item || typeof item !== "object") continue;
9696
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt } = item;
10003
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
9697
10004
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
9698
10005
  if (seen.has(id)) continue;
9699
10006
  seen.add(id);
10007
+ const tagList = sanitizeTagList(tags);
9700
10008
  out.push({
9701
10009
  id,
9702
10010
  title: typeof title === "string" ? title : void 0,
@@ -9704,6 +10012,8 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9704
10012
  mirrorSessionId: typeof mirrorSessionId === "string" ? mirrorSessionId : void 0,
9705
10013
  claudeSessionId: typeof claudeSessionId === "string" ? claudeSessionId : void 0,
9706
10014
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10015
+ ...tagList !== void 0 ? { tags: tagList } : {},
10016
+ ...manual === true ? { manual: true } : {},
9707
10017
  closedAt
9708
10018
  });
9709
10019
  }
@@ -9827,6 +10137,9 @@ function startupInjectionArgs(tmuxSession, command) {
9827
10137
  ["send-keys", "-t", `=${tmuxSession}:`, "Enter"]
9828
10138
  ];
9829
10139
  }
10140
+ function seenInfoOf(t) {
10141
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10142
+ }
9830
10143
  function terminalListSignature(items) {
9831
10144
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
9832
10145
  t.id,
@@ -10463,7 +10776,7 @@ class WebTerminalManager {
10463
10776
  }
10464
10777
  /** Refresh the per-id {title, cwd} cache from an observed live list. */
10465
10778
  noteSeen(list) {
10466
- this.lastSeenInfo = new Map(list.map((t) => [t.id, { title: t.title, cwd: t.cwd }]));
10779
+ this.lastSeenInfo = new Map(list.map((t) => [t.id, seenInfoOf(t)]));
10467
10780
  }
10468
10781
  /** Record one terminal as closed (dedupe/cap in the pure module) and
10469
10782
  * persist. The confirming daemonState push rides the list refresh that
@@ -10537,7 +10850,7 @@ class WebTerminalManager {
10537
10850
  const liveIds = new Set(list.map((t) => t.id));
10538
10851
  this.reconcileRestoredSnapshot(liveIds);
10539
10852
  const next = new Map(
10540
- list.map((t) => [t.id, { title: t.title, cwd: t.cwd }])
10853
+ list.map((t) => [t.id, seenInfoOf(t)])
10541
10854
  );
10542
10855
  let changed = false;
10543
10856
  const now = Date.now();
@@ -10554,6 +10867,8 @@ class WebTerminalManager {
10554
10867
  id,
10555
10868
  title: info.title,
10556
10869
  cwd: info.cwd,
10870
+ tags: info.tags,
10871
+ manual: info.manual,
10557
10872
  // Live binding first (authoritative while it exists), persisted
10558
10873
  // metadata as the fallback once the mirror has been torn down.
10559
10874
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10604,6 +10919,8 @@ class WebTerminalManager {
10604
10919
  id,
10605
10920
  title: info.title,
10606
10921
  cwd: info.cwd,
10922
+ tags: info.tags,
10923
+ manual: info.manual,
10607
10924
  mirrorSessionId: mirror?.sessionId,
10608
10925
  claudeSessionId: mirror?.claudeSessionId,
10609
10926
  reason: "daemon-gap",
@@ -10614,6 +10931,8 @@ class WebTerminalManager {
10614
10931
  id,
10615
10932
  title: info.title,
10616
10933
  cwd: info.cwd,
10934
+ tags: info.tags,
10935
+ manual: info.manual,
10617
10936
  seenAt: info.seenAt,
10618
10937
  claudeSessionId: mirror?.claudeSessionId
10619
10938
  });
@@ -10689,6 +11008,51 @@ class WebTerminalManager {
10689
11008
  * exactly like a terminal whose pty was reaped.
10690
11009
  */
10691
11010
  restoreOneTerminal(plan) {
11011
+ return this.createDetachedTerminal(plan);
11012
+ }
11013
+ /**
11014
+ * B-265: `restore-terminal` — bring ONE closed terminal back with its
11015
+ * original id / cwd / title (+ manual flag) / tags and, when the record
11016
+ * knows a claude conversation whose JSONL is still on disk, `claude
11017
+ * --resume` injected. Idempotent: an id whose tmux session already exists
11018
+ * is a success. The closed record is left to `pruneClosedAgainstLive` (the
11019
+ * next tick sees the id live again); the tombstone is deliberately kept —
11020
+ * a live tmux session attaches regardless, and dropping it would re-open
11021
+ * the stale-client resurrection hole B-149 closed.
11022
+ */
11023
+ restoreClosedTerminal(terminalId) {
11024
+ if (typeof terminalId !== "string" || !TERMINAL_ID_RE$1.test(terminalId)) return { type: "error", reason: "invalid-id" };
11025
+ if (!isTmuxAvailable()) return { type: "error", reason: "no-tmux" };
11026
+ const record = this.closedTerminals.find((r) => r.id === terminalId);
11027
+ if (!record) return { type: "error", reason: "no-record" };
11028
+ const env = ptyEnv();
11029
+ const alive = spawnSync(
11030
+ "tmux",
11031
+ tmuxArgs(["has-session", "-t", `=vh-${terminalId}:`]),
11032
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11033
+ ).status === 0;
11034
+ const plan = planTerminalRestore(record, {
11035
+ tmuxAlive: alive,
11036
+ cwdExists: (cwd) => {
11037
+ try {
11038
+ return existsSync(cwd) && statSync(cwd).isDirectory();
11039
+ } catch {
11040
+ return false;
11041
+ }
11042
+ },
11043
+ conversationExists: (cwd, claudeSessionId) => existsSync(join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
11044
+ });
11045
+ if (plan.kind === "already-live") return { type: "success", terminalId };
11046
+ if (plan.kind === "error") return { type: "error", reason: plan.reason };
11047
+ if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
11048
+ logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
11049
+ this.kickListRefresh();
11050
+ return { type: "success", terminalId };
11051
+ }
11052
+ /** Shared cold-create for auto-restore (B-150) and restore-terminal
11053
+ * (B-265): same create-only env as the interactive path, title/tags
11054
+ * written back, optional startup command injected. */
11055
+ createDetachedTerminal(plan) {
10692
11056
  const env = ptyEnv();
10693
11057
  const name = `vh-${plan.terminalId}`;
10694
11058
  const exists = spawnSync(
@@ -10725,18 +11089,35 @@ class WebTerminalManager {
10725
11089
  tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title", plan.title]),
10726
11090
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
10727
11091
  );
11092
+ if (plan.manual) {
11093
+ spawnSync(
11094
+ "tmux",
11095
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title_manual", "1"]),
11096
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11097
+ );
11098
+ }
11099
+ }
11100
+ const tags = plan.tags !== void 0 ? validateTerminalTags(plan.tags) : void 0;
11101
+ if (tags && tags.length > 0) {
11102
+ spawnSync(
11103
+ "tmux",
11104
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_tags", JSON.stringify(tags)]),
11105
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11106
+ );
10728
11107
  }
10729
- for (const argv of startupInjectionArgs(name, plan.command)) {
10730
- spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11108
+ if (plan.command) {
11109
+ for (const argv of startupInjectionArgs(name, plan.command)) {
11110
+ spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11111
+ }
10731
11112
  }
10732
- logger.info(`[WEB TERMINAL] auto-restored ${plan.terminalId} in ${plan.cwd}`);
11113
+ logger.info(`[WEB TERMINAL] cold-created ${plan.terminalId} in ${plan.cwd}`);
10733
11114
  return true;
10734
11115
  }
10735
11116
  /** Mirror the in-memory cache to disk, but only when something a restart
10736
11117
  * would care about changed (see liveSnapshotChanged). */
10737
11118
  persistLiveSnapshot(live, now) {
10738
11119
  const candidate = /* @__PURE__ */ new Map();
10739
- for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, seenAt: now });
11120
+ for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
10740
11121
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
10741
11122
  this.lastPersistedSnapshot = candidate;
10742
11123
  saveLiveSnapshot(candidate);
@@ -11311,6 +11692,8 @@ class WebTerminalManager {
11311
11692
  id: terminalId,
11312
11693
  title: info.title,
11313
11694
  cwd: info.cwd,
11695
+ tags: info.tags,
11696
+ manual: info.manual,
11314
11697
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11315
11698
  claudeSessionId: mirror?.claudeSessionId,
11316
11699
  reason: "closed",
@@ -11394,7 +11777,8 @@ class WebTerminalManager {
11394
11777
  // tmux last-activity (epoch s) → ms; optional so old daemons
11395
11778
  // simply omit it and web clients fall back to createdAt.
11396
11779
  activityAt: s.activity,
11397
- agentState: this.probeAgentState(s.name, s.paneCurrentCommand)
11780
+ agentState: this.probeAgentState(s.name, s.paneCurrentCommand),
11781
+ manual: s.manual
11398
11782
  });
11399
11783
  }
11400
11784
  return out;
@@ -11844,12 +12228,6 @@ function detectResumeSupport(happyHomeDir = configuration.happyHomeDir) {
11844
12228
  };
11845
12229
  }
11846
12230
 
11847
- function getProjectPath(workingDirectory) {
11848
- const projectId = resolve$1(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
11849
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
11850
- return join(claudeConfigDir, "projects", projectId);
11851
- }
11852
-
11853
12231
  class ForkTruncateUuidNotFoundError extends Error {
11854
12232
  constructor(cutAfterUuid, sourcePath) {
11855
12233
  super(`Truncation UUID ${cutAfterUuid} not found in ${sourcePath}`);
@@ -13354,6 +13732,7 @@ class ApiMachineClient {
13354
13732
  cliUpdatePushChain = Promise.resolve();
13355
13733
  rpcHandlerManager;
13356
13734
  resumeSessionHandler = null;
13735
+ restartSessionHandler = null;
13357
13736
  stopSessionHandler = null;
13358
13737
  listTrackedSessionIds = null;
13359
13738
  reconnectInterval = null;
@@ -13420,11 +13799,13 @@ class ApiMachineClient {
13420
13799
  setRPCHandlers({
13421
13800
  spawnSession,
13422
13801
  resumeSession,
13802
+ restartSession,
13423
13803
  stopSession,
13424
13804
  listTrackedSessionIds,
13425
13805
  requestShutdown
13426
13806
  }) {
13427
13807
  this.resumeSessionHandler = resumeSession ?? null;
13808
+ this.restartSessionHandler = restartSession ?? null;
13428
13809
  this.stopSessionHandler = stopSession;
13429
13810
  this.listTrackedSessionIds = listTrackedSessionIds ?? null;
13430
13811
  this.rpcHandlerManager.registerHandler("spawn-happy-session", async (params) => {
@@ -13446,6 +13827,7 @@ class ApiMachineClient {
13446
13827
  }
13447
13828
  });
13448
13829
  this.syncResumeSessionRpcRegistration();
13830
+ this.syncRestartSessionRpcRegistration();
13449
13831
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13450
13832
  const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
13451
13833
  const result = await this.webTerminal.open({
@@ -13521,6 +13903,14 @@ class ApiMachineClient {
13521
13903
  this.encTerminals.delete(terminalId);
13522
13904
  return { type: "success" };
13523
13905
  });
13906
+ this.rpcHandlerManager.registerHandler("restore-terminal", async (params) => {
13907
+ const { terminalId } = params || {};
13908
+ const result = this.webTerminal.restoreClosedTerminal(terminalId);
13909
+ if (result.type === "error") {
13910
+ return { type: "error", reason: result.reason, errorMessage: `restore-terminal: ${result.reason}` };
13911
+ }
13912
+ return { type: "success", terminalId: result.terminalId };
13913
+ });
13524
13914
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
13525
13915
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
13526
13916
  });
@@ -13672,6 +14062,40 @@ class ApiMachineClient {
13672
14062
  return { message: "Daemon stop request acknowledged, starting shutdown sequence..." };
13673
14063
  });
13674
14064
  }
14065
+ // B-264: symmetric to resume, but the handler stops a live-but-broken
14066
+ // wrapper first. Old web never calls it; new web calling an old daemon that
14067
+ // lacks the handler gets "method not found" and shows an upgrade hint —
14068
+ // never a double-spawn.
14069
+ syncRestartSessionRpcRegistration() {
14070
+ const method = "restart-session";
14071
+ if (this.restartSessionHandler) {
14072
+ if (!this.rpcHandlerManager.hasHandler(method)) {
14073
+ this.rpcHandlerManager.registerHandler(method, async (params) => {
14074
+ const { sessionId, model, permissionMode } = params || {};
14075
+ if (!sessionId || typeof sessionId !== "string") {
14076
+ throw new Error("Session ID is required");
14077
+ }
14078
+ const handler = this.restartSessionHandler;
14079
+ if (!handler) {
14080
+ throw new Error("Restart session handler not available");
14081
+ }
14082
+ const result = await handler(sessionId, { model, permissionMode });
14083
+ switch (result.type) {
14084
+ case "success":
14085
+ return { type: "success", sessionId: result.sessionId };
14086
+ case "requestToApproveDirectoryCreation":
14087
+ return result;
14088
+ case "error":
14089
+ throw new Error(result.errorMessage);
14090
+ }
14091
+ });
14092
+ }
14093
+ return;
14094
+ }
14095
+ if (this.rpcHandlerManager.hasHandler(method)) {
14096
+ this.rpcHandlerManager.unregisterHandler(method);
14097
+ }
14098
+ }
13675
14099
  syncResumeSessionRpcRegistration() {
13676
14100
  const method = "resume-happy-session";
13677
14101
  if (this.resumeSessionHandler) {
@@ -13879,6 +14303,9 @@ class ApiMachineClient {
13879
14303
  httpPort: this.machine.daemonState?.httpPort,
13880
14304
  startedAt: now,
13881
14305
  webTerminals: { updatedAt: now, terminals: initialTerminals },
14306
+ // B-265 capability flag; restamped every connect so the
14307
+ // web's `detectedAt >= startedAt` trust rule holds.
14308
+ terminalRestore: { rpcAvailable: true, detectedAt: now },
13882
14309
  // B-084: closed records survive daemon restarts (persisted
13883
14310
  // in closed-terminals.json), so the connect snapshot ships
13884
14311
  // them too — not just the incremental pushes.
@@ -13888,6 +14315,7 @@ class ApiMachineClient {
13888
14315
  this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13889
14316
  if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13890
14317
  this.syncResumeSessionRpcRegistration();
14318
+ this.syncRestartSessionRpcRegistration();
13891
14319
  this.startKeepAlive();
13892
14320
  void this.reconcileArchivedSessions();
13893
14321
  void this.refreshRelayConnection();
@@ -14123,6 +14551,7 @@ class ApiMachineClient {
14123
14551
  if (this.relaySocket !== relaySocket) return;
14124
14552
  this.rpcHandlerManager.onSocketConnect(relaySocket);
14125
14553
  this.syncResumeSessionRpcRegistration();
14554
+ this.syncRestartSessionRpcRegistration();
14126
14555
  logger.debug(`[API MACHINE] Connected to regional relay ${selected.assignment.relayId}`);
14127
14556
  });
14128
14557
  relaySocket.on("disconnect", () => {
@@ -14897,8 +15326,8 @@ class ApiClient {
14897
15326
  throw error;
14898
15327
  }
14899
15328
  }
14900
- sessionSyncClient(session) {
14901
- return new ApiSessionClient(this.credential.token, session);
15329
+ sessionSyncClient(session, opts) {
15330
+ return new ApiSessionClient(this.credential.token, session, opts);
14902
15331
  }
14903
15332
  machineSyncClient(machine) {
14904
15333
  return new ApiMachineClient(this.credential.token, machine);
@@ -15074,6 +15503,56 @@ class ApiClient {
15074
15503
  return false;
15075
15504
  }
15076
15505
  }
15506
+ /**
15507
+ * B-265: one session by id — the message cursor (`seq`), metadata and
15508
+ * agent state with their versions, decrypted with the session key the
15509
+ * reconnecting process already holds. `unsupported` = the server has no
15510
+ * such route (or the row is gone): callers fall back to the legacy
15511
+ * skip-all-history reconnect. Transient failures are retried a few times
15512
+ * first, because falling back silently discards the very message a restore
15513
+ * is meant to deliver.
15514
+ */
15515
+ async getSession(sessionId, encryptionKey, encryptionVariant, opts) {
15516
+ const attempts = opts?.attempts ?? 3;
15517
+ const delays = opts?.delaysMs ?? [500, 1500];
15518
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
15519
+ try {
15520
+ const response = await axios.get(
15521
+ `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
15522
+ {
15523
+ headers: {
15524
+ "Authorization": `Bearer ${this.credential.token}`,
15525
+ "X-Happy-Client": `cli-coding-session/${configuration.currentCliVersion}`
15526
+ },
15527
+ timeout: opts?.timeoutMs ?? 4e3,
15528
+ validateStatus: () => true
15529
+ }
15530
+ );
15531
+ if (response.status === 404 || response.status === 401 || response.status === 403) {
15532
+ return { ok: false, reason: "unsupported" };
15533
+ }
15534
+ if (response.status >= 200 && response.status < 300) {
15535
+ const row = response.data.session;
15536
+ if (!row || typeof row.seq !== "number" || typeof row.metadata !== "string") {
15537
+ return { ok: false, reason: "unsupported" };
15538
+ }
15539
+ const metadata = decrypt(encryptionKey, encryptionVariant, decodeBase64(row.metadata));
15540
+ if (!metadata) return { ok: false, reason: "unavailable" };
15541
+ const agentState = row.agentState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(row.agentState)) : null;
15542
+ return {
15543
+ ok: true,
15544
+ session: { seq: row.seq, metadata, metadataVersion: row.metadataVersion, agentState, agentStateVersion: row.agentStateVersion }
15545
+ };
15546
+ }
15547
+ logger.debug(`[API] getSession ${sessionId}: status ${response.status} (attempt ${attempt + 1}/${attempts})`);
15548
+ } catch (error) {
15549
+ logger.debug(`[API] getSession ${sessionId} failed (attempt ${attempt + 1}/${attempts}):`, error);
15550
+ }
15551
+ const delayMs = delays[Math.min(attempt, delays.length - 1)];
15552
+ if (attempt < attempts - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
15553
+ }
15554
+ return { ok: false, reason: "unavailable" };
15555
+ }
15077
15556
  /** Clear the server-owned archive tombstone before an intentional resume.
15078
15557
  * Older servers return 404 because the endpoint does not exist; that is a
15079
15558
  * compatible no-op because those servers also have no durable tombstone. */
@@ -15165,4 +15644,4 @@ const RawJSONLinesSchema = z$1.discriminatedUnion("type", [
15165
15644
  }).passthrough()
15166
15645
  ]);
15167
15646
 
15168
- export { detectCLIAvailability as $, ApiClient as A, deriveLocalCliUpdateSummary as B, updateSettings as C, encodeBase64Url as D, decodeBase64 as E, writeCredentialsLegacy as F, writeCredentialsDataKey as G, readCredentialsForConfiguredRelay as H, InvalidateSync as I, encrypt as J, getLocalHappyAgentCredentialPath as K, readLocalHappyAgentCredentials as L, MAX_CHAT_ATTACHMENT_SOURCE_BYTES as M, decryptWithDataKey as N, decryptLegacy as O, extractCompleteLines as P, decideBackfill as Q, RawJSONLinesSchema as R, parseTerminalHookPayload as S, tmuxArgs as T, scrubTmuxClientEnv as U, decideMirrorBinding as V, persistSession as W, MIRROR_BACKFILL_LINES_DEFAULT as X, mirrorLineKey as Y, mirrorLocalId as Z, detectResumeSupport as _, ApiSessionClient as a, acquireDaemonLock as a0, summarizeSpawnSessionForLog as a1, deletePersistedSessions as a2, decrypt as a3, writeDaemonState as a4, fetchCliUpdateState as a5, resolveCliUpdateCheckInterval as a6, DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS as a7, releaseDaemonLock as a8, isValidSessionId as a9, listVhTerminals as aa, isValidTerminalId as ab, readVhTerminal as ac, sendToVhTerminal as ad, startOfflineReconnection as ae, clearCredentials as af, clearMachineId as ag, SandboxConfigSchema as ah, CodexAppServerClient as ai, getLatestDaemonLog as aj, persistence as ak, api as al, createEnvelope as b, configuration as c, contentLogMetadata as d, errorLogMetadata as e, packageJson as f, delay as g, connectionState as h, readSettings as i, encodeBase64 as j, ensurePrivateDirectorySync as k, logger as l, hardenPrivateFileSync as m, readDaemonState as n, clearDaemonState as o, projectPath as p, getProjectPath as q, readPersistedSessions as r, initializeSandbox as s, wrapCommand as t, AsyncLock as u, ensurePrivateDirectory as v, writePrivateFileSync as w, hardenPrivateFile as x, readCredentials as y, credentialRelayProblem as z };
15647
+ export { detectResumeSupport as $, ApiClient as A, deriveLocalCliUpdateSummary as B, updateSettings as C, encodeBase64Url as D, decodeBase64 as E, writeCredentialsLegacy as F, writeCredentialsDataKey as G, readCredentialsForConfiguredRelay as H, InvalidateSync as I, encrypt as J, isClaudeSessionId as K, getLocalHappyAgentCredentialPath as L, MAX_CHAT_ATTACHMENT_SOURCE_BYTES as M, readLocalHappyAgentCredentials as N, decryptWithDataKey as O, decryptLegacy as P, extractCompleteLines as Q, RawJSONLinesSchema as R, decideBackfill as S, parseTerminalHookPayload as T, tmuxArgs as U, scrubTmuxClientEnv as V, decideMirrorBinding as W, persistSession as X, MIRROR_BACKFILL_LINES_DEFAULT as Y, mirrorLineKey as Z, mirrorLocalId as _, ApiSessionClient as a, detectCLIAvailability as a0, acquireDaemonLock as a1, summarizeSpawnSessionForLog as a2, deletePersistedSessions as a3, decrypt as a4, writeDaemonState as a5, fetchCliUpdateState as a6, resolveCliUpdateCheckInterval as a7, DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS as a8, releaseDaemonLock as a9, isValidSessionId as aa, listVhTerminals as ab, isValidTerminalId as ac, readVhTerminal as ad, sendToVhTerminal as ae, startOfflineReconnection as af, clearCredentials as ag, clearMachineId as ah, SandboxConfigSchema as ai, CodexAppServerClient as aj, getLatestDaemonLog as ak, persistence as al, api as am, createEnvelope as b, configuration as c, contentLogMetadata as d, errorLogMetadata as e, packageJson as f, delay as g, connectionState as h, readSettings as i, encodeBase64 as j, ensurePrivateDirectorySync as k, logger as l, hardenPrivateFileSync as m, readDaemonState as n, clearDaemonState as o, projectPath as p, getProjectPath as q, readPersistedSessions as r, initializeSandbox as s, wrapCommand as t, AsyncLock as u, ensurePrivateDirectory as v, writePrivateFileSync as w, hardenPrivateFile as x, readCredentials as y, credentialRelayProblem as z };