@ferris1225/pi-subagents 4.3.3 → 4.3.5

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +61 -67
  3. package/agents/artisan.md +0 -1
  4. package/agents/steward.md +1 -2
  5. package/{src/index.ts → index.ts} +19 -19
  6. package/package.json +4 -3
  7. package/src/{config.ts → configuration/config.ts} +19 -24
  8. package/src/configuration/setup.ts +375 -0
  9. package/src/configuration/ui.ts +245 -0
  10. package/src/{agents.ts → delegation/agents.ts} +3 -3
  11. package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
  12. package/src/{prompt.ts → delegation/prompt.ts} +3 -8
  13. package/src/{background.ts → execution/background.ts} +3 -6
  14. package/src/execution/rpc-control.ts +200 -0
  15. package/src/{rpc-run.ts → execution/rpc-run.ts} +11 -199
  16. package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
  17. package/src/{spawn.ts → execution/spawn.ts} +10 -8
  18. package/src/isolation/git-command.ts +147 -0
  19. package/src/{recovery.ts → isolation/recovery.ts} +1 -1
  20. package/src/{worktree.ts → isolation/worktree.ts} +10 -147
  21. package/src/{completion.ts → lifecycle/completion.ts} +2 -2
  22. package/src/{durable.ts → lifecycle/durable.ts} +3 -3
  23. package/src/{runtime.ts → lifecycle/runtime.ts} +7 -7
  24. package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
  25. package/src/lifecycle/thread-restore.ts +250 -0
  26. package/src/lifecycle/thread-shared.ts +269 -0
  27. package/src/{tools.ts → lifecycle/tools.ts} +8 -8
  28. package/src/{announcements.ts → presentation/announcements.ts} +4 -4
  29. package/src/{format.ts → presentation/format.ts} +3 -3
  30. package/src/{monitor.ts → presentation/monitor.ts} +2 -2
  31. package/src/{widget.ts → presentation/widget.ts} +1 -1
  32. package/agents/sentinel.md +0 -16
  33. package/src/setup.ts +0 -344
  34. package/src/ui.ts +0 -160
  35. /package/src/{models.ts → configuration/models.ts} +0 -0
  36. /package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +0 -0
  37. /package/src/{status.ts → presentation/status.ts} +0 -0
@@ -0,0 +1,250 @@
1
+ /** Durable restoration and startup hygiene for logical sub-agent threads. */
2
+
3
+ import { rm } from "node:fs/promises";
4
+ import { type ThinkingLevel } from "../configuration/config.ts";
5
+ import {
6
+ isCurrentBoot,
7
+ pruneStaleProjectRoots,
8
+ pruneThreadRecords,
9
+ readThreadRecords,
10
+ referencedDurablePaths,
11
+ removeThreadRecord,
12
+ restoredResultFromSummary,
13
+ type ThreadRecord,
14
+ } from "./durable.ts";
15
+ import { failedStartResult } from "../presentation/format.ts";
16
+ import { monitor } from "../presentation/monitor.ts";
17
+ import { emptyUsage } from "../execution/rpc-control.ts";
18
+ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
19
+ import {
20
+ getSubagentsRoot,
21
+ RpcRunControl,
22
+ sessionExists,
23
+ sweepProjectResultArtifacts,
24
+ type SingleResult,
25
+ } from "../execution/spawn.ts";
26
+ import { isProcessAlive, killProcessTree, sweepProjectDurableDirs, sweepProjectTempDirs } from "../isolation/temp-hygiene.ts";
27
+ import {
28
+ isPathInside,
29
+ restoreWorktreeIsolation,
30
+ worktreeGroupId,
31
+ type WorktreeIsolation,
32
+ } from "../isolation/worktree.ts";
33
+ import { installThreadLifecycle } from "./thread-lifecycle.ts";
34
+
35
+ /** Dropped restored record: no session means no context to resume, so its
36
+ * artifacts go away with the record. */
37
+ async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
38
+ if (record.sessionDir) {
39
+ await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
40
+ runtime.sessionDirs.delete(record.sessionDir);
41
+ }
42
+ if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
43
+ const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
44
+ await worktree?.discard().catch(() => undefined);
45
+ }
46
+ await removeThreadRecord(runtime.configPath, record.runId, record.cwd).catch(() => undefined);
47
+ }
48
+
49
+ function createRestoredThread(
50
+ runtime: SubagentRuntime,
51
+ record: ThreadRecord,
52
+ worktree: WorktreeIsolation | undefined,
53
+ state: ThreadState,
54
+ ): SubagentThread {
55
+ const thread: SubagentThread = {
56
+ id: record.runId,
57
+ generation: record.generation,
58
+ agentName: record.agentName,
59
+ task: record.task,
60
+ cwd: record.cwd,
61
+ executionCwd: record.executionCwd,
62
+ ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
63
+ isolation: record.isolation,
64
+ worktree,
65
+ state,
66
+ control: new RpcRunControl(record.task, record.generation),
67
+ generationCompletion: Promise.resolve(),
68
+ lifecycleVersion: 0,
69
+ elapsedMs: record.elapsedMs,
70
+ sessionId: record.sessionId,
71
+ sessionDir: record.sessionDir,
72
+ lastResult: restoredResultFromSummary(record),
73
+ resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
74
+ finalizeIsolation: async () => undefined,
75
+ };
76
+ installThreadLifecycle(thread, {
77
+ runtime,
78
+ startBackground: (...args) => {
79
+ const dispatcher = runtime.dispatcher;
80
+ if (!dispatcher) {
81
+ return Promise.resolve(failedStartResult(
82
+ record.agentName,
83
+ record.task,
84
+ `Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
85
+ ));
86
+ }
87
+ return dispatcher(...args);
88
+ },
89
+ });
90
+ return thread;
91
+ }
92
+
93
+ /** Rebuild interrupted (parked) threads from the durable manifest after a
94
+ * reload or restart. Orphaned children recorded by the previous process are
95
+ * killed first; records whose retained session vanished — and settled records
96
+ * left by older versions, which hold no work worth resuming — drop out with
97
+ * their artifacts. Returns the restored run ids. */
98
+ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
99
+ const records = await readThreadRecords(runtime.configPath);
100
+ const restoredIds: number[] = [];
101
+ for (const record of records) {
102
+ if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
103
+ if (record.state !== "parked") {
104
+ await discardRestoredRecord(runtime, record);
105
+ continue;
106
+ }
107
+ // A child orphaned by reload/crash may still hold the retained session.
108
+ // The on-disk session checkpoint is what survives; kill the writer — but
109
+ // only while the recorded pids are still ours. Across a reboot the same
110
+ // numbers belong to unrelated processes.
111
+ if (isCurrentBoot(record)) {
112
+ for (const pid of record.childPids) {
113
+ if (isProcessAlive(pid)) killProcessTree(pid);
114
+ }
115
+ }
116
+ const sessionValid =
117
+ record.sessionId !== undefined &&
118
+ record.sessionDir !== undefined &&
119
+ sessionExists(record.sessionDir, record.sessionId);
120
+ if (!sessionValid) {
121
+ await discardRestoredRecord(runtime, record);
122
+ continue;
123
+ }
124
+ const worktree = record.worktree
125
+ ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
126
+ : undefined;
127
+ const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
128
+ const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
129
+ runtime.threads.set(record.runId, thread);
130
+ runtime.sessionDirs.add(record.sessionDir!);
131
+ if (restorationFailed) {
132
+ const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The retained session and durable record were kept, but this thread cannot be resumed.`;
133
+ thread.resumeUnavailableReason = reason;
134
+ thread.restorationRecord = record;
135
+ const previous = restoredResultFromSummary(record);
136
+ const failed: SingleResult = {
137
+ agent: record.agentName,
138
+ task: record.task,
139
+ exitCode: 1,
140
+ messages: previous?.messages ?? [],
141
+ stderr: reason,
142
+ usage: previous?.usage ?? emptyUsage(),
143
+ model: previous?.model,
144
+ thinking: previous?.thinking,
145
+ stopReason: "error",
146
+ errorMessage: reason,
147
+ dispatchFailed: true,
148
+ sessionId: record.sessionId,
149
+ sessionDir: record.sessionDir,
150
+ projectCwd: record.cwd,
151
+ runId: record.runId,
152
+ isolation: "worktree",
153
+ integrationStatus: "retained",
154
+ };
155
+ thread.lastResult = failed;
156
+ runtime.registerRunResult(record.runId, failed);
157
+ monitor.restoreRun({
158
+ id: record.runId,
159
+ agent: record.agentName,
160
+ task: record.task,
161
+ status: "failed",
162
+ elapsedMs: record.elapsedMs,
163
+ isolation: "worktree",
164
+ integrationStatus: "retained",
165
+ });
166
+ runtime.claimRunDelivery(record.runId, "background");
167
+ runtime.publishRunCompletion(record.runId, {
168
+ agent: record.agentName,
169
+ block: `### Subagent restoration failed: #${record.runId} ${record.agentName}\n\n${reason}`,
170
+ usage: failed.usage,
171
+ }, true);
172
+ continue;
173
+ }
174
+ monitor.restoreRun({
175
+ id: record.runId,
176
+ agent: record.agentName,
177
+ task: record.task,
178
+ status: "parked",
179
+ elapsedMs: record.elapsedMs,
180
+ isolation: record.isolation,
181
+ ...(record.worktree
182
+ ? {
183
+ integrationStatus: record.worktree.state === "active"
184
+ ? ("pending" as const)
185
+ : record.worktree.state,
186
+ ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
187
+ }
188
+ : {}),
189
+ });
190
+ restoredIds.push(record.runId);
191
+ }
192
+ return restoredIds;
193
+ }
194
+
195
+ /** Session-start durable bootstrap: restore threads, age out expired records, and
196
+ * sweep leaked temp/state directories. Every stage is best-effort so a broken
197
+ * manifest never blocks the session.
198
+ *
199
+ * Restore is published on the runtime as `durableRestore` before this returns,
200
+ * so callers that must see restored threads await that pass alone and never the
201
+ * hygiene sweeps behind it. Hygiene still runs after restore: pruning decides
202
+ * what to delete from the records restore has already claimed. */
203
+ export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
204
+ const restore = (async () => {
205
+ try {
206
+ runtime.restoredRunIds = await restoreDurableThreads(runtime);
207
+ } catch {
208
+ /* restore is best-effort */
209
+ }
210
+ })();
211
+ runtime.durableRestore = restore;
212
+ return (async () => {
213
+ await restore;
214
+ try {
215
+ await pruneThreadRecords(runtime.configPath);
216
+ } catch {
217
+ /* retention is best-effort */
218
+ }
219
+ const projectRoots = getSubagentsRoot(runtime.configPath);
220
+ try {
221
+ sweepProjectTempDirs(projectRoots);
222
+ } catch {
223
+ /* temp hygiene is best-effort */
224
+ }
225
+ try {
226
+ // Sessions and worktrees outlive their process on purpose, so only
227
+ // ownership separates state a live pi still resumes from state a crash
228
+ // abandoned. Parked work is claimed by the manifest and always kept.
229
+ const records = await readThreadRecords(runtime.configPath);
230
+ const referenced = [...referencedDurablePaths(records)];
231
+ sweepProjectDurableDirs(projectRoots, {
232
+ keep: (path) => referenced.some((claimed) => isPathInside(path, claimed)),
233
+ });
234
+ } catch {
235
+ /* durable-state hygiene is best-effort */
236
+ }
237
+ try {
238
+ // Result excerpts are bounded on write, which never reaches a project
239
+ // that has stopped producing them.
240
+ sweepProjectResultArtifacts(projectRoots);
241
+ } catch {
242
+ /* result retention is best-effort */
243
+ }
244
+ try {
245
+ await pruneStaleProjectRoots(runtime.configPath);
246
+ } catch {
247
+ /* project-root hygiene is best-effort */
248
+ }
249
+ })();
250
+ }
@@ -0,0 +1,269 @@
1
+ /** Shared contracts and coordination primitives for logical sub-agent threads. */
2
+
3
+ import { realpath } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
7
+ import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
8
+ import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
9
+ import {
10
+ availableModelsInScope,
11
+ currentModelRef,
12
+ findModelByRef,
13
+ modelRef,
14
+ resolveAgentModelRoute,
15
+ resolveThinkingLevel,
16
+ } from "../configuration/models.ts";
17
+ import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
18
+ import { getProjectRoot, type SingleResult } from "../execution/spawn.ts";
19
+ import { resolveRepositoryRoot, type IsolationMode, type WorktreeIsolation } from "../isolation/worktree.ts";
20
+
21
+ /** Control operations must never wait forever on a settling generation: the
22
+ * queue task can legitimately spend minutes in worktree finalization (bounded
23
+ * per-Git-command timeouts) or wait behind the managed repository lane. After
24
+ * this deadline the control path owns the lifecycle synchronously and proceeds
25
+ * while the stuck tail settles silently in the background. */
26
+ export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
27
+
28
+ /** Resolve true when the promise settles, or false after the bounded deadline. */
29
+ export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
30
+ return Promise.race([
31
+ promise.then(() => true, () => true),
32
+ new Promise<boolean>((resolve) => {
33
+ const timer = setTimeout(() => resolve(false), timeoutMs);
34
+ if (typeof timer.unref === "function") timer.unref();
35
+ }),
36
+ ]);
37
+ }
38
+
39
+ const managedRepositoryRootTails = new Map<string, Promise<void>>();
40
+
41
+ async function canonicalManagedRepositoryRoot(cwd: string): Promise<string> {
42
+ try {
43
+ // Repository identity does not depend on HEAD: empty repositories must
44
+ // serialize root and nested cwd requests under the same lane too.
45
+ return await resolveRepositoryRoot(cwd);
46
+ } catch {
47
+ try {
48
+ return await realpath(resolve(cwd));
49
+ } catch {
50
+ return resolve(cwd);
51
+ }
52
+ }
53
+ }
54
+
55
+ /** Run one operation under the canonical original-repository lane.
56
+ *
57
+ * Shared write-capable generations use the abortable overload for their whole
58
+ * run. Isolated generations use the non-abortable overload only for their
59
+ * final worktree apply, so model work remains parallel while the original
60
+ * checkout mutation cannot race a shared writer.
61
+ */
62
+ export async function runInManagedRepositoryLane<T>(
63
+ cwd: string,
64
+ task: () => Promise<T>,
65
+ ): Promise<T>;
66
+ export async function runInManagedRepositoryLane<T>(
67
+ cwd: string,
68
+ task: () => Promise<T>,
69
+ signal: AbortSignal,
70
+ ): Promise<T | undefined>;
71
+ export async function runInManagedRepositoryLane<T>(
72
+ cwd: string,
73
+ task: () => Promise<T>,
74
+ signal?: AbortSignal,
75
+ ): Promise<T | undefined> {
76
+ if (signal?.aborted) return undefined;
77
+ const root = await canonicalManagedRepositoryRoot(cwd);
78
+ const key = process.platform === "win32" ? root.toLowerCase() : root;
79
+ const previous = managedRepositoryRootTails.get(key) ?? Promise.resolve();
80
+ let release!: () => void;
81
+ const gate = new Promise<void>((resolveGate) => {
82
+ release = resolveGate;
83
+ });
84
+ const tail = previous.catch(() => undefined).then(() => gate);
85
+ managedRepositoryRootTails.set(key, tail);
86
+ let onAbort: (() => void) | undefined;
87
+ try {
88
+ if (signal) {
89
+ await Promise.race([
90
+ previous.catch(() => undefined),
91
+ new Promise<void>((resolveAborted) => {
92
+ if (signal.aborted) resolveAborted();
93
+ else {
94
+ onAbort = resolveAborted;
95
+ signal.addEventListener("abort", onAbort, { once: true });
96
+ }
97
+ }),
98
+ ]);
99
+ } else {
100
+ await previous.catch(() => undefined);
101
+ }
102
+ if (signal?.aborted) return undefined;
103
+ return await task();
104
+ } finally {
105
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
106
+ release();
107
+ // An aborted waiter may finish before the prior owner. Keep its chained
108
+ // tail installed until that owner also settles, otherwise a newcomer could
109
+ // observe an empty map and race the still-running workflow.
110
+ void tail.then(() => {
111
+ if (managedRepositoryRootTails.get(key) === tail) managedRepositoryRootTails.delete(key);
112
+ });
113
+ }
114
+ }
115
+
116
+ /** Track resume setup that has claimed a thread but has not yet enqueued
117
+ * its next generation. Shutdown invalidates these claims and waits for cleanup. */
118
+ export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
119
+ let resolvePreflight!: () => void;
120
+ const preflight = new Promise<void>((resolve) => {
121
+ resolvePreflight = resolve;
122
+ });
123
+ runtime.preflightOperations.add(preflight);
124
+ return () => {
125
+ runtime.preflightOperations.delete(preflight);
126
+ resolvePreflight();
127
+ };
128
+ }
129
+
130
+ /** Synchronous CAS used by lifecycle controls across their async preflight. */
131
+ export function ownsResumeReservation(
132
+ runtime: SubagentRuntime,
133
+ thread: SubagentThread,
134
+ reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
135
+ ): boolean {
136
+ return (
137
+ runtime.sessionActive &&
138
+ runtime.threads.get(thread.id) === thread &&
139
+ !thread.retired &&
140
+ thread.lifecycleOperation === "resume" &&
141
+ thread.lifecycleVersion === reservation.version &&
142
+ thread.generation === reservation.generation &&
143
+ thread.sessionId === reservation.sessionId &&
144
+ thread.sessionDir === reservation.sessionDir
145
+ );
146
+ }
147
+
148
+ /** Fire-and-forget durable checkpoint. Parked threads stay resumable across
149
+ * reloads; a settled thread drops its record so the manifest only exists
150
+ * while unfinished work needs it. The live session keeps working when the
151
+ * manifest is unwritable; only cross-reload resume is degraded. */
152
+ export function persistThreadCheckpoint(
153
+ runtime: SubagentRuntime,
154
+ thread: SubagentThread,
155
+ state: "parked" | "completed" | "failed",
156
+ ): void {
157
+ const write = state === "parked"
158
+ ? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
159
+ : removeThreadRecord(runtime.configPath, thread.id, thread.cwd);
160
+ void write.catch(() => undefined);
161
+ }
162
+
163
+ const WORKTREE_ISOLATION_INSTRUCTIONS =
164
+ "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
165
+
166
+ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
167
+ return {
168
+ ...agent,
169
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
170
+ };
171
+ }
172
+
173
+ /** Only write-capable agents can run in an isolated worktree. */
174
+ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
175
+ return isWriteCapableAgent(agent);
176
+ }
177
+
178
+ export interface DispatchEnvironment {
179
+ ctx: ExtensionContext;
180
+ config: SubagentsConfig;
181
+ agents: AgentConfig[];
182
+ }
183
+
184
+ export interface SessionSeed {
185
+ sessionId?: string;
186
+ sessionDir?: string;
187
+ prompt?: string;
188
+ worktree?: WorktreeIsolation;
189
+ }
190
+
191
+ export interface ResumeReservation {
192
+ version: number;
193
+ generation: number;
194
+ sessionId?: string;
195
+ sessionDir?: string;
196
+ }
197
+
198
+ /** The dispatcher's full internal entry point; the public tool surface only
199
+ * uses the first four parameters. */
200
+ export interface StartBackgroundOptions {
201
+ /** Resume path only: the thread whose retained context continues. */
202
+ existingThread?: SubagentThread;
203
+ appendedObjectiveOnResume?: boolean;
204
+ environment?: DispatchEnvironment;
205
+ seed?: SessionSeed;
206
+ resumeReservation?: ResumeReservation;
207
+ /** Chosen by the tool call before the queue can start a fast child. */
208
+ deliveryRoute?: "background" | "await";
209
+ }
210
+
211
+ export type StartBackgroundInternal = (
212
+ agentName: string,
213
+ task: string,
214
+ cwd: string | undefined,
215
+ isolation?: IsolationMode,
216
+ options?: StartBackgroundOptions,
217
+ ) => Promise<SingleResult>;
218
+
219
+ export interface ThreadLifecycleDeps {
220
+ runtime: SubagentRuntime;
221
+ /** Fallback context when a control caller supplies none; restored threads
222
+ * install without one and rely on the per-call context. */
223
+ runCtx?: ExtensionContext;
224
+ /** Fresh dispatch passes the live dispatcher; restored threads resolve it
225
+ * from the runtime at call time so they never pin a stale closure. */
226
+ startBackground: StartBackgroundInternal;
227
+ }
228
+
229
+ interface DispatchModelRoute {
230
+ agent: AgentConfig;
231
+ mainFallbackRef?: string;
232
+ thinkingLevel: ThinkingLevel;
233
+ thinkingLevelForModel: (ref?: string) => ThinkingLevel;
234
+ }
235
+
236
+ export function resolveDispatchModelRoute(
237
+ agent: AgentConfig,
238
+ config: SubagentsConfig,
239
+ ctx: ExtensionContext,
240
+ ): DispatchModelRoute {
241
+ const availableModels = availableModelsInScope(ctx);
242
+ const mainRef = currentModelRef(ctx);
243
+ const route = resolveAgentModelRoute({
244
+ selectedRef: config.agentModels[agent.name],
245
+ mainRef,
246
+ availableRefs: availableModels.map(modelRef),
247
+ });
248
+ // A `/subagents-setup` override wins; otherwise the role default. No
249
+ // per-call or frontmatter thinking.
250
+ const preferred =
251
+ config.agentThinkingLevels[agent.name] ?? roleThinkingLevel(agent.name);
252
+ const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
253
+ const model = ref === mainRef && ctx.model
254
+ ? ctx.model
255
+ : findModelByRef(availableModels, ref);
256
+ return resolveThinkingLevel(model, preferred);
257
+ };
258
+ return {
259
+ agent: { ...agent, model: route.primaryRef },
260
+ mainFallbackRef: route.mainFallbackRef,
261
+ thinkingLevel: thinkingLevelForModel(route.primaryRef),
262
+ thinkingLevelForModel,
263
+ };
264
+ }
265
+
266
+ /** Project-scoped <projectRoot>/results for a completion's artifacts. */
267
+ export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
268
+ return join(getProjectRoot(configPath, cwd), "results");
269
+ }
@@ -11,16 +11,16 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { Text } from "@earendil-works/pi-tui";
12
12
  import { existsSync } from "node:fs";
13
13
  import { Type } from "typebox";
14
- import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
14
+ import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "../configuration/config.ts";
15
15
  import { removeThreadRecord } from "./durable.ts";
16
- import { formatCompletionBlock, matchRunIds } from "./format.ts";
17
- import { emptyUsage } from "./rpc-run.ts";
18
- import { formatTaskSummary, monitor } from "./monitor.ts";
19
- import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
16
+ import { formatCompletionBlock, matchRunIds } from "../presentation/format.ts";
17
+ import { emptyUsage } from "../execution/rpc-control.ts";
18
+ import { formatTaskSummary, monitor } from "../presentation/monitor.ts";
19
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
20
20
  import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
21
- import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
22
- import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
23
- import type { WorktreeFinalization } from "./worktree.ts";
21
+ import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-shared.ts";
22
+ import { getResultOutput, type SingleResult } from "../execution/spawn.ts";
23
+ import type { WorktreeFinalization } from "../isolation/worktree.ts";
24
24
 
25
25
  function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
26
26
  const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
@@ -2,10 +2,10 @@
2
2
 
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
- import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "./config.ts";
6
- import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
7
- import { announceRecoveryRecords, relocateRecoveryManifest } from "./recovery.ts";
8
- import type { SubagentRuntime } from "./runtime.ts";
5
+ import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "../configuration/config.ts";
6
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "../configuration/models.ts";
7
+ import { announceRecoveryRecords, relocateRecoveryManifest } from "../isolation/recovery.ts";
8
+ import type { SubagentRuntime } from "../lifecycle/runtime.ts";
9
9
  import { installActiveRunsStatus } from "./status.ts";
10
10
  import { installActiveRunsWidget } from "./widget.ts";
11
11
 
@@ -4,9 +4,9 @@
4
4
  * and run-id matching.
5
5
  */
6
6
 
7
- import type { AgentConfig } from "./agents.ts";
7
+ import type { AgentConfig } from "../delegation/agents.ts";
8
8
  import { runLabel, shrinkRunLabel } from "./monitor.ts";
9
- import { emptyUsage } from "./rpc-run.ts";
9
+ import { emptyUsage } from "../execution/rpc-control.ts";
10
10
  import {
11
11
  RESULT_LINE_MAX,
12
12
  getResultOutput,
@@ -15,7 +15,7 @@ import {
15
15
  writeResultArtifact,
16
16
  type SingleResult,
17
17
  type UsageStats,
18
- } from "./spawn.ts";
18
+ } from "../execution/spawn.ts";
19
19
 
20
20
  export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
21
21
  return {
@@ -12,8 +12,8 @@
12
12
  import { stripVTControlCharacters } from "node:util";
13
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
14
  import { visibleWidth } from "@earendil-works/pi-tui";
15
- import { emptyUsage, type UsageStats } from "./rpc-run.ts";
16
- import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
15
+ import { emptyUsage, type UsageStats } from "../execution/rpc-control.ts";
16
+ import type { IsolationMode, WorktreeFinalizationStatus } from "../isolation/worktree.ts";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Types
@@ -29,7 +29,7 @@ import {
29
29
  statusIcon,
30
30
  type RunView,
31
31
  } from "./monitor.ts";
32
- import type { UsageStats } from "./rpc-run.ts";
32
+ import type { UsageStats } from "../execution/rpc-control.ts";
33
33
 
34
34
  export const SUBAGENTS_WIDGET_ID = "pi-subagents";
35
35
 
@@ -1,16 +0,0 @@
1
- ---
2
- name: sentinel
3
- description: Adversarial post-cleanup review; returns only evidence-backed defects and test gaps.
4
- tools: read, grep, find, ls, anchor_grep, web_search, fetch_content, resolve-library-id, query-docs, bash
5
- isolation: shared
6
- ---
7
-
8
- You own one adversarial final-review phase after cleanup. The brief is your only conversation context.
9
-
10
- ## Rules
11
-
12
- - Inspect the complete diff, untracked files, affected callers, and claimed checks. Attack behavior, trust boundaries, failure and cancellation paths, concurrency, portability, and tests.
13
- - Load only matching ferris skills. Treat them and AGENTS.md as the contract, not suggestions. Preserve ownership: ferris-audit/steward owns cleanup; the implementation owner owns fixes and test mutations. Do not duplicate either phase.
14
- - Work read-only. Run only the smallest targeted check needed to prove a suspected defect. Never edit, stage, commit, push, publish, tag, or release.
15
- - Report only actionable findings, highest severity first: `SEVERITY path:line — failure scenario; evidence; smallest fix`.
16
- - No nits, praise, vague risks, or inspection narrative. If none, output `No findings.` Add only concrete missing verification that could hide a regression. Stay under 30 lines.