@paradow/ledge 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/cli/{attach-Y4OMZXAH.js → attach-5H727FCW.js} +4 -4
- package/dist/cli/{bridge-6K523KUX.js → bridge-LXMOQN6Q.js} +1 -1
- package/dist/cli/chunk-CBKKOJIG.js +6 -0
- package/dist/cli/{chunk-CU2E3W4L.js → chunk-DBSCO3HI.js} +66 -27
- package/dist/cli/{chunk-PXO4FFJR.js → chunk-DEXDXDO4.js} +1 -1
- package/dist/cli/{chunk-KGEF6OIU.js → chunk-H36JD4E3.js} +61 -10
- package/dist/cli/{chunk-4IBQ4PG6.js → chunk-MQICEVKR.js} +3 -3
- package/dist/cli/{chunk-2LQITRTR.js → chunk-Q5WWH7FG.js} +50 -9
- package/dist/cli/{chunk-IMSPQUFC.js → chunk-QJVODYJ5.js} +2 -2
- package/dist/cli/{chunk-DXVHFVJI.js → chunk-S4ANPQGA.js} +27 -15
- package/dist/cli/{chunk-NKYJF3VT.js → chunk-TB47EHML.js} +2 -2
- package/dist/cli/{daemon-NIEJZDEC.js → daemon-HWAHX4RR.js} +406 -62
- package/dist/cli/{hook-BGIM76EW.js → hook-LY2YTK5U.js} +27 -13
- package/dist/cli/index.js +17 -12
- package/dist/cli/{launch-JS74JGW6.js → launch-UQEFFPBN.js} +4 -4
- package/dist/cli/{send-JX6O7AQR.js → send-EY63XQXI.js} +4 -4
- package/dist/cli/{setup-2GROTY6B.js → setup-3N2EOJXS.js} +7 -5
- package/dist/cli/{status-Q6TQXVLF.js → status-ZQSYXILE.js} +2 -2
- package/dist/cli/{uninstall-JCX7QXU2.js → uninstall-W2OMKU3B.js} +9 -4
- package/dist/cli/{version-X45HSUBB.js → version-5DA7G7HN.js} +1 -1
- package/dist/cli/{who-KAAEASAA.js → who-VGIMGQNS.js} +3 -3
- package/package.json +1 -1
- package/dist/cli/chunk-EHHYRQWY.js +0 -6
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
readPeers,
|
|
27
27
|
reduce,
|
|
28
28
|
repositoryId,
|
|
29
|
-
shownCommand
|
|
30
|
-
|
|
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-
|
|
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-
|
|
103
|
+
} from "./chunk-Q5WWH7FG.js";
|
|
100
104
|
import {
|
|
101
105
|
LEDGE_VERSION
|
|
102
|
-
} from "./chunk-
|
|
106
|
+
} from "./chunk-CBKKOJIG.js";
|
|
103
107
|
import {
|
|
104
108
|
SERVICE,
|
|
105
109
|
inDaemonService,
|
|
106
110
|
tmuxServerAtRisk
|
|
107
|
-
} from "./chunk-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 ||
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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)
|
|
4294
|
-
else
|
|
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
|
|
4465
|
+
return send2({ type: "chat.history", turns: toPhoneHistory(m.turns) }, "chat.history");
|
|
4305
4466
|
case "chat.step":
|
|
4306
|
-
return
|
|
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
|
-
|
|
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)
|
|
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
|
|
5273
|
-
return push(deviceId, { type: "confirm.challenge", purpose: "decide", challenge
|
|
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
|
|
5280
|
-
return push(deviceId, { type: "confirm.challenge", purpose: "launch", challenge
|
|
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
|
|
5283
|
-
|
|
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
|
|
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)
|
|
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
|
|
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) =>
|
|
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:
|
|
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) ||
|
|
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
|
-
|
|
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) =>
|
|
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,
|
|
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
|
|
6489
|
-
if (!info.systemd) return
|
|
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
|
|
6493
|
-
|
|
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
|
|
6843
|
+
return send2({ type: "update.failed", version, reason: "npm-failed", detail: lastLines(r.output), command: shown });
|
|
6500
6844
|
}
|
|
6501
|
-
|
|
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
|
|
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
|
-
|
|
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;
|