@edspencer/paddock 0.60.0 → 0.61.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/LICENSE +21 -0
- package/README.md +44 -3
- package/package.json +3 -2
- package/packages/server/dist/adoptable.d.ts +36 -2
- package/packages/server/dist/adoptable.js +51 -11
- package/packages/server/dist/agent-errors.d.ts +41 -0
- package/packages/server/dist/agent-errors.js +127 -0
- package/packages/server/dist/app.js +36 -0
- package/packages/server/dist/claude-home.d.ts +192 -0
- package/packages/server/dist/claude-home.js +387 -0
- package/packages/server/dist/cli/args.d.ts +9 -1
- package/packages/server/dist/cli/args.js +21 -5
- package/packages/server/dist/cli/claude-home-choice.d.ts +44 -0
- package/packages/server/dist/cli/claude-home-choice.js +108 -0
- package/packages/server/dist/cli/import-chats.js +9 -6
- package/packages/server/dist/cli/paddock.js +54 -33
- 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 +15 -0
- package/packages/server/dist/herdctl.js +50 -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 +33 -6
- package/packages/server/dist/sweep.js +13 -1
- package/packages/server/dist/transcripts.d.ts +32 -6
- package/packages/server/dist/transcripts.js +30 -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.js +43 -11
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-xn9Rvoah.js → InstanceConfigPage-BgNXqAox.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DMONssKA.js → MarkdownRenderer-DfD_UtqU.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-DHoWtvtY.js → ProjectRedirect-1EXdHXmO.js} +1 -1
- package/packages/web/dist/assets/ProjectView-DHb5vNUp.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CjfwpeFj.js → ProjectsGrid-Br-EhLZE.js} +1 -1
- package/packages/web/dist/assets/{arc-DdFdbD-c.js → arc-C2Jekujs.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DC8UKtwV.js → architectureDiagram-3BPJPVTR-CflCA-em.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DqFFe_5c.js → blockDiagram-GPEHLZMM-Ds7GLNnC.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DbffFSM-.js → c4Diagram-AAUBKEIU-D4WyGO6I.js} +1 -1
- package/packages/web/dist/assets/channel-B1K_gwAH.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-8PP2RHft.js → chunk-2J33WTMH-tQunO_M5.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Hag68B5N.js → chunk-4BX2VUAB-DT5dOWd2.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BXp_gQIa.js → chunk-55IACEB6-CfTUqUEF.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DlntjQux.js → chunk-727SXJPM-sK5bOAA0.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BhI9KPrE.js → chunk-AQP2D5EJ-DcMgVe-P.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-WhfORs2z.js → chunk-FMBD7UC4-In3h_-7v.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CIskmv20.js → chunk-ND2GUHAM-DuaIeRaC.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CNOTpCW4.js → chunk-QZHKN3VN-DeT5mR5T.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +2 -0
- package/packages/web/dist/assets/{core-usOVRkLM.js → core-DgOgLvnM.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CniuMIIW.js → cose-bilkent-S5V4N54A-CyO-rznJ.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-5CteYfQY.js → dagre-BM42HDAG-B_9Ihzgn.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BIQjD2LA.js → diagram-2AECGRRQ-z5mvZ5YT.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-1vy3ErTt.js → diagram-5GNKFQAL-Bsz0cgfn.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-D45iqFk6.js → diagram-KO2AKTUF-B29k5f4q.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DNktDktn.js → diagram-LMA3HP47-CFrwsHoI.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-BQ9JgV70.js → diagram-OG6HWLK6-D9Sjc2HR.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-oBasZ1uv.js → erDiagram-TEJ5UH35-C3vbkD7M.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DxnJHMBS.js → flowDiagram-I6XJVG4X-DkMIgntd.js} +1 -1
- package/packages/web/dist/assets/{format-CIAat6sU.js → format-BFDqyFuw.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CtJQ2m7z.js → ganttDiagram-6RSMTGT7-nvHanjpW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5RPscwD_.js → gitGraphDiagram-PVQCEYII-DbCNfOu7.js} +1 -1
- package/packages/web/dist/assets/index-DGCs1YIO.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-zQu3xkW4.js → infoDiagram-5YYISTIA-aJjH9x3D.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-ChoEjcdw.js → ishikawaDiagram-YF4QCWOH-DoifwNnZ.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DM2KWmDr.js → journeyDiagram-JHISSGLW-Cz9kG9HN.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DWzTpYgh.js → kanban-definition-UN3LZRKU-BoXnfr7A.js} +1 -1
- package/packages/web/dist/assets/{linear-Bpy9Aj-K.js → linear-BlTTO9yR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D43x5bFb.js → mermaid.core-YAG8eSPQ.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BhceB-cv.js → mindmap-definition-RKZ34NQL-DtUBKfkr.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BgcGlFFA.js → pieDiagram-4H26LBE5-B3Rwpmy0.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZRVaOoSp.js → quadrantDiagram-W4KKPZXB-B8eJtTyk.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-2hHcJHBt.js → requirementDiagram-4Y6WPE33-CpAluiY9.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js → sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-D4BXQSSM.js → sequenceDiagram-3UESZ5HK-CRpqKSGm.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DftS7dIo.js → stateDiagram-AJRCARHV-h3h8gXv_.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C0OAxyWY.js → timeline-definition-PNZ67QCA-PVr5V_pG.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C94fINN3.js → vennDiagram-CIIHVFJN-De-4NPAX.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CbFrlwHE.js → wardley-L42UT6IY-Dtv10D2d.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CHG2nndd.js → wardleyDiagram-YWT4CUSO-p2fJuQ6C.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-U9ESc19z.js → xychartDiagram-2RQKCTM6-DyYF-k92.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +0 -13
- package/packages/web/dist/assets/channel-QxsJGL6F.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +0 -2
- package/packages/web/dist/assets/index-DtRblEY_.js +0 -71
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +0 -2
|
@@ -165,6 +165,14 @@ export declare class HerdctlService {
|
|
|
165
165
|
private jobsIndexOrNull;
|
|
166
166
|
private get jobsIndex();
|
|
167
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();
|
|
168
176
|
/**
|
|
169
177
|
* The environment system prompt to append to a keeper turn (issue #635), or
|
|
170
178
|
* `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
|
|
@@ -511,11 +519,18 @@ export declare class HerdctlService {
|
|
|
511
519
|
* compacts the real chat history. SDK messages stream via `onMessage` (same
|
|
512
520
|
* shape as {@link chat}); resolves when the turn completes, then closes the
|
|
513
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`.
|
|
514
527
|
*/
|
|
515
528
|
runCommand(agentName: string, opts: {
|
|
516
529
|
command: string;
|
|
517
530
|
resume?: string | null;
|
|
518
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;
|
|
519
534
|
}): Promise<{
|
|
520
535
|
sessionId: string | null;
|
|
521
536
|
}>;
|
|
@@ -226,6 +226,16 @@ export class HerdctlService {
|
|
|
226
226
|
constructor(cfg) {
|
|
227
227
|
this.cfg = cfg;
|
|
228
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
|
+
}
|
|
229
239
|
/**
|
|
230
240
|
* The environment system prompt to append to a keeper turn (issue #635), or
|
|
231
241
|
* `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
|
|
@@ -315,7 +325,7 @@ export class HerdctlService {
|
|
|
315
325
|
// at the .chats store in the metadata dir — so repo-backed transcripts stay
|
|
316
326
|
// out of the external checkout's working tree (issue #187). For a notebook
|
|
317
327
|
// project workingDir === dir, so this is the classic behavior.
|
|
318
|
-
await ensureProjectChats(project.workingDir, project.dir, this.
|
|
328
|
+
await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
|
|
319
329
|
await this.ensureSweeperHome(project);
|
|
320
330
|
await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
|
|
321
331
|
await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
|
|
@@ -389,7 +399,7 @@ export class HerdctlService {
|
|
|
389
399
|
async ensureProjectAgent(project) {
|
|
390
400
|
if (!this.fleet)
|
|
391
401
|
return;
|
|
392
|
-
await ensureProjectChats(project.workingDir, project.dir, this.
|
|
402
|
+
await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
|
|
393
403
|
await this.ensureSweeperHome(project);
|
|
394
404
|
await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
|
|
395
405
|
await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
|
|
@@ -490,7 +500,7 @@ export class HerdctlService {
|
|
|
490
500
|
*/
|
|
491
501
|
adoptableIndexOrNull = null;
|
|
492
502
|
get adoptableIndex() {
|
|
493
|
-
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));
|
|
494
504
|
}
|
|
495
505
|
/** What `project` could import right now (see `./adoptable.ts`). */
|
|
496
506
|
async listAdoptable(project) {
|
|
@@ -541,9 +551,26 @@ export class HerdctlService {
|
|
|
541
551
|
const mode = opts.mode ?? "copy";
|
|
542
552
|
const dryRun = opts.dryRun ?? false;
|
|
543
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
|
+
};
|
|
544
571
|
const sources = opts.sourceCwd !== undefined
|
|
545
|
-
?
|
|
546
|
-
: summary.sources.map((s) => s.sourceCwd);
|
|
572
|
+
? engineDirs(opts.sourceCwd)
|
|
573
|
+
: summary.sources.map((s) => s.importFrom ?? s.sourceCwd);
|
|
547
574
|
// sessionId -> why detection withheld it.
|
|
548
575
|
const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
|
|
549
576
|
// A selection narrows the offer the same way the noise filter does, and for
|
|
@@ -961,9 +988,15 @@ export class HerdctlService {
|
|
|
961
988
|
* compacts the real chat history. SDK messages stream via `onMessage` (same
|
|
962
989
|
* shape as {@link chat}); resolves when the turn completes, then closes the
|
|
963
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`.
|
|
964
996
|
*/
|
|
965
997
|
async runCommand(agentName, opts) {
|
|
966
998
|
const isResume = typeof opts.resume === "string";
|
|
999
|
+
const turnId = randomUUID();
|
|
967
1000
|
const session = await this.manager.openChatSession(agentName, {
|
|
968
1001
|
resume: opts.resume,
|
|
969
1002
|
// Stream the command's assistant text token-by-token (paddock#315).
|
|
@@ -972,6 +1005,14 @@ export class HerdctlService {
|
|
|
972
1005
|
// turn, so it gets the same environment context (#635).
|
|
973
1006
|
systemPromptAppend: this.environmentPromptAppend,
|
|
974
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);
|
|
975
1016
|
let sessionId = isResume ? opts.resume : null;
|
|
976
1017
|
try {
|
|
977
1018
|
// Gap A parity: BOTH branches consume via the non-closing consumeResumedTurn
|
|
@@ -1002,6 +1043,9 @@ export class HerdctlService {
|
|
|
1002
1043
|
// Stream error — fall through and let the caller surface completion.
|
|
1003
1044
|
}
|
|
1004
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);
|
|
1005
1049
|
await session.close();
|
|
1006
1050
|
}
|
|
1007
1051
|
return { sessionId };
|
|
@@ -1427,7 +1471,7 @@ export class HerdctlService {
|
|
|
1427
1471
|
*/
|
|
1428
1472
|
async ensureSweeperHome(project) {
|
|
1429
1473
|
const dir = sweeperWorkingDir(this.cfg, project.slug);
|
|
1430
|
-
await ensureProjectChats(dir, dir);
|
|
1474
|
+
await ensureProjectChats(dir, dir, this.claudeHomeTarget);
|
|
1431
1475
|
}
|
|
1432
1476
|
triggerAgentConfig(project, triggerName, trigger) {
|
|
1433
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
|
};
|
|
@@ -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);
|
|
@@ -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);
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { promises as fs } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
40
|
import { keeperAgentName } from "./herdctl.js";
|
|
41
|
+
import { classifyAgentError } from "./agent-errors.js";
|
|
41
42
|
import { DEFAULT_CURATION, resolveCurationConfig, } from "./curation-config.js";
|
|
42
43
|
import { curatorTriggerOf, triggerPromptFileAbsPath, } from "./trigger-config.js";
|
|
43
44
|
const STATE_FILE = "sweep-state.json";
|
|
@@ -204,7 +205,18 @@ export class SweepService {
|
|
|
204
205
|
lastSweptSessionMtime: wm.lastSweptSessionMtime,
|
|
205
206
|
lastSweptAt: Date.now(),
|
|
206
207
|
});
|
|
207
|
-
this
|
|
208
|
+
// #684: this is a best-effort background task whose failure is already
|
|
209
|
+
// labelled non-fatal, so a recognised cause gets ONE line at warn — no
|
|
210
|
+
// `err`, because pino serialising it is what put four copies of the
|
|
211
|
+
// curator system prompt on a first-time user's terminal. An unrecognised
|
|
212
|
+
// failure keeps the full object: that is the case somebody needs it.
|
|
213
|
+
const cause = classifyAgentError(err);
|
|
214
|
+
if (cause === undefined) {
|
|
215
|
+
this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
this.log.warn({ slug }, `sweep: skipped, ${cause} (non-fatal — chat is unaffected)`);
|
|
219
|
+
}
|
|
208
220
|
}
|
|
209
221
|
finally {
|
|
210
222
|
this.running.delete(slug);
|
|
@@ -21,13 +21,39 @@ 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 EVERY write into the home (#620, #682). Two of them:
|
|
35
|
+
*
|
|
36
|
+
* - Migrating an existing real transcript directory into `.chats/` copies the
|
|
37
|
+
* files and then REMOVES the originals. That is correct self-healing inside a
|
|
38
|
+
* home paddock owns, where such a directory can only be paddock's own doing.
|
|
39
|
+
* Inside the user's `~/.claude` it is somebody else's data — a `claude` CLI
|
|
40
|
+
* session run in a directory paddock happens to also manage — so we leave it
|
|
41
|
+
* strictly alone and let the SDK keep writing there.
|
|
42
|
+
* - Planting the redirect symlink at a path that does not exist yet. Not
|
|
43
|
+
* destructive on the day it happens, which is why it was missed, but it
|
|
44
|
+
* silently claims where the user's FUTURE sessions for that directory get
|
|
45
|
+
* written (#682). See the branch itself for the full argument.
|
|
46
|
+
*
|
|
47
|
+
* Either way those transcripts stay importable through adoption (#588), which is
|
|
48
|
+
* the deliberate, user-driven version of the same move.
|
|
29
49
|
*/
|
|
30
|
-
export
|
|
50
|
+
export interface ClaudeHomeTarget {
|
|
51
|
+
/** Absolute path to the Claude home to plant the redirect symlink in. */
|
|
52
|
+
path: string;
|
|
53
|
+
/** Whether paddock owns this home — see `PaddockConfig.ownsClaudeHome`. */
|
|
54
|
+
owned: boolean;
|
|
55
|
+
}
|
|
56
|
+
export declare function ensureProjectChats(workingDir: string, chatsHostDir: string, home: ClaudeHomeTarget): Promise<void>;
|
|
31
57
|
/**
|
|
32
58
|
* Read a session's FIRST user message text, untruncated, straight from its
|
|
33
59
|
* 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)) {
|
|
@@ -88,6 +76,24 @@ export async function ensureProjectChats(workingDir, chatsHostDir = workingDir,
|
|
|
88
76
|
await fs.symlink(chatsDir, encoded);
|
|
89
77
|
return;
|
|
90
78
|
}
|
|
79
|
+
// Nothing there yet. In a home paddock does NOT own, planting here is the
|
|
80
|
+
// data-loss branch (#682): the encoded path is where the USER's future
|
|
81
|
+
// `claude` sessions for this directory land, so a link redirects every one of
|
|
82
|
+
// them into paddock's `.chats/` — from that moment "their history" and "our
|
|
83
|
+
// store" are the same files, and an ordinary `rm -rf .chats` destroys
|
|
84
|
+
// transcripts paddock never owned. It also breaks unrelated tooling that
|
|
85
|
+
// expects `~/.claude/projects/<enc>` to be a directory. Creating a name the
|
|
86
|
+
// user has not used yet is not "overwriting", so it slipped past the letter
|
|
87
|
+
// of claude-home.ts's invariant while breaking its intent.
|
|
88
|
+
//
|
|
89
|
+
// The cost of bailing is that transcripts stay in the user's home instead of
|
|
90
|
+
// being self-contained in `.chats/`, which is exactly the pre-#620 trade for
|
|
91
|
+
// the `CLAUDE_HOME=~/.claude` escape hatch. Chat still works end to end:
|
|
92
|
+
// paddock and the engine both resolve THIS home, so discovery, resume and
|
|
93
|
+
// rename all read the real directory. Adoption (#588) remains the deliberate,
|
|
94
|
+
// user-driven way to pull those transcripts into a project.
|
|
95
|
+
if (!home.owned)
|
|
96
|
+
return;
|
|
91
97
|
// Nothing there yet — just create the symlink so future turns land in .chats.
|
|
92
98
|
await fs.symlink(chatsDir, encoded);
|
|
93
99
|
}
|
|
@@ -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
|
*/
|