@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
|
@@ -141,15 +141,22 @@ export function sweeperWorkingDir(cfg, slug) {
|
|
|
141
141
|
return path.join(cfg.dataDir, "sweepers", slug === "" ? "_root" : slug);
|
|
142
142
|
}
|
|
143
143
|
/**
|
|
144
|
-
* A project's sweeper (curator) agent config. TOOL-LESS
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
144
|
+
* A project's sweeper (curator) agent config. TOOL-LESS BY DESIGN: it declares
|
|
145
|
+
* `allowed_tools: []` and never reads or writes files. Instead it RETURNS the
|
|
146
|
+
* curated content as plain assistant text in marked sections (OVERVIEW /
|
|
147
|
+
* CHANGELOG / optional CLAUDE, issue #177); SweepService parses that text and
|
|
148
|
+
* writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
|
|
149
149
|
*
|
|
150
150
|
* This is cheaper and far more predictable than letting a Haiku agent drive
|
|
151
151
|
* file edits: no tool-loop turns, no partial writes, no permission_mode /
|
|
152
|
-
* denied_tools to reason about
|
|
152
|
+
* denied_tools to reason about.
|
|
153
|
+
*
|
|
154
|
+
* What makes it tool-less is NOT the empty `allowed_tools` — herdctl emits an
|
|
155
|
+
* allow-list only when it is non-empty, so `[]` restricts nothing (#647). The
|
|
156
|
+
* actual guarantees are: no injected MCP servers (the sweeper is the one turn
|
|
157
|
+
* path that does not even get `send_file`), `max_turns: 4`, a system prompt
|
|
158
|
+
* whose first line is "You DO NOT use any tools", and a non-interactive
|
|
159
|
+
* `claude -p` run that cannot answer a permission prompt.
|
|
153
160
|
*/
|
|
154
161
|
export function buildSweeperConfig(cfg, project) {
|
|
155
162
|
// T5: the sweeper IS the default `curate-overview` (event/afterTurn) trigger. When a
|
|
@@ -171,9 +178,13 @@ export function buildSweeperConfig(cfg, project) {
|
|
|
171
178
|
// addAgent does deep-merge the loaded defaults, they just never carry it).
|
|
172
179
|
runtime: "cli",
|
|
173
180
|
model: curatorModel ?? SWEEPER_DEFAULT_MODEL,
|
|
174
|
-
//
|
|
181
|
+
// A handful of turns is plenty since there are no tool loops — and this, not
|
|
182
|
+
// the empty allow-list below, is one of the things actually bounding the run.
|
|
175
183
|
max_turns: 4,
|
|
176
|
-
// NO tools
|
|
184
|
+
// Declares NO tools: the sweeper returns text only; SweepService does the
|
|
185
|
+
// writing. Note herdctl does not emit an empty allow-list at all, so this
|
|
186
|
+
// restricts nothing on its own (#647) — see the doc comment above for the
|
|
187
|
+
// properties that do make the sweeper tool-less in practice.
|
|
177
188
|
allowed_tools: [],
|
|
178
189
|
system_prompt: "You are a concise project curator. You DO NOT use any tools — you only " +
|
|
179
190
|
"return text. From the recent activity, the current OVERVIEW.md, the " +
|
|
@@ -226,7 +237,8 @@ export function buildSweeperConfig(cfg, project) {
|
|
|
226
237
|
* (`allowed_tools`/`permission_mode`/`model`/`max_turns`, projected by
|
|
227
238
|
* {@link triggerToAgentToolConfig} from the trigger's `run`) IS its capability set.
|
|
228
239
|
* Runs in the project's WORKING dir (so a trigger's Bash/Write act on the same tree
|
|
229
|
-
* the keeper does). A
|
|
240
|
+
* the keeper does). A trigger that declares no tools gets `allowed_tools: []` — which
|
|
241
|
+
* herdctl does not enforce as a deny-all; see {@link triggerToAgentToolConfig} (#647).
|
|
230
242
|
*/
|
|
231
243
|
export function buildTriggerConfig(cfg, project, triggerName, trigger) {
|
|
232
244
|
const config = {
|
|
@@ -134,6 +134,24 @@ export declare class HerdctlService {
|
|
|
134
134
|
* {@link cancel}). Entries are removed when the turn's stream ends.
|
|
135
135
|
*/
|
|
136
136
|
private liveSessions;
|
|
137
|
+
/**
|
|
138
|
+
* Background-phase turns keyed by the synthetic job id the sink minted for
|
|
139
|
+
* them, valued by the chat session they belong to (paddock#528).
|
|
140
|
+
*
|
|
141
|
+
* After a turn's primary `result` lands, {@link chatSession} returns and drops
|
|
142
|
+
* its {@link liveSessions} entry — but the session itself may live on, held
|
|
143
|
+
* open by the reaper because the turn launched background work, streaming
|
|
144
|
+
* autonomous re-invocation turns. Those turns are real, running, and shown as
|
|
145
|
+
* such; until now they were also uncancellable, because nothing registered
|
|
146
|
+
* them anywhere Stop could reach.
|
|
147
|
+
*
|
|
148
|
+
* `interrupt()` would be the wrong tool even if it could: it targets an
|
|
149
|
+
* in-flight model turn, and the wedge case is a session sitting idle holding
|
|
150
|
+
* background work that will never finish. So these route to a force-reap
|
|
151
|
+
* instead — end the session, let the stream end, and let the ordinary unwind
|
|
152
|
+
* emit `chat:complete`. Entries are removed when the background stream ends.
|
|
153
|
+
*/
|
|
154
|
+
private backgroundTurns;
|
|
137
155
|
/**
|
|
138
156
|
* Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
|
|
139
157
|
* (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
|
|
@@ -147,6 +165,14 @@ export declare class HerdctlService {
|
|
|
147
165
|
private jobsIndexOrNull;
|
|
148
166
|
private get jobsIndex();
|
|
149
167
|
constructor(cfg: PaddockConfig);
|
|
168
|
+
/**
|
|
169
|
+
* The Claude home every transcript symlink is planted in, plus whether paddock
|
|
170
|
+
* owns it (#620). One accessor, so no call site can quietly take a default and
|
|
171
|
+
* land in a different home than the FleetManager was built with — the bug
|
|
172
|
+
* `ensureSweeperHome` had, which put the sweeper's symlink in `~/.claude` while
|
|
173
|
+
* every other agent's went to the configured home.
|
|
174
|
+
*/
|
|
175
|
+
private get claudeHomeTarget();
|
|
150
176
|
/**
|
|
151
177
|
* The environment system prompt to append to a keeper turn (issue #635), or
|
|
152
178
|
* `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
|
|
@@ -325,12 +351,61 @@ export declare class HerdctlService {
|
|
|
325
351
|
*/
|
|
326
352
|
adoptChats(project: Project, opts?: {
|
|
327
353
|
sourceCwd?: string;
|
|
354
|
+
/**
|
|
355
|
+
* Import only these sessions (#660). Anything else the engine adopts along
|
|
356
|
+
* the way is released again, exactly as a noise-filtered session is — see
|
|
357
|
+
* the "honouring the noise filter" note above, which this reuses wholesale.
|
|
358
|
+
* Undefined means "everything on offer", which is what a confirmed
|
|
359
|
+
* select-all sends and what the CLI has always sent.
|
|
360
|
+
*/
|
|
361
|
+
sessionIds?: string[];
|
|
328
362
|
mode?: AdoptionPlacementMode;
|
|
329
363
|
dryRun?: boolean;
|
|
330
364
|
}): Promise<{
|
|
331
365
|
adopted: string[];
|
|
332
366
|
skipped: AdoptSkipped[];
|
|
333
367
|
}>;
|
|
368
|
+
/**
|
|
369
|
+
* Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
|
|
370
|
+
*
|
|
371
|
+
* The same test `releaseWithheld` makes before deleting anything: an in-place
|
|
372
|
+
* adoption resolves the destination and the origin to one path, so there is no
|
|
373
|
+
* copy and nothing that may be removed.
|
|
374
|
+
*/
|
|
375
|
+
private placedACopy;
|
|
376
|
+
/**
|
|
377
|
+
* What the LAST import into each workspace did, so it can be undone (#660).
|
|
378
|
+
*
|
|
379
|
+
* Keyed by workspace key (which may be `""` — the root). Holds the sessions
|
|
380
|
+
* that import actually brought in, and of those, the ones where it PLACED a
|
|
381
|
+
* copy and may therefore delete one again.
|
|
382
|
+
*
|
|
383
|
+
* Server-side rather than a token the client hands back: undo deletes files, so
|
|
384
|
+
* the set of paths it may touch must come from what this process actually did,
|
|
385
|
+
* not from a request body. A restart forgets it, which is the right failure —
|
|
386
|
+
* the offer to undo is a transient toast, not a durable feature.
|
|
387
|
+
*
|
|
388
|
+
* Only the most recent import per workspace is kept. Undoing an import two
|
|
389
|
+
* imports ago is not offered and would be a lie to describe as "undo".
|
|
390
|
+
*/
|
|
391
|
+
private readonly lastImport;
|
|
392
|
+
/**
|
|
393
|
+
* Undo the import this process most recently performed into `project` (#660).
|
|
394
|
+
*
|
|
395
|
+
* Releases each adoption and deletes the copies that import placed, leaving the
|
|
396
|
+
* user's own `~/.claude` history exactly as it was. Returns the sessions it
|
|
397
|
+
* released; an empty array means there was nothing to undo (nothing imported
|
|
398
|
+
* yet, already undone, or the server restarted since).
|
|
399
|
+
*
|
|
400
|
+
* `sessionIds` narrows it to part of that import — the rest stays. Ids that
|
|
401
|
+
* were not part of it are ignored rather than rejected: undo is a best-effort
|
|
402
|
+
* safety net, and the alternative is a 400 for a button the user pressed once.
|
|
403
|
+
*/
|
|
404
|
+
unadoptChats(project: Project, opts?: {
|
|
405
|
+
sessionIds?: string[];
|
|
406
|
+
}): Promise<{
|
|
407
|
+
released: string[];
|
|
408
|
+
}>;
|
|
334
409
|
/**
|
|
335
410
|
* Undo the adoption of a session detection had withheld, restoring the state
|
|
336
411
|
* that existed before this import. See {@link adoptChats} for why the file
|
|
@@ -444,19 +519,39 @@ export declare class HerdctlService {
|
|
|
444
519
|
* compacts the real chat history. SDK messages stream via `onMessage` (same
|
|
445
520
|
* shape as {@link chat}); resolves when the turn completes, then closes the
|
|
446
521
|
* session. Returns the resolved session id.
|
|
522
|
+
*
|
|
523
|
+
* Like {@link chatSession}, the session is registered in {@link liveSessions}
|
|
524
|
+
* under a synthetic turn id handed back via `onJobCreated`, so Stop can
|
|
525
|
+
* interrupt a long-running command (#632). The session is still NOT managed by
|
|
526
|
+
* the reaper — teardown remains this method's own `finally`.
|
|
447
527
|
*/
|
|
448
528
|
runCommand(agentName: string, opts: {
|
|
449
529
|
command: string;
|
|
450
530
|
resume?: string | null;
|
|
451
531
|
onMessage?: (msg: SDKMessage) => void | Promise<void>;
|
|
532
|
+
/** Receives the synthetic turn id Stop cancels this command turn by (#632). */
|
|
533
|
+
onJobCreated?: (jobId: string) => void;
|
|
452
534
|
}): Promise<{
|
|
453
535
|
sessionId: string | null;
|
|
454
536
|
}>;
|
|
537
|
+
/**
|
|
538
|
+
* Register a background-phase turn so Stop can reach it (paddock#528). Called
|
|
539
|
+
* by the background turn sink once it knows both the synthetic job id it
|
|
540
|
+
* published and the session that stream belongs to. Re-registering the same
|
|
541
|
+
* job id with a later session id is fine — the sink refreshes the mapping if
|
|
542
|
+
* the stream resolves a different session.
|
|
543
|
+
*/
|
|
544
|
+
registerBackgroundTurn(jobId: string, sessionId: string): void;
|
|
545
|
+
/** Forget a background-phase turn once its stream has ended. */
|
|
546
|
+
unregisterBackgroundTurn(jobId: string): void;
|
|
455
547
|
/**
|
|
456
548
|
* Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
|
|
457
549
|
* modes off the single id the client holds as `jobId`:
|
|
458
550
|
* - **session mode** — the id is a synthetic turn id in {@link liveSessions};
|
|
459
551
|
* interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
|
|
552
|
+
* - **background phase** — the id is a synthetic id in
|
|
553
|
+
* {@link backgroundTurns}; there is no model turn to interrupt, so reap the
|
|
554
|
+
* session (paddock#528).
|
|
460
555
|
* - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
|
|
461
556
|
* (which kills the CLI subprocess / aborts the SDK query).
|
|
462
557
|
*
|
|
@@ -191,6 +191,24 @@ export class HerdctlService {
|
|
|
191
191
|
* {@link cancel}). Entries are removed when the turn's stream ends.
|
|
192
192
|
*/
|
|
193
193
|
liveSessions = new Map();
|
|
194
|
+
/**
|
|
195
|
+
* Background-phase turns keyed by the synthetic job id the sink minted for
|
|
196
|
+
* them, valued by the chat session they belong to (paddock#528).
|
|
197
|
+
*
|
|
198
|
+
* After a turn's primary `result` lands, {@link chatSession} returns and drops
|
|
199
|
+
* its {@link liveSessions} entry — but the session itself may live on, held
|
|
200
|
+
* open by the reaper because the turn launched background work, streaming
|
|
201
|
+
* autonomous re-invocation turns. Those turns are real, running, and shown as
|
|
202
|
+
* such; until now they were also uncancellable, because nothing registered
|
|
203
|
+
* them anywhere Stop could reach.
|
|
204
|
+
*
|
|
205
|
+
* `interrupt()` would be the wrong tool even if it could: it targets an
|
|
206
|
+
* in-flight model turn, and the wedge case is a session sitting idle holding
|
|
207
|
+
* background work that will never finish. So these route to a force-reap
|
|
208
|
+
* instead — end the session, let the stream end, and let the ordinary unwind
|
|
209
|
+
* emit `chat:complete`. Entries are removed when the background stream ends.
|
|
210
|
+
*/
|
|
211
|
+
backgroundTurns = new Map();
|
|
194
212
|
/**
|
|
195
213
|
* Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
|
|
196
214
|
* (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
|
|
@@ -208,6 +226,16 @@ export class HerdctlService {
|
|
|
208
226
|
constructor(cfg) {
|
|
209
227
|
this.cfg = cfg;
|
|
210
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* The Claude home every transcript symlink is planted in, plus whether paddock
|
|
231
|
+
* owns it (#620). One accessor, so no call site can quietly take a default and
|
|
232
|
+
* land in a different home than the FleetManager was built with — the bug
|
|
233
|
+
* `ensureSweeperHome` had, which put the sweeper's symlink in `~/.claude` while
|
|
234
|
+
* every other agent's went to the configured home.
|
|
235
|
+
*/
|
|
236
|
+
get claudeHomeTarget() {
|
|
237
|
+
return { path: this.cfg.claudeHome, owned: this.cfg.ownsClaudeHome };
|
|
238
|
+
}
|
|
211
239
|
/**
|
|
212
240
|
* The environment system prompt to append to a keeper turn (issue #635), or
|
|
213
241
|
* `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
|
|
@@ -297,7 +325,7 @@ export class HerdctlService {
|
|
|
297
325
|
// at the .chats store in the metadata dir — so repo-backed transcripts stay
|
|
298
326
|
// out of the external checkout's working tree (issue #187). For a notebook
|
|
299
327
|
// project workingDir === dir, so this is the classic behavior.
|
|
300
|
-
await ensureProjectChats(project.workingDir, project.dir, this.
|
|
328
|
+
await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
|
|
301
329
|
await this.ensureSweeperHome(project);
|
|
302
330
|
await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
|
|
303
331
|
await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
|
|
@@ -371,7 +399,7 @@ export class HerdctlService {
|
|
|
371
399
|
async ensureProjectAgent(project) {
|
|
372
400
|
if (!this.fleet)
|
|
373
401
|
return;
|
|
374
|
-
await ensureProjectChats(project.workingDir, project.dir, this.
|
|
402
|
+
await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
|
|
375
403
|
await this.ensureSweeperHome(project);
|
|
376
404
|
await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
|
|
377
405
|
await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
|
|
@@ -472,7 +500,7 @@ export class HerdctlService {
|
|
|
472
500
|
*/
|
|
473
501
|
adoptableIndexOrNull = null;
|
|
474
502
|
get adoptableIndex() {
|
|
475
|
-
return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir));
|
|
503
|
+
return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir, this.cfg.legacyClaudeHome));
|
|
476
504
|
}
|
|
477
505
|
/** What `project` could import right now (see `./adoptable.ts`). */
|
|
478
506
|
async listAdoptable(project) {
|
|
@@ -523,12 +551,51 @@ export class HerdctlService {
|
|
|
523
551
|
const mode = opts.mode ?? "copy";
|
|
524
552
|
const dryRun = opts.dryRun ?? false;
|
|
525
553
|
const summary = await this.listAdoptable(project);
|
|
554
|
+
// Translate the DISPLAY path a caller sends back into the path the engine
|
|
555
|
+
// actually reads that source through (#620). They differ for the user's own
|
|
556
|
+
// `~/.claude` history, which is reached via a mirror named for a synthetic
|
|
557
|
+
// path — handing the engine the recorded cwd would send it to the project's
|
|
558
|
+
// OWN transcript folder and import nothing.
|
|
559
|
+
//
|
|
560
|
+
// Filter rather than find: post-#620 the user's `~/.claude` folder for a cwd
|
|
561
|
+
// and the project's own `.chats/` are two different directories that both
|
|
562
|
+
// display as that cwd, so one display path can legitimately name two sources.
|
|
563
|
+
const engineDirs = (displayCwd) => {
|
|
564
|
+
const matched = summary.sources
|
|
565
|
+
.filter((s) => s.sourceCwd === displayCwd)
|
|
566
|
+
.map((s) => s.importFrom ?? s.sourceCwd);
|
|
567
|
+
// An unmatched path is one the route already validated as the project's own
|
|
568
|
+
// working directory; pass it straight through, as before.
|
|
569
|
+
return matched.length > 0 ? matched : [displayCwd];
|
|
570
|
+
};
|
|
526
571
|
const sources = opts.sourceCwd !== undefined
|
|
527
|
-
?
|
|
528
|
-
: summary.sources.map((s) => s.sourceCwd);
|
|
572
|
+
? engineDirs(opts.sourceCwd)
|
|
573
|
+
: summary.sources.map((s) => s.importFrom ?? s.sourceCwd);
|
|
529
574
|
// sessionId -> why detection withheld it.
|
|
530
575
|
const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
|
|
576
|
+
// A selection narrows the offer the same way the noise filter does, and for
|
|
577
|
+
// the same reason: the engine adopts a whole SOURCE, so anything the user did
|
|
578
|
+
// not tick has to be released again afterwards. Tracked separately from
|
|
579
|
+
// `withheld` because a session the user simply did not choose is not a
|
|
580
|
+
// "skip" worth reporting back — it was never part of this import, and listing
|
|
581
|
+
// 23 of them would read as an import that half failed.
|
|
582
|
+
const deselected = opts.sessionIds === undefined
|
|
583
|
+
? null
|
|
584
|
+
: (() => {
|
|
585
|
+
const wanted = new Set(opts.sessionIds);
|
|
586
|
+
const out = new Set();
|
|
587
|
+
for (const source of summary.sources) {
|
|
588
|
+
for (const id of source.sessionIds)
|
|
589
|
+
if (!wanted.has(id))
|
|
590
|
+
out.add(id);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
})();
|
|
531
594
|
const adopted = [];
|
|
595
|
+
// Of `adopted`, the ones where this call PLACED a file — the only ones undo
|
|
596
|
+
// may ever delete. Computed here, where `fromWorkingDir` is still known;
|
|
597
|
+
// afterwards nothing can tell a copy from an in-place adoption.
|
|
598
|
+
const copies = [];
|
|
532
599
|
const skipped = [];
|
|
533
600
|
for (const fromWorkingDir of sources) {
|
|
534
601
|
// Sequential, not parallel: each source re-scans the adoption store, so a
|
|
@@ -542,8 +609,20 @@ export class HerdctlService {
|
|
|
542
609
|
skipped.push(...result.skipped);
|
|
543
610
|
for (const sessionId of result.adopted) {
|
|
544
611
|
const reason = withheld.get(sessionId);
|
|
612
|
+
// Not chosen: release it, and say nothing about it. Checked BEFORE the
|
|
613
|
+
// noise filter so a deselected session stays silent even if it would
|
|
614
|
+
// also have been withheld.
|
|
615
|
+
if (deselected?.has(sessionId) === true) {
|
|
616
|
+
if (!dryRun) {
|
|
617
|
+
await this.releaseWithheld(agent, project, fromWorkingDir, sessionId, mode);
|
|
618
|
+
}
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
545
621
|
if (reason === undefined) {
|
|
546
622
|
adopted.push(sessionId);
|
|
623
|
+
if (mode === "copy" && !dryRun && this.placedACopy(project, fromWorkingDir, sessionId)) {
|
|
624
|
+
copies.push(sessionId);
|
|
625
|
+
}
|
|
547
626
|
continue;
|
|
548
627
|
}
|
|
549
628
|
skipped.push({ sessionId, reason });
|
|
@@ -558,6 +637,8 @@ export class HerdctlService {
|
|
|
558
637
|
this.invalidateSessions(agent);
|
|
559
638
|
this.jobsIndexOrNull?.invalidate();
|
|
560
639
|
this.invalidateAdoptable(project.slug);
|
|
640
|
+
// Replaces any earlier import: only the most recent one is undoable.
|
|
641
|
+
this.lastImport.set(project.slug, { adopted: [...adopted], copies });
|
|
561
642
|
}
|
|
562
643
|
// A session adopted from one source and then met again in the NEXT source
|
|
563
644
|
// (its fresh copy now sits in the project's own transcript folder) comes back
|
|
@@ -567,6 +648,81 @@ export class HerdctlService {
|
|
|
567
648
|
const done = new Set(adopted);
|
|
568
649
|
return { adopted, skipped: skipped.filter((s) => !done.has(s.sessionId)) };
|
|
569
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
|
|
653
|
+
*
|
|
654
|
+
* The same test `releaseWithheld` makes before deleting anything: an in-place
|
|
655
|
+
* adoption resolves the destination and the origin to one path, so there is no
|
|
656
|
+
* copy and nothing that may be removed.
|
|
657
|
+
*/
|
|
658
|
+
placedACopy(project, fromWorkingDir, sessionId) {
|
|
659
|
+
const home = this.cfg.claudeHome;
|
|
660
|
+
const placed = getCliSessionFile(project.workingDir, sessionId, home);
|
|
661
|
+
const origin = getCliSessionFile(fromWorkingDir, sessionId, home);
|
|
662
|
+
return path.resolve(placed) !== path.resolve(origin);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* What the LAST import into each workspace did, so it can be undone (#660).
|
|
666
|
+
*
|
|
667
|
+
* Keyed by workspace key (which may be `""` — the root). Holds the sessions
|
|
668
|
+
* that import actually brought in, and of those, the ones where it PLACED a
|
|
669
|
+
* copy and may therefore delete one again.
|
|
670
|
+
*
|
|
671
|
+
* Server-side rather than a token the client hands back: undo deletes files, so
|
|
672
|
+
* the set of paths it may touch must come from what this process actually did,
|
|
673
|
+
* not from a request body. A restart forgets it, which is the right failure —
|
|
674
|
+
* the offer to undo is a transient toast, not a durable feature.
|
|
675
|
+
*
|
|
676
|
+
* Only the most recent import per workspace is kept. Undoing an import two
|
|
677
|
+
* imports ago is not offered and would be a lie to describe as "undo".
|
|
678
|
+
*/
|
|
679
|
+
lastImport = new Map();
|
|
680
|
+
/**
|
|
681
|
+
* Undo the import this process most recently performed into `project` (#660).
|
|
682
|
+
*
|
|
683
|
+
* Releases each adoption and deletes the copies that import placed, leaving the
|
|
684
|
+
* user's own `~/.claude` history exactly as it was. Returns the sessions it
|
|
685
|
+
* released; an empty array means there was nothing to undo (nothing imported
|
|
686
|
+
* yet, already undone, or the server restarted since).
|
|
687
|
+
*
|
|
688
|
+
* `sessionIds` narrows it to part of that import — the rest stays. Ids that
|
|
689
|
+
* were not part of it are ignored rather than rejected: undo is a best-effort
|
|
690
|
+
* safety net, and the alternative is a 400 for a button the user pressed once.
|
|
691
|
+
*/
|
|
692
|
+
async unadoptChats(project, opts = {}) {
|
|
693
|
+
const agent = keeperAgentName(project.slug);
|
|
694
|
+
const last = this.lastImport.get(project.slug);
|
|
695
|
+
if (last === undefined)
|
|
696
|
+
return { released: [] };
|
|
697
|
+
const wanted = opts.sessionIds === undefined ? null : new Set(opts.sessionIds);
|
|
698
|
+
const target = last.adopted.filter((id) => wanted === null || wanted.has(id));
|
|
699
|
+
if (target.length === 0)
|
|
700
|
+
return { released: [] };
|
|
701
|
+
const copies = new Set(last.copies);
|
|
702
|
+
const released = [];
|
|
703
|
+
for (const sessionId of target) {
|
|
704
|
+
await this.manager.unadoptSession(agent, sessionId).catch(() => undefined);
|
|
705
|
+
// Delete ONLY a file this import placed. A session adopted in place has no
|
|
706
|
+
// copy, and its transcript is the user's own — removing it would destroy
|
|
707
|
+
// history rather than restore it.
|
|
708
|
+
if (copies.has(sessionId)) {
|
|
709
|
+
const placed = getCliSessionFile(project.workingDir, sessionId, this.cfg.claudeHome);
|
|
710
|
+
await fs.rm(placed, { force: true }).catch(() => undefined);
|
|
711
|
+
}
|
|
712
|
+
released.push(sessionId);
|
|
713
|
+
}
|
|
714
|
+
// Consume what was undone, so a second undo is a no-op rather than a repeat
|
|
715
|
+
// attempt to delete files that are already gone.
|
|
716
|
+
const remaining = last.adopted.filter((id) => !released.includes(id));
|
|
717
|
+
if (remaining.length === 0)
|
|
718
|
+
this.lastImport.delete(project.slug);
|
|
719
|
+
else
|
|
720
|
+
this.lastImport.set(project.slug, { adopted: remaining, copies: last.copies });
|
|
721
|
+
this.invalidateSessions(agent);
|
|
722
|
+
this.jobsIndexOrNull?.invalidate();
|
|
723
|
+
this.invalidateAdoptable(project.slug);
|
|
724
|
+
return { released };
|
|
725
|
+
}
|
|
570
726
|
/**
|
|
571
727
|
* Undo the adoption of a session detection had withheld, restoring the state
|
|
572
728
|
* that existed before this import. See {@link adoptChats} for why the file
|
|
@@ -832,9 +988,15 @@ export class HerdctlService {
|
|
|
832
988
|
* compacts the real chat history. SDK messages stream via `onMessage` (same
|
|
833
989
|
* shape as {@link chat}); resolves when the turn completes, then closes the
|
|
834
990
|
* session. Returns the resolved session id.
|
|
991
|
+
*
|
|
992
|
+
* Like {@link chatSession}, the session is registered in {@link liveSessions}
|
|
993
|
+
* under a synthetic turn id handed back via `onJobCreated`, so Stop can
|
|
994
|
+
* interrupt a long-running command (#632). The session is still NOT managed by
|
|
995
|
+
* the reaper — teardown remains this method's own `finally`.
|
|
835
996
|
*/
|
|
836
997
|
async runCommand(agentName, opts) {
|
|
837
998
|
const isResume = typeof opts.resume === "string";
|
|
999
|
+
const turnId = randomUUID();
|
|
838
1000
|
const session = await this.manager.openChatSession(agentName, {
|
|
839
1001
|
resume: opts.resume,
|
|
840
1002
|
// Stream the command's assistant text token-by-token (paddock#315).
|
|
@@ -843,6 +1005,14 @@ export class HerdctlService {
|
|
|
843
1005
|
// turn, so it gets the same environment context (#635).
|
|
844
1006
|
systemPromptAppend: this.environmentPromptAppend,
|
|
845
1007
|
});
|
|
1008
|
+
// Register the live session under the synthetic turn id exactly as
|
|
1009
|
+
// {@link chatSession} does, so `chat:cancel` routes to `session.interrupt()`
|
|
1010
|
+
// (#632) — without this there is nothing for {@link cancel} to resolve and
|
|
1011
|
+
// Stop is a no-op for the whole command. Removed in the same `finally` that
|
|
1012
|
+
// closes the session, so the entry never outlives the turn.
|
|
1013
|
+
this.liveSessions.set(turnId, session);
|
|
1014
|
+
// Surface the synthetic id as the turn's jobId so the client renders Stop.
|
|
1015
|
+
opts.onJobCreated?.(turnId);
|
|
846
1016
|
let sessionId = isResume ? opts.resume : null;
|
|
847
1017
|
try {
|
|
848
1018
|
// Gap A parity: BOTH branches consume via the non-closing consumeResumedTurn
|
|
@@ -873,15 +1043,35 @@ export class HerdctlService {
|
|
|
873
1043
|
// Stream error — fall through and let the caller surface completion.
|
|
874
1044
|
}
|
|
875
1045
|
finally {
|
|
1046
|
+
// Deregister BEFORE closing, so a Stop racing teardown can't reach a
|
|
1047
|
+
// session that is already shutting down.
|
|
1048
|
+
this.liveSessions.delete(turnId);
|
|
876
1049
|
await session.close();
|
|
877
1050
|
}
|
|
878
1051
|
return { sessionId };
|
|
879
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Register a background-phase turn so Stop can reach it (paddock#528). Called
|
|
1055
|
+
* by the background turn sink once it knows both the synthetic job id it
|
|
1056
|
+
* published and the session that stream belongs to. Re-registering the same
|
|
1057
|
+
* job id with a later session id is fine — the sink refreshes the mapping if
|
|
1058
|
+
* the stream resolves a different session.
|
|
1059
|
+
*/
|
|
1060
|
+
registerBackgroundTurn(jobId, sessionId) {
|
|
1061
|
+
this.backgroundTurns.set(jobId, sessionId);
|
|
1062
|
+
}
|
|
1063
|
+
/** Forget a background-phase turn once its stream has ended. */
|
|
1064
|
+
unregisterBackgroundTurn(jobId) {
|
|
1065
|
+
this.backgroundTurns.delete(jobId);
|
|
1066
|
+
}
|
|
880
1067
|
/**
|
|
881
1068
|
* Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
|
|
882
1069
|
* modes off the single id the client holds as `jobId`:
|
|
883
1070
|
* - **session mode** — the id is a synthetic turn id in {@link liveSessions};
|
|
884
1071
|
* interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
|
|
1072
|
+
* - **background phase** — the id is a synthetic id in
|
|
1073
|
+
* {@link backgroundTurns}; there is no model turn to interrupt, so reap the
|
|
1074
|
+
* session (paddock#528).
|
|
885
1075
|
* - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
|
|
886
1076
|
* (which kills the CLI subprocess / aborts the SDK query).
|
|
887
1077
|
*
|
|
@@ -901,6 +1091,19 @@ export class HerdctlService {
|
|
|
901
1091
|
return false;
|
|
902
1092
|
}
|
|
903
1093
|
}
|
|
1094
|
+
// Background phase: the primary turn is over and its liveSessions entry is
|
|
1095
|
+
// gone, but the session is still open streaming autonomous re-invocation
|
|
1096
|
+
// turns. Stop here means "end this session" — reaping reaches even a session
|
|
1097
|
+
// the reap policy would hold open forever, and ending the stream is what
|
|
1098
|
+
// drives the sink's onDone → chat:complete → the UI unlocking.
|
|
1099
|
+
const backgroundSession = this.backgroundTurns.get(jobId);
|
|
1100
|
+
if (backgroundSession !== undefined) {
|
|
1101
|
+
const reaped = this.manager.reapChatSession(backgroundSession);
|
|
1102
|
+
if (!reaped) {
|
|
1103
|
+
console.warn(`[herdctl] no live managed session ${backgroundSession} to reap for background turn ${jobId}`);
|
|
1104
|
+
}
|
|
1105
|
+
return reaped;
|
|
1106
|
+
}
|
|
904
1107
|
try {
|
|
905
1108
|
await this.manager.cancelJob(jobId);
|
|
906
1109
|
return true;
|
|
@@ -1268,7 +1471,7 @@ export class HerdctlService {
|
|
|
1268
1471
|
*/
|
|
1269
1472
|
async ensureSweeperHome(project) {
|
|
1270
1473
|
const dir = sweeperWorkingDir(this.cfg, project.slug);
|
|
1271
|
-
await ensureProjectChats(dir, dir);
|
|
1474
|
+
await ensureProjectChats(dir, dir, this.claudeHomeTarget);
|
|
1272
1475
|
}
|
|
1273
1476
|
triggerAgentConfig(project, triggerName, trigger) {
|
|
1274
1477
|
return buildTriggerConfig(this.cfg, project, triggerName, trigger);
|
|
@@ -37,17 +37,17 @@ export declare const HOOK_EVENTS: ReadonlySet<HookEvent>;
|
|
|
37
37
|
export type HookPermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan";
|
|
38
38
|
/**
|
|
39
39
|
* A hook's capability set (GG-1) — projected verbatim onto the hook's OWN herdctl
|
|
40
|
-
* agent's tool config, so the registered agent
|
|
41
|
-
* absent/empty {@link HookCapabilities.allowedTools}
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* higher-level profile.
|
|
40
|
+
* agent's tool config, so the registered agent carries exactly these tools. An
|
|
41
|
+
* absent/empty {@link HookCapabilities.allowedTools} DECLARES a **tool-less** hook;
|
|
42
|
+
* granting `Bash` lets it spin down servers / delete clones itself. This is
|
|
43
|
+
* intentionally the whole capability model — there is no higher-level profile.
|
|
45
44
|
*/
|
|
46
45
|
export interface HookCapabilities {
|
|
47
46
|
/**
|
|
48
|
-
* The tools the hook agent may use (herdctl `allowed_tools`). Omit or `[]`
|
|
49
|
-
* tool-less hook
|
|
50
|
-
* is the
|
|
47
|
+
* The tools the hook agent may use (herdctl `allowed_tools`). Omit or `[]` to
|
|
48
|
+
* declare a tool-less hook — but note herdctl emits the allow-list only when it
|
|
49
|
+
* is NON-empty, so `[]` is not enforced as a deny-all and the agent falls back to
|
|
50
|
+
* Claude Code's default tools (#647; enforcement itself is #319).
|
|
51
51
|
*/
|
|
52
52
|
allowedTools?: string[];
|
|
53
53
|
/** Tools explicitly denied even if otherwise allowed (herdctl `denied_tools`). */
|
|
@@ -120,7 +120,7 @@ export interface ChatHookInfo {
|
|
|
120
120
|
agentName: string;
|
|
121
121
|
/** Whether the hook is armed (a disabled hook's past chats are still shown). */
|
|
122
122
|
enabled: boolean;
|
|
123
|
-
/** The
|
|
123
|
+
/** The declared tool grant (herdctl `allowed_tools`); `[]` = none, unenforced (#647). */
|
|
124
124
|
allowedTools: string[];
|
|
125
125
|
/** Tools explicitly denied even if otherwise allowed, when the hook sets any. */
|
|
126
126
|
deniedTools?: string[];
|
|
@@ -207,11 +207,13 @@ export declare function sanitizeHook(raw: unknown): PaddockHook | null;
|
|
|
207
207
|
export declare function sanitizeHooks(raw: unknown): Record<string, PaddockHook> | undefined;
|
|
208
208
|
/**
|
|
209
209
|
* Project a hook's {@link HookCapabilities} onto the exact herdctl agent tool-config
|
|
210
|
-
* fields (snake_case), so the registered `hook-<slug>-<name>` agent
|
|
211
|
-
* capability BY CONSTRUCTION. A
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
210
|
+
* fields (snake_case), so the registered `hook-<slug>-<name>` agent carries the
|
|
211
|
+
* capability BY CONSTRUCTION. A hook that declares no tools yields
|
|
212
|
+
* `allowed_tools: []`, which herdctl does NOT enforce as a deny-all — the same
|
|
213
|
+
* caveat spelled out on `triggerToAgentToolConfig` in `trigger-config.ts` (#647).
|
|
214
|
+
* Always sets `allowed_tools` + `max_turns` so a hook agent never silently inherits
|
|
215
|
+
* the keeper's broad default toolset; the other fields are set only when the
|
|
216
|
+
* capability specifies them (else the fleet defaults apply). This is the ONE place
|
|
217
|
+
* capability→config translation lives.
|
|
216
218
|
*/
|
|
217
219
|
export declare function hookToAgentToolConfig(caps: HookCapabilities | undefined): Record<string, unknown>;
|
|
@@ -199,18 +199,20 @@ export function sanitizeHooks(raw) {
|
|
|
199
199
|
}
|
|
200
200
|
/**
|
|
201
201
|
* Project a hook's {@link HookCapabilities} onto the exact herdctl agent tool-config
|
|
202
|
-
* fields (snake_case), so the registered `hook-<slug>-<name>` agent
|
|
203
|
-
* capability BY CONSTRUCTION. A
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
202
|
+
* fields (snake_case), so the registered `hook-<slug>-<name>` agent carries the
|
|
203
|
+
* capability BY CONSTRUCTION. A hook that declares no tools yields
|
|
204
|
+
* `allowed_tools: []`, which herdctl does NOT enforce as a deny-all — the same
|
|
205
|
+
* caveat spelled out on `triggerToAgentToolConfig` in `trigger-config.ts` (#647).
|
|
206
|
+
* Always sets `allowed_tools` + `max_turns` so a hook agent never silently inherits
|
|
207
|
+
* the keeper's broad default toolset; the other fields are set only when the
|
|
208
|
+
* capability specifies them (else the fleet defaults apply). This is the ONE place
|
|
209
|
+
* capability→config translation lives.
|
|
208
210
|
*/
|
|
209
211
|
export function hookToAgentToolConfig(caps) {
|
|
210
212
|
const out = {
|
|
211
|
-
// A hook's grant is exactly its allowedTools — default to NONE
|
|
212
|
-
//
|
|
213
|
-
//
|
|
213
|
+
// A hook's grant is exactly its allowedTools — default to NONE, never the
|
|
214
|
+
// keeper's broad inherited allowlist. An empty list expresses "no capability",
|
|
215
|
+
// though herdctl never emits it, so it is not enforced as one (#647).
|
|
214
216
|
allowed_tools: caps?.allowedTools ?? [],
|
|
215
217
|
max_turns: caps?.maxTurns ?? HOOK_DEFAULT_MAX_TURNS,
|
|
216
218
|
};
|