@edspencer/paddock 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -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/management-ops.js +40 -1
- package/packages/server/dist/management-policy.d.ts +4 -3
- package/packages/server/dist/management-policy.js +5 -2
- package/packages/server/dist/projects.d.ts +23 -3
- package/packages/server/dist/projects.js +54 -7
- 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/self-mcp-descriptions.d.ts +1 -0
- package/packages/server/dist/self-mcp-descriptions.js +14 -1
- package/packages/server/dist/self-mcp-types.d.ts +26 -4
- package/packages/server/dist/self-mcp-write.d.ts +21 -0
- package/packages/server/dist/self-mcp-write.js +44 -0
- package/packages/server/dist/self-mcp.d.ts +7 -6
- package/packages/server/dist/self-mcp.js +36 -10
- 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 +76 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-R6YzfI9Y.js → MarkdownRenderer-Bn6VPBdL.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Cy-7Thc-.js → ProjectRedirect-CQmDBtd-.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-C9XHf4eS.js → ProjectsGrid-BW56zqke.js} +1 -1
- package/packages/web/dist/assets/{arc-B_42F7Yk.js → arc-CGy6WkLl.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BAXzSOyw.js → architectureDiagram-3BPJPVTR-DJH1WIxY.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BdYEdA-3.js → blockDiagram-GPEHLZMM-Cufb_pvX.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cc4o15R5.js → c4Diagram-AAUBKEIU-glqeg1Ky.js} +1 -1
- package/packages/web/dist/assets/channel-C4kPYn_d.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-CeuxHjIm.js → chunk-2J33WTMH-SOFTHYgh.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DnZfrAaV.js → chunk-4BX2VUAB-DFxFAiG8.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DFE6UYOT.js → chunk-55IACEB6-CEEDxZVG.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-YVHQ5cHc.js → chunk-727SXJPM--3eiptsh.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-5BYSbg-F.js → chunk-AQP2D5EJ-DIHIugCo.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-HBcelndR.js → chunk-FMBD7UC4-BmxWZl5c.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-2AwEsdxJ.js → chunk-ND2GUHAM-hMHtJs_I.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-BPpaZ6ct.js → chunk-QZHKN3VN-CRnnasyy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +2 -0
- package/packages/web/dist/assets/{core-Cu_4vw1U.js → core-CGW2CVAY.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DWkF0NrF.js → cose-bilkent-S5V4N54A-kcl9qSfK.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DTgNmO9I.js → dagre-BM42HDAG-BiLsFx1W.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DC28b7YS.js → diagram-2AECGRRQ-BcIpoY66.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-_WvuLno6.js → diagram-5GNKFQAL-BK5p5kJa.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BEYxPfWq.js → diagram-KO2AKTUF-BmJYalCP.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-Biyev7x0.js → diagram-LMA3HP47-BHs0SWEM.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-u47NLW3S.js → diagram-OG6HWLK6-CSs8hie4.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bud2ISlK.js → erDiagram-TEJ5UH35-4qd_Td27.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D-18vLQ1.js → flowDiagram-I6XJVG4X-CstUgr7V.js} +1 -1
- package/packages/web/dist/assets/{format-CQ2BDlby.js → format-B_kAvAW3.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CzyRX39N.js → ganttDiagram-6RSMTGT7-DTn9OuIc.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DLogfrjg.js → gitGraphDiagram-PVQCEYII-E_bCor2O.js} +1 -1
- package/packages/web/dist/assets/{index-B0gJ0qWe.js → index-B-QAOq9a.js} +2 -2
- package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-OgNPt6RG.js → infoDiagram-5YYISTIA-foRUrIOQ.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CrM0t0_X.js → ishikawaDiagram-YF4QCWOH-CTON8v5t.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-xbxfHfwR.js → journeyDiagram-JHISSGLW-D-qwGjr7.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DpszBaZb.js → kanban-definition-UN3LZRKU-5l4y83rE.js} +1 -1
- package/packages/web/dist/assets/{linear-BLujdbjQ.js → linear-B1GiUWpI.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DzEhy9Qq.js → mermaid.core-btQIOU6d.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DaH3Lmoo.js → mindmap-definition-RKZ34NQL-DduczfMv.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxeW1kuf.js → pieDiagram-4H26LBE5-D86uGusc.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BKs2CT0r.js → quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CVdO-hXD.js → requirementDiagram-4Y6WPE33-C9aZJK93.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-yusj_kJC.js → sankeyDiagram-5OEKKPKP-b__W6SAJ.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C0h-9c8Y.js → sequenceDiagram-3UESZ5HK-Lr4Df8kX.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-BSBY8Psb.js → stateDiagram-AJRCARHV-R0XxGxD5.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DEFUdNb8.js → timeline-definition-PNZ67QCA-D42Je9LW.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-DunhMqxK.js → vennDiagram-CIIHVFJN-KCu14-QB.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CXJsBGKl.js → wardley-L42UT6IY-Cfir3V9o.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DfT_-dqX.js → wardleyDiagram-YWT4CUSO-BkXjbTXu.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BysTtULW.js → xychartDiagram-2RQKCTM6-BVT60Pln.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-Blr1a-hx.js +0 -2
- package/packages/web/dist/assets/ProjectView-bMX4GEau.js +0 -13
- package/packages/web/dist/assets/channel-BZnRb_8C.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +0 -2
- package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +0 -2
|
@@ -96,7 +96,7 @@ export const FORK_CHAT_BATCH_DESC = "FAN-OUT primitive: fork ONE source chat int
|
|
|
96
96
|
`"<name_prefix> <i>" (1-based). With \`model\`, every fork's kickoff turn runs on ` +
|
|
97
97
|
`that model. Up to ${FORK_BATCH_MAX} forks per call; they run ` +
|
|
98
98
|
"concurrently. Returns the source and every new child's sessionId.";
|
|
99
|
-
// ── Project tools (
|
|
99
|
+
// ── Project tools (issues #467, #470) ───────────────────────────────────────
|
|
100
100
|
export const CREATE_PROJECT_DESC = "Create a BRAND-NEW Paddock project — the workspace a keeper agent lives in — and " +
|
|
101
101
|
"register its keeper. `name` is the only required argument; the kebab-case `slug` " +
|
|
102
102
|
"is derived from it unless you pass one. Two kinds: pass a `repo` git URL for a " +
|
|
@@ -109,6 +109,19 @@ export const CREATE_PROJECT_DESC = "Create a BRAND-NEW Paddock project — the w
|
|
|
109
109
|
"pass that slug to `create_chat` to start working in it. A bad or unreachable " +
|
|
110
110
|
"repo URL fails cleanly and leaves NO half-created project behind, so it is safe " +
|
|
111
111
|
"to retry with a corrected URL.";
|
|
112
|
+
export const PROMOTE_PROJECT_DESC = "Turn an EXISTING notebook project into a REPO-BACKED one, in place — use this " +
|
|
113
|
+
"when a notes-only project has grown into (or was always meant to be) a codebase, " +
|
|
114
|
+
"instead of creating a second project and abandoning the first. `repo` (a git URL) " +
|
|
115
|
+
"is required; `project` defaults to the CURRENT project, so an agent can promote " +
|
|
116
|
+
"the workspace it is running in. The repo is cloned into a nested checkout inside " +
|
|
117
|
+
"the project, that checkout becomes the agent's working directory, and the agent is " +
|
|
118
|
+
"re-registered there. Everything already in the project is KEPT — its existing " +
|
|
119
|
+
"chats stay listed and resumable, and its OVERVIEW/CHANGELOG/settings are " +
|
|
120
|
+
"untouched. Not reversible through this MCP, and only ever notebook → repo-backed: " +
|
|
121
|
+
"a project that is ALREADY repo-backed is refused, as is the root workspace. A bad " +
|
|
122
|
+
"or unreachable repo URL fails cleanly, leaving the notebook exactly as it was, so " +
|
|
123
|
+
"it is safe to retry with a corrected URL. Returns the project's new working " +
|
|
124
|
+
"directory and whether its agent re-registered.";
|
|
112
125
|
// ── Unified trigger tools (Epic T / T3) ─────────────────────────────────────
|
|
113
126
|
export const SET_TRIGGER_DESC = "Create or update a unified project TRIGGER — a standing rule that runs an agent " +
|
|
114
127
|
"turn when something happens, keyed by `name`. Enable/disable is just this call " +
|
|
@@ -54,6 +54,13 @@ export interface SelfMcpCreatedProject {
|
|
|
54
54
|
*/
|
|
55
55
|
agentRegistered: boolean;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A promoted project as surfaced back to the agent (issue #470). Declared as an
|
|
59
|
+
* ALIAS rather than a second copy of the same fields: promotion's whole point is
|
|
60
|
+
* that the project ends up in exactly the state a repo-backed `create_project`
|
|
61
|
+
* would have produced, so the two reports must not be able to drift.
|
|
62
|
+
*/
|
|
63
|
+
export type SelfMcpPromotedProject = SelfMcpCreatedProject;
|
|
57
64
|
/** A chat as surfaced to the agent. */
|
|
58
65
|
export interface SelfMcpChat {
|
|
59
66
|
/** Owning project slug. */
|
|
@@ -192,10 +199,11 @@ export interface SelfMcpWriteContext {
|
|
|
192
199
|
/** Set (or clear) a chat's archived flag (presentational metadata only). */
|
|
193
200
|
setArchived: (projectSlug: string, sessionId: string, archived: boolean) => Promise<void>;
|
|
194
201
|
/**
|
|
195
|
-
* Whether the PROJECT tools (`create_project`, issue #467
|
|
196
|
-
* instance — the `selfMcpProjectsEnabled` gate,
|
|
197
|
-
* the
|
|
198
|
-
* binary discipline as
|
|
202
|
+
* Whether the PROJECT tools (`create_project`, issue #467; `promote_project`,
|
|
203
|
+
* issue #470) are enabled for this instance — the `selfMcpProjectsEnabled` gate,
|
|
204
|
+
* resolved by the caller. When false the tools are NOT injected at all (absent,
|
|
205
|
+
* not present-but-refusing), the same binary discipline as
|
|
206
|
+
* {@link triggersMcpEnabled}.
|
|
199
207
|
*/
|
|
200
208
|
projectsMcpEnabled: boolean;
|
|
201
209
|
/**
|
|
@@ -208,6 +216,20 @@ export interface SelfMcpWriteContext {
|
|
|
208
216
|
* non-fatal (mirroring the route) and reported via `agentRegistered`.
|
|
209
217
|
*/
|
|
210
218
|
createProject: (input: SelfMcpCreateProjectInput) => Promise<SelfMcpCreatedProject>;
|
|
219
|
+
/**
|
|
220
|
+
* Promote an EXISTING notebook project to repo-backed in place, and re-register
|
|
221
|
+
* its agent — the SAME two steps `POST /api/projects/:slug/promote` performs, in
|
|
222
|
+
* the same order (`ProjectStore.promote` then `ensureProjectAgent`), so the REST
|
|
223
|
+
* and MCP paths cannot drift (issue #470/#213). All the real work (the already-
|
|
224
|
+
* repo-backed / root-workspace / bad-URL guards, the clone into the nested
|
|
225
|
+
* checkout, the `writeYaml` commit point and its rollback to an UNTOUCHED
|
|
226
|
+
* notebook on failure) stays in the store. Throws on a promote failure; a
|
|
227
|
+
* re-registration failure is non-fatal (mirroring the route) and reported via
|
|
228
|
+
* `agentRegistered` — though it matters more here than for create, since
|
|
229
|
+
* re-registration is what re-symlinks the new working dir at the project's
|
|
230
|
+
* existing `.chats/` store so its chats stay listed and resumable.
|
|
231
|
+
*/
|
|
232
|
+
promoteProject: (projectSlug: string, repo: string) => Promise<SelfMcpPromotedProject>;
|
|
211
233
|
/**
|
|
212
234
|
* Whether the unified trigger-management MCP is enabled for THIS turn's project
|
|
213
235
|
* (Epic T / T3 — the successor to the G5 hook-MCP gate, resolved from the SAME
|
|
@@ -32,4 +32,25 @@ export declare function archiveChatHandler(write: SelfMcpWriteContext, archived:
|
|
|
32
32
|
* an unclonable repo, and still rolls the whole project dir back on a clone failure.
|
|
33
33
|
*/
|
|
34
34
|
export declare function createProjectHandler(write: SelfMcpWriteContext): (args: Record<string, unknown>) => Promise<McpToolCallResult>;
|
|
35
|
+
/**
|
|
36
|
+
* `promote_project` (issue #470) — turn an EXISTING notebook project into a
|
|
37
|
+
* repo-backed one in place, over the same `ProjectStore.promote` (#213) that
|
|
38
|
+
* `POST /api/projects/:slug/promote` uses. Shares `create_project`'s
|
|
39
|
+
* `projectsMcpEnabled` gate: same blast-radius class (it clones a caller-supplied
|
|
40
|
+
* URL and restructures a project), and this one MUTATES existing state.
|
|
41
|
+
*
|
|
42
|
+
* Pure, like its siblings: it resolves the target (defaulting to the current
|
|
43
|
+
* project, as every other project-targeting write tool does), shape-checks the URL
|
|
44
|
+
* with the very same `isValidRepoUrl` the store uses, and delegates. Everything
|
|
45
|
+
* that makes promotion safe — the already-repo-backed / root-workspace / existing-
|
|
46
|
+
* checkout-dir guards, and the rollback that leaves an untouched notebook when the
|
|
47
|
+
* clone fails — stays in the store; nothing is re-implemented here.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately NO up-front `SLUG_RE` check on the target, unlike `create_project`:
|
|
50
|
+
* there the slug names a project being INVENTED, here it names one that must
|
|
51
|
+
* already exist, so the store's own `not_found` is both the real check and the
|
|
52
|
+
* better message — and `SLUG_RE` would reject the root workspace's legitimate ""
|
|
53
|
+
* key with a misleading "invalid slug" instead of the store's explicit refusal.
|
|
54
|
+
*/
|
|
55
|
+
export declare function promoteProjectHandler(write: SelfMcpWriteContext): (args: Record<string, unknown>) => Promise<McpToolCallResult>;
|
|
35
56
|
export declare function forkChatBatchHandler(write: SelfMcpWriteContext): (args: Record<string, unknown>) => Promise<McpToolCallResult>;
|
|
@@ -171,6 +171,50 @@ export function createProjectHandler(write) {
|
|
|
171
171
|
}
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* `promote_project` (issue #470) — turn an EXISTING notebook project into a
|
|
176
|
+
* repo-backed one in place, over the same `ProjectStore.promote` (#213) that
|
|
177
|
+
* `POST /api/projects/:slug/promote` uses. Shares `create_project`'s
|
|
178
|
+
* `projectsMcpEnabled` gate: same blast-radius class (it clones a caller-supplied
|
|
179
|
+
* URL and restructures a project), and this one MUTATES existing state.
|
|
180
|
+
*
|
|
181
|
+
* Pure, like its siblings: it resolves the target (defaulting to the current
|
|
182
|
+
* project, as every other project-targeting write tool does), shape-checks the URL
|
|
183
|
+
* with the very same `isValidRepoUrl` the store uses, and delegates. Everything
|
|
184
|
+
* that makes promotion safe — the already-repo-backed / root-workspace / existing-
|
|
185
|
+
* checkout-dir guards, and the rollback that leaves an untouched notebook when the
|
|
186
|
+
* clone fails — stays in the store; nothing is re-implemented here.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately NO up-front `SLUG_RE` check on the target, unlike `create_project`:
|
|
189
|
+
* there the slug names a project being INVENTED, here it names one that must
|
|
190
|
+
* already exist, so the store's own `not_found` is both the real check and the
|
|
191
|
+
* better message — and `SLUG_RE` would reject the root workspace's legitimate ""
|
|
192
|
+
* key with a misleading "invalid slug" instead of the store's explicit refusal.
|
|
193
|
+
*/
|
|
194
|
+
export function promoteProjectHandler(write) {
|
|
195
|
+
return async (args) => {
|
|
196
|
+
try {
|
|
197
|
+
const projectArg = typeof args.project === "string" ? args.project.trim() : "";
|
|
198
|
+
const projectSlug = projectArg.length > 0 ? projectArg : write.currentProjectSlug;
|
|
199
|
+
const repo = typeof args.repo === "string" ? args.repo.trim() : "";
|
|
200
|
+
if (!repo) {
|
|
201
|
+
return fail("Error: `repo` is required (the git URL to back the project with).");
|
|
202
|
+
}
|
|
203
|
+
if (!isValidRepoUrl(repo)) {
|
|
204
|
+
return fail(`Error: invalid repo URL "${repo}" — expected an https://, git://, ssh:// or ` +
|
|
205
|
+
"git@host:owner/repo url.");
|
|
206
|
+
}
|
|
207
|
+
const project = await write.promoteProject(projectSlug, repo);
|
|
208
|
+
return ok({ promoted: true, ...project });
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
// Same hygiene as create_project: a clone failure arrives as git's whole argv
|
|
212
|
+
// (`Command failed: git clone -- <url> <dest>`), and the agent needs the repo
|
|
213
|
+
// URL + git's reason, not our on-disk layout.
|
|
214
|
+
return fail(`Error promoting project: ${truncateText(redactPaths(errText(error)))}`);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
174
218
|
export function forkChatBatchHandler(write) {
|
|
175
219
|
return async (args) => {
|
|
176
220
|
try {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - `self-mcp-util.ts` — result envelopes, clamps, arg coercion + caps
|
|
13
13
|
* - `self-mcp-descriptions.ts` — the agent-facing `*_DESC` prose
|
|
14
14
|
* - `self-mcp-read.ts` — list_projects / list_chats / read_chat handlers
|
|
15
|
-
* - `self-mcp-write.ts` — create/fork/send/archive/fork-batch +
|
|
15
|
+
* - `self-mcp-write.ts` — create/fork/send/archive/fork-batch + project handlers
|
|
16
16
|
* - `self-mcp-triggers.ts` — list/set/remove/run trigger handlers
|
|
17
17
|
*
|
|
18
18
|
* ── How it reaches the keeper ───────────────────────────────────────────────
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import type { InjectedMcpServerDef } from "@herdctl/core";
|
|
34
34
|
import type { SelfMcpContext, SelfMcpWriteContext } from "./self-mcp-types.js";
|
|
35
|
-
export type { SelfMcpProject, SelfMcpChat, SelfMcpMessage, SelfMcpTrigger, SelfMcpContext, SelfMcpWriteContext, SelfMcpCreateProjectInput, SelfMcpCreatedProject, } from "./self-mcp-types.js";
|
|
35
|
+
export type { SelfMcpProject, SelfMcpChat, SelfMcpMessage, SelfMcpTrigger, SelfMcpContext, SelfMcpWriteContext, SelfMcpCreateProjectInput, SelfMcpCreatedProject, SelfMcpPromotedProject, } from "./self-mcp-types.js";
|
|
36
36
|
export { FORK_BATCH_MAX, redactPaths, READ_CHAT_DEFAULT_LIMIT, READ_CHAT_MAX_LIMIT, READ_CHAT_MAX_TEXT, clampLimit, truncateText, resolveModelArg, coercePrompts, coerceToolList, coerceBoolean, } from "./self-mcp-util.js";
|
|
37
37
|
/** Assembly-time options for {@link selfMcpServerDef}. */
|
|
38
38
|
export interface SelfMcpServerDefOptions {
|
|
@@ -50,8 +50,8 @@ export interface SelfMcpServerDefOptions {
|
|
|
50
50
|
* write flag is on), the WRITE tools (create_chat/fork_chat/send_message/
|
|
51
51
|
* archive_chat/unarchive_chat/fork_chat_batch) are appended too; omit it for
|
|
52
52
|
* unchanged read-only behavior. Two further blocks hang off independent gates ON
|
|
53
|
-
* that write context: {@link SelfMcpWriteContext.projectsMcpEnabled} (
|
|
54
|
-
* appends the project
|
|
53
|
+
* that write context: {@link SelfMcpWriteContext.projectsMcpEnabled} (issues #467,
|
|
54
|
+
* #470) appends the project tools (create_project/promote_project), and
|
|
55
55
|
* {@link SelfMcpWriteContext.triggersMcpEnabled} (the per-project trigger-MCP
|
|
56
56
|
* opt-in, Epic T / T3) appends the unified trigger-management tools (list_triggers/
|
|
57
57
|
* set_trigger/remove_trigger/run_trigger). Inject under
|
|
@@ -75,11 +75,12 @@ export declare const SELF_MCP_WRITE_TOOL_NAMES: {
|
|
|
75
75
|
readonly forkChatBatch: "mcp__paddock_manage__fork_chat_batch";
|
|
76
76
|
};
|
|
77
77
|
/**
|
|
78
|
-
* The fully-qualified
|
|
79
|
-
* write context has {@link SelfMcpWriteContext.projectsMcpEnabled} on.
|
|
78
|
+
* The fully-qualified names of the PROJECT tools (issues #467, #470), present only
|
|
79
|
+
* when a write context has {@link SelfMcpWriteContext.projectsMcpEnabled} on.
|
|
80
80
|
*/
|
|
81
81
|
export declare const SELF_MCP_PROJECT_TOOL_NAMES: {
|
|
82
82
|
readonly createProject: "mcp__paddock_manage__create_project";
|
|
83
|
+
readonly promoteProject: "mcp__paddock_manage__promote_project";
|
|
83
84
|
};
|
|
84
85
|
/**
|
|
85
86
|
* The fully-qualified names of the Epic T / T3 unified trigger-management tools
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FORK_BATCH_MAX, READ_CHAT_DEFAULT_LIMIT, READ_CHAT_MAX_LIMIT, } from "./self-mcp-util.js";
|
|
2
|
-
import { MODEL_ARG_DESC, LIST_PROJECTS_DESC, LIST_CHATS_DESC, READ_CHAT_DESC, CREATE_CHAT_DESC, FORK_CHAT_DESC, SEND_MESSAGE_DESC, ARCHIVE_CHAT_DESC, UNARCHIVE_CHAT_DESC, FORK_CHAT_BATCH_DESC, CREATE_PROJECT_DESC, SET_TRIGGER_DESC, REMOVE_TRIGGER_DESC, RUN_TRIGGER_DESC, LIST_TRIGGERS_DESC, } from "./self-mcp-descriptions.js";
|
|
2
|
+
import { MODEL_ARG_DESC, LIST_PROJECTS_DESC, LIST_CHATS_DESC, READ_CHAT_DESC, CREATE_CHAT_DESC, FORK_CHAT_DESC, SEND_MESSAGE_DESC, ARCHIVE_CHAT_DESC, UNARCHIVE_CHAT_DESC, FORK_CHAT_BATCH_DESC, CREATE_PROJECT_DESC, PROMOTE_PROJECT_DESC, SET_TRIGGER_DESC, REMOVE_TRIGGER_DESC, RUN_TRIGGER_DESC, LIST_TRIGGERS_DESC, } from "./self-mcp-descriptions.js";
|
|
3
3
|
import { listProjectsHandler, listChatsHandler, readChatHandler } from "./self-mcp-read.js";
|
|
4
|
-
import { createChatHandler, forkChatHandler, sendMessageHandler, archiveChatHandler, forkChatBatchHandler, createProjectHandler, } from "./self-mcp-write.js";
|
|
4
|
+
import { createChatHandler, forkChatHandler, sendMessageHandler, archiveChatHandler, forkChatBatchHandler, createProjectHandler, promoteProjectHandler, } from "./self-mcp-write.js";
|
|
5
5
|
import { PROJECT_STATUSES } from "./project-types.js";
|
|
6
6
|
import { listTriggersHandler, setTriggerHandler, removeTriggerHandler, runTriggerHandler, } from "./self-mcp-triggers.js";
|
|
7
7
|
export { FORK_BATCH_MAX, redactPaths, READ_CHAT_DEFAULT_LIMIT, READ_CHAT_MAX_LIMIT, READ_CHAT_MAX_TEXT, clampLimit, truncateText, resolveModelArg, coercePrompts, coerceToolList, coerceBoolean, } from "./self-mcp-util.js";
|
|
@@ -204,11 +204,15 @@ function writeTools(write) {
|
|
|
204
204
|
];
|
|
205
205
|
}
|
|
206
206
|
/**
|
|
207
|
-
* The PROJECT tools (issue #467 — `create_project
|
|
207
|
+
* The PROJECT tools (issue #467 — `create_project`; issue #470 —
|
|
208
|
+
* `promote_project`). Appended only when
|
|
208
209
|
* {@link SelfMcpWriteContext.projectsMcpEnabled} is on: the same coarse binary gate
|
|
209
|
-
* as the trigger tools (when off the
|
|
210
|
-
* own flag because — unlike every other write tool —
|
|
211
|
-
* instance-level state and
|
|
210
|
+
* as the trigger tools (when off the tools are ABSENT, not present-but-refusing).
|
|
211
|
+
* Their own flag because — unlike every other write tool — these provision or
|
|
212
|
+
* restructure instance-level state and clone a caller-supplied git URL. Promotion
|
|
213
|
+
* shares the flag rather than getting a third one: it is the same blast-radius
|
|
214
|
+
* class, and an operator who has granted "this agent may provision projects" has
|
|
215
|
+
* already made the decision promotion asks for.
|
|
212
216
|
*/
|
|
213
217
|
function projectTools(write) {
|
|
214
218
|
return [
|
|
@@ -244,6 +248,27 @@ function projectTools(write) {
|
|
|
244
248
|
},
|
|
245
249
|
handler: createProjectHandler(write),
|
|
246
250
|
},
|
|
251
|
+
{
|
|
252
|
+
name: "promote_project",
|
|
253
|
+
description: PROMOTE_PROJECT_DESC,
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: "object",
|
|
256
|
+
properties: {
|
|
257
|
+
project: {
|
|
258
|
+
type: "string",
|
|
259
|
+
description: "Slug of the NOTEBOOK project to promote. Omit to promote the current project.",
|
|
260
|
+
},
|
|
261
|
+
repo: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description: "Git URL to back the project with (https://, git://, ssh:// or " +
|
|
264
|
+
"git@host:owner/repo). Cloned into a nested checkout that becomes the " +
|
|
265
|
+
"agent's working directory. Required.",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
required: ["repo"],
|
|
269
|
+
},
|
|
270
|
+
handler: promoteProjectHandler(write),
|
|
271
|
+
},
|
|
247
272
|
];
|
|
248
273
|
}
|
|
249
274
|
/**
|
|
@@ -386,8 +411,8 @@ function triggerTools(write) {
|
|
|
386
411
|
* write flag is on), the WRITE tools (create_chat/fork_chat/send_message/
|
|
387
412
|
* archive_chat/unarchive_chat/fork_chat_batch) are appended too; omit it for
|
|
388
413
|
* unchanged read-only behavior. Two further blocks hang off independent gates ON
|
|
389
|
-
* that write context: {@link SelfMcpWriteContext.projectsMcpEnabled} (
|
|
390
|
-
* appends the project
|
|
414
|
+
* that write context: {@link SelfMcpWriteContext.projectsMcpEnabled} (issues #467,
|
|
415
|
+
* #470) appends the project tools (create_project/promote_project), and
|
|
391
416
|
* {@link SelfMcpWriteContext.triggersMcpEnabled} (the per-project trigger-MCP
|
|
392
417
|
* opt-in, Epic T / T3) appends the unified trigger-management tools (list_triggers/
|
|
393
418
|
* set_trigger/remove_trigger/run_trigger). Inject under
|
|
@@ -428,11 +453,12 @@ export const SELF_MCP_WRITE_TOOL_NAMES = {
|
|
|
428
453
|
forkChatBatch: `mcp__${SERVER_NAME}__fork_chat_batch`,
|
|
429
454
|
};
|
|
430
455
|
/**
|
|
431
|
-
* The fully-qualified
|
|
432
|
-
* write context has {@link SelfMcpWriteContext.projectsMcpEnabled} on.
|
|
456
|
+
* The fully-qualified names of the PROJECT tools (issues #467, #470), present only
|
|
457
|
+
* when a write context has {@link SelfMcpWriteContext.projectsMcpEnabled} on.
|
|
433
458
|
*/
|
|
434
459
|
export const SELF_MCP_PROJECT_TOOL_NAMES = {
|
|
435
460
|
createProject: `mcp__${SERVER_NAME}__create_project`,
|
|
461
|
+
promoteProject: `mcp__${SERVER_NAME}__promote_project`,
|
|
436
462
|
};
|
|
437
463
|
/**
|
|
438
464
|
* The fully-qualified names of the Epic T / T3 unified trigger-management tools
|
|
@@ -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}),
|
|
@@ -88,7 +88,7 @@ export const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
|
88
88
|
*/
|
|
89
89
|
export const MTIME_CACHE_MAX = 1024;
|
|
90
90
|
const taskUsesCache = new Map();
|
|
91
|
-
const
|
|
91
|
+
const factsCache = new Map();
|
|
92
92
|
const usageCache = new Map();
|
|
93
93
|
/** The file's mtime in epoch ms, or undefined if it can't be stat'd. */
|
|
94
94
|
export async function statMtimeMs(file) {
|
|
@@ -137,7 +137,7 @@ export async function readTaskToolUses(projectDir, sessionId) {
|
|
|
137
137
|
*/
|
|
138
138
|
async function readTaskUsesForSession(projectDir, sessionId) {
|
|
139
139
|
if (!SAFE_SEGMENT.test(sessionId))
|
|
140
|
-
return
|
|
140
|
+
return emptyTaskToolUses();
|
|
141
141
|
return readTaskUsesFromFile(path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`));
|
|
142
142
|
}
|
|
143
143
|
/**
|
|
@@ -158,10 +158,32 @@ async function readTaskUsesFromFile(file) {
|
|
|
158
158
|
mtimeCacheSet(taskUsesCache, file, mtimeMs, value);
|
|
159
159
|
return value;
|
|
160
160
|
}
|
|
161
|
+
function emptyTaskToolUses() {
|
|
162
|
+
return { paired: [], pending: [], sidechain: { uuids: new Set(), taskIds: new Set() } };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* True when a raw transcript LINE was written on a sub-agent's own lane.
|
|
166
|
+
*
|
|
167
|
+
* Two markers, because the writers disagree: Claude Code stamps `isSidechain:
|
|
168
|
+
* true` (plus an `agentId`) on the line, while the SDK message stream — which
|
|
169
|
+
* `isSidechainMessage` in `ws-turn.ts` reads live, and which the test harness
|
|
170
|
+
* mirrors onto disk — carries `parent_tool_use_id`. Either is conclusive.
|
|
171
|
+
*
|
|
172
|
+
* Exported because EVERY file-order join over a main transcript has to agree with
|
|
173
|
+
* {@link dropSidechainMessages} about which lines exist: a scan that keeps a
|
|
174
|
+
* sidechain line while the parsed message list has dropped it shifts the whole
|
|
175
|
+
* join by one (#727).
|
|
176
|
+
*/
|
|
177
|
+
export function isSidechainLine(parsed) {
|
|
178
|
+
if (parsed.isSidechain === true)
|
|
179
|
+
return true;
|
|
180
|
+
return Boolean(parsed.parent_tool_use_id ?? parsed.message?.parent_tool_use_id);
|
|
181
|
+
}
|
|
161
182
|
async function readTaskUsesFromFileUncached(file) {
|
|
162
183
|
const byId = new Map();
|
|
163
184
|
const order = [];
|
|
164
185
|
const resultIds = new Set();
|
|
186
|
+
const sidechain = { uuids: new Set(), taskIds: new Set() };
|
|
165
187
|
const stream = createReadStream(file, { encoding: "utf8" });
|
|
166
188
|
stream.on("error", () => undefined);
|
|
167
189
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -177,12 +199,20 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
177
199
|
catch {
|
|
178
200
|
continue;
|
|
179
201
|
}
|
|
202
|
+
// A sub-agent's OWN transcript is sidechain top to bottom, so this only ever
|
|
203
|
+
// marks anything on a MAIN session file — which is the only place the
|
|
204
|
+
// distinction means something (issue #727).
|
|
205
|
+
const onSidechain = isSidechainLine(parsed);
|
|
206
|
+
if (onSidechain && typeof parsed.uuid === "string")
|
|
207
|
+
sidechain.uuids.add(parsed.uuid);
|
|
180
208
|
const content = parsed.message?.content;
|
|
181
209
|
if (!Array.isArray(content))
|
|
182
210
|
continue;
|
|
183
211
|
for (const block of content) {
|
|
184
212
|
const b = block;
|
|
185
213
|
if (b?.type === "tool_use" && b.name && SUBAGENT_TOOL_NAMES.has(b.name) && b.id) {
|
|
214
|
+
if (onSidechain)
|
|
215
|
+
sidechain.taskIds.add(b.id);
|
|
186
216
|
if (!byId.has(b.id)) {
|
|
187
217
|
order.push(b.id);
|
|
188
218
|
byId.set(b.id, {
|
|
@@ -200,7 +230,7 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
200
230
|
}
|
|
201
231
|
}
|
|
202
232
|
catch {
|
|
203
|
-
return
|
|
233
|
+
return emptyTaskToolUses();
|
|
204
234
|
}
|
|
205
235
|
finally {
|
|
206
236
|
rl.close();
|
|
@@ -209,8 +239,43 @@ async function readTaskUsesFromFileUncached(file) {
|
|
|
209
239
|
return {
|
|
210
240
|
paired: order.filter((id) => resultIds.has(id)).map((id) => byId.get(id)),
|
|
211
241
|
pending: order.filter((id) => !resultIds.has(id)).map((id) => byId.get(id)),
|
|
242
|
+
sidechain,
|
|
212
243
|
};
|
|
213
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Drop a session's sub-agent sidechain steps from its parsed history (issue
|
|
247
|
+
* #727) — the history-path counterpart of the live path's `isSidechainMessage`
|
|
248
|
+
* filter, and the same invariant: a sub-agent's steps render INSIDE its Task
|
|
249
|
+
* card (via the subagents endpoint), never as top-level rows of the parent.
|
|
250
|
+
*
|
|
251
|
+
* Joins on `uuid`, the source line's own id, which `@herdctl/core` preserves on
|
|
252
|
+
* every parsed message (and, for a paired tool message, takes from the
|
|
253
|
+
* originating `tool_use` entry — so a sidechain tool call matches on the same
|
|
254
|
+
* line that made it sidechain). Rides the mtime-cached transcript scan, and
|
|
255
|
+
* short-circuits to the identity for the overwhelmingly common transcript with
|
|
256
|
+
* no sidechain lines at all, so this costs nothing on the normal path.
|
|
257
|
+
*
|
|
258
|
+
* A transcript that is sidechain top to bottom is not a parent with leaked steps
|
|
259
|
+
* — it IS a sub-agent's own session, the shape Claude Code writes as its own
|
|
260
|
+
* `agent-<hex>.jsonl`. herdctl keeps those out of session discovery, but if one
|
|
261
|
+
* is ever reached directly it should render its content, not an empty chat, so
|
|
262
|
+
* a drop that would empty the history is refused.
|
|
263
|
+
*/
|
|
264
|
+
export async function dropSidechainMessages(projectDir, sessionId, messages) {
|
|
265
|
+
const { sidechain } = await readTaskUsesForSession(projectDir, sessionId);
|
|
266
|
+
if (sidechain.uuids.size === 0)
|
|
267
|
+
return messages;
|
|
268
|
+
const kept = messages.filter((m) => !(m.uuid && sidechain.uuids.has(m.uuid)));
|
|
269
|
+
return kept.length === 0 ? messages : kept;
|
|
270
|
+
}
|
|
271
|
+
/** The same launches minus any a sub-agent made on its own sidechain lane. */
|
|
272
|
+
function withoutSidechainLaunches(uses) {
|
|
273
|
+
const { taskIds } = uses.sidechain;
|
|
274
|
+
if (taskIds.size === 0)
|
|
275
|
+
return uses;
|
|
276
|
+
const keep = (u) => !taskIds.has(u.toolUseId);
|
|
277
|
+
return { ...uses, paired: uses.paired.filter(keep), pending: uses.pending.filter(keep) };
|
|
278
|
+
}
|
|
214
279
|
/**
|
|
215
280
|
* Live sub-agent enrichment for a tool frame (issue #429). When the tool is a
|
|
216
281
|
* `Task`/`Agent` launch this turn already recovered (via {@link extractSubagentLaunches}),
|
|
@@ -306,10 +371,16 @@ export async function readSubagentMessages(projectDir, sessionId, toolUseId) {
|
|
|
306
371
|
export async function enrichWithSubagents(projectDir, sessionId, messages) {
|
|
307
372
|
if (!hasSubagentTool(messages))
|
|
308
373
|
return messages;
|
|
309
|
-
const [
|
|
374
|
+
const [rawTaskUses, subagents] = await Promise.all([
|
|
310
375
|
readTaskUsesForSession(projectDir, sessionId),
|
|
311
376
|
listSubagents(projectDir, sessionId),
|
|
312
377
|
]);
|
|
378
|
+
// The join is by FILE ORDER, so it has to see the same launches the caller's
|
|
379
|
+
// message list does. {@link dropSidechainMessages} has already removed the
|
|
380
|
+
// sidechain rows from that list, so a nested launch a sub-agent made on its own
|
|
381
|
+
// lane must come out of the cursor too — leaving it in would shift every
|
|
382
|
+
// following top-level sub-agent's enrichment onto the wrong card (#727).
|
|
383
|
+
const taskUses = withoutSidechainLaunches(rawTaskUses);
|
|
313
384
|
const [durations, costs] = await Promise.all([
|
|
314
385
|
subagentDurations(subagents),
|
|
315
386
|
subagentCosts(subagents),
|
|
@@ -317,42 +388,85 @@ export async function enrichWithSubagents(projectDir, sessionId, messages) {
|
|
|
317
388
|
return attachSubagentFields(messages, taskUses, subagents, durations, costs);
|
|
318
389
|
}
|
|
319
390
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
391
|
+
* How long a sub-agent transcript with no terminal `end_turn` may stay quiet
|
|
392
|
+
* before we stop calling it live (issue #725).
|
|
393
|
+
*
|
|
394
|
+
* A sub-agent that was interrupted — its keeper killed, the box restarted — never
|
|
395
|
+
* writes an `end_turn`, so without this fallback its card would advertise "still
|
|
396
|
+
* running" forever and its duration would never appear. The window has to be
|
|
397
|
+
* comfortably longer than the longest gap a WORKING sub-agent can leave between
|
|
398
|
+
* transcript lines, which is one slow tool call (a full test suite, a long build),
|
|
399
|
+
* not one slow model response. Ten minutes clears that with room to spare, and the
|
|
400
|
+
* `end_turn` check below means the overwhelming majority of finished sub-agents
|
|
401
|
+
* (392 of the 483 real transcripts this was measured against) never wait for it at
|
|
402
|
+
* all — they are recognised as finished the instant their last line lands.
|
|
403
|
+
*/
|
|
404
|
+
const SUBAGENT_STALE_MS = 10 * 60_000;
|
|
405
|
+
/**
|
|
406
|
+
* Compute each FINISHED sub-agent's run time (ms) from the first→last timestamp
|
|
407
|
+
* in its own transcript. Keyed by toolUseId. Runs once per session on chat open
|
|
408
|
+
* (only over that session's sub-agent files), so the cost is bounded to the open
|
|
409
|
+
* chat.
|
|
410
|
+
*
|
|
411
|
+
* A sub-agent that still looks live is deliberately left OUT of the map (issue
|
|
412
|
+
* #725). `subagentDurationMs` is not merely cosmetic — it is the client's
|
|
413
|
+
* finished signal (`useRunningSubagents` drops any card that carries one) — and
|
|
414
|
+
* it is derived from a transcript that is still growing, so publishing it for a
|
|
415
|
+
* running sub-agent both freezes a bogus part-way number onto the card and
|
|
416
|
+
* evicts it from the running-sub-agents bar for the rest of the run. #622 fixed
|
|
417
|
+
* that for the `pending` branch of {@link attachSubagentFields} by omitting the
|
|
418
|
+
* field; the `paired` branch below it kept stamping one, and because the SDK
|
|
419
|
+
* backgrounds sub-agents, the launching `Task` pairs within milliseconds while
|
|
420
|
+
* the sub-agent keeps working — so a live sub-agent reaches the paired branch as
|
|
421
|
+
* a matter of course. Gating at the source fixes both branches by construction.
|
|
323
422
|
*/
|
|
324
423
|
async function subagentDurations(subagents) {
|
|
325
424
|
const out = new Map();
|
|
425
|
+
const now = Date.now();
|
|
326
426
|
await Promise.all([...subagents.values()].map(async (m) => {
|
|
327
|
-
const ms = await
|
|
427
|
+
const ms = await readFinishedSubagentDurationMs(m.transcriptPath, now);
|
|
328
428
|
if (ms !== undefined)
|
|
329
429
|
out.set(m.toolUseId, ms);
|
|
330
430
|
}));
|
|
331
431
|
return out;
|
|
332
432
|
}
|
|
333
433
|
/**
|
|
334
|
-
* The elapsed wall-clock of a sub-agent transcript
|
|
335
|
-
*
|
|
336
|
-
* undefined if the file is missing or has < 2 timestamps.
|
|
434
|
+
* The elapsed wall-clock of a FINISHED sub-agent transcript, or undefined when it
|
|
435
|
+
* is missing, has < 2 timestamps, or still looks live (see {@link SUBAGENT_STALE_MS}).
|
|
337
436
|
*/
|
|
338
|
-
async function
|
|
437
|
+
async function readFinishedSubagentDurationMs(file, now) {
|
|
339
438
|
const mtimeMs = await statMtimeMs(file);
|
|
439
|
+
const facts = await readSubagentFacts(file, mtimeMs);
|
|
440
|
+
if (facts.durationMs === undefined)
|
|
441
|
+
return undefined;
|
|
442
|
+
// No mtime (the file vanished between the stat and the read) ⇒ nothing is
|
|
443
|
+
// writing to it, so fall back to the structural check alone.
|
|
444
|
+
const quietMs = mtimeMs === undefined ? Infinity : now - mtimeMs;
|
|
445
|
+
if (!facts.endsWithEndTurn && quietMs < SUBAGENT_STALE_MS)
|
|
446
|
+
return undefined;
|
|
447
|
+
return facts.durationMs;
|
|
448
|
+
}
|
|
449
|
+
async function readSubagentFacts(file, mtimeMs) {
|
|
340
450
|
if (mtimeMs !== undefined) {
|
|
341
|
-
const cached = mtimeCacheGet(
|
|
451
|
+
const cached = mtimeCacheGet(factsCache, file, mtimeMs);
|
|
342
452
|
if (cached.hit)
|
|
343
453
|
return cached.value;
|
|
344
454
|
}
|
|
345
|
-
const value = await
|
|
346
|
-
// Cache even
|
|
347
|
-
// duration-less transcript doesn't re-read the whole file every time.
|
|
455
|
+
const value = await readSubagentFactsUncached(file);
|
|
456
|
+
// Cache even the empty result (missing/malformed) so a repeat open of an
|
|
457
|
+
// unchanged, duration-less transcript doesn't re-read the whole file every time.
|
|
348
458
|
if (mtimeMs !== undefined)
|
|
349
|
-
mtimeCacheSet(
|
|
459
|
+
mtimeCacheSet(factsCache, file, mtimeMs, value);
|
|
350
460
|
return value;
|
|
351
461
|
}
|
|
352
|
-
async function
|
|
462
|
+
async function readSubagentFactsUncached(file) {
|
|
353
463
|
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
354
464
|
if (raw === null)
|
|
355
|
-
return
|
|
465
|
+
return { endsWithEndTurn: false };
|
|
466
|
+
return { durationMs: transcriptSpanMs(raw), endsWithEndTurn: endsWithEndTurn(raw) };
|
|
467
|
+
}
|
|
468
|
+
/** First→last `timestamp` delta. Scans for timestamps without a full JSON parse. */
|
|
469
|
+
function transcriptSpanMs(raw) {
|
|
356
470
|
const matches = [...raw.matchAll(/"timestamp":"([^"]+)"/g)];
|
|
357
471
|
if (matches.length < 2)
|
|
358
472
|
return undefined;
|
|
@@ -362,6 +476,25 @@ async function readSubagentDurationMsUncached(file) {
|
|
|
362
476
|
return undefined;
|
|
363
477
|
return last - first;
|
|
364
478
|
}
|
|
479
|
+
/** True when the transcript's last non-empty line is an assistant entry that
|
|
480
|
+
* stopped with `end_turn`. Only that line is JSON-parsed. */
|
|
481
|
+
function endsWithEndTurn(raw) {
|
|
482
|
+
const lines = raw.split("\n");
|
|
483
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
484
|
+
const trimmed = lines[i].trim();
|
|
485
|
+
if (!trimmed)
|
|
486
|
+
continue;
|
|
487
|
+
try {
|
|
488
|
+
const parsed = JSON.parse(trimmed);
|
|
489
|
+
return parsed.type === "assistant" && parsed.message?.stop_reason === "end_turn";
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// A torn final line means the writer is mid-append — definitively not settled.
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
365
498
|
/**
|
|
366
499
|
* Compute each sub-agent's estimated API-rate cost (USD), keyed by toolUseId, as
|
|
367
500
|
* the RECURSIVE total of the sub-agent PLUS every sub-agent it (transitively)
|
|
@@ -611,6 +744,13 @@ function attachSubagentFields(messages, taskUses, subagents, durations, costs) {
|
|
|
611
744
|
description: use.description,
|
|
612
745
|
prompt: use.prompt,
|
|
613
746
|
hasSubagent: subagents.has(use.toolUseId),
|
|
747
|
+
// Same hazard as the `pending` branch above, and for the same reason:
|
|
748
|
+
// a PAIRED `Task` does NOT mean the sub-agent is done — the SDK
|
|
749
|
+
// backgrounds it, so the tool_result lands in milliseconds while the
|
|
750
|
+
// sub-agent keeps working. `durations` is therefore gated on liveness at
|
|
751
|
+
// its source ({@link subagentDurations}) and holds an entry only for a
|
|
752
|
+
// sub-agent whose transcript has actually settled, so a still-running one
|
|
753
|
+
// gets `undefined` here and keeps its place in the bar (#725).
|
|
614
754
|
subagentDurationMs: durations.get(use.toolUseId),
|
|
615
755
|
subagentCostUsd: costs.get(use.toolUseId),
|
|
616
756
|
},
|