pi-pier 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +121 -47
- package/src/core/todo.ts +25 -11
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +4 -2
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +82 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/session-tail.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
11
|
import * as path from 'node:path';
|
|
12
|
-
import {
|
|
12
|
+
import { piSessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
13
13
|
|
|
14
14
|
export { sessionDirName };
|
|
15
15
|
|
|
@@ -90,6 +90,34 @@ export function hasAssistantAfter(
|
|
|
90
90
|
return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Return whether the NEWEST assistant message after `sinceTs` ended its turn.
|
|
95
|
+
*
|
|
96
|
+
* Why (A16): a worker sitting between tool calls (tool result already written, next assistant
|
|
97
|
+
* message still streaming) has "an assistant message" but has NOT finished. Treating any assistant
|
|
98
|
+
* message as activity made the poller announce such workers as finished — three real workers were
|
|
99
|
+
* declared settled while still working on 2026-09-13 and one was later closed mid-task by GC.
|
|
100
|
+
*
|
|
101
|
+
* `stopReason === 'toolUse'` means the model requested tools, so the turn continues; a non-string
|
|
102
|
+
* stopReason (message still streaming / older shapes) is treated as "not ended" on purpose — the
|
|
103
|
+
* cost of waiting is small, the cost of a false settlement is losing supervision of live work.
|
|
104
|
+
*/
|
|
105
|
+
export function lastAssistantTurnEnded(
|
|
106
|
+
entries: readonly SessionEntryLike[],
|
|
107
|
+
sinceTs: number,
|
|
108
|
+
): boolean {
|
|
109
|
+
let newest: { ts: number; stopReason: unknown } | null = null;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const m = messageOf(entry);
|
|
112
|
+
if (!m || m.role !== 'assistant') continue;
|
|
113
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
114
|
+
if (ts < sinceTs) continue;
|
|
115
|
+
if (!newest || ts >= newest.ts) newest = { ts, stopReason: m.stopReason };
|
|
116
|
+
}
|
|
117
|
+
if (!newest) return false;
|
|
118
|
+
const sr = newest.stopReason;
|
|
119
|
+
return typeof sr === 'string' && sr !== 'toolUse' && sr !== 'pending';
|
|
120
|
+
}
|
|
93
121
|
/**
|
|
94
122
|
* Return whether an initiated toolCall still lacks a result after injection (for example,
|
|
95
123
|
* ask_user_question waiting on human input), meaning settlement has not actually completed
|
|
@@ -115,10 +143,37 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
|
|
|
115
143
|
|
|
116
144
|
|
|
117
145
|
|
|
118
|
-
/**
|
|
146
|
+
/** Derived settlement state of one child session, from the inject point onward. */
|
|
147
|
+
export interface SubSessionState {
|
|
148
|
+
text: string | null;
|
|
149
|
+
pendingTool: boolean;
|
|
150
|
+
activity: boolean;
|
|
151
|
+
turnEnded: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Derive the settlement state from already-parsed entries.
|
|
156
|
+
*
|
|
157
|
+
* Closing text is by construction terminal (lastAssistantText requires stopReason 'stop').
|
|
158
|
+
* A16: an assistant message alone is not a settlement; the turn must have ENDED.
|
|
159
|
+
*/
|
|
160
|
+
export function deriveSubSessionState(
|
|
161
|
+
entries: readonly SessionEntryLike[],
|
|
162
|
+
sinceTs: number,
|
|
163
|
+
): SubSessionState {
|
|
164
|
+
const r = lastAssistantText(entries, { sinceTs });
|
|
165
|
+
if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true };
|
|
166
|
+
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false };
|
|
167
|
+
if (hasAssistantAfter(entries, sinceTs)) {
|
|
168
|
+
return { text: null, pendingTool: false, activity: true, turnEnded: lastAssistantTurnEnded(entries, sinceTs) };
|
|
169
|
+
}
|
|
170
|
+
return { text: null, pendingTool: false, activity: false, turnEnded: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Newest `limit` session files under cwd's session dir (pi core's name first, then pier's old encodings). */
|
|
119
174
|
export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
|
|
120
175
|
const files: Array<{ file: string; mtime: number }> = [];
|
|
121
|
-
for (const name of
|
|
176
|
+
for (const name of piSessionDirCandidates(cwd)) {
|
|
122
177
|
const dir = path.join(agentDir, name);
|
|
123
178
|
let names: string[];
|
|
124
179
|
try {
|
|
@@ -142,11 +197,11 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
|
|
|
142
197
|
|
|
143
198
|
/**
|
|
144
199
|
* Locate a session file by id (`<ts>_<id>.jsonl`).
|
|
145
|
-
* Searches
|
|
200
|
+
* Searches pi core's own directory name first, then pier's legacy flattened dirs.
|
|
146
201
|
*/
|
|
147
202
|
export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
|
|
148
203
|
const suffix = `_${id}.jsonl`;
|
|
149
|
-
for (const name of
|
|
204
|
+
for (const name of piSessionDirCandidates(cwd)) {
|
|
150
205
|
const dir = path.join(agentDir, name);
|
|
151
206
|
let names: string[];
|
|
152
207
|
try {
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
|
21
21
|
export interface SettleWakeInput {
|
|
22
22
|
/** stopReason of the last assistant turn before this settlement (unknown = null, treated as natural completion). */
|
|
23
23
|
lastStopReason: string | null;
|
|
24
|
+
/** True when abort was triggered intentionally by internal mechanisms like OCC. */
|
|
25
|
+
intentionalAbort?: boolean;
|
|
24
26
|
/** Background subagents still running (empty means none). */
|
|
25
27
|
running: ReadonlyArray<{ paneId: string }>;
|
|
26
28
|
/** Set key recorded for the previous D96 notice (none = null). */
|
|
@@ -41,7 +43,7 @@ export interface SettleWakePlan {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
44
|
-
if (input.lastStopReason === ABORT_STOP_REASON) {
|
|
46
|
+
if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
45
47
|
return { wake: false, notice: false, noticeKey: input.lastNoticeKey, noticeAt: input.lastNoticeAt };
|
|
46
48
|
}
|
|
47
49
|
const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
|
package/src/slim-frame.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { isArchived } from './stale-core.ts';
|
|
31
31
|
import type { TodoItem } from './vocab.ts';
|
|
32
32
|
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
|
|
33
|
+
import { pierOption } from './pier-options.ts';
|
|
33
34
|
|
|
34
35
|
/** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
|
|
35
36
|
* Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
|
|
@@ -343,7 +344,7 @@ let active: SlimFrameComponent | null = null;
|
|
|
343
344
|
*/
|
|
344
345
|
export function registerSlimFrame(eventCtx: unknown): void {
|
|
345
346
|
if (active) return;
|
|
346
|
-
if (
|
|
347
|
+
if (pierOption('PIER_SLIM_FRAME') === '0') return;
|
|
347
348
|
const ui = eventCtx !== null && typeof eventCtx === 'object' && 'ui' in eventCtx
|
|
348
349
|
? eventCtx.ui
|
|
349
350
|
: null;
|
|
@@ -353,8 +354,9 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
353
354
|
try {
|
|
354
355
|
// overlay: true keeps the floating layer from clearing the screen (pi ctx.ui.custom replaces the editor area by default, so cover it explicitly);
|
|
355
356
|
// nonCapturing leaves the keyboard with the editor (typing in a focused narrow pane remains possible, and the expanded heat view hides the frame).
|
|
357
|
+
if (!custom) throw new Error('pi ui.custom unavailable');
|
|
356
358
|
const pending = custom(
|
|
357
|
-
(tui, theme) => {
|
|
359
|
+
(tui: FrameTui, theme: { fg(color: string, text: string): string }) => {
|
|
358
360
|
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
359
361
|
comp.startResizeWatchdog();
|
|
360
362
|
active = comp;
|
package/src/storage-layout.ts
CHANGED
|
@@ -43,6 +43,29 @@ export function sessionDirCandidates(cwd: string): readonly string[] {
|
|
|
43
43
|
return next === prev ? [next] : [next, prev];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* pi core's own session-directory encoding, byte-for-byte.
|
|
48
|
+
*
|
|
49
|
+
* Why: `<agentDir>/sessions/` is owned by pi core, which names it
|
|
50
|
+
* `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` (strip ONE leading separator,
|
|
51
|
+
* then flatten `/` `\` `:` — note it does NOT escape `%` like `sessionDirName` does).
|
|
52
|
+
* Verified on this machine: `/Users/yehaoyu/Documents/pier` →
|
|
53
|
+
* `--Users-yehaoyu-Documents-pier--` (pier's own encodings produce `---Users-…--` and
|
|
54
|
+
* `--%2FUsers…--`, neither of which exists on disk).
|
|
55
|
+
*/
|
|
56
|
+
export function piCoreSessionDirName(cwd: string): string {
|
|
57
|
+
return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Candidates for reading pi core's session dirs: pi core's exact name first, then pier's
|
|
62
|
+
* own historical encodings (they may hold stray dirs from earlier versions).
|
|
63
|
+
* Read-only on purpose: writes to `<agentDir>/sessions/` are pi core's business.
|
|
64
|
+
*/
|
|
65
|
+
export function piSessionDirCandidates(cwd: string): readonly string[] {
|
|
66
|
+
return [...new Set([piCoreSessionDirName(cwd), ...sessionDirCandidates(cwd)])];
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
/**
|
|
47
70
|
* Directory to read/write under `parent`.
|
|
48
71
|
* Existing legacy dirs win over a missing new dir so history is not split.
|
package/src/subagent-core.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface AliveProbe {
|
|
|
91
91
|
agentStatus: string | null;
|
|
92
92
|
/** Why: Preserve the established compatibility and safety behavior. */
|
|
93
93
|
lastActivityMs: number | null;
|
|
94
|
+
/** Herdr 0.9.1: foreground working directory of the PTY process. */
|
|
95
|
+
foregroundCwd?: string | null;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/** Why: Preserve the established compatibility and safety behavior. */
|
|
@@ -368,7 +370,7 @@ export interface LaunchRuntime {
|
|
|
368
370
|
export function buildLaunchParts(
|
|
369
371
|
runtime: LaunchRuntime,
|
|
370
372
|
opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
|
|
371
|
-
env:
|
|
373
|
+
env: { PI_HERDR_TUI?: string | undefined } = process.env,
|
|
372
374
|
): string[] {
|
|
373
375
|
const parts = [runtime.nodePath, runtime.cliPath];
|
|
374
376
|
if (opts.approve) parts.push('-a');
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent `spawn` action (isolate worktree, pane launch, prompt injection, foreground wait).
|
|
3
|
+
*
|
|
4
|
+
* Why: the plugin entry keeps tool registration and lifecycle wiring; the spawn path is the one
|
|
5
|
+
* action with git, placement, injection, and settle branches, so it lives here as a unit.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve as pathResolve } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import type { HerdrClientLike } from './herdr-client.ts';
|
|
12
|
+
import { composeForRole } from './manifest-compose.ts';
|
|
13
|
+
import { pipeNameFor, pipeRequestTo } from './pipe-channel.ts';
|
|
14
|
+
import { platformPaths } from './platform-paths.ts';
|
|
15
|
+
import { runtimePolicy } from './runtime-policy.ts';
|
|
16
|
+
import {
|
|
17
|
+
FOREGROUND_POLL_MS,
|
|
18
|
+
planForegroundTick,
|
|
19
|
+
planIsolateRepoGuard,
|
|
20
|
+
planLaunchValidation,
|
|
21
|
+
planPatienceExpiry,
|
|
22
|
+
} from './subagent-launch.ts';
|
|
23
|
+
import {
|
|
24
|
+
Semaphore,
|
|
25
|
+
buildAliveNotice,
|
|
26
|
+
buildBlockedGateNotice,
|
|
27
|
+
buildIsolatePreamble,
|
|
28
|
+
classifyWorktreeZone,
|
|
29
|
+
formatSubagentResult,
|
|
30
|
+
isAlive,
|
|
31
|
+
isPathUnder,
|
|
32
|
+
makeProgressUpdate,
|
|
33
|
+
planIsolateWorktree,
|
|
34
|
+
type SubEntry,
|
|
35
|
+
} from './subagent-core.ts';
|
|
36
|
+
import type { HistoryEntry } from './history-store.ts';
|
|
37
|
+
import type { GitIo } from './subagent-git-io.ts';
|
|
38
|
+
import { sleep, type Poller } from './subagent-poll-loop.ts';
|
|
39
|
+
import type { SessionIo } from './subagent-session-io.ts';
|
|
40
|
+
import type { SpawnEnv, Spawner } from './subagent-spawn.ts';
|
|
41
|
+
import { toolError } from './tool-error.ts';
|
|
42
|
+
|
|
43
|
+
const statAsync = promisify(statCb);
|
|
44
|
+
|
|
45
|
+
export interface SpawnActionHost {
|
|
46
|
+
client: HerdrClientLike;
|
|
47
|
+
env: SpawnEnv | null;
|
|
48
|
+
subSemaphore: Semaphore;
|
|
49
|
+
subs: Map<string, SubEntry>;
|
|
50
|
+
persistSubs(): void;
|
|
51
|
+
writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
|
|
52
|
+
pendingIsolateBranches: Set<string>;
|
|
53
|
+
lastMachineInjectAt: Map<string, number>;
|
|
54
|
+
lastRequestIdByPane: Map<string, string>;
|
|
55
|
+
git: GitIo;
|
|
56
|
+
session: SessionIo;
|
|
57
|
+
spawn: Spawner;
|
|
58
|
+
poller: Poller;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SpawnAction = (
|
|
62
|
+
params: Record<string, unknown> | undefined,
|
|
63
|
+
toolCtx: unknown,
|
|
64
|
+
onUpdate?: (update: unknown) => void,
|
|
65
|
+
) => Promise<unknown>;
|
|
66
|
+
|
|
67
|
+
export function createSpawnAction(h: SpawnActionHost): SpawnAction {
|
|
68
|
+
const { client, env, git, subs } = h;
|
|
69
|
+
const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
|
|
70
|
+
const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
|
|
71
|
+
const { startPoller } = h.poller;
|
|
72
|
+
|
|
73
|
+
return async function executeSubagentSpawn(params, toolCtx, onUpdate) {
|
|
74
|
+
const launch = planLaunchValidation(params, client.available);
|
|
75
|
+
if (launch.kind === 'error') {
|
|
76
|
+
return toolError(launch.text);
|
|
77
|
+
}
|
|
78
|
+
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
79
|
+
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
80
|
+
// Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
|
|
81
|
+
const taskId = randomUUID();
|
|
82
|
+
// D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
|
|
83
|
+
// socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
|
|
84
|
+
// workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
|
|
85
|
+
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
86
|
+
let cwd = masterCwd;
|
|
87
|
+
if (isolate) {
|
|
88
|
+
const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
89
|
+
const isoGuard = planIsolateRepoGuard(baseSha);
|
|
90
|
+
if (isoGuard.kind === 'error') {
|
|
91
|
+
return toolError(isoGuard.text);
|
|
92
|
+
}
|
|
93
|
+
const sha = isoGuard.sha.trim();
|
|
94
|
+
const pierBranches = new Set(
|
|
95
|
+
((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
96
|
+
.split('\n').filter(Boolean),
|
|
97
|
+
);
|
|
98
|
+
const worktrees = await git.listWorktrees(masterCwd);
|
|
99
|
+
const repoName = basename(worktrees[0] ?? masterCwd);
|
|
100
|
+
const plan = planIsolateWorktree({
|
|
101
|
+
description: spec.description,
|
|
102
|
+
taskHex: taskId.slice(0, 6),
|
|
103
|
+
existingPierBranches: pierBranches,
|
|
104
|
+
});
|
|
105
|
+
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
|
|
108
|
+
} catch { /* Existing directory or permissions failure is handled by worktree add. */ }
|
|
109
|
+
// Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
|
|
110
|
+
// scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
111
|
+
h.pendingIsolateBranches.add(plan.branch);
|
|
112
|
+
const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
113
|
+
if (added === null) {
|
|
114
|
+
h.pendingIsolateBranches.delete(plan.branch);
|
|
115
|
+
return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
|
|
116
|
+
}
|
|
117
|
+
git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
|
|
118
|
+
cwd = wtPath;
|
|
119
|
+
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
120
|
+
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
121
|
+
} else if (cwdParam) {
|
|
122
|
+
cwd = pathResolve(masterCwd, cwdParam);
|
|
123
|
+
try {
|
|
124
|
+
const st = await statAsync(cwd);
|
|
125
|
+
if (!st.isDirectory()) throw new Error('not a directory');
|
|
126
|
+
} catch {
|
|
127
|
+
return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
|
|
131
|
+
const zone = classifyWorktreeZone({
|
|
132
|
+
cwd,
|
|
133
|
+
masterCwd,
|
|
134
|
+
worktrees: await git.listWorktrees(masterCwd),
|
|
135
|
+
});
|
|
136
|
+
// D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
|
|
137
|
+
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
138
|
+
let roleManifestEnv: Record<string, string> = {};
|
|
139
|
+
let roleModel: string | null = null;
|
|
140
|
+
try {
|
|
141
|
+
// planLaunchValidation always names a role ('worker-default' by default); keep the same fallback
|
|
142
|
+
// here for callers that build a launch spec directly.
|
|
143
|
+
const roleName = manifestRole ?? 'worker-default';
|
|
144
|
+
const { role, manifest } = composeForRole(roleName, suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
145
|
+
roleManifestEnv = {
|
|
146
|
+
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
147
|
+
role: role.role,
|
|
148
|
+
version: role.version,
|
|
149
|
+
tools: manifest.tools,
|
|
150
|
+
permissions: manifest.permissions,
|
|
151
|
+
unknownTools: manifest.unknownTools,
|
|
152
|
+
services: role.services ?? {},
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
// WS-D10: Route the model by role; omission intentionally uses the process default.
|
|
156
|
+
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
|
|
160
|
+
}
|
|
161
|
+
const release = await h.subSemaphore.acquire();
|
|
162
|
+
let paneId = '';
|
|
163
|
+
try {
|
|
164
|
+
const spawnedAt = Date.now();
|
|
165
|
+
const spawned = await spawnPaneInTaskTab(
|
|
166
|
+
{ desiredTab: tab, description: spec.description, zone },
|
|
167
|
+
cwd,
|
|
168
|
+
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
169
|
+
launchLine(null, roleModel, approve),
|
|
170
|
+
);
|
|
171
|
+
paneId = spawned.paneId;
|
|
172
|
+
const entry: SubEntry = {
|
|
173
|
+
taskId,
|
|
174
|
+
kind,
|
|
175
|
+
paneId,
|
|
176
|
+
tabId: spawned.tabId,
|
|
177
|
+
tabName: spawned.tabName,
|
|
178
|
+
cwd,
|
|
179
|
+
description: spec.description,
|
|
180
|
+
background,
|
|
181
|
+
status: 'running',
|
|
182
|
+
sessionFile: null,
|
|
183
|
+
launchCommand: [launchLine(null, roleModel, approve)],
|
|
184
|
+
createdAt: Date.now(),
|
|
185
|
+
revivedFrom: null,
|
|
186
|
+
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
187
|
+
};
|
|
188
|
+
const ready = await waitSubReady(cwd, paneId);
|
|
189
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
190
|
+
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
191
|
+
subs.set(paneId, entry);
|
|
192
|
+
h.persistSubs();
|
|
193
|
+
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
194
|
+
// M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
|
|
195
|
+
// reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
|
|
196
|
+
// Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
|
|
197
|
+
// leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
|
|
198
|
+
// running ledger entry). Keep injection and injectTs together in future refactors.
|
|
199
|
+
const injectTs = Date.now();
|
|
200
|
+
const injected = await pipeRequestTo(cwd, paneId, {
|
|
201
|
+
type: 'prompt',
|
|
202
|
+
id: `prompt-${taskId}`,
|
|
203
|
+
text: spec.prompt,
|
|
204
|
+
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
205
|
+
push: background,
|
|
206
|
+
});
|
|
207
|
+
if (injected.type !== 'ok') {
|
|
208
|
+
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
209
|
+
}
|
|
210
|
+
h.lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
|
|
211
|
+
|
|
212
|
+
// A7: run_in_background returns immediately after successful injection instead of blocking in foreground wait.
|
|
213
|
+
if (background) {
|
|
214
|
+
entry.background = true;
|
|
215
|
+
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
216
|
+
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
217
|
+
h.persistSubs();
|
|
218
|
+
h.writeHistory(entry, undefined, 'to-background');
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
|
|
221
|
+
details: { paneId, taskId, background: true, role: kind },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
|
|
226
|
+
// Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
|
|
227
|
+
// three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
|
|
228
|
+
const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
|
|
229
|
+
let text: string | null = null;
|
|
230
|
+
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
231
|
+
while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
|
|
232
|
+
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
233
|
+
const session = (state === 'idle' || state === 'done')
|
|
234
|
+
? await subSessionState(paneId, cwd, injectTs)
|
|
235
|
+
: { text: null, pendingTool: false, activity: false };
|
|
236
|
+
const tick = planForegroundTick({ state, session });
|
|
237
|
+
if (tick.kind === 'blocked') {
|
|
238
|
+
const question = await readAskFlag(paneId);
|
|
239
|
+
entry.background = true;
|
|
240
|
+
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
241
|
+
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
242
|
+
h.persistSubs();
|
|
243
|
+
h.writeHistory(entry, undefined, 'to-background');
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
246
|
+
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (tick.kind === 'settled') {
|
|
250
|
+
text = tick.text;
|
|
251
|
+
settledKind = 'settled';
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (tick.kind === 'wait') {
|
|
255
|
+
await sleep(tick.delayMs);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (tick.kind === 'collect-final') {
|
|
259
|
+
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
260
|
+
if (text) { settledKind = 'settled'; break; }
|
|
261
|
+
await sleep(FOREGROUND_POLL_MS);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!text) {
|
|
266
|
+
const probe = await probeAlive(paneId, cwd);
|
|
267
|
+
if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
|
|
268
|
+
entry.background = true;
|
|
269
|
+
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
270
|
+
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
271
|
+
h.persistSubs();
|
|
272
|
+
const notice = buildAliveNotice(
|
|
273
|
+
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
274
|
+
Date.now(),
|
|
275
|
+
);
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: 'text', text: notice }],
|
|
278
|
+
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
settledKind = 'timeout';
|
|
282
|
+
}
|
|
283
|
+
const outcome =
|
|
284
|
+
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
285
|
+
: text ? { kind: 'completed' as const, text }
|
|
286
|
+
: { kind: 'no-output' as const, text: '' };
|
|
287
|
+
entry.status = 'consumed';
|
|
288
|
+
entry.consumedAt = Date.now();
|
|
289
|
+
h.persistSubs();
|
|
290
|
+
h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
291
|
+
return {
|
|
292
|
+
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
293
|
+
details: { paneId, taskId, background, role: kind },
|
|
294
|
+
};
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
|
|
297
|
+
// otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
|
|
298
|
+
// to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
|
|
299
|
+
if (paneId) {
|
|
300
|
+
subs.delete(paneId);
|
|
301
|
+
h.persistSubs();
|
|
302
|
+
void client.closePane(paneId).catch(() => { /* Best effort only. */ });
|
|
303
|
+
}
|
|
304
|
+
// D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
|
|
305
|
+
// exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
|
|
306
|
+
if (isolateMeta) {
|
|
307
|
+
const branch = isolateMeta.branch;
|
|
308
|
+
const worktreePath = isolateMeta.worktreePath;
|
|
309
|
+
void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
|
|
310
|
+
.then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
|
|
311
|
+
.catch(() => { /* Best effort only. */ });
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
content: [{
|
|
315
|
+
type: 'text',
|
|
316
|
+
text: formatSubagentResult(
|
|
317
|
+
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
318
|
+
spec.description,
|
|
319
|
+
),
|
|
320
|
+
}],
|
|
321
|
+
details: {},
|
|
322
|
+
};
|
|
323
|
+
} finally {
|
|
324
|
+
release();
|
|
325
|
+
if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
package/src/subagent-gc.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type { HerdrClientLike } from './herdr-client.ts';
|
|
|
10
10
|
import { shouldClosePane, shouldCloseTaskTab } from './gc-core.ts';
|
|
11
11
|
import { runtimePolicy } from './runtime-policy.ts';
|
|
12
12
|
import { evaluateRelease, parseWorktreePorcelain, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import { planIsolateSweep, isPathInside } from './gc-core.ts';
|
|
14
|
+
|
|
15
|
+
interface Cand { branch: string; wtPath: string; entry: SubEntry | null }
|
|
13
16
|
import type { GitIo } from './subagent-git-io.ts';
|
|
14
17
|
import type { TerminalStateSlot } from './core/terminal.ts';
|
|
15
18
|
|
|
@@ -140,21 +143,35 @@ export function createGcController(h: GcHost): GcController {
|
|
|
140
143
|
const wtPorcelain = await h.git.runGit(masterCwd, ['worktree', 'list', '--porcelain']);
|
|
141
144
|
if (wtPorcelain === null) return;
|
|
142
145
|
const wtByBranch = parseWorktreePorcelain(wtPorcelain);
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
// Session-owned isolates: settled or consumed entries whose worktree was not released yet.
|
|
147
|
+
const sessionOwned = [...h.subs.values()]
|
|
148
|
+
.filter((e) => e.isolate && e.isolate.releasedAt == null && e.status !== 'running')
|
|
149
|
+
.map((e) => ({ branch: e.isolate!.branch, worktreePath: e.isolate!.worktreePath }));
|
|
150
|
+
// Untracked `pier/*` branches are only swept on explicit request: they may belong to
|
|
151
|
+
// another session (whose process may even be running inside them).
|
|
152
|
+
const sweepOrphans = process.env.PIER_ISOLATE_SWEEP_ORPHANS === '1';
|
|
153
|
+
const branches = sweepOrphans
|
|
154
|
+
? (await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']))
|
|
155
|
+
?.split('\n').map((l) => l.trim()).filter(Boolean) ?? []
|
|
156
|
+
: [];
|
|
157
|
+
const plan = planIsolateSweep({
|
|
158
|
+
branches,
|
|
159
|
+
worktreesByBranch: wtByBranch,
|
|
160
|
+
registeredBranches,
|
|
161
|
+
pendingBranches: h.pendingIsolateBranches,
|
|
162
|
+
sessionOwned,
|
|
163
|
+
cwd: masterCwd,
|
|
164
|
+
sweepOrphans,
|
|
165
|
+
});
|
|
166
|
+
const entryByBranch = new Map<string, SubEntry>();
|
|
152
167
|
for (const e of h.subs.values()) {
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
if (e.isolate && e.isolate.releasedAt == null && e.status !== 'running') entryByBranch.set(e.isolate.branch, e);
|
|
169
|
+
}
|
|
170
|
+
const byBranch = new Map<string, Cand>();
|
|
171
|
+
for (const cand of plan.candidates) {
|
|
172
|
+
byBranch.set(cand.branch, { branch: cand.branch, wtPath: cand.worktreePath, entry: entryByBranch.get(cand.branch) ?? null });
|
|
156
173
|
}
|
|
157
|
-
trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
|
|
174
|
+
trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} skipped=${plan.skipped.map((s) => `${s.branch}:${s.reason}`).join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
|
|
158
175
|
let persisted = false;
|
|
159
176
|
for (const cand of byBranch.values()) {
|
|
160
177
|
const { branch, wtPath, entry } = cand;
|
|
@@ -179,6 +196,9 @@ export function createGcController(h: GcHost): GcController {
|
|
|
179
196
|
const dirtyCount = dirtyOut === null ? null : dirtyOut.split('\n').filter((l) => l.trim() !== '').length;
|
|
180
197
|
const decision = evaluateRelease({ merged: mergedFinal, dirtyCount });
|
|
181
198
|
if (decision.action === 'release') {
|
|
199
|
+
// Defense in depth: planner already refuses the current worktree, but a removal here
|
|
200
|
+
// must never be able to delete the directory this process is running in.
|
|
201
|
+
if (isPathInside(masterCwd, wtPath)) continue;
|
|
182
202
|
const removed = await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath]);
|
|
183
203
|
let ok = removed !== null;
|
|
184
204
|
if (!ok) {
|
package/src/subagent-launch.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type LaunchParams = {
|
|
|
19
19
|
tab?: unknown;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
import type { SubagentSpec } from './subagent-core.ts';
|
|
23
|
+
|
|
22
24
|
export type LaunchValidation =
|
|
23
25
|
| { kind: 'error'; text: string }
|
|
24
26
|
| {
|
|
@@ -30,6 +32,8 @@ export type LaunchValidation =
|
|
|
30
32
|
roleKind: string;
|
|
31
33
|
suggested: string[];
|
|
32
34
|
tab: string | null;
|
|
35
|
+
/** Role manifest supplied by the caller (drives tool gating); null when unset. */
|
|
36
|
+
manifestRole: string | null;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
export function planLaunchValidation(
|