pi-pier 0.3.0 → 0.4.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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,328 +0,0 @@
1
- /**
2
- * Subagent `spawn` action (isolate worktree, pane launch, prompt injection, foreground wait).
3
- *
4
- * Why: the plugin entry keeps tool registration and lifecycle wiring; the spawn path is the one
5
- * action with git, placement, injection, and settle branches, so it lives here as a unit.
6
- */
7
- import { randomUUID } from 'node:crypto';
8
- import { mkdirSync, stat as statCb } from 'node:fs';
9
- import { basename, dirname, join, resolve as pathResolve } from 'node:path';
10
- import { promisify } from 'node:util';
11
- import type { HerdrClientLike } from './herdr-client.ts';
12
- import { composeForRole } from './manifest-compose.ts';
13
- import { pipeNameFor, pipeRequestTo } from './pipe-channel.ts';
14
- import { platformPaths } from './platform-paths.ts';
15
- import { runtimePolicy } from './runtime-policy.ts';
16
- import {
17
- FOREGROUND_POLL_MS,
18
- planForegroundTick,
19
- planIsolateRepoGuard,
20
- planLaunchValidation,
21
- planPatienceExpiry,
22
- } from './subagent-launch.ts';
23
- import {
24
- Semaphore,
25
- buildAliveNotice,
26
- buildBlockedGateNotice,
27
- buildIsolatePreamble,
28
- classifyWorktreeZone,
29
- formatSubagentResult,
30
- isAlive,
31
- isPathUnder,
32
- makeProgressUpdate,
33
- planIsolateWorktree,
34
- type SubEntry,
35
- } from './subagent-core.ts';
36
- import type { HistoryEntry } from './history-store.ts';
37
- import type { GitIo } from './subagent-git-io.ts';
38
- import { sleep, type Poller } from './subagent-poll-loop.ts';
39
- import type { SessionIo } from './subagent-session-io.ts';
40
- import type { SpawnEnv, Spawner } from './subagent-spawn.ts';
41
- import { toolError } from './tool-error.ts';
42
-
43
- const statAsync = promisify(statCb);
44
-
45
- export interface SpawnActionHost {
46
- client: HerdrClientLike;
47
- env: SpawnEnv | null;
48
- subSemaphore: Semaphore;
49
- subs: Map<string, SubEntry>;
50
- persistSubs(): void;
51
- writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
52
- pendingIsolateBranches: Set<string>;
53
- lastMachineInjectAt: Map<string, number>;
54
- lastRequestIdByPane: Map<string, string>;
55
- git: GitIo;
56
- session: SessionIo;
57
- spawn: Spawner;
58
- poller: Poller;
59
- }
60
-
61
- export type SpawnAction = (
62
- params: Record<string, unknown> | undefined,
63
- toolCtx: unknown,
64
- onUpdate?: (update: unknown) => void,
65
- ) => Promise<unknown>;
66
-
67
- export function createSpawnAction(h: SpawnActionHost): SpawnAction {
68
- const { client, env, git, subs } = h;
69
- const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
70
- const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
71
- const { startPoller } = h.poller;
72
-
73
- return async function executeSubagentSpawn(params, toolCtx, onUpdate) {
74
- const launch = planLaunchValidation(params, client.available);
75
- if (launch.kind === 'error') {
76
- return toolError(launch.text);
77
- }
78
- const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
79
- const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
80
- // Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
81
- const taskId = randomUUID();
82
- // D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
83
- // socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
84
- // workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
85
- let isolateMeta: SubEntry['isolate'] | null = null;
86
- let cwd = masterCwd;
87
- if (isolate) {
88
- const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
89
- const isoGuard = planIsolateRepoGuard(baseSha);
90
- if (isoGuard.kind === 'error') {
91
- return toolError(isoGuard.text);
92
- }
93
- const sha = isoGuard.sha.trim();
94
- const pierBranches = new Set(
95
- ((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
96
- .split('\n').filter(Boolean),
97
- );
98
- const worktrees = await git.listWorktrees(masterCwd);
99
- const repoName = basename(worktrees[0] ?? masterCwd);
100
- const plan = planIsolateWorktree({
101
- description: spec.description,
102
- taskHex: taskId.slice(0, 6),
103
- existingPierBranches: pierBranches,
104
- });
105
- const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
106
- try {
107
- mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
108
- } catch { /* Existing directory or permissions failure is handled by worktree add. */ }
109
- // Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
110
- // scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
111
- h.pendingIsolateBranches.add(plan.branch);
112
- const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
113
- if (added === null) {
114
- h.pendingIsolateBranches.delete(plan.branch);
115
- return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
116
- }
117
- git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
118
- cwd = wtPath;
119
- isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
120
- spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
121
- } else if (cwdParam) {
122
- cwd = pathResolve(masterCwd, cwdParam);
123
- try {
124
- const st = await statAsync(cwd);
125
- if (!st.isDirectory()) throw new Error('not a directory');
126
- } catch {
127
- return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
128
- }
129
- }
130
- // D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
131
- const zone = classifyWorktreeZone({
132
- cwd,
133
- masterCwd,
134
- worktrees: await git.listWorktrees(masterCwd),
135
- });
136
- // D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
137
- const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
138
- let roleManifestEnv: Record<string, string> = {};
139
- let roleModel: string | null = null;
140
- try {
141
- // planLaunchValidation always names a role ('worker-default' by default); keep the same fallback
142
- // here for callers that build a launch spec directly.
143
- const roleName = manifestRole ?? 'worker-default';
144
- const { role, manifest } = composeForRole(roleName, suggested, { loadRoleOpts: { baseDir: masterCwd } });
145
- roleManifestEnv = {
146
- PI_HERDR_ROLE_MANIFEST: JSON.stringify({
147
- role: role.role,
148
- version: role.version,
149
- tools: manifest.tools,
150
- permissions: manifest.permissions,
151
- unknownTools: manifest.unknownTools,
152
- services: role.services ?? {},
153
- }),
154
- };
155
- // WS-D10: Route the model by role; omission intentionally uses the process default.
156
- if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
157
- } catch (err) {
158
- const msg = err instanceof Error ? err.message : String(err);
159
- return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
160
- }
161
- const release = await h.subSemaphore.acquire();
162
- let paneId = '';
163
- try {
164
- const spawnedAt = Date.now();
165
- const spawned = await spawnPaneInTaskTab(
166
- { desiredTab: tab, description: spec.description, zone },
167
- cwd,
168
- { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
169
- launchLine(null, roleModel, approve),
170
- );
171
- paneId = spawned.paneId;
172
- const entry: SubEntry = {
173
- taskId,
174
- kind,
175
- paneId,
176
- tabId: spawned.tabId,
177
- tabName: spawned.tabName,
178
- cwd,
179
- description: spec.description,
180
- background,
181
- status: 'running',
182
- sessionFile: null,
183
- launchCommand: [launchLine(null, roleModel, approve)],
184
- createdAt: Date.now(),
185
- revivedFrom: null,
186
- ...(isolateMeta ? { isolate: isolateMeta } : {}),
187
- };
188
- const ready = await waitSubReady(cwd, paneId);
189
- if (!ready.ok) throw new Error(ready.message);
190
- entry.sessionFile = await resolveSessionFile(paneId, cwd);
191
- subs.set(paneId, entry);
192
- h.persistSubs();
193
- onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
194
- // M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
195
- // reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
196
- // Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
197
- // leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
198
- // running ledger entry). Keep injection and injectTs together in future refactors.
199
- const injectTs = Date.now();
200
- const injected = await pipeRequestTo(cwd, paneId, {
201
- type: 'prompt',
202
- id: `prompt-${taskId}`,
203
- text: spec.prompt,
204
- from: pipeNameFor(cwd, env?.paneId ?? ''),
205
- push: background,
206
- });
207
- if (injected.type !== 'ok') {
208
- throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
209
- }
210
- h.lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
211
-
212
- // A7: run_in_background returns immediately after successful injection instead of blocking in foreground wait.
213
- if (background) {
214
- entry.background = true;
215
- h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
216
- void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
217
- h.persistSubs();
218
- h.writeHistory(entry, undefined, 'to-background');
219
- return {
220
- content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
221
- details: { paneId, taskId, background: true, role: kind },
222
- };
223
- }
224
-
225
- // A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
226
- // Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
227
- // three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
228
- const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
229
- let text: string | null = null;
230
- let settledKind: 'settled' | 'timeout' = 'timeout';
231
- while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
232
- const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
233
- const session = (state === 'idle' || state === 'done')
234
- ? await subSessionState(paneId, cwd, injectTs)
235
- : { text: null, pendingTool: false, activity: false };
236
- const tick = planForegroundTick({ state, session });
237
- if (tick.kind === 'blocked') {
238
- const question = await readAskFlag(paneId);
239
- entry.background = true;
240
- h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
241
- void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
242
- h.persistSubs();
243
- h.writeHistory(entry, undefined, 'to-background');
244
- return {
245
- content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
246
- details: { paneId, taskId, background: true, blocked: true, role: kind },
247
- };
248
- }
249
- if (tick.kind === 'settled') {
250
- text = tick.text;
251
- settledKind = 'settled';
252
- break;
253
- }
254
- if (tick.kind === 'wait') {
255
- await sleep(tick.delayMs);
256
- continue;
257
- }
258
- if (tick.kind === 'collect-final') {
259
- text = await collectFinalText(paneId, cwd, injectTs, 6);
260
- if (text) { settledKind = 'settled'; break; }
261
- await sleep(FOREGROUND_POLL_MS);
262
- continue;
263
- }
264
- }
265
- if (!text) {
266
- const probe = await probeAlive(paneId, cwd);
267
- if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
268
- entry.background = true;
269
- h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
270
- void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
271
- h.persistSubs();
272
- const notice = buildAliveNotice(
273
- { paneId, description: spec.description, scenario: 'moved-to-bg', probe },
274
- Date.now(),
275
- );
276
- return {
277
- content: [{ type: 'text', text: notice }],
278
- details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
279
- };
280
- }
281
- settledKind = 'timeout';
282
- }
283
- const outcome =
284
- settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
285
- : text ? { kind: 'completed' as const, text }
286
- : { kind: 'no-output' as const, text: '' };
287
- entry.status = 'consumed';
288
- entry.consumedAt = Date.now();
289
- h.persistSubs();
290
- h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
291
- return {
292
- content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
293
- details: { paneId, taskId, background, role: kind },
294
- };
295
- } catch (err) {
296
- // Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
297
- // otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
298
- // to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
299
- if (paneId) {
300
- subs.delete(paneId);
301
- h.persistSubs();
302
- void client.closePane(paneId).catch(() => { /* Best effort only. */ });
303
- }
304
- // D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
305
- // exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
306
- if (isolateMeta) {
307
- const branch = isolateMeta.branch;
308
- const worktreePath = isolateMeta.worktreePath;
309
- void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
310
- .then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
311
- .catch(() => { /* Best effort only. */ });
312
- }
313
- return {
314
- content: [{
315
- type: 'text',
316
- text: formatSubagentResult(
317
- { kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
318
- spec.description,
319
- ),
320
- }],
321
- details: {},
322
- };
323
- } finally {
324
- release();
325
- if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
326
- }
327
- };
328
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * Git/worktree I/O used by spawn, isolate, GC, and settlement stat lines.
3
- *
4
- * Why: core/subagent mixed adapter calls with tool registration. Centralizing
5
- * git behind one cache/timeout policy keeps placement and isolate GC consistent.
6
- */
7
- import { defaultGitAdapter, type GitAdapter } from './git-adapter.ts';
8
- import { formatWorktreeStat, type SubEntry } from './subagent-core.ts';
9
-
10
- const WORKTREES_CACHE_MS = 5000;
11
- export interface GitIo {
12
- listWorktrees(cwd: string): Promise<string[]>;
13
- runGit(cwd: string, args: string[]): Promise<string | null>;
14
- worktreeStatLine(entry: SubEntry): Promise<string | null>;
15
- invalidateWorktreesCache(): void;
16
- }
17
-
18
- export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
19
- let worktreesCache: { at: number; list: string[] } | null = null;
20
- async function listWorktrees(cwd: string): Promise<string[]> {
21
- if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
22
- let list: string[] = [];
23
- try {
24
- const { stdout } = await git.listWorktrees(cwd);
25
- for (const line of String(stdout).split('\n')) {
26
- const m = /^worktree (.+)$/.exec(line.trim());
27
- if (m) list.push(m[1]);
28
- }
29
- } catch {
30
- list = [];
31
- }
32
- worktreesCache = { at: Date.now(), list };
33
- return list;
34
- }
35
-
36
- async function runGit(cwd: string, args: string[]): Promise<string | null> {
37
- try {
38
- const { stdout } = await git.run(cwd, args);
39
- return stdout == null ? null : String(stdout);
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- function lastStatLine(out: string | null): string | null {
46
- if (!out) return null;
47
- const lines = out.replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
48
- return lines.length ? lines[lines.length - 1]! : null;
49
- }
50
-
51
- async function worktreeStatLine(entry: SubEntry): Promise<string | null> {
52
- const porcelain = await runGit(entry.cwd, ['status', '--porcelain']);
53
- if (porcelain === null) return null;
54
- const dirtyCount = porcelain.split('\n').filter((l) => l.trim() !== '').length;
55
- if (entry.isolate) {
56
- const statOut = await runGit(entry.cwd, ['diff', '--stat', `${entry.isolate.baseSha}...HEAD`]);
57
- const commitsOut = await runGit(entry.cwd, ['rev-list', '--count', `${entry.isolate.baseSha}..HEAD`]);
58
- const commits = commitsOut != null && /^\d+$/.test(commitsOut.trim()) ? Number(commitsOut.trim()) : null;
59
- return formatWorktreeStat({ branch: entry.isolate.branch, commits, statLine: lastStatLine(statOut), dirtyCount });
60
- }
61
- return formatWorktreeStat({
62
- branch: null,
63
- commits: null,
64
- statLine: lastStatLine(await runGit(entry.cwd, ['diff', '--stat', 'HEAD'])),
65
- dirtyCount,
66
- });
67
- }
68
-
69
- function invalidateWorktreesCache(): void {
70
- worktreesCache = null;
71
- }
72
-
73
- return {
74
- listWorktrees,
75
- runGit,
76
- worktreeStatLine,
77
- invalidateWorktreesCache,
78
- };
79
- }
80
-
@@ -1,114 +0,0 @@
1
- /**
2
- * Subagent `execute` planners (validation + foreground wait).
3
- *
4
- * Why: execute mixed param checks, isolate git I/O, spawn, and wait-loop
5
- * decisions. The decisions are pure and must stay testable without a pane.
6
- */
7
- import { normalizeEntryKind } from './history-store.ts';
8
-
9
- export const FOREGROUND_POLL_MS = 2_000;
10
-
11
- export type LaunchParams = {
12
- description?: unknown;
13
- prompt?: unknown;
14
- run_in_background?: unknown;
15
- cwd?: unknown;
16
- isolate?: unknown;
17
- role?: unknown;
18
- allowed_tools?: unknown;
19
- tab?: unknown;
20
- };
21
-
22
- import type { SubagentSpec } from './subagent-core.ts';
23
-
24
- export type LaunchValidation =
25
- | { kind: 'error'; text: string }
26
- | {
27
- kind: 'ok';
28
- spec: SubagentSpec;
29
- background: boolean;
30
- isolate: boolean;
31
- cwdParam: string | null;
32
- roleKind: string;
33
- suggested: string[];
34
- tab: string | null;
35
- /** Role manifest supplied by the caller (drives tool gating); null when unset. */
36
- manifestRole: string | null;
37
- };
38
-
39
- export function planLaunchValidation(
40
- params: LaunchParams | null | undefined,
41
- herdrAvailable: boolean,
42
- ): LaunchValidation {
43
- if (!herdrAvailable) {
44
- return {
45
- kind: 'error',
46
- text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
47
- };
48
- }
49
- const spec: SubagentSpec = {
50
- description: String(params?.description ?? 'subagent'),
51
- prompt: String(params?.prompt ?? ''),
52
- };
53
- if (!spec.prompt.trim()) {
54
- return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
55
- }
56
- const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
57
- const isolate = params?.isolate === true;
58
- if (isolate && cwdParam) {
59
- return {
60
- kind: 'error',
61
- text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
62
- };
63
- }
64
- const suggested = Array.isArray(params?.allowed_tools)
65
- ? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
66
- : [];
67
- const manifestRole =
68
- typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
69
- const tab = typeof params?.tab === 'string' ? params.tab : null;
70
- return {
71
- kind: 'ok',
72
- spec,
73
- background: params?.run_in_background === true,
74
- isolate,
75
- cwdParam,
76
- roleKind: normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined),
77
- suggested,
78
- manifestRole,
79
- tab,
80
- };
81
- }
82
-
83
- export function planIsolateRepoGuard(baseSha: string | null): { kind: 'error'; text: string } | { kind: 'ok'; sha: string } {
84
- if (!baseSha) {
85
- return { kind: 'error', text: 'Error: isolate requires a git repository with at least one commit' };
86
- }
87
- return { kind: 'ok', sha: baseSha };
88
- }
89
-
90
- export type ForegroundTickPlan =
91
- | { kind: 'blocked' }
92
- | { kind: 'settled'; text: string }
93
- | { kind: 'wait'; delayMs: number }
94
- | { kind: 'collect-final' }
95
- | { kind: 'continue' };
96
-
97
- export function planForegroundTick(input: {
98
- state: string | null;
99
- session: { text: string | null; pendingTool: boolean; activity: boolean };
100
- }): ForegroundTickPlan {
101
- if (input.state === 'blocked') return { kind: 'blocked' };
102
- if (input.state === 'idle' || input.state === 'done') {
103
- if (input.session.text) return { kind: 'settled', text: input.session.text };
104
- if (input.session.pendingTool || !input.session.activity) {
105
- return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
106
- }
107
- return { kind: 'collect-final' };
108
- }
109
- return { kind: 'continue' };
110
- }
111
-
112
- export function planPatienceExpiry(alive: boolean): 'move-to-background' | 'timeout' {
113
- return alive ? 'move-to-background' : 'timeout';
114
- }
@@ -1,69 +0,0 @@
1
- import { basename } from 'node:path';
2
- import { agoText, type AliveProbe, type SubEntry } from './subagent-core.ts';
3
-
4
- export interface ListActionResult {
5
- content: Array<{ type: 'text'; text: string }>;
6
- details: Record<string, unknown>;
7
- }
8
-
9
- export interface ListActionDeps {
10
- subs: () => Iterable<SubEntry>;
11
- probeAlive: (paneId: string, cwd: string) => Promise<AliveProbe>;
12
- readAskFlag: (paneId: string) => Promise<string | null>;
13
- now?: () => number;
14
- }
15
-
16
- /** Render the live background registry without coupling it to Cordis or the tool registration. */
17
- export async function executeSubagentList(deps: ListActionDeps): Promise<ListActionResult> {
18
- const listed = [...deps.subs()].filter((sub) => sub.background);
19
- if (listed.length === 0) {
20
- return {
21
- content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }],
22
- details: {},
23
- };
24
- }
25
-
26
- const byTask = new Map<string, SubEntry>();
27
- for (const sub of listed) {
28
- const previous = byTask.get(sub.taskId);
29
- if (!previous || sub.createdAt >= previous.createdAt) byTask.set(sub.taskId, sub);
30
- }
31
-
32
- const probes = new Map<string, AliveProbe>();
33
- await Promise.all([...byTask.values()].map(async (sub) => {
34
- if (sub.status === 'closed') return;
35
- try {
36
- probes.set(sub.paneId, await deps.probeAlive(sub.paneId, sub.cwd));
37
- } catch {
38
- /* Best effort for display. */
39
- }
40
- }));
41
-
42
- const now = deps.now ?? Date.now;
43
- const lines: string[] = [];
44
- for (const sub of byTask.values()) {
45
- const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
46
- const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
47
- if (sub.status === 'closed') {
48
- lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
49
- continue;
50
- }
51
-
52
- const state = sub.status === 'settled' ? 'idle' : 'running';
53
- const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
54
- const probe = probes.get(sub.paneId);
55
- const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
56
- const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, now())}` : '';
57
- let gateTag = '';
58
- if (probe?.agentStatus === 'blocked') {
59
- const question = await deps.readAskFlag(sub.paneId);
60
- gateTag = question ? ` — AWAITING HUMAN: "${question}"` : ' — AWAITING HUMAN decision';
61
- }
62
- const cwdTag = probe?.foregroundCwd && probe.foregroundCwd !== sub.cwd
63
- ? ` [cwd: ${basename(probe.foregroundCwd) || probe.foregroundCwd}]`
64
- : '';
65
- lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
66
- }
67
-
68
- return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
69
- }