@edspencer/paddock 0.65.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -1
- package/packages/server/dist/cli/args.d.ts +4 -4
- package/packages/server/dist/cli/args.js +5 -5
- package/packages/server/dist/cli/paddock.js +1 -1
- package/packages/server/dist/config.d.ts +7 -0
- package/packages/server/dist/config.js +9 -1
- package/packages/server/dist/herdctl.d.ts +89 -0
- package/packages/server/dist/herdctl.js +169 -2
- package/packages/server/dist/instance-config.js +12 -1
- package/packages/server/dist/projects.d.ts +11 -0
- package/packages/server/dist/projects.js +40 -2
- package/packages/server/dist/queued-message.d.ts +57 -0
- package/packages/server/dist/queued-message.js +135 -1
- package/packages/server/dist/routes/chats.js +77 -6
- package/packages/server/dist/routes/projects.js +22 -2
- package/packages/server/dist/schema-version.d.ts +111 -0
- package/packages/server/dist/schema-version.js +142 -0
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/subagents.d.ts +65 -0
- package/packages/server/dist/subagents.js +159 -19
- package/packages/server/dist/tooldetails.d.ts +10 -4
- package/packages/server/dist/tooldetails.js +23 -6
- package/packages/server/dist/trusted-proxy.js +1 -1
- package/packages/server/dist/turn-interlock.d.ts +101 -0
- package/packages/server/dist/turn-interlock.js +84 -0
- package/packages/server/dist/ws-protocol.d.ts +76 -5
- package/packages/server/dist/ws-protocol.js +2 -0
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +472 -119
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Ri1LwAUN.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Dze5H8Xy.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BidsM7ah.js → ProjectsGrid-BaUE8zaF.js} +1 -1
- package/packages/web/dist/assets/{arc-eQ3ryE9c.js → arc-DqMSW0As.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DBX5pma_.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CnWrtTpa.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-2qmLvtsr.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Dn-LUDMV.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DB0augUY.js → chunk-55IACEB6-HO60VE45.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BPQQM0i8.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DOhfwA-x.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CatHHdFD.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-B645aAlG.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-D1fAaPSZ.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/{core-B4t7D8Om.js → core-DwSEw_oE.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BLdKDAwa.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CbTbTVKv.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CorSS29_.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-PLKivts0.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CUSIBacZ.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CZ69mAZs.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Cmf042Rh.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-DyIKGj6e.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B79pLdLD.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
- package/packages/web/dist/assets/{format-C5nhFL8q.js → format-u-YtEZFd.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BFTR_oNA.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DdGUlJxS.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
- package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
- package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-B80FyUOy.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-_wRlphn9.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-qu4yvjW8.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-D2DEUp2-.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
- package/packages/web/dist/assets/{linear-D-CLRUEW.js → linear-Dolq_eLR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DdihBJl4.js → mermaid.core-D1T_GQW7.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BEh_TDBf.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BbrmLVaZ.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DfVuygAv.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
- package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BHp2QpZt.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D2G6lRMY.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Dj-jzR-c.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C5D6bT13.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DE4gGjhP.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C9QNrl92.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-U2fSGy-d.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-B9RhObdH.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BnlPrtQ9.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DcprTH2W.js +0 -2
- package/packages/web/dist/assets/ProjectView-PrA8purE.js +0 -13
- package/packages/web/dist/assets/channel-B4sED4OP.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
- package/packages/web/dist/assets/index-DVSsta3B.js +0 -71
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +0 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema versioning for the two on-disk formats paddock OWNS (issue #724).
|
|
3
|
+
*
|
|
4
|
+
* Those two are `project.yaml` and `paddock.config.yaml`, and only those two.
|
|
5
|
+
* The `version: 1` fields elsewhere in the tree belong to herdctl — its fleet
|
|
6
|
+
* config (`herdctl-agent-config.ts`) and `@herdctl/core`'s session metadata —
|
|
7
|
+
* and transcripts are Claude Code's format. Paddock reads neither back.
|
|
8
|
+
*
|
|
9
|
+
* ## What this is for: the DOWNGRADE guard, not migrations
|
|
10
|
+
*
|
|
11
|
+
* There is no migration runner here, deliberately (#724, and #717 for the
|
|
12
|
+
* sequencing). With `1` as the only version there is nothing to migrate, and a
|
|
13
|
+
* runner with zero migrations cannot be meaningfully tested.
|
|
14
|
+
*
|
|
15
|
+
* The value delivered on its own is the guard. Running an OLDER paddock against
|
|
16
|
+
* a data dir a newer one wrote — `npx @edspencer/paddock@0.62.0` is one command
|
|
17
|
+
* away — lenient-parses it: `ProjectStore.normalize` drops every key it does not
|
|
18
|
+
* recognise, and the next `update()` writes the file back without them. A
|
|
19
|
+
* `path:` or a `managed:` disappears with no error. A declared version turns
|
|
20
|
+
* that into "this file is version 4, I understand 1, I am not touching it".
|
|
21
|
+
*
|
|
22
|
+
* **Never lenient-parse a file from the future.** That is the whole point.
|
|
23
|
+
*
|
|
24
|
+
* ## Adoption cost: nothing
|
|
25
|
+
*
|
|
26
|
+
* The CURRENT on-disk shape *is* version 1, and an ABSENT `schemaVersion` reads
|
|
27
|
+
* as 1 — so no live instance needs rewriting and nothing pre-adoption can break,
|
|
28
|
+
* because those files genuinely are v1. Files we write carry the field
|
|
29
|
+
* explicitly; existing files gain it whenever they are next written for some
|
|
30
|
+
* other reason. There is no backfill.
|
|
31
|
+
*
|
|
32
|
+
* ## When to bump — read this before changing either format
|
|
33
|
+
*
|
|
34
|
+
* Bump ONLY when an old reader would get the wrong answer, or a new reader
|
|
35
|
+
* cannot recover the truth from an old file:
|
|
36
|
+
*
|
|
37
|
+
* | Change | Bump? |
|
|
38
|
+
* | --------------------------------------------------- | ----- |
|
|
39
|
+
* | Add an optional key with a safe default | **No** — lenient parsing already handles it |
|
|
40
|
+
* | Rename a key | Yes |
|
|
41
|
+
* | Change what an existing key *means* | Yes — silent misinterpretation, the dangerous one |
|
|
42
|
+
* | Change a default so *absence* now means something new | Yes |
|
|
43
|
+
* | Remove a load-bearing key | Yes |
|
|
44
|
+
*
|
|
45
|
+
* Adding a key does not bump, which covers most changes. The additive-vs-
|
|
46
|
+
* breaking distinction is expressed by NOT bumping, rather than by a second
|
|
47
|
+
* version component — hence a monotonic integer and never semver. A data file
|
|
48
|
+
* has one consumer, the app, and it always wants latest: there is no version
|
|
49
|
+
* negotiation for a minor/patch to describe, and migrations chain (1→2→3), which
|
|
50
|
+
* "does 1.2→2.0 include 1.3?" cannot express. Rails, Django, SQLite's
|
|
51
|
+
* `user_version` and IndexedDB all land in the same place.
|
|
52
|
+
*
|
|
53
|
+
* Worked example, so the table is not abstract. v0.64.0's `managed` default is
|
|
54
|
+
* `managed ?? !(repo || path)` (`project-paths.ts`) — row four, the absence of a
|
|
55
|
+
* key acquiring a derived meaning. Under this scheme that was a 1→2 migration:
|
|
56
|
+
* read v1, compute `managed`, write it explicitly, stamp 2. Instead it is a
|
|
57
|
+
* derivation re-evaluated on every read, forever, with `stripDto` deliberately
|
|
58
|
+
* letting `managed` survive so the first PATCH pins it. It works, but it is a
|
|
59
|
+
* migration in disguise, and that pattern accretes.
|
|
60
|
+
*
|
|
61
|
+
* The two constants are independent on purpose: a breaking change to
|
|
62
|
+
* `project.yaml` should not force every `paddock.config.yaml` to claim a version
|
|
63
|
+
* whose shape it never had. They happen to both be 1 today.
|
|
64
|
+
*/
|
|
65
|
+
/** Schema version of the `project.yaml` format this build writes and understands. */
|
|
66
|
+
export const PROJECT_SCHEMA_VERSION = 1;
|
|
67
|
+
/** Schema version of the `paddock.config.yaml` format this build writes and understands. */
|
|
68
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
69
|
+
/** The key both formats carry it under. */
|
|
70
|
+
export const SCHEMA_VERSION_KEY = "schemaVersion";
|
|
71
|
+
/**
|
|
72
|
+
* The version a parsed file declares, or `null` if it declares something we
|
|
73
|
+
* cannot make sense of.
|
|
74
|
+
*
|
|
75
|
+
* - **Absent / null** → {@link PROJECT_SCHEMA_VERSION}-era, i.e. `1`. Every file
|
|
76
|
+
* written before adoption is genuinely a v1 file.
|
|
77
|
+
* - A positive integer (or a string spelling one, since YAML quoting is easy to
|
|
78
|
+
* get wrong by hand) → that integer.
|
|
79
|
+
* - Anything else — `0`, `-1`, `1.5`, `"banana"`, a list — → `null`.
|
|
80
|
+
*
|
|
81
|
+
* `null` is deliberately treated by callers the same way a from-the-future
|
|
82
|
+
* version is, not the same way an absent one is. A file whose own claim about
|
|
83
|
+
* its shape is unreadable is a file we cannot establish is safe to rewrite, and
|
|
84
|
+
* the failure mode this whole mechanism exists to prevent is rewriting one we
|
|
85
|
+
* did not understand.
|
|
86
|
+
*/
|
|
87
|
+
export function readSchemaVersion(raw) {
|
|
88
|
+
if (raw === undefined || raw === null)
|
|
89
|
+
return 1;
|
|
90
|
+
const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.trim()) : NaN;
|
|
91
|
+
if (!Number.isInteger(n) || n < 1)
|
|
92
|
+
return null;
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
95
|
+
/** How a declared version reads in a message, including the unreadable case. */
|
|
96
|
+
function describeDeclared(raw) {
|
|
97
|
+
const found = readSchemaVersion(raw);
|
|
98
|
+
return found === null ? `an unreadable \`schemaVersion\` (${JSON.stringify(raw)})` : `version ${found}`;
|
|
99
|
+
}
|
|
100
|
+
/** Whether `raw` declares a version this build cannot safely read. */
|
|
101
|
+
export function isFromTheFuture(raw, supported) {
|
|
102
|
+
const found = readSchemaVersion(raw);
|
|
103
|
+
return found === null || found > supported;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Why `paddock.config.yaml` is unreadable by this build, or `undefined` if it is
|
|
107
|
+
* fine. Pure, so the decision is testable without a filesystem.
|
|
108
|
+
*
|
|
109
|
+
* Fail-closed, in the same shape as the `claudeHome`-points-at-your-own-`~/.claude`
|
|
110
|
+
* refusal in `config.ts`: an instance config from the future governs auth mode,
|
|
111
|
+
* bind host and data paths, and half-understanding those is worse than not
|
|
112
|
+
* starting.
|
|
113
|
+
*/
|
|
114
|
+
export function configSchemaRefusal(raw, configPath) {
|
|
115
|
+
if (!isFromTheFuture(raw, CONFIG_SCHEMA_VERSION))
|
|
116
|
+
return undefined;
|
|
117
|
+
return (`refusing to start: ${configPath} declares ${describeDeclared(raw)} of the paddock config ` +
|
|
118
|
+
`format, but this build understands up to version ${CONFIG_SCHEMA_VERSION}. It was written ` +
|
|
119
|
+
`by a NEWER paddock. Reading it with this build would silently drop every key this version ` +
|
|
120
|
+
`does not know, and the next write would persist the loss. Upgrade paddock to a version that ` +
|
|
121
|
+
`understands it, or point PADDOCK_CONFIG at a config file this build wrote.`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Why a `project.yaml` cannot be read by this build, or `undefined` if it is fine.
|
|
125
|
+
*
|
|
126
|
+
* Asymmetric with {@link configSchemaRefusal} on purpose, and this is the
|
|
127
|
+
* judgement call worth knowing about: a project from the future is SKIPPED,
|
|
128
|
+
* loudly, rather than refusing the boot. `readSafe` already returns `null` for
|
|
129
|
+
* an unreadable `project.yaml` and the project simply vanishes from `list()`, so
|
|
130
|
+
* saying it out loud is a strict improvement on today. Bricking a whole instance
|
|
131
|
+
* because one project directory was copied in from a newer box would be a worse
|
|
132
|
+
* failure than the data loss being prevented.
|
|
133
|
+
*/
|
|
134
|
+
export function projectSchemaSkip(raw, file) {
|
|
135
|
+
if (!isFromTheFuture(raw, PROJECT_SCHEMA_VERSION))
|
|
136
|
+
return undefined;
|
|
137
|
+
return (`skipping a project: ${file} declares ${describeDeclared(raw)} of the paddock project ` +
|
|
138
|
+
`format, but this build understands up to version ${PROJECT_SCHEMA_VERSION}. It was written ` +
|
|
139
|
+
`by a NEWER paddock. The project is hidden — NOT deleted, and the file is left exactly as it ` +
|
|
140
|
+
`is — because reading it here would drop the keys this version does not know and the next ` +
|
|
141
|
+
`save would persist that loss. Upgrade paddock to see it again.`);
|
|
142
|
+
}
|
|
@@ -98,6 +98,17 @@ export interface ActiveInfo {
|
|
|
98
98
|
jobId: string | null;
|
|
99
99
|
running: boolean;
|
|
100
100
|
}
|
|
101
|
+
/** Why a turn is being cancelled, and which socket asked for it. */
|
|
102
|
+
export interface CancelIntent {
|
|
103
|
+
/**
|
|
104
|
+
* `stop` — a user pressed Stop (`chat:cancel`): they are taking control back.
|
|
105
|
+
* `quiesce` — a destructive op is stopping the turn so it can safely mutate
|
|
106
|
+
* the transcript (#731); the chat is being deleted, reverted or promoted.
|
|
107
|
+
*/
|
|
108
|
+
reason: "stop" | "quiesce";
|
|
109
|
+
/** The socket that asked, if any. */
|
|
110
|
+
origin: HubSocket | null;
|
|
111
|
+
}
|
|
101
112
|
export declare class SessionHub {
|
|
102
113
|
/** sessionId → the current (running or recently-completed) turn for that session. */
|
|
103
114
|
private bySession;
|
|
@@ -109,6 +120,51 @@ export declare class SessionHub {
|
|
|
109
120
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
110
121
|
*/
|
|
111
122
|
onActive: ((info: ActiveInfo) => void) | null;
|
|
123
|
+
/**
|
|
124
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
125
|
+
* layer, which uses it to drain a queued follow-up.
|
|
126
|
+
*
|
|
127
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
128
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
129
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
130
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
131
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
132
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
133
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
134
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
135
|
+
* to extend — including for turn paths that don't exist yet.
|
|
136
|
+
*
|
|
137
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
138
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
139
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
140
|
+
*/
|
|
141
|
+
onTurnEnd: ((info: {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
projectSlug: string;
|
|
144
|
+
origin: HubSocket | null;
|
|
145
|
+
/** Set when this end was CAUSED by an explicit cancel — see {@link noteCancel}. */
|
|
146
|
+
cancel?: CancelIntent;
|
|
147
|
+
}) => void) | null;
|
|
148
|
+
/**
|
|
149
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
150
|
+
* Consumed by the next {@link end} for that session.
|
|
151
|
+
*/
|
|
152
|
+
private cancels;
|
|
153
|
+
/**
|
|
154
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
155
|
+
* causes can be told apart from a turn that finished on its own.
|
|
156
|
+
*
|
|
157
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
158
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
159
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
160
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
161
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
162
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
163
|
+
*
|
|
164
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
165
|
+
* message back; null when the cancel came from no socket at all.
|
|
166
|
+
*/
|
|
167
|
+
noteCancel(sessionId: string, intent: CancelIntent): void;
|
|
112
168
|
/**
|
|
113
169
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
114
170
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -143,7 +199,19 @@ export declare class SessionHub {
|
|
|
143
199
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
144
200
|
* socket after the origin died.
|
|
145
201
|
*/
|
|
146
|
-
broadcast(sessionId: string, msg: HubMessage
|
|
202
|
+
broadcast(sessionId: string, msg: HubMessage, opts?: {
|
|
203
|
+
except?: HubSocket | null;
|
|
204
|
+
}): void;
|
|
205
|
+
/**
|
|
206
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
207
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
208
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
209
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
210
|
+
*/
|
|
211
|
+
sessionForJob(jobId: string): {
|
|
212
|
+
sessionId: string;
|
|
213
|
+
projectSlug: string;
|
|
214
|
+
} | null;
|
|
147
215
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
148
216
|
activeInfo(sessionId: string): ActiveInfo | null;
|
|
149
217
|
/** Snapshot of every session with a running turn (for a newly-connected client). */
|
|
@@ -50,6 +50,47 @@ export class SessionHub {
|
|
|
50
50
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
51
51
|
*/
|
|
52
52
|
onActive = null;
|
|
53
|
+
/**
|
|
54
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
55
|
+
* layer, which uses it to drain a queued follow-up.
|
|
56
|
+
*
|
|
57
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
58
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
59
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
60
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
61
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
62
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
63
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
64
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
65
|
+
* to extend — including for turn paths that don't exist yet.
|
|
66
|
+
*
|
|
67
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
68
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
69
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
70
|
+
*/
|
|
71
|
+
onTurnEnd = null;
|
|
72
|
+
/**
|
|
73
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
74
|
+
* Consumed by the next {@link end} for that session.
|
|
75
|
+
*/
|
|
76
|
+
cancels = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
79
|
+
* causes can be told apart from a turn that finished on its own.
|
|
80
|
+
*
|
|
81
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
82
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
83
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
84
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
85
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
86
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
87
|
+
*
|
|
88
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
89
|
+
* message back; null when the cancel came from no socket at all.
|
|
90
|
+
*/
|
|
91
|
+
noteCancel(sessionId, intent) {
|
|
92
|
+
this.cancels.set(sessionId, intent);
|
|
93
|
+
}
|
|
53
94
|
/**
|
|
54
95
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
55
96
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -129,7 +170,7 @@ export class SessionHub {
|
|
|
129
170
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
130
171
|
* socket after the origin died.
|
|
131
172
|
*/
|
|
132
|
-
broadcast(sessionId, msg) {
|
|
173
|
+
broadcast(sessionId, msg, opts = {}) {
|
|
133
174
|
const set = new Set();
|
|
134
175
|
const turn = this.bySession.get(sessionId);
|
|
135
176
|
if (turn?.origin)
|
|
@@ -138,9 +179,24 @@ export class SessionHub {
|
|
|
138
179
|
if (subs)
|
|
139
180
|
for (const s of subs)
|
|
140
181
|
set.add(s);
|
|
182
|
+
if (opts.except)
|
|
183
|
+
set.delete(opts.except);
|
|
141
184
|
for (const s of set)
|
|
142
185
|
SessionHub.write(s, msg);
|
|
143
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
189
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
190
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
191
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
192
|
+
*/
|
|
193
|
+
sessionForJob(jobId) {
|
|
194
|
+
for (const [sessionId, turn] of this.bySession) {
|
|
195
|
+
if (turn.jobId === jobId)
|
|
196
|
+
return { sessionId, projectSlug: turn.projectSlug };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
144
200
|
// --- internals, driven by TurnHandle -------------------------------------
|
|
145
201
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
146
202
|
activeInfo(sessionId) {
|
|
@@ -191,11 +247,27 @@ export class SessionHub {
|
|
|
191
247
|
}
|
|
192
248
|
/** Mark a turn finished; retain its buffer briefly for an end-of-turn reconnect. */
|
|
193
249
|
end(turn) {
|
|
250
|
+
if (!turn.running)
|
|
251
|
+
return; // already ended — never fire onTurnEnd twice
|
|
194
252
|
turn.running = false;
|
|
195
253
|
if (turn.sessionId) {
|
|
196
254
|
this.fireActive(turn);
|
|
197
255
|
turn.evictTimer = setTimeout(() => this.evict(turn), COMPLETED_TTL_MS);
|
|
198
256
|
turn.evictTimer.unref?.();
|
|
257
|
+
// #627: the session is idle again — let the WS layer deal with anything
|
|
258
|
+
// queued behind this turn. Never allowed to throw into a caller finalizing
|
|
259
|
+
// its turn. A pending cancel intent belongs to THIS end, so consume it.
|
|
260
|
+
const cancel = this.cancels.get(turn.sessionId);
|
|
261
|
+
this.cancels.delete(turn.sessionId);
|
|
262
|
+
if (this.onTurnEnd) {
|
|
263
|
+
const { sessionId, projectSlug, origin } = turn;
|
|
264
|
+
try {
|
|
265
|
+
this.onTurnEnd({ sessionId, projectSlug, origin, ...(cancel ? { cancel } : {}) });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* a drain that throws must not break turn teardown */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
199
271
|
}
|
|
200
272
|
}
|
|
201
273
|
/**
|
|
@@ -266,6 +266,31 @@ export interface TaskToolUses {
|
|
|
266
266
|
paired: TaskToolUse[];
|
|
267
267
|
/** Launches still in flight (no tool_result yet), in file order. */
|
|
268
268
|
pending: TaskToolUse[];
|
|
269
|
+
/** What of this transcript belongs to a sub-agent's own lane — see {@link SidechainLines}. */
|
|
270
|
+
sidechain: SidechainLines;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The sub-agent-lane content of one transcript (issue #727).
|
|
274
|
+
*
|
|
275
|
+
* A sub-agent's steps belong INSIDE its Task card, served by the subagents
|
|
276
|
+
* endpoint — never as top-level rows of the parent transcript. The live path
|
|
277
|
+
* enforces that in five places via `isSidechainMessage` (`ws-turn.ts`), but the
|
|
278
|
+
* history path had no equivalent: `@herdctl/core`'s parser treats `isSidechain`
|
|
279
|
+
* as a whole-SESSION property (a sidechain gets its own file) and drops the
|
|
280
|
+
* per-line flag from the `ChatMessage` it returns, so any sidechain line written
|
|
281
|
+
* INTO a main transcript is parsed as a first-class message and served by
|
|
282
|
+
* `/messages`. This recovers the markers from the raw transcript, which is the
|
|
283
|
+
* only place they survive.
|
|
284
|
+
*
|
|
285
|
+
* Recovered in the same pass as the Task launches above, so it costs no extra I/O.
|
|
286
|
+
*/
|
|
287
|
+
export interface SidechainLines {
|
|
288
|
+
/** `uuid` of every sidechain line — the key `ChatMessage.uuid` joins on. */
|
|
289
|
+
uuids: Set<string>;
|
|
290
|
+
/** `tool_use.id` of every Task/Agent launched FROM a sidechain line (a sub-agent
|
|
291
|
+
* spawning its own sub-agent). Dropped from the parent's join so the file-order
|
|
292
|
+
* alignment survives losing the sidechain messages. */
|
|
293
|
+
taskIds: Set<string>;
|
|
269
294
|
}
|
|
270
295
|
/**
|
|
271
296
|
* Stream the main session transcript and recover, in file order, every
|
|
@@ -277,6 +302,46 @@ export interface TaskToolUses {
|
|
|
277
302
|
* launches separately, and mixing them here would break that alignment.
|
|
278
303
|
*/
|
|
279
304
|
export declare function readTaskToolUses(projectDir: string, sessionId: string): Promise<TaskToolUse[]>;
|
|
305
|
+
/**
|
|
306
|
+
* True when a raw transcript LINE was written on a sub-agent's own lane.
|
|
307
|
+
*
|
|
308
|
+
* Two markers, because the writers disagree: Claude Code stamps `isSidechain:
|
|
309
|
+
* true` (plus an `agentId`) on the line, while the SDK message stream — which
|
|
310
|
+
* `isSidechainMessage` in `ws-turn.ts` reads live, and which the test harness
|
|
311
|
+
* mirrors onto disk — carries `parent_tool_use_id`. Either is conclusive.
|
|
312
|
+
*
|
|
313
|
+
* Exported because EVERY file-order join over a main transcript has to agree with
|
|
314
|
+
* {@link dropSidechainMessages} about which lines exist: a scan that keeps a
|
|
315
|
+
* sidechain line while the parsed message list has dropped it shifts the whole
|
|
316
|
+
* join by one (#727).
|
|
317
|
+
*/
|
|
318
|
+
export declare function isSidechainLine(parsed: {
|
|
319
|
+
isSidechain?: unknown;
|
|
320
|
+
parent_tool_use_id?: unknown;
|
|
321
|
+
message?: {
|
|
322
|
+
parent_tool_use_id?: unknown;
|
|
323
|
+
};
|
|
324
|
+
}): boolean;
|
|
325
|
+
/**
|
|
326
|
+
* Drop a session's sub-agent sidechain steps from its parsed history (issue
|
|
327
|
+
* #727) — the history-path counterpart of the live path's `isSidechainMessage`
|
|
328
|
+
* filter, and the same invariant: a sub-agent's steps render INSIDE its Task
|
|
329
|
+
* card (via the subagents endpoint), never as top-level rows of the parent.
|
|
330
|
+
*
|
|
331
|
+
* Joins on `uuid`, the source line's own id, which `@herdctl/core` preserves on
|
|
332
|
+
* every parsed message (and, for a paired tool message, takes from the
|
|
333
|
+
* originating `tool_use` entry — so a sidechain tool call matches on the same
|
|
334
|
+
* line that made it sidechain). Rides the mtime-cached transcript scan, and
|
|
335
|
+
* short-circuits to the identity for the overwhelmingly common transcript with
|
|
336
|
+
* no sidechain lines at all, so this costs nothing on the normal path.
|
|
337
|
+
*
|
|
338
|
+
* A transcript that is sidechain top to bottom is not a parent with leaked steps
|
|
339
|
+
* — it IS a sub-agent's own session, the shape Claude Code writes as its own
|
|
340
|
+
* `agent-<hex>.jsonl`. herdctl keeps those out of session discovery, but if one
|
|
341
|
+
* is ever reached directly it should render its content, not an empty chat, so
|
|
342
|
+
* a drop that would empty the history is refused.
|
|
343
|
+
*/
|
|
344
|
+
export declare function dropSidechainMessages<T extends ChatMessage>(projectDir: string, sessionId: string, messages: T[]): Promise<T[]>;
|
|
280
345
|
/**
|
|
281
346
|
* Live sub-agent enrichment for a tool frame (issue #429). When the tool is a
|
|
282
347
|
* `Task`/`Agent` launch this turn already recovered (via {@link extractSubagentLaunches}),
|