pi-pier 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/subagent-core.ts
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Subagent domain: registry rows, launch lines, liveness notices, tab/worktree planning, isolate
|
|
3
|
+
* decisions, task-id resolution, and the outbound port types. Pure: planners decide, adapters do I/O.
|
|
4
|
+
*/
|
|
2
5
|
import { normalizeEntryKind } from './history-store.ts';
|
|
3
6
|
|
|
7
|
+
export function sleep(ms: number): Promise<void> {
|
|
8
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
9
|
+
setTimeout(resolve, ms);
|
|
10
|
+
return promise;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
export interface SubagentSpec {
|
|
5
14
|
description: string;
|
|
6
15
|
prompt: string;
|
|
7
16
|
}
|
|
8
17
|
|
|
9
|
-
/**
|
|
10
|
-
export function psQuote(s: string): string {
|
|
11
|
-
return `'${String(s).replace(/'/g, "''")}'`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
15
|
-
export function shQuote(s: string): string {
|
|
16
|
-
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
18
|
+
/** POSIX single-quote escape; win32 PowerShell doubles the quote instead. */
|
|
20
19
|
export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platform = process.platform): string {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
const win = platform === 'win32';
|
|
21
|
+
const quoted = parts.map((s) => (win
|
|
22
|
+
? `'${String(s).replace(/'/g, "''")}'`
|
|
23
|
+
: `'${String(s).replace(/'/g, `'\\''`)}'`));
|
|
24
|
+
return (win ? '& ' : '') + quoted.join(' ');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
|
|
28
28
|
export class Semaphore {
|
|
29
29
|
private active = 0;
|
|
30
30
|
private queue: Array<() => void> = [];
|
|
@@ -44,12 +44,12 @@ export class Semaphore {
|
|
|
44
44
|
this.active++;
|
|
45
45
|
return Promise.resolve(this.release.bind(this));
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
47
|
+
const { promise, resolve } = Promise.withResolvers<() => void>();
|
|
48
|
+
this.queue.push(() => {
|
|
49
|
+
this.active++;
|
|
50
|
+
resolve(this.release.bind(this));
|
|
52
51
|
});
|
|
52
|
+
return promise;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
private release(): void {
|
|
@@ -59,19 +59,15 @@ export class Semaphore {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
interface SubagentOutcome {
|
|
63
63
|
kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
|
|
64
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
65
64
|
text: string;
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
69
67
|
export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
|
|
70
68
|
switch (outcome.kind) {
|
|
71
|
-
case 'completed':
|
|
72
|
-
|
|
73
|
-
return text ? text : 'Subagent finished but produced no output.';
|
|
74
|
-
}
|
|
69
|
+
case 'completed':
|
|
70
|
+
return outcome.text ? outcome.text : 'Subagent finished but produced no output.';
|
|
75
71
|
case 'timeout':
|
|
76
72
|
return `Error: subagent "${description}" timed out. Partial output:\n${outcome.text || '(none)'}`;
|
|
77
73
|
case 'blocked':
|
|
@@ -83,27 +79,25 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
|
|
|
83
79
|
}
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
87
|
-
|
|
88
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
89
82
|
export interface AliveProbe {
|
|
90
83
|
paneExists: boolean;
|
|
91
84
|
agentStatus: string | null;
|
|
92
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
93
85
|
lastActivityMs: number | null;
|
|
94
86
|
/** Herdr 0.9.1: foreground working directory of the PTY process. */
|
|
95
87
|
foregroundCwd?: string | null;
|
|
96
88
|
}
|
|
97
89
|
|
|
98
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
|
|
92
|
+
* age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
|
|
93
|
+
* the caller's to report, not guessed here.
|
|
94
|
+
*/
|
|
99
95
|
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
100
96
|
if (!probe.paneExists) return false;
|
|
101
97
|
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
102
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
103
98
|
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
104
99
|
}
|
|
105
100
|
|
|
106
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
107
101
|
export function agoText(ms: number, nowMs: number): string {
|
|
108
102
|
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
109
103
|
if (s < 60) return `${s}s ago`;
|
|
@@ -111,7 +105,6 @@ export function agoText(ms: number, nowMs: number): string {
|
|
|
111
105
|
return `${Math.floor(s / 3600)}h ago`;
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
115
108
|
export function buildAliveNotice(
|
|
116
109
|
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
117
110
|
nowMs: number,
|
|
@@ -134,7 +127,7 @@ export function buildAliveNotice(
|
|
|
134
127
|
].join(' ');
|
|
135
128
|
}
|
|
136
129
|
|
|
137
|
-
/**
|
|
130
|
+
/** A pane blocked on a human decision keeps running: the master must not take the work over. */
|
|
138
131
|
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
139
132
|
const { paneId, description, question } = opts;
|
|
140
133
|
const q = question ? ` (question: "${question}")` : '';
|
|
@@ -147,35 +140,30 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
|
|
|
147
140
|
].join(' ');
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
151
|
-
|
|
152
143
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
|
153
144
|
|
|
154
145
|
export interface SubEntry {
|
|
155
146
|
taskId: string;
|
|
156
|
-
/**
|
|
147
|
+
/** 'task' or a role name; only a display label for GC. */
|
|
157
148
|
kind: string;
|
|
158
149
|
paneId: string;
|
|
159
150
|
tabId: string;
|
|
160
|
-
/** Why: Preserve the established compatibility and safety behavior (D25, D26). */
|
|
161
151
|
tabName: string;
|
|
162
152
|
cwd: string;
|
|
163
153
|
description: string;
|
|
164
154
|
background: boolean;
|
|
165
155
|
status: 'running' | 'settled' | 'consumed' | 'closed';
|
|
166
|
-
/**
|
|
156
|
+
/** GC grace basis (durable); closed rows keep the original value. */
|
|
167
157
|
consumedAt?: number | null;
|
|
168
158
|
sessionFile: string | null;
|
|
169
159
|
launchCommand: string[];
|
|
170
160
|
createdAt: number;
|
|
171
161
|
revivedFrom?: string | null;
|
|
172
|
-
/**
|
|
162
|
+
/** D94: the human took the pane over; observation window judges when to hand control back. */
|
|
173
163
|
userTakeover?: boolean;
|
|
174
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
175
164
|
observationStartedAt?: number | null;
|
|
176
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
177
165
|
lastAgentStatus?: string | null;
|
|
178
|
-
/**
|
|
166
|
+
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
179
167
|
isolate?: {
|
|
180
168
|
worktreePath: string;
|
|
181
169
|
branch: string;
|
|
@@ -194,14 +182,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
|
|
|
194
182
|
return { version: 2, subs };
|
|
195
183
|
}
|
|
196
184
|
|
|
197
|
-
|
|
198
|
-
|
|
185
|
+
const SUB_STATUSES: Record<string, true> = { running: true, settled: true, consumed: true, closed: true };
|
|
186
|
+
|
|
187
|
+
/** Replay `pi-herdr.subs` custom branch entries into a registry; last snapshot wins. Tolerant of
|
|
188
|
+
* v1 rows (missing taskId/kind/tabName) and unknown future fields. */
|
|
189
|
+
export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>): SubsRegistry {
|
|
199
190
|
let found = makeRegistry();
|
|
200
191
|
for (const entry of entries) {
|
|
201
192
|
if (entry.type !== 'custom' || entry.customType !== SUBS_CUSTOM_TYPE) continue;
|
|
202
|
-
const data = entry.data as {
|
|
203
|
-
subs?: Array<Partial<SubEntry> & { paneId: string }>;
|
|
204
|
-
} | undefined;
|
|
193
|
+
const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
|
|
205
194
|
if (!data || !Array.isArray(data.subs)) continue;
|
|
206
195
|
const subs: SubEntry[] = data.subs
|
|
207
196
|
.filter((s) => typeof s?.paneId === 'string')
|
|
@@ -214,7 +203,9 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
214
203
|
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
215
204
|
description: typeof s.description === 'string' ? s.description : '',
|
|
216
205
|
background: s.background === true,
|
|
217
|
-
status:
|
|
206
|
+
status: typeof s.status === 'string' && SUB_STATUSES[s.status] === true
|
|
207
|
+
? (s.status as SubEntry['status'])
|
|
208
|
+
: 'running',
|
|
218
209
|
consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
|
|
219
210
|
sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
|
|
220
211
|
launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
|
|
@@ -240,36 +231,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
240
231
|
return found;
|
|
241
232
|
}
|
|
242
233
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
234
|
+
/** Latest row per taskId (same taskId rows are generations); newest createdAt wins. */
|
|
235
|
+
export function newestPerTaskId(entries: Iterable<SubEntry>): Map<string, SubEntry> {
|
|
236
|
+
const byTask = new Map<string, SubEntry>();
|
|
237
|
+
for (const sub of entries) {
|
|
238
|
+
const prev = byTask.get(sub.taskId);
|
|
239
|
+
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
240
|
+
}
|
|
241
|
+
return byTask;
|
|
247
242
|
}
|
|
248
243
|
|
|
249
|
-
/**
|
|
244
|
+
/** Re-key a revived entry under its new pane id; the dead id must go, or a later zombie sweep closes the live row. */
|
|
245
|
+
export function rekeySub(subs: Map<string, SubEntry>, previousPaneId: string, entry: SubEntry): void {
|
|
246
|
+
if (previousPaneId !== entry.paneId && subs.get(previousPaneId) === entry) subs.delete(previousPaneId);
|
|
247
|
+
subs.set(entry.paneId, entry);
|
|
248
|
+
}
|
|
250
249
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
/** Split `provider/model` on the first '/' only: model ids such as `moonshotai/kimi-k2` carry their own. */
|
|
251
|
+
export function splitRoleModel(roleModel: string): { provider: string | null; model: string } {
|
|
252
|
+
const slash = roleModel.indexOf('/');
|
|
253
|
+
if (slash <= 0 || slash === roleModel.length - 1) return { provider: null, model: roleModel };
|
|
254
|
+
return { provider: roleModel.slice(0, slash), model: roleModel.slice(slash + 1) };
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
/**
|
|
257
|
-
export function makeProgressUpdate(msg: string)
|
|
258
|
-
return { content: [{ type: 'text', text: msg }], details: {} };
|
|
257
|
+
/** pi's AgentToolResult shape — a bare string crashes the interactive TUI's getTextOutput. */
|
|
258
|
+
export function makeProgressUpdate(msg: string) {
|
|
259
|
+
return { content: [{ type: 'text' as const, text: msg }], details: {} as Record<string, never> };
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* Models sometimes wrap fields in a `parameters` object (JSON-schema habit). Top-level wins;
|
|
264
|
+
* only missing keys are filled from the nested bag so a real field is never overwritten.
|
|
265
|
+
*/
|
|
266
|
+
export function flattenToolParams(params: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
267
|
+
if (!params) return params;
|
|
268
|
+
const nested = params.parameters;
|
|
269
|
+
if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) return params;
|
|
270
|
+
const out: Record<string, unknown> = { ...params };
|
|
271
|
+
for (const [key, value] of Object.entries(nested as Record<string, unknown>)) {
|
|
272
|
+
if (key === 'parameters') continue;
|
|
273
|
+
const current = out[key];
|
|
274
|
+
if (current == null || current === '') out[key] = value;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
export function idParam(params: Record<string, unknown> | undefined, ...keys: string[]): string {
|
|
281
|
+
const flat = flattenToolParams(params);
|
|
282
|
+
for (const key of keys) {
|
|
283
|
+
const v = flat?.[key];
|
|
284
|
+
if (v != null && String(v).trim() !== '') return String(v).trim();
|
|
285
|
+
}
|
|
286
|
+
return '';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function ambiguousIdError(label: 'task id' | 'subagent id', query: string, candidates: readonly string[]): string {
|
|
290
|
+
const head = candidates.slice(0, 5).join(', ');
|
|
291
|
+
const more = candidates.length > 5 ? `, ... (+${candidates.length - 5} more)` : '';
|
|
292
|
+
return `Error: ambiguous ${label} "${query}" matches ${candidates.length} tasks: ${head}${more}`;
|
|
293
|
+
}
|
|
262
294
|
|
|
263
295
|
export const TAB_NAME_MAX = 20;
|
|
264
296
|
|
|
265
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
266
297
|
export function tabNameForTask(description: string): string {
|
|
267
298
|
const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
|
|
268
299
|
const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
|
|
269
300
|
return truncated || 'task';
|
|
270
301
|
}
|
|
271
302
|
|
|
272
|
-
/** Why: Preserve the established compatibility and safety behavior (D26). */
|
|
273
303
|
export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
|
|
274
304
|
let name = base;
|
|
275
305
|
let n = 2;
|
|
@@ -282,16 +312,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
|
|
|
282
312
|
}
|
|
283
313
|
|
|
284
314
|
export interface TabPlacementPlan {
|
|
285
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
286
315
|
mode: 'append' | 'new';
|
|
287
316
|
tabName: string;
|
|
288
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
289
317
|
tabId: string | null;
|
|
290
318
|
}
|
|
291
319
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
294
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
320
|
+
/** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
|
|
295
321
|
export function isPathUnder(cwd: string, wt: string): boolean {
|
|
296
322
|
const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
297
323
|
const a = n(cwd);
|
|
@@ -300,13 +326,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
|
|
|
300
326
|
}
|
|
301
327
|
|
|
302
328
|
export interface WorktreeZone {
|
|
303
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
304
329
|
zone: 'main' | 'worktree';
|
|
305
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
306
330
|
tabName: string | null;
|
|
307
331
|
}
|
|
308
332
|
|
|
309
|
-
/**
|
|
333
|
+
/** D86: subagents working in the master's checkout share its tab; another worktree gets its own. */
|
|
310
334
|
export function classifyWorktreeZone(opts: {
|
|
311
335
|
cwd: string;
|
|
312
336
|
masterCwd: string;
|
|
@@ -314,21 +338,19 @@ export function classifyWorktreeZone(opts: {
|
|
|
314
338
|
}): WorktreeZone {
|
|
315
339
|
for (const wt of opts.worktrees) {
|
|
316
340
|
if (!isPathUnder(opts.cwd, wt)) continue;
|
|
317
|
-
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
341
|
+
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
318
342
|
const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
|
|
319
343
|
return { zone: 'worktree', tabName: base };
|
|
320
344
|
}
|
|
321
345
|
return { zone: 'main', tabName: null };
|
|
322
346
|
}
|
|
323
347
|
|
|
324
|
-
/**
|
|
348
|
+
/** Explicit `tab` wins; otherwise D86 zone decides, and an unknown zone falls back to the description. */
|
|
325
349
|
export function planTabPlacement(opts: {
|
|
326
350
|
desiredTab?: string | null;
|
|
327
351
|
description: string;
|
|
328
352
|
knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
|
|
329
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
330
353
|
zone?: WorktreeZone;
|
|
331
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
332
354
|
mainTabId?: string | null;
|
|
333
355
|
}): TabPlacementPlan {
|
|
334
356
|
const known = new Map<string, string>();
|
|
@@ -340,35 +362,21 @@ export function planTabPlacement(opts: {
|
|
|
340
362
|
}
|
|
341
363
|
const zone = opts.zone;
|
|
342
364
|
if (zone?.zone === 'main' && opts.mainTabId) {
|
|
343
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
344
365
|
return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
|
|
345
366
|
}
|
|
346
367
|
if (zone?.zone === 'worktree' && zone.tabName) {
|
|
347
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
348
368
|
const base = zone.tabName;
|
|
349
369
|
const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
|
|
350
370
|
if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
|
|
351
|
-
|
|
352
|
-
return { mode: 'new', tabName, tabId: null };
|
|
371
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
353
372
|
}
|
|
354
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
355
373
|
const base = tabNameForTask(opts.description);
|
|
356
|
-
|
|
357
|
-
return { mode: 'new', tabName, tabId: null };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Why: Preserve the established compatibility and safety behavior (D97). */
|
|
361
|
-
|
|
362
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
363
|
-
export interface LaunchRuntime {
|
|
364
|
-
nodePath: string;
|
|
365
|
-
cliPath: string;
|
|
366
|
-
extPath: string;
|
|
374
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
367
375
|
}
|
|
368
376
|
|
|
369
|
-
/**
|
|
377
|
+
/** D97: fullscreen is what makes the pane a static frame the master can read. */
|
|
370
378
|
export function buildLaunchParts(
|
|
371
|
-
runtime:
|
|
379
|
+
runtime: { nodePath: string; cliPath: string; extPath: string },
|
|
372
380
|
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
373
381
|
env: { PI_HERDR_TUI?: string | undefined } = process.env,
|
|
374
382
|
): string[] {
|
|
@@ -376,29 +384,22 @@ export function buildLaunchParts(
|
|
|
376
384
|
if (opts.approve) parts.push('-a');
|
|
377
385
|
parts.push('-e', runtime.extPath);
|
|
378
386
|
if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
|
|
379
|
-
if (opts.roleModel)
|
|
387
|
+
if (opts.roleModel) {
|
|
388
|
+
const { provider, model } = splitRoleModel(opts.roleModel);
|
|
389
|
+
if (provider) parts.push('--provider', provider);
|
|
390
|
+
parts.push('--model', model);
|
|
391
|
+
}
|
|
380
392
|
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
381
393
|
return parts;
|
|
382
394
|
}
|
|
383
395
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
387
|
-
export interface IsolatePlan {
|
|
388
|
-
branch: string;
|
|
389
|
-
slug: string;
|
|
390
|
-
worktreeDirName: string;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
394
|
-
export const ISOLATE_SLUG_MAX = 40;
|
|
396
|
+
const ISOLATE_SLUG_MAX = 40;
|
|
395
397
|
|
|
396
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
397
398
|
export function planIsolateWorktree(opts: {
|
|
398
399
|
description: string;
|
|
399
400
|
taskHex: string;
|
|
400
401
|
existingPierBranches: ReadonlySet<string>;
|
|
401
|
-
})
|
|
402
|
+
}) {
|
|
402
403
|
const folded = String(opts.description ?? '')
|
|
403
404
|
.toLowerCase()
|
|
404
405
|
.replace(/[^a-z0-9]+/g, '-')
|
|
@@ -415,7 +416,7 @@ export function planIsolateWorktree(opts: {
|
|
|
415
416
|
return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
|
|
416
417
|
}
|
|
417
418
|
|
|
418
|
-
/**
|
|
419
|
+
/** Commit discipline is injected into the prompt: the worktree has no other protection. */
|
|
419
420
|
export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
|
|
420
421
|
return [
|
|
421
422
|
`You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
|
|
@@ -426,7 +427,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
|
|
|
426
427
|
].join('\n');
|
|
427
428
|
}
|
|
428
429
|
|
|
429
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
430
430
|
export function formatWorktreeStat(opts: {
|
|
431
431
|
branch: string | null;
|
|
432
432
|
commits: number | null;
|
|
@@ -445,17 +445,16 @@ export function formatWorktreeStat(opts: {
|
|
|
445
445
|
return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
/**
|
|
449
|
-
export
|
|
450
|
-
|
|
451
|
-
export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }): ReleaseDecision {
|
|
448
|
+
/** D98: only a merged, clean, known worktree may be removed automatically. */
|
|
449
|
+
export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }):
|
|
450
|
+
{ action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' } {
|
|
452
451
|
if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
|
|
453
452
|
if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
|
|
454
453
|
if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
|
|
455
454
|
return { action: 'release' };
|
|
456
455
|
}
|
|
457
456
|
|
|
458
|
-
/**
|
|
457
|
+
/** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
|
|
459
458
|
export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
460
459
|
const byBranch = new Map<string, string>();
|
|
461
460
|
let curPath: string | null = null;
|
|
@@ -467,3 +466,139 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
|
467
466
|
}
|
|
468
467
|
return byBranch;
|
|
469
468
|
}
|
|
469
|
+
|
|
470
|
+
/* ── execute planners ───────────────────────────────────────────── */
|
|
471
|
+
|
|
472
|
+
export const FOREGROUND_POLL_MS = 2_000;
|
|
473
|
+
|
|
474
|
+
type LaunchValidation =
|
|
475
|
+
| { kind: 'error'; text: string }
|
|
476
|
+
| {
|
|
477
|
+
kind: 'ok';
|
|
478
|
+
spec: SubagentSpec;
|
|
479
|
+
background: boolean;
|
|
480
|
+
isolate: boolean;
|
|
481
|
+
cwdParam: string | null;
|
|
482
|
+
roleKind: string;
|
|
483
|
+
suggested: string[];
|
|
484
|
+
manifestRole: string | null;
|
|
485
|
+
tab: string | null;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** Validate spawn parameters. `role` doubles as the pane label and (when it names a profile) the
|
|
489
|
+
* manifest to compose; unknown names stay labels. `isolate` and `cwd` are mutually exclusive. */
|
|
490
|
+
export function planLaunchValidation(
|
|
491
|
+
params: {
|
|
492
|
+
description?: unknown;
|
|
493
|
+
prompt?: unknown;
|
|
494
|
+
run_in_background?: unknown;
|
|
495
|
+
cwd?: unknown;
|
|
496
|
+
isolate?: unknown;
|
|
497
|
+
role?: unknown;
|
|
498
|
+
allowed_tools?: unknown;
|
|
499
|
+
tab?: unknown;
|
|
500
|
+
} | null | undefined,
|
|
501
|
+
herdrAvailable: boolean,
|
|
502
|
+
): LaunchValidation {
|
|
503
|
+
if (!herdrAvailable) {
|
|
504
|
+
return { kind: 'error', text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).' };
|
|
505
|
+
}
|
|
506
|
+
const spec: SubagentSpec = {
|
|
507
|
+
description: String(params?.description ?? 'subagent'),
|
|
508
|
+
prompt: String(params?.prompt ?? ''),
|
|
509
|
+
};
|
|
510
|
+
if (!spec.prompt.trim()) return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
|
|
511
|
+
const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
|
|
512
|
+
const isolate = params?.isolate === true;
|
|
513
|
+
if (isolate && cwdParam) {
|
|
514
|
+
return {
|
|
515
|
+
kind: 'error',
|
|
516
|
+
text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const role = typeof params?.role === 'string' ? params.role.trim() : undefined;
|
|
520
|
+
return {
|
|
521
|
+
kind: 'ok',
|
|
522
|
+
spec,
|
|
523
|
+
background: params?.run_in_background === true,
|
|
524
|
+
isolate,
|
|
525
|
+
cwdParam,
|
|
526
|
+
roleKind: normalizeEntryKind(role),
|
|
527
|
+
suggested: Array.isArray(params?.allowed_tools)
|
|
528
|
+
? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
|
529
|
+
: [],
|
|
530
|
+
manifestRole: role || 'worker-default',
|
|
531
|
+
tab: typeof params?.tab === 'string' ? params.tab : null,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export type ForegroundTickPlan =
|
|
536
|
+
| { kind: 'blocked' }
|
|
537
|
+
| { kind: 'settled'; text: string }
|
|
538
|
+
| { kind: 'wait'; delayMs: number }
|
|
539
|
+
| { kind: 'collect-final' }
|
|
540
|
+
| { kind: 'continue' };
|
|
541
|
+
|
|
542
|
+
/** One foreground-wait iteration: only a human gate or finalized text ends the wait. */
|
|
543
|
+
export function planForegroundTick(input: {
|
|
544
|
+
state: string | null;
|
|
545
|
+
session: { text: string | null; pendingTool: boolean; activity: boolean };
|
|
546
|
+
}): ForegroundTickPlan {
|
|
547
|
+
if (input.state === 'blocked') return { kind: 'blocked' };
|
|
548
|
+
if (input.state !== 'idle' && input.state !== 'done') return { kind: 'continue' };
|
|
549
|
+
if (input.session.text) return { kind: 'settled', text: input.session.text };
|
|
550
|
+
if (input.session.pendingTool || !input.session.activity) return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
|
|
551
|
+
return { kind: 'collect-final' };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* ── task-id resolution ─────────────────────────────────────────── */
|
|
555
|
+
|
|
556
|
+
type TaskIdResolutionResult =
|
|
557
|
+
| { kind: 'resolved'; taskId: string }
|
|
558
|
+
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
559
|
+
| { kind: 'too_short'; query: string }
|
|
560
|
+
| { kind: 'not_found'; query: string };
|
|
561
|
+
|
|
562
|
+
/** Resolve a full or short task ID: exact matches at any length, prefix matches need four characters. */
|
|
563
|
+
export function resolveTaskIdPrefix(
|
|
564
|
+
query: string,
|
|
565
|
+
candidates: Iterable<string>,
|
|
566
|
+
): TaskIdResolutionResult {
|
|
567
|
+
const trimmed = query.trim();
|
|
568
|
+
if (!trimmed) return { kind: 'not_found', query: trimmed };
|
|
569
|
+
|
|
570
|
+
const unique = Array.from(new Set(candidates));
|
|
571
|
+
const exact = unique.find((candidate) => candidate === trimmed);
|
|
572
|
+
if (exact) return { kind: 'resolved', taskId: exact };
|
|
573
|
+
|
|
574
|
+
if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
|
|
575
|
+
|
|
576
|
+
const lower = trimmed.toLowerCase();
|
|
577
|
+
const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
|
|
578
|
+
if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
|
|
579
|
+
if (matches.length > 1) {
|
|
580
|
+
matches.sort();
|
|
581
|
+
return { kind: 'ambiguous', query: trimmed, candidates: matches };
|
|
582
|
+
}
|
|
583
|
+
return { kind: 'not_found', query: trimmed };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/* ── outbound port (composition root → plugin) ──────────────────── */
|
|
587
|
+
|
|
588
|
+
/** Bound atomically at session_start: consumers read `current` and must tolerate null. */
|
|
589
|
+
export interface SubagentPort {
|
|
590
|
+
applyReplySession(paneId: string, sessionFile: string | null): void;
|
|
591
|
+
reconcileOnReply(paneId: string): string[];
|
|
592
|
+
listRunningSubs(): Array<{ paneId: string; description: string }>;
|
|
593
|
+
settleStatLine(paneId: string): Promise<string | null>;
|
|
594
|
+
/** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
|
|
595
|
+
consumeReply(paneId: string, outcome: string | null): void;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export interface SubagentPortBox {
|
|
599
|
+
current: SubagentPort | null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function emptySubagentPortBox(): SubagentPortBox {
|
|
603
|
+
return { current: null };
|
|
604
|
+
}
|