pi-pier 0.2.7 → 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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
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,25 +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;
|
|
86
|
+
/** Herdr 0.9.1: foreground working directory of the PTY process. */
|
|
87
|
+
foregroundCwd?: string | null;
|
|
94
88
|
}
|
|
95
89
|
|
|
96
|
-
/**
|
|
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
|
+
*/
|
|
97
95
|
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
98
96
|
if (!probe.paneExists) return false;
|
|
99
97
|
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
100
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
101
98
|
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
105
101
|
export function agoText(ms: number, nowMs: number): string {
|
|
106
102
|
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
107
103
|
if (s < 60) return `${s}s ago`;
|
|
@@ -109,7 +105,6 @@ export function agoText(ms: number, nowMs: number): string {
|
|
|
109
105
|
return `${Math.floor(s / 3600)}h ago`;
|
|
110
106
|
}
|
|
111
107
|
|
|
112
|
-
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
113
108
|
export function buildAliveNotice(
|
|
114
109
|
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
115
110
|
nowMs: number,
|
|
@@ -132,7 +127,7 @@ export function buildAliveNotice(
|
|
|
132
127
|
].join(' ');
|
|
133
128
|
}
|
|
134
129
|
|
|
135
|
-
/**
|
|
130
|
+
/** A pane blocked on a human decision keeps running: the master must not take the work over. */
|
|
136
131
|
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
137
132
|
const { paneId, description, question } = opts;
|
|
138
133
|
const q = question ? ` (question: "${question}")` : '';
|
|
@@ -145,35 +140,30 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
|
|
|
145
140
|
].join(' ');
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
149
|
-
|
|
150
143
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
|
151
144
|
|
|
152
145
|
export interface SubEntry {
|
|
153
146
|
taskId: string;
|
|
154
|
-
/**
|
|
147
|
+
/** 'task' or a role name; only a display label for GC. */
|
|
155
148
|
kind: string;
|
|
156
149
|
paneId: string;
|
|
157
150
|
tabId: string;
|
|
158
|
-
/** Why: Preserve the established compatibility and safety behavior (D25, D26). */
|
|
159
151
|
tabName: string;
|
|
160
152
|
cwd: string;
|
|
161
153
|
description: string;
|
|
162
154
|
background: boolean;
|
|
163
155
|
status: 'running' | 'settled' | 'consumed' | 'closed';
|
|
164
|
-
/**
|
|
156
|
+
/** GC grace basis (durable); closed rows keep the original value. */
|
|
165
157
|
consumedAt?: number | null;
|
|
166
158
|
sessionFile: string | null;
|
|
167
159
|
launchCommand: string[];
|
|
168
160
|
createdAt: number;
|
|
169
161
|
revivedFrom?: string | null;
|
|
170
|
-
/**
|
|
162
|
+
/** D94: the human took the pane over; observation window judges when to hand control back. */
|
|
171
163
|
userTakeover?: boolean;
|
|
172
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
173
164
|
observationStartedAt?: number | null;
|
|
174
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
175
165
|
lastAgentStatus?: string | null;
|
|
176
|
-
/**
|
|
166
|
+
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
177
167
|
isolate?: {
|
|
178
168
|
worktreePath: string;
|
|
179
169
|
branch: string;
|
|
@@ -192,14 +182,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
|
|
|
192
182
|
return { version: 2, subs };
|
|
193
183
|
}
|
|
194
184
|
|
|
195
|
-
|
|
196
|
-
|
|
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 {
|
|
197
190
|
let found = makeRegistry();
|
|
198
191
|
for (const entry of entries) {
|
|
199
192
|
if (entry.type !== 'custom' || entry.customType !== SUBS_CUSTOM_TYPE) continue;
|
|
200
|
-
const data = entry.data as {
|
|
201
|
-
subs?: Array<Partial<SubEntry> & { paneId: string }>;
|
|
202
|
-
} | undefined;
|
|
193
|
+
const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
|
|
203
194
|
if (!data || !Array.isArray(data.subs)) continue;
|
|
204
195
|
const subs: SubEntry[] = data.subs
|
|
205
196
|
.filter((s) => typeof s?.paneId === 'string')
|
|
@@ -212,7 +203,9 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
212
203
|
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
213
204
|
description: typeof s.description === 'string' ? s.description : '',
|
|
214
205
|
background: s.background === true,
|
|
215
|
-
status:
|
|
206
|
+
status: typeof s.status === 'string' && SUB_STATUSES[s.status] === true
|
|
207
|
+
? (s.status as SubEntry['status'])
|
|
208
|
+
: 'running',
|
|
216
209
|
consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
|
|
217
210
|
sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
|
|
218
211
|
launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
|
|
@@ -238,36 +231,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
238
231
|
return found;
|
|
239
232
|
}
|
|
240
233
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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;
|
|
245
242
|
}
|
|
246
243
|
|
|
247
|
-
/**
|
|
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
|
+
}
|
|
248
249
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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) };
|
|
252
255
|
}
|
|
253
256
|
|
|
254
|
-
/**
|
|
255
|
-
export function makeProgressUpdate(msg: string)
|
|
256
|
-
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> };
|
|
257
260
|
}
|
|
258
261
|
|
|
259
|
-
/**
|
|
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
|
+
}
|
|
260
294
|
|
|
261
295
|
export const TAB_NAME_MAX = 20;
|
|
262
296
|
|
|
263
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
264
297
|
export function tabNameForTask(description: string): string {
|
|
265
298
|
const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
|
|
266
299
|
const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
|
|
267
300
|
return truncated || 'task';
|
|
268
301
|
}
|
|
269
302
|
|
|
270
|
-
/** Why: Preserve the established compatibility and safety behavior (D26). */
|
|
271
303
|
export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
|
|
272
304
|
let name = base;
|
|
273
305
|
let n = 2;
|
|
@@ -280,16 +312,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
|
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
export interface TabPlacementPlan {
|
|
283
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
284
315
|
mode: 'append' | 'new';
|
|
285
316
|
tabName: string;
|
|
286
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
287
317
|
tabId: string | null;
|
|
288
318
|
}
|
|
289
319
|
|
|
290
|
-
/**
|
|
291
|
-
|
|
292
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
320
|
+
/** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
|
|
293
321
|
export function isPathUnder(cwd: string, wt: string): boolean {
|
|
294
322
|
const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
295
323
|
const a = n(cwd);
|
|
@@ -298,13 +326,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
|
|
|
298
326
|
}
|
|
299
327
|
|
|
300
328
|
export interface WorktreeZone {
|
|
301
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
302
329
|
zone: 'main' | 'worktree';
|
|
303
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
304
330
|
tabName: string | null;
|
|
305
331
|
}
|
|
306
332
|
|
|
307
|
-
/**
|
|
333
|
+
/** D86: subagents working in the master's checkout share its tab; another worktree gets its own. */
|
|
308
334
|
export function classifyWorktreeZone(opts: {
|
|
309
335
|
cwd: string;
|
|
310
336
|
masterCwd: string;
|
|
@@ -312,21 +338,19 @@ export function classifyWorktreeZone(opts: {
|
|
|
312
338
|
}): WorktreeZone {
|
|
313
339
|
for (const wt of opts.worktrees) {
|
|
314
340
|
if (!isPathUnder(opts.cwd, wt)) continue;
|
|
315
|
-
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
341
|
+
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
316
342
|
const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
|
|
317
343
|
return { zone: 'worktree', tabName: base };
|
|
318
344
|
}
|
|
319
345
|
return { zone: 'main', tabName: null };
|
|
320
346
|
}
|
|
321
347
|
|
|
322
|
-
/**
|
|
348
|
+
/** Explicit `tab` wins; otherwise D86 zone decides, and an unknown zone falls back to the description. */
|
|
323
349
|
export function planTabPlacement(opts: {
|
|
324
350
|
desiredTab?: string | null;
|
|
325
351
|
description: string;
|
|
326
352
|
knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
|
|
327
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
328
353
|
zone?: WorktreeZone;
|
|
329
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
330
354
|
mainTabId?: string | null;
|
|
331
355
|
}): TabPlacementPlan {
|
|
332
356
|
const known = new Map<string, string>();
|
|
@@ -338,65 +362,44 @@ export function planTabPlacement(opts: {
|
|
|
338
362
|
}
|
|
339
363
|
const zone = opts.zone;
|
|
340
364
|
if (zone?.zone === 'main' && opts.mainTabId) {
|
|
341
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
342
365
|
return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
|
|
343
366
|
}
|
|
344
367
|
if (zone?.zone === 'worktree' && zone.tabName) {
|
|
345
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
346
368
|
const base = zone.tabName;
|
|
347
369
|
const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
|
|
348
370
|
if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
|
|
349
|
-
|
|
350
|
-
return { mode: 'new', tabName, tabId: null };
|
|
371
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
351
372
|
}
|
|
352
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
353
373
|
const base = tabNameForTask(opts.description);
|
|
354
|
-
|
|
355
|
-
return { mode: 'new', tabName, tabId: null };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Why: Preserve the established compatibility and safety behavior (D97). */
|
|
359
|
-
|
|
360
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
361
|
-
export interface LaunchRuntime {
|
|
362
|
-
nodePath: string;
|
|
363
|
-
cliPath: string;
|
|
364
|
-
extPath: string;
|
|
374
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
365
375
|
}
|
|
366
376
|
|
|
367
|
-
/**
|
|
377
|
+
/** D97: fullscreen is what makes the pane a static frame the master can read. */
|
|
368
378
|
export function buildLaunchParts(
|
|
369
|
-
runtime:
|
|
379
|
+
runtime: { nodePath: string; cliPath: string; extPath: string },
|
|
370
380
|
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
371
|
-
env:
|
|
381
|
+
env: { PI_HERDR_TUI?: string | undefined } = process.env,
|
|
372
382
|
): string[] {
|
|
373
383
|
const parts = [runtime.nodePath, runtime.cliPath];
|
|
374
384
|
if (opts.approve) parts.push('-a');
|
|
375
385
|
parts.push('-e', runtime.extPath);
|
|
376
386
|
if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
|
|
377
|
-
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
|
+
}
|
|
378
392
|
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
379
393
|
return parts;
|
|
380
394
|
}
|
|
381
395
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
385
|
-
export interface IsolatePlan {
|
|
386
|
-
branch: string;
|
|
387
|
-
slug: string;
|
|
388
|
-
worktreeDirName: string;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
392
|
-
export const ISOLATE_SLUG_MAX = 40;
|
|
396
|
+
const ISOLATE_SLUG_MAX = 40;
|
|
393
397
|
|
|
394
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
395
398
|
export function planIsolateWorktree(opts: {
|
|
396
399
|
description: string;
|
|
397
400
|
taskHex: string;
|
|
398
401
|
existingPierBranches: ReadonlySet<string>;
|
|
399
|
-
})
|
|
402
|
+
}) {
|
|
400
403
|
const folded = String(opts.description ?? '')
|
|
401
404
|
.toLowerCase()
|
|
402
405
|
.replace(/[^a-z0-9]+/g, '-')
|
|
@@ -413,7 +416,7 @@ export function planIsolateWorktree(opts: {
|
|
|
413
416
|
return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
|
|
414
417
|
}
|
|
415
418
|
|
|
416
|
-
/**
|
|
419
|
+
/** Commit discipline is injected into the prompt: the worktree has no other protection. */
|
|
417
420
|
export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
|
|
418
421
|
return [
|
|
419
422
|
`You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
|
|
@@ -424,7 +427,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
|
|
|
424
427
|
].join('\n');
|
|
425
428
|
}
|
|
426
429
|
|
|
427
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
428
430
|
export function formatWorktreeStat(opts: {
|
|
429
431
|
branch: string | null;
|
|
430
432
|
commits: number | null;
|
|
@@ -443,17 +445,16 @@ export function formatWorktreeStat(opts: {
|
|
|
443
445
|
return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
|
|
444
446
|
}
|
|
445
447
|
|
|
446
|
-
/**
|
|
447
|
-
export
|
|
448
|
-
|
|
449
|
-
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' } {
|
|
450
451
|
if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
|
|
451
452
|
if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
|
|
452
453
|
if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
|
|
453
454
|
return { action: 'release' };
|
|
454
455
|
}
|
|
455
456
|
|
|
456
|
-
/**
|
|
457
|
+
/** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
|
|
457
458
|
export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
458
459
|
const byBranch = new Map<string, string>();
|
|
459
460
|
let curPath: string | null = null;
|
|
@@ -465,3 +466,139 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
|
465
466
|
}
|
|
466
467
|
return byBranch;
|
|
467
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
|
+
}
|