piher 0.0.0-stage → 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/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent — master-only plugin entry: tool registration, action dispatch, the resume / send / interrupt /
|
|
3
|
+
* output / list actions, and port binding. Inbound deps arrive via `piher.subagent-deps`; outbound
|
|
4
|
+
* pipe/settle queries bind atomically onto `port.current`.
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import { Type } from 'typebox';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
11
|
+
import { CORE_REQUIRED, type CoreClientLike, type CoreEnv } from '../core-client.ts';
|
|
12
|
+
import {
|
|
13
|
+
Semaphore,
|
|
14
|
+
SUBAGENT_OUTPUT_SCHEMA,
|
|
15
|
+
attachSubagentStructuredContent,
|
|
16
|
+
buildAliveNotice,
|
|
17
|
+
buildLaunchLine,
|
|
18
|
+
entryScript,
|
|
19
|
+
formatRoleCatalog,
|
|
20
|
+
idParam,
|
|
21
|
+
flattenToolParams,
|
|
22
|
+
isAlive,
|
|
23
|
+
newestPerTaskId,
|
|
24
|
+
resolveTaskIdPrefix,
|
|
25
|
+
taskIdResolutionError,
|
|
26
|
+
tabNameForTask,
|
|
27
|
+
withAdmission,
|
|
28
|
+
type AliveProbe,
|
|
29
|
+
type SubagentPort,
|
|
30
|
+
type SubagentPortBox,
|
|
31
|
+
} from '../subagent-core.ts';
|
|
32
|
+
import {
|
|
33
|
+
applyReportedSessionFile,
|
|
34
|
+
describeTaskState,
|
|
35
|
+
isActionable,
|
|
36
|
+
isClosed,
|
|
37
|
+
isSpent,
|
|
38
|
+
taskForPane,
|
|
39
|
+
taskRef,
|
|
40
|
+
type ResolvedTaskEntry,
|
|
41
|
+
type TaskEntry,
|
|
42
|
+
type TaskObservation,
|
|
43
|
+
type TaskRow,
|
|
44
|
+
} from '../task-row.ts';
|
|
45
|
+
import {
|
|
46
|
+
deleteTaskRecord,
|
|
47
|
+
listTaskRecords,
|
|
48
|
+
readTaskRecord,
|
|
49
|
+
repoTasksDir,
|
|
50
|
+
writeTaskRecord,
|
|
51
|
+
writeTaskView,
|
|
52
|
+
} from '../task-store.ts';
|
|
53
|
+
import { platformPaths } from '../platform-paths.ts';
|
|
54
|
+
import { listRoleNames, loadRoleConfig } from '../role-loader.ts';
|
|
55
|
+
import type { RuntimePolicy } from '../runtime-policy.ts';
|
|
56
|
+
import type { Lifecycle } from '../role-manifest.ts';
|
|
57
|
+
import { toolError, ToolError } from '../tool-error.ts';
|
|
58
|
+
import { readTaskPrompt } from '../prompt-store.ts';
|
|
59
|
+
import { pipeNameFor, pipePathFor, pipeRequestTo, type PipeRequest, type PipeResponse } from '../pipe-channel.ts';
|
|
60
|
+
import type { PaneStateSlot } from './bg-job.ts';
|
|
61
|
+
import { bareSessionId, resolveSessionFileValue } from '../session-tail.ts';
|
|
62
|
+
import { mainWorktreeRoot } from '../storage-layout.ts';
|
|
63
|
+
import { settlementCard, type SettlementCard } from '../settlement-card.ts';
|
|
64
|
+
import { DELEGATION_ENV, type DelegationContext } from '../delegation-core.ts';
|
|
65
|
+
import { createGitIo, createSpawnAction, createSpawner } from '../subagent-spawn.ts';
|
|
66
|
+
import { createCheckoutIo, createCheckoutSpace } from '../worktree-io.ts';
|
|
67
|
+
import {
|
|
68
|
+
missingCheckoutText,
|
|
69
|
+
closeCheckoutSpace,
|
|
70
|
+
releaseCheckout,
|
|
71
|
+
releasedCheckoutLine,
|
|
72
|
+
checkoutSpaceId,
|
|
73
|
+
type CheckoutIo,
|
|
74
|
+
type CheckoutSpace,
|
|
75
|
+
} from '../worktree.ts';
|
|
76
|
+
import { createPoller } from '../subagent-poller.ts';
|
|
77
|
+
import { createGcController, restoreTasks, retirePane, seedForkView } from '../subagent-gc.ts';
|
|
78
|
+
import { createSessionIo, executeSubagentOutput, type SubagentOutputCursor } from '../subagent-session.ts';
|
|
79
|
+
import type { WorkerWidgetRow } from '../tree-view.ts';
|
|
80
|
+
|
|
81
|
+
type ToolParams = Record<string, unknown> | undefined;
|
|
82
|
+
|
|
83
|
+
export interface SubagentDeps {
|
|
84
|
+
client: CoreClientLike;
|
|
85
|
+
env: CoreEnv | null;
|
|
86
|
+
/** Extension entry (index.ts) path — launchLine `-e`. */
|
|
87
|
+
extPath: string;
|
|
88
|
+
/** Master tree root (scope mount). */
|
|
89
|
+
sessionRoot: Context;
|
|
90
|
+
port: SubagentPortBox;
|
|
91
|
+
getSessionId: () => string;
|
|
92
|
+
/** This session's transcript path, for piher's per-session state root; null when it has none. */
|
|
93
|
+
getSessionFile?: () => string | null;
|
|
94
|
+
/** Repository the restore reads task records from; the session's own checkout (default: cwd). */
|
|
95
|
+
getCwd?: () => string;
|
|
96
|
+
/** Test seam: tasks the session starts out holding, as a restore would have seeded them. */
|
|
97
|
+
initialTasks?: TaskEntry[];
|
|
98
|
+
claimSettleNotice: (key: string) => boolean;
|
|
99
|
+
/** Settlement notice injector from index: buffer while busy, flush at turn_end. */
|
|
100
|
+
deliverNotice?: (content: string, paneId?: string, card?: SettlementCard | null) => Promise<void>;
|
|
101
|
+
/** Panes whose settlement notice is still buffered (GC exemption). */
|
|
102
|
+
noticePending?: () => ReadonlySet<string>;
|
|
103
|
+
/** Pane-owning plugins (background jobs) are exempt from subagent panes collection. */
|
|
104
|
+
terminalState: PaneStateSlot;
|
|
105
|
+
/** Test seam for the budgets the loop reads, so a suite never waits out a production default. */
|
|
106
|
+
policy?: Partial<RuntimePolicy>;
|
|
107
|
+
/** Test seam for the checkout piher may create and the app space that shows it (S71). */
|
|
108
|
+
checkoutIo?: CheckoutIo;
|
|
109
|
+
checkoutSpace?: CheckoutSpace;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Sent with every request: what a call needs. Details live in SUBAGENT_MANUAL (`action: "help"`). */
|
|
113
|
+
const SUBAGENT_DESCRIPTION = [
|
|
114
|
+
'Delegate a self-contained task to a subagent: a separate pi session in its own herdr pane (its own context — it does NOT see this conversation; a human can talk to it there).',
|
|
115
|
+
'Actions (default spawn): spawn | send | retry | output | list | interrupt | resume | close | help.',
|
|
116
|
+
'spawn: `prompt` is the COMPLETE task, since only it reaches the worker; `description` labels the pane. Several spawns per message run in parallel (max 4).',
|
|
117
|
+
'spawn places the worker by `cwd` or by `worktree` (a name: piher creates the checkout and branch).',
|
|
118
|
+
'spawn returns at once; the result arrives as a settlement notice at your next turn boundary — never sleep or poll.',
|
|
119
|
+
'retry: re-sends the spawned prompt after a fault outside the task. send: follow-up steered between tool calls; a task with no pane revives first. output: report once finished, lines while it runs — normally unneeded. resume: revive an unfinished task by taskId. list: yours plus another session\'s live ones, marked.',
|
|
120
|
+
'help: the full manual (placement, role profiles, the two documents).',
|
|
121
|
+
].join(' ');
|
|
122
|
+
|
|
123
|
+
/** Returned by `action: "help"`: the reference text kept out of the per-request description. */
|
|
124
|
+
const SUBAGENT_MANUAL = [
|
|
125
|
+
'subagent manual — `help` returns this text: what a call needs beyond the always-sent description.',
|
|
126
|
+
'',
|
|
127
|
+
'spawn — an independent worker on this workspace; its result arrives as a settlement notice, so return at once and never wait in-turn. `prompt` is the whole task (the worker does not see this conversation): paths, acceptance criteria, what to report. Every spawn mints a `taskId` for later actions. `description` labels the pane. No pane by default; `tab` joins or creates a task tab. `cwd` runs in a checkout you have, `worktree` names one piher creates (branch of the same name); they are mutually exclusive.',
|
|
128
|
+
'',
|
|
129
|
+
'role + allowed_tools — the profile list below (name, description, tools, lifecycle); profiles come from workspace `.piher/roles/`, then the user directory, then builtin. `allowed_tools` adds on top; role denies always win. Unknown names stay display labels; a profile is fixed for life.',
|
|
130
|
+
'',
|
|
131
|
+
'durability — one task record per task at `<repo>/.piher/tasks/<taskId>.json` plus this session\'s view (`<sessionDir>/piher/<sessionId>/tasks.json`). Liveness is observed, never stored; spawn, settle, close write the record.',
|
|
132
|
+
'',
|
|
133
|
+
'list — every actionable task, one line per row: `<slug>@<paneId> [<state>] (<kind> · <lifecycle>) <description>`. States: `running` (turn in flight), `idle` (report consumed), `no report`, `no pane` (gone pane). Another session\'s live task shows marked `[otra sesión]`; its gone pane never shows. Closed and spent rows never print.',
|
|
134
|
+
'',
|
|
135
|
+
'output — `agentId`; the state plus the finalized report or, while it runs, the latest lines (`max_chars`, 100-16000; whole pane only with `full: true`). Normally unneeded: the settlement notice carries the words.',
|
|
136
|
+
'',
|
|
137
|
+
'send — `agentId` + `message`: steered into a working worker at its next tool gap. A task whose pane is gone revives a new generation first, then takes the text. A closed or spent task is refused. `interrupt` aborts the current turn only — the pane and session stay, and no report arrives.',
|
|
138
|
+
'',
|
|
139
|
+
'close — `agentId`; reclaims a finished worker: pane and tab go, and the record keeps the decision — close is final for every session, so later calls are refused and a new subagent is the only next step. A turn in flight is refused — interrupt first. One-shots reclaim automatically; a `worktree` checkout goes too (never forced; dirty refused, branch survives). Another session\'s live task closes the same way, no adoption step.',
|
|
140
|
+
'',
|
|
141
|
+
'retry — `agentId` or `taskId`, no message: re-sends the prompt stored at spawn, after a fault outside the task. A closed task or spent one-shot starts a new task with the same brief, role and checkout; no stored prompt names `send`.',
|
|
142
|
+
'',
|
|
143
|
+
'resume — `taskId`; revives an unfinished task in a new generation, reusing a live pane for the same session when one exists, never the master\'s own. A turn in flight is not resumed — use send or output. Closed tasks are refused, as is another session\'s (only its own resumes it). A missing transcript names its path, with `retry` as the way out.',
|
|
144
|
+
].join('\n');
|
|
145
|
+
|
|
146
|
+
const SUBAGENT_CONCURRENCY = 4;
|
|
147
|
+
|
|
148
|
+
/** Role-declared lifetime (roles.md S9): only `one-shot` changes behaviour, and a role that cannot be
|
|
149
|
+
* resolved is a persistent worker — a renamed or deleted role file never reclaims a pane by surprise. */
|
|
150
|
+
function roleLifecycle(roleName: string): Lifecycle {
|
|
151
|
+
if (roleName === 'task') return 'persistent';
|
|
152
|
+
try {
|
|
153
|
+
return loadRoleConfig(roleName, { baseDir: process.cwd() }).lifecycle === 'one-shot' ? 'one-shot' : 'persistent';
|
|
154
|
+
} catch {
|
|
155
|
+
return 'persistent';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function defaultAgentSessionsDir(): string {
|
|
160
|
+
const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
|
|
161
|
+
return join(base, 'sessions');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default function subagentPlugin(ctx: Context): void {
|
|
165
|
+
const surface = cordisGet(ctx, 'piher.surface');
|
|
166
|
+
const d = cordisGet(ctx, 'piher.subagent-deps');
|
|
167
|
+
const { client, env, sessionRoot, port, terminalState } = d;
|
|
168
|
+
const pi = surface.raw as {
|
|
169
|
+
appendEntry?: (customType: string, data: unknown) => void;
|
|
170
|
+
/** Fire-and-forget in pi: the method returns void, not a promise. */
|
|
171
|
+
sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => void;
|
|
172
|
+
};
|
|
173
|
+
const scoped = surface.forModule(import.meta.url);
|
|
174
|
+
|
|
175
|
+
/** Every launchable profile, read at call time: workspace roles change without restarting pi. */
|
|
176
|
+
function roleCatalog(cwd: string): string {
|
|
177
|
+
const profiles = listRoleNames(cwd, undefined).map((name) => {
|
|
178
|
+
try {
|
|
179
|
+
const role = loadRoleConfig(name, { baseDir: cwd });
|
|
180
|
+
return {
|
|
181
|
+
name,
|
|
182
|
+
description: role.description ?? null,
|
|
183
|
+
tools: role.manifest.tools,
|
|
184
|
+
lifecycle: role.lifecycle === 'one-shot' ? ('one-shot' as const) : ('persistent' as const),
|
|
185
|
+
};
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { name, invalid: (err as Error).message.split('\n')[0] ?? 'invalid manifest' };
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return formatRoleCatalog(profiles);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const runtime = { nodePath: process.execPath, cliPath: entryScript(process.argv[1], existsSync), extPath: d.extPath };
|
|
194
|
+
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
195
|
+
/* Tasks this session owns, keyed by taskId: a task outlives its pane (S1). */
|
|
196
|
+
const tasks = new Map<string, TaskEntry>();
|
|
197
|
+
/* Pane→task index: the pane id is part of the generation, so calls arriving by pane id
|
|
198
|
+
* resolve through the task. A revive re-keys it; the task map is never re-keyed. */
|
|
199
|
+
const panes = new Map<string, string>();
|
|
200
|
+
/* In-memory session view: the task ids this session delegated, in order (S4). Spawn appends;
|
|
201
|
+
* without a transcript path it stays in memory only (S25). */
|
|
202
|
+
const viewIds: string[] = [];
|
|
203
|
+
/* Observation cache: the last liveness core reported per task. It feeds only the state text
|
|
204
|
+
* surfaces print; an action never reads it to decide (S26). */
|
|
205
|
+
const observations = new Map<string, TaskObservation>();
|
|
206
|
+
|
|
207
|
+
/** Record one probe result for the state text. Stale entries mislabel, never authorize (S26). */
|
|
208
|
+
function noteObservation(taskId: string, probe: AliveProbe): void {
|
|
209
|
+
observations.set(taskId, {
|
|
210
|
+
paneExists: probe.paneExists,
|
|
211
|
+
agentStatus: probe.agentStatus,
|
|
212
|
+
lastActivityMs: probe.lastActivityMs,
|
|
213
|
+
at: Date.now(),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const outputCursors = new Map<string, SubagentOutputCursor>();
|
|
217
|
+
/** Read counters shown in `subagent output` headers (one monotonic number per worker). */
|
|
218
|
+
const outputRevisions = new Map<string, number>();
|
|
219
|
+
const lastRequestIdByPane = new Map<string, string>();
|
|
220
|
+
/** Pane id → its in-flight boot (pipe readiness, prompt injection); a follow-up waits for it. */
|
|
221
|
+
const boots = new Map<string, Promise<void>>();
|
|
222
|
+
/** Deduplicates gate notices while blocked but permits a later, distinct human question. */
|
|
223
|
+
const blockedGateNotified = new Set<string>();
|
|
224
|
+
|
|
225
|
+
/** Write one task record: the task, its current generation and its decision. */
|
|
226
|
+
function writeRecord(entry: TaskEntry): void {
|
|
227
|
+
writeTaskRecord(entry.cwd, entry);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Write the close decision onto the freshest record: the rename is atomic against a torn
|
|
231
|
+
* file, not against a lost field, so a concurrent report delivery from the owning session
|
|
232
|
+
* is merged in, never dropped by flushing a snapshot read earlier in the call. */
|
|
233
|
+
function writeCloseRecord(cwd: string, entry: TaskEntry): void {
|
|
234
|
+
const at = Date.now();
|
|
235
|
+
entry.closedAt = at;
|
|
236
|
+
const fresh = readTaskRecord(cwd, entry.taskId);
|
|
237
|
+
if (!fresh) {
|
|
238
|
+
writeTaskRecord(cwd, entry);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
fresh.closedAt = at;
|
|
242
|
+
writeTaskRecord(cwd, fresh);
|
|
243
|
+
// Keep memory truthful: the record may carry a delivery this session had not seen.
|
|
244
|
+
if (fresh.gen.consumedAt !== null) entry.gen.consumedAt = fresh.gen.consumedAt;
|
|
245
|
+
if (fresh.gen.reportDelivered !== null) entry.gen.reportDelivered = fresh.gen.reportDelivered;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The disk is the authority (S24): adopt durable decisions another session may have written
|
|
249
|
+
* since this row was read. Facts only move toward final — closedAt, consumedAt and the
|
|
250
|
+
* delivered fingerprint are set, never cleared — so a refresh can never revive a task. */
|
|
251
|
+
function refreshFromRecord(entry: TaskEntry): void {
|
|
252
|
+
const row = readTaskRecord(entry.cwd, entry.taskId);
|
|
253
|
+
if (!row) return;
|
|
254
|
+
if (row.closedAt !== null) entry.closedAt = row.closedAt;
|
|
255
|
+
if (row.gen.consumedAt !== null) entry.gen.consumedAt = row.gen.consumedAt;
|
|
256
|
+
if (row.gen.reportDelivered !== null) entry.gen.reportDelivered = row.gen.reportDelivered;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A spawn appends its id to the view; every other operation touches one record. */
|
|
260
|
+
function appendView(taskId: string): void {
|
|
261
|
+
if (!viewIds.includes(taskId)) viewIds.push(taskId);
|
|
262
|
+
writeTaskView(d.getSessionFile?.() ?? null, viewIds);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A spawn that never started leaves no record and no view id behind. */
|
|
266
|
+
function dropTask(taskId: string): void {
|
|
267
|
+
const entry = tasks.get(taskId);
|
|
268
|
+
if (entry) deleteTaskRecord(entry.cwd, taskId);
|
|
269
|
+
observations.delete(taskId);
|
|
270
|
+
const at = viewIds.indexOf(taskId);
|
|
271
|
+
if (at >= 0) {
|
|
272
|
+
viewIds.splice(at, 1);
|
|
273
|
+
writeTaskView(d.getSessionFile?.() ?? null, viewIds);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const board = {
|
|
278
|
+
client,
|
|
279
|
+
tasks,
|
|
280
|
+
panes,
|
|
281
|
+
restoreCwd: () => d.getCwd?.() ?? process.cwd(),
|
|
282
|
+
sessionFile: () => d.getSessionFile?.() ?? null,
|
|
283
|
+
viewIds,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Test seam: the session starts out holding these tasks, the way a restore would have seeded them.
|
|
287
|
+
for (const seed of d.initialTasks ?? []) {
|
|
288
|
+
tasks.set(seed.taskId, seed);
|
|
289
|
+
if (seed.gen.paneId) panes.set(seed.gen.paneId, seed.taskId);
|
|
290
|
+
if (!viewIds.includes(seed.taskId)) viewIds.push(seed.taskId);
|
|
291
|
+
}
|
|
292
|
+
const git = createGitIo();
|
|
293
|
+
const checkout = d.checkoutIo ?? createCheckoutIo();
|
|
294
|
+
const checkoutSpace = d.checkoutSpace ?? createCheckoutSpace();
|
|
295
|
+
|
|
296
|
+
/** True while another row of this session still works in the checkout at `path`. The app space is
|
|
297
|
+
* the checkout's, not the worker's: a reviewer and the worker it reads share one, and closing it
|
|
298
|
+
* under the survivor takes its tab away while the worker keeps running in it. */
|
|
299
|
+
function checkoutInUseByAnother(entry: TaskEntry, path: string): boolean {
|
|
300
|
+
for (const other of tasks.values()) {
|
|
301
|
+
if (other === entry || isClosed(other)) continue;
|
|
302
|
+
if (other.worktree?.path === path) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** S54/S71: a role-declared one-shot reclaims its pane and the checkout its spawn created. */
|
|
308
|
+
function reclaim(entry: TaskEntry): void {
|
|
309
|
+
void (async () => {
|
|
310
|
+
if (entry.worktree) {
|
|
311
|
+
// A one-shot closes its window, never its checkout: the master decides whether to merge, and
|
|
312
|
+
// the working tree is what it reviews. Removing it here would leave only the branch behind.
|
|
313
|
+
const record = entry.worktree;
|
|
314
|
+
// A checkout can host more than one worker (a reviewer reading its worker's tree), so the
|
|
315
|
+
// space goes only with the last row that works in it — never under a live sibling.
|
|
316
|
+
if (!checkoutInUseByAnother(entry, record.path)) {
|
|
317
|
+
await closeCheckoutSpace(checkoutSpace, record).catch(() => null);
|
|
318
|
+
}
|
|
319
|
+
writeRecord(entry);
|
|
320
|
+
}
|
|
321
|
+
await retirePane(client, entry.gen.paneId);
|
|
322
|
+
})();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const boundPort: SubagentPort = {
|
|
326
|
+
applyReplySession(paneId, sessionFile) {
|
|
327
|
+
const entry = taskForPane(tasks, panes, paneId);
|
|
328
|
+
if (!entry) return;
|
|
329
|
+
// Workers self-report a BARE session id over the pipe; the .jsonl-only guard in applyReportedSessionFile
|
|
330
|
+
// would discard it and freeze a mis-attributed sessionFile forever, so map the id to its transcript path.
|
|
331
|
+
const reported = resolveSessionFileValue(entry.cwd, defaultAgentSessionsDir(), sessionFile);
|
|
332
|
+
const next = applyReportedSessionFile(entry.gen.sessionFile, reported);
|
|
333
|
+
if (next === entry.gen.sessionFile) return;
|
|
334
|
+
entry.gen.sessionFile = next;
|
|
335
|
+
writeRecord(entry);
|
|
336
|
+
},
|
|
337
|
+
listRunningSubs() {
|
|
338
|
+
// The widget shows the tasks still awaiting a report. The state text comes from the last
|
|
339
|
+
// observation, defaulting to the zero-observation base a fresh spawn shares (S26).
|
|
340
|
+
return [...tasks.values()]
|
|
341
|
+
.filter((s) => isActionable(s) && s.gen.consumedAt === null)
|
|
342
|
+
.map((s): WorkerWidgetRow => ({
|
|
343
|
+
paneId: s.gen.paneId,
|
|
344
|
+
slug: s.gen.slug,
|
|
345
|
+
role: s.kind,
|
|
346
|
+
state: describeTaskState(s, observations.get(s.taskId)),
|
|
347
|
+
startedAt: s.createdAt,
|
|
348
|
+
description: s.description,
|
|
349
|
+
}));
|
|
350
|
+
},
|
|
351
|
+
settlementCard(paneId, outcome) {
|
|
352
|
+
const entry = taskForPane(tasks, panes, paneId);
|
|
353
|
+
return settlementCard({
|
|
354
|
+
name: entry ? `${paneId} (${entry.description})` : paneId,
|
|
355
|
+
...(entry ? { kind: entry.kind, startedAt: entry.createdAt } : {}),
|
|
356
|
+
outcome,
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
consumeReply(paneId, _outcome) {
|
|
360
|
+
const entry = taskForPane(tasks, panes, paneId);
|
|
361
|
+
if (!entry || entry.gen.consumedAt !== null || isClosed(entry)) return;
|
|
362
|
+
entry.gen.consumedAt = Date.now();
|
|
363
|
+
writeRecord(entry);
|
|
364
|
+
// S54: a one-shot worker exits right after this push; its pane goes now, not at the GC TTL.
|
|
365
|
+
if (entry.lifecycle === 'one-shot') reclaim(entry);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
port.current = boundPort;
|
|
369
|
+
ctx.effect(() => () => {
|
|
370
|
+
if (port.current === boundPort) port.current = null;
|
|
371
|
+
}, 'subagent-port');
|
|
372
|
+
|
|
373
|
+
const injectNotice = (content: string, paneId?: string, card?: SettlementCard | null): Promise<void> => {
|
|
374
|
+
if (d.deliverNotice) return d.deliverNotice(content, paneId, card);
|
|
375
|
+
pi.sendUserMessage?.(content, { deliverAs: 'followUp' });
|
|
376
|
+
return Promise.resolve();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const session = createSessionIo({ client, getSessionId: d.getSessionId, sessionsDir: defaultAgentSessionsDir });
|
|
380
|
+
const spawn = createSpawner({ client, env, runtime, git, readinessTimeoutMs: d.policy?.readinessTimeoutMs });
|
|
381
|
+
const poller = createPoller({
|
|
382
|
+
client,
|
|
383
|
+
sessionRoot,
|
|
384
|
+
tasks,
|
|
385
|
+
panes,
|
|
386
|
+
writeRecord,
|
|
387
|
+
noteObservation: (paneId, obs) => {
|
|
388
|
+
const taskId = panes.get(paneId);
|
|
389
|
+
if (taskId) noteObservation(taskId, obs);
|
|
390
|
+
},
|
|
391
|
+
blockedGateNotified,
|
|
392
|
+
session,
|
|
393
|
+
injectNotice,
|
|
394
|
+
claimSettleNotice: d.claimSettleNotice,
|
|
395
|
+
reclaim,
|
|
396
|
+
policy: d.policy,
|
|
397
|
+
});
|
|
398
|
+
const { startPoller, pollers } = poller;
|
|
399
|
+
const gc = createGcController({
|
|
400
|
+
client,
|
|
401
|
+
env,
|
|
402
|
+
tasks,
|
|
403
|
+
noteObservation,
|
|
404
|
+
terminalState,
|
|
405
|
+
noticePending: d.noticePending,
|
|
406
|
+
checkoutSpace,
|
|
407
|
+
deliverNotice: d.deliverNotice,
|
|
408
|
+
});
|
|
409
|
+
gc.startTicker(ctx);
|
|
410
|
+
scoped.on('turn_start', () => gc.onTurnStart());
|
|
411
|
+
const { probeAlive } = session;
|
|
412
|
+
const { spawnPaneInTaskTab, launchParts, approveFor, waitSubReady, findExistingPane } = spawn;
|
|
413
|
+
|
|
414
|
+
/** Re-arm settlement watches after a restart: only rows still awaiting a report whose pane core
|
|
415
|
+
* still lists. A delivered report needs no watch; a closed task is final; a gone pane is
|
|
416
|
+
* `resume` work, not a re-arm. The liveness lookup decides — never a stored status. */
|
|
417
|
+
async function recoverRunningPollers(via: string): Promise<void> {
|
|
418
|
+
if (!client.available) return;
|
|
419
|
+
const running = [...tasks.values()].filter((s) =>
|
|
420
|
+
isActionable(s) && s.gen.consumedAt === null && !pollers.has(s.gen.paneId));
|
|
421
|
+
if (running.length === 0) return;
|
|
422
|
+
let liveTerminalIds: ReadonlySet<string>;
|
|
423
|
+
try {
|
|
424
|
+
liveTerminalIds = new Set((await client.listTerminals()).map((terminal) => terminal.id));
|
|
425
|
+
} catch {
|
|
426
|
+
return; // liveness lookup failed → do not guess
|
|
427
|
+
}
|
|
428
|
+
const recovered: string[] = [];
|
|
429
|
+
for (const entry of running) {
|
|
430
|
+
// A gone pane is resume work, not a re-arm.
|
|
431
|
+
if (!liveTerminalIds.has(entry.gen.paneId)) continue;
|
|
432
|
+
try {
|
|
433
|
+
await startPoller(entry.gen.paneId, entry.cwd, entry.gen.startedAt, entry.description, `recover-${via}-${entry.gen.paneId}`);
|
|
434
|
+
recovered.push(`${entry.gen.paneId} (${entry.description})`);
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (recovered.length > 0) {
|
|
439
|
+
try {
|
|
440
|
+
await injectNotice(
|
|
441
|
+
`Session recovery (${via}): ${recovered.length} background subagent(s) still running — settlement watch re-armed for: ${recovered.join('; ')}.`,
|
|
442
|
+
);
|
|
443
|
+
} catch { /* non-fatal */ }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
scoped.on('session_start', async (event: unknown, eventCtx: unknown) => {
|
|
448
|
+
// The reason is core's own word for it (`startup`, `reload`, `new`, `resume`, `fork`): a fork is
|
|
449
|
+
// the only one that lands on a session file with no state of its own, so it inherits the parent's
|
|
450
|
+
// view document before the restore reads it.
|
|
451
|
+
const reason = (event as { reason?: string } | undefined)?.reason;
|
|
452
|
+
const previous = (event as { previousSessionFile?: string } | undefined)?.previousSessionFile;
|
|
453
|
+
if (reason === 'fork' || reason === 'resume') seedForkView(d.getSessionFile?.() ?? null, previous ?? null);
|
|
454
|
+
// Records live in the session's own repository: its checkout from the event context, else the
|
|
455
|
+
// configured override, else the process checkout.
|
|
456
|
+
const ctxCwd = (eventCtx as { cwd?: string } | undefined)?.cwd;
|
|
457
|
+
restoreTasks(board, typeof ctxCwd === 'string' ? ctxCwd : undefined);
|
|
458
|
+
await recoverRunningPollers('session_start');
|
|
459
|
+
});
|
|
460
|
+
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
461
|
+
const ctxCwd = (eventCtx as { cwd?: string } | undefined)?.cwd;
|
|
462
|
+
restoreTasks(board, typeof ctxCwd === 'string' ? ctxCwd : undefined);
|
|
463
|
+
await recoverRunningPollers('session_tree');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
/** A failing subagent call must not read as a dead worker: probe liveness first, so the model
|
|
467
|
+
* stops seizing work from a healthy subagent. */
|
|
468
|
+
scoped.on('tool_result', async (rawEvent: unknown) => {
|
|
469
|
+
const event = (rawEvent ?? {}) as { toolName?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> };
|
|
470
|
+
if (event.toolName !== 'subagent' || !event.isError) return;
|
|
471
|
+
// Extract only ids of rows we know; leave unrelated errors untouched.
|
|
472
|
+
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
473
|
+
const paneId = [...panes.keys()].find((id) => errText.includes(id));
|
|
474
|
+
if (!paneId) return;
|
|
475
|
+
const entry = taskForPane(tasks, panes, paneId);
|
|
476
|
+
if (!entry || isClosed(entry) || isSpent(entry)) return;
|
|
477
|
+
const probe = await probeAlive(paneId, entry.cwd);
|
|
478
|
+
if (!isAlive(probe, Date.now())) return; // keep the original error only when the agent is truly dead
|
|
479
|
+
// Move a live agent to the poller so its eventual result replaces the false error.
|
|
480
|
+
if (!pollers.has(paneId)) {
|
|
481
|
+
startPoller(paneId, entry.cwd, entry.gen.startedAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
|
|
482
|
+
writeRecord(entry);
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
content: [{ type: 'text', text: buildAliveNotice({ paneId, description: entry.description, probe }, Date.now()) }],
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
async function reviveEntry(entry: TaskEntry): Promise<TaskEntry> {
|
|
490
|
+
// A revive only continues a resident row: a transient record copy from another session must
|
|
491
|
+
// never grow a generation here — that write belongs to its own session (S7).
|
|
492
|
+
if (tasks.get(entry.taskId) !== entry) {
|
|
493
|
+
throw new ToolError(
|
|
494
|
+
`Error: task ${entry.taskId} belongs to another session; only its own session can revive `
|
|
495
|
+
+ 'it — close it with action "close" if its work is over.',
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const latest: TaskRow = readTaskRecord(entry.cwd, entry.taskId) ?? entry;
|
|
499
|
+
// S9: close is final for every session, and the disk is the authority. A closed (or spent)
|
|
500
|
+
// task never grows a new generation — otherwise a live pane would sit behind a record that
|
|
501
|
+
// still says closed, and the revive would carry the stale `closedAt` into the new record.
|
|
502
|
+
const verdict = spentText(latest);
|
|
503
|
+
if (verdict !== null) throw new ToolError(verdict);
|
|
504
|
+
// S71: resume decides by existence, never by a flag — the same refusal whether `close`, a
|
|
505
|
+
// one-shot settlement or a human removed the checkout.
|
|
506
|
+
if (!existsSync(entry.cwd)) {
|
|
507
|
+
throw new ToolError(missingCheckoutText({
|
|
508
|
+
taskId: entry.taskId,
|
|
509
|
+
cwd: entry.cwd,
|
|
510
|
+
worktree: latest.worktree ?? entry.worktree ?? null,
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
// Never relaunch the master's own transcript in a worker pane (two pi processes competing on
|
|
514
|
+
// one jsonl): a mis-attributed record degrades to a fresh conversation instead.
|
|
515
|
+
const resumeFile = latest.gen.sessionFile && /\.jsonl$/.test(latest.gen.sessionFile)
|
|
516
|
+
&& bareSessionId(latest.gen.sessionFile) !== bareSessionId(d.getSessionId())
|
|
517
|
+
? latest.gen.sessionFile : null;
|
|
518
|
+
// D86 trust matches spawn; revive has no tool context, so use the process cwd.
|
|
519
|
+
const approve = await approveFor(entry.cwd, process.cwd());
|
|
520
|
+
const argv = launchParts(resumeFile, null, null, approve);
|
|
521
|
+
const lifecycle = roleLifecycle(entry.kind);
|
|
522
|
+
// Admission, not readiness: the slot is held only while the pane is created, so a revive still
|
|
523
|
+
// waiting for its pipe cannot starve a spawn (S50). The wait below runs outside the slot.
|
|
524
|
+
const spawned = await withAdmission(subSemaphore, () => spawnPaneInTaskTab(
|
|
525
|
+
{
|
|
526
|
+
desiredTab: entry.gen.tabName || latest.gen.tabName || null,
|
|
527
|
+
description: entry.description,
|
|
528
|
+
// A revive keeps the visibility the worker was given: only one that had a tab gets a pane.
|
|
529
|
+
visible: entry.gen.tabId !== '',
|
|
530
|
+
// A revived worker goes back to the window its checkout owns. Without this a spawn carries
|
|
531
|
+
// no `herdr.space` at all, and the app adopts the pane into whatever space is focused — the
|
|
532
|
+
// worker reappears in a different workspace than the one its checkout lives in.
|
|
533
|
+
zone: entry.worktree ? { zone: 'worktree', tabName: basename(entry.worktree.path) } : undefined,
|
|
534
|
+
spaceId: entry.worktree ? checkoutSpaceId(entry.worktree.path) : null,
|
|
535
|
+
},
|
|
536
|
+
entry.cwd,
|
|
537
|
+
{
|
|
538
|
+
PIHER_SUBAGENT: '1',
|
|
539
|
+
[DELEGATION_ENV]: JSON.stringify({
|
|
540
|
+
parentPaneId: env?.terminalId ?? '',
|
|
541
|
+
parentSessionId: bareSessionId(d.getSessionId()),
|
|
542
|
+
taskId: entry.taskId,
|
|
543
|
+
description: entry.description,
|
|
544
|
+
role: entry.kind === 'task' ? 'worker-default' : entry.kind,
|
|
545
|
+
lifecycle,
|
|
546
|
+
} satisfies DelegationContext),
|
|
547
|
+
},
|
|
548
|
+
argv,
|
|
549
|
+
));
|
|
550
|
+
const ready = await waitSubReady(entry.cwd, spawned.paneId);
|
|
551
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
552
|
+
const previousPaneId = entry.gen.paneId;
|
|
553
|
+
Object.assign(entry.gen, {
|
|
554
|
+
paneId: spawned.paneId,
|
|
555
|
+
tabId: spawned.tabId,
|
|
556
|
+
tabName: spawned.tabName,
|
|
557
|
+
sessionFile: resumeFile,
|
|
558
|
+
consumedAt: null,
|
|
559
|
+
launchCommand: [buildLaunchLine(argv)],
|
|
560
|
+
startedAt: Date.now(),
|
|
561
|
+
});
|
|
562
|
+
// S3: only the generation is replaced — `createdAt` is the task's durable fact, and the new
|
|
563
|
+
// generation's start rides `gen.startedAt` above.
|
|
564
|
+
// A revived row is reclaimed at settlement too when its role still declares one-shot (S54).
|
|
565
|
+
entry.lifecycle = lifecycle;
|
|
566
|
+
// A task outlives its pane: the pane index moves to the new generation, and the dead id must
|
|
567
|
+
// go, or a later zombie sweep closes the live row.
|
|
568
|
+
if (previousPaneId !== entry.gen.paneId) panes.delete(previousPaneId);
|
|
569
|
+
panes.set(entry.gen.paneId, entry.taskId);
|
|
570
|
+
writeRecord(entry);
|
|
571
|
+
return entry;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A row that lives only as a record, never adopted: actions read it, never join it to the
|
|
575
|
+
* tasks, the panes or the view (S4). */
|
|
576
|
+
function transientFromRecord(row: TaskRow): TaskEntry {
|
|
577
|
+
const worktree = row.worktree ? { worktree: { ...row.worktree } } : {};
|
|
578
|
+
return { ...row, gen: { ...row.gen }, ...worktree };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Ownership refusal: the row belongs to another session, so this one may only close it (S24).
|
|
582
|
+
* Close and interrupt never reach here; send, retry and resume do. */
|
|
583
|
+
function foreignRefusal(taskId: string, action: string): string {
|
|
584
|
+
return `Error: task ${taskId} belongs to another session (list marks it [otra sesión]); `
|
|
585
|
+
+ `action "${action}" only runs where the task lives. Close it with action "close" `
|
|
586
|
+
+ 'if its work is over — close is final for every session.';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Resolve every id `list` prints (S17): this session's rows first, then the repository records
|
|
590
|
+
* in the same form order — pane id, `slug@paneId`, bare slug, taskId prefix. A record match
|
|
591
|
+
* belongs to another session: resolved for action, never adopted into the view (S4). */
|
|
592
|
+
function resolveSubEntry(rawId: string, cwd?: string): ResolvedTaskEntry {
|
|
593
|
+
if (!rawId) return { error: 'Error: unknown subagent id "" (see action list)' };
|
|
594
|
+
const direct = taskForPane(tasks, panes, rawId);
|
|
595
|
+
if (direct) return { entry: direct, foreign: false };
|
|
596
|
+
// Slug reference: exact match against live rows (paneId stays first for back-compat).
|
|
597
|
+
// `slug@paneId` accepts both parts; a revived task changed its paneId, so the slug part wins then.
|
|
598
|
+
const at = rawId.indexOf('@');
|
|
599
|
+
const slugPart = at > 0 ? rawId.slice(0, at) : rawId;
|
|
600
|
+
const panePart = at > 0 ? rawId.slice(at + 1) : null;
|
|
601
|
+
const combined = panePart !== null
|
|
602
|
+
? (taskForPane(tasks, panes, panePart)
|
|
603
|
+
?? [...tasks.values()].find((s) => s.gen.slug === slugPart && s.gen.paneId === panePart) ?? null)
|
|
604
|
+
: null;
|
|
605
|
+
if (combined) return { entry: combined, foreign: false };
|
|
606
|
+
const bySlug = [...tasks.values()].find((s) => s.gen.slug === slugPart);
|
|
607
|
+
if (bySlug) return { entry: bySlug, foreign: false };
|
|
608
|
+
|
|
609
|
+
// Beyond the view: another session's record, same form order. Exact pane and slug ids match
|
|
610
|
+
// at any length; only prefixes keep the four-character rule.
|
|
611
|
+
const records = listTaskRecords(cwd ?? d.getCwd?.() ?? process.cwd());
|
|
612
|
+
const recordByPane = new Map<string, TaskRow>();
|
|
613
|
+
for (const row of records) {
|
|
614
|
+
if (!row.gen.paneId || recordByPane.has(row.gen.paneId)) continue;
|
|
615
|
+
recordByPane.set(row.gen.paneId, row);
|
|
616
|
+
}
|
|
617
|
+
const foreignPane = recordByPane.get(rawId);
|
|
618
|
+
if (foreignPane) return { entry: transientFromRecord(foreignPane), foreign: true };
|
|
619
|
+
const foreignCombined = panePart !== null
|
|
620
|
+
? (recordByPane.get(panePart)
|
|
621
|
+
?? records.find((r) => r.gen.slug === slugPart && r.gen.paneId === panePart) ?? null)
|
|
622
|
+
: null;
|
|
623
|
+
if (foreignCombined) return { entry: transientFromRecord(foreignCombined), foreign: true };
|
|
624
|
+
const foreignSlug = records.find((r) => r.gen.slug === slugPart);
|
|
625
|
+
if (foreignSlug) return { entry: transientFromRecord(foreignSlug), foreign: true };
|
|
626
|
+
|
|
627
|
+
const byTaskId = newestPerTaskId(tasks.values());
|
|
628
|
+
const res = resolveTaskIdPrefix(rawId, byTaskId.keys());
|
|
629
|
+
if (res.kind === 'resolved') return { entry: byTaskId.get(res.taskId)!, foreign: false };
|
|
630
|
+
const resError = taskIdResolutionError(res, 'subagent id', rawId);
|
|
631
|
+
if (resError) return { error: resError };
|
|
632
|
+
|
|
633
|
+
const recordRes = resolveTaskIdPrefix(rawId, records.map((r) => r.taskId));
|
|
634
|
+
if (recordRes.kind === 'resolved') {
|
|
635
|
+
const row = records.find((r) => r.taskId === recordRes.taskId)!;
|
|
636
|
+
return { entry: transientFromRecord(row), foreign: true };
|
|
637
|
+
}
|
|
638
|
+
const recordError = taskIdResolutionError(recordRes, 'subagent id', rawId);
|
|
639
|
+
if (recordError) return { error: recordError };
|
|
640
|
+
return { error: `Error: unknown subagent id "${rawId}" (see action list)` };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* `close` is the master's final word on a worker and a one-shot spends itself when its result is
|
|
645
|
+
* consumed: neither may be revived. Resume exists for work that was left unfinished.
|
|
646
|
+
*/
|
|
647
|
+
/** Refusal verdict from the durable facts alone (S12): a synthesized memory status never
|
|
648
|
+
* authorizes it, so a closed record answers final whatever the caller resolved. */
|
|
649
|
+
function spentText(row: TaskRow): string | null {
|
|
650
|
+
if (isClosed(row)) {
|
|
651
|
+
return `Error: task ${row.taskId} was closed by the master; close is final, so there is nothing to resume or send to. Start a new subagent for further work.`;
|
|
652
|
+
}
|
|
653
|
+
if (isSpent(row)) {
|
|
654
|
+
return `Error: task ${row.taskId} was a one-shot and already delivered its verdict; there is nothing to resume. Spawn a new one-shot for new work.`;
|
|
655
|
+
}
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function targetOf(
|
|
660
|
+
params: ToolParams,
|
|
661
|
+
toolCtx: unknown,
|
|
662
|
+
): { entry: TaskEntry; cwd: string; foreign: boolean } {
|
|
663
|
+
const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
664
|
+
const resolved = resolveSubEntry(idParam(params, 'agentId', 'taskId'), cwd);
|
|
665
|
+
if ('error' in resolved) toolError(resolved.error);
|
|
666
|
+
// Freshness at the single shared spine: no decision below may read a stale memory copy.
|
|
667
|
+
// Transient rows are already fresh — they were read from the record in this same call.
|
|
668
|
+
if (!resolved.foreign) refreshFromRecord(resolved.entry);
|
|
669
|
+
return { entry: resolved.entry, cwd, foreign: resolved.foreign };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Bring back a worker whose pane is gone but whose task was never closed. Housekeeping reclaims
|
|
674
|
+
* panes and tabs (`gc`), so a persistent worker with a consumed report usually has no pane left; `send` used to
|
|
675
|
+
* read that as `closed` and revive, which collapsed "not live" and "closed by the master" into one
|
|
676
|
+
* state. They are different: a closed task is final, a vanished pane is unfinished work.
|
|
677
|
+
*/
|
|
678
|
+
/** Returns whether a pane was actually made: only a fresh pane is still booting. */
|
|
679
|
+
/** Bring back a worker whose pane is gone but whose task is still actionable. The probe decides;
|
|
680
|
+
* a closed or spent task never reaches here (`send` refuses it first, `reviveEntry` guards). */
|
|
681
|
+
async function ensureLive(entry: TaskEntry): Promise<boolean> {
|
|
682
|
+
let alive = false;
|
|
683
|
+
try {
|
|
684
|
+
alive = (await probeAlive(entry.gen.paneId, entry.cwd)).paneExists;
|
|
685
|
+
} catch {
|
|
686
|
+
alive = true; // a probe that fails must not spawn a second pane over a live one
|
|
687
|
+
}
|
|
688
|
+
if (alive) return false;
|
|
689
|
+
await reviveEntry(entry);
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function executeSubagentResume(params: ToolParams, toolCtx: unknown) {
|
|
694
|
+
if (!client.available) return toolError(`subagent: ${CORE_REQUIRED}`);
|
|
695
|
+
const rawTaskId = idParam(params, 'taskId', 'agentId');
|
|
696
|
+
// Refuse a spent task by its record verdict, before any pane is made for it.
|
|
697
|
+
{
|
|
698
|
+
const known = resolveSubEntry(rawTaskId ?? '');
|
|
699
|
+
if ('entry' in known) {
|
|
700
|
+
const spent = spentText(known.entry);
|
|
701
|
+
if (spent !== null) return toolError(spent);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (!rawTaskId) return toolError('missing taskId for resume (see action list).');
|
|
705
|
+
// A task with a turn in flight is not resumed — use send or output. The probe decides (S26).
|
|
706
|
+
// A delivered report ends the turn, so consumed rows skip the probe; a live but silent pane
|
|
707
|
+
// with an active settlement watch — or a boot still injecting its first prompt — still counts
|
|
708
|
+
// as in flight, so resume never doubles it.
|
|
709
|
+
{
|
|
710
|
+
const live = resolveTaskIdPrefix(rawTaskId, tasks.keys());
|
|
711
|
+
if (live.kind === 'resolved') {
|
|
712
|
+
const entry = tasks.get(live.taskId)!;
|
|
713
|
+
let running = false;
|
|
714
|
+
if (entry.gen.consumedAt === null) {
|
|
715
|
+
try {
|
|
716
|
+
const probe = await probeAlive(entry.gen.paneId, entry.cwd);
|
|
717
|
+
noteObservation(entry.taskId, probe);
|
|
718
|
+
const silent = probe.agentStatus === null || probe.agentStatus === 'unknown';
|
|
719
|
+
const tracked = pollers.has(entry.gen.paneId) || boots.has(entry.gen.paneId);
|
|
720
|
+
running = probe.paneExists && (probe.agentStatus === 'working'
|
|
721
|
+
|| probe.agentStatus === 'blocked'
|
|
722
|
+
|| (silent && tracked));
|
|
723
|
+
} catch {
|
|
724
|
+
running = false; // a failed probe never strands a resume; the revive path re-checks
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (running) {
|
|
728
|
+
const busyText =
|
|
729
|
+
`task ${entry.taskId} is already running as pane ${entry.gen.paneId}. `
|
|
730
|
+
+ 'Use action send or output; resume is only for a collected task whose pane is gone.';
|
|
731
|
+
return {
|
|
732
|
+
content: [{ type: 'text', text: busyText }],
|
|
733
|
+
details: { paneId: entry.gen.paneId, taskId: entry.taskId, alreadyRunning: true },
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
const liveError = taskIdResolutionError(live, 'task id', rawTaskId);
|
|
738
|
+
if (liveError) return toolError(liveError);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// S41: one task directory per repository, so resume resolves against the current one — never a
|
|
742
|
+
// sweep of other checkouts this master happened to spawn into.
|
|
743
|
+
const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
744
|
+
const dir = repoTasksDir(cwd);
|
|
745
|
+
if (dir === null) {
|
|
746
|
+
return toolError(
|
|
747
|
+
`resume needs a git repository: task records live inside the repository (<repo>/.piher/tasks) and ${cwd} is not in one.`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const records = listTaskRecords(cwd);
|
|
751
|
+
const resolution = resolveTaskIdPrefix(rawTaskId, records.map((e) => e.taskId));
|
|
752
|
+
if (resolution.kind !== 'resolved') {
|
|
753
|
+
return toolError(taskIdResolutionError(resolution, 'task id', rawTaskId)
|
|
754
|
+
?? `Error: no history for task "${rawTaskId}" in this repository's tasks (${dir}).`);
|
|
755
|
+
}
|
|
756
|
+
const taskId = resolution.taskId;
|
|
757
|
+
const latest = readTaskRecord(cwd, taskId);
|
|
758
|
+
if (!latest) return toolError(`Error: no history for task "${taskId}" in this repository's tasks (${dir}).`);
|
|
759
|
+
// Resume needs an unresolved task: a closed one is final, a spent one-shot has no session
|
|
760
|
+
// left. The verdict derives from the record's durable facts, never from a synthesized status —
|
|
761
|
+
// a record closed before its report was consumed still says closed (S9).
|
|
762
|
+
{
|
|
763
|
+
const verdict = spentText(latest);
|
|
764
|
+
if (verdict !== null) return toolError(verdict);
|
|
765
|
+
}
|
|
766
|
+
// D94: reuse an existing pane for the same session to avoid competing pi processes. This
|
|
767
|
+
// lookup only reads terminal labels, so it runs before the ownership check below.
|
|
768
|
+
const existing = await findExistingPane(latest.gen.sessionFile);
|
|
769
|
+
// A record sessionFile mis-attributed to the master's own transcript would match the MASTER
|
|
770
|
+
// terminal here, registering the master as its own subagent — never adopt self, whatever
|
|
771
|
+
// session the row belongs to.
|
|
772
|
+
if (existing?.paneId === env?.terminalId) {
|
|
773
|
+
return toolError(
|
|
774
|
+
`Error: task ${taskId} is attributed to the master's own session (mis-recorded sessionFile `
|
|
775
|
+
+ 'in the record); it cannot be resumed here — '
|
|
776
|
+
+ 'spawn a fresh subagent for this work instead.',
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
// Reviving would write a generation this session does not own (S7): another session's task
|
|
780
|
+
// is resumed where it lives, never adopted here (S4).
|
|
781
|
+
if (!viewIds.includes(taskId)) return toolError(foreignRefusal(taskId, 'resume'));
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
const entry: TaskEntry = {
|
|
785
|
+
...latest,
|
|
786
|
+
gen: {
|
|
787
|
+
...latest.gen,
|
|
788
|
+
paneId: existing?.paneId ?? '',
|
|
789
|
+
tabId: existing?.tabId ?? '',
|
|
790
|
+
tabName: latest.gen.tabName || tabNameForTask(latest.description),
|
|
791
|
+
consumedAt: null,
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
// One row per task: the revived generation replaces whatever the restore seeded, and stale
|
|
795
|
+
// pane ids for it go so the index never points at the dead generation.
|
|
796
|
+
for (const [paneId, id] of [...panes]) if (id === taskId) panes.delete(paneId);
|
|
797
|
+
tasks.set(taskId, entry);
|
|
798
|
+
if (existing) {
|
|
799
|
+
panes.set(entry.gen.paneId, taskId);
|
|
800
|
+
writeRecord(entry);
|
|
801
|
+
} else {
|
|
802
|
+
await reviveEntry(entry);
|
|
803
|
+
tasks.set(entry.taskId, entry);
|
|
804
|
+
}
|
|
805
|
+
const how = existing
|
|
806
|
+
? 'reused existing pane with same session; pi still running there'
|
|
807
|
+
: entry.gen.sessionFile ? 'session restored' : 'session file missing; fresh conversation';
|
|
808
|
+
return {
|
|
809
|
+
content: [{ type: 'text', text: `resumed subagent ${taskRef(entry.gen)} from task ${taskId} (${how}).` }],
|
|
810
|
+
details: { paneId: entry.gen.paneId, taskId },
|
|
811
|
+
};
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (err instanceof ToolError) throw err;
|
|
814
|
+
return toolError(`Error: failed to resume task "${taskId}": ${(err as Error).message}`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** The shared spine of every id-carrying action: revive a closed row, wait for its pipe, send one request,
|
|
819
|
+
* fold failures into the error wording. `interrupt` passes `ready: false`: a worker with no turn in
|
|
820
|
+
* flight is refused before the pipe instead of being woken to receive a stop. */
|
|
821
|
+
async function pipeAction(
|
|
822
|
+
entry: TaskEntry,
|
|
823
|
+
opts: {
|
|
824
|
+
/** One request from the closed pipe protocol; its `type`/`id` drive the reply and settle keys. */
|
|
825
|
+
build(): PipeRequest;
|
|
826
|
+
ok(res: Extract<PipeResponse, { type: 'ok' }>, reqId: string): unknown;
|
|
827
|
+
fail?(paneId: string): string;
|
|
828
|
+
ready?: false;
|
|
829
|
+
},
|
|
830
|
+
): Promise<unknown> {
|
|
831
|
+
const t0 = Date.now();
|
|
832
|
+
const trace = (step: string): void => {
|
|
833
|
+
if (process.env.PIHER_PIPE_TRACE === '1') console.error(`piher: pipeAction ${step} +${Date.now() - t0}ms`);
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
if (opts.ready !== false) {
|
|
837
|
+
// A just-spawned worker is still booting: its prompt must reach it before any follow-up.
|
|
838
|
+
const boot = boots.get(entry.gen.paneId);
|
|
839
|
+
if (boot) {
|
|
840
|
+
await boot;
|
|
841
|
+
trace('boot');
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const payload = opts.build();
|
|
845
|
+
let res: PipeResponse;
|
|
846
|
+
try {
|
|
847
|
+
// The pipe IS the liveness proof: a worker that answers it is alive, so a healthy `send` is
|
|
848
|
+
// one round trip with no liveness RPC and no terminal read in front of it.
|
|
849
|
+
res = await pipeRequestTo(entry.cwd, entry.gen.paneId, payload);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
if (opts.ready === false) throw err;
|
|
852
|
+
// A pipe that did not answer may mean the pane is gone: revive it, and only a pane made right
|
|
853
|
+
// now is worth waiting for readiness. A pane that was already alive is not "starting": it has
|
|
854
|
+
// no listener, and spending the 90 s readiness budget on it turns a dead channel into a
|
|
855
|
+
// timeout that reads like a slow boot.
|
|
856
|
+
const revived = await ensureLive(entry);
|
|
857
|
+
trace(revived ? 'recovered-liveness' : 'alive-no-pipe');
|
|
858
|
+
if (revived) {
|
|
859
|
+
const ready = await waitSubReady(entry.cwd, entry.gen.paneId);
|
|
860
|
+
trace('recovered-ready');
|
|
861
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
res = await pipeRequestTo(entry.cwd, entry.gen.paneId, payload);
|
|
865
|
+
} catch (retryErr) {
|
|
866
|
+
throw new Error(await pipeUnreachableText(entry, retryErr));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
trace('delivered');
|
|
870
|
+
if (res.type !== 'ok') {
|
|
871
|
+
throw new Error(`pipe ${payload.type} rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
872
|
+
}
|
|
873
|
+
return opts.ok(res, payload.id);
|
|
874
|
+
} catch (err) {
|
|
875
|
+
const what = opts.fail?.(entry.gen.paneId) ?? `failed to reach subagent ${entry.gen.paneId}`;
|
|
876
|
+
return toolError(`Error: ${what}: ${(err as Error).message}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Why a live pane did not take the request: a ping separates "nothing listens" (its extension
|
|
881
|
+
* host is not running, so the message was lost) from "listens but did not answer" (busy or
|
|
882
|
+
* rejecting). Both are honest; "retry is safe" is only true for the second. */
|
|
883
|
+
async function pipeUnreachableText(entry: TaskEntry, err: unknown): Promise<string> {
|
|
884
|
+
const path = pipePathFor(pipeNameFor(entry.cwd, entry.gen.paneId));
|
|
885
|
+
let listening = false;
|
|
886
|
+
try {
|
|
887
|
+
listening = (await pipeRequestTo(entry.cwd, entry.gen.paneId, { type: 'ping', id: `ping-${Date.now()}` }, 2_000)).type === 'ok';
|
|
888
|
+
} catch {
|
|
889
|
+
listening = false;
|
|
890
|
+
}
|
|
891
|
+
const head = `subagent pane ${entry.gen.paneId} is alive but its message channel is dead (${path})`;
|
|
892
|
+
const detail = String((err as Error)?.message ?? err);
|
|
893
|
+
return listening
|
|
894
|
+
? `${head}: it answers a ping but did not take this request (${detail}). Nothing was confirmed delivered — check the pane before resending.`
|
|
895
|
+
: `${head}: nothing answers there, so the worker's extension host is not running and NOTHING was delivered (${detail}). action "resume" with taskId ${entry.taskId} restarts it in a new pane; action "close" retires the row.`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Follow_up goes through the extension pipe with steering so supplemental instructions reach a
|
|
899
|
+
* long-running worker within seconds during a tool-call gap; a plain queue waits for the run. */
|
|
900
|
+
function executeSubagentSend(params: ToolParams, toolCtx: unknown) {
|
|
901
|
+
const { entry, cwd, foreign } = targetOf(params, toolCtx);
|
|
902
|
+
// A closed worker or a spent one-shot is not a conversation partner any more.
|
|
903
|
+
const spent = spentText(entry);
|
|
904
|
+
if (spent !== null) return toolError(spent);
|
|
905
|
+
// Another session's live task resolves so it can be closed, not steered: steering would
|
|
906
|
+
// write a generation this session does not own (S7).
|
|
907
|
+
if (foreign) return toolError(foreignRefusal(entry.taskId, 'send'));
|
|
908
|
+
return pipeAction(entry, {
|
|
909
|
+
build: () => ({
|
|
910
|
+
type: 'follow_up',
|
|
911
|
+
id: `fu-${Date.now()}`,
|
|
912
|
+
text: String(params?.message ?? ''),
|
|
913
|
+
// The reply pipe is the MASTER's (bound at session_start with this session's cwd);
|
|
914
|
+
// entry.cwd would name a dead pipe for cross-repo workers.
|
|
915
|
+
from: pipeNameFor(cwd, env?.terminalId ?? ''),
|
|
916
|
+
steer: true,
|
|
917
|
+
}),
|
|
918
|
+
ok: (_res, reqId) => {
|
|
919
|
+
// A follow_up re-arms the row: keeping the old consume time would read as finished while
|
|
920
|
+
// a turn is in flight. Roll it back if no poller takes over.
|
|
921
|
+
const prevConsumedAt = entry.gen.consumedAt;
|
|
922
|
+
entry.gen.consumedAt = null;
|
|
923
|
+
writeRecord(entry);
|
|
924
|
+
try {
|
|
925
|
+
lastRequestIdByPane.set(entry.gen.paneId, reqId);
|
|
926
|
+
startPoller(entry.gen.paneId, entry.cwd, Date.now(), entry.description, reqId);
|
|
927
|
+
} catch (inner) {
|
|
928
|
+
entry.gen.consumedAt = prevConsumedAt;
|
|
929
|
+
writeRecord(entry);
|
|
930
|
+
throw inner;
|
|
931
|
+
}
|
|
932
|
+
return { content: [{ type: 'text', text: `Message sent to subagent ${taskRef(entry.gen)}.` }], details: { paneId: entry.gen.paneId } };
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Re-send the prompt this task was spawned with. An upstream failure (a provider outage, a dropped
|
|
938
|
+
* stream) says nothing about the task, and rewriting the brief costs the master real tokens: the
|
|
939
|
+
* stored prompt makes the retry free. A closed task or spent one-shot starts a new task with it —
|
|
940
|
+
* there is no live turn to send into. */
|
|
941
|
+
async function executeSubagentRetry(params: ToolParams, toolCtx: unknown) {
|
|
942
|
+
const { entry, foreign } = targetOf(params, toolCtx);
|
|
943
|
+
const spawnNew = (brief: string) => executeSubagentSpawn({
|
|
944
|
+
prompt: brief,
|
|
945
|
+
description: entry.description,
|
|
946
|
+
cwd: entry.cwd,
|
|
947
|
+
...(entry.kind !== 'task' ? { role: entry.kind } : {}),
|
|
948
|
+
...(entry.worktree ? { worktree: entry.worktree.branch } : {}),
|
|
949
|
+
}, toolCtx);
|
|
950
|
+
const needBrief = (): string => {
|
|
951
|
+
const brief = readTaskPrompt(entry.taskId);
|
|
952
|
+
if (brief === null) {
|
|
953
|
+
throw new ToolError(
|
|
954
|
+
`Error: no stored prompt for task ${entry.taskId} — piher keeps the brief `
|
|
955
|
+
+ 'of a spawn it made, and this task has none (a resumed session, or a row from '
|
|
956
|
+
+ 'another checkout). Send the task again with action "send".',
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
return brief;
|
|
960
|
+
};
|
|
961
|
+
// S2, no session qualifier: a closed task or a spent one-shot starts a NEW task. The new task
|
|
962
|
+
// is this session's own — new taskId, own view and record — so a foreign closed row retries
|
|
963
|
+
// the same way, and the old row is untouched.
|
|
964
|
+
if (spentText(entry) !== null) return spawnNew(needBrief());
|
|
965
|
+
// An actionable foreign row refuses: this session may not steer another session's live
|
|
966
|
+
// conversation (S7).
|
|
967
|
+
if (foreign) return toolError(foreignRefusal(entry.taskId, 'retry'));
|
|
968
|
+
return executeSubagentSend({ ...params, message: needBrief() }, toolCtx);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function executeSubagentInterrupt(params: ToolParams, toolCtx: unknown) {
|
|
972
|
+
const { entry } = targetOf(params, toolCtx);
|
|
973
|
+
// Interrupt stops the current turn and nothing else. A closed task is final; otherwise the
|
|
974
|
+
// probe decides — a worker with no turn in flight has nothing to stop, and naming `close`
|
|
975
|
+
// points at the natural next step instead of a wait. A probe that fails never refuses: the
|
|
976
|
+
// pipe attempt itself is the honest test.
|
|
977
|
+
// No ownership refusal here: `close` refuses a worker with a turn in flight and points at
|
|
978
|
+
// `interrupt`, so refusing `interrupt` across sessions would make another session's stuck
|
|
979
|
+
// worker impossible to end. Interrupting writes no document and adopts nothing.
|
|
980
|
+
if (isClosed(entry)) {
|
|
981
|
+
return toolError(
|
|
982
|
+
`Error: subagent ${taskRef(entry.gen)} is closed — its pane and tab are gone, so there is `
|
|
983
|
+
+ 'no turn to interrupt. '
|
|
984
|
+
+ `Close is final for task ${entry.taskId} — start a new subagent for further work.`,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
let probe: AliveProbe;
|
|
988
|
+
try {
|
|
989
|
+
probe = await probeAlive(entry.gen.paneId, entry.cwd);
|
|
990
|
+
} catch {
|
|
991
|
+
probe = { paneExists: true, agentStatus: null, lastActivityMs: null };
|
|
992
|
+
}
|
|
993
|
+
noteObservation(entry.taskId, probe);
|
|
994
|
+
const inFlight = probe.paneExists
|
|
995
|
+
&& (probe.agentStatus === 'working' || probe.agentStatus === 'blocked');
|
|
996
|
+
if (!inFlight) {
|
|
997
|
+
const observed = !probe.paneExists
|
|
998
|
+
? 'its pane is gone'
|
|
999
|
+
: `its agent reports ${probe.agentStatus ?? 'no turn'}`;
|
|
1000
|
+
return toolError(
|
|
1001
|
+
`Error: subagent ${taskRef(entry.gen)} has no running turn (${observed}) — interrupt stops `
|
|
1002
|
+
+ 'the current turn only, and there is nothing to stop. '
|
|
1003
|
+
+ 'Close it with action "close" if its work is accepted, send it a follow-up '
|
|
1004
|
+
+ 'with action "send" if its pane is still listed, or revive it with action "resume" '
|
|
1005
|
+
+ `and taskId ${entry.taskId}.`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
return pipeAction(entry, {
|
|
1009
|
+
ready: false,
|
|
1010
|
+
build: () => ({ type: 'interrupt', id: `int-${Date.now()}` }),
|
|
1011
|
+
ok: () => {
|
|
1012
|
+
// No settlement notice for an interrupted turn: the requester already knows.
|
|
1013
|
+
const lastId = lastRequestIdByPane.get(entry.gen.paneId);
|
|
1014
|
+
if (lastId) d.claimSettleNotice(`${entry.gen.paneId}:${lastId}`);
|
|
1015
|
+
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${taskRef(entry.gen)} (fire-and-return).` }], details: { paneId: entry.gen.paneId } };
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** S53: reclaim a worker that already finished. `close` is not `cancel`: a worker that is still
|
|
1021
|
+
* running is refused with a pointer at `interrupt`, because a task must not vanish without a report. */
|
|
1022
|
+
async function executeSubagentClose(params: ToolParams, toolCtx: unknown) {
|
|
1023
|
+
const { entry } = targetOf(params, toolCtx);
|
|
1024
|
+
if (isClosed(entry)) {
|
|
1025
|
+
// Memory can be closed while the record still holds no such decision (a close that happened
|
|
1026
|
+
// elsewhere, or a sweep). Record the decision here too, so the record says who ended it
|
|
1027
|
+
// instead of trailing off at `consumed`.
|
|
1028
|
+
const latest = readTaskRecord(entry.cwd, entry.taskId);
|
|
1029
|
+
if (!latest || !isClosed(latest)) writeCloseRecord(entry.cwd, entry);
|
|
1030
|
+
const finality = `Error: task ${entry.taskId} was closed by the master; close is final, `
|
|
1031
|
+
+ 'so there is nothing to resume or send to. Start a new subagent for further work.';
|
|
1032
|
+
return {
|
|
1033
|
+
content: [{ type: 'text', text: finality }],
|
|
1034
|
+
details: { paneId: entry.gen.paneId, taskId: entry.taskId, alreadyClosed: true },
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// S14: a task must not disappear while someone could still report it. The probe decides for
|
|
1038
|
+
// every row now that no stored status says otherwise: only a pane with a turn in flight is
|
|
1039
|
+
// protected, and an unknown status is treated as in flight because core has not said otherwise.
|
|
1040
|
+
// A failed probe must not authorise the removal of a task.
|
|
1041
|
+
let live = true;
|
|
1042
|
+
let finished = false;
|
|
1043
|
+
try {
|
|
1044
|
+
const probe = await probeAlive(entry.gen.paneId, entry.cwd);
|
|
1045
|
+
noteObservation(entry.taskId, probe);
|
|
1046
|
+
live = probe.paneExists;
|
|
1047
|
+
finished = probe.agentStatus === 'idle'
|
|
1048
|
+
|| probe.agentStatus === 'done'
|
|
1049
|
+
|| probe.agentStatus === 'error';
|
|
1050
|
+
} catch {
|
|
1051
|
+
live = true;
|
|
1052
|
+
}
|
|
1053
|
+
if (live && !finished) {
|
|
1054
|
+
return toolError(
|
|
1055
|
+
`Error: subagent ${taskRef(entry.gen)} is still running — close only reclaims `
|
|
1056
|
+
+ 'a worker that already finished, and a task must not disappear without a report. '
|
|
1057
|
+
+ 'Use action "interrupt" to stop its current turn (the pane and its session stay), '
|
|
1058
|
+
+ 'then close it once it has finished.',
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
// Nothing to reclaim: fall through and record the decision, so `list` stops offering it.
|
|
1062
|
+
if (!client.available) return toolError(`subagent: ${CORE_REQUIRED}`);
|
|
1063
|
+
// S71: close is terminal for what piher created. The checkout goes first, because a dirty one is
|
|
1064
|
+
// a refusal that must leave the worker reachable. The record is resolved through the repository
|
|
1065
|
+
// root before the release, since the checkout it records is about to disappear.
|
|
1066
|
+
const toolCwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
1067
|
+
// The checkout a job/worker named can be gone (a removed worktree), and `mainWorktreeRoot` cannot
|
|
1068
|
+
// resolve a repository from a path that no longer exists: the decision would be written nowhere.
|
|
1069
|
+
const recordCwd = mainWorktreeRoot(entry.cwd) ?? mainWorktreeRoot(toolCwd) ?? toolCwd;
|
|
1070
|
+
let checkoutLine = '';
|
|
1071
|
+
if (entry.worktree) {
|
|
1072
|
+
const released = await releaseCheckout(checkout, entry.worktree, checkoutSpace);
|
|
1073
|
+
if (!released.ok) return toolError(released.text);
|
|
1074
|
+
checkoutLine = ` ${releasedCheckoutLine(entry.worktree, released.removed)};`;
|
|
1075
|
+
}
|
|
1076
|
+
await retirePane(client, entry.gen.paneId);
|
|
1077
|
+
// The record survives the pane so the decision is durable: `list` and the refusals answer from it.
|
|
1078
|
+
writeCloseRecord(recordCwd, entry);
|
|
1079
|
+
const doneText = `Subagent ${taskRef(entry.gen)} closed — pane and tab gone;${checkoutLine} `
|
|
1080
|
+
+ 'close is final — start a new subagent for further work.';
|
|
1081
|
+
return {
|
|
1082
|
+
content: [{ type: 'text', text: doneText }],
|
|
1083
|
+
details: { paneId: entry.gen.paneId, taskId: entry.taskId },
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** The session's actionable tasks first, then another session's live ones, marked (S13/S23).
|
|
1088
|
+
* A closed or spent row is never printed, and a foreign record whose pane is gone is not
|
|
1089
|
+
* printed at all. Every row shown is probed — the probe feeds the state text (S26). */
|
|
1090
|
+
async function executeSubagentList(toolCtx?: unknown) {
|
|
1091
|
+
const rows = [...tasks.values()];
|
|
1092
|
+
// Every own row is re-read before it is shown: another session may have closed it since the
|
|
1093
|
+
// restore, and `list` must drop it on sight, not on the next reload (S24).
|
|
1094
|
+
for (const row of rows) refreshFromRecord(row);
|
|
1095
|
+
const visible = rows.filter(isActionable);
|
|
1096
|
+
const cwd = (toolCtx as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1097
|
+
const foreign = listTaskRecords(cwd)
|
|
1098
|
+
.filter((row) => isActionable(row) && !viewIds.includes(row.taskId) && !tasks.has(row.taskId))
|
|
1099
|
+
.sort((a, b) => a.createdAt - b.createdAt || (a.taskId < b.taskId ? -1 : 1));
|
|
1100
|
+
const probes = new Map<string, AliveProbe>();
|
|
1101
|
+
const probeRow = async (row: TaskRow, note: boolean): Promise<void> => {
|
|
1102
|
+
try {
|
|
1103
|
+
const probe = await probeAlive(row.gen.paneId, row.cwd);
|
|
1104
|
+
probes.set(row.taskId, probe);
|
|
1105
|
+
if (note) noteObservation(row.taskId, probe);
|
|
1106
|
+
} catch { /* best effort: the cache below covers a failed probe */ }
|
|
1107
|
+
};
|
|
1108
|
+
await Promise.all([
|
|
1109
|
+
...visible.map((row) => probeRow(row, true)),
|
|
1110
|
+
...foreign.map((row) => probeRow(row, false)),
|
|
1111
|
+
]);
|
|
1112
|
+
const lines: string[] = [];
|
|
1113
|
+
for (const row of visible) {
|
|
1114
|
+
const cached = probes.get(row.taskId) ?? observations.get(row.taskId) ?? null;
|
|
1115
|
+
const head = `${taskRef(row.gen)} [${describeTaskState(row, cached)}]`;
|
|
1116
|
+
lines.push(`${head} (${row.kind} · ${row.lifecycle}) ${row.description}`);
|
|
1117
|
+
}
|
|
1118
|
+
for (const row of foreign) {
|
|
1119
|
+
const probe = probes.get(row.taskId);
|
|
1120
|
+
if (!probe?.paneExists) continue;
|
|
1121
|
+
const head = `${taskRef(row.gen)} [${describeTaskState(row, probe)}]`;
|
|
1122
|
+
lines.push(`[otra sesión] ${head} (${row.kind} · ${row.lifecycle}) ${row.description}`);
|
|
1123
|
+
}
|
|
1124
|
+
if (lines.length === 0) {
|
|
1125
|
+
const text = 'No subagents started (from this session view).';
|
|
1126
|
+
return { content: [{ type: 'text', text }], details: {} };
|
|
1127
|
+
}
|
|
1128
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const executeSubagentSpawn = createSpawnAction({
|
|
1132
|
+
client,
|
|
1133
|
+
env,
|
|
1134
|
+
subSemaphore,
|
|
1135
|
+
tasks,
|
|
1136
|
+
panes,
|
|
1137
|
+
writeRecord,
|
|
1138
|
+
appendView,
|
|
1139
|
+
dropTask,
|
|
1140
|
+
lastRequestIdByPane,
|
|
1141
|
+
boots,
|
|
1142
|
+
notifySpawnFailed: injectNotice,
|
|
1143
|
+
git,
|
|
1144
|
+
checkout,
|
|
1145
|
+
checkoutSpace,
|
|
1146
|
+
session,
|
|
1147
|
+
spawn,
|
|
1148
|
+
poller,
|
|
1149
|
+
getSessionId: d.getSessionId,
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
const outputActionDeps = {
|
|
1153
|
+
client,
|
|
1154
|
+
resolveEntry: (rawId: string, cwd?: string) => {
|
|
1155
|
+
const resolved = resolveSubEntry(rawId, cwd);
|
|
1156
|
+
if (!('error' in resolved) && !resolved.foreign) refreshFromRecord(resolved.entry);
|
|
1157
|
+
return resolved;
|
|
1158
|
+
},
|
|
1159
|
+
outputCursors,
|
|
1160
|
+
nextRevision: (paneId: string): number => {
|
|
1161
|
+
const next = (outputRevisions.get(paneId) ?? 0) + 1;
|
|
1162
|
+
outputRevisions.set(paneId, next);
|
|
1163
|
+
return next;
|
|
1164
|
+
},
|
|
1165
|
+
// Transcript fallback: small worker panes show only the opaque status overlay, so the pane delta can never
|
|
1166
|
+
// carry the final report. No preferred file — start from the worker's `piher.session` label, not a stale registry value.
|
|
1167
|
+
readFinalReport: async (paneId: string, entryCwd: string) =>
|
|
1168
|
+
(await session.subSessionState(paneId, entryCwd, 0)).text,
|
|
1169
|
+
writeRecord,
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const ACTIONS: Record<string, (params: ToolParams, toolCtx: unknown, onUpdate?: (u: unknown) => void) => Promise<unknown>> = {
|
|
1173
|
+
spawn: (params, toolCtx) => executeSubagentSpawn(params, toolCtx),
|
|
1174
|
+
resume: (params, toolCtx) => executeSubagentResume(params, toolCtx),
|
|
1175
|
+
list: (_params, toolCtx) => executeSubagentList(toolCtx),
|
|
1176
|
+
send: (params, toolCtx) => executeSubagentSend(params, toolCtx),
|
|
1177
|
+
retry: (params, toolCtx) => executeSubagentRetry(params, toolCtx),
|
|
1178
|
+
interrupt: (params, toolCtx) => executeSubagentInterrupt(params, toolCtx),
|
|
1179
|
+
output: (params, toolCtx) => executeSubagentOutput(params, toolCtx, outputActionDeps),
|
|
1180
|
+
close: (params, toolCtx) => executeSubagentClose(params, toolCtx),
|
|
1181
|
+
help: async (_params, toolCtx) => {
|
|
1182
|
+
const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
1183
|
+
return { content: [{ type: 'text' as const, text: `${SUBAGENT_MANUAL}\n\n${roleCatalog(cwd)}` }], details: {} };
|
|
1184
|
+
},
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
scoped.registerTool({
|
|
1188
|
+
name: 'subagent',
|
|
1189
|
+
label: 'Subagent',
|
|
1190
|
+
// B4: the model picks tools from the system prompt's snippet/guideline surface; delegation
|
|
1191
|
+
// decisions are made here, so state them rather than hoping for recall.
|
|
1192
|
+
promptSnippet: 'subagent: delegate a self-contained task to a pi worker pane (spawn/send/output/interrupt/resume/list/close/help).',
|
|
1193
|
+
promptGuidelines: [
|
|
1194
|
+
'Use subagent for self-contained work whose result is what matters: output that would flood this context, long builds/test runs, or independent parts of a bigger change that can run in parallel.',
|
|
1195
|
+
'The worker does not see this conversation: put paths, acceptance criteria, the test command and what to report back into the prompt.',
|
|
1196
|
+
'Pass agentId, taskId, message and max_chars as top-level arguments, not inside a parameters object.',
|
|
1197
|
+
],
|
|
1198
|
+
description: SUBAGENT_DESCRIPTION,
|
|
1199
|
+
parameters: Type.Object({
|
|
1200
|
+
action: Type.Optional(Type.Union([
|
|
1201
|
+
Type.Literal('spawn'),
|
|
1202
|
+
Type.Literal('resume'),
|
|
1203
|
+
Type.Literal('list'),
|
|
1204
|
+
Type.Literal('send'),
|
|
1205
|
+
Type.Literal('retry'),
|
|
1206
|
+
Type.Literal('interrupt'),
|
|
1207
|
+
Type.Literal('output'),
|
|
1208
|
+
Type.Literal('close'),
|
|
1209
|
+
Type.Literal('help'),
|
|
1210
|
+
], { description: 'Default spawn' })),
|
|
1211
|
+
description: Type.Optional(Type.String({ description: '[spawn] Pane label' })),
|
|
1212
|
+
prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task' })),
|
|
1213
|
+
cwd: Type.Optional(Type.String({ description: '[spawn] Existing directory or worktree to run in (absolute or workspace-relative)' })),
|
|
1214
|
+
worktree: Type.Optional(Type.String({ description: '[spawn] Name of a checkout piher creates for the worker (branch of the same name); mutually exclusive with cwd' })),
|
|
1215
|
+
role: Type.Optional(Type.String({ description: '[spawn] Role profile name; action help lists the profiles available here (description, tools, lifecycle). Unknown names are display labels only' })),
|
|
1216
|
+
tab: Type.Optional(Type.String({ description: '[spawn] Task tab to join or create (default: grouped by worktree)' })),
|
|
1217
|
+
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Extra tools on top of the role baseline' })),
|
|
1218
|
+
taskId: Type.Optional(Type.String({ description: '[resume] Task id of a collected task, as printed by list' })),
|
|
1219
|
+
agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|close] Slug, pane id, or slug@paneId as printed by spawn/list' })),
|
|
1220
|
+
message: Type.Optional(Type.String({ description: '[send] Follow-up message' })),
|
|
1221
|
+
max_chars: Type.Optional(Type.Integer({ description: '[output] Output cap (default 6000, 100-16000)' })),
|
|
1222
|
+
full: Type.Optional(Type.Boolean({ description: '[output] Return the whole pane buffer instead of the latest lines (default false)' })),
|
|
1223
|
+
}),
|
|
1224
|
+
outputSchema: SUBAGENT_OUTPUT_SCHEMA,
|
|
1225
|
+
// B2: `action` is optional in the schema; normalize it so the validated arguments and the
|
|
1226
|
+
// code path never disagree and `spawn` is explicit in logs.
|
|
1227
|
+
prepareArguments: (args: unknown) => {
|
|
1228
|
+
const rec = flattenToolParams(args && typeof args === 'object' ? args as Record<string, unknown> : {}) ?? {};
|
|
1229
|
+
const hasAction = typeof rec.action === 'string' && rec.action.trim() !== '';
|
|
1230
|
+
return hasAction ? rec : { ...rec, action: 'spawn' };
|
|
1231
|
+
},
|
|
1232
|
+
async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, toolCtx: unknown) {
|
|
1233
|
+
void toolCallId;
|
|
1234
|
+
void signal;
|
|
1235
|
+
const flat = flattenToolParams(params);
|
|
1236
|
+
const action = typeof flat?.action === 'string' && flat.action.trim() ? flat.action.trim() : 'spawn';
|
|
1237
|
+
const handler = ACTIONS[action];
|
|
1238
|
+
if (!handler) {
|
|
1239
|
+
return toolError(`Error: unknown action "${action}" (valid: ${Object.keys(ACTIONS).join(', ')})`);
|
|
1240
|
+
}
|
|
1241
|
+
return attachSubagentStructuredContent(action, await handler(flat, toolCtx, onUpdate));
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
}
|