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.
- package/README.md +16 -12
- package/package.json +3 -3
- 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 +10 -13
- package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- 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/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -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 +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- 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 +61 -22
- 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 +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -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/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- 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/focus-poller.ts +0 -244
- 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,138 @@
|
|
|
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';
|
|
6
|
+
import { Type } from 'typebox';
|
|
7
|
+
|
|
8
|
+
export function sleep(ms: number): Promise<void> {
|
|
9
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
10
|
+
setTimeout(resolve, ms);
|
|
11
|
+
return promise;
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export interface SubagentSpec {
|
|
5
15
|
description: string;
|
|
6
16
|
prompt: string;
|
|
7
17
|
}
|
|
8
18
|
|
|
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. */
|
|
19
|
+
/** POSIX single-quote escape; win32 PowerShell doubles the quote instead. */
|
|
20
20
|
export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platform = process.platform): string {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
const win = platform === 'win32';
|
|
22
|
+
const quoted = parts.map((s) => (win
|
|
23
|
+
? `'${String(s).replace(/'/g, "''")}'`
|
|
24
|
+
: `'${String(s).replace(/'/g, `'\\''`)}'`));
|
|
25
|
+
return (win ? '& ' : '') + quoted.join(' ');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shells that mean a freshly split pane is at its prompt and can accept a launch line. */
|
|
29
|
+
const SHELL_PROCESS_NAMES: ReadonlySet<string> = new Set([
|
|
30
|
+
'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'login',
|
|
31
|
+
'pwsh', 'powershell', 'cmd',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Normalize `name` / `argv0` (`/bin/zsh`, `C:\\Windows\\System32\\cmd.exe`, `-zsh`) to a shell key. */
|
|
35
|
+
export function shellProcessName(value: string | null | undefined): string {
|
|
36
|
+
const base = String(value ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
|
|
37
|
+
const stripped = base.replace(/^-/, '').replace(/\.exe$/, '');
|
|
38
|
+
return stripped;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether a pane's foreground set means its shell is ready for input. `null`/`undefined`
|
|
43
|
+
* means the probe was unavailable: return ready so an older herdr never adds spawn latency.
|
|
44
|
+
* An empty set is not proof (the shell may still be starting), so callers wait for the timeout.
|
|
45
|
+
*/
|
|
46
|
+
export function planShellReady(
|
|
47
|
+
foreground: ReadonlyArray<{ name?: string | null; argv0?: string | null }> | null | undefined,
|
|
48
|
+
): 'ready' | 'wait' {
|
|
49
|
+
if (foreground == null) return 'ready';
|
|
50
|
+
if (foreground.length === 0) return 'wait';
|
|
51
|
+
return foreground.some((p) => SHELL_PROCESS_NAMES.has(shellProcessName(p.name)) || SHELL_PROCESS_NAMES.has(shellProcessName(p.argv0)))
|
|
52
|
+
? 'ready'
|
|
53
|
+
: 'wait';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Flags pier passes at spawn (buildLaunchParts) that a herdr 0.9.2+ resume command must replay. */
|
|
57
|
+
const RESUME_FLAG_ARITY: Record<string, number> = {
|
|
58
|
+
'-a': 0,
|
|
59
|
+
'-e': 1,
|
|
60
|
+
'--extension': 1,
|
|
61
|
+
'--tui-mode': 1,
|
|
62
|
+
'--provider': 1,
|
|
63
|
+
'--model': 1,
|
|
64
|
+
'--session': 1,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const RESUME_MAX_ARGS = 64;
|
|
68
|
+
const RESUME_MAX_BYTES = 8 * 1024;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* herdr 0.9.2 self-report (docs "Add Herdr support to your agent"): rebuild this worker's launch
|
|
72
|
+
* line as a `resume_argv` herdr runs after a server restart. The worker's own process.argv is the
|
|
73
|
+
* source of truth — interpreter and cli.js paths are dropped, whitelisted spawn flags are mirrored
|
|
74
|
+
* (`--flag=value` splits), argv[0] becomes a plain PATH command name. herdr rules: argv[0] plain,
|
|
75
|
+
* no apostrophes/control characters, ≤64 args, ≤8 KiB; violations (or a missing `-e`, which could
|
|
76
|
+
* not restore the extension) return null and the caller skips the report — restore stays best-effort.
|
|
77
|
+
*/
|
|
78
|
+
export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): string[] | null {
|
|
79
|
+
if (!/^[A-Za-z0-9._-]+$/.test(piBin)) return null;
|
|
80
|
+
const collected: string[] = [];
|
|
81
|
+
let index = 0;
|
|
82
|
+
// Skip interpreter + cli script (everything before the first flag).
|
|
83
|
+
while (index < workerArgv.length && !workerArgv[index]!.startsWith('-')) index++;
|
|
84
|
+
for (; index < workerArgv.length; index++) {
|
|
85
|
+
const raw = workerArgv[index]!;
|
|
86
|
+
if (!raw.startsWith('-')) continue; // positional (unknown flag's value) — not pier spawn shape
|
|
87
|
+
const eq = raw.indexOf('=');
|
|
88
|
+
const flag = eq > 0 ? raw.slice(0, eq) : raw;
|
|
89
|
+
const inlineValue = eq > 0 ? raw.slice(eq + 1) : null;
|
|
90
|
+
const arity = RESUME_FLAG_ARITY[flag];
|
|
91
|
+
if (arity === undefined) continue; // pi-internal flags are not replayed
|
|
92
|
+
if (arity === 0) {
|
|
93
|
+
collected.push(flag);
|
|
94
|
+
} else if (inlineValue !== null) {
|
|
95
|
+
collected.push(flag, inlineValue);
|
|
96
|
+
} else if (index + 1 < workerArgv.length) {
|
|
97
|
+
collected.push(flag, workerArgv[++index]!);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!collected.some((f, i) => (f === '-e' || f === '--extension') && i + 1 < collected.length)) return null;
|
|
101
|
+
const argv = [piBin, ...collected];
|
|
102
|
+
if (argv.length > RESUME_MAX_ARGS) return null;
|
|
103
|
+
if (argv.some((a) => /['\u0000-\u001f\u007f]/.test(a))) return null;
|
|
104
|
+
if (argv.reduce((sum, a) => sum + a.length + 1, 0) > RESUME_MAX_BYTES) return null;
|
|
105
|
+
return argv;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* pi ≥ 0.99 outputSchema for the subagent tool: the `details` each action already produces, stamped
|
|
110
|
+
* with the normalized action. Codemode scripts (and any structured consumer) read this instead of
|
|
111
|
+
* parsing the prose reply; the text content stays authoritative for the transcript. Hard failures
|
|
112
|
+
* throw (toolError), so every returned value is a success and always carries structuredContent.
|
|
113
|
+
*/
|
|
114
|
+
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
115
|
+
action: Type.Union([
|
|
116
|
+
Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
|
|
117
|
+
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
|
|
118
|
+
]),
|
|
119
|
+
paneId: Type.Optional(Type.String()),
|
|
120
|
+
taskId: Type.Optional(Type.String()),
|
|
121
|
+
role: Type.Optional(Type.String()),
|
|
122
|
+
background: Type.Optional(Type.Boolean()),
|
|
123
|
+
alreadyRunning: Type.Optional(Type.Boolean()),
|
|
124
|
+
}, { additionalProperties: true });
|
|
125
|
+
|
|
126
|
+
export function attachSubagentStructuredContent(action: string, result: unknown): unknown {
|
|
127
|
+
if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
|
|
128
|
+
const record = result as Record<string, unknown>;
|
|
129
|
+
const details = record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details)
|
|
130
|
+
? record.details as Record<string, unknown>
|
|
131
|
+
: {};
|
|
132
|
+
return { ...record, structuredContent: { action, ...details } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
|
|
28
136
|
export class Semaphore {
|
|
29
137
|
private active = 0;
|
|
30
138
|
private queue: Array<() => void> = [];
|
|
@@ -44,12 +152,12 @@ export class Semaphore {
|
|
|
44
152
|
this.active++;
|
|
45
153
|
return Promise.resolve(this.release.bind(this));
|
|
46
154
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
155
|
+
const { promise, resolve } = Promise.withResolvers<() => void>();
|
|
156
|
+
this.queue.push(() => {
|
|
157
|
+
this.active++;
|
|
158
|
+
resolve(this.release.bind(this));
|
|
52
159
|
});
|
|
160
|
+
return promise;
|
|
53
161
|
}
|
|
54
162
|
|
|
55
163
|
private release(): void {
|
|
@@ -59,19 +167,15 @@ export class Semaphore {
|
|
|
59
167
|
}
|
|
60
168
|
}
|
|
61
169
|
|
|
62
|
-
|
|
170
|
+
interface SubagentOutcome {
|
|
63
171
|
kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
|
|
64
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
65
172
|
text: string;
|
|
66
173
|
}
|
|
67
174
|
|
|
68
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
69
175
|
export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
|
|
70
176
|
switch (outcome.kind) {
|
|
71
|
-
case 'completed':
|
|
72
|
-
|
|
73
|
-
return text ? text : 'Subagent finished but produced no output.';
|
|
74
|
-
}
|
|
177
|
+
case 'completed':
|
|
178
|
+
return outcome.text ? outcome.text : 'Subagent finished but produced no output.';
|
|
75
179
|
case 'timeout':
|
|
76
180
|
return `Error: subagent "${description}" timed out. Partial output:\n${outcome.text || '(none)'}`;
|
|
77
181
|
case 'blocked':
|
|
@@ -83,27 +187,25 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
|
|
|
83
187
|
}
|
|
84
188
|
}
|
|
85
189
|
|
|
86
|
-
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
87
|
-
|
|
88
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
89
190
|
export interface AliveProbe {
|
|
90
191
|
paneExists: boolean;
|
|
91
192
|
agentStatus: string | null;
|
|
92
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
93
193
|
lastActivityMs: number | null;
|
|
94
194
|
/** Herdr 0.9.1: foreground working directory of the PTY process. */
|
|
95
195
|
foregroundCwd?: string | null;
|
|
96
196
|
}
|
|
97
197
|
|
|
98
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
|
|
200
|
+
* age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
|
|
201
|
+
* the caller's to report, not guessed here.
|
|
202
|
+
*/
|
|
99
203
|
export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
|
|
100
204
|
if (!probe.paneExists) return false;
|
|
101
205
|
if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
|
|
102
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
103
206
|
return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
|
|
104
207
|
}
|
|
105
208
|
|
|
106
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
107
209
|
export function agoText(ms: number, nowMs: number): string {
|
|
108
210
|
const s = Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
109
211
|
if (s < 60) return `${s}s ago`;
|
|
@@ -111,7 +213,6 @@ export function agoText(ms: number, nowMs: number): string {
|
|
|
111
213
|
return `${Math.floor(s / 3600)}h ago`;
|
|
112
214
|
}
|
|
113
215
|
|
|
114
|
-
/** Why: Preserve the established compatibility and safety behavior (A2, B1). */
|
|
115
216
|
export function buildAliveNotice(
|
|
116
217
|
opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
|
|
117
218
|
nowMs: number,
|
|
@@ -134,7 +235,7 @@ export function buildAliveNotice(
|
|
|
134
235
|
].join(' ');
|
|
135
236
|
}
|
|
136
237
|
|
|
137
|
-
/**
|
|
238
|
+
/** A pane blocked on a human decision keeps running: the master must not take the work over. */
|
|
138
239
|
export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
|
|
139
240
|
const { paneId, description, question } = opts;
|
|
140
241
|
const q = question ? ` (question: "${question}")` : '';
|
|
@@ -147,35 +248,33 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
|
|
|
147
248
|
].join(' ');
|
|
148
249
|
}
|
|
149
250
|
|
|
150
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
151
|
-
|
|
152
251
|
export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
|
|
153
252
|
|
|
154
253
|
export interface SubEntry {
|
|
155
254
|
taskId: string;
|
|
156
|
-
/**
|
|
255
|
+
/** 'task' or a role name; only a display label for GC. */
|
|
157
256
|
kind: string;
|
|
158
257
|
paneId: string;
|
|
258
|
+
/** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
|
|
259
|
+
* accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
|
|
260
|
+
slug?: string;
|
|
159
261
|
tabId: string;
|
|
160
|
-
/** Why: Preserve the established compatibility and safety behavior (D25, D26). */
|
|
161
262
|
tabName: string;
|
|
162
263
|
cwd: string;
|
|
163
264
|
description: string;
|
|
164
265
|
background: boolean;
|
|
165
266
|
status: 'running' | 'settled' | 'consumed' | 'closed';
|
|
166
|
-
/**
|
|
267
|
+
/** GC grace basis (durable); closed rows keep the original value. */
|
|
167
268
|
consumedAt?: number | null;
|
|
168
269
|
sessionFile: string | null;
|
|
169
270
|
launchCommand: string[];
|
|
170
271
|
createdAt: number;
|
|
171
272
|
revivedFrom?: string | null;
|
|
172
|
-
/**
|
|
273
|
+
/** D94: the human took the pane over; observation window judges when to hand control back. */
|
|
173
274
|
userTakeover?: boolean;
|
|
174
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
175
275
|
observationStartedAt?: number | null;
|
|
176
|
-
/** Why: Preserve the established compatibility and safety behavior (D94). */
|
|
177
276
|
lastAgentStatus?: string | null;
|
|
178
|
-
/**
|
|
277
|
+
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
179
278
|
isolate?: {
|
|
180
279
|
worktreePath: string;
|
|
181
280
|
branch: string;
|
|
@@ -185,6 +284,41 @@ export interface SubEntry {
|
|
|
185
284
|
};
|
|
186
285
|
}
|
|
187
286
|
|
|
287
|
+
/* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
|
|
288
|
+
|
|
289
|
+
/** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
|
|
290
|
+
const SLUG_MAX = 24;
|
|
291
|
+
|
|
292
|
+
/** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
|
|
293
|
+
* then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
|
|
294
|
+
export function slugifyTask(description: string): string {
|
|
295
|
+
return description
|
|
296
|
+
.toLowerCase()
|
|
297
|
+
.replace(/[''']/g, '')
|
|
298
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
299
|
+
.replace(/^-+|-+$/g, '')
|
|
300
|
+
.slice(0, SLUG_MAX)
|
|
301
|
+
.replace(/-+$/g, '');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
|
|
305
|
+
* sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
|
|
306
|
+
export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
|
|
307
|
+
const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
|
|
308
|
+
if (!taken.has(base)) return base;
|
|
309
|
+
for (let n = 2; ; n++) {
|
|
310
|
+
const next = `${base}-${n}`;
|
|
311
|
+
if (!taken.has(next)) return next;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
|
|
316
|
+
export function subRef(sub: { paneId: string; slug?: string }): string {
|
|
317
|
+
return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const PROGRESS_CUSTOM_TYPE = 'pi-herdr.progress';
|
|
321
|
+
|
|
188
322
|
export interface SubsRegistry {
|
|
189
323
|
version: 2;
|
|
190
324
|
subs: SubEntry[];
|
|
@@ -194,14 +328,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
|
|
|
194
328
|
return { version: 2, subs };
|
|
195
329
|
}
|
|
196
330
|
|
|
197
|
-
|
|
198
|
-
|
|
331
|
+
const SUB_STATUSES: Record<string, true> = { running: true, settled: true, consumed: true, closed: true };
|
|
332
|
+
|
|
333
|
+
/** Replay `pi-herdr.subs` custom branch entries into a registry; last snapshot wins. Tolerant of
|
|
334
|
+
* v1 rows (missing taskId/kind/tabName) and unknown future fields. */
|
|
335
|
+
export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>): SubsRegistry {
|
|
199
336
|
let found = makeRegistry();
|
|
200
337
|
for (const entry of entries) {
|
|
201
338
|
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;
|
|
339
|
+
const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
|
|
205
340
|
if (!data || !Array.isArray(data.subs)) continue;
|
|
206
341
|
const subs: SubEntry[] = data.subs
|
|
207
342
|
.filter((s) => typeof s?.paneId === 'string')
|
|
@@ -209,12 +344,15 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
209
344
|
taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
|
|
210
345
|
kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
|
|
211
346
|
paneId: s.paneId,
|
|
347
|
+
slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
|
|
212
348
|
tabId: typeof s.tabId === 'string' ? s.tabId : '',
|
|
213
349
|
tabName: typeof s.tabName === 'string' ? s.tabName : '',
|
|
214
350
|
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
215
351
|
description: typeof s.description === 'string' ? s.description : '',
|
|
216
352
|
background: s.background === true,
|
|
217
|
-
status:
|
|
353
|
+
status: typeof s.status === 'string' && SUB_STATUSES[s.status] === true
|
|
354
|
+
? (s.status as SubEntry['status'])
|
|
355
|
+
: 'running',
|
|
218
356
|
consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
|
|
219
357
|
sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
|
|
220
358
|
launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
|
|
@@ -240,36 +378,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
|
|
|
240
378
|
return found;
|
|
241
379
|
}
|
|
242
380
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
381
|
+
/** Latest row per taskId (same taskId rows are generations); newest createdAt wins. */
|
|
382
|
+
export function newestPerTaskId(entries: Iterable<SubEntry>): Map<string, SubEntry> {
|
|
383
|
+
const byTask = new Map<string, SubEntry>();
|
|
384
|
+
for (const sub of entries) {
|
|
385
|
+
const prev = byTask.get(sub.taskId);
|
|
386
|
+
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
387
|
+
}
|
|
388
|
+
return byTask;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Re-key a revived entry under its new pane id; the dead id must go, or a later zombie sweep closes the live row. */
|
|
392
|
+
export function rekeySub(subs: Map<string, SubEntry>, previousPaneId: string, entry: SubEntry): void {
|
|
393
|
+
if (previousPaneId !== entry.paneId && subs.get(previousPaneId) === entry) subs.delete(previousPaneId);
|
|
394
|
+
subs.set(entry.paneId, entry);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Split `provider/model` on the first '/' only: model ids such as `moonshotai/kimi-k2` carry their own. */
|
|
398
|
+
export function splitRoleModel(roleModel: string): { provider: string | null; model: string } {
|
|
399
|
+
const slash = roleModel.indexOf('/');
|
|
400
|
+
if (slash <= 0 || slash === roleModel.length - 1) return { provider: null, model: roleModel };
|
|
401
|
+
return { provider: roleModel.slice(0, slash), model: roleModel.slice(slash + 1) };
|
|
247
402
|
}
|
|
248
403
|
|
|
249
|
-
/**
|
|
404
|
+
/** pi's AgentToolResult shape — a bare string crashes the interactive TUI's getTextOutput. */
|
|
405
|
+
export function makeProgressUpdate(msg: string) {
|
|
406
|
+
return { content: [{ type: 'text' as const, text: msg }], details: {} as Record<string, never> };
|
|
407
|
+
}
|
|
250
408
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Models sometimes wrap fields in a `parameters` object (JSON-schema habit). Top-level wins;
|
|
411
|
+
* only missing keys are filled from the nested bag so a real field is never overwritten.
|
|
412
|
+
*/
|
|
413
|
+
export function flattenToolParams(params: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
414
|
+
if (!params) return params;
|
|
415
|
+
const nested = params.parameters;
|
|
416
|
+
if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) return params;
|
|
417
|
+
const out: Record<string, unknown> = { ...params };
|
|
418
|
+
for (const [key, value] of Object.entries(nested as Record<string, unknown>)) {
|
|
419
|
+
if (key === 'parameters') continue;
|
|
420
|
+
const current = out[key];
|
|
421
|
+
if (current == null || current === '') out[key] = value;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
254
424
|
}
|
|
255
425
|
|
|
256
|
-
|
|
257
|
-
export function
|
|
258
|
-
|
|
426
|
+
|
|
427
|
+
export function idParam(params: Record<string, unknown> | undefined, ...keys: string[]): string {
|
|
428
|
+
const flat = flattenToolParams(params);
|
|
429
|
+
for (const key of keys) {
|
|
430
|
+
const v = flat?.[key];
|
|
431
|
+
if (v != null && String(v).trim() !== '') return String(v).trim();
|
|
432
|
+
}
|
|
433
|
+
return '';
|
|
259
434
|
}
|
|
260
435
|
|
|
261
|
-
|
|
436
|
+
export function ambiguousIdError(label: 'task id' | 'subagent id', query: string, candidates: readonly string[]): string {
|
|
437
|
+
const head = candidates.slice(0, 5).join(', ');
|
|
438
|
+
const more = candidates.length > 5 ? `, ... (+${candidates.length - 5} more)` : '';
|
|
439
|
+
return `Error: ambiguous ${label} "${query}" matches ${candidates.length} tasks: ${head}${more}`;
|
|
440
|
+
}
|
|
262
441
|
|
|
263
442
|
export const TAB_NAME_MAX = 20;
|
|
264
443
|
|
|
265
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
266
444
|
export function tabNameForTask(description: string): string {
|
|
267
445
|
const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
|
|
268
446
|
const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
|
|
269
447
|
return truncated || 'task';
|
|
270
448
|
}
|
|
271
449
|
|
|
272
|
-
/** Why: Preserve the established compatibility and safety behavior (D26). */
|
|
273
450
|
export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
|
|
274
451
|
let name = base;
|
|
275
452
|
let n = 2;
|
|
@@ -282,16 +459,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
|
|
|
282
459
|
}
|
|
283
460
|
|
|
284
461
|
export interface TabPlacementPlan {
|
|
285
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
286
462
|
mode: 'append' | 'new';
|
|
287
463
|
tabName: string;
|
|
288
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
289
464
|
tabId: string | null;
|
|
290
465
|
}
|
|
291
466
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
294
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
467
|
+
/** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
|
|
295
468
|
export function isPathUnder(cwd: string, wt: string): boolean {
|
|
296
469
|
const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
297
470
|
const a = n(cwd);
|
|
@@ -300,13 +473,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
|
|
|
300
473
|
}
|
|
301
474
|
|
|
302
475
|
export interface WorktreeZone {
|
|
303
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
304
476
|
zone: 'main' | 'worktree';
|
|
305
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
306
477
|
tabName: string | null;
|
|
307
478
|
}
|
|
308
479
|
|
|
309
|
-
/**
|
|
480
|
+
/** D86: subagents working in the master's checkout share its tab; another worktree gets its own. */
|
|
310
481
|
export function classifyWorktreeZone(opts: {
|
|
311
482
|
cwd: string;
|
|
312
483
|
masterCwd: string;
|
|
@@ -314,21 +485,19 @@ export function classifyWorktreeZone(opts: {
|
|
|
314
485
|
}): WorktreeZone {
|
|
315
486
|
for (const wt of opts.worktrees) {
|
|
316
487
|
if (!isPathUnder(opts.cwd, wt)) continue;
|
|
317
|
-
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
488
|
+
if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
|
|
318
489
|
const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
|
|
319
490
|
return { zone: 'worktree', tabName: base };
|
|
320
491
|
}
|
|
321
492
|
return { zone: 'main', tabName: null };
|
|
322
493
|
}
|
|
323
494
|
|
|
324
|
-
/**
|
|
495
|
+
/** Explicit `tab` wins; otherwise D86 zone decides, and an unknown zone falls back to the description. */
|
|
325
496
|
export function planTabPlacement(opts: {
|
|
326
497
|
desiredTab?: string | null;
|
|
327
498
|
description: string;
|
|
328
499
|
knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
|
|
329
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
330
500
|
zone?: WorktreeZone;
|
|
331
|
-
/** Why: Preserve the established compatibility and safety behavior (D86). */
|
|
332
501
|
mainTabId?: string | null;
|
|
333
502
|
}): TabPlacementPlan {
|
|
334
503
|
const known = new Map<string, string>();
|
|
@@ -340,35 +509,21 @@ export function planTabPlacement(opts: {
|
|
|
340
509
|
}
|
|
341
510
|
const zone = opts.zone;
|
|
342
511
|
if (zone?.zone === 'main' && opts.mainTabId) {
|
|
343
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
344
512
|
return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
|
|
345
513
|
}
|
|
346
514
|
if (zone?.zone === 'worktree' && zone.tabName) {
|
|
347
|
-
// Why: Preserve the established compatibility and safety behavior (D86, R1).
|
|
348
515
|
const base = zone.tabName;
|
|
349
516
|
const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
|
|
350
517
|
if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
|
|
351
|
-
|
|
352
|
-
return { mode: 'new', tabName, tabId: null };
|
|
518
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
353
519
|
}
|
|
354
|
-
// Why: Preserve the established compatibility and safety behavior.
|
|
355
520
|
const base = tabNameForTask(opts.description);
|
|
356
|
-
|
|
357
|
-
return { mode: 'new', tabName, tabId: null };
|
|
521
|
+
return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
|
|
358
522
|
}
|
|
359
523
|
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
363
|
-
export interface LaunchRuntime {
|
|
364
|
-
nodePath: string;
|
|
365
|
-
cliPath: string;
|
|
366
|
-
extPath: string;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Why: Preserve the established compatibility and safety behavior (D97). */
|
|
524
|
+
/** D97: fullscreen is what makes the pane a static frame the master can read. */
|
|
370
525
|
export function buildLaunchParts(
|
|
371
|
-
runtime:
|
|
526
|
+
runtime: { nodePath: string; cliPath: string; extPath: string },
|
|
372
527
|
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
373
528
|
env: { PI_HERDR_TUI?: string | undefined } = process.env,
|
|
374
529
|
): string[] {
|
|
@@ -376,29 +531,28 @@ export function buildLaunchParts(
|
|
|
376
531
|
if (opts.approve) parts.push('-a');
|
|
377
532
|
parts.push('-e', runtime.extPath);
|
|
378
533
|
if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
|
|
379
|
-
if (opts.roleModel)
|
|
534
|
+
if (opts.roleModel) {
|
|
535
|
+
const { provider, model } = splitRoleModel(opts.roleModel);
|
|
536
|
+
if (provider) parts.push('--provider', provider);
|
|
537
|
+
parts.push('--model', model);
|
|
538
|
+
}
|
|
380
539
|
if (opts.resumeFile) parts.push('--session', opts.resumeFile);
|
|
381
540
|
return parts;
|
|
382
541
|
}
|
|
383
542
|
|
|
384
|
-
|
|
543
|
+
const ISOLATE_SLUG_MAX = 40;
|
|
385
544
|
|
|
386
|
-
/**
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
394
|
-
export const ISOLATE_SLUG_MAX = 40;
|
|
395
|
-
|
|
396
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
545
|
+
/**
|
|
546
|
+
* D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
|
|
547
|
+
* top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
|
|
548
|
+
* placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
|
|
549
|
+
* an option only for workflows that accept a separate workspace.
|
|
550
|
+
*/
|
|
397
551
|
export function planIsolateWorktree(opts: {
|
|
398
552
|
description: string;
|
|
399
553
|
taskHex: string;
|
|
400
554
|
existingPierBranches: ReadonlySet<string>;
|
|
401
|
-
})
|
|
555
|
+
}) {
|
|
402
556
|
const folded = String(opts.description ?? '')
|
|
403
557
|
.toLowerCase()
|
|
404
558
|
.replace(/[^a-z0-9]+/g, '-')
|
|
@@ -415,7 +569,7 @@ export function planIsolateWorktree(opts: {
|
|
|
415
569
|
return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
|
|
416
570
|
}
|
|
417
571
|
|
|
418
|
-
/**
|
|
572
|
+
/** Commit discipline is injected into the prompt: the worktree has no other protection. */
|
|
419
573
|
export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
|
|
420
574
|
return [
|
|
421
575
|
`You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
|
|
@@ -426,7 +580,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
|
|
|
426
580
|
].join('\n');
|
|
427
581
|
}
|
|
428
582
|
|
|
429
|
-
/** Why: Preserve the established compatibility and safety behavior (D98). */
|
|
430
583
|
export function formatWorktreeStat(opts: {
|
|
431
584
|
branch: string | null;
|
|
432
585
|
commits: number | null;
|
|
@@ -445,17 +598,16 @@ export function formatWorktreeStat(opts: {
|
|
|
445
598
|
return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
|
|
446
599
|
}
|
|
447
600
|
|
|
448
|
-
/**
|
|
449
|
-
export
|
|
450
|
-
|
|
451
|
-
export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }): ReleaseDecision {
|
|
601
|
+
/** D98: only a merged, clean, known worktree may be removed automatically. */
|
|
602
|
+
export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }):
|
|
603
|
+
{ action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' } {
|
|
452
604
|
if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
|
|
453
605
|
if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
|
|
454
606
|
if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
|
|
455
607
|
return { action: 'release' };
|
|
456
608
|
}
|
|
457
609
|
|
|
458
|
-
/**
|
|
610
|
+
/** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
|
|
459
611
|
export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
460
612
|
const byBranch = new Map<string, string>();
|
|
461
613
|
let curPath: string | null = null;
|
|
@@ -467,3 +619,154 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
|
|
|
467
619
|
}
|
|
468
620
|
return byBranch;
|
|
469
621
|
}
|
|
622
|
+
|
|
623
|
+
/* ── execute planners ───────────────────────────────────────────── */
|
|
624
|
+
|
|
625
|
+
export const FOREGROUND_POLL_MS = 2_000;
|
|
626
|
+
|
|
627
|
+
type LaunchValidation =
|
|
628
|
+
| { kind: 'error'; text: string }
|
|
629
|
+
| {
|
|
630
|
+
kind: 'ok';
|
|
631
|
+
spec: SubagentSpec;
|
|
632
|
+
background: boolean;
|
|
633
|
+
isolate: boolean;
|
|
634
|
+
cwdParam: string | null;
|
|
635
|
+
roleKind: string;
|
|
636
|
+
suggested: string[];
|
|
637
|
+
manifestRole: string | null;
|
|
638
|
+
tab: string | null;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/** Validate spawn parameters. `role` doubles as the pane label and (when it names a profile) the
|
|
642
|
+
* manifest to compose; unknown names stay labels. `isolate` and `cwd` are mutually exclusive. */
|
|
643
|
+
export function planLaunchValidation(
|
|
644
|
+
params: {
|
|
645
|
+
description?: unknown;
|
|
646
|
+
prompt?: unknown;
|
|
647
|
+
run_in_background?: unknown;
|
|
648
|
+
cwd?: unknown;
|
|
649
|
+
isolate?: unknown;
|
|
650
|
+
role?: unknown;
|
|
651
|
+
allowed_tools?: unknown;
|
|
652
|
+
tab?: unknown;
|
|
653
|
+
} | null | undefined,
|
|
654
|
+
herdrAvailable: boolean,
|
|
655
|
+
): LaunchValidation {
|
|
656
|
+
if (!herdrAvailable) {
|
|
657
|
+
return { kind: 'error', text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).' };
|
|
658
|
+
}
|
|
659
|
+
const spec: SubagentSpec = {
|
|
660
|
+
description: String(params?.description ?? 'subagent'),
|
|
661
|
+
prompt: String(params?.prompt ?? ''),
|
|
662
|
+
};
|
|
663
|
+
if (!spec.prompt.trim()) return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
|
|
664
|
+
const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
|
|
665
|
+
const isolate = params?.isolate === true;
|
|
666
|
+
if (isolate && cwdParam) {
|
|
667
|
+
return {
|
|
668
|
+
kind: 'error',
|
|
669
|
+
text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const role = typeof params?.role === 'string' ? params.role.trim() : undefined;
|
|
673
|
+
return {
|
|
674
|
+
kind: 'ok',
|
|
675
|
+
spec,
|
|
676
|
+
background: params?.run_in_background === true,
|
|
677
|
+
isolate,
|
|
678
|
+
cwdParam,
|
|
679
|
+
roleKind: normalizeEntryKind(role),
|
|
680
|
+
suggested: Array.isArray(params?.allowed_tools)
|
|
681
|
+
? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
|
682
|
+
: [],
|
|
683
|
+
manifestRole: role || 'worker-default',
|
|
684
|
+
tab: typeof params?.tab === 'string' ? params.tab : null,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export type ForegroundTickPlan =
|
|
689
|
+
| { kind: 'blocked' }
|
|
690
|
+
| { kind: 'settled'; text: string }
|
|
691
|
+
| { kind: 'wait'; delayMs: number }
|
|
692
|
+
| { kind: 'collect-final' }
|
|
693
|
+
| { kind: 'continue' };
|
|
694
|
+
|
|
695
|
+
/** One foreground-wait iteration: only a human gate or finalized text ends the wait. */
|
|
696
|
+
export function planForegroundTick(input: {
|
|
697
|
+
state: string | null;
|
|
698
|
+
session: { text: string | null; pendingTool: boolean; activity: boolean };
|
|
699
|
+
}): ForegroundTickPlan {
|
|
700
|
+
if (input.state === 'blocked') return { kind: 'blocked' };
|
|
701
|
+
if (input.state !== 'idle' && input.state !== 'done') return { kind: 'continue' };
|
|
702
|
+
if (input.session.text) return { kind: 'settled', text: input.session.text };
|
|
703
|
+
if (input.session.pendingTool || !input.session.activity) return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
|
|
704
|
+
return { kind: 'collect-final' };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/* ── task-id resolution ─────────────────────────────────────────── */
|
|
708
|
+
|
|
709
|
+
export type TaskIdResolutionResult =
|
|
710
|
+
| { kind: 'resolved'; taskId: string }
|
|
711
|
+
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
712
|
+
| { kind: 'too_short'; query: string }
|
|
713
|
+
| { kind: 'not_found'; query: string };
|
|
714
|
+
|
|
715
|
+
/** Resolve a full or short task ID: exact matches at any length, prefix matches need four characters. */
|
|
716
|
+
export function resolveTaskIdPrefix(
|
|
717
|
+
query: string,
|
|
718
|
+
candidates: Iterable<string>,
|
|
719
|
+
): TaskIdResolutionResult {
|
|
720
|
+
const trimmed = query.trim();
|
|
721
|
+
if (!trimmed) return { kind: 'not_found', query: trimmed };
|
|
722
|
+
|
|
723
|
+
const unique = Array.from(new Set(candidates));
|
|
724
|
+
const exact = unique.find((candidate) => candidate === trimmed);
|
|
725
|
+
if (exact) return { kind: 'resolved', taskId: exact };
|
|
726
|
+
|
|
727
|
+
if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
|
|
728
|
+
|
|
729
|
+
const lower = trimmed.toLowerCase();
|
|
730
|
+
const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
|
|
731
|
+
if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
|
|
732
|
+
if (matches.length > 1) {
|
|
733
|
+
matches.sort();
|
|
734
|
+
return { kind: 'ambiguous', query: trimmed, candidates: matches };
|
|
735
|
+
}
|
|
736
|
+
return { kind: 'not_found', query: trimmed };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
|
|
740
|
+
* its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
|
|
741
|
+
* the caller's original string in the message even though matching works on the trimmed form. */
|
|
742
|
+
export function taskIdResolutionError(
|
|
743
|
+
res: TaskIdResolutionResult,
|
|
744
|
+
label: 'task id' | 'subagent id',
|
|
745
|
+
rawQuery: string,
|
|
746
|
+
): string | null {
|
|
747
|
+
if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
|
|
748
|
+
if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* ── outbound port (composition root → plugin) ──────────────────── */
|
|
753
|
+
|
|
754
|
+
/** Bound atomically at session_start: consumers read `current` and must tolerate null. */
|
|
755
|
+
export interface SubagentPort {
|
|
756
|
+
applyReplySession(paneId: string, sessionFile: string | null): void;
|
|
757
|
+
reconcileOnReply(paneId: string): string[];
|
|
758
|
+
listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
|
|
759
|
+
/** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
|
|
760
|
+
listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
|
|
761
|
+
settleStatLine(paneId: string): Promise<string | null>;
|
|
762
|
+
/** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
|
|
763
|
+
consumeReply(paneId: string, outcome: string | null): void;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export interface SubagentPortBox {
|
|
767
|
+
current: SubagentPort | null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function emptySubagentPortBox(): SubagentPortBox {
|
|
771
|
+
return { current: null };
|
|
772
|
+
}
|