@henryqw/pi-subagent 27.8.0 → 27.10.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 +11 -1
- package/dist/git-runtime.d.ts +1 -0
- package/dist/git-runtime.js +1 -1
- package/dist/herdr-runtime.d.ts +1 -1
- package/dist/herdr-runtime.js +10 -5
- package/dist/integration-git.d.ts +1 -1
- package/dist/integration-git.js +2 -2
- package/dist/prune.d.ts +1 -1
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +17 -10
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +6 -2
- package/dist/worktree.d.ts +3 -2
- package/dist/worktree.js +12 -4
- package/docs/orchestration.md +12 -6
- package/extensions/isolated.ts +2 -1
- package/extensions/subagent-command.ts +50 -6
- package/extensions/subagent.ts +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,8 @@ Use isolated mode for checked implementation. An isolated request has one durabl
|
|
|
136
136
|
|
|
137
137
|
Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
|
|
138
138
|
|
|
139
|
+
New isolated worktree names use the request and task IDs, plus an identity suffix. Integration names use the request ID and `integration-<generation>`. Herdr workspace and tab labels show the worker worktree name. Names do not use goals or requirements. Saved worktrees and labels keep their exact names for recovery and cleanup.
|
|
140
|
+
|
|
139
141
|
Keep the durable ID; Main can work or end its turn without polling. Inspect ready candidates with `subagent_status`.
|
|
140
142
|
|
|
141
143
|
- `subagent_stage` stages exact candidates while siblings run; resolve conflicts in the integration checkout.
|
|
@@ -155,9 +157,17 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
|
|
|
155
157
|
|
|
156
158
|
Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. The menu groups rows under **Direct**, **Isolated**, and **Orphaned** headers. Each isolated row shows the request ID, the status, and a short goal summary. **Refresh** or a header reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
|
|
157
159
|
|
|
160
|
+
Before the menu opens, and after each **Refresh**, a **Current subagent work** report shows which subagents are working. The extension calculates it from local and saved evidence without a model call. Without interactive UI, `/subagent` sends this report before the request list.
|
|
161
|
+
|
|
162
|
+
A direct workflow is working only when this Pi process owns it and has a running worker. Direct work with a delivered result shows its outcome under **Completed / history** and is not in the report. Other recorded-only direct work shows as unverified. An isolated request is working only when its status is active and a live run holds the repository lease. Without a live lease, an active request shows as interrupted; run `/subagent recover`.
|
|
163
|
+
|
|
158
164
|
Each row starts with a status symbol: `●` active, `✓` completed, `⚠` needs attention, `✗` failed. In the terminal UI, `/subagent` reports (Roles, details, reviews, and results) show as colored transcript entries. The model does not get these entries. In RPC mode, they show as notifications. Without interactive UI, they show as plain text messages.
|
|
159
165
|
|
|
160
|
-
Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository.
|
|
166
|
+
Requests whose parent checkout was removed show in the **Orphaned · parent checkout removed** group from any checkout of the same repository. Saved `running` or `pending` states show as `saved running · unverified` or `saved pending · unverified`, not as live work. The report lists them under **Unverified**. This does not prove completion.
|
|
167
|
+
|
|
168
|
+
Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Select **Completed / history** to see them. Requests with retained resources or unresolved recovery stay visible. This filter does not delete saved records. Without interactive UI, the request list includes history.
|
|
169
|
+
|
|
170
|
+
Every isolated request, including an orphaned request, has **Close and delete worktrees (discard work)**. Select it to review owned resources and confirm deletion. **This permanently deletes committed, uncommitted, and ignored content in the listed checkouts.** Cancel makes no change.
|
|
161
171
|
|
|
162
172
|
Select **Select multiple…** to apply one action to many rows. Select a row to mark it `[x]` or clear it, or use **Select all** or **Clear**, then select **Continue**. The next menu shows only the actions that apply. **Close and delete worktrees (discard work)** applies to isolated requests. **Close/cancel-and-release ALL owned workers** applies to direct workflows that can close. Each request shows its review, then one confirmation covers all of them.
|
|
163
173
|
|
package/dist/git-runtime.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export declare class CheckedGitRuntime implements GitRuntime, TaskCandidateInspe
|
|
|
46
46
|
root: string;
|
|
47
47
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
48
48
|
allocateWorktree(input: {
|
|
49
|
+
requestId: string;
|
|
49
50
|
root: string;
|
|
50
51
|
baseRoot?: string;
|
|
51
52
|
intent: WorktreeAllocationIntent;
|
package/dist/git-runtime.js
CHANGED
|
@@ -111,7 +111,7 @@ export class CheckedGitRuntime {
|
|
|
111
111
|
if (!sameIdentity(current, input.attempt.waveBase)) {
|
|
112
112
|
throw new Error("Recorded wave snapshot drifted after worktree preparation and before git worktree add.");
|
|
113
113
|
}
|
|
114
|
-
});
|
|
114
|
+
}, [input.requestId, input.task.id]);
|
|
115
115
|
if (!created)
|
|
116
116
|
return { kind: "worktree", outcome: "absent", failure: "Git worktree allocation requires a committed repository." };
|
|
117
117
|
const worktree = cloneWorktree(created);
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface HerdrHostRuntimeOptions {
|
|
|
24
24
|
lsofCommand?: string;
|
|
25
25
|
executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
|
|
26
26
|
}
|
|
27
|
-
export declare function workspaceLabel(attempt:
|
|
27
|
+
export declare function workspaceLabel(attempt: TaskAttempt): string;
|
|
28
28
|
export declare class HerdrHostRuntime implements HostRuntime {
|
|
29
29
|
private readonly inspectInFlightCandidate;
|
|
30
30
|
private readonly execute;
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runProcess as defaultRunProcess } from "./process.js";
|
|
|
11
11
|
import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
|
|
12
12
|
import { assertPrivateLease, scanProcessLease } from "./process-lease.js";
|
|
13
13
|
import { exactDirectTerminalTurn } from "./direct-herdr.js";
|
|
14
|
+
import { READABLE_BRANCH_PATTERN } from "./worktree.js";
|
|
14
15
|
const MIN_HERDR_VERSION = [0, 9, 3];
|
|
15
16
|
const MIN_HERDR_PROTOCOL = 22;
|
|
16
17
|
const HERDR_OPERATION_CAP_MS = 30_000;
|
|
@@ -153,10 +154,14 @@ function worktreeIntent(attempt) {
|
|
|
153
154
|
return intent.worktree;
|
|
154
155
|
}
|
|
155
156
|
export function workspaceLabel(attempt) {
|
|
156
|
-
|
|
157
|
+
const { branch } = worktreeIntent(attempt);
|
|
158
|
+
return READABLE_BRANCH_PATTERN.test(branch) ? basename(branch) : attempt.correlationToken.slice(-6);
|
|
157
159
|
}
|
|
158
|
-
function expectedWorkerLabel(task) {
|
|
159
|
-
|
|
160
|
+
function expectedWorkerLabel(task, attempt) {
|
|
161
|
+
const { branch } = worktreeIntent(attempt);
|
|
162
|
+
return READABLE_BRANCH_PATTERN.test(branch)
|
|
163
|
+
? `${workspaceLabel(attempt)}/${task.modelClass}`
|
|
164
|
+
: `${task.role}/${task.modelClass}`;
|
|
160
165
|
}
|
|
161
166
|
function expectedAgentName(token) {
|
|
162
167
|
if (!CORRELATION_TOKEN_PATTERN.test(token))
|
|
@@ -187,7 +192,7 @@ function assertWorkspaceIntent(intent, attempt) {
|
|
|
187
192
|
function assertWorkerTabIntent(intent, task, attempt) {
|
|
188
193
|
const worktree = worktreeIntent(attempt);
|
|
189
194
|
const workspace = ownedIntent(attempt, "workspace");
|
|
190
|
-
if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task)
|
|
195
|
+
if (exactString(intent.label, "worker-tab label") !== expectedWorkerLabel(task, attempt)
|
|
191
196
|
|| exactString(intent.workspaceId, "worker-tab workspace ID") !== workspace.workspaceId
|
|
192
197
|
|| exactString(intent.workspaceRootTabId, "workspace root tab ID") !== workspace.rootTabId
|
|
193
198
|
|| exactString(intent.workspaceRootPaneId, "workspace root pane ID") !== workspace.rootPaneId
|
|
@@ -340,7 +345,7 @@ export class HerdrHostRuntime {
|
|
|
340
345
|
this.assertLeasePath(leasePath, input.attempt.correlationToken);
|
|
341
346
|
return {
|
|
342
347
|
kind: "worker_tab",
|
|
343
|
-
label: expectedWorkerLabel(input.task),
|
|
348
|
+
label: expectedWorkerLabel(input.task, input.attempt),
|
|
344
349
|
workspaceId,
|
|
345
350
|
workspaceRootTabId: exactString(workspace.rootTabId, "owned workspace root tab ID"),
|
|
346
351
|
workspaceRootPaneId: exactString(workspace.rootPaneId, "owned workspace root pane ID"),
|
|
@@ -17,7 +17,7 @@ export declare class IntegrationGit {
|
|
|
17
17
|
/** Refresh is allowed only for the exact clean checked-out Main and a strict same-branch descendant. */
|
|
18
18
|
inspectMainAdvance(root: string, from: WorkspaceIdentity, to: WorkspaceIdentity, signal: AbortSignal): Promise<void>;
|
|
19
19
|
/** Persist the prepared path/branch/base before Git is allowed to create anything. */
|
|
20
|
-
allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal): Promise<GitOutcome<WorktreeInfo>>;
|
|
20
|
+
allocate(root: string, childId: string, base: WorkspaceIdentity, onPrepared: (info: WorktreeInfo) => Promise<void>, signal: AbortSignal, nameParts?: readonly string[]): Promise<GitOutcome<WorktreeInfo>>;
|
|
21
21
|
/** Prove each recorded merge before any new merge or final gate. */
|
|
22
22
|
inspectCombined(root: string, integration: WorktreeInfo, base: WorkspaceIdentity, stages: readonly StageReceipt[], signal: AbortSignal): Promise<WorkspaceIdentity>;
|
|
23
23
|
/** Main's correction is one exact committed child of the checked staged tip. */
|
package/dist/integration-git.js
CHANGED
|
@@ -158,7 +158,7 @@ export class IntegrationGit {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
/** Persist the prepared path/branch/base before Git is allowed to create anything. */
|
|
161
|
-
async allocate(root, childId, base, onPrepared, signal) {
|
|
161
|
+
async allocate(root, childId, base, onPrepared, signal, nameParts) {
|
|
162
162
|
if (!sameIdentity(await inspect(root, signal), base))
|
|
163
163
|
return { outcome: "drift", failure: "Main drifted before integration allocation." };
|
|
164
164
|
let prepared;
|
|
@@ -170,7 +170,7 @@ export class IntegrationGit {
|
|
|
170
170
|
await onPrepared(info);
|
|
171
171
|
if (!sameIdentity(await inspect(root, signal), base))
|
|
172
172
|
throw new Error("Main drifted before worktree add.");
|
|
173
|
-
});
|
|
173
|
+
}, nameParts);
|
|
174
174
|
if (!added)
|
|
175
175
|
return { outcome: "blocked", failure: "Integration requires committed Git Main." };
|
|
176
176
|
if (!sameIdentity(await inspect(root, signal), base))
|
package/dist/prune.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface PruneItem {
|
|
|
33
33
|
ignored: readonly string[];
|
|
34
34
|
}
|
|
35
35
|
export interface PruneOwnership {
|
|
36
|
-
/** Every request or state that may still own
|
|
36
|
+
/** Every request or state that may still own an exact resource name; unreadable state has no request. */
|
|
37
37
|
owners(name: string): readonly {
|
|
38
38
|
label: string;
|
|
39
39
|
request?: PruneRequest;
|
package/dist/runner.d.ts
CHANGED
|
@@ -226,6 +226,7 @@ export interface GitRuntime {
|
|
|
226
226
|
root: string;
|
|
227
227
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
228
228
|
allocateWorktree(input: {
|
|
229
|
+
requestId: string;
|
|
229
230
|
root: string;
|
|
230
231
|
baseRoot?: string;
|
|
231
232
|
intent: WorktreeAllocationIntent;
|
|
@@ -332,6 +333,8 @@ export declare class IsolatedRunner {
|
|
|
332
333
|
invalidIds: string[];
|
|
333
334
|
states: RunState[];
|
|
334
335
|
}>;
|
|
336
|
+
/** True while a live productive run holds this root's lease; the lease does not name its request. */
|
|
337
|
+
hasProductiveRunLease(root: string): Promise<boolean>;
|
|
335
338
|
/** Classify orphaned work under the same repository lease as productive execution.
|
|
336
339
|
* Status remains read-only; Main alone chooses any subsequent action. */
|
|
337
340
|
recoverRepository(root: string): Promise<{
|
package/dist/runner.js
CHANGED
|
@@ -5,7 +5,7 @@ import { utf8Prefix } from "./ephemeral.js";
|
|
|
5
5
|
import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
|
|
6
6
|
import { runGit } from "./git-process.js";
|
|
7
7
|
import { IntegrationGit } from "./integration-git.js";
|
|
8
|
-
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, } from "./schema.js";
|
|
8
|
+
import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reusableCheckSource, reviewEvidencePasses, sameIdentity, formatCheckCommand, stagedCheckSources, RUN_STATE_VERSION, TERMINAL_RUN_STATUSES, } from "./schema.js";
|
|
9
9
|
import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
|
|
10
10
|
const TRUNCATION_MARKER = "\n[truncated]";
|
|
11
11
|
const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
|
|
@@ -427,15 +427,17 @@ function correctionEligible(request, attempt) {
|
|
|
427
427
|
&& sameIdentity(attempt.preliminaryReview.identityAfter, attempt.candidate)));
|
|
428
428
|
}
|
|
429
429
|
function terminal(state) {
|
|
430
|
-
return
|
|
430
|
+
return TERMINAL_RUN_STATUSES.some((status) => status === state.status);
|
|
431
431
|
}
|
|
432
|
-
function inventoryStatus(state) {
|
|
432
|
+
function inventoryStatus(state, orphaned = false) {
|
|
433
|
+
if (orphaned && ["pending", "running"].includes(state.status))
|
|
434
|
+
return `saved ${state.status} · unverified`;
|
|
433
435
|
const retained = state.tasks.some((task) => task.kind === "changeset"
|
|
434
436
|
? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
|
|
435
437
|
: task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
|
|
436
438
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
437
439
|
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true));
|
|
438
|
-
return
|
|
440
|
+
return terminal(state) && retained ? `${state.status} · retained` : state.status;
|
|
439
441
|
}
|
|
440
442
|
/** Build one bounded changeset assignment. Call this before acquiring its Role launch. */
|
|
441
443
|
export function buildChangesetTaskPrompt(input) {
|
|
@@ -639,6 +641,10 @@ export class IsolatedRunner {
|
|
|
639
641
|
})),
|
|
640
642
|
})) };
|
|
641
643
|
}
|
|
644
|
+
/** True while a live productive run holds this root's lease; the lease does not name its request. */
|
|
645
|
+
async hasProductiveRunLease(root) {
|
|
646
|
+
return await this.store.hasProductiveRunLease(root);
|
|
647
|
+
}
|
|
642
648
|
/** Classify orphaned work under the same repository lease as productive execution.
|
|
643
649
|
* Status remains read-only; Main alone chooses any subsequent action. */
|
|
644
650
|
async recoverRepository(root) {
|
|
@@ -666,7 +672,7 @@ export class IsolatedRunner {
|
|
|
666
672
|
}
|
|
667
673
|
const requests = [];
|
|
668
674
|
for (const request of inventory.requests) {
|
|
669
|
-
if (["pending", "running", "needs_attention"
|
|
675
|
+
if (["pending", "running", "needs_attention"].includes(request.status) || request.status.endsWith(" · retained")) {
|
|
670
676
|
requests.push(await this.status(request.id, root));
|
|
671
677
|
}
|
|
672
678
|
}
|
|
@@ -679,7 +685,7 @@ export class IsolatedRunner {
|
|
|
679
685
|
const owners = [];
|
|
680
686
|
let lease;
|
|
681
687
|
const request = ({ state, path }, orphaned) => ({ id: state.request.id, name: state.request.goal,
|
|
682
|
-
status: inventoryStatus(state), directory: dirname(path), root: state.root, orphaned });
|
|
688
|
+
status: inventoryStatus(state, orphaned), directory: dirname(path), root: state.root, orphaned });
|
|
683
689
|
const orphans = checkouts[0] ? (await this.store.listOrphans(realpathSync.native(checkouts[0]))).states : [];
|
|
684
690
|
for (const handle of orphans) {
|
|
685
691
|
const { state } = handle;
|
|
@@ -1175,7 +1181,7 @@ export class IsolatedRunner {
|
|
|
1175
1181
|
};
|
|
1176
1182
|
generations.push(generation);
|
|
1177
1183
|
await this.saveProductive(handle); // Before worktree add.
|
|
1178
|
-
}, context.signal));
|
|
1184
|
+
}, context.signal, [action.id, `integration-${action.generation}`]));
|
|
1179
1185
|
if (result.outcome !== "ready") {
|
|
1180
1186
|
if (!generations.at(-1))
|
|
1181
1187
|
throw new Error("Integration allocation did not persist an owned worktree plan.");
|
|
@@ -1376,7 +1382,7 @@ export class IsolatedRunner {
|
|
|
1376
1382
|
state.integration.refresh = { from: action.expectedMain, to: action.newMain, generation: action.generation,
|
|
1377
1383
|
status: "unknown", failure: "Integration allocation has not been proved; retain the recorded checkout." };
|
|
1378
1384
|
await this.saveProductive(handle);
|
|
1379
|
-
}, context.signal));
|
|
1385
|
+
}, context.signal, [action.id, `integration-${action.generation}`]));
|
|
1380
1386
|
}
|
|
1381
1387
|
catch (error) {
|
|
1382
1388
|
// A prepared checkout remains recorded; no repeated allocation from this intent.
|
|
@@ -1782,7 +1788,7 @@ export class IsolatedRunner {
|
|
|
1782
1788
|
const primary = await primaryCheckout(cwd);
|
|
1783
1789
|
const { states, invalidIds } = await this.store.listOrphans(primary);
|
|
1784
1790
|
return { invalidIds, requests: states.map((handle) => ({
|
|
1785
|
-
id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state),
|
|
1791
|
+
id: handle.state.request.id, name: handle.state.request.goal, status: inventoryStatus(handle.state, true),
|
|
1786
1792
|
directory: dirname(handle.path), root: handle.state.root, orphaned: true, tasks: [],
|
|
1787
1793
|
})) };
|
|
1788
1794
|
}
|
|
@@ -2297,6 +2303,7 @@ export class IsolatedRunner {
|
|
|
2297
2303
|
try {
|
|
2298
2304
|
if (intent.kind === "worktree") {
|
|
2299
2305
|
result = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.allocateWorktree({
|
|
2306
|
+
requestId: state.request.id,
|
|
2300
2307
|
root: state.root,
|
|
2301
2308
|
...(attempt.waveBase.head !== state.main.head ? { baseRoot: state.integration.generations.at(-1)?.worktree?.path } : {}),
|
|
2302
2309
|
intent,
|
|
@@ -2422,7 +2429,7 @@ export class IsolatedRunner {
|
|
|
2422
2429
|
if (worktree.baseCommit !== wave.base.head || !sameIdentity(await inspectBase({ root: baseRoot }, context), wave.base)) {
|
|
2423
2430
|
throw new Error("Staged dependency snapshot drifted before text worktree creation.");
|
|
2424
2431
|
}
|
|
2425
|
-
});
|
|
2432
|
+
}, [state.request.id, task.taskId, `text-${attempt.number}`]);
|
|
2426
2433
|
if (!isolated)
|
|
2427
2434
|
throw new Error("Explicit isolated text work requires a Git checkout with a committed HEAD; it never falls back to Main.");
|
|
2428
2435
|
let result;
|
package/dist/schema.d.ts
CHANGED
|
@@ -1791,6 +1791,8 @@ export type IntegrationCandidate = Static<typeof IntegrationCandidateSchema>;
|
|
|
1791
1791
|
export type IntegrationStage = Static<typeof IntegrationStageSchema>;
|
|
1792
1792
|
export type IntegrationGeneration = Static<typeof IntegrationGenerationSchema>;
|
|
1793
1793
|
export type IntegrationState = Static<typeof IntegrationStateSchema>;
|
|
1794
|
+
declare const TerminalRunStatusSchema: Type.TUnion<[Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
1795
|
+
export declare const TERMINAL_RUN_STATUSES: readonly Static<typeof TerminalRunStatusSchema>[];
|
|
1794
1796
|
declare const RunStateSchema: Type.TObject<{
|
|
1795
1797
|
version: Type.TLiteral<5>;
|
|
1796
1798
|
request: Type.TObject<{
|
package/dist/schema.js
CHANGED
|
@@ -412,6 +412,10 @@ export const IntegrationStateSchema = Type.Object({
|
|
|
412
412
|
failure: OptionalRuntimeTextSchema,
|
|
413
413
|
}, { additionalProperties: false })),
|
|
414
414
|
}, { additionalProperties: false });
|
|
415
|
+
const TerminalRunStatusSchema = Type.Union([
|
|
416
|
+
Type.Literal("completed"), Type.Literal("final_failed"), Type.Literal("superseded"), Type.Literal("aborted"),
|
|
417
|
+
]);
|
|
418
|
+
export const TERMINAL_RUN_STATUSES = TerminalRunStatusSchema.anyOf.map((status) => status.const);
|
|
415
419
|
const RunStateSchema = Type.Object({
|
|
416
420
|
version: Type.Literal(RUN_STATE_VERSION),
|
|
417
421
|
request: ExecuteRequestSchema,
|
|
@@ -421,8 +425,8 @@ const RunStateSchema = Type.Object({
|
|
|
421
425
|
requestStartMain: WorkspaceSchema,
|
|
422
426
|
main: WorkspaceSchema,
|
|
423
427
|
status: Type.Union([
|
|
424
|
-
Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"),
|
|
425
|
-
|
|
428
|
+
Type.Literal("pending"), Type.Literal("running"), Type.Literal("needs_attention"),
|
|
429
|
+
...TerminalRunStatusSchema.anyOf,
|
|
426
430
|
]),
|
|
427
431
|
tasks: Type.Array(TaskStateSchema, { minItems: 1, maxItems: MAX_TASKS }),
|
|
428
432
|
waves: Type.Array(WaveStateSchema, { maxItems: MAX_TASKS }),
|
package/dist/worktree.d.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type WorktreePayload = {
|
|
|
25
25
|
dirty?: boolean;
|
|
26
26
|
};
|
|
27
27
|
export declare const WORKTREES_DIRNAME = ".worktrees";
|
|
28
|
-
|
|
28
|
+
export declare const READABLE_BRANCH_PATTERN: RegExp;
|
|
29
|
+
/** Keep saved opaque names eligible for exact recovery and cleanup. */
|
|
29
30
|
export declare const OWNED_BRANCH_PATTERN: RegExp;
|
|
30
31
|
export declare class WorktreeSetupError extends Error {
|
|
31
32
|
name: string;
|
|
@@ -39,7 +40,7 @@ export declare class WorktreeSetupError extends Error {
|
|
|
39
40
|
* repository (unwritable path, stale branch, git lock) throw so an isolated
|
|
40
41
|
* role never silently loses its isolation.
|
|
41
42
|
*/
|
|
42
|
-
export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void
|
|
43
|
+
export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal, onPrepared?: (worktree: WorktreeInfo) => Promise<void>, nameParts?: readonly string[]): Promise<WorktreeInfo | undefined>;
|
|
43
44
|
export interface WorktreeDirtyInspection {
|
|
44
45
|
dirty: boolean;
|
|
45
46
|
failure?: string;
|
package/dist/worktree.js
CHANGED
|
@@ -5,8 +5,11 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
import { runGit } from "./git-process.js";
|
|
6
6
|
export const WORKTREES_DIRNAME = ".worktrees";
|
|
7
7
|
const BRANCH_NAMESPACE = "pi-subagent";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const NAME_COMPONENT_LIMIT = 24;
|
|
9
|
+
const NAME_LIMIT = 72;
|
|
10
|
+
export const READABLE_BRANCH_PATTERN = new RegExp(`^${BRANCH_NAMESPACE}/subagent-[a-z0-9](?:[a-z0-9-]{0,${NAME_LIMIT - 2}}[a-z0-9])?-[0-9a-f]{24}$`);
|
|
11
|
+
/** Keep saved opaque names eligible for exact recovery and cleanup. */
|
|
12
|
+
export const OWNED_BRANCH_PATTERN = new RegExp(`${READABLE_BRANCH_PATTERN.source}|^${BRANCH_NAMESPACE}/subagent-[0-9a-f]{24}$`);
|
|
10
13
|
export class WorktreeSetupError extends Error {
|
|
11
14
|
name = "WorktreeSetupError";
|
|
12
15
|
worktree;
|
|
@@ -16,6 +19,11 @@ export class WorktreeSetupError extends Error {
|
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
const sanitizeShortId = (childId) => createHash("sha256").update(childId).digest("hex").slice(0, 24);
|
|
22
|
+
function nameSlug(parts) {
|
|
23
|
+
return parts.map((part) => part.toLowerCase().replace(/[^a-z0-9]+/g, "-")
|
|
24
|
+
.slice(0, NAME_COMPONENT_LIMIT).replace(/^-+|-+$/g, "") || "task")
|
|
25
|
+
.join("-").slice(0, NAME_LIMIT).replace(/-+$/, "") || "task";
|
|
26
|
+
}
|
|
19
27
|
const stripGitLineEnd = (value) => value.replace(/\r?\n$/, "");
|
|
20
28
|
function hasRepositoryMarker(cwd) {
|
|
21
29
|
for (let directory = resolve(cwd);; directory = dirname(directory)) {
|
|
@@ -60,7 +68,7 @@ async function ensureLocalExclude(gitDir) {
|
|
|
60
68
|
* repository (unwritable path, stale branch, git lock) throw so an isolated
|
|
61
69
|
* role never silently loses its isolation.
|
|
62
70
|
*/
|
|
63
|
-
export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared) {
|
|
71
|
+
export async function createChildWorktree(cwd, childId, run = runGit, signal, onPrepared, nameParts = [childId]) {
|
|
64
72
|
const root = await run(["rev-parse", "--show-toplevel"], cwd, signal);
|
|
65
73
|
signal?.throwIfAborted();
|
|
66
74
|
if (root.code === -1)
|
|
@@ -130,7 +138,7 @@ export async function createChildWorktree(cwd, childId, run = runGit, signal, on
|
|
|
130
138
|
}
|
|
131
139
|
signal?.throwIfAborted();
|
|
132
140
|
const worktreesRoot = join(stableRepoRoot, WORKTREES_DIRNAME);
|
|
133
|
-
const name = `subagent-${sanitizeShortId(childId)}`;
|
|
141
|
+
const name = `subagent-${nameSlug(nameParts)}-${sanitizeShortId(childId)}`;
|
|
134
142
|
const branch = `${BRANCH_NAMESPACE}/${name}`;
|
|
135
143
|
const path = join(worktreesRoot, name);
|
|
136
144
|
const worktree = { path, cwd: join(path, relativeCwd), branch, repoRoot: stableRepoRoot, baseCommit };
|
package/docs/orchestration.md
CHANGED
|
@@ -194,7 +194,13 @@ Activity uses the existing session sample and file-size cache. The extension ign
|
|
|
194
194
|
|
|
195
195
|
## Isolated contract
|
|
196
196
|
|
|
197
|
-
An isolated request has one durable ID, one goal, and 1 to 8 typed tasks
|
|
197
|
+
An isolated request has one durable ID, one goal, and 1 to 8 typed tasks.
|
|
198
|
+
|
|
199
|
+
New worktrees use `.worktrees/subagent-<name>-<24hex>` and branch `pi-subagent/subagent-<name>-<24hex>`. The name uses the request and task IDs. Text tasks also include `text-<attempt>`. Integration checkouts use the request ID and `integration-<generation>`. Each name component has at most 24 characters; the full readable part has at most 72. Names use lowercase ASCII letters, digits, and hyphens. The 24-hex identity suffix keeps names distinct when readable parts are shortened or normalized.
|
|
200
|
+
|
|
201
|
+
Herdr workspace labels use the worker worktree name. Worker tab labels add the model class. Names never use goals, requirements, or other free-form task text. Saved opaque worktree names and their Herdr labels stay unchanged. Resume and cleanup use the exact saved allocations; the extension does not rename existing resources.
|
|
202
|
+
|
|
203
|
+
For example:
|
|
198
204
|
|
|
199
205
|
```json
|
|
200
206
|
{
|
|
@@ -269,15 +275,15 @@ After readiness, use `subagent_stage revise` with the exact candidate, generatio
|
|
|
269
275
|
|
|
270
276
|
### Inspect and revise active work
|
|
271
277
|
|
|
272
|
-
To select work by label, open `/subagent` without arguments. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout. This includes requests launched in other sessions. Select an isolated request. Then select **Inspect status and recovery**.
|
|
278
|
+
To select work by label, open `/subagent` without arguments. First, a **Current subagent work** report shows the work that local and saved evidence proves is running: locally owned direct workflows with a running worker, and active isolated requests while a live run holds the repository lease. The report lists active requests without a live lease as interrupted, and recorded-only direct work as unverified. A live lease does not identify its request, so several active requests in one checkout all show as working. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout. This includes requests launched in other sessions. Select an isolated request. Then select **Inspect status and recovery**.
|
|
273
279
|
|
|
274
|
-
The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
|
|
280
|
+
The menu also shows requests whose saved parent checkout no longer exists, in their own **Orphaned · parent checkout removed** group. Saved `running` and `pending` states have a `saved` prefix and an `unverified` suffix. The report lists them under **Unverified**, never **Working**, even if the current checkout has a live lease. These labels do not change saved state or prove completion. Orphaned requests with retained resources or `needs_attention` show under **Needs attention**. You can open this view from any checkout of the same repository. The saved worker or integration path identifies the primary repository. Orphaned requests have the same **Close and delete worktrees (discard work)** action, but no inspection or follow-up actions. The extension reads their original state directories; it does not recreate the removed parent checkout. Unreadable files stay preserved and are reported.
|
|
275
281
|
|
|
276
282
|
This launches a Herdr tab without focus, with the configured `scout` Role on the configured `fast` route. The tab runs a read-access task with the resources of the Role. Its prompt contains bounded saved status, including retained paths, but not the raw launch environment or session transcripts. The analysis stays in that tab and does not queue a Main turn. The tab shows in the direct list with its own reference and name, such as `D9.1 · scout · Inspect <request ID>`. The extension records the tab and session file for recovery, even if the launch becomes uncertain.
|
|
277
283
|
|
|
278
284
|
Before any stage or recovery action, the read-only `subagent_status` tool stays Main's authority for exact identities. If Herdr, the `scout` Role, or the fast route is not available, inspection fails and does not change the request. Main can then use `subagent_status`.
|
|
279
285
|
|
|
280
|
-
|
|
286
|
+
Both isolated groups hide terminal requests by default: `completed`, `aborted`, `final_failed`, and `superseded`. Requests with retained resources or unresolved recovery stay visible. Select **Completed / history** to see all saved requests; **Refresh** reloads the current view. The filter does not delete records. Without interactive UI, the request list includes history. The extension shows a warning for invalid state IDs and preserves them. Direct recovery still works outside Git or when isolated inventory is not available. The browse action does not resume work or change resources. Direct **Close/cancel-and-release** is an explicit termination action.
|
|
281
287
|
|
|
282
288
|
For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor. A submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text shows in FIFO order, with blank lines between them. An explicit submission queues **one replacement** at the end. If you cancel, the withdrawn instructions stay removed.
|
|
283
289
|
|
|
@@ -400,7 +406,7 @@ One confirmation permits permanent deletion of committed, uncommitted, and ignor
|
|
|
400
406
|
|
|
401
407
|
Each resource is checked again before removal. A live productive lease or local job blocks the action. Uncertain allocations, changed branch tips, locked or redirected checkouts, and other Herdr panes inside a checkout block that resource. Initialized submodules and index flags that hide clean-state evidence also require separate inspection. If Herdr cannot be inspected, checkouts stay in place.
|
|
402
408
|
|
|
403
|
-
Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<24hex>` branches and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
|
|
409
|
+
Main cannot run inside a checkout selected for deletion. A session change stops further removal. Only this request's recorded `pi-subagent/subagent-<name>-<24hex>` branches, saved legacy `pi-subagent/subagent-<24hex>` branches, and their exact primary `.worktrees/` paths can be removed. The primary checkout is never removed.
|
|
404
410
|
|
|
405
411
|
To close and delete many requests, select **Select multiple…** in `/subagent`, select the requests, then select **Continue** and **Close and delete worktrees (discard work)**. The command shows the review of each request. Then one confirmation covers the unblocked resource groups of all of them. The same menu can close all owned workers of each selected direct workflow that can close. The command reports the number of selected rows that the action skipped.
|
|
406
412
|
|
|
@@ -408,7 +414,7 @@ The report lists removed, skipped, and failed resources. After complete cleanup,
|
|
|
408
414
|
|
|
409
415
|
### Prune leftover worktrees
|
|
410
416
|
|
|
411
|
-
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent
|
|
417
|
+
Run `/subagent prune` to clean up leftover worker and integration checkouts under `.worktrees/`. Their branches have the name `pi-subagent/subagent-<name>-<24hex>`, or the saved legacy name `pi-subagent/subagent-<24hex>`. The command also cleans up such branches that have no checkout. The review starts with the number of removable and blocked items. It groups the items by the exact reason that the command can remove them, or by their exact blockers, and shows each reason once. Under each reason, it shows the readable name, if present, and the first 8 characters of the identity suffix. The command can remove an item only when all of these conditions are true:
|
|
412
418
|
|
|
413
419
|
- Its branch tip is already in Main's HEAD or in the local `origin` default-branch ref. The command does not fetch.
|
|
414
420
|
- Its checkout has no tracked or untracked changes. No merge, rebase, cherry-pick, revert, or bisect is in progress.
|
package/extensions/isolated.ts
CHANGED
|
@@ -842,7 +842,8 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
842
842
|
catch (error) { console.error("Pi Subagent workspace widget update failed.", error); }
|
|
843
843
|
}
|
|
844
844
|
const orphans = await getComponents().runner.listOrphanRequests(root);
|
|
845
|
-
|
|
845
|
+
const leaseLive = await getComponents().runner.hasProductiveRunLease(root);
|
|
846
|
+
return { root, leaseLive, requests: [...requests, ...orphans.requests], invalidIds: [...new Set([...invalidIds, ...orphans.invalidIds])] };
|
|
846
847
|
},
|
|
847
848
|
async recover(cwd) {
|
|
848
849
|
const root = await lookupRoot(cwd);
|
|
@@ -2,6 +2,7 @@ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type Theme
|
|
|
2
2
|
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { DiscardPlan } from "../dist/discard.js";
|
|
4
4
|
import type { PruneItem } from "../dist/prune.js";
|
|
5
|
+
import { TERMINAL_RUN_STATUSES } from "../dist/schema.js";
|
|
5
6
|
import { compactCost } from "./result-transport.ts";
|
|
6
7
|
|
|
7
8
|
/** Saved before launch for user matching only; never ownership or mutation authority. */
|
|
@@ -56,6 +57,8 @@ export interface IsolatedRequest {
|
|
|
56
57
|
}
|
|
57
58
|
export interface IsolatedInventory {
|
|
58
59
|
root: string;
|
|
60
|
+
/** A live productive run holds this root's lease; the lease does not name its request. */
|
|
61
|
+
leaseLive: boolean;
|
|
59
62
|
requests: readonly IsolatedRequest[];
|
|
60
63
|
invalidIds: readonly string[];
|
|
61
64
|
}
|
|
@@ -190,7 +193,7 @@ const summary = (text: string, max = 60): string => {
|
|
|
190
193
|
};
|
|
191
194
|
// Identity and status first so clipping cuts only the goal summary.
|
|
192
195
|
const isolatedLabel = ({ id, status, name }: IsolatedRequest): string => `${id} · ${status} · ${summary(name)}`;
|
|
193
|
-
const completed = (status: string) => [
|
|
196
|
+
const completed = (status: string) => [...TERMINAL_RUN_STATUSES, "success", "failed", "failure", "integrated"].includes(status);
|
|
194
197
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
195
198
|
// Recorded-only and unverified work is uncertain, not active.
|
|
196
199
|
const statusTone = (status: string): Tone => /attention|blocked|retained|recorded only|unverified/i.test(status) ? "warning"
|
|
@@ -200,7 +203,7 @@ const glyph = (status: string) => TONES[statusTone(status)].glyph;
|
|
|
200
203
|
const cleanupReport = ({ text, failed, skipped }: CleanupReport): Report => textReport(failed ? "error" : skipped ? "warning" : "success", text);
|
|
201
204
|
|
|
202
205
|
const PRUNE_IGNORED_CONFIRM_CODE_POINTS = 4_000;
|
|
203
|
-
const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").
|
|
206
|
+
const shortId = ({ branch }: PruneItem) => branch.replace(/^pi-subagent\/subagent-/, "").replace(/([0-9a-f]{8})[0-9a-f]{16}$/, "$1");
|
|
204
207
|
const NOT_SHOWN = "they are not removed in this run; run /subagent prune again after this run.";
|
|
205
208
|
|
|
206
209
|
/** Fit whole item lines within `max` code points; never cut silently, and report exactly which items were shown. */
|
|
@@ -244,7 +247,7 @@ function pruneReview({ root, items }: PrunePlan): { report: Report; shown: Prune
|
|
|
244
247
|
const prunable = items.filter((item) => item.prunable).length;
|
|
245
248
|
const head: Report = { tone: "info", title: "Subagent prune review", sections: [{ rows: [
|
|
246
249
|
{ label: "root", value: root, code: true }, { label: "prunable", value: String(prunable) }, { label: "blocked", value: String(items.length - prunable) },
|
|
247
|
-
{ label: "IDs", value: "first 8 characters
|
|
250
|
+
{ label: "IDs", value: "readable name, if present, and first 8 suffix characters" },
|
|
248
251
|
] }] };
|
|
249
252
|
// Each line measures its exact Markdown, so the cap never cuts a shown item.
|
|
250
253
|
const reviewed = fitItems(reportMarkdown(head), rows, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
|
|
@@ -291,6 +294,42 @@ function directReport(task: DirectTask): Report {
|
|
|
291
294
|
] };
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
const WORKING_REPORT_CODE_POINTS = 4_000;
|
|
298
|
+
/** Current work from local and saved evidence only. Recorded-only direct work and isolated work without a live lease never count as working. */
|
|
299
|
+
export function workingReport(direct: readonly DirectTask[], inventory: IsolatedInventory | undefined): Report {
|
|
300
|
+
const working: Row[] = [];
|
|
301
|
+
const attention: Row[] = [];
|
|
302
|
+
const unverified: Row[] = [];
|
|
303
|
+
for (const task of direct) {
|
|
304
|
+
const text = `${directLabel(task)} · ${task.status}`;
|
|
305
|
+
// Read workflows list their running workers; write workflows stay observed locally until their workers are proved stopped.
|
|
306
|
+
if (task.status === "observed locally" && task.stoppable?.length !== 0) working.push({ text: task.stoppable ? `${directLabel(task)} · ${task.stoppable.length} running` : text });
|
|
307
|
+
else if (/retained|attention/.test(task.status)) attention.push({ text });
|
|
308
|
+
else if (completed(task.status)) continue;
|
|
309
|
+
else unverified.push({ text: task.status === "observed locally" ? `${text}; no running worker` : text });
|
|
310
|
+
}
|
|
311
|
+
for (const request of inventory?.requests ?? []) {
|
|
312
|
+
const active = !request.orphaned && ["pending", "running"].includes(request.status);
|
|
313
|
+
if (active && inventory!.leaseLive) working.push({ text: isolatedLabel(request) });
|
|
314
|
+
else if (active) attention.push({ text: `${isolatedLabel(request)} · interrupted; run /subagent recover` });
|
|
315
|
+
else if (request.status === "needs_attention" || /retained/.test(request.status)) attention.push({ text: isolatedLabel(request) });
|
|
316
|
+
else if (request.orphaned && /unverified/.test(request.status)) unverified.push({ text: isolatedLabel(request) });
|
|
317
|
+
}
|
|
318
|
+
const sections: Section[] = [
|
|
319
|
+
working.length ? { heading: `Working (${working.length})`, tone: "info", rows: working } : { rows: [{ text: inventory ? "No subagents are working." : "No direct subagents are working. Isolated status is unavailable." }] },
|
|
320
|
+
{ heading: `Needs attention (${attention.length})`, tone: "warning", rows: attention },
|
|
321
|
+
{ heading: `Unverified (${unverified.length})`, note: "not proof of running or completion", tone: "warning", rows: unverified },
|
|
322
|
+
];
|
|
323
|
+
const head: Report = { tone: attention.length ? "warning" : "info", title: "Current subagent work", sections: [] };
|
|
324
|
+
const items = sections.flatMap((section) => section.rows.map((row, index) => ({ section, index, row })));
|
|
325
|
+
const fitted = fitItems(reportMarkdown(head), items, ({ section, index, row }) => index ? sectionMarkdown({ rows: [row] }) : `\n${sectionMarkdown({ ...section, rows: [row] })}`,
|
|
326
|
+
WORKING_REPORT_CODE_POINTS, "open /subagent with interactive UI to see them.");
|
|
327
|
+
return { ...head, sections: [...sections.flatMap((section) => {
|
|
328
|
+
const rows = fitted.shown.filter((item) => item.section === section).map(({ row }) => row);
|
|
329
|
+
return rows.length ? [{ ...section, rows }] : [];
|
|
330
|
+
}), ...moreSection(fitted.more)] };
|
|
331
|
+
}
|
|
332
|
+
|
|
294
333
|
type Choice<T> = { label: string; value: T };
|
|
295
334
|
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
|
|
296
335
|
// Numeric suffixes only tell duplicate labels apart. Never parse display text into an identity.
|
|
@@ -462,6 +501,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
462
501
|
}
|
|
463
502
|
if (!ctx.hasUI) {
|
|
464
503
|
const inventory = await adapter.isolated(ctx.cwd, current);
|
|
504
|
+
deliver(ctx, workingReport(adapter.direct(ctx), inventory));
|
|
465
505
|
const head: Report = { tone: "info", title: "Isolated requests (report only)", sections: [] };
|
|
466
506
|
const row = (request: IsolatedRequest): Row => ({ text: `${glyph(request.status)} ${request.orphaned ? "orphaned · " : ""}${isolatedLabel(request)}` });
|
|
467
507
|
const listed = fitItems(`${reportMarkdown(head)}\n`, inventory.requests, (request) => sectionMarkdown({ rows: [row(request)] }), ROLES_REPORT_CODE_POINTS,
|
|
@@ -473,6 +513,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
473
513
|
return;
|
|
474
514
|
}
|
|
475
515
|
let history = false;
|
|
516
|
+
// The working report shows on open and on each Refresh, not after other menu actions.
|
|
517
|
+
let report = true;
|
|
476
518
|
for (;;) {
|
|
477
519
|
if (!valid()) return;
|
|
478
520
|
// Direct recovery is independent of Git and isolated config/inventory failures.
|
|
@@ -482,6 +524,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
482
524
|
try { inventory = await adapter.isolated(ctx.cwd, current); }
|
|
483
525
|
catch (error) { unavailable = errorText(error); }
|
|
484
526
|
if (!valid()) return;
|
|
527
|
+
if (report) deliver(ctx, workingReport(direct, inventory));
|
|
528
|
+
report = false;
|
|
485
529
|
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
486
530
|
if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
|
|
487
531
|
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "many" } | { kind: "close" };
|
|
@@ -489,13 +533,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
489
533
|
const group = <T extends { status: string }>(title: string, items: readonly T[], choice: (item: T) => Choice<Selection>): Choice<Selection>[] =>
|
|
490
534
|
items.length ? [{ label: `── ${title} (${items.length}) ──`, value: { kind: "refresh" } },
|
|
491
535
|
...[...items].sort((a, b) => priority(a.status) - priority(b.status)).map(choice)] : [];
|
|
492
|
-
const requests = inventory?.requests ?? [];
|
|
536
|
+
const requests = (inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status));
|
|
493
537
|
const isolatedChoice = (request: IsolatedRequest): Choice<Selection> => ({ label: ` ${glyph(request.status)} ${isolatedLabel(request)}`, value: { kind: "isolated", request, root: inventory!.root } });
|
|
494
538
|
// One row per workflow; new records keep opaque IDs in details. Status is last so clipping preserves the reference.
|
|
495
539
|
const directChoice = (task: DirectTask): Choice<Selection> => ({ label: ` ${glyph(task.status)} ${directLabel(task)} · ${task.status}${costSuffix(task.cost)}${task.workers.length || task.name === task.id ? "" : ` · ${task.id}`}`, value: { kind: "direct", task } });
|
|
496
540
|
const groups = [
|
|
497
541
|
group("Direct", direct.filter((task) => history || !completed(task.status)), directChoice),
|
|
498
|
-
group("Isolated", requests.filter((request) => !request.orphaned
|
|
542
|
+
group("Isolated", requests.filter((request) => !request.orphaned), isolatedChoice),
|
|
499
543
|
group("Orphaned · parent checkout removed", requests.filter((request) => request.orphaned), isolatedChoice),
|
|
500
544
|
].flat();
|
|
501
545
|
const rows = groups.filter(({ value }) => value.kind === "direct" || value.kind === "isolated");
|
|
@@ -509,7 +553,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
509
553
|
if (!unavailable && !direct.length && !inventory?.requests.length && !inventory?.invalidIds.length) ctx.ui.notify("No direct tasks recorded on this session branch or isolated requests in this checkout. Ask Main to start delegation.", "info");
|
|
510
554
|
const selected = await choose(ctx, history ? "Subagent · completed / history" : "Subagent · current work", selections);
|
|
511
555
|
if (!valid() || !selected || selected.kind === "close") return;
|
|
512
|
-
if (selected.kind === "refresh") continue;
|
|
556
|
+
if (selected.kind === "refresh") { report = true; continue; }
|
|
513
557
|
if (selected.kind === "history") { history = !history; continue; }
|
|
514
558
|
if (selected.kind === "many") {
|
|
515
559
|
const picked = await chooseMany(ctx, "Subagent · select work", rows.map(({ label, value }) => ({ label: label.trim(), value })), [], valid);
|
package/extensions/subagent.ts
CHANGED
|
@@ -300,7 +300,10 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
300
300
|
const local = directTasks.get(record.taskId);
|
|
301
301
|
let task = grouped.get(record.taskId);
|
|
302
302
|
if (!task) grouped.set(record.taskId, task = { id: record.taskId, name: record.taskId,
|
|
303
|
-
status: local?.recovery ? "admission retained" : local ? "observed locally"
|
|
303
|
+
status: local?.recovery ? "admission retained" : local ? "observed locally"
|
|
304
|
+
// A recovery result did not prove worker termination, so it stays visible for recovery.
|
|
305
|
+
: results.get(record.taskId)?.recovery ? "needs attention: worker termination unproved"
|
|
306
|
+
: results.get(record.taskId)?.outcome ?? "recorded only; not proof of running or completion",
|
|
304
307
|
...(results.get(record.taskId)?.cost === undefined ? {} : { cost: results.get(record.taskId)!.cost }),
|
|
305
308
|
...(local?.recovery ? { recovery: local.recovery } : {}), canClose: Boolean(local?.close),
|
|
306
309
|
...(local?.stopWorker ? { stoppable: local.stopWorker.running() } : {}), workers: [], tabs: [] });
|