@edspencer/paddock 0.59.1 → 0.61.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/LICENSE +21 -0
- package/README.md +31 -1
- package/package.json +4 -3
- package/packages/server/dist/adoptable.d.ts +108 -5
- package/packages/server/dist/adoptable.js +279 -33
- package/packages/server/dist/app.js +15 -0
- package/packages/server/dist/claude-home.d.ts +125 -0
- package/packages/server/dist/claude-home.js +276 -0
- package/packages/server/dist/cli/import-chats.js +9 -6
- package/packages/server/dist/cli/paddock.js +9 -31
- package/packages/server/dist/config.d.ts +75 -12
- package/packages/server/dist/config.js +58 -11
- package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
- package/packages/server/dist/herdctl-agent-config.js +21 -9
- package/packages/server/dist/herdctl.d.ts +95 -0
- package/packages/server/dist/herdctl.js +209 -6
- package/packages/server/dist/hook-config.d.ts +17 -15
- package/packages/server/dist/hook-config.js +11 -9
- package/packages/server/dist/routes/chats.js +92 -9
- package/packages/server/dist/run-provenance.d.ts +11 -0
- package/packages/server/dist/run-provenance.js +18 -0
- package/packages/server/dist/transcripts.d.ts +25 -6
- package/packages/server/dist/transcripts.js +12 -24
- package/packages/server/dist/trigger-config.d.ts +18 -8
- package/packages/server/dist/trigger-config.js +18 -8
- package/packages/server/dist/ws-turn.d.ts +0 -13
- package/packages/server/dist/ws-turn.js +50 -0
- package/packages/server/dist/ws.js +43 -11
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-CzMqTdUU.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-YSEZHWDe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DLWxZbqK.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BFX8ff2n.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-DLOeY_ww.js} +1 -1
- package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-BwC1ik-b.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-CsVvXQWw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DXQk2bIa.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DXiA26It.js} +1 -1
- package/packages/web/dist/assets/channel-DDgTXrGP.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-Dt5nC02A.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-DcjnWvl6.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-CRa3LkU4.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-H3V-11qu.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-DPCrENYV.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-C4UO0ZTc.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-BtI37CnI.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-C5bTaK5j.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +2 -0
- package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-DB3cpm5N.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-q-cjTZO4.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-DYEV5HFo.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-D16XCw78.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-CeztryYE.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D5SLIMDS.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-Bw-sVPxM.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-DsRMITgx.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-T8Qqc_ku.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-Cyx47UWU.js} +1 -1
- package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-D2I0duZa.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-DxYJmG9o.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-COK5YOTD.js} +1 -1
- package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
- package/packages/web/dist/assets/index-wBXQMkGI.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-BamRN_Nn.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-DuOHdpEH.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-Bri7GKTW.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-BdkgUnzI.js} +1 -1
- package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-DOcNPCpB.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D9SbVBpN.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-CVmLm7zn.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-D0N10eVU.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-B5gnYdTa.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-CwtxezNZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-D79O3cux.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-xhKcI6tI.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DBYMAjcX.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-e-MYF4lm.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-B8UsvRDM.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-1ucACjTl.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-DJufLd_u.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-qqaZNlu9.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BI1TtrOK.js +0 -13
- package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
- package/packages/web/dist/assets/index-DKDgTDXa.js +0 -71
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BwcJspgG.js +0 -2
|
@@ -868,11 +868,18 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
868
868
|
// optional `when` (epoch-ms) lets the client pass its own timestamp; defaults
|
|
869
869
|
// to now. Mirrors the archive toggle's shape/validation. Monotonic in the
|
|
870
870
|
// store (an older `when` is a no-op), so it never resurrects a stale unread.
|
|
871
|
+
//
|
|
872
|
+
// `keepUnread: true` marks the seen as INFERRED rather than intentional (#608):
|
|
873
|
+
// the watermark still advances, but an explicit "mark unread" override is left
|
|
874
|
+
// alone. The web client uses it when a turn happens to land while the chat is
|
|
875
|
+
// focused — "you were watching" must not silently spend a flag the user (or an
|
|
876
|
+
// API caller) set seconds earlier. The response reports the resulting flag so a
|
|
877
|
+
// caller can see what its request did.
|
|
871
878
|
app.post("/chats/:sessionId/seen", {
|
|
872
879
|
schema: {
|
|
873
880
|
tags: ["Chats"],
|
|
874
881
|
summary: "Mark a project chat as seen",
|
|
875
|
-
description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Responds 200 with `{ ok, lastSeen }
|
|
882
|
+
description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Clears the chat's MANUAL unread override (see the `/unread` route) unless `keepUnread` is true, which marks the request as an INFERRED seen and leaves an explicit flag intact. Responds 200 with `{ ok, lastSeen, unread }`, `unread` being the override's state afterwards.",
|
|
876
883
|
params: {
|
|
877
884
|
type: "object",
|
|
878
885
|
properties: {
|
|
@@ -886,12 +893,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
886
893
|
additionalProperties: true,
|
|
887
894
|
properties: {
|
|
888
895
|
when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
|
|
896
|
+
keepUnread: {
|
|
897
|
+
description: "When true, advance the watermark WITHOUT clearing the manual unread override — for a seen that was inferred rather than intended. Defaults to false.",
|
|
898
|
+
},
|
|
889
899
|
},
|
|
890
900
|
required: [],
|
|
891
901
|
},
|
|
892
902
|
response: {
|
|
893
903
|
200: {
|
|
894
|
-
description: "Object `{ ok, lastSeen }` with the recorded watermark.",
|
|
904
|
+
description: "Object `{ ok, lastSeen, unread }` with the recorded watermark and the manual unread override's state afterwards.",
|
|
895
905
|
type: "object",
|
|
896
906
|
additionalProperties: true,
|
|
897
907
|
},
|
|
@@ -906,9 +916,17 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
906
916
|
const user = readStateUser(req);
|
|
907
917
|
await readState.setLastSeen(user, agent, req.params.sessionId, when);
|
|
908
918
|
// Marking a chat seen also clears any MANUAL unread override (#458): once
|
|
909
|
-
// the user has viewed it, the "look at it again later" flag is spent
|
|
910
|
-
|
|
911
|
-
|
|
919
|
+
// the user has viewed it, the "look at it again later" flag is spent —
|
|
920
|
+
// UNLESS the caller flagged this as an inferred seen (#608), in which case
|
|
921
|
+
// the explicit intent outranks it and the flag stands.
|
|
922
|
+
let stillUnread = false;
|
|
923
|
+
if (req.body?.keepUnread === true) {
|
|
924
|
+
stillUnread = await unread.isUnread(user, agent, req.params.sessionId);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
await unread.setUnread(user, agent, req.params.sessionId, false).catch(() => undefined);
|
|
928
|
+
}
|
|
929
|
+
return reply.code(200).send({ ok: true, lastSeen: when, unread: stillUnread });
|
|
912
930
|
}
|
|
913
931
|
catch (err) {
|
|
914
932
|
return sendProjectError(reply, err);
|
|
@@ -1311,7 +1329,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1311
1329
|
schema: {
|
|
1312
1330
|
tags: ["Chats"],
|
|
1313
1331
|
summary: "Count the native Claude Code chats this project could import",
|
|
1314
|
-
description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty
|
|
1332
|
+
description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty transcripts, slash-command-only transcripts and Paddock's own sweeper curation runs (`too-small` / `slash-command-only` / `sweeper-run`) are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
|
|
1315
1333
|
params: {
|
|
1316
1334
|
type: "object",
|
|
1317
1335
|
properties: {
|
|
@@ -1330,7 +1348,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1330
1348
|
}, async (req, reply) => {
|
|
1331
1349
|
try {
|
|
1332
1350
|
const project = await projects.get(req.params.slug);
|
|
1333
|
-
|
|
1351
|
+
const summary = await herdctl.listAdoptable(project);
|
|
1352
|
+
// `importFrom` is a server-internal detail — the synthetic path a legacy
|
|
1353
|
+
// source is reached through (#620). Clients round-trip `sourceCwd`, so
|
|
1354
|
+
// strip it rather than publishing a `~/.claude` path in the DTO; the
|
|
1355
|
+
// response schema is `additionalProperties: true`, so it would otherwise
|
|
1356
|
+
// serialize.
|
|
1357
|
+
return {
|
|
1358
|
+
...summary,
|
|
1359
|
+
sources: summary.sources.map(({ importFrom: _importFrom, ...rest }) => rest),
|
|
1360
|
+
};
|
|
1334
1361
|
}
|
|
1335
1362
|
catch (err) {
|
|
1336
1363
|
return sendProjectError(reply, err);
|
|
@@ -1340,7 +1367,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1340
1367
|
schema: {
|
|
1341
1368
|
tags: ["Chats"],
|
|
1342
1369
|
summary: "Import native Claude Code chats into this project",
|
|
1343
|
-
description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
|
|
1370
|
+
description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. `sessionIds` narrows the import to a chosen SUBSET of what is on offer (what the confirmation dialog sends); ids that are not on offer are simply not imported, and anything the engine adopts as a side effect of taking a whole source is released again. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
|
|
1344
1371
|
params: {
|
|
1345
1372
|
type: "object",
|
|
1346
1373
|
properties: {
|
|
@@ -1355,6 +1382,11 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1355
1382
|
sourceCwd: {
|
|
1356
1383
|
description: "Optional single source working directory to import from. Must be one of the `sourceCwd` values reported by GET …/adoptable-chats (or the project's own working directory); anything else is rejected with 400.",
|
|
1357
1384
|
},
|
|
1385
|
+
sessionIds: {
|
|
1386
|
+
type: "array",
|
|
1387
|
+
items: { type: "string" },
|
|
1388
|
+
description: "Optional subset of the offered session ids to import. Omit to import everything on offer.",
|
|
1389
|
+
},
|
|
1358
1390
|
},
|
|
1359
1391
|
required: [],
|
|
1360
1392
|
},
|
|
@@ -1382,7 +1414,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1382
1414
|
.send({ error: `Not an importable source: ${sourceCwd}`, code: "invalid" });
|
|
1383
1415
|
}
|
|
1384
1416
|
}
|
|
1385
|
-
const result = await herdctl.adoptChats(project, {
|
|
1417
|
+
const result = await herdctl.adoptChats(project, {
|
|
1418
|
+
sourceCwd,
|
|
1419
|
+
sessionIds: req.body?.sessionIds,
|
|
1420
|
+
});
|
|
1386
1421
|
// Stamp each imported chat's provenance so the list can badge it as
|
|
1387
1422
|
// imported rather than as an ordinary human chat. `stampIfAbsent`, not
|
|
1388
1423
|
// `stamp`: provenance describes how a chat came to exist and is never
|
|
@@ -1403,4 +1438,52 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1403
1438
|
return sendProjectError(reply, err);
|
|
1404
1439
|
}
|
|
1405
1440
|
});
|
|
1441
|
+
app.post("/unadopt-chats", {
|
|
1442
|
+
schema: {
|
|
1443
|
+
tags: ["Chats"],
|
|
1444
|
+
summary: "Undo the most recent native-chat import into this project",
|
|
1445
|
+
description: "Reverses the import this server most recently performed into the workspace: each adoption is released and the copies that import placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that import; omit it to undo all of it. Deliberately scoped to the LAST import and remembered in memory — undo may delete files, so the paths it can touch come from what this process actually did rather than from the request body, and a restart forgets the offer rather than acting on a stale one. Response: `{ released }`, the session ids actually released; an empty array means there was nothing to undo (nothing imported, already undone, or the server restarted since).",
|
|
1446
|
+
params: {
|
|
1447
|
+
type: "object",
|
|
1448
|
+
properties: {
|
|
1449
|
+
slug: { type: "string", description: "Project slug." },
|
|
1450
|
+
},
|
|
1451
|
+
required: ["slug"],
|
|
1452
|
+
},
|
|
1453
|
+
body: {
|
|
1454
|
+
type: ["object", "null"],
|
|
1455
|
+
additionalProperties: true,
|
|
1456
|
+
properties: {
|
|
1457
|
+
sessionIds: {
|
|
1458
|
+
type: "array",
|
|
1459
|
+
items: { type: "string" },
|
|
1460
|
+
description: "Optional subset of the last import's session ids to release. Omit to undo the whole import. Ids outside that import are ignored.",
|
|
1461
|
+
},
|
|
1462
|
+
},
|
|
1463
|
+
required: [],
|
|
1464
|
+
},
|
|
1465
|
+
response: {
|
|
1466
|
+
200: {
|
|
1467
|
+
description: "Object `{ released }` listing the session ids released.",
|
|
1468
|
+
type: "object",
|
|
1469
|
+
additionalProperties: true,
|
|
1470
|
+
},
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
}, async (req, reply) => {
|
|
1474
|
+
try {
|
|
1475
|
+
const project = await projects.get(req.params.slug);
|
|
1476
|
+
const result = await herdctl.unadoptChats(project, {
|
|
1477
|
+
sessionIds: req.body?.sessionIds,
|
|
1478
|
+
});
|
|
1479
|
+
// Drop the provenance marker the import stamped, so a session that is
|
|
1480
|
+
// later re-imported is badged by that import rather than carrying a
|
|
1481
|
+
// marker from one that has been undone.
|
|
1482
|
+
await Promise.all(result.released.map((sessionId) => runProvenance.clear(sessionId).catch(() => undefined)));
|
|
1483
|
+
return result;
|
|
1484
|
+
}
|
|
1485
|
+
catch (err) {
|
|
1486
|
+
return sendProjectError(reply, err);
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1406
1489
|
}
|
|
@@ -104,6 +104,17 @@ export declare class RunProvenanceStore {
|
|
|
104
104
|
* survives.
|
|
105
105
|
*/
|
|
106
106
|
stampIfAbsent(sessionId: string, provenance: RunProvenance): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Forget a chat's provenance (#660).
|
|
109
|
+
*
|
|
110
|
+
* The inverse of {@link stamp}, for the one case where a chat's creation is
|
|
111
|
+
* itself undone: undoing a native-chat import releases the adoption and deletes
|
|
112
|
+
* the copy, and a marker left behind would badge a LATER import of the same
|
|
113
|
+
* session as belonging to the one that was undone. Returns silently when there
|
|
114
|
+
* was nothing recorded, so an undo of a partially-stamped import is not an
|
|
115
|
+
* error.
|
|
116
|
+
*/
|
|
117
|
+
clear(sessionId: string): Promise<void>;
|
|
107
118
|
/** Write-through, serialised so overlapping stamps can't corrupt the file. */
|
|
108
119
|
private persist;
|
|
109
120
|
}
|
|
@@ -192,6 +192,24 @@ export class RunProvenanceStore {
|
|
|
192
192
|
return;
|
|
193
193
|
await this.stamp(sessionId, provenance);
|
|
194
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Forget a chat's provenance (#660).
|
|
197
|
+
*
|
|
198
|
+
* The inverse of {@link stamp}, for the one case where a chat's creation is
|
|
199
|
+
* itself undone: undoing a native-chat import releases the adoption and deletes
|
|
200
|
+
* the copy, and a marker left behind would badge a LATER import of the same
|
|
201
|
+
* session as belonging to the one that was undone. Returns silently when there
|
|
202
|
+
* was nothing recorded, so an undo of a partially-stamped import is not an
|
|
203
|
+
* error.
|
|
204
|
+
*/
|
|
205
|
+
async clear(sessionId) {
|
|
206
|
+
if (!isSafeId(sessionId))
|
|
207
|
+
return;
|
|
208
|
+
const map = await this.ensureLoaded();
|
|
209
|
+
if (!map.delete(sessionId))
|
|
210
|
+
return;
|
|
211
|
+
await this.persist(map);
|
|
212
|
+
}
|
|
195
213
|
/** Write-through, serialised so overlapping stamps can't corrupt the file. */
|
|
196
214
|
persist(map) {
|
|
197
215
|
this.writing = this.writing.then(async () => {
|
|
@@ -21,13 +21,32 @@ export declare function projectChatsDir(projectDir: string): string;
|
|
|
21
21
|
* store stays in the project's metadata dir (the data repo) — so transcripts
|
|
22
22
|
* never pollute the external repo's working tree — hence the two are split.
|
|
23
23
|
*
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
* same resolved value, and a symlink planted in a
|
|
27
|
-
* the engine reads is exactly the "chats list but
|
|
28
|
-
*
|
|
24
|
+
* `home.path` is the Claude home to plant the symlink in, and it is REQUIRED —
|
|
25
|
+
* no default (#620). Callers must pass `cfg.claudeHome`: the engine is
|
|
26
|
+
* constructed with that same resolved value, and a symlink planted in a
|
|
27
|
+
* different home than the one the engine reads is exactly the "chats list but
|
|
28
|
+
* open empty" failure (#588). It used to default to the env-derived home for
|
|
29
|
+
* callers with no config, and `ensureSweeperHome` silently took that default —
|
|
30
|
+
* so the sweeper's symlink landed in a different home than every other agent's.
|
|
31
|
+
* Making the parameter required is what stops that class of bug at the type
|
|
32
|
+
* level.
|
|
33
|
+
*
|
|
34
|
+
* `home.owned` gates the one destructive branch (#620): migrating an existing
|
|
35
|
+
* real transcript directory into `.chats/` copies the files and then REMOVES the
|
|
36
|
+
* originals. That is correct self-healing inside a home paddock owns, where such
|
|
37
|
+
* a directory can only be paddock's own doing. Inside the user's `~/.claude` it
|
|
38
|
+
* is somebody else's data — a `claude` CLI session run in a directory paddock
|
|
39
|
+
* happens to also manage — so we leave it strictly alone and let the SDK keep
|
|
40
|
+
* writing there. Those transcripts stay importable through adoption (#588),
|
|
41
|
+
* which is the deliberate, user-driven version of the same move.
|
|
29
42
|
*/
|
|
30
|
-
export
|
|
43
|
+
export interface ClaudeHomeTarget {
|
|
44
|
+
/** Absolute path to the Claude home to plant the redirect symlink in. */
|
|
45
|
+
path: string;
|
|
46
|
+
/** Whether paddock owns this home — see `PaddockConfig.ownsClaudeHome`. */
|
|
47
|
+
owned: boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare function ensureProjectChats(workingDir: string, chatsHostDir: string, home: ClaudeHomeTarget): Promise<void>;
|
|
31
50
|
/**
|
|
32
51
|
* Read a session's FIRST user message text, untruncated, straight from its
|
|
33
52
|
* transcript JSONL (issue #62). Claude Code's own `preview` is capped at 100
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transcript relocation (backing-store Phase 3).
|
|
3
3
|
*
|
|
4
|
-
* Claude Code writes a session transcript to
|
|
5
|
-
* where `<
|
|
4
|
+
* Claude Code writes a session transcript to `<claudeHome>/projects/<encoded-cwd>/`,
|
|
5
|
+
* where `<claudeHome>` is `CLAUDE_CONFIG_DIR` (paddock's own `<dataDir>/claude-home`
|
|
6
|
+
* since #620) and `<encoded-cwd>` is the agent's absolute working directory with every
|
|
6
7
|
* non-[A-Za-z0-9] char replaced by '-'. That makes transcripts path-coupled and
|
|
7
8
|
* not portable. We relocate them INTO the project by making the encoded path a
|
|
8
9
|
* **symlink to `<projectDir>/.chats/`** — Claude then writes through the symlink
|
|
@@ -17,7 +18,6 @@
|
|
|
17
18
|
import { promises as fs, createReadStream } from "node:fs";
|
|
18
19
|
import { createInterface } from "node:readline";
|
|
19
20
|
import path from "node:path";
|
|
20
|
-
import { claudeHome } from "./config.js";
|
|
21
21
|
/**
|
|
22
22
|
* Encode a working directory the way Claude Code names its transcript dir.
|
|
23
23
|
* For paths under 200 chars this is just the non-alphanumeric→'-' replacement
|
|
@@ -31,27 +31,8 @@ export function encodeProjectDir(dir) {
|
|
|
31
31
|
export function projectChatsDir(projectDir) {
|
|
32
32
|
return path.join(projectDir, ".chats");
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* transcript path for `workingDir` is a symlink pointing at it (migrating an
|
|
37
|
-
* existing real transcript dir in the process). Safe to call on every agent
|
|
38
|
-
* registration. Never throws — a failure here must not block agent registration
|
|
39
|
-
* or chat.
|
|
40
|
-
*
|
|
41
|
-
* `workingDir` is the agent's cwd (which Claude encodes into the transcript dir
|
|
42
|
-
* name); `chatsHostDir` is where the `.chats/` store actually lives. For a
|
|
43
|
-
* notebook project these are the same dir (the default). For a REPO-BACKED
|
|
44
|
-
* project (issue #187) the keeper's cwd is the nested checkout but the `.chats/`
|
|
45
|
-
* store stays in the project's metadata dir (the data repo) — so transcripts
|
|
46
|
-
* never pollute the external repo's working tree — hence the two are split.
|
|
47
|
-
*
|
|
48
|
-
* `claudeHomePath` is the Claude home to plant the symlink in. Callers holding a
|
|
49
|
-
* config MUST pass `cfg.claudeHome` (#588): the engine is constructed with that
|
|
50
|
-
* same resolved value, and a symlink planted in a different home than the one
|
|
51
|
-
* the engine reads is exactly the "chats list but open empty" failure. The
|
|
52
|
-
* env-derived default is kept only for callers with no config (unit tests).
|
|
53
|
-
*/
|
|
54
|
-
export async function ensureProjectChats(workingDir, chatsHostDir = workingDir, claudeHomePath = claudeHome()) {
|
|
34
|
+
export async function ensureProjectChats(workingDir, chatsHostDir, home) {
|
|
35
|
+
const claudeHomePath = home.path;
|
|
55
36
|
try {
|
|
56
37
|
const chatsDir = projectChatsDir(chatsHostDir);
|
|
57
38
|
await fs.mkdir(chatsDir, { recursive: true });
|
|
@@ -68,6 +49,13 @@ export async function ensureProjectChats(workingDir, chatsHostDir = workingDir,
|
|
|
68
49
|
}
|
|
69
50
|
return;
|
|
70
51
|
}
|
|
52
|
+
// A real directory of existing transcripts. In a home paddock does NOT own
|
|
53
|
+
// these are the user's own CLI transcripts (#620) — leave them exactly where
|
|
54
|
+
// they are. The SDK keeps reading and appending to them, and adoption is how
|
|
55
|
+
// the user pulls them into the project if they want to. Bailing here also
|
|
56
|
+
// means we never plant a symlink over them.
|
|
57
|
+
if (st?.isDirectory() && !home.owned)
|
|
58
|
+
return;
|
|
71
59
|
// A real directory of existing transcripts — migrate into .chats, then link.
|
|
72
60
|
if (st?.isDirectory()) {
|
|
73
61
|
for (const entry of await fs.readdir(encoded)) {
|
|
@@ -197,11 +197,21 @@ export declare function curatorTriggerOf(triggers: Record<string, PaddockTrigger
|
|
|
197
197
|
/**
|
|
198
198
|
* Project a trigger's {@link TriggerRun} onto the exact herdctl agent tool-config
|
|
199
199
|
* fields (snake_case), so an event/webhook trigger's OWN `trigger-<slug>-<name>`
|
|
200
|
-
* agent
|
|
200
|
+
* agent carries the capability BY CONSTRUCTION. Always sets `allowed_tools` +
|
|
201
201
|
* `max_turns` so a trigger agent never silently inherits the keeper's broad default
|
|
202
|
-
* toolset
|
|
203
|
-
*
|
|
204
|
-
*
|
|
202
|
+
* toolset.
|
|
203
|
+
*
|
|
204
|
+
* CAVEAT on the EMPTY case (#647): a trigger that declares no tools yields
|
|
205
|
+
* `allowed_tools: []`, and BOTH herdctl runtimes emit the allow-list only when it is
|
|
206
|
+
* non-empty (`if (allowed_tools?.length)`, in `cli-runtime` and `sdk-adapter` alike).
|
|
207
|
+
* So an empty list is indistinguishable from an unset one — no `--allowedTools` flag,
|
|
208
|
+
* no `allowedTools` option — and the agent runs with Claude Code's DEFAULT tools.
|
|
209
|
+
* `[]` is a declaration of intent, NOT an enforced deny-all: `max_turns`, the prompt
|
|
210
|
+
* and `permission_mode` are the real bounds. Making a grant enforceable at all is
|
|
211
|
+
* #319. Pinned by `test/unit/empty-allowed-tools-runtime.test.ts`.
|
|
212
|
+
*
|
|
213
|
+
* The ONE place run→agent-config translation lives. (The exact analogue of
|
|
214
|
+
* `hookToAgentToolConfig`.)
|
|
205
215
|
*/
|
|
206
216
|
export declare function triggerToAgentToolConfig(run: TriggerRun): Record<string, unknown>;
|
|
207
217
|
/**
|
|
@@ -250,7 +260,7 @@ export interface ChatTriggerInfo {
|
|
|
250
260
|
agentName: string;
|
|
251
261
|
/** Whether the trigger is armed (a disabled trigger's past chats are still shown). */
|
|
252
262
|
enabled: boolean;
|
|
253
|
-
/** The
|
|
263
|
+
/** The declared tool grant (herdctl `allowed_tools`); `[]` = none, unenforced (#647). */
|
|
254
264
|
allowedTools: string[];
|
|
255
265
|
/** The permission mode the trigger's turns run under, when it sets one. */
|
|
256
266
|
permissionMode?: TriggerPermissionMode;
|
|
@@ -262,9 +272,9 @@ export interface ChatTriggerInfo {
|
|
|
262
272
|
/**
|
|
263
273
|
* Project a persisted trigger ({@link TriggerDto} fields) onto the web-facing
|
|
264
274
|
* {@link ChatTriggerInfo}, resolving the SAME capability defaults
|
|
265
|
-
* {@link triggerToAgentToolConfig} uses so the banner mirrors the
|
|
266
|
-
* exactly: an absent/empty allow-list surfaces as
|
|
267
|
-
*
|
|
275
|
+
* {@link triggerToAgentToolConfig} uses so the banner mirrors the registered grant
|
|
276
|
+
* exactly: an absent/empty allow-list surfaces as `[]`, and an unset `maxTurns`
|
|
277
|
+
* surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
|
|
268
278
|
* The WHEN fields are read off the discriminated `trigger` so the banner can state
|
|
269
279
|
* the trigger type and the exact firing condition.
|
|
270
280
|
*/
|
|
@@ -107,7 +107,7 @@ const runSchema = z.object({
|
|
|
107
107
|
session: z.enum(["new", "resume"]).default("new"),
|
|
108
108
|
/** Optional per-trigger model override (else the keeper default applies). */
|
|
109
109
|
model: z.string().trim().min(1).optional(),
|
|
110
|
-
/**
|
|
110
|
+
/** The trigger's declared capability. `[]` = no tools declared (see #647: unenforced). */
|
|
111
111
|
tools: z.array(z.string().trim().min(1)).default([]),
|
|
112
112
|
/** Recursion bound for internal spawning (reuses B1); 0 = may not spawn. */
|
|
113
113
|
maxSpawnDepth: z.number().int().nonnegative().optional(),
|
|
@@ -298,11 +298,21 @@ export function curatorTriggerOf(triggers) {
|
|
|
298
298
|
/**
|
|
299
299
|
* Project a trigger's {@link TriggerRun} onto the exact herdctl agent tool-config
|
|
300
300
|
* fields (snake_case), so an event/webhook trigger's OWN `trigger-<slug>-<name>`
|
|
301
|
-
* agent
|
|
301
|
+
* agent carries the capability BY CONSTRUCTION. Always sets `allowed_tools` +
|
|
302
302
|
* `max_turns` so a trigger agent never silently inherits the keeper's broad default
|
|
303
|
-
* toolset
|
|
304
|
-
*
|
|
305
|
-
*
|
|
303
|
+
* toolset.
|
|
304
|
+
*
|
|
305
|
+
* CAVEAT on the EMPTY case (#647): a trigger that declares no tools yields
|
|
306
|
+
* `allowed_tools: []`, and BOTH herdctl runtimes emit the allow-list only when it is
|
|
307
|
+
* non-empty (`if (allowed_tools?.length)`, in `cli-runtime` and `sdk-adapter` alike).
|
|
308
|
+
* So an empty list is indistinguishable from an unset one — no `--allowedTools` flag,
|
|
309
|
+
* no `allowedTools` option — and the agent runs with Claude Code's DEFAULT tools.
|
|
310
|
+
* `[]` is a declaration of intent, NOT an enforced deny-all: `max_turns`, the prompt
|
|
311
|
+
* and `permission_mode` are the real bounds. Making a grant enforceable at all is
|
|
312
|
+
* #319. Pinned by `test/unit/empty-allowed-tools-runtime.test.ts`.
|
|
313
|
+
*
|
|
314
|
+
* The ONE place run→agent-config translation lives. (The exact analogue of
|
|
315
|
+
* `hookToAgentToolConfig`.)
|
|
306
316
|
*/
|
|
307
317
|
export function triggerToAgentToolConfig(run) {
|
|
308
318
|
const out = {
|
|
@@ -361,9 +371,9 @@ export function triggersToHerdctlSchedules(map) {
|
|
|
361
371
|
/**
|
|
362
372
|
* Project a persisted trigger ({@link TriggerDto} fields) onto the web-facing
|
|
363
373
|
* {@link ChatTriggerInfo}, resolving the SAME capability defaults
|
|
364
|
-
* {@link triggerToAgentToolConfig} uses so the banner mirrors the
|
|
365
|
-
* exactly: an absent/empty allow-list surfaces as
|
|
366
|
-
*
|
|
374
|
+
* {@link triggerToAgentToolConfig} uses so the banner mirrors the registered grant
|
|
375
|
+
* exactly: an absent/empty allow-list surfaces as `[]`, and an unset `maxTurns`
|
|
376
|
+
* surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
|
|
367
377
|
* The WHEN fields are read off the discriminated `trigger` so the banner can state
|
|
368
378
|
* the trigger type and the exact firing condition.
|
|
369
379
|
*/
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The turn-execution engine, extracted from ws.ts (#403).
|
|
3
|
-
*
|
|
4
|
-
* `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
|
|
5
|
-
* that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
|
|
6
|
-
* the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
|
|
7
|
-
* the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
|
|
8
|
-
* (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
|
|
9
|
-
* other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
|
|
10
|
-
* selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
|
|
11
|
-
* as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
|
|
12
|
-
* the returned surface.
|
|
13
|
-
*/
|
|
14
1
|
import type { SDKMessage, InjectedMcpServerDef } from "@herdctl/core";
|
|
15
2
|
import type { Project } from "./projects.js";
|
|
16
3
|
import { SessionHub } from "./session-hub.js";
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn-execution engine, extracted from ws.ts (#403).
|
|
3
|
+
*
|
|
4
|
+
* `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
|
|
5
|
+
* that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
|
|
6
|
+
* the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
|
|
7
|
+
* the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
|
|
8
|
+
* (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
|
|
9
|
+
* other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
|
|
10
|
+
* selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
|
|
11
|
+
* as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
|
|
12
|
+
* the returned surface.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
1
15
|
import { createSDKMessageHandler } from "@herdctl/chat";
|
|
2
16
|
import { keeperAgentName, keeperSlugFromAgent } from "./herdctl.js";
|
|
3
17
|
import { isKnownDriveMode, getContextLimit } from "./models.js";
|
|
@@ -172,6 +186,13 @@ export function makeTurnEngine(engine) {
|
|
|
172
186
|
let producedReply = false;
|
|
173
187
|
let noticeEmitted = false;
|
|
174
188
|
let sawError = false;
|
|
189
|
+
// #528: the synthetic job id published for this background stretch, and the
|
|
190
|
+
// session id currently registered against it. The stretch is a real running
|
|
191
|
+
// turn as far as the UI is concerned, so it needs a cancellable identity —
|
|
192
|
+
// without one `chat:active` carries `jobId: null`, the client's deferred
|
|
193
|
+
// cancel never fires, and Stop puts nothing on the wire at all.
|
|
194
|
+
let bgJobId = null;
|
|
195
|
+
let registeredSession = null;
|
|
175
196
|
// #429: sub-agent launches recovered live from the tool_use input, keyed by
|
|
176
197
|
// toolUseId, so the enriched card renders without a refresh (see subagentLaunchFields).
|
|
177
198
|
const launches = new Map();
|
|
@@ -180,6 +201,19 @@ export function makeTurnEngine(engine) {
|
|
|
180
201
|
sessionId: resolvedSession,
|
|
181
202
|
jobId: turn?.jobId ?? null,
|
|
182
203
|
});
|
|
204
|
+
/**
|
|
205
|
+
* Point the published job id at whatever session this stream has resolved.
|
|
206
|
+
* Deferred rather than done once at mint time because the id is published when
|
|
207
|
+
* the turn opens, which can precede the first message that names a session;
|
|
208
|
+
* re-run on every message so a stream that resolves (or moves to) a different
|
|
209
|
+
* session stays cancellable.
|
|
210
|
+
*/
|
|
211
|
+
const syncBackgroundRegistration = () => {
|
|
212
|
+
if (!bgJobId || !resolvedSession || registeredSession === resolvedSession)
|
|
213
|
+
return;
|
|
214
|
+
deps.herdctl.registerBackgroundTurn(bgJobId, resolvedSession);
|
|
215
|
+
registeredSession = resolvedSession;
|
|
216
|
+
};
|
|
183
217
|
const emitNotice = (notice) => {
|
|
184
218
|
if (!turn || noticeEmitted)
|
|
185
219
|
return;
|
|
@@ -196,6 +230,13 @@ export function makeTurnEngine(engine) {
|
|
|
196
230
|
return;
|
|
197
231
|
resolvedSession = sid;
|
|
198
232
|
turn = hub.startTurn(projectSlug, null, sid);
|
|
233
|
+
// #528: give the stretch a cancellable identity the moment it opens, exactly
|
|
234
|
+
// as the foreground path does via onJobCreated. Stop then routes to a
|
|
235
|
+
// force-reap of this session rather than an interrupt — there is no model
|
|
236
|
+
// turn in flight here to interrupt.
|
|
237
|
+
bgJobId = randomUUID();
|
|
238
|
+
turn.setJobId(bgJobId);
|
|
239
|
+
syncBackgroundRegistration();
|
|
199
240
|
const t = turn;
|
|
200
241
|
translate = createSDKMessageHandler({
|
|
201
242
|
onText: (chunk) => {
|
|
@@ -252,6 +293,7 @@ export function makeTurnEngine(engine) {
|
|
|
252
293
|
ensureTurn(m.session_id ?? resolvedSession);
|
|
253
294
|
if (m.session_id)
|
|
254
295
|
turn.setSession(m.session_id);
|
|
296
|
+
syncBackgroundRegistration();
|
|
255
297
|
if (messageProducedReply(m))
|
|
256
298
|
producedReply = true;
|
|
257
299
|
const notice = noticeFromMessage(m);
|
|
@@ -266,6 +308,14 @@ export function makeTurnEngine(engine) {
|
|
|
266
308
|
// Finalize the single turn once the background stream ends (reaper reap). No-op
|
|
267
309
|
// if nothing was ever rendered (a sidechain-only stretch opened no turn).
|
|
268
310
|
const onDone = () => {
|
|
311
|
+
// Drop the cancellable identity first, and unconditionally: the stream is
|
|
312
|
+
// over, so a later Stop must fall through to the "already finished" path
|
|
313
|
+
// rather than reaping whatever session has since taken this id. Runs even
|
|
314
|
+
// for a sidechain-only stretch that opened no turn, and on a second call.
|
|
315
|
+
if (bgJobId)
|
|
316
|
+
deps.herdctl.unregisterBackgroundTurn(bgJobId);
|
|
317
|
+
bgJobId = null;
|
|
318
|
+
registeredSession = null;
|
|
269
319
|
if (!turn)
|
|
270
320
|
return;
|
|
271
321
|
turn.emit({
|
|
@@ -46,13 +46,21 @@ export function makeChatHandler(deps) {
|
|
|
46
46
|
// currently attached, so a turn survives the death of the socket that started
|
|
47
47
|
// it (issue #54). See session-hub.ts.
|
|
48
48
|
const hub = new SessionHub();
|
|
49
|
-
// Per-session marker of the
|
|
50
|
-
// (#245), keyed `agent \0 sessionId
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
49
|
+
// Per-session marker of the queued messages the server has already drained
|
|
50
|
+
// (#245), keyed `agent \0 sessionId`. Lets a drain skip a message it already
|
|
51
|
+
// sent — e.g. a stale localStorage copy a reloaded client re-asserts — instead
|
|
52
|
+
// of double-sending. In-memory (shared across this handler's sockets); a rare
|
|
53
|
+
// double-send only survives a server restart, when the persisted store is
|
|
54
|
+
// already empty anyway.
|
|
55
|
+
//
|
|
56
|
+
// A message's identity is the (ts, text) TUPLE, not the timestamp alone (#628).
|
|
57
|
+
// The client deliberately KEEPS one enqueue ts when APPENDING to an existing
|
|
58
|
+
// queue (#245 stable identity), so "same ts, different text" is a genuinely new
|
|
59
|
+
// message — deduping on the ts alone silently destroyed the appended text.
|
|
60
|
+
// Timestamps are monotonic per session (a fresh queue stamps `Date.now()`), so
|
|
61
|
+
// anything strictly OLDER than `ts` is stale by construction and only the texts
|
|
62
|
+
// flushed AT `ts` need remembering — one bounded set per session.
|
|
63
|
+
const lastFlushed = new Map();
|
|
56
64
|
// Every currently-connected socket, so a turn's start/stop transition can be
|
|
57
65
|
// broadcast to all clients — powering the per-chat sidebar streaming dots that
|
|
58
66
|
// must update even for chats whose pane isn't mounted (issue #53).
|
|
@@ -385,7 +393,7 @@ export function makeChatHandler(deps) {
|
|
|
385
393
|
// successfully and (b) when a queue is set while the session is idle — a queue
|
|
386
394
|
// that arrived (e.g. via the reconnect outbox) after the turn it was meant to
|
|
387
395
|
// follow already ended. `take` makes the read+clear atomic so the two callers
|
|
388
|
-
// can never both send it; the `
|
|
396
|
+
// can never both send it; the `lastFlushed` marker skips a message already
|
|
389
397
|
// drained (a stale client re-assert on reload) so it isn't sent twice.
|
|
390
398
|
const drainQueue = async (slug, sessionId) => {
|
|
391
399
|
if (!deps.queuedMessage)
|
|
@@ -395,7 +403,14 @@ export function makeChatHandler(deps) {
|
|
|
395
403
|
if (!queued?.text)
|
|
396
404
|
return;
|
|
397
405
|
const markerKey = `${agent}${KEY_SEP}${sessionId}`;
|
|
398
|
-
const
|
|
406
|
+
const marker = lastFlushed.get(markerKey);
|
|
407
|
+
// Already drained, on the (ts, text) tuple (#628): either this exact text
|
|
408
|
+
// went out at this exact ts, or the whole message predates the last one we
|
|
409
|
+
// flushed — which, timestamps being monotonic, can only be a stale re-assert.
|
|
410
|
+
// An APPEND reuses the ts with longer text, so it is NOT a duplicate.
|
|
411
|
+
const already = marker !== undefined &&
|
|
412
|
+
(queued.createdAtMs < marker.ts ||
|
|
413
|
+
(queued.createdAtMs === marker.ts && marker.texts.has(queued.text)));
|
|
399
414
|
// Tell every attached client (origin + reconnected sockets) to clear its copy
|
|
400
415
|
// of this message. When we're really sending it, carry the text so the client
|
|
401
416
|
// renders the sent bubble in-transcript; on a stale re-assert we only clear.
|
|
@@ -409,7 +424,12 @@ export function makeChatHandler(deps) {
|
|
|
409
424
|
});
|
|
410
425
|
if (already)
|
|
411
426
|
return;
|
|
412
|
-
|
|
427
|
+
// Record the tuple. A newer ts supersedes the marker outright (everything at
|
|
428
|
+
// the old ts is now stale); the same ts adds one more flushed text.
|
|
429
|
+
if (marker !== undefined && marker.ts === queued.createdAtMs)
|
|
430
|
+
marker.texts.add(queued.text);
|
|
431
|
+
else
|
|
432
|
+
lastFlushed.set(markerKey, { ts: queued.createdAtMs, texts: new Set([queued.text]) });
|
|
413
433
|
// Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
|
|
414
434
|
// above the reply. Run it detached, like a human send. A leading-slash queued
|
|
415
435
|
// message is a slash command (e.g. "/compact"): route it through the command
|
|
@@ -865,6 +885,11 @@ export function makeChatHandler(deps) {
|
|
|
865
885
|
const slug = msg.payload.projectSlug;
|
|
866
886
|
const { command, sessionId } = msg.payload;
|
|
867
887
|
let resolvedSession = sessionId ?? null;
|
|
888
|
+
// The turn id Stop cancels this command by (#632). Hardcoded `null` until
|
|
889
|
+
// now, so a `/compact` was permanently un-stoppable: the client's cancel is
|
|
890
|
+
// guarded by `if (meta.jobId)`, and even the deferred pre-arm cancel (#196)
|
|
891
|
+
// waited forever for an id that never arrived.
|
|
892
|
+
let jobId = null;
|
|
868
893
|
const seen = initTurnUsage();
|
|
869
894
|
// Same hub-tracked turn as onChatSend so a slash-command turn also survives
|
|
870
895
|
// a mid-turn socket drop (issue #54). A command always targets an existing
|
|
@@ -873,7 +898,7 @@ export function makeChatHandler(deps) {
|
|
|
873
898
|
const routing = () => ({
|
|
874
899
|
projectSlug: slug,
|
|
875
900
|
sessionId: resolvedSession,
|
|
876
|
-
jobId
|
|
901
|
+
jobId,
|
|
877
902
|
});
|
|
878
903
|
// #429: sub-agent launches recovered live from the tool_use input (a command
|
|
879
904
|
// like a custom slash-command can still spawn a Task/Agent), keyed by toolUseId.
|
|
@@ -923,6 +948,13 @@ export function makeChatHandler(deps) {
|
|
|
923
948
|
const { sessionId: finalSession } = await deps.herdctl.runCommand(agentName, {
|
|
924
949
|
command,
|
|
925
950
|
resume: resolvedSession,
|
|
951
|
+
// Arm Stop the moment the session exists (#632) — the same contract
|
|
952
|
+
// onChatSend uses, so the client renders and can fire Stop while the
|
|
953
|
+
// command is still working.
|
|
954
|
+
onJobCreated: (id) => {
|
|
955
|
+
jobId = id;
|
|
956
|
+
turn.setJobId(id);
|
|
957
|
+
},
|
|
926
958
|
onMessage: async (m) => {
|
|
927
959
|
// A slash-command turn can spawn a Task too; its nested steps never
|
|
928
960
|
// render top-level ({@link isSidechainMessage}).
|