pi-pier 0.1.5 → 0.2.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/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab/pane collection and isolate-worktree sweep.
|
|
3
|
+
*
|
|
4
|
+
* Why: GC mixed herdr close I/O with the plugin body. Predicates stay in
|
|
5
|
+
* gc-core.ts; this adapter owns list/close, isolate git, and the ticker.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, existsSync, rmSync } from 'node:fs';
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
+
import type { HerdrClientLike } from './herdr-client.ts';
|
|
10
|
+
import { shouldClosePane, shouldCloseTaskTab } from './gc-core.ts';
|
|
11
|
+
import { runtimePolicy } from './runtime-policy.ts';
|
|
12
|
+
import { evaluateRelease, parseWorktreePorcelain, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import type { GitIo } from './subagent-git-io.ts';
|
|
14
|
+
import type { TerminalStateSlot } from './core/terminal.ts';
|
|
15
|
+
|
|
16
|
+
function sleep(ms: number): Promise<void> {
|
|
17
|
+
const wait = Promise.withResolvers<void>();
|
|
18
|
+
setTimeout(wait.resolve, ms);
|
|
19
|
+
return wait.promise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GcHost {
|
|
23
|
+
client: HerdrClientLike;
|
|
24
|
+
env: { tabId: string } | null;
|
|
25
|
+
subs: Map<string, SubEntry>;
|
|
26
|
+
persistSubs(): void;
|
|
27
|
+
writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
|
|
28
|
+
terminalState: TerminalStateSlot;
|
|
29
|
+
noticePending?: () => ReadonlySet<string>;
|
|
30
|
+
pendingIsolateBranches: Set<string>;
|
|
31
|
+
git: GitIo;
|
|
32
|
+
injectNotice(content: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GcController {
|
|
36
|
+
runGcSafely(): Promise<void>;
|
|
37
|
+
onTurnStart(): Promise<void>;
|
|
38
|
+
startTicker(ctx: Context): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createGcController(h: GcHost): GcController {
|
|
42
|
+
let prevTurnStart = Date.now();
|
|
43
|
+
let gcRunning = false;
|
|
44
|
+
|
|
45
|
+
async function gcPass(): Promise<void> {
|
|
46
|
+
if (h.subs.size === 0) return;
|
|
47
|
+
const ttlMs = runtimePolicy.sessionTtlSeconds * 1000;
|
|
48
|
+
const autoCloseTabs = ttlMs > 0;
|
|
49
|
+
let panesList: Array<{ paneId: string; tabId: string; agentStatus: string }>;
|
|
50
|
+
try {
|
|
51
|
+
panesList = await h.client.listPanes();
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
|
|
56
|
+
const termPaneIds = h.terminalState.activePaneIds();
|
|
57
|
+
const pendingNoticeIds = h.noticePending?.() ?? new Set<string>();
|
|
58
|
+
|
|
59
|
+
const byTab = new Map<string, SubEntry[]>();
|
|
60
|
+
for (const e of h.subs.values()) {
|
|
61
|
+
if (!e.tabId) continue;
|
|
62
|
+
const arr = byTab.get(e.tabId) ?? [];
|
|
63
|
+
arr.push(e);
|
|
64
|
+
byTab.set(e.tabId, arr);
|
|
65
|
+
}
|
|
66
|
+
const taskTabIds = new Set(byTab.keys());
|
|
67
|
+
const mainTabId = h.env?.tabId ?? '';
|
|
68
|
+
|
|
69
|
+
for (const [tabId, entries] of byTab) {
|
|
70
|
+
if (tabId === mainTabId) continue;
|
|
71
|
+
const tabPanes = panesList.filter((p) => p.tabId === tabId);
|
|
72
|
+
if (tabPanes.length === 0) {
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
if (e.status !== 'closed') {
|
|
75
|
+
e.status = 'closed';
|
|
76
|
+
h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const should = shouldCloseTaskTab({
|
|
82
|
+
entries,
|
|
83
|
+
paneStatuses: tabPanes.map((p) => p.agentStatus),
|
|
84
|
+
ttlMs,
|
|
85
|
+
now: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
if (!autoCloseTabs || !should) continue;
|
|
88
|
+
if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
|
|
89
|
+
try {
|
|
90
|
+
await h.client.tabClose(tabId);
|
|
91
|
+
} catch {
|
|
92
|
+
/* tab may already be gone */
|
|
93
|
+
}
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
if (e.status !== 'closed') {
|
|
96
|
+
e.status = 'closed';
|
|
97
|
+
h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await sleep(300);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
|
|
104
|
+
const candidates = [...h.subs.values()].filter(
|
|
105
|
+
(e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
|
|
106
|
+
);
|
|
107
|
+
for (const e of candidates) {
|
|
108
|
+
if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue;
|
|
109
|
+
if (!shouldClosePane({
|
|
110
|
+
consumedAt: e.consumedAt ?? null,
|
|
111
|
+
herdrStatus: statuses.get(e.paneId),
|
|
112
|
+
prevTurnStart,
|
|
113
|
+
})) continue;
|
|
114
|
+
if (statuses.get(e.paneId) === undefined) {
|
|
115
|
+
e.status = 'closed';
|
|
116
|
+
h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await h.client.closePane(e.paneId);
|
|
121
|
+
} catch {
|
|
122
|
+
/* pane may already be gone */
|
|
123
|
+
}
|
|
124
|
+
e.status = 'closed';
|
|
125
|
+
h.writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
126
|
+
await sleep(300);
|
|
127
|
+
}
|
|
128
|
+
h.persistSubs();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function isolateSweep(): Promise<void> {
|
|
132
|
+
const trace = process.env.PI_HERDR_TRACE
|
|
133
|
+
? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98sweep ${Date.now()} ${msg}\n`); } catch { /* best-effort */ } }
|
|
134
|
+
: null;
|
|
135
|
+
const masterCwd = process.cwd();
|
|
136
|
+
const registeredBranches = new Set<string>();
|
|
137
|
+
for (const e of h.subs.values()) {
|
|
138
|
+
if (e.isolate) registeredBranches.add(e.isolate.branch);
|
|
139
|
+
}
|
|
140
|
+
const wtPorcelain = await h.git.runGit(masterCwd, ['worktree', 'list', '--porcelain']);
|
|
141
|
+
if (wtPorcelain === null) return;
|
|
142
|
+
const wtByBranch = parseWorktreePorcelain(wtPorcelain);
|
|
143
|
+
const pierBranchOut = await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']);
|
|
144
|
+
const pierBranches = (pierBranchOut ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
145
|
+
type Cand = { branch: string; wtPath: string; entry: SubEntry | null };
|
|
146
|
+
const byBranch = new Map<string, Cand>();
|
|
147
|
+
for (const branch of pierBranches) {
|
|
148
|
+
if (registeredBranches.has(branch) || h.pendingIsolateBranches.has(branch)) continue;
|
|
149
|
+
const wtPath = wtByBranch.get(branch);
|
|
150
|
+
if (wtPath) byBranch.set(branch, { branch, wtPath, entry: null });
|
|
151
|
+
}
|
|
152
|
+
for (const e of h.subs.values()) {
|
|
153
|
+
if (!e.isolate || e.isolate.releasedAt != null || e.status === 'running') continue;
|
|
154
|
+
const prev = byBranch.get(e.isolate.branch);
|
|
155
|
+
byBranch.set(e.isolate.branch, { branch: e.isolate.branch, wtPath: prev?.wtPath ?? e.isolate.worktreePath, entry: e });
|
|
156
|
+
}
|
|
157
|
+
trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
|
|
158
|
+
let persisted = false;
|
|
159
|
+
for (const cand of byBranch.values()) {
|
|
160
|
+
const { branch, wtPath, entry } = cand;
|
|
161
|
+
if (entry && !wtByBranch.has(branch)) {
|
|
162
|
+
if (existsSync(wtPath)) {
|
|
163
|
+
try { rmSync(wtPath, { recursive: true, force: true }); } catch { continue; }
|
|
164
|
+
if (existsSync(wtPath)) continue;
|
|
165
|
+
}
|
|
166
|
+
entry.isolate!.releasedAt = Date.now();
|
|
167
|
+
persisted = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const mergedOut = await h.git.runGit(masterCwd, ['merge-base', '--is-ancestor', branch, 'HEAD']);
|
|
171
|
+
const merged = mergedOut !== null ? true : null;
|
|
172
|
+
let mergedFinal = merged;
|
|
173
|
+
if (merged === null) {
|
|
174
|
+
const sha = await h.git.runGit(masterCwd, ['rev-parse', branch]);
|
|
175
|
+
const headSha = await h.git.runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
176
|
+
if (sha != null && headSha != null) mergedFinal = false;
|
|
177
|
+
}
|
|
178
|
+
const dirtyOut = await h.git.runGit(wtPath, ['status', '--porcelain']);
|
|
179
|
+
const dirtyCount = dirtyOut === null ? null : dirtyOut.split('\n').filter((l) => l.trim() !== '').length;
|
|
180
|
+
const decision = evaluateRelease({ merged: mergedFinal, dirtyCount });
|
|
181
|
+
if (decision.action === 'release') {
|
|
182
|
+
const removed = await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath]);
|
|
183
|
+
let ok = removed !== null;
|
|
184
|
+
if (!ok) {
|
|
185
|
+
await sleep(2000);
|
|
186
|
+
ok = (await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath])) !== null;
|
|
187
|
+
}
|
|
188
|
+
if (ok) {
|
|
189
|
+
h.git.invalidateWorktreesCache();
|
|
190
|
+
if (entry) { entry.isolate!.releasedAt = Date.now(); persisted = true; }
|
|
191
|
+
}
|
|
192
|
+
} else if (entry && !entry.isolate!.retainNotified) {
|
|
193
|
+
entry.isolate!.retainNotified = true;
|
|
194
|
+
persisted = true;
|
|
195
|
+
try {
|
|
196
|
+
await h.injectNotice(`worktree ${branch} retained (${decision.reason}) — merge it (git merge --no-ff ${branch}) or remove manually (git worktree remove --force ${wtPath})`);
|
|
197
|
+
} catch {
|
|
198
|
+
/* retainNotified prevents repeated alerts */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (persisted) h.persistSubs();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runGcSafely(): Promise<void> {
|
|
206
|
+
if (gcRunning) return;
|
|
207
|
+
gcRunning = true;
|
|
208
|
+
try {
|
|
209
|
+
await gcPass();
|
|
210
|
+
} catch {
|
|
211
|
+
/* next turn/tick retries */
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await isolateSweep();
|
|
215
|
+
} catch {
|
|
216
|
+
/* next turn/tick retries */
|
|
217
|
+
} finally {
|
|
218
|
+
gcRunning = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
runGcSafely,
|
|
224
|
+
async onTurnStart(): Promise<void> {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
await runGcSafely();
|
|
227
|
+
prevTurnStart = now;
|
|
228
|
+
},
|
|
229
|
+
startTicker(ctx: Context): void {
|
|
230
|
+
const gcTickMs = runtimePolicy.gcTickMs;
|
|
231
|
+
const gcTicker = gcTickMs > 0 ? setInterval(() => { void runGcSafely(); }, gcTickMs) : null;
|
|
232
|
+
ctx.effect(() => () => {
|
|
233
|
+
clearInterval(gcTicker ?? undefined);
|
|
234
|
+
}, 'gc-ticker');
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent `execute` planners (validation + foreground wait).
|
|
3
|
+
*
|
|
4
|
+
* Why: execute mixed param checks, isolate git I/O, spawn, and wait-loop
|
|
5
|
+
* decisions. The decisions are pure and must stay testable without a pane.
|
|
6
|
+
*/
|
|
7
|
+
import { normalizeEntryKind } from './history-store.ts';
|
|
8
|
+
|
|
9
|
+
export const FOREGROUND_POLL_MS = 2_000;
|
|
10
|
+
|
|
11
|
+
export type LaunchParams = {
|
|
12
|
+
description?: unknown;
|
|
13
|
+
prompt?: unknown;
|
|
14
|
+
run_in_background?: unknown;
|
|
15
|
+
cwd?: unknown;
|
|
16
|
+
isolate?: unknown;
|
|
17
|
+
role?: unknown;
|
|
18
|
+
allowed_tools?: unknown;
|
|
19
|
+
tab?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LaunchValidation =
|
|
23
|
+
| { kind: 'error'; text: string }
|
|
24
|
+
| {
|
|
25
|
+
kind: 'ok';
|
|
26
|
+
spec: SubagentSpec;
|
|
27
|
+
background: boolean;
|
|
28
|
+
isolate: boolean;
|
|
29
|
+
cwdParam: string | null;
|
|
30
|
+
roleKind: string;
|
|
31
|
+
suggested: string[];
|
|
32
|
+
tab: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function planLaunchValidation(
|
|
36
|
+
params: LaunchParams | null | undefined,
|
|
37
|
+
herdrAvailable: boolean,
|
|
38
|
+
): LaunchValidation {
|
|
39
|
+
if (!herdrAvailable) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'error',
|
|
42
|
+
text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const spec: SubagentSpec = {
|
|
46
|
+
description: String(params?.description ?? 'subagent'),
|
|
47
|
+
prompt: String(params?.prompt ?? ''),
|
|
48
|
+
};
|
|
49
|
+
if (!spec.prompt.trim()) {
|
|
50
|
+
return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
|
|
51
|
+
}
|
|
52
|
+
const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
|
|
53
|
+
const isolate = params?.isolate === true;
|
|
54
|
+
if (isolate && cwdParam) {
|
|
55
|
+
return {
|
|
56
|
+
kind: 'error',
|
|
57
|
+
text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const suggested = Array.isArray(params?.allowed_tools)
|
|
61
|
+
? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
|
62
|
+
: [];
|
|
63
|
+
const manifestRole =
|
|
64
|
+
typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
|
|
65
|
+
const tab = typeof params?.tab === 'string' ? params.tab : null;
|
|
66
|
+
return {
|
|
67
|
+
kind: 'ok',
|
|
68
|
+
spec,
|
|
69
|
+
background: params?.run_in_background === true,
|
|
70
|
+
isolate,
|
|
71
|
+
cwdParam,
|
|
72
|
+
roleKind: normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined),
|
|
73
|
+
suggested,
|
|
74
|
+
manifestRole,
|
|
75
|
+
tab,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function planIsolateRepoGuard(baseSha: string | null): { kind: 'error'; text: string } | { kind: 'ok'; sha: string } {
|
|
80
|
+
if (!baseSha) {
|
|
81
|
+
return { kind: 'error', text: 'Error: isolate requires a git repository with at least one commit' };
|
|
82
|
+
}
|
|
83
|
+
return { kind: 'ok', sha: baseSha };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ForegroundTickPlan =
|
|
87
|
+
| { kind: 'blocked' }
|
|
88
|
+
| { kind: 'settled'; text: string }
|
|
89
|
+
| { kind: 'wait'; delayMs: number }
|
|
90
|
+
| { kind: 'collect-final' }
|
|
91
|
+
| { kind: 'continue' };
|
|
92
|
+
|
|
93
|
+
export function planForegroundTick(input: {
|
|
94
|
+
state: string | null;
|
|
95
|
+
session: { text: string | null; pendingTool: boolean; activity: boolean };
|
|
96
|
+
}): ForegroundTickPlan {
|
|
97
|
+
if (input.state === 'blocked') return { kind: 'blocked' };
|
|
98
|
+
if (input.state === 'idle' || input.state === 'done') {
|
|
99
|
+
if (input.session.text) return { kind: 'settled', text: input.session.text };
|
|
100
|
+
if (input.session.pendingTool || !input.session.activity) {
|
|
101
|
+
return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
|
|
102
|
+
}
|
|
103
|
+
return { kind: 'collect-final' };
|
|
104
|
+
}
|
|
105
|
+
return { kind: 'continue' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function planPatienceExpiry(alive: boolean): 'move-to-background' | 'timeout' {
|
|
109
|
+
return alive ? 'move-to-background' : 'timeout';
|
|
110
|
+
}
|