@paradow/ledge 0.15.1 → 0.16.1

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.
@@ -26,8 +26,9 @@ import {
26
26
  readPeers,
27
27
  reduce,
28
28
  repositoryId,
29
- shownCommand
30
- } from "./chunk-QCEVJMXI.js";
29
+ shownCommand,
30
+ stripNote
31
+ } from "./chunk-DBSCO3HI.js";
31
32
  import {
32
33
  PARENT_AGENT_VARS,
33
34
  agentEnv,
@@ -35,8 +36,10 @@ import {
35
36
  claudeOpenCall,
36
37
  claudePlan,
37
38
  claudeToolAnswered,
39
+ labelTranscript,
40
+ resumeLabel,
38
41
  spawnSession
39
- } from "./chunk-KGEF6OIU.js";
42
+ } from "./chunk-H36JD4E3.js";
40
43
  import {
41
44
  CHAT_HISTORY_BUDGET,
42
45
  DEFAULT_PROJECTS,
@@ -85,6 +88,7 @@ import {
85
88
  offers,
86
89
  padPlain,
87
90
  parseClientMessage,
91
+ parseCloseBlock,
88
92
  parseProjectBlock,
89
93
  parseProposalBlock,
90
94
  parseRelayBlock,
@@ -96,15 +100,15 @@ import {
96
100
  pushEndpointAllowed,
97
101
  requestAuthorized,
98
102
  selectDaemonProtocol
99
- } from "./chunk-2LQITRTR.js";
103
+ } from "./chunk-Q5WWH7FG.js";
100
104
  import {
101
105
  LEDGE_VERSION
102
- } from "./chunk-LKQ5DDQ4.js";
106
+ } from "./chunk-J4ICZEGP.js";
103
107
  import {
104
108
  SERVICE,
105
109
  inDaemonService,
106
110
  tmuxServerAtRisk
107
- } from "./chunk-ZANOMSLW.js";
111
+ } from "./chunk-S4ANPQGA.js";
108
112
  import {
109
113
  realTmux,
110
114
  runFile
@@ -567,7 +571,8 @@ var History = class {
567
571
  this.turns.set(id, turn + 1);
568
572
  let last;
569
573
  messages.forEach((m, i) => {
570
- last = this.receive(id, { kind: "received", id: `recv-${turn + 1}-${i}`, at: ev.at, turn: turn + 1, from: m.name ?? m.from, text: m.text }, m);
574
+ const { sender, text } = stripNote(m.text);
575
+ last = this.receive(id, { kind: "received", id: `recv-${turn + 1}-${i}`, at: ev.at, turn: turn + 1, from: sender ?? m.name ?? m.from, text }, { ...m, ...sender ? { sender } : {} });
571
576
  });
572
577
  return last;
573
578
  }
@@ -625,9 +630,13 @@ var History = class {
625
630
  return this.put(id, { kind: "permission", id: `perm-${ev.at}-${this.items(id).length}`, ...common, tool: ev.tool, text: ev.text });
626
631
  }
627
632
  case "Decided": {
628
- if (!ev.decision) return void 0;
633
+ if (!ev.decision) {
634
+ if (!ev.byClose) return void 0;
635
+ const q = [...this.items(id)].reverse().find((i) => i.kind === "tool" && QUESTION_TOOLS.has(i.tool) && i.status === "running" && !i.helperId);
636
+ return q ? this.put(id, { ...q, status: "unknown", byClose: true }) : void 0;
637
+ }
629
638
  const perm = [...this.items(id)].reverse().find((i) => i.kind === "permission" && i.decision === void 0);
630
- return perm && perm.kind === "permission" ? this.put(id, { ...perm, decision: ev.decision }) : void 0;
639
+ return perm && perm.kind === "permission" ? this.put(id, { ...perm, decision: ev.decision, ...ev.byClose ? { byClose: true } : {} }) : void 0;
631
640
  }
632
641
  case "AgentMessage": {
633
642
  if (!ev.messageId || !ev.text) return void 0;
@@ -697,7 +706,7 @@ var History = class {
697
706
  const input = ev.toolInput ?? {};
698
707
  const to = typeof input.to === "string" ? input.to : "";
699
708
  const peer = to ? this.peerOf(to) : void 0;
700
- return { kind: "sent", id: ev.toolUseId ?? `sent-${ev.at}`, ...common, to, ...peer ? { peer } : {}, ...typeof input.summary === "string" && input.summary ? { summary: input.summary } : {}, text: typeof input.message === "string" ? input.message : "", status };
709
+ return { kind: "sent", id: ev.toolUseId ?? `sent-${ev.at}`, ...common, to, ...peer ? { peer } : {}, ...typeof input.summary === "string" && input.summary ? { summary: input.summary } : {}, text: typeof input.message === "string" ? stripNote(input.message).text : "", status };
701
710
  }
702
711
  /** A notice as a thread item: the session it is about named, and its Ledge session when it is one. */
703
712
  noticeOf(n, common) {
@@ -718,7 +727,7 @@ var History = class {
718
727
  * messages to this session not read yet, the one whose text starts like the body if any (the hook may have cut
719
728
  * either), both read as Ledge types a body (a multi-line one arrives on one line). That sent one becomes read, in the sender's thread. */
720
729
  receive(id, recv, m) {
721
- const peer = this.peerOf(m.from) ?? (m.name ? this.peerOf(m.name) : void 0);
730
+ const peer = (m.sender ? this.peerOf(m.sender) : void 0) ?? this.peerOf(m.from) ?? (m.name ? this.peerOf(m.name) : void 0);
722
731
  if (!peer) return this.put(id, recv);
723
732
  const head = (t) => ledgeBody(t).trim().slice(0, 200);
724
733
  const open3 = this.items(peer).filter((i) => i.kind === "sent" && i.peer === id && i.status !== "read");
@@ -964,9 +973,22 @@ function optionNumber(text, pending) {
964
973
  var FREE_ROW = /^Type something\.?$/;
965
974
  var NO_FREE_ROW = "no free-text row in this dialog, answer in the terminal";
966
975
  var CLEAR = [{ keys: ["C-u"] }];
976
+ var REPLY_ENTER_DELAY_MS = 300;
977
+ var send = (text) => [{ text }, { waitMs: REPLY_ENTER_DELAY_MS }, { keys: ["Enter"] }];
978
+ function sentFrom(text) {
979
+ const head = text.replace(/\s+/g, "").slice(0, 40);
980
+ return (pane) => {
981
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
982
+ const box = inputBox(lines);
983
+ if (!box) return true;
984
+ const draft = lines.slice(box.top + 1, box.bottom).join("").replace(/\s+/g, "").replace(/^❯/, "");
985
+ return !draft.includes("[Pastedtext#") && !(head && draft.startsWith(head));
986
+ };
987
+ }
988
+ var submit = (text, denied) => [...send(text), { until: sentFrom(text), ...denied ? { denied } : {} }];
967
989
  function rowPlan(n, text) {
968
990
  const row = new RegExp(`^\\s*\u276F\\s*${n}\\.\\s`, "m");
969
- return [...digit(n), { until: (p) => row.test(p) }, ...CLEAR, { text }, { keys: ["Enter"] }];
991
+ return [...digit(n), { until: (p) => row.test(p) }, ...CLEAR, ...send(text)];
970
992
  }
971
993
  function freeTextPlan(text, pane) {
972
994
  const d = pane ? parseDialog(pane) : null;
@@ -993,7 +1015,7 @@ function notePlan(note, pane) {
993
1015
  const d = pane ? parseDialog(pane) : null;
994
1016
  const row = d?.kind === "plan" ? d.options.findIndex((o) => PLAN_NOTE_ROW.test(o)) : -1;
995
1017
  if (row >= 0) return rowPlan(row + 1, text);
996
- return [{ keys: ["Escape"] }, { until: atRest, denied: true }, ...CLEAR, { text }, { keys: ["Enter"] }];
1018
+ return [{ keys: ["Escape"] }, { until: atRest, denied: true }, ...CLEAR, ...submit(text, true)];
997
1019
  }
998
1020
  var claudeAdapter = {
999
1021
  tool: "claude",
@@ -1025,9 +1047,12 @@ var claudeAdapter = {
1025
1047
  const n = optionNumber(flat, pending);
1026
1048
  if (n !== null) return digit(n);
1027
1049
  if (pending?.kind === "question") return freeTextPlan(flat, pane);
1028
- return [{ text: flat }, { keys: ["Enter"] }];
1050
+ return submit(flat);
1029
1051
  },
1030
- interruptKeys: () => ["Escape"]
1052
+ interruptKeys: () => ["Escape"],
1053
+ // `/exit` quits Claude Code 2.1.x, also after a typed draft was cleared with Ctrl+U (seen in tmux).
1054
+ exitText: () => "/exit",
1055
+ exitPlan: () => [...CLEAR, ...send("/exit")]
1031
1056
  };
1032
1057
 
1033
1058
  // src/adapters/codex.ts
@@ -1157,7 +1182,7 @@ function optionNumber2(options, re) {
1157
1182
  const i = options.findIndex((o) => re.test(o));
1158
1183
  return i < 0 ? null : i + 1;
1159
1184
  }
1160
- var REPLY_ENTER_DELAY_MS = 300;
1185
+ var REPLY_ENTER_DELAY_MS2 = 300;
1161
1186
  var ALWAYS2 = /don['’]t ask again/i;
1162
1187
  function alwaysKeys(d) {
1163
1188
  const i = d?.kind === "approval" ? d.raw.findIndex((o) => ALWAYS2.test(o)) : -1;
@@ -1168,7 +1193,7 @@ function alwaysKeys(d) {
1168
1193
  function noteKeys(d, note) {
1169
1194
  if (d?.kind === "confirm") throw new Refusal(NO_NOTE, "not_offered");
1170
1195
  const no = d?.kind === "plan" ? optionNumber2(d.options, /^No\b/) : null;
1171
- return [...no ? digit2(no) : [{ keys: ["Escape"] }], { until: atRest2, denied: true }, { text: flatten(note) }, { waitMs: REPLY_ENTER_DELAY_MS }, { keys: ["Enter"] }];
1196
+ return [...no ? digit2(no) : [{ keys: ["Escape"] }], { until: atRest2, denied: true }, { text: flatten(note) }, { waitMs: REPLY_ENTER_DELAY_MS2 }, { keys: ["Enter"] }];
1172
1197
  }
1173
1198
  var codexAdapter = {
1174
1199
  tool: "codex",
@@ -1199,10 +1224,14 @@ var codexAdapter = {
1199
1224
  replyPlan(text, pending) {
1200
1225
  const flat = flatten(text);
1201
1226
  if (pending?.kind === "question" && /^[1-9]$/.test(flat)) return digit2(Number(flat));
1202
- return [{ text: flat }, { waitMs: REPLY_ENTER_DELAY_MS }, { keys: ["Enter"] }];
1227
+ return [{ text: flat }, { waitMs: REPLY_ENTER_DELAY_MS2 }, { keys: ["Enter"] }];
1203
1228
  },
1204
1229
  // Esc interrupts the turn; Ctrl+C clears the draft and a second one quits Codex.
1205
- interruptKeys: () => ["Escape"]
1230
+ interruptKeys: () => ["Escape"],
1231
+ // Codex 0.162, seen in tmux: Ctrl+U clears a typed draft, then `/exit` and `/quit` both quit it (a draft cleared that
1232
+ // way does not stop either from quitting).
1233
+ exitText: () => "/exit",
1234
+ exitPlan: () => [{ keys: ["C-u"] }, { text: "/exit" }, { waitMs: REPLY_ENTER_DELAY_MS2 }, { keys: ["Enter"] }]
1206
1235
  };
1207
1236
 
1208
1237
  // src/adapters/cursor.ts
@@ -1392,6 +1421,7 @@ function notePlan2(lines, note) {
1392
1421
  if (parsePlan(lines)) return [{ text: "p" }, { until: (p) => REVISE.test(p) }, ...typeIt];
1393
1422
  return [{ text: "n" }, { until: inSkipField }, ...typeIt];
1394
1423
  }
1424
+ var EXIT_ENTER_DELAY_MS = 300;
1395
1425
  var cursorAdapter = {
1396
1426
  tool: "cursor",
1397
1427
  detectPrompt: detectPrompt3,
@@ -1420,7 +1450,12 @@ var cursorAdapter = {
1420
1450
  return [{ text: flat }, { until: typed(flat) }, { keys: ["Enter"] }];
1421
1451
  },
1422
1452
  // Ctrl-C aborts a running turn; at rest it only clears the input line, it never quits.
1423
- interruptKeys: () => ["C-c"]
1453
+ interruptKeys: () => ["C-c"],
1454
+ // Not run on the real CLI (its TUI asked for a login that could not be done here): `/exit` as the quit command is an assumption,
1455
+ // not verified on the real CLI; the `killSession` fallback covers it if wrong. Ctrl-C at rest only clears the input line. No `until`
1456
+ // (replyPlan's `typed` check): a pause lets the text land in the input before Enter.
1457
+ exitText: () => "/exit",
1458
+ exitPlan: () => [{ keys: ["C-c"] }, { text: "/exit" }, { waitMs: EXIT_ENTER_DELAY_MS }, { keys: ["Enter"] }]
1424
1459
  };
1425
1460
 
1426
1461
  // src/adapters/preview.ts
@@ -1501,6 +1536,11 @@ function startPoller(opts) {
1501
1536
  const deadCheck = t - lastDeadCheck >= deadCheckMs;
1502
1537
  if (deadCheck) lastDeadCheck = t;
1503
1538
  for (const id of previews.keys()) if (!store.get(id)) previews.delete(id);
1539
+ try {
1540
+ opts.labelExited?.();
1541
+ } catch (e) {
1542
+ log(`resume labels failed: ${e.message}`);
1543
+ }
1504
1544
  for (const snapshot of store.all()) {
1505
1545
  const s = store.get(snapshot.id);
1506
1546
  if (!s) continue;
@@ -3005,7 +3045,7 @@ function runJsonl(file, args, o, parse, onEvent, spawnImpl = nodeSpawn2) {
3005
3045
  var PERSONA = `Tu es Ledge, le compagnon qui veille sur les sessions d'agents (Claude Code, Codex, Cursor Agent, OMP) de l'utilisateur. Tu lis, tu ne touches \xE0 rien : seulement des commandes de lecture (cat, ls, rg\u2026), aucune commande qui modifie quoi que ce soit, pas d'\xE9criture, et tu n'envoies jamais rien toi-m\xEAme \xE0 une session.
3006
3046
  Tes sources : le dossier courant contient sessions.md (toutes les sessions, leur \xE9tat et le nom de leurs fichiers), pane-<id>.txt (les derni\xE8res lignes du terminal d'une session), reply-<id>.md (sa derni\xE8re r\xE9ponse), git-<id>.txt (status, log et diff --stat de son dossier) folders.md (les dossiers r\xE9cents o\xF9 l'utilisateur a lanc\xE9 des sessions) et, quand il existe, projects.md (les projets, leurs d\xE9p\xF4ts par machine et les dossiers rang\xE9s). Les d\xE9p\xF4ts eux-m\xEAmes sont lisibles, avec les outils de lecture de ton harnais, pour aller plus loin. Pour une question sur les issues, les pull/merge requests, la CI ou ce qui a \xE9t\xE9 merg\xE9, lis forge.md puis le forge-<projet>.md concern\xE9 : le daemon y a \xE9crit ce qu'il a lu de GitHub ou GitLab, avec l'heure de lecture, que tu cites. Si ce fichier manque, ou s'il dit que \`gh\`/\`glab\` est absent, non connect\xE9 ou que le d\xE9p\xF4t n'a pas de forge, dis que Ledge n'a pas acc\xE8s \xE0 cette forge au lieu de supposer.
3007
3047
  Commence toujours par lire sessions.md. Ne lis que ce que la question demande.
3008
- R\xE9ponds dans la langue de la question, en 3 \xE0 10 lignes, en Markdown simple : paragraphes courts, listes \xE0 puces, \`code\` pour les commandes, fichiers et branches. Nomme chaque bot par son pr\xE9nom en gras (**Nova**) la premi\xE8re fois. Dis d'o\xF9 vient chaque fait et de quand il date quand cela compte. Si une information manque, dis-le au lieu de supposer. Tu n'agis jamais toi-m\xEAme ; tu peux lui dire quel bot ouvrir. Quand l'utilisateur demande explicitement d'ouvrir ou de lancer une session, et seulement si le dossier est s\xFBr \u2014 un chemin absolu lu dans sessions.md ou folders.md, ou donn\xE9 tel quel par l'utilisateur \u2014, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-session contenant {"cwd": "<chemin>", "prompt": "<l'instruction, s'il y en a une>"}. Si plusieurs dossiers conviennent ou aucun, ne mets pas de bloc : cite les candidats et demande lequel. Pour plusieurs sessions demand\xE9es d'un coup, \xE9cris une ligne par session puis un bloc \`\`\`ledge-session par session, 8 sessions au plus, avec la m\xEAme r\xE8gle pour chaque dossier : ne mets un bloc que pour les dossiers s\xFBrs, et pour chacun des autres cite les candidats et demande lequel. Quand l'utilisateur demande explicitement de dire, demander ou envoyer quelque chose \xE0 une session, et seulement si une seule session de sessions.md correspond et qu'elle n'est pas en lecture seule, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-relay contenant {"session": "<id>", "text": "<l'instruction, formul\xE9e pour l'agent>"}. Si plusieurs sessions conviennent ou aucune, ne mets pas de bloc : cite les candidats et demande laquelle. Tu ne proposes un projet que si l'utilisateur demande explicitement de cr\xE9er un projet ou d'en compl\xE9ter un : lis alors projects.md, puis \xE9cris une r\xE9ponse qui se suffit \xE0 elle-m\xEAme (le nom, chaque d\xE9p\xF4t et pourquoi ; rien n'est cr\xE9\xE9 avant sa validation) et termine par un bloc \`\`\`ledge-project contenant {"name": "<nom>", "description": "<une phrase>", "repos": [{"path": "<chemin absolu>", "why": "<pourquoi ce d\xE9p\xF4t>"}], "addTo": "<nom exact d'un projet de projects.md>"}. Ne cite que des chemins absolus lus dans sessions.md, folders.md ou projects.md : n'invente jamais de chemin. Pour compl\xE9ter un projet existant, mets son nom exact dans addTo ; pour un nouveau projet, omets addTo, et la description si tu n'en as pas. Ne propose pas un d\xE9p\xF4t qui appartient d\xE9j\xE0 \xE0 un projet de projects.md. Si aucun d\xE9p\xF4t ne convient, ne mets pas de bloc et dis-le.`;
3048
+ R\xE9ponds dans la langue de la question, en 3 \xE0 10 lignes, en Markdown simple : paragraphes courts, listes \xE0 puces, \`code\` pour les commandes, fichiers et branches. Nomme chaque bot par son pr\xE9nom en gras (**Nova**) la premi\xE8re fois. Dis d'o\xF9 vient chaque fait et de quand il date quand cela compte. Si une information manque, dis-le au lieu de supposer. Tu n'agis jamais toi-m\xEAme ; tu peux lui dire quel bot ouvrir. Quand l'utilisateur demande explicitement d'ouvrir ou de lancer une session, et seulement si le dossier est s\xFBr \u2014 un chemin absolu lu dans sessions.md ou folders.md, ou donn\xE9 tel quel par l'utilisateur \u2014, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-session contenant {"cwd": "<chemin>", "prompt": "<l'instruction, s'il y en a une>"}. Si plusieurs dossiers conviennent ou aucun, ne mets pas de bloc : cite les candidats et demande lequel. Pour plusieurs sessions demand\xE9es d'un coup, \xE9cris une ligne par session puis un bloc \`\`\`ledge-session par session, 8 sessions au plus, avec la m\xEAme r\xE8gle pour chaque dossier : ne mets un bloc que pour les dossiers s\xFBrs, et pour chacun des autres cite les candidats et demande lequel. Quand l'utilisateur demande explicitement de dire, demander ou envoyer quelque chose \xE0 une session, et seulement si une seule session de sessions.md correspond et qu'elle n'est pas en lecture seule, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-relay contenant {"session": "<id>", "text": "<l'instruction, formul\xE9e pour l'agent>"}. Si plusieurs sessions conviennent ou aucune, ne mets pas de bloc : cite les candidats et demande laquelle. Quand l'utilisateur demande explicitement de fermer, clore, arr\xEAter ou quitter une ou plusieurs sessions, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-close contenant {"session": "<id>"} par session, 8 au plus, et seulement pour les sessions de sessions.md qui correspondent sans ambigu\xEFt\xE9, qui ne sont pas en lecture seule et pas d\xE9j\xE0 termin\xE9es. Pour chacune des autres, cite les candidats et demande laquelle, ou dis pourquoi elle ne peut pas \xEAtre ferm\xE9e. La fermeture est propre et la conversation reste reprenable. Si l'utilisateur veut d'abord un point de reprise, propose seulement le relais de cette demande et dis-lui de demander la fermeture une fois les r\xE9ponses arriv\xE9es. Tu ne proposes un projet que si l'utilisateur demande explicitement de cr\xE9er un projet ou d'en compl\xE9ter un : lis alors projects.md, puis \xE9cris une r\xE9ponse qui se suffit \xE0 elle-m\xEAme (le nom, chaque d\xE9p\xF4t et pourquoi ; rien n'est cr\xE9\xE9 avant sa validation) et termine par un bloc \`\`\`ledge-project contenant {"name": "<nom>", "description": "<une phrase>", "repos": [{"path": "<chemin absolu>", "why": "<pourquoi ce d\xE9p\xF4t>"}], "addTo": "<nom exact d'un projet de projects.md>"}. Ne cite que des chemins absolus lus dans sessions.md, folders.md ou projects.md : n'invente jamais de chemin. Pour compl\xE9ter un projet existant, mets son nom exact dans addTo ; pour un nouveau projet, omets addTo, et la description si tu n'en as pas. Ne propose pas un d\xE9p\xF4t qui appartient d\xE9j\xE0 \xE0 un projet de projects.md. Si aucun d\xE9p\xF4t ne convient, ne mets pas de bloc et dis-le.`;
3009
3049
  function claudeArgs(o) {
3010
3050
  return [
3011
3051
  "-p",
@@ -3296,6 +3336,24 @@ function createChat(deps) {
3296
3336
  }
3297
3337
  return { id: s.id, name: s.name, tool: s.tool, text: block.text };
3298
3338
  }
3339
+ function closesFrom(blocks, qid) {
3340
+ const rows = [];
3341
+ for (const { raw } of blocks) {
3342
+ const block = parseCloseBlock(raw);
3343
+ if (!block) {
3344
+ log(`chat ${qid}: close ignored, invalid block: ${raw.slice(0, 200)}`);
3345
+ continue;
3346
+ }
3347
+ const s = deps.store.get(block.session);
3348
+ const why = !s ? "unknown session" : s.source === "hook_only" ? "read-only session" : s.state === "exited" ? "session has exited" : rows.some((r) => r.id === s.id) ? "duplicate session" : null;
3349
+ if (why) {
3350
+ log(`chat ${qid}: close ignored: ${why} ${block.session}`);
3351
+ continue;
3352
+ }
3353
+ rows.push({ id: s.id, name: s.name, tool: s.tool });
3354
+ }
3355
+ return rows;
3356
+ }
3299
3357
  async function projectFrom(raw, qid) {
3300
3358
  const block = parseProjectBlock(raw);
3301
3359
  if (!block) {
@@ -3390,6 +3448,9 @@ function createChat(deps) {
3390
3448
  }
3391
3449
  const proposal = proposals[0];
3392
3450
  const relay = kind === "relay" ? relayFrom(raw, turn.qid) : void 0;
3451
+ const closeBlocks = kind === "close" ? blocks.filter((b) => b.kind === "close") : [];
3452
+ if (closeBlocks.length > MAX_PROPOSALS) log(`chat ${turn.qid}: ${closeBlocks.length - MAX_PROPOSALS} sessions beyond ${MAX_PROPOSALS} ignored`);
3453
+ const closes = closesFrom(closeBlocks.slice(0, MAX_PROPOSALS), turn.qid);
3393
3454
  const projectProposal = kind === "project" ? await projectFrom(raw, turn.qid) : void 0;
3394
3455
  turn.answer = text;
3395
3456
  turn.answeredAt = now();
@@ -3398,8 +3459,9 @@ function createChat(deps) {
3398
3459
  turn.proposals = proposals;
3399
3460
  }
3400
3461
  if (relay) turn.relay = relay;
3462
+ if (closes.length > 0) turn.closes = closes;
3401
3463
  if (projectProposal) turn.projectProposal = projectProposal;
3402
- emit({ type: "chat.done", qid: turn.qid, text, at: turn.answeredAt, ...proposal ? { proposal, proposals } : {}, ...relay ? { relay } : {}, ...projectProposal ? { projectProposal } : {} });
3464
+ emit({ type: "chat.done", qid: turn.qid, text, at: turn.answeredAt, ...proposal ? { proposal, proposals } : {}, ...relay ? { relay } : {}, ...closes.length > 0 ? { closes } : {}, ...projectProposal ? { projectProposal } : {} });
3403
3465
  return { ok: true };
3404
3466
  }
3405
3467
  return {
@@ -3432,6 +3494,26 @@ function createChat(deps) {
3432
3494
  t.relay = { id, name, tool, text, ...patch };
3433
3495
  emit({ type: "chat.history", turns });
3434
3496
  },
3497
+ close: (qid, row) => {
3498
+ rollDay();
3499
+ return turnOf(qid)?.closes?.[row];
3500
+ },
3501
+ markClosed(qid, row, how, emit) {
3502
+ const t = turnOf(qid);
3503
+ const c = t?.closes?.[row];
3504
+ if (!t || !c) return;
3505
+ const next2 = { ...c, closedAt: now(), how };
3506
+ delete next2.error;
3507
+ t.closes = t.closes.map((r, i) => i === row ? next2 : r);
3508
+ emit({ type: "chat.history", turns });
3509
+ },
3510
+ markCloseFailed(qid, row, message, emit) {
3511
+ const t = turnOf(qid);
3512
+ const c = t?.closes?.[row];
3513
+ if (!t || !c) return;
3514
+ t.closes = t.closes.map((r, i) => i === row ? { ...c, error: message } : r);
3515
+ emit({ type: "chat.history", turns });
3516
+ },
3435
3517
  // A roll caught here is silent otherwise: the overlays would keep showing yesterday's queued card.
3436
3518
  queuedRelays(emit) {
3437
3519
  if (rollDay()) emit?.({ type: "chat.history", turns });
@@ -3847,6 +3929,7 @@ function createRelay(d) {
3847
3929
  const now = d.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
3848
3930
  const typing = d.typing ?? /* @__PURE__ */ new Set();
3849
3931
  const sending = /* @__PURE__ */ new Set();
3932
+ const open3 = (id) => free(d.store.get(id)) && !typing.has(id) && !d.closing?.has(id);
3850
3933
  const emitTo = (thread) => (m) => {
3851
3934
  try {
3852
3935
  d.threads.emitterOf(thread)?.(m);
@@ -3883,14 +3966,14 @@ function createRelay(d) {
3883
3966
  }
3884
3967
  function kick(id) {
3885
3968
  const q = queued().find((x) => x.relay.id === id);
3886
- if (!q || typing.has(id) || !free(d.store.get(id))) return;
3969
+ if (!q || !open3(id)) return;
3887
3970
  void deliverAndKick(q.thread, q.qid, q.relay).catch((e) => log(`relay ${q.qid} failed: ${e.message}`));
3888
3971
  }
3889
3972
  async function deliverAndKick(thread, qid, r) {
3890
3973
  const refused = await deliver(thread, qid, r);
3891
3974
  if (refused) {
3892
3975
  if (queued().some((q) => q.relay.id === r.id)) kick(r.id);
3893
- else if (free(d.store.get(r.id)) && !typing.has(r.id)) d.next?.(r.id);
3976
+ else if (open3(r.id)) d.next?.(r.id);
3894
3977
  }
3895
3978
  return refused;
3896
3979
  }
@@ -3907,7 +3990,7 @@ function createRelay(d) {
3907
3990
  }
3908
3991
  return;
3909
3992
  }
3910
- if (free(s) && !typing.has(id)) kick(id);
3993
+ if (open3(id)) kick(id);
3911
3994
  } catch (e) {
3912
3995
  log(`relay change handler failed: ${e.message}`);
3913
3996
  }
@@ -3925,7 +4008,7 @@ function createRelay(d) {
3925
4008
  mark(thread, qid, { error: m });
3926
4009
  throw new Error(m);
3927
4010
  }
3928
- if (free(s) && !typing.has(r.id)) {
4011
+ if (open3(r.id)) {
3929
4012
  if (queued().some((q) => q.relay.id === r.id)) {
3930
4013
  markQueued(thread, qid);
3931
4014
  kick(r.id);
@@ -3946,7 +4029,7 @@ function createRelay(d) {
3946
4029
  settle(id) {
3947
4030
  try {
3948
4031
  if (queued().some((q) => q.relay.id === id)) kick(id);
3949
- else if (free(d.store.get(id)) && !typing.has(id)) d.next?.(id);
4032
+ else if (open3(id)) d.next?.(id);
3950
4033
  } catch (e) {
3951
4034
  log(`relay settle failed: ${e.message}`);
3952
4035
  }
@@ -3994,6 +4077,74 @@ async function launchFromChat(deps, qid, rows = [0]) {
3994
4077
  return { launched, failed };
3995
4078
  }
3996
4079
 
4080
+ // src/chat/close.ts
4081
+ var CloseRefused = class extends Error {
4082
+ constructor(message, underWay = false) {
4083
+ super(message);
4084
+ this.underWay = underWay;
4085
+ }
4086
+ underWay;
4087
+ };
4088
+ async function closeFromChat(deps, qid, rows) {
4089
+ const { chat, inFlight } = deps;
4090
+ const safe = (m) => {
4091
+ try {
4092
+ deps.emit(m);
4093
+ } catch (e) {
4094
+ log(`close: emit failed: ${e.message}`);
4095
+ }
4096
+ };
4097
+ const known = [...new Set(rows ?? [...Array(MAX_PROPOSALS).keys()])].filter((r) => chat.close(qid, r));
4098
+ if (known.length === 0) throw new CloseRefused("unknown close");
4099
+ const todo = known.filter((r) => !chat.close(qid, r).closedAt && !inFlight.has(`${qid}#${r}`));
4100
+ if (todo.length === 0) throw new CloseRefused("already closed", known.some((r) => !chat.close(qid, r).closedAt));
4101
+ for (const r of todo) inFlight.add(`${qid}#${r}`);
4102
+ const closed = [];
4103
+ const failed = [];
4104
+ try {
4105
+ await Promise.all(todo.map(async (r) => {
4106
+ const { id } = chat.close(qid, r);
4107
+ let how;
4108
+ try {
4109
+ ({ how } = await deps.close(id));
4110
+ } catch (e) {
4111
+ failed.push({ row: r, message: e.message });
4112
+ chat.markCloseFailed(qid, r, e.message, safe);
4113
+ return;
4114
+ } finally {
4115
+ inFlight.delete(`${qid}#${r}`);
4116
+ }
4117
+ closed.push({ row: r, id, how });
4118
+ try {
4119
+ chat.markClosed(qid, r, how, safe);
4120
+ } catch (e) {
4121
+ log(`close of ${id}: marking row ${r} failed: ${e.message}`);
4122
+ }
4123
+ try {
4124
+ deps.onClosed?.(r, id, how);
4125
+ } catch (e) {
4126
+ log(`close of ${id}: onClosed failed: ${e.message}`);
4127
+ }
4128
+ }));
4129
+ } finally {
4130
+ for (const r of todo) inFlight.delete(`${qid}#${r}`);
4131
+ }
4132
+ const byRow = (a, b) => a.row - b.row;
4133
+ return { closed: closed.sort(byRow), failed: failed.sort(byRow) };
4134
+ }
4135
+ function cardOf(threads, qid) {
4136
+ const thread = threads.owner(qid);
4137
+ const chat = thread && threads.all().find(([id]) => id === thread)?.[1];
4138
+ if (!thread || !chat) return void 0;
4139
+ return { thread, chat, emit: (m) => {
4140
+ try {
4141
+ threads.emitterOf(thread)?.(m);
4142
+ } catch (e) {
4143
+ log(`close: emit to ${thread} failed: ${e.message}`);
4144
+ }
4145
+ } };
4146
+ }
4147
+
3997
4148
  // src/daemon/mailbox.ts
3998
4149
  var MAX_SEEN = 1e3;
3999
4150
  var PASSING = [
@@ -4007,7 +4158,7 @@ function createMailbox(d) {
4007
4158
  const now = d.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
4008
4159
  const queue = [];
4009
4160
  const seen = /* @__PURE__ */ new Map();
4010
- const ready = (id) => free(d.store.get(id)) && !d.typing.has(id) && !d.relayQueued(id);
4161
+ const ready = (id) => free(d.store.get(id)) && !d.typing.has(id) && !d.closing?.has(id) && !d.relayQueued(id);
4011
4162
  async function deliver(m) {
4012
4163
  const s = d.store.get(m.target);
4013
4164
  if (!s) {
@@ -4255,6 +4406,16 @@ function toPhoneTurn(t) {
4255
4406
  const r = t.relay;
4256
4407
  out.relay = { id: r.id, name: r.name, tool: r.tool, text: r.text, ...r.status ? { status: r.status } : {}, ...r.sentAt ? { sentAt: r.sentAt } : {}, ...r.error !== void 0 ? { failed: true } : {} };
4257
4408
  }
4409
+ if (t.closes) {
4410
+ out.closes = t.closes.map((c) => ({
4411
+ id: c.id,
4412
+ name: c.name,
4413
+ tool: c.tool,
4414
+ ...c.how ? { how: c.how } : {},
4415
+ ...c.closedAt ? { closedAt: c.closedAt } : {},
4416
+ ...c.error !== void 0 ? { failed: true } : {}
4417
+ }));
4418
+ }
4258
4419
  return out;
4259
4420
  }
4260
4421
  var bytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
@@ -4266,14 +4427,14 @@ function toPhoneHistory(turns) {
4266
4427
  while (from < projected.length && total > CHAT_HISTORY_BUDGET) total -= sizes[from++] + 1;
4267
4428
  return projected.slice(from);
4268
4429
  }
4269
- function phoneEmitter(send, chat, opts = {}) {
4430
+ function phoneEmitter(send2, chat, opts = {}) {
4270
4431
  const now = opts.now ?? Date.now;
4271
4432
  const throttleMs = opts.throttleMs ?? 250;
4272
4433
  const live2 = /* @__PURE__ */ new Map();
4273
4434
  const flushLive = (qid) => {
4274
4435
  const l = live2.get(qid);
4275
4436
  if (l && l.text !== l.sent) {
4276
- send({ type: "chat.delta", qid, text: l.text.slice(0, THREAD_TEXT_MAX) }, `chat.live:${qid}`);
4437
+ send2({ type: "chat.delta", qid, text: l.text.slice(0, THREAD_TEXT_MAX) }, `chat.live:${qid}`);
4277
4438
  l.sent = l.text;
4278
4439
  }
4279
4440
  };
@@ -4290,8 +4451,8 @@ function phoneEmitter(send, chat, opts = {}) {
4290
4451
  flushLive(qid);
4291
4452
  live2.delete(qid);
4292
4453
  const turn = chat.history().find((t) => t.qid === qid);
4293
- if (turn) send({ type: "chat.done", qid, turn: toPhoneTurn(turn) });
4294
- else send({ type: "chat.error", qid, code: "failed" });
4454
+ if (turn) send2({ type: "chat.done", qid, turn: toPhoneTurn(turn) });
4455
+ else send2({ type: "chat.error", qid, code: "failed" });
4295
4456
  };
4296
4457
  const finished = /* @__PURE__ */ new Set();
4297
4458
  const finish = (qid) => {
@@ -4301,9 +4462,9 @@ function phoneEmitter(send, chat, opts = {}) {
4301
4462
  return (m) => {
4302
4463
  switch (m.type) {
4303
4464
  case "chat.history":
4304
- return send({ type: "chat.history", turns: toPhoneHistory(m.turns) }, "chat.history");
4465
+ return send2({ type: "chat.history", turns: toPhoneHistory(m.turns) }, "chat.history");
4305
4466
  case "chat.step":
4306
- return send({ type: "chat.step", qid: m.qid, step: stepOf(m.step) });
4467
+ return send2({ type: "chat.step", qid: m.qid, step: stepOf(m.step) });
4307
4468
  case "chat.delta": {
4308
4469
  if (finished.has(m.qid)) return;
4309
4470
  const l = live2.get(m.qid) ?? { text: "", sent: "", at: Number.NEGATIVE_INFINITY };
@@ -4335,13 +4496,13 @@ function phoneEmitter(send, chat, opts = {}) {
4335
4496
  live2.delete(m.qid);
4336
4497
  finish(m.qid);
4337
4498
  const code = errorCode2(m.message);
4338
- send({ type: "chat.error", qid: m.qid, code });
4499
+ send2({ type: "chat.error", qid: m.qid, code });
4339
4500
  return code === "busy" ? void 0 : opts.onEnd?.({ qid: m.qid, code });
4340
4501
  }
4341
4502
  default: {
4342
4503
  const qid = m.qid;
4343
4504
  const turn = typeof qid === "string" ? chat.history().find((t) => t.qid === qid) : void 0;
4344
- if (turn) send({ type: "chat.done", qid: turn.qid, turn: toPhoneTurn(turn) });
4505
+ if (turn) send2({ type: "chat.done", qid: turn.qid, turn: toPhoneTurn(turn) });
4345
4506
  }
4346
4507
  }
4347
4508
  };
@@ -5152,6 +5313,25 @@ function createPhoneGateway(deps) {
5152
5313
  const done = await deps.launchChat(`device:${deviceId}`, msg.qid, targets);
5153
5314
  return done.launched.length > 0 ? { status: "applied" } : { status: "failed" };
5154
5315
  };
5316
+ const chatClose = async (deviceId, msg) => {
5317
+ const turn = ownTurn(deviceId, msg.qid);
5318
+ if (!turn || !deps.closeChat) return { status: "rejected", reason: "unknown_session" };
5319
+ const closes = turn.closes;
5320
+ const targets = msg.rows ?? (closes ? [...closes.keys()] : []);
5321
+ if (!closes || targets.length === 0) return { status: "rejected", reason: "invalid" };
5322
+ for (const r of targets) if (!closes[r]) return { status: "rejected", reason: "invalid" };
5323
+ if (deps.credential(deviceId) === null) return { status: "rejected", reason: "confirm_required" };
5324
+ if (targets.every((r) => closes[r].closedAt)) return { status: "failed" };
5325
+ const refused = checkAssertion(deviceId, msg.assertion, "close", msg.qid);
5326
+ if (refused) return refused;
5327
+ try {
5328
+ const done = await deps.closeChat(`device:${deviceId}`, msg.qid, targets);
5329
+ return done.closed.length > 0 ? { status: "applied" } : { status: "failed" };
5330
+ } catch (e) {
5331
+ if (e instanceof CloseRefused) return { status: e.underWay ? "applied" : "failed" };
5332
+ throw e;
5333
+ }
5334
+ };
5155
5335
  const run2 = async (deviceId, msg) => {
5156
5336
  if (msg.type === "credential.register") return register(deviceId, msg);
5157
5337
  if (msg.type === "device.forget") return { status: "applied" };
@@ -5166,6 +5346,7 @@ function createPhoneGateway(deps) {
5166
5346
  }
5167
5347
  if (msg.type === "chat.relay") return chatRelay(deviceId, msg);
5168
5348
  if (msg.type === "chat.launch") return chatLaunch(deviceId, msg);
5349
+ if (msg.type === "chat.close") return chatClose(deviceId, msg);
5169
5350
  const refused = precheck(deviceId, msg);
5170
5351
  if (refused) return refused;
5171
5352
  const out = local(msg);
@@ -5269,18 +5450,30 @@ function createPhoneGateway(deps) {
5269
5450
  if (!live2(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
5270
5451
  const cred = deps.credential(deviceId);
5271
5452
  if (!cred || cred.rpId !== rp.rpId) return invalid();
5272
- const challenge2 = deps.challenges.issue({ deviceId, purpose: "decide", subject: subjectOf(msg.id, msg.expect.key, live2(msg.id)) });
5273
- return push(deviceId, { type: "confirm.challenge", purpose: "decide", challenge: challenge2, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
5453
+ const challenge = deps.challenges.issue({ deviceId, purpose: "decide", subject: subjectOf(msg.id, msg.expect.key, live2(msg.id)) });
5454
+ return push(deviceId, { type: "confirm.challenge", purpose: "decide", challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
5274
5455
  }
5275
5456
  if (msg.purpose === "launch") {
5276
5457
  if (msg.qid === void 0 || !ownTurn(deviceId, msg.qid)) return invalid();
5277
5458
  const cred = deps.credential(deviceId);
5278
5459
  if (!cred || cred.rpId !== rp.rpId) return invalid();
5279
- const challenge2 = deps.challenges.issue({ deviceId, purpose: "launch", subject: msg.qid });
5280
- return push(deviceId, { type: "confirm.challenge", purpose: "launch", challenge: challenge2, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
5460
+ const challenge = deps.challenges.issue({ deviceId, purpose: "launch", subject: msg.qid });
5461
+ return push(deviceId, { type: "confirm.challenge", purpose: "launch", challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
5462
+ }
5463
+ if (msg.purpose === "close") {
5464
+ if (msg.qid === void 0 || !ownTurn(deviceId, msg.qid)) return invalid();
5465
+ const cred = deps.credential(deviceId);
5466
+ if (!cred || cred.rpId !== rp.rpId) return invalid();
5467
+ const challenge = deps.challenges.issue({ deviceId, purpose: "close", subject: msg.qid });
5468
+ return push(deviceId, { type: "confirm.challenge", purpose: "close", challenge, rpId: rp.rpId, credentialId: cred.id, expiresInMs: CHALLENGE_TTL_MS });
5469
+ }
5470
+ if (msg.purpose === "register") {
5471
+ const challenge = deps.challenges.issue({ deviceId, purpose: "register", subject: "" });
5472
+ return push(deviceId, { type: "confirm.challenge", purpose: "register", challenge, rpId: rp.rpId, expiresInMs: CHALLENGE_TTL_MS });
5281
5473
  }
5282
- const challenge = deps.challenges.issue({ deviceId, purpose: "register", subject: "" });
5283
- return push(deviceId, { type: "confirm.challenge", purpose: "register", challenge, rpId: rp.rpId, expiresInMs: CHALLENGE_TTL_MS });
5474
+ const unhandled = msg.purpose;
5475
+ void unhandled;
5476
+ return invalid();
5284
5477
  }
5285
5478
  case "session.read": {
5286
5479
  const s = live2(msg.id);
@@ -5307,7 +5500,7 @@ function createPhoneGateway(deps) {
5307
5500
  const request = typeof type === "string" && REQUEST_NAME.test(type) ? type : void 0;
5308
5501
  log2(`phone message refused: ${request ?? "?"}`);
5309
5502
  const op = OperationIdSchema.safeParse(raw?.op);
5310
- if ((request === "push.subscribe" || request === "chat.launch") && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
5503
+ if ((request === "push.subscribe" || request === "chat.launch" || request === "chat.close") && op.success && approved(deviceId)) return push(deviceId, { type: "operation.result", op: op.data, status: "rejected", reason: "invalid" });
5311
5504
  return push(deviceId, { type: "error", code: "invalid", ...request !== void 0 ? { request } : {} });
5312
5505
  }
5313
5506
  const msg = parsed.data;
@@ -5671,7 +5864,7 @@ async function startServer(opts) {
5671
5864
  const islandMsg = () => ({ type: "island", prefs: island });
5672
5865
  const mobile = opts.mobile;
5673
5866
  const unavailable = !mobile && opts.mobileUnavailable !== void 0 ? { enabled: false, relayUrl: DEFAULT_RELAY_URL, account: null, signin: null, signinError: null, link: { state: "unavailable", error: opts.mobileUnavailable.slice(0, 200) }, devices: [], pairings: [] } : void 0;
5674
- const send = (ws, msg) => {
5867
+ const send2 = (ws, msg) => {
5675
5868
  if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify(msg));
5676
5869
  };
5677
5870
  const localClients = /* @__PURE__ */ new Set();
@@ -5679,7 +5872,7 @@ async function startServer(opts) {
5679
5872
  let gateway;
5680
5873
  let notifyChatEnd;
5681
5874
  const broadcast = (msg) => {
5682
- for (const c of localClients) send(c, msg);
5875
+ for (const c of localClients) send2(c, msg);
5683
5876
  gateway?.forward(msg);
5684
5877
  };
5685
5878
  threads.setEmitter("desktop", broadcast);
@@ -5699,9 +5892,10 @@ async function startServer(opts) {
5699
5892
  ] : [];
5700
5893
  const unsubscribeMobile = mobile?.onChange(() => broadcast({ type: "mobile", state: mobile.state() }));
5701
5894
  const typing = /* @__PURE__ */ new Set();
5702
- const relayThreads = opts.chat ? {
5895
+ const closing = /* @__PURE__ */ new Set();
5896
+ const cardThreads = opts.chat ? {
5703
5897
  all: () => [["desktop", chat]],
5704
- owner: (qid) => chat.relay(qid) ? "desktop" : void 0,
5898
+ owner: (qid) => chat.relay(qid) || chat.close(qid, 0) ? "desktop" : void 0,
5705
5899
  emitterOf: () => broadcast,
5706
5900
  queuedRelays: () => chat.queuedRelays(broadcast).map((q) => ({ thread: "desktop", ...q }))
5707
5901
  } : threads;
@@ -5710,15 +5904,38 @@ async function startServer(opts) {
5710
5904
  store,
5711
5905
  replyTo: replyText,
5712
5906
  typing,
5907
+ closing,
5713
5908
  now,
5714
- relayQueued: (id) => relayThreads.queuedRelays().some((q) => q.relay.id === id),
5909
+ relayQueued: (id) => cardThreads.queuedRelays().some((q) => q.relay.id === id),
5715
5910
  sent: { record: (id, m) => {
5716
5911
  history?.recordSent(id, m);
5717
5912
  }, mark: (id, rid, status) => history?.markSent(id, rid, status) }
5718
5913
  });
5719
- const relay = createRelay({ threads: relayThreads, store, replyTo: replyText, now, typing, next: (id) => mailbox.kick(id) });
5914
+ const relay = createRelay({ threads: cardThreads, store, replyTo: replyText, now, typing, closing, next: (id) => mailbox.kick(id) });
5720
5915
  const previews = /* @__PURE__ */ new Map();
5721
5916
  const titles = /* @__PURE__ */ new Map();
5917
+ const labelled = /* @__PURE__ */ new Map();
5918
+ const unlabelled = /* @__PURE__ */ new Set();
5919
+ const livePeers = opts.peers ?? (() => readPeers());
5920
+ const labelExit = (s) => {
5921
+ unlabelled.delete(s.id);
5922
+ if (s.tool !== "claude" || s.source !== "launcher" || s.state !== "exited" || !s.transcriptPath || !s.topic || s.topic === s.name || labelled.get(s.id) === (s.exitedAt ?? "")) return;
5923
+ const id = basename4(s.transcriptPath, ".jsonl");
5924
+ if (livePeers().some((p) => p.sessionId === id)) {
5925
+ unlabelled.add(s.id);
5926
+ return;
5927
+ }
5928
+ labelled.set(s.id, s.exitedAt ?? "");
5929
+ const r = labelTranscript(s.transcriptPath, s.name, resumeLabel(s.name, s.topic));
5930
+ if (r instanceof Error) log(`resume label of ${s.id} not written: ${r.message}`);
5931
+ };
5932
+ const labelExited = () => {
5933
+ for (const id of [...unlabelled]) {
5934
+ const s = store.get(id);
5935
+ if (s) labelExit(s);
5936
+ else unlabelled.delete(id);
5937
+ }
5938
+ };
5722
5939
  const unsubscribe = store.onChange((c) => {
5723
5940
  if (c.type === "recents") return broadcast({ type: "recents", recents: c.recents });
5724
5941
  if (c.type === "updated" && c.session.tmuxSession && titles.get(c.session.id) !== c.session.title) {
@@ -5728,7 +5945,10 @@ async function startServer(opts) {
5728
5945
  if (c.type === "removed") {
5729
5946
  previews.delete(c.id);
5730
5947
  titles.delete(c.id);
5948
+ labelled.delete(c.id);
5949
+ unlabelled.delete(c.id);
5731
5950
  }
5951
+ if (c.type === "updated") labelExit(c.session);
5732
5952
  if (c.type === "updated" && c.session.modelNext) void applyModelNext(c.session.id).catch((e) => log(`model change of ${c.session.id} failed: ${e.message}`));
5733
5953
  broadcast(c.type === "updated" ? { type: "session.updated", session: c.session } : { type: "session.removed", id: c.id });
5734
5954
  });
@@ -5867,10 +6087,83 @@ async function startServer(opts) {
5867
6087
  if (s.tmuxSession) await tmux.killSession(s.tmuxSession).catch((e) => log(`kill of ${s.tmuxSession} failed: ${e.message}`));
5868
6088
  store.apply(s.id, { name: "PaneGone", at: now(), source: "daemon" });
5869
6089
  }
6090
+ const CLOSE_FREE_MS = opts.close?.freeMs ?? 1e4;
6091
+ const CLOSE_EXIT_MS = opts.close?.exitMs ?? 15e3;
6092
+ async function closeWait(id, ms, ok) {
6093
+ for (const end = Date.now() + ms; ; ) {
6094
+ const cur = store.get(id);
6095
+ if (!cur || cur.state === "exited" && !typing.has(id)) return "gone";
6096
+ if (ok(cur)) return "ok";
6097
+ if (Date.now() >= end) return "timeout";
6098
+ await new Promise((r) => setTimeout(r, UNTIL_POLL_MS));
6099
+ }
6100
+ }
6101
+ async function closeSession(s) {
6102
+ const id = s.id;
6103
+ const cur = store.get(id);
6104
+ if (!cur) throw unknownSession();
6105
+ if (cur.source === "hook_only") throw readOnly();
6106
+ if (cur.state === "exited") throw new Refusal("the session has already exited", "busy");
6107
+ if (closing.has(id)) throw new Refusal("a close is already in progress", "busy");
6108
+ closing.add(id);
6109
+ try {
6110
+ if (backendOf(cur) === "omp-rpc") {
6111
+ await omp.close(id).catch((e) => log(`close of omp ${id} failed: ${e.message}`));
6112
+ store.apply(id, { name: "PaneGone", at: now(), source: "daemon" });
6113
+ return { how: "exit" };
6114
+ }
6115
+ const adapter = adapterFor(cur.tool);
6116
+ const { tmuxSession: pane, pending } = cur;
6117
+ if (!free(cur) && pane) {
6118
+ const asked = pending && pendingKey(pending);
6119
+ const sent = await tmux.sendKeys(pane, adapter.interruptKeys()).then(() => true, (e) => {
6120
+ log(`close of ${id}: the interrupt failed: ${e.message}`);
6121
+ return false;
6122
+ });
6123
+ const still = store.get(id)?.pending;
6124
+ if (sent && pending && !pending.helperId && still && pendingKey(still) === asked) {
6125
+ if (pending.kind === "question") store.apply(id, { name: "Decided", at: now(), source: "daemon", byClose: true });
6126
+ else if (pending.source === "hook") store.apply(id, { name: "Decided", at: now(), source: "daemon", decision: "deny", byClose: true });
6127
+ }
6128
+ }
6129
+ let why;
6130
+ const idle = (x) => free(x) && !typing.has(id) && !inFlight.has(id);
6131
+ const until = Date.now() + CLOSE_FREE_MS;
6132
+ let freed = await closeWait(id, CLOSE_FREE_MS, idle);
6133
+ while (freed === "ok" && !idle(store.get(id))) freed = await closeWait(id, Math.max(0, until - Date.now()), idle);
6134
+ if (freed === "gone") return { how: "exit" };
6135
+ if (freed === "timeout") why = `its prompt was not free after ${CLOSE_FREE_MS} ms`;
6136
+ else {
6137
+ let typed2 = true;
6138
+ typing.add(id);
6139
+ inFlight.add(id);
6140
+ try {
6141
+ await runPlan(store.get(id), adapter.exitPlan());
6142
+ } catch (e) {
6143
+ typed2 = false;
6144
+ log(`close of ${id}: the exit command failed: ${e.message}`);
6145
+ } finally {
6146
+ typing.delete(id);
6147
+ inFlight.delete(id);
6148
+ }
6149
+ why = typed2 ? `still running ${CLOSE_EXIT_MS} ms after its exit command` : "its exit command could not be typed";
6150
+ if (typed2 && await closeWait(id, CLOSE_EXIT_MS, () => false) === "gone") return { how: "exit" };
6151
+ }
6152
+ const last = store.get(id);
6153
+ if (!last || last.state === "exited") return { how: "exit" };
6154
+ if (last.tmuxSession) await tmux.killSession(last.tmuxSession).catch((e) => log(`kill of ${last.tmuxSession} failed: ${e.message}`));
6155
+ store.apply(id, { name: "PaneGone", at: now(), source: "daemon" });
6156
+ log(`close of ${id}: killed (${why})`);
6157
+ return { how: "killed" };
6158
+ } finally {
6159
+ closing.delete(id);
6160
+ }
6161
+ }
5870
6162
  async function replyTo(id, text, how = {}) {
5871
6163
  const s = store.get(id);
5872
6164
  if (!s) return unknownSession();
5873
6165
  if (s.source === "hook_only") return readOnly();
6166
+ if (closing.has(id)) return new Refusal("the session is being closed", "busy");
5874
6167
  if (backendOf(s) === "omp-rpc") return replyOmp(s, text, how.steer === true);
5875
6168
  if (s.pending && s.pending.kind !== "question") return new Refusal(`a ${s.pending.kind} prompt takes accept or deny, not a reply`, "not_offered");
5876
6169
  if (text.trim() === "") return new Refusal("empty reply", "invalid");
@@ -5966,7 +6259,7 @@ async function startServer(opts) {
5966
6259
  }
5967
6260
  async function applyModelNext(id) {
5968
6261
  const s = store.get(id);
5969
- if (!s?.modelNext || !free(s) || typing.has(id) || relayThreads.queuedRelays().some((q) => q.relay.id === id)) return;
6262
+ if (!s?.modelNext || !free(s) || typing.has(id) || closing.has(id) || cardThreads.queuedRelays().some((q) => q.relay.id === id)) return;
5970
6263
  if (backendOf(s) === "omp-rpc") return applyOmpModel(s);
5971
6264
  const key = resumeKey(s);
5972
6265
  if (!key || s.source !== "launcher") {
@@ -5988,6 +6281,21 @@ async function startServer(opts) {
5988
6281
  }
5989
6282
  }
5990
6283
  const chatLaunches = /* @__PURE__ */ new Set();
6284
+ const chatCloses = /* @__PURE__ */ new Set();
6285
+ const cardSessionCloses = /* @__PURE__ */ new Map();
6286
+ function closeForCard(id) {
6287
+ const running = cardSessionCloses.get(id);
6288
+ if (running) return running;
6289
+ const s = store.get(id);
6290
+ if (!s) throw unknownSession();
6291
+ const p = closeSession(s);
6292
+ cardSessionCloses.set(id, p);
6293
+ const done = () => {
6294
+ if (cardSessionCloses.get(id) === p) cardSessionCloses.delete(id);
6295
+ };
6296
+ p.then(done, done);
6297
+ return p;
6298
+ }
5991
6299
  async function launch(req) {
5992
6300
  if (req.tool === "omp") return launchOmp(req);
5993
6301
  if (req.approval) throw new Error(`launch: ${req.tool} takes no approval mode`);
@@ -6280,6 +6588,27 @@ async function startServer(opts) {
6280
6588
  return relay.request(msg.qid);
6281
6589
  case "session.relay.cancel":
6282
6590
  return relay.cancel(msg.qid);
6591
+ // A chat's close card (spec 2026-10-21 close §4), on the thread that holds the turn: each row closed answers the
6592
+ // requester, the card's state reaches the thread's clients through chat.history. A failed row is the requester's one
6593
+ // `error`, once every row has settled.
6594
+ case "session.close": {
6595
+ const card = cardOf(cardThreads, msg.qid);
6596
+ if (!card) return refuse("unknown close");
6597
+ try {
6598
+ const done = await closeFromChat({
6599
+ chat: card.chat,
6600
+ emit: card.emit,
6601
+ inFlight: chatCloses,
6602
+ close: closeForCard,
6603
+ onClosed: (row, id, how) => reply({ type: "session.closed", qid: msg.qid, row, id, how })
6604
+ }, msg.qid, msg.rows);
6605
+ if (done.failed.length > 0) throw new Error(done.failed[0].message);
6606
+ } catch (e) {
6607
+ if (e instanceof CloseRefused) return refuse(e.message);
6608
+ throw e;
6609
+ }
6610
+ return;
6611
+ }
6283
6612
  // Broadcast: every overlay shows the same progress, and the one that asked may be the one that reconnects.
6284
6613
  // A busy refusal goes to the asker only: it must not fail the flow the other overlays follow.
6285
6614
  case "update.daemon":
@@ -6354,6 +6683,16 @@ async function startServer(opts) {
6354
6683
  }
6355
6684
  }, qid, rows);
6356
6685
  },
6686
+ closeChat: (thread, qid, rows) => {
6687
+ const card = cardOf(cardThreads, qid);
6688
+ if (!card || card.thread !== thread) return Promise.reject(new CloseRefused("unknown close"));
6689
+ return closeFromChat({
6690
+ chat: card.chat,
6691
+ emit: card.emit,
6692
+ inFlight: chatCloses,
6693
+ close: closeForCard
6694
+ }, qid, rows);
6695
+ },
6357
6696
  onChatEnd: (deviceId, end) => notifyChatEnd?.(deviceId, end.code),
6358
6697
  version: opts.daemon?.version ?? "unknown",
6359
6698
  boot: randomUUID3(),
@@ -6396,14 +6735,14 @@ async function startServer(opts) {
6396
6735
  void agentEnv2().then((env) => {
6397
6736
  if (ws.readyState === WebSocket2.OPEN) (opts.ssh ?? serveSsh)(ws, alias, { env });
6398
6737
  }).catch((e) => {
6399
- send(ws, { type: "error", message: e.message });
6738
+ send2(ws, { type: "error", message: e.message });
6400
6739
  ws.close(1011);
6401
6740
  });
6402
6741
  return;
6403
6742
  }
6404
6743
  localClients.add(ws);
6405
6744
  ws.once("close", () => localClients.delete(ws));
6406
- const reply = (m) => send(ws, m);
6745
+ const reply = (m) => send2(ws, m);
6407
6746
  sendSnapshot(reply);
6408
6747
  ws.on("message", (data) => {
6409
6748
  let msg;
@@ -6425,6 +6764,11 @@ async function startServer(opts) {
6425
6764
  broadcast,
6426
6765
  preview,
6427
6766
  omp,
6767
+ closeSession: (id) => {
6768
+ const s = store.get(id);
6769
+ return s ? closeSession(s) : Promise.reject(unknownSession());
6770
+ },
6771
+ labelExited,
6428
6772
  close: () => new Promise((res) => {
6429
6773
  unsubscribe();
6430
6774
  for (const u of unsubscribeHistory) u();
@@ -6480,35 +6824,35 @@ function createDaemonUpdater(deps) {
6480
6824
  return {
6481
6825
  running: () => busy,
6482
6826
  /** `reply` reaches only the asking client: a busy refusal must not fail the flow other overlays are following. */
6483
- async update(version, send, reply = send) {
6827
+ async update(version, send2, reply = send2) {
6484
6828
  if (busy) return reply({ type: "update.failed", version, reason: "busy" });
6485
6829
  const info = deps.info();
6486
6830
  const cmd = npmCommand(deps.execPath ?? process.execPath, version);
6487
6831
  const shown = `${cmd.file} ${cmd.args.join(" ")}`;
6488
- if (info.install !== "npm") return send({ type: "update.failed", version, reason: "git-install", ...info.manual ? { command: info.manual } : {} });
6489
- if (!info.systemd) return send({ type: "update.failed", version, reason: "no-systemd", command: shown });
6832
+ if (info.install !== "npm") return send2({ type: "update.failed", version, reason: "git-install", ...info.manual ? { command: info.manual } : {} });
6833
+ if (!info.systemd) return send2({ type: "update.failed", version, reason: "no-systemd", command: shown });
6490
6834
  busy = true;
6491
6835
  try {
6492
- if (await deps.atRisk()) return send({ type: "update.failed", version, reason: "sessions-at-risk" });
6493
- send({ type: "update.progress", version, step: "installing" });
6836
+ if (await deps.atRisk()) return send2({ type: "update.failed", version, reason: "sessions-at-risk" });
6837
+ send2({ type: "update.progress", version, step: "installing" });
6494
6838
  log(`update: ${shown}`);
6495
6839
  const r = await deps.npm(cmd);
6496
6840
  if (r.code !== 0) {
6497
6841
  log(`update: npm failed (${r.code})
6498
6842
  ${r.output}`);
6499
- return send({ type: "update.failed", version, reason: "npm-failed", detail: lastLines(r.output), command: shown });
6843
+ return send2({ type: "update.failed", version, reason: "npm-failed", detail: lastLines(r.output), command: shown });
6500
6844
  }
6501
- send({ type: "update.progress", version, step: "restarting" });
6845
+ send2({ type: "update.progress", version, step: "restarting" });
6502
6846
  await deps.save();
6503
6847
  const code = await deps.restart();
6504
6848
  if (code !== 0) {
6505
6849
  log(`update: systemctl restart failed (${code})`);
6506
- return send({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
6850
+ return send2({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
6507
6851
  }
6508
6852
  restarting = true;
6509
6853
  } catch (e) {
6510
6854
  log(`update: ${e.stack ?? String(e)}`);
6511
- send({ type: "update.failed", version, reason: "npm-failed", detail: e instanceof Error ? e.message : String(e) });
6855
+ send2({ type: "update.failed", version, reason: "npm-failed", detail: e instanceof Error ? e.message : String(e) });
6512
6856
  } finally {
6513
6857
  if (!restarting) busy = false;
6514
6858
  }
@@ -7946,7 +8290,7 @@ async function startDaemon(opts = {}) {
7946
8290
  }
7947
8291
  const server = await startServer({ store, tmux, port: opts.port, daemon, updater, history, mobile, mobileUnavailable });
7948
8292
  ompLive = () => server.omp.liveCount();
7949
- const poller = startPoller({ store, tmux, broadcastPreview: server.preview });
8293
+ const poller = startPoller({ store, tmux, broadcastPreview: server.preview, labelExited: server.labelExited });
7950
8294
  await writeFile7(paths().pid, String(process.pid));
7951
8295
  log(`daemon started on ${server.port} with ${store.all().length} sessions`);
7952
8296
  let stopping = null;