@ferris1225/pi-subagents 4.3.8 → 4.3.9
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/CHANGELOG.md +17 -0
- package/README.md +84 -19
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/delegation/agents.ts +1 -0
- package/src/delegation/dispatch.ts +176 -20
- package/src/delegation/phase-scope.ts +208 -0
- package/src/delegation/prompt.ts +34 -13
- package/src/delegation/risk.ts +168 -0
- package/src/lifecycle/durable.ts +18 -0
- package/src/lifecycle/runtime.ts +12 -1
- package/src/lifecycle/thread-lifecycle.ts +62 -6
- package/src/lifecycle/thread-restore.ts +3 -0
- package/src/lifecycle/thread-shared.ts +8 -2
- package/src/lifecycle/tools.ts +12 -2
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
} from "../presentation/format.ts";
|
|
29
29
|
import { monitor } from "../presentation/monitor.ts";
|
|
30
30
|
import { findDuplicateDispatch } from "../delegation/prompt.ts";
|
|
31
|
+
import {
|
|
32
|
+
findWriterLeaseScopeOverlap,
|
|
33
|
+
mergePhaseScopes,
|
|
34
|
+
normalizePhaseId,
|
|
35
|
+
normalizePhaseScope,
|
|
36
|
+
} from "../delegation/phase-scope.ts";
|
|
31
37
|
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
|
|
32
38
|
import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
|
|
33
39
|
import { forkRetainedSession } from "../execution/session-fork.ts";
|
|
@@ -105,6 +111,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
105
111
|
startOptions: StartBackgroundOptions = {},
|
|
106
112
|
): Promise<SingleResult> => {
|
|
107
113
|
const {
|
|
114
|
+
phaseId: requestedPhaseId,
|
|
115
|
+
scope: requestedScope,
|
|
116
|
+
writeCapable: requestedWriteCapable,
|
|
108
117
|
existingThread,
|
|
109
118
|
appendedObjectiveOnResume = false,
|
|
110
119
|
environment,
|
|
@@ -127,16 +136,30 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
127
136
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
128
137
|
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
129
138
|
const agent = resolveLiveAgentTools(discoveredAgent);
|
|
139
|
+
|
|
140
|
+
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
141
|
+
let phaseId: string | undefined;
|
|
142
|
+
let scope: ReturnType<typeof normalizePhaseScope>;
|
|
143
|
+
try {
|
|
144
|
+
phaseId = normalizePhaseId(existingThread ? existingThread.phaseId : requestedPhaseId);
|
|
145
|
+
const requested = normalizePhaseScope(requestedScope, originalCwd);
|
|
146
|
+
scope = existingThread ? mergePhaseScopes(existingThread.scope, requested) : requested;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
|
|
149
|
+
}
|
|
150
|
+
const currentWriteCapable = isWriteCapableAgent(agent);
|
|
151
|
+
const priorWriteCapable = existingThread
|
|
152
|
+
? (existingThread.writeCapable ?? existingThread.agentName !== "scout")
|
|
153
|
+
: Boolean(requestedWriteCapable);
|
|
154
|
+
const writeCapable = priorWriteCapable || currentWriteCapable;
|
|
130
155
|
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
131
156
|
return {
|
|
132
157
|
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
|
|
133
158
|
isolation,
|
|
134
159
|
};
|
|
135
160
|
}
|
|
136
|
-
|
|
137
|
-
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
138
161
|
if (!existingThread) {
|
|
139
|
-
const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
|
|
162
|
+
const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
|
|
140
163
|
if (duplicate?.kind === "active") {
|
|
141
164
|
return failedStartResult(
|
|
142
165
|
agentName,
|
|
@@ -150,7 +173,17 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
150
173
|
return failedStartResult(
|
|
151
174
|
agentName,
|
|
152
175
|
task,
|
|
153
|
-
`Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this
|
|
176
|
+
`Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this logical phase and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run${phaseId ? "; keep using the same phaseId on that thread" : ", or restate the brief with what changed"}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (writeCapable && scope) {
|
|
181
|
+
const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values(), existingThread?.id);
|
|
182
|
+
if (conflict) {
|
|
183
|
+
return failedStartResult(
|
|
184
|
+
agentName,
|
|
185
|
+
task,
|
|
186
|
+
`Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no new generation was started.`,
|
|
154
187
|
);
|
|
155
188
|
}
|
|
156
189
|
}
|
|
@@ -230,6 +263,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
230
263
|
thread.generation = generation;
|
|
231
264
|
thread.agentName = agent.name;
|
|
232
265
|
thread.task = task;
|
|
266
|
+
thread.phaseId = phaseId;
|
|
267
|
+
thread.scope = scope;
|
|
268
|
+
thread.writeCapable = writeCapable;
|
|
233
269
|
thread.cwd = originalCwd;
|
|
234
270
|
thread.executionCwd = executionCwd;
|
|
235
271
|
thread.thinkingLevel = thinkingLevel;
|
|
@@ -253,6 +289,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
253
289
|
generation,
|
|
254
290
|
agentName: agent.name,
|
|
255
291
|
task,
|
|
292
|
+
phaseId,
|
|
293
|
+
scope,
|
|
294
|
+
writeCapable,
|
|
256
295
|
cwd: originalCwd,
|
|
257
296
|
executionCwd,
|
|
258
297
|
thinkingLevel,
|
|
@@ -281,7 +320,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
281
320
|
// Shared write-capable runs serialize on the repository lane so their
|
|
282
321
|
// edits cannot race; the lane wait releases the process slot because it
|
|
283
322
|
// is write serialization, not pool pacing.
|
|
284
|
-
const reserveManagedLane = isolation === "shared" &&
|
|
323
|
+
const reserveManagedLane = isolation === "shared" && writeCapable;
|
|
285
324
|
const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
|
|
286
325
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
287
326
|
if (isolation === "worktree" && !worktree) {
|
|
@@ -718,7 +757,11 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
718
757
|
});
|
|
719
758
|
};
|
|
720
759
|
|
|
721
|
-
thread.resume = async (
|
|
760
|
+
thread.resume = async (
|
|
761
|
+
objective?: string,
|
|
762
|
+
resumeCtx?: ExtensionContext,
|
|
763
|
+
metadata?: { scope?: Parameters<typeof normalizePhaseScope>[0] },
|
|
764
|
+
): Promise<SingleResult> => {
|
|
722
765
|
const requestedObjective = objective?.trim();
|
|
723
766
|
if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
|
|
724
767
|
return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
|
|
@@ -726,6 +769,14 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
726
769
|
if (objective !== undefined && !requestedObjective) {
|
|
727
770
|
return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
|
|
728
771
|
}
|
|
772
|
+
const continuationPhaseId = thread.phaseId;
|
|
773
|
+
let continuationScope: ReturnType<typeof normalizePhaseScope>;
|
|
774
|
+
try {
|
|
775
|
+
const additionalScope = normalizePhaseScope(metadata?.scope, thread.cwd);
|
|
776
|
+
continuationScope = mergePhaseScopes(thread.scope, additionalScope);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
return failedStartResult(thread.agentName, thread.task, error instanceof Error ? error.message : String(error));
|
|
779
|
+
}
|
|
729
780
|
if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
|
|
730
781
|
if (thread.resumeUnavailableReason) {
|
|
731
782
|
return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
|
|
@@ -750,6 +801,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
750
801
|
sessionId: previousSessionId,
|
|
751
802
|
sessionDir: previousSessionDir,
|
|
752
803
|
};
|
|
804
|
+
thread.admissionScope = continuationScope;
|
|
753
805
|
thread.lifecycleOperation = "resume";
|
|
754
806
|
thread.state = "resuming";
|
|
755
807
|
const finishPreflight = beginRuntimePreflight(runtime);
|
|
@@ -763,6 +815,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
763
815
|
// Never wait forever on a previous generation that is still settling
|
|
764
816
|
// (e.g. blocked behind the managed repository lane in finalization).
|
|
765
817
|
if (!(await quiesced(thread.generationCompletion))) {
|
|
818
|
+
if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
|
|
766
819
|
return failedStartResult(
|
|
767
820
|
thread.agentName,
|
|
768
821
|
thread.task,
|
|
@@ -829,6 +882,8 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
829
882
|
thread.isolation,
|
|
830
883
|
{
|
|
831
884
|
existingThread: thread,
|
|
885
|
+
phaseId: continuationPhaseId,
|
|
886
|
+
scope: continuationScope,
|
|
832
887
|
appendedObjectiveOnResume: objective !== undefined,
|
|
833
888
|
environment: {
|
|
834
889
|
ctx: currentCtx,
|
|
@@ -883,6 +938,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
883
938
|
);
|
|
884
939
|
} finally {
|
|
885
940
|
finishPreflight();
|
|
941
|
+
if (thread.lifecycleVersion === reservation.version) thread.admissionScope = undefined;
|
|
886
942
|
if (
|
|
887
943
|
thread.lifecycleOperation === "resume" &&
|
|
888
944
|
thread.lifecycleVersion === reservation.version
|
|
@@ -58,6 +58,9 @@ function createRestoredThread(
|
|
|
58
58
|
generation: record.generation,
|
|
59
59
|
agentName: record.agentName,
|
|
60
60
|
task: record.task,
|
|
61
|
+
phaseId: record.phaseId,
|
|
62
|
+
scope: record.scope,
|
|
63
|
+
writeCapable: record.writeCapable ?? record.agentName !== "scout",
|
|
61
64
|
cwd: record.cwd,
|
|
62
65
|
executionCwd: record.executionCwd,
|
|
63
66
|
...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
|
|
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
|
|
7
|
+
import type { PhaseScope } from "../delegation/phase-scope.ts";
|
|
7
8
|
import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
|
|
8
9
|
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
|
|
9
10
|
import {
|
|
@@ -195,9 +196,14 @@ export interface ResumeReservation {
|
|
|
195
196
|
sessionDir?: string;
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
/**
|
|
199
|
-
*
|
|
199
|
+
/** Dispatcher's internal entry point. Public phase/scope claims are normalized
|
|
200
|
+
* into options; resume adds lifecycle-only continuation fields there too. */
|
|
200
201
|
export interface StartBackgroundOptions {
|
|
202
|
+
/** Normalized identity and claims for a fresh or resumed generation. */
|
|
203
|
+
phaseId?: string;
|
|
204
|
+
scope?: PhaseScope;
|
|
205
|
+
/** Fresh-dispatch hint OR-merged with live capability; false cannot downgrade a writer. Resume stays monotonic. */
|
|
206
|
+
writeCapable?: boolean;
|
|
201
207
|
/** Resume path only: the thread whose retained context continues. */
|
|
202
208
|
existingThread?: SubagentThread;
|
|
203
209
|
appendedObjectiveOnResume?: boolean;
|
package/src/lifecycle/tools.ts
CHANGED
|
@@ -34,6 +34,13 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
37
|
+
const ResumeScopeSchema = Type.Optional(Type.Object({
|
|
38
|
+
paths: Type.Optional(Type.Array(Type.String({ minLength: 1, pattern: "\\S" }))),
|
|
39
|
+
symbols: Type.Optional(Type.Array(Type.Object({
|
|
40
|
+
path: Type.String({ minLength: 1, pattern: "\\S" }),
|
|
41
|
+
name: Type.String({ minLength: 1, pattern: "\\S" }),
|
|
42
|
+
}))),
|
|
43
|
+
}, { description: "Additional declarative write claims for resume; normalized claims are unioned with retained scope and cannot remove it. Scope is conflict metadata, not permissions or a sandbox." }));
|
|
37
44
|
const SubagentControlParams = Type.Object({
|
|
38
45
|
action: StringEnum(["steer", "resume", "park"] as const, {
|
|
39
46
|
description:
|
|
@@ -43,6 +50,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
43
50
|
objective: Type.Optional(
|
|
44
51
|
Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
|
|
45
52
|
),
|
|
53
|
+
scope: ResumeScopeSchema,
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
/** A thread that a steer can continue instead of reject: it is not live, but
|
|
@@ -52,7 +60,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
52
60
|
pi.registerTool({
|
|
53
61
|
name: "subagent_control",
|
|
54
62
|
label: "Subagent Control",
|
|
55
|
-
description: "Steer a running child
|
|
63
|
+
description: "Steer a running child, resume a parked/settled thread, or park a running thread by stable run id. Resume keeps phaseId immutable and may only extend retained scope.",
|
|
56
64
|
parameters: SubagentControlParams,
|
|
57
65
|
|
|
58
66
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -103,7 +111,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
103
111
|
return textResult("resume objective must be non-blank when provided.");
|
|
104
112
|
}
|
|
105
113
|
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
106
|
-
const pending = await thread.resume(requestedObjective, ctx
|
|
114
|
+
const pending = await thread.resume(requestedObjective, ctx, {
|
|
115
|
+
scope: params.scope,
|
|
116
|
+
});
|
|
107
117
|
if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
|
|
108
118
|
const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
|
|
109
119
|
const mode = requestedObjective
|