pi-pier 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
export interface PiCompactionSettings {
|
|
6
|
-
enabled?: boolean;
|
|
7
|
-
keepRecentTokens?: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function readCompactionSettings(filePath: string): PiCompactionSettings {
|
|
11
|
-
if (!existsSync(filePath)) return {};
|
|
12
|
-
try {
|
|
13
|
-
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
14
|
-
if (!parsed || typeof parsed !== 'object' || !('compaction' in parsed)) return {};
|
|
15
|
-
const compaction = parsed.compaction;
|
|
16
|
-
if (!compaction || typeof compaction !== 'object') return {};
|
|
17
|
-
const result: PiCompactionSettings = {};
|
|
18
|
-
if ('enabled' in compaction && typeof compaction.enabled === 'boolean') result.enabled = compaction.enabled;
|
|
19
|
-
if ('keepRecentTokens' in compaction && typeof compaction.keepRecentTokens === 'number') {
|
|
20
|
-
result.keepRecentTokens = compaction.keepRecentTokens;
|
|
21
|
-
}
|
|
22
|
-
return result;
|
|
23
|
-
} catch {
|
|
24
|
-
return {};
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function defaultAgentDir(): string {
|
|
29
|
-
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Read pi's own compaction settings (global, plus the project file when trusted). */
|
|
33
|
-
export function loadPiNativeCompactionSettings(opts: {
|
|
34
|
-
cwd?: string;
|
|
35
|
-
isProjectTrusted?: boolean;
|
|
36
|
-
agentDir?: string;
|
|
37
|
-
} = {}): PiCompactionSettings {
|
|
38
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
39
|
-
const globalSettings = readCompactionSettings(join(opts.agentDir ?? defaultAgentDir(), 'settings.json'));
|
|
40
|
-
if (!opts.isProjectTrusted) return globalSettings;
|
|
41
|
-
const projectSettings = readCompactionSettings(join(cwd, '.pi', 'settings.json'));
|
|
42
|
-
return { ...globalSettings, ...projectSettings };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function readJsonConfig(filePath: string, onError?: (error: unknown) => void): unknown | undefined {
|
|
46
|
-
if (!existsSync(filePath)) return undefined;
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
|
|
49
|
-
} catch (error) {
|
|
50
|
-
onError?.(error);
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
}
|
package/src/git-adapter.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { execFile as nodeExecFile } from 'node:child_process'
|
|
2
|
-
import { promisify } from 'node:util'
|
|
3
|
-
import { runtimePolicy } from './runtime-policy.ts'
|
|
4
|
-
|
|
5
|
-
const execFile = promisify(nodeExecFile)
|
|
6
|
-
|
|
7
|
-
export interface GitResult {
|
|
8
|
-
readonly stdout: string
|
|
9
|
-
readonly stderr: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface GitError extends Error {
|
|
13
|
-
readonly code?: string | number
|
|
14
|
-
readonly stdout?: string
|
|
15
|
-
readonly stderr?: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Injectable execFile used by NodeGitAdapter.
|
|
20
|
-
* Why: tests drive git without a real process.
|
|
21
|
-
*/
|
|
22
|
-
export type GitExecFile = (
|
|
23
|
-
file: string,
|
|
24
|
-
args: readonly string[],
|
|
25
|
-
options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
|
|
26
|
-
) => Promise<{ stdout: string; stderr: string }>;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Output ceiling for one git call. `execFile` defaults to 1MB, which a large checkout exceeds
|
|
30
|
-
* (`status --porcelain` on a repo with tens of thousands of untracked files, or `diff --stat`
|
|
31
|
-
* across a wide change): the call then fails with ENOBUFS, callers normalize it to null, and the
|
|
32
|
-
* user sees a silently missing stat line — or "failed to create worktree" for a worktree that was
|
|
33
|
-
* in fact created. 64MB bounds memory without hitting realistic repositories.
|
|
34
|
-
*/
|
|
35
|
-
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Minimal Git adapter for worktree and status operations.
|
|
39
|
-
*
|
|
40
|
-
* Why: Centralizes platform-specific git executable resolution, timeout policy,
|
|
41
|
-
* and error normalization; makes tests independent of real git processes.
|
|
42
|
-
*/
|
|
43
|
-
export interface GitAdapter {
|
|
44
|
-
/**
|
|
45
|
-
* List worktrees in porcelain format.
|
|
46
|
-
* Returns stdout containing worktree entries.
|
|
47
|
-
*/
|
|
48
|
-
listWorktrees(cwd: string): Promise<GitResult>
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Get status of worktree in short format.
|
|
52
|
-
* Returns stdout containing status entries.
|
|
53
|
-
*/
|
|
54
|
-
status(cwd: string): Promise<GitResult>
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Run an arbitrary git command in `cwd` (`git -C <cwd> ...args`).
|
|
58
|
-
* Throws a GitError on failure/timeout — callers that want "null on error"
|
|
59
|
-
* (isolate sweep, placement) catch at the call site.
|
|
60
|
-
*/
|
|
61
|
-
run(cwd: string, args: readonly string[]): Promise<GitResult>
|
|
62
|
-
}
|
|
63
|
-
export class NodeGitAdapter implements GitAdapter {
|
|
64
|
-
private readonly gitExecutable: string
|
|
65
|
-
private readonly timeoutMs: number
|
|
66
|
-
private readonly exec: GitExecFile
|
|
67
|
-
|
|
68
|
-
constructor(
|
|
69
|
-
gitExecutable: string = 'git',
|
|
70
|
-
timeoutMs: number = runtimePolicy.gitTimeoutMs,
|
|
71
|
-
exec: GitExecFile = execFile as unknown as GitExecFile,
|
|
72
|
-
) {
|
|
73
|
-
this.gitExecutable = gitExecutable
|
|
74
|
-
this.timeoutMs = timeoutMs
|
|
75
|
-
this.exec = exec
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
listWorktrees(cwd: string): Promise<GitResult> {
|
|
79
|
-
return this.run(cwd, ['worktree', 'list', '--porcelain'])
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
status(cwd: string): Promise<GitResult> {
|
|
83
|
-
return this.run(cwd, ['status', '--short'])
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async run(cwd: string, args: readonly string[]): Promise<GitResult> {
|
|
87
|
-
try {
|
|
88
|
-
const { stdout, stderr } = await this.exec(
|
|
89
|
-
this.gitExecutable,
|
|
90
|
-
['-C', cwd, ...args],
|
|
91
|
-
{ timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
|
|
92
|
-
)
|
|
93
|
-
return { stdout, stderr }
|
|
94
|
-
} catch (err) {
|
|
95
|
-
throw this.normalizeError(err, args[0] ?? 'command')
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private normalizeError(err: unknown, operation: string): GitError {
|
|
100
|
-
if (err instanceof Error) {
|
|
101
|
-
const gitError = err as GitError
|
|
102
|
-
gitError.message = `Git ${operation} failed: ${gitError.message}`
|
|
103
|
-
return gitError
|
|
104
|
-
}
|
|
105
|
-
return new Error(`Git ${operation} failed: ${String(err)}`) as GitError
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Default production git adapter.
|
|
111
|
-
* Tests should inject fake implementations or a fake exec.
|
|
112
|
-
*/
|
|
113
|
-
export const defaultGitAdapter = new NodeGitAdapter()
|
package/src/notice-buffer.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* D92 settlement-notice collapser (pure half of the followup-flood fix).
|
|
3
|
-
*
|
|
4
|
-
* Why: `sendUserMessage(followUp)` only delivers when the agent has no more
|
|
5
|
-
* tool calls, so a long master run queues every settlement and floods at the
|
|
6
|
-
* end. Buffer while busy; collapse at turn_end. The other half is the index
|
|
7
|
-
* notice buffer.
|
|
8
|
-
*/
|
|
9
|
-
export const NOTICE_MAX_SHOWN = 3;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Collapse a batch into one message body.
|
|
13
|
-
* - empty → null (do not inject)
|
|
14
|
-
* - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
|
|
15
|
-
* - >max → first max originals + a tail pointing at history / subagent list
|
|
16
|
-
*/
|
|
17
|
-
export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
|
|
18
|
-
if (contents.length === 0) return null;
|
|
19
|
-
if (contents.length <= max) return contents.join('\n\n');
|
|
20
|
-
const hidden = contents.length - max;
|
|
21
|
-
const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
|
|
22
|
-
return [...contents.slice(0, max), tail].join('\n\n');
|
|
23
|
-
}
|
package/src/subagent-execute.ts
DELETED
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subagent `spawn` action (isolate worktree, pane launch, prompt injection, foreground wait).
|
|
3
|
-
*
|
|
4
|
-
* Why: the plugin entry keeps tool registration and lifecycle wiring; the spawn path is the one
|
|
5
|
-
* action with git, placement, injection, and settle branches, so it lives here as a unit.
|
|
6
|
-
*/
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
9
|
-
import { basename, dirname, join, resolve as pathResolve } from 'node:path';
|
|
10
|
-
import { promisify } from 'node:util';
|
|
11
|
-
import type { HerdrClientLike } from './herdr-client.ts';
|
|
12
|
-
import { composeForRole } from './manifest-compose.ts';
|
|
13
|
-
import { pipeNameFor, pipeRequestTo } from './pipe-channel.ts';
|
|
14
|
-
import { platformPaths } from './platform-paths.ts';
|
|
15
|
-
import { runtimePolicy } from './runtime-policy.ts';
|
|
16
|
-
import {
|
|
17
|
-
FOREGROUND_POLL_MS,
|
|
18
|
-
planForegroundTick,
|
|
19
|
-
planIsolateRepoGuard,
|
|
20
|
-
planLaunchValidation,
|
|
21
|
-
planPatienceExpiry,
|
|
22
|
-
} from './subagent-launch.ts';
|
|
23
|
-
import {
|
|
24
|
-
Semaphore,
|
|
25
|
-
buildAliveNotice,
|
|
26
|
-
buildBlockedGateNotice,
|
|
27
|
-
buildIsolatePreamble,
|
|
28
|
-
classifyWorktreeZone,
|
|
29
|
-
formatSubagentResult,
|
|
30
|
-
isAlive,
|
|
31
|
-
isPathUnder,
|
|
32
|
-
makeProgressUpdate,
|
|
33
|
-
planIsolateWorktree,
|
|
34
|
-
type SubEntry,
|
|
35
|
-
} from './subagent-core.ts';
|
|
36
|
-
import type { HistoryEntry } from './history-store.ts';
|
|
37
|
-
import type { GitIo } from './subagent-git-io.ts';
|
|
38
|
-
import { sleep, type Poller } from './subagent-poll-loop.ts';
|
|
39
|
-
import type { SessionIo } from './subagent-session-io.ts';
|
|
40
|
-
import type { SpawnEnv, Spawner } from './subagent-spawn.ts';
|
|
41
|
-
import { toolError } from './tool-error.ts';
|
|
42
|
-
|
|
43
|
-
const statAsync = promisify(statCb);
|
|
44
|
-
|
|
45
|
-
export interface SpawnActionHost {
|
|
46
|
-
client: HerdrClientLike;
|
|
47
|
-
env: SpawnEnv | null;
|
|
48
|
-
subSemaphore: Semaphore;
|
|
49
|
-
subs: Map<string, SubEntry>;
|
|
50
|
-
persistSubs(): void;
|
|
51
|
-
writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
|
|
52
|
-
pendingIsolateBranches: Set<string>;
|
|
53
|
-
lastMachineInjectAt: Map<string, number>;
|
|
54
|
-
lastRequestIdByPane: Map<string, string>;
|
|
55
|
-
git: GitIo;
|
|
56
|
-
session: SessionIo;
|
|
57
|
-
spawn: Spawner;
|
|
58
|
-
poller: Poller;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export type SpawnAction = (
|
|
62
|
-
params: Record<string, unknown> | undefined,
|
|
63
|
-
toolCtx: unknown,
|
|
64
|
-
onUpdate?: (update: unknown) => void,
|
|
65
|
-
) => Promise<unknown>;
|
|
66
|
-
|
|
67
|
-
export function createSpawnAction(h: SpawnActionHost): SpawnAction {
|
|
68
|
-
const { client, env, git, subs } = h;
|
|
69
|
-
const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
|
|
70
|
-
const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
|
|
71
|
-
const { startPoller } = h.poller;
|
|
72
|
-
|
|
73
|
-
return async function executeSubagentSpawn(params, toolCtx, onUpdate) {
|
|
74
|
-
const launch = planLaunchValidation(params, client.available);
|
|
75
|
-
if (launch.kind === 'error') {
|
|
76
|
-
return toolError(launch.text);
|
|
77
|
-
}
|
|
78
|
-
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
79
|
-
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
80
|
-
// Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
|
|
81
|
-
const taskId = randomUUID();
|
|
82
|
-
// D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
|
|
83
|
-
// socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
|
|
84
|
-
// workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
|
|
85
|
-
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
86
|
-
let cwd = masterCwd;
|
|
87
|
-
if (isolate) {
|
|
88
|
-
const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
89
|
-
const isoGuard = planIsolateRepoGuard(baseSha);
|
|
90
|
-
if (isoGuard.kind === 'error') {
|
|
91
|
-
return toolError(isoGuard.text);
|
|
92
|
-
}
|
|
93
|
-
const sha = isoGuard.sha.trim();
|
|
94
|
-
const pierBranches = new Set(
|
|
95
|
-
((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
96
|
-
.split('\n').filter(Boolean),
|
|
97
|
-
);
|
|
98
|
-
const worktrees = await git.listWorktrees(masterCwd);
|
|
99
|
-
const repoName = basename(worktrees[0] ?? masterCwd);
|
|
100
|
-
const plan = planIsolateWorktree({
|
|
101
|
-
description: spec.description,
|
|
102
|
-
taskHex: taskId.slice(0, 6),
|
|
103
|
-
existingPierBranches: pierBranches,
|
|
104
|
-
});
|
|
105
|
-
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
106
|
-
try {
|
|
107
|
-
mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
|
|
108
|
-
} catch { /* Existing directory or permissions failure is handled by worktree add. */ }
|
|
109
|
-
// Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
|
|
110
|
-
// scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
111
|
-
h.pendingIsolateBranches.add(plan.branch);
|
|
112
|
-
const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
113
|
-
if (added === null) {
|
|
114
|
-
h.pendingIsolateBranches.delete(plan.branch);
|
|
115
|
-
return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
|
|
116
|
-
}
|
|
117
|
-
git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
|
|
118
|
-
cwd = wtPath;
|
|
119
|
-
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
120
|
-
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
121
|
-
} else if (cwdParam) {
|
|
122
|
-
cwd = pathResolve(masterCwd, cwdParam);
|
|
123
|
-
try {
|
|
124
|
-
const st = await statAsync(cwd);
|
|
125
|
-
if (!st.isDirectory()) throw new Error('not a directory');
|
|
126
|
-
} catch {
|
|
127
|
-
return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
|
|
131
|
-
const zone = classifyWorktreeZone({
|
|
132
|
-
cwd,
|
|
133
|
-
masterCwd,
|
|
134
|
-
worktrees: await git.listWorktrees(masterCwd),
|
|
135
|
-
});
|
|
136
|
-
// D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
|
|
137
|
-
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
138
|
-
let roleManifestEnv: Record<string, string> = {};
|
|
139
|
-
let roleModel: string | null = null;
|
|
140
|
-
try {
|
|
141
|
-
// planLaunchValidation always names a role ('worker-default' by default); keep the same fallback
|
|
142
|
-
// here for callers that build a launch spec directly.
|
|
143
|
-
const roleName = manifestRole ?? 'worker-default';
|
|
144
|
-
const { role, manifest } = composeForRole(roleName, suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
145
|
-
roleManifestEnv = {
|
|
146
|
-
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
147
|
-
role: role.role,
|
|
148
|
-
version: role.version,
|
|
149
|
-
tools: manifest.tools,
|
|
150
|
-
permissions: manifest.permissions,
|
|
151
|
-
unknownTools: manifest.unknownTools,
|
|
152
|
-
services: role.services ?? {},
|
|
153
|
-
}),
|
|
154
|
-
};
|
|
155
|
-
// WS-D10: Route the model by role; omission intentionally uses the process default.
|
|
156
|
-
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
157
|
-
} catch (err) {
|
|
158
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
-
return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
|
|
160
|
-
}
|
|
161
|
-
const release = await h.subSemaphore.acquire();
|
|
162
|
-
let paneId = '';
|
|
163
|
-
try {
|
|
164
|
-
const spawnedAt = Date.now();
|
|
165
|
-
const spawned = await spawnPaneInTaskTab(
|
|
166
|
-
{ desiredTab: tab, description: spec.description, zone },
|
|
167
|
-
cwd,
|
|
168
|
-
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
169
|
-
launchLine(null, roleModel, approve),
|
|
170
|
-
);
|
|
171
|
-
paneId = spawned.paneId;
|
|
172
|
-
const entry: SubEntry = {
|
|
173
|
-
taskId,
|
|
174
|
-
kind,
|
|
175
|
-
paneId,
|
|
176
|
-
tabId: spawned.tabId,
|
|
177
|
-
tabName: spawned.tabName,
|
|
178
|
-
cwd,
|
|
179
|
-
description: spec.description,
|
|
180
|
-
background,
|
|
181
|
-
status: 'running',
|
|
182
|
-
sessionFile: null,
|
|
183
|
-
launchCommand: [launchLine(null, roleModel, approve)],
|
|
184
|
-
createdAt: Date.now(),
|
|
185
|
-
revivedFrom: null,
|
|
186
|
-
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
187
|
-
};
|
|
188
|
-
const ready = await waitSubReady(cwd, paneId);
|
|
189
|
-
if (!ready.ok) throw new Error(ready.message);
|
|
190
|
-
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
191
|
-
subs.set(paneId, entry);
|
|
192
|
-
h.persistSubs();
|
|
193
|
-
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
194
|
-
// M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
|
|
195
|
-
// reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
|
|
196
|
-
// Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
|
|
197
|
-
// leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
|
|
198
|
-
// running ledger entry). Keep injection and injectTs together in future refactors.
|
|
199
|
-
const injectTs = Date.now();
|
|
200
|
-
const injected = await pipeRequestTo(cwd, paneId, {
|
|
201
|
-
type: 'prompt',
|
|
202
|
-
id: `prompt-${taskId}`,
|
|
203
|
-
text: spec.prompt,
|
|
204
|
-
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
205
|
-
push: background,
|
|
206
|
-
});
|
|
207
|
-
if (injected.type !== 'ok') {
|
|
208
|
-
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
209
|
-
}
|
|
210
|
-
h.lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
|
|
211
|
-
|
|
212
|
-
// A7: run_in_background returns immediately after successful injection instead of blocking in foreground wait.
|
|
213
|
-
if (background) {
|
|
214
|
-
entry.background = true;
|
|
215
|
-
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
216
|
-
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
217
|
-
h.persistSubs();
|
|
218
|
-
h.writeHistory(entry, undefined, 'to-background');
|
|
219
|
-
return {
|
|
220
|
-
content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
|
|
221
|
-
details: { paneId, taskId, background: true, role: kind },
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
|
|
226
|
-
// Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
|
|
227
|
-
// three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
|
|
228
|
-
const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
|
|
229
|
-
let text: string | null = null;
|
|
230
|
-
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
231
|
-
while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
|
|
232
|
-
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
233
|
-
const session = (state === 'idle' || state === 'done')
|
|
234
|
-
? await subSessionState(paneId, cwd, injectTs)
|
|
235
|
-
: { text: null, pendingTool: false, activity: false };
|
|
236
|
-
const tick = planForegroundTick({ state, session });
|
|
237
|
-
if (tick.kind === 'blocked') {
|
|
238
|
-
const question = await readAskFlag(paneId);
|
|
239
|
-
entry.background = true;
|
|
240
|
-
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
241
|
-
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
242
|
-
h.persistSubs();
|
|
243
|
-
h.writeHistory(entry, undefined, 'to-background');
|
|
244
|
-
return {
|
|
245
|
-
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
246
|
-
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
if (tick.kind === 'settled') {
|
|
250
|
-
text = tick.text;
|
|
251
|
-
settledKind = 'settled';
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
if (tick.kind === 'wait') {
|
|
255
|
-
await sleep(tick.delayMs);
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (tick.kind === 'collect-final') {
|
|
259
|
-
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
260
|
-
if (text) { settledKind = 'settled'; break; }
|
|
261
|
-
await sleep(FOREGROUND_POLL_MS);
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (!text) {
|
|
266
|
-
const probe = await probeAlive(paneId, cwd);
|
|
267
|
-
if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
|
|
268
|
-
entry.background = true;
|
|
269
|
-
h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
270
|
-
void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
271
|
-
h.persistSubs();
|
|
272
|
-
const notice = buildAliveNotice(
|
|
273
|
-
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
274
|
-
Date.now(),
|
|
275
|
-
);
|
|
276
|
-
return {
|
|
277
|
-
content: [{ type: 'text', text: notice }],
|
|
278
|
-
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
settledKind = 'timeout';
|
|
282
|
-
}
|
|
283
|
-
const outcome =
|
|
284
|
-
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
285
|
-
: text ? { kind: 'completed' as const, text }
|
|
286
|
-
: { kind: 'no-output' as const, text: '' };
|
|
287
|
-
entry.status = 'consumed';
|
|
288
|
-
entry.consumedAt = Date.now();
|
|
289
|
-
h.persistSubs();
|
|
290
|
-
h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
291
|
-
return {
|
|
292
|
-
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
293
|
-
details: { paneId, taskId, background, role: kind },
|
|
294
|
-
};
|
|
295
|
-
} catch (err) {
|
|
296
|
-
// Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
|
|
297
|
-
// otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
|
|
298
|
-
// to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
|
|
299
|
-
if (paneId) {
|
|
300
|
-
subs.delete(paneId);
|
|
301
|
-
h.persistSubs();
|
|
302
|
-
void client.closePane(paneId).catch(() => { /* Best effort only. */ });
|
|
303
|
-
}
|
|
304
|
-
// D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
|
|
305
|
-
// exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
|
|
306
|
-
if (isolateMeta) {
|
|
307
|
-
const branch = isolateMeta.branch;
|
|
308
|
-
const worktreePath = isolateMeta.worktreePath;
|
|
309
|
-
void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
|
|
310
|
-
.then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
|
|
311
|
-
.catch(() => { /* Best effort only. */ });
|
|
312
|
-
}
|
|
313
|
-
return {
|
|
314
|
-
content: [{
|
|
315
|
-
type: 'text',
|
|
316
|
-
text: formatSubagentResult(
|
|
317
|
-
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
318
|
-
spec.description,
|
|
319
|
-
),
|
|
320
|
-
}],
|
|
321
|
-
details: {},
|
|
322
|
-
};
|
|
323
|
-
} finally {
|
|
324
|
-
release();
|
|
325
|
-
if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
}
|
package/src/subagent-git-io.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git/worktree I/O used by spawn, isolate, GC, and settlement stat lines.
|
|
3
|
-
*
|
|
4
|
-
* Why: core/subagent mixed adapter calls with tool registration. Centralizing
|
|
5
|
-
* git behind one cache/timeout policy keeps placement and isolate GC consistent.
|
|
6
|
-
*/
|
|
7
|
-
import { defaultGitAdapter, type GitAdapter } from './git-adapter.ts';
|
|
8
|
-
import { formatWorktreeStat, type SubEntry } from './subagent-core.ts';
|
|
9
|
-
|
|
10
|
-
const WORKTREES_CACHE_MS = 5000;
|
|
11
|
-
export interface GitIo {
|
|
12
|
-
listWorktrees(cwd: string): Promise<string[]>;
|
|
13
|
-
runGit(cwd: string, args: string[]): Promise<string | null>;
|
|
14
|
-
worktreeStatLine(entry: SubEntry): Promise<string | null>;
|
|
15
|
-
invalidateWorktreesCache(): void;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
|
|
19
|
-
let worktreesCache: { at: number; list: string[] } | null = null;
|
|
20
|
-
async function listWorktrees(cwd: string): Promise<string[]> {
|
|
21
|
-
if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
|
|
22
|
-
let list: string[] = [];
|
|
23
|
-
try {
|
|
24
|
-
const { stdout } = await git.listWorktrees(cwd);
|
|
25
|
-
for (const line of String(stdout).split('\n')) {
|
|
26
|
-
const m = /^worktree (.+)$/.exec(line.trim());
|
|
27
|
-
if (m) list.push(m[1]);
|
|
28
|
-
}
|
|
29
|
-
} catch {
|
|
30
|
-
list = [];
|
|
31
|
-
}
|
|
32
|
-
worktreesCache = { at: Date.now(), list };
|
|
33
|
-
return list;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function runGit(cwd: string, args: string[]): Promise<string | null> {
|
|
37
|
-
try {
|
|
38
|
-
const { stdout } = await git.run(cwd, args);
|
|
39
|
-
return stdout == null ? null : String(stdout);
|
|
40
|
-
} catch {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function lastStatLine(out: string | null): string | null {
|
|
46
|
-
if (!out) return null;
|
|
47
|
-
const lines = out.replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
48
|
-
return lines.length ? lines[lines.length - 1]! : null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function worktreeStatLine(entry: SubEntry): Promise<string | null> {
|
|
52
|
-
const porcelain = await runGit(entry.cwd, ['status', '--porcelain']);
|
|
53
|
-
if (porcelain === null) return null;
|
|
54
|
-
const dirtyCount = porcelain.split('\n').filter((l) => l.trim() !== '').length;
|
|
55
|
-
if (entry.isolate) {
|
|
56
|
-
const statOut = await runGit(entry.cwd, ['diff', '--stat', `${entry.isolate.baseSha}...HEAD`]);
|
|
57
|
-
const commitsOut = await runGit(entry.cwd, ['rev-list', '--count', `${entry.isolate.baseSha}..HEAD`]);
|
|
58
|
-
const commits = commitsOut != null && /^\d+$/.test(commitsOut.trim()) ? Number(commitsOut.trim()) : null;
|
|
59
|
-
return formatWorktreeStat({ branch: entry.isolate.branch, commits, statLine: lastStatLine(statOut), dirtyCount });
|
|
60
|
-
}
|
|
61
|
-
return formatWorktreeStat({
|
|
62
|
-
branch: null,
|
|
63
|
-
commits: null,
|
|
64
|
-
statLine: lastStatLine(await runGit(entry.cwd, ['diff', '--stat', 'HEAD'])),
|
|
65
|
-
dirtyCount,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function invalidateWorktreesCache(): void {
|
|
70
|
-
worktreesCache = null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
listWorktrees,
|
|
75
|
-
runGit,
|
|
76
|
-
worktreeStatLine,
|
|
77
|
-
invalidateWorktreesCache,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|