pi-pier 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +116 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/core/subagent.ts
CHANGED
|
@@ -1,75 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* core/subagent — master-only loader entry (tools, registry, poller, GC).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 代际)+ 复活(revive)+ 任务 tab 放置(D25/D26)+ GC(tab 级 + pane 级 + ticker)。
|
|
8
|
-
*
|
|
9
|
-
* 依赖全经 `pi-herdr.subagent-deps`(client/env/extPath/sessionRoot/回调槽…):
|
|
10
|
-
* common 段的 pipe 消费者(applyReplySession/reconcileOnReply)经 slots 反向暴露。
|
|
11
|
-
* reconcileOnSettlement(M17 对账)与 withReconcileNotes/claimSettleNotice 留在
|
|
12
|
-
* index session 状态层,本插件回调消费(③ 的显式留守决策)。
|
|
4
|
+
* Inbound deps via `pi-herdr.subagent-deps`. Outbound pipe/settle queries bind
|
|
5
|
+
* atomically onto `port.current` (see subagent-port.ts). Settlement reconcile
|
|
6
|
+
* stays in index session state; this plugin consumes it through the port.
|
|
13
7
|
*/
|
|
14
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
15
9
|
import { Type } from 'typebox';
|
|
16
10
|
import { randomUUID } from 'node:crypto';
|
|
17
|
-
import {
|
|
18
|
-
import { homedir } from 'node:os';
|
|
19
|
-
import { join, dirname } from 'node:path';
|
|
11
|
+
import { basename, join, dirname } from 'node:path';
|
|
20
12
|
import type { PiSurface } from '../pi-surface.ts';
|
|
21
|
-
import type { HerdrClientLike
|
|
13
|
+
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
22
14
|
import type { TodosService } from '../todos-service.ts';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
15
|
+
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildIsolatePreamble, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planIsolateWorktree, tabNameForTask, type AliveProbe, type SubEntry } from '../subagent-core.ts';
|
|
16
|
+
import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
17
|
+
import { runtimePolicy } from '../runtime-policy.ts';
|
|
18
|
+
import { platformPaths } from '../platform-paths.ts';
|
|
19
|
+
import type { SubagentPort, SubagentPortBox } from '../subagent-port.ts';
|
|
20
|
+
import {
|
|
21
|
+
FOREGROUND_POLL_MS,
|
|
22
|
+
planForegroundTick,
|
|
23
|
+
planIsolateRepoGuard,
|
|
24
|
+
planLaunchValidation,
|
|
25
|
+
planPatienceExpiry,
|
|
26
|
+
} from '../subagent-launch.ts';
|
|
27
|
+
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
29
28
|
import { resolve as pathResolve } from 'node:path';
|
|
30
29
|
import { promisify } from 'node:util';
|
|
31
30
|
const statAsync = promisify(statCb);
|
|
32
|
-
import {
|
|
33
|
-
import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
|
|
31
|
+
import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
|
|
34
32
|
import { composeForRole } from '../manifest-compose.ts';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
33
|
+
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
34
|
+
import type { TerminalStateSlot } from './terminal.ts';
|
|
35
|
+
import { createGitIo } from '../subagent-git-io.ts';
|
|
36
|
+
import { createSessionIo } from '../subagent-session-io.ts';
|
|
37
|
+
import { createSpawner } from '../subagent-spawn.ts';
|
|
38
|
+
import { createPoller } from '../subagent-poll-loop.ts';
|
|
39
|
+
import { createGcController } from '../subagent-gc.ts';
|
|
40
|
+
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
interface SubagentEnv {
|
|
39
43
|
paneId: string;
|
|
40
44
|
tabId: string;
|
|
41
45
|
workspaceId: string;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
export interface SubagentSlots {
|
|
46
|
-
applyReplySession: ((paneId: string, sessionFile: string | null) => void) | null;
|
|
47
|
-
reconcileOnReply: ((paneId: string) => string[]) | null;
|
|
48
|
-
/** D96:running 后台 subagent 列表(master settled 时检查用;worker 侧为 null)。 */
|
|
49
|
-
listRunningSubs: (() => Array<{ paneId: string; description: string }>) | null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface SubagentDeps {
|
|
48
|
+
interface SubagentDeps {
|
|
53
49
|
client: HerdrClientLike;
|
|
54
50
|
env: SubagentEnv | null;
|
|
55
|
-
/**
|
|
51
|
+
/** Extension entry (index.ts) path — launchLine `-e`. */
|
|
56
52
|
extPath: string;
|
|
57
|
-
/**
|
|
53
|
+
/** Master tree root (scope mount). */
|
|
58
54
|
sessionRoot: Context;
|
|
59
|
-
|
|
55
|
+
port: SubagentPortBox;
|
|
60
56
|
getSessionId: () => string;
|
|
61
57
|
getBlockedDepth: () => number;
|
|
62
58
|
reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
|
|
63
59
|
withReconcileNotes: (base: string, notes: readonly string[]) => string;
|
|
64
60
|
claimSettleNotice: (key: string) => boolean;
|
|
65
61
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* Settlement notice injector from index: buffer while busy, flush at turn_end.
|
|
63
|
+
* Tests may omit it and fall back to pi.sendUserMessage(followUp).
|
|
68
64
|
*/
|
|
69
65
|
deliverNotice?: (content: string, paneId?: string) => Promise<void>;
|
|
70
|
-
/**
|
|
66
|
+
/** Panes whose settlement notice is still buffered (GC exemption). */
|
|
71
67
|
noticePending?: () => ReadonlySet<string>;
|
|
72
|
-
/** terminal
|
|
68
|
+
/** Terminal-family GC exemption (live terminal panes are not collected). */
|
|
69
|
+
terminalState: TerminalStateSlot;
|
|
73
70
|
todos: TodosService;
|
|
74
71
|
}
|
|
75
72
|
|
|
@@ -81,25 +78,16 @@ const SUBAGENT_DESCRIPTION = [
|
|
|
81
78
|
'`run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use list_agents to see its state, send_message to give it follow-up work, interrupt_agent to stop it. When it settles, you receive a notification message with its closing output.',
|
|
82
79
|
'`tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
|
|
83
80
|
'`role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
|
|
81
|
+
'`isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean, the worktree auto-removes (the branch is kept for audit). Mutually exclusive with cwd.',
|
|
84
82
|
].join(' ');
|
|
85
83
|
|
|
86
|
-
/**
|
|
84
|
+
/** Subagent concurrency limit (max parallel delegations) */
|
|
87
85
|
const SUBAGENT_CONCURRENCY = 4;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
* 「距最近一次活动」——>10min 的健康长任务不再被误杀(session 01a03c0d 实证)。 */
|
|
91
|
-
const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
|
|
92
|
-
/** 就绪等待上限(TUI 启动 + herdr 检测)。 */
|
|
93
|
-
const SUB_READY_TIMEOUT_MS = 30000;
|
|
94
|
-
/** D94/B4:结算观察窗(此期内 working 默认视为用户接管)。 */
|
|
95
|
-
const OBSERVE_WINDOW_MS = Number(process.env.PI_HERDR_OBSERVE_MS ?? 30000) || 30000;
|
|
96
|
-
/** B4:机器注入宽限——观察窗内的 working 若由自己的 prompt/follow_up 触发,不算接管。 */
|
|
97
|
-
const MACHINE_INJECT_GRACE_MS = Number(process.env.PI_HERDR_INJECT_GRACE_MS ?? 30000) || 30000;
|
|
98
|
-
/** D94:接管态归还阈值(持续 idle 即归还)。 */
|
|
99
|
-
const TAKEOVER_IDLE_MS = Number(process.env.PI_HERDR_TAKEOVER_IDLE_MS ?? 60000) || 60000;
|
|
86
|
+
const SUBAGENT_TIMEOUT_MS = runtimePolicy.subagentTimeoutMs;
|
|
87
|
+
const SUB_READY_TIMEOUT_MS = runtimePolicy.readinessTimeoutMs;
|
|
100
88
|
|
|
101
89
|
function defaultAgentSessionsDir(): string {
|
|
102
|
-
const base = process.env.PI_CODING_AGENT_DIR ||
|
|
90
|
+
const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
|
|
103
91
|
return join(base, 'sessions');
|
|
104
92
|
}
|
|
105
93
|
|
|
@@ -110,10 +98,14 @@ function agentRootDir(): string {
|
|
|
110
98
|
export default function subagentPlugin(ctx: Context): void {
|
|
111
99
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
112
100
|
const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
|
|
113
|
-
const { client, env, sessionRoot,
|
|
101
|
+
const { client, env, sessionRoot, port, terminalState, todos } = d;
|
|
114
102
|
const pi = surface.raw as {
|
|
115
103
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
116
104
|
sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
|
|
105
|
+
sendMessage?: (
|
|
106
|
+
message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
|
|
107
|
+
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
108
|
+
) => Promise<void>;
|
|
117
109
|
};
|
|
118
110
|
const scoped = surface.forModule(import.meta.url);
|
|
119
111
|
|
|
@@ -123,75 +115,82 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
123
115
|
extPath: d.extPath,
|
|
124
116
|
};
|
|
125
117
|
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
126
|
-
/** B4
|
|
127
|
-
* working 若发生在宽限期内,是自己的注入在被处理,不是用户接管。 */
|
|
118
|
+
/** B4 timestamps machine injection so grace-period working events are not mistaken for takeover. */
|
|
128
119
|
const lastMachineInjectAt = new Map<string, number>();
|
|
129
|
-
/*
|
|
120
|
+
/* Persist the subagent registry as custom branch state so parent restarts can rebuild it. */
|
|
130
121
|
const subs = new Map<string, SubEntry>();
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
/** D50
|
|
122
|
+
/** D98 excludes branches during worktree-add-to-registry races from orphan collection. */
|
|
123
|
+
const pendingIsolateBranches = new Set<string>();
|
|
124
|
+
/** D50 tracks the latest machine request per pane for interrupt claims and poll deduplication. */
|
|
134
125
|
const lastRequestIdByPane = new Map<string, string>();
|
|
135
126
|
|
|
136
|
-
/** O1
|
|
127
|
+
/** O1 suppresses duplicate snapshots because 01a03c0d found 48% of session rows were heartbeat repeats. */
|
|
137
128
|
let lastSubsSnapshot = '';
|
|
138
129
|
|
|
139
130
|
function persistSubs(): void {
|
|
140
131
|
try {
|
|
141
132
|
const reg = makeRegistry([...subs.values()]);
|
|
142
133
|
const snap = JSON.stringify(reg);
|
|
143
|
-
if (snap === lastSubsSnapshot) return; //
|
|
134
|
+
if (snap === lastSubsSnapshot) return; // Avoid duplicate writes from the 1s/5s observation loops.
|
|
144
135
|
lastSubsSnapshot = snap;
|
|
145
136
|
pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
|
|
146
137
|
} catch {
|
|
147
|
-
/*
|
|
138
|
+
/* Persistence is best-effort so session logging cannot break delegation. */
|
|
148
139
|
}
|
|
149
140
|
}
|
|
150
|
-
/** E2
|
|
141
|
+
/** E2 deduplicates gate notices while blocked but permits a later, distinct human question. */
|
|
151
142
|
const blockedGateNotified = new Set<string>();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
143
|
+
const git = createGitIo();
|
|
144
|
+
|
|
145
|
+
const boundPort: SubagentPort = {
|
|
146
|
+
applyReplySession(paneId, sessionFile) {
|
|
147
|
+
const entry = subs.get(paneId);
|
|
148
|
+
if (!entry) return;
|
|
149
|
+
const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
|
|
150
|
+
if (next === entry.sessionFile) return;
|
|
151
|
+
entry.sessionFile = next;
|
|
152
|
+
persistSubs();
|
|
153
|
+
writeHistory(entry, undefined, 'session-report');
|
|
154
|
+
},
|
|
155
|
+
reconcileOnReply(paneId) {
|
|
156
|
+
const entry = subs.get(paneId);
|
|
157
|
+
return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
|
|
158
|
+
},
|
|
159
|
+
listRunningSubs() {
|
|
160
|
+
return [...subs.values()]
|
|
161
|
+
.filter((s) => s.background && s.status === 'running')
|
|
162
|
+
.map((s) => ({ paneId: s.paneId, description: s.description }));
|
|
163
|
+
},
|
|
164
|
+
async settleStatLine(paneId) {
|
|
165
|
+
const entry = subs.get(paneId);
|
|
166
|
+
return entry ? await git.worktreeStatLine(entry) : null;
|
|
167
|
+
},
|
|
172
168
|
};
|
|
173
|
-
|
|
169
|
+
port.current = boundPort;
|
|
170
|
+
ctx.effect(() => () => {
|
|
171
|
+
if (port.current === boundPort) port.current = null;
|
|
172
|
+
}, 'subagent-port');
|
|
174
173
|
function rebuildSubs(eventCtx: unknown): void {
|
|
175
174
|
try {
|
|
176
175
|
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
177
176
|
?.sessionManager?.getBranch?.() ?? [];
|
|
178
177
|
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
179
178
|
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
180
|
-
lastSubsSnapshot = ''; //
|
|
179
|
+
lastSubsSnapshot = ''; // Force persistence because branch replay replaced live state.
|
|
181
180
|
} catch {
|
|
182
|
-
/*
|
|
181
|
+
/* Registry recovery failure must not block the live session. */
|
|
183
182
|
}
|
|
184
183
|
}
|
|
185
184
|
|
|
186
|
-
/** B6
|
|
187
|
-
*
|
|
185
|
+
/** B6 closes crash-stale running rows only after herdr confirms their pane is gone; 01a03c0d
|
|
186
|
+
* showed p6/p7 otherwise remained permanently running. */
|
|
188
187
|
async function sweepZombieRunning(): Promise<void> {
|
|
189
188
|
if (!client.available || subs.size === 0) return;
|
|
190
189
|
let livePaneIds: ReadonlySet<string>;
|
|
191
190
|
try {
|
|
192
191
|
livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
|
|
193
192
|
} catch {
|
|
194
|
-
return; //
|
|
193
|
+
return; // Do not close agents when liveness lookup itself failed.
|
|
195
194
|
}
|
|
196
195
|
let changed = false;
|
|
197
196
|
for (const [paneId, e] of subs) {
|
|
@@ -212,14 +211,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
212
211
|
await sweepZombieRunning();
|
|
213
212
|
});
|
|
214
213
|
|
|
215
|
-
/* ── B1
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
|
|
214
|
+
/* ── B1 liveness rewrite for subagent errors ─────────────────────
|
|
215
|
+
* A false no-output result caused the model to seize work from a healthy subagent. Probe
|
|
216
|
+
* agent.list and session mtime before the tool result enters model context; if alive, emit the
|
|
217
|
+
* same notice as A2 backgrounding. This hook enforces what prompting alone could not. */
|
|
220
218
|
scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
|
|
221
219
|
if (event?.toolName !== 'subagent' || !event.isError) return;
|
|
222
|
-
//
|
|
220
|
+
// Extract only pane ids carried by our own errors; leave unrelated errors untouched.
|
|
223
221
|
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
224
222
|
const paneId = [...subs.keys()].find((id) => errText.includes(id))
|
|
225
223
|
?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
|
|
@@ -227,8 +225,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
227
225
|
const entry = subs.get(paneId);
|
|
228
226
|
if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
|
|
229
227
|
const probe = await probeAlive(paneId, entry.cwd);
|
|
230
|
-
if (!isAlive(probe, Date.now())) return; //
|
|
231
|
-
//
|
|
228
|
+
if (!isAlive(probe, Date.now())) return; // Preserve the original error only when the agent is truly dead.
|
|
229
|
+
// Move a live agent to the poller so its eventual result can replace the false error.
|
|
232
230
|
if (!pollers.has(paneId)) {
|
|
233
231
|
entry.background = true;
|
|
234
232
|
startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
|
|
@@ -241,410 +239,70 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
241
239
|
return { content: [{ type: 'text', text: notice }] };
|
|
242
240
|
});
|
|
243
241
|
|
|
244
|
-
/* ── D41
|
|
242
|
+
/* ── D41 safe reminders for unfinished todos ─────────────────────
|
|
243
|
+
* 01a040cc showed that a user-role reminder overrode model judgment and executed work reserved
|
|
244
|
+
* for the user. Use a distinguishable custom message, request reconciliation rather than action,
|
|
245
|
+
* and delay delivery so any new agent start cancels it. Preserve the ESC guard from 01a03bf0 to
|
|
246
|
+
* avoid wake-up storms after explicit interruption. */
|
|
245
247
|
|
|
246
248
|
let todoReminders = 0;
|
|
247
|
-
const TODO_REMINDERS_MAX = 3;
|
|
248
|
-
// 反唤醒风暴:用户 ESC 中止后的 settled 不催(否则 abort → 提醒 → 新 run → 再 abort
|
|
249
|
-
// 死循环;与 index 的 D96/结算缓冲同款守卫,session 01a03bf0 实证)。
|
|
250
249
|
let lastAssistantStopReason: string | null = null;
|
|
250
|
+
let todoReminderTimer: NodeJS.Timeout | null = null;
|
|
251
|
+
|
|
252
|
+
function cancelTodoReminder(): void {
|
|
253
|
+
if (todoReminderTimer !== null) {
|
|
254
|
+
clearTimeout(todoReminderTimer);
|
|
255
|
+
todoReminderTimer = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
251
259
|
scoped.on('turn_end', async (event: unknown) => {
|
|
252
260
|
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
253
|
-
const msg = (event as { message: unknown }).message; //
|
|
261
|
+
const msg = (event as { message: unknown }).message; // Safe after the property guard above.
|
|
254
262
|
if (msg === null || typeof msg !== 'object') return;
|
|
255
263
|
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
256
264
|
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
257
265
|
lastAssistantStopReason = stopReason;
|
|
258
266
|
}
|
|
259
267
|
});
|
|
260
|
-
scoped.on('agent_settled', async () => {
|
|
261
|
-
if (lastAssistantStopReason === ABORT_STOP_REASON) return;
|
|
262
|
-
if (todoReminders >= TODO_REMINDERS_MAX) return;
|
|
263
|
-
if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
|
|
264
|
-
if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
|
|
265
|
-
const open = todos.items.filter((it) => it.status === 'pending' || it.status === 'in_progress');
|
|
266
|
-
if (open.length === 0) return;
|
|
267
|
-
todoReminders += 1;
|
|
268
|
-
const list = open
|
|
269
|
-
.map((it) => (it.status === 'in_progress' ? `▶ ${it.content}` : `· ${it.content}`))
|
|
270
|
-
.join('\n');
|
|
271
|
-
const reminder = `<system-reminder>You stopped with unfinished todos (Reminder ${todoReminders}/${TODO_REMINDERS_MAX}):\n${list}\nContinue working on them before stopping, or call todo_write to update the list if they no longer apply.</system-reminder>`;
|
|
272
|
-
try {
|
|
273
|
-
await pi.sendUserMessage?.(reminder, { deliverAs: 'followUp' });
|
|
274
|
-
} catch {
|
|
275
|
-
/* 静默 */
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
/* ── 通道助手(M11:就绪 = 管道握手) ── */
|
|
280
|
-
|
|
281
|
-
/** 就绪等待:管道 ping(子扩展 session_start 起 server 即就绪;D47)。 */
|
|
282
|
-
async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
|
|
283
|
-
return pingUntilReady(pipeNameFor(cwd, paneId), SUB_READY_TIMEOUT_MS);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* 子代理会话文件候选(v1.3 M7 修复结算文本串线,实测):
|
|
288
|
-
* 1. 上报路径(本扩展 report_agent_session 的 .jsonl path);
|
|
289
|
-
* 2. 上报 session id → 按 id 还原;
|
|
290
|
-
* 3. 目录内最新 4 个(排除主控自己的会话——父在写盘时其 mtime 常最新,
|
|
291
|
-
* 曾实测致结算文本取到父的回复)。
|
|
292
|
-
*/
|
|
293
|
-
async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
|
|
294
|
-
const out: string[] = [];
|
|
295
|
-
try {
|
|
296
|
-
const reported = await client.getAgentSessionPath(paneId);
|
|
297
|
-
if (reported) {
|
|
298
|
-
if (/\.jsonl$/.test(reported)) out.push(reported);
|
|
299
|
-
else {
|
|
300
|
-
const byId = sessionFileById(cwd, defaultAgentSessionsDir(), reported);
|
|
301
|
-
if (byId) out.push(byId);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
305
|
-
/* 走目录扫描 */
|
|
306
|
-
}
|
|
307
|
-
const ownSession = d.getSessionId();
|
|
308
|
-
for (const f of listSessionFiles(cwd, defaultAgentSessionsDir(), 4)) {
|
|
309
|
-
if (f !== ownSession && !out.includes(f)) out.push(f);
|
|
310
|
-
}
|
|
311
|
-
return out;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** 定位可解析的子代理会话文件(spawn/结算登记用)。 */
|
|
315
|
-
async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
|
|
316
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
317
|
-
if (readSessionFile(file)) return file;
|
|
318
|
-
}
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** 结算后(带重试)取注入时间点之后的最终回答。 */
|
|
323
|
-
async function collectFinalText(
|
|
324
|
-
paneId: string,
|
|
325
|
-
cwd: string,
|
|
326
|
-
sinceTs: number,
|
|
327
|
-
attempts = 12,
|
|
328
|
-
): Promise<string | null> {
|
|
329
|
-
for (let i = 0; i < attempts; i++) {
|
|
330
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
331
|
-
const entries = readSessionFile(file);
|
|
332
|
-
if (!entries) continue;
|
|
333
|
-
const r = lastAssistantText(entries, { sinceTs });
|
|
334
|
-
if (r?.text) return r.text;
|
|
335
|
-
}
|
|
336
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
337
|
-
}
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** E1/E2:读子 pane 的人类闸门问题(tokens['pi-ask'];子扩展 reportAskFlag 上报)。 */
|
|
342
|
-
async function readAskFlag(paneId: string): Promise<string | null> {
|
|
343
|
-
try {
|
|
344
|
-
const a = (await client.listAgents()).find((x) => x.paneId === paneId);
|
|
345
|
-
const v = a?.tokens?.['pi-ask'];
|
|
346
|
-
return typeof v === 'string' && v ? v : null;
|
|
347
|
-
} catch {
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** A2/B1 探活:pane 实时状态 + 会话候选最新 mtime(毫秒级,失败字段为 null)。 */
|
|
353
|
-
async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
|
|
354
|
-
const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
|
|
355
|
-
try {
|
|
356
|
-
const agents = await client.listAgents();
|
|
357
|
-
const a = agents.find((x) => x.paneId === paneId);
|
|
358
|
-
probe.paneExists = a != null;
|
|
359
|
-
probe.agentStatus = a?.status ?? null;
|
|
360
|
-
} catch {
|
|
361
|
-
/* agent.list 失败 → 状态未知,靠会话活动判定 */
|
|
362
|
-
}
|
|
363
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
364
|
-
try {
|
|
365
|
-
const mtime = (await statAsync(file)).mtimeMs;
|
|
366
|
-
if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
|
|
367
|
-
} catch {
|
|
368
|
-
/* 文件消失跳过 */
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
return probe;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* 子会话结算状态(v1.3 M8 结算竞态修复):
|
|
376
|
-
* herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
|
|
377
|
-
* 以会话内容为准——定稿文本 = 已结算;挂起 toolCall = 未结算;
|
|
378
|
-
* 有 assistant 活动但无文本 = 真·无输出;无活动 = 还没开始。
|
|
379
|
-
*/
|
|
380
|
-
async function subSessionState(
|
|
381
|
-
paneId: string,
|
|
382
|
-
cwd: string,
|
|
383
|
-
sinceTs: number,
|
|
384
|
-
): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
|
|
385
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
386
|
-
const entries = readSessionFile(file);
|
|
387
|
-
if (entries.length === 0) continue;
|
|
388
|
-
const r = lastAssistantText(entries, sinceTs);
|
|
389
|
-
if (r?.text) return { text: r.text, pendingTool: false, activity: true };
|
|
390
|
-
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
|
|
391
|
-
if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
|
|
392
|
-
}
|
|
393
|
-
return { text: null, pendingTool: false, activity: false };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
|
|
397
|
-
const injectNotice = (content: string): Promise<void> =>
|
|
398
|
-
d.deliverNotice ? d.deliverNotice(content)
|
|
399
|
-
: (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
|
|
400
268
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
cwd: string,
|
|
405
|
-
spawnedAt: number,
|
|
406
|
-
injectTs: number,
|
|
407
|
-
description: string,
|
|
408
|
-
requestId: string,
|
|
409
|
-
): Promise<void> {
|
|
410
|
-
void spawnedAt;
|
|
411
|
-
// B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
|
|
412
|
-
// 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
|
|
413
|
-
// 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
|
|
414
|
-
const startedAt = Date.now();
|
|
415
|
-
let lastActivityAt = Date.now();
|
|
416
|
-
try {
|
|
417
|
-
while (true) {
|
|
418
|
-
const entry = subs.get(paneId);
|
|
419
|
-
if (!entry || entry.status === 'settled') return;
|
|
420
|
-
|
|
421
|
-
// D94:归还检测——若用户已接管且 idle 超 60s,自动归还控制权
|
|
422
|
-
if (entry.userTakeover) {
|
|
423
|
-
try {
|
|
424
|
-
const agents = await client.listAgents();
|
|
425
|
-
const agent = agents.find((a) => a.paneId === paneId);
|
|
426
|
-
if (agent) {
|
|
427
|
-
const currentStatus = agent.status;
|
|
428
|
-
const previousStatus = entry.lastAgentStatus;
|
|
429
|
-
|
|
430
|
-
if (currentStatus === 'idle') {
|
|
431
|
-
// 状态刚变为 idle(从 working/blocked 转来)→ 记录 idle 起点
|
|
432
|
-
if (previousStatus !== 'idle') {
|
|
433
|
-
entry.observationStartedAt = Date.now();
|
|
434
|
-
entry.lastAgentStatus = 'idle';
|
|
435
|
-
persistSubs();
|
|
436
|
-
} else if (entry.observationStartedAt) {
|
|
437
|
-
// 持续 idle → 检查是否超过 60s
|
|
438
|
-
if (Date.now() - entry.observationStartedAt > 60000) {
|
|
439
|
-
// Idle 超 60s,归还控制权
|
|
440
|
-
entry.userTakeover = false;
|
|
441
|
-
entry.observationStartedAt = null;
|
|
442
|
-
entry.lastAgentStatus = null;
|
|
443
|
-
persistSubs();
|
|
444
|
-
// 继续正常管理流程(下一轮会走正常 waitAgent 分支)
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
} else {
|
|
448
|
-
// working/blocked → 重置 idle 计时(只记录状态,不记时间)
|
|
449
|
-
entry.lastAgentStatus = currentStatus;
|
|
450
|
-
entry.observationStartedAt = null; // 清除 idle 计时
|
|
451
|
-
persistSubs();
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
} catch {
|
|
455
|
-
// listAgents 失败静默
|
|
456
|
-
}
|
|
457
|
-
// 已接管,跳过正常管理逻辑,5s 后再检查
|
|
458
|
-
if (entry.userTakeover) {
|
|
459
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
460
|
-
continue;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
let state: HerdrAgentState | null;
|
|
465
|
-
try {
|
|
466
|
-
state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
|
|
467
|
-
} catch {
|
|
468
|
-
state = null;
|
|
469
|
-
}
|
|
470
|
-
if (state === 'blocked') {
|
|
471
|
-
// E2(人类闸门):blocked 时 master 对话流里也要知道(此前只有 herdr 系统
|
|
472
|
-
// 通知发给人类,master 毫不知情 → 可能自作主张揽活)。一次性 gate 通知注入;
|
|
473
|
-
// 解除(idle)后重置,二次 ask 新问题可再通知。
|
|
474
|
-
if (!blockedGateNotified.has(paneId)) {
|
|
475
|
-
blockedGateNotified.add(paneId);
|
|
476
|
-
const question = await readAskFlag(paneId);
|
|
477
|
-
try {
|
|
478
|
-
await injectNotice(buildBlockedGateNotice({ paneId, description, question }));
|
|
479
|
-
} catch {
|
|
480
|
-
/* 注入失败静默(下次 turn 可 list_agents 自查) */
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
continue; // 人类闸门:保持 running
|
|
484
|
-
}
|
|
485
|
-
if (state !== null) blockedGateNotified.delete(paneId); // 闸门解除(working/idle)
|
|
486
|
-
if (state === 'idle' || state === 'done') {
|
|
487
|
-
const s = await subSessionState(paneId, cwd, injectTs);
|
|
488
|
-
if (s.text || (!s.pendingTool && s.activity)) {
|
|
489
|
-
// Settled
|
|
490
|
-
const closing = s.text;
|
|
269
|
+
// B3 cancels the reminder when any source resumes work during the grace window.
|
|
270
|
+
scoped.on('agent_start', () => cancelTodoReminder());
|
|
271
|
+
scoped.on('session_shutdown', () => cancelTodoReminder());
|
|
491
272
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
// 20min 后投递,恰好撞进观察窗)。
|
|
510
|
-
try {
|
|
511
|
-
const agents = await client.listAgents();
|
|
512
|
-
const agent = agents.find((a) => a.paneId === paneId);
|
|
513
|
-
if (agent && agent.status === 'working') {
|
|
514
|
-
const injectedAgo = Date.now() - (lastMachineInjectAt.get(paneId) ?? 0);
|
|
515
|
-
if (injectedAgo > MACHINE_INJECT_GRACE_MS) {
|
|
516
|
-
// 用户介入!标记接管,取消结算
|
|
517
|
-
entry.userTakeover = true;
|
|
518
|
-
entry.observationStartedAt = Date.now();
|
|
519
|
-
entry.lastAgentStatus = 'working';
|
|
520
|
-
persistSubs();
|
|
521
|
-
continue; // 跳过结算,进入用户接管模式
|
|
522
|
-
}
|
|
523
|
-
// 机器注入在处理中 → 归零观察窗,等这轮 run 收尾再判结算
|
|
524
|
-
entry.observationStartedAt = Date.now();
|
|
525
|
-
persistSubs();
|
|
526
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
} catch {
|
|
530
|
-
// listAgents 失败静默
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (elapsed < 30000) {
|
|
534
|
-
// 观察期未结束,继续等待
|
|
535
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// 观察期结束(30s 无用户介入),正常结算
|
|
540
|
-
entry.observationStartedAt = null; // 清除观察期标记
|
|
541
|
-
}
|
|
542
|
-
// 正常结算逻辑
|
|
543
|
-
// O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
|
|
544
|
-
if (!entry.sessionFile) {
|
|
545
|
-
entry.sessionFile = applyReportedSessionFile(
|
|
546
|
-
entry.sessionFile,
|
|
547
|
-
await resolveSessionFile(paneId, cwd),
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
entry.status = 'consumed';
|
|
551
|
-
entry.consumedAt = Date.now();
|
|
552
|
-
writeHistory(entry, { outcome: closing }, 'poll-settle');
|
|
553
|
-
// M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
|
|
554
|
-
const notes = d.reconcileOnSettlement(description, 'settled');
|
|
555
|
-
const notice = d.withReconcileNotes(
|
|
556
|
-
formatSettlementNotice(`${paneId} (${description})`, closing),
|
|
557
|
-
notes,
|
|
558
|
-
);
|
|
559
|
-
if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
560
|
-
try {
|
|
561
|
-
await injectNotice(notice);
|
|
562
|
-
} catch {
|
|
563
|
-
/* 注入失败静默(下次 turn 可 list_agents 自查) */
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
// 挂起 toolCall(等人类输入)或还没开工 → 继续等
|
|
569
|
-
}
|
|
570
|
-
// B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
|
|
571
|
-
if (state === null) lastActivityAt = Date.now();
|
|
572
|
-
// 切片超时:检查 pane 存活与总预算
|
|
573
|
-
let alive = false;
|
|
273
|
+
scoped.on('agent_settled', async () => {
|
|
274
|
+
cancelTodoReminder(); // Cancel any timer left from the previous settlement.
|
|
275
|
+
const plan = planStopTodoReminder({
|
|
276
|
+
lastStopReason: lastAssistantStopReason,
|
|
277
|
+
reminders: todoReminders,
|
|
278
|
+
runningSubs: pollers.size,
|
|
279
|
+
blockedDepth: d.getBlockedDepth(),
|
|
280
|
+
items: todos.items,
|
|
281
|
+
});
|
|
282
|
+
if (!plan.due || plan.content == null) return;
|
|
283
|
+
const content = plan.content;
|
|
284
|
+
todoReminderTimer = setTimeout(() => {
|
|
285
|
+
todoReminderTimer = null;
|
|
286
|
+
void (async () => {
|
|
287
|
+
// Without sendMessage, skip the reminder rather than impersonating the user channel.
|
|
288
|
+
const send = pi.sendMessage;
|
|
289
|
+
if (typeof send !== 'function') return;
|
|
574
290
|
try {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
if (!alive) {
|
|
580
|
-
entry.status = 'consumed';
|
|
581
|
-
entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
|
|
582
|
-
persistSubs();
|
|
583
|
-
writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
|
|
584
|
-
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
585
|
-
const notice = d.withReconcileNotes(
|
|
586
|
-
`Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
|
|
587
|
-
notes,
|
|
588
|
-
);
|
|
589
|
-
try {
|
|
590
|
-
await injectNotice(notice);
|
|
591
|
-
} catch {
|
|
592
|
-
/* 静默 */
|
|
593
|
-
}
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
// B1:无活动预算超时——working 心跳持续续命,此处只在「pane 活着但
|
|
597
|
-
// 既不 working、也无结算进展」真空期超预算时触发(旧总墙钟 10min 误杀
|
|
598
|
-
// 27min 健康长任务 ×2,01a03c0d 实证)。
|
|
599
|
-
if (Date.now() - lastActivityAt > SUBAGENT_TIMEOUT_MS) {
|
|
600
|
-
entry.status = 'consumed';
|
|
601
|
-
entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
|
|
602
|
-
persistSubs();
|
|
603
|
-
writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
604
|
-
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
605
|
-
// O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
|
|
606
|
-
// 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
|
|
607
|
-
const notice = d.withReconcileNotes(
|
|
608
|
-
`Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
|
|
609
|
-
notes,
|
|
291
|
+
await send(
|
|
292
|
+
{ customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
293
|
+
{ deliverAs: 'followUp', triggerTurn: true },
|
|
610
294
|
);
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
/* 静默 */
|
|
615
|
-
}
|
|
616
|
-
return;
|
|
295
|
+
todoReminders += 1; // Count only delivered reminders so cancellation and failure do not consume the cap.
|
|
296
|
+
} catch {
|
|
297
|
+
/* Delivery failure is non-fatal. */
|
|
617
298
|
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
|
|
625
|
-
if (pollers.has(paneId)) return;
|
|
626
|
-
pollers.add(paneId);
|
|
627
|
-
void (async () => {
|
|
628
|
-
if (!subScopes.has(paneId)) {
|
|
629
|
-
const fiber = await mountSubagentScope(sessionRoot, paneId, {
|
|
630
|
-
onDispose: () => { pollers.delete(paneId); },
|
|
631
|
-
});
|
|
632
|
-
subScopes.set(paneId, fiber);
|
|
633
|
-
}
|
|
634
|
-
await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
|
|
635
|
-
const fiber = subScopes.get(paneId);
|
|
636
|
-
subScopes.delete(paneId);
|
|
637
|
-
try { await fiber?.dispose(); } catch { /* already gone */ }
|
|
638
|
-
})();
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/* ── 工具:subagent(v1.3:任务 tab 放置 + 短/长 pane + 历史 + GC) ── */
|
|
642
|
-
|
|
643
|
-
/** 串行化 tab 创建/追加(同消息并行委派的读改写互斥;D26 放置决策在锁内做)。 */
|
|
644
|
-
const tabMutex = new Semaphore(1);
|
|
299
|
+
})();
|
|
300
|
+
}, todoReminderGraceMs());
|
|
301
|
+
todoReminderTimer.unref?.(); // Do not keep tests or headless processes alive for a reminder.
|
|
302
|
+
});
|
|
645
303
|
|
|
646
304
|
function histFile(cwd: string): string {
|
|
647
|
-
return
|
|
305
|
+
return preferredHistoryFile(agentRootDir(), cwd);
|
|
648
306
|
}
|
|
649
307
|
|
|
650
308
|
function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
|
|
@@ -668,8 +326,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
668
326
|
};
|
|
669
327
|
}
|
|
670
328
|
|
|
671
|
-
/** B5
|
|
672
|
-
*
|
|
329
|
+
/** B5 retains the latest non-empty outcome because closed generations otherwise hid settled
|
|
330
|
+
* results behind outcome:null in latest-generation views. */
|
|
673
331
|
const lastOutcomeByTask = new Map<string, string>();
|
|
674
332
|
|
|
675
333
|
function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
|
|
@@ -678,283 +336,61 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
678
336
|
appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
|
|
679
337
|
}
|
|
680
338
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const ws = env?.workspaceId ?? '';
|
|
685
|
-
return (await client.tabList())
|
|
686
|
-
.filter((t) => !ws || t.workspaceId === ws)
|
|
687
|
-
.map((t) => ({ tabName: t.label, tabId: t.tabId }));
|
|
688
|
-
} catch {
|
|
689
|
-
return [];
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/* ── D86:git worktree 分组键 ─────────────────────────────────── */
|
|
694
|
-
|
|
695
|
-
/** worktree 列表缓存(spawn 时一次查询;TTL 短——放置只需近似新鲜度)。 */
|
|
696
|
-
let worktreesCache: { at: number; list: string[] } | null = null;
|
|
697
|
-
const WORKTREES_CACHE_MS = 5000;
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* 列出 repo 的全部 git worktree(`git worktree list --porcelain`,主检出在前)。
|
|
701
|
-
* 非 git 目录 / git 不可用 → 空数组(分类器自然全兜底 main,规则退化不炸)。
|
|
702
|
-
*/
|
|
703
|
-
async function listWorktrees(cwd: string): Promise<string[]> {
|
|
704
|
-
if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
|
|
705
|
-
const list = await new Promise<string[]>((resolve) => {
|
|
706
|
-
execFile('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { timeout: 5000 }, (err, stdout) => {
|
|
707
|
-
if (err) return resolve([]);
|
|
708
|
-
const out: string[] = [];
|
|
709
|
-
for (const line of String(stdout).split('\n')) {
|
|
710
|
-
const m = /^worktree (.+)$/.exec(line.trim());
|
|
711
|
-
if (m) out.push(m[1]);
|
|
712
|
-
}
|
|
713
|
-
resolve(out);
|
|
714
|
-
});
|
|
715
|
-
});
|
|
716
|
-
worktreesCache = { at: Date.now(), list };
|
|
717
|
-
return list;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* 按放置计划在任务 tab 内新建子 pane(v1.3 D25/D26 + D86 worktree 分组):
|
|
722
|
-
* - new:tab.create{label}(focus 默认 false 不抢焦点)+ 根 pane 注入启动命令;
|
|
723
|
-
* - append:校验既有 tab 存活 → split 追加(进程不重启);
|
|
724
|
-
* tab 已消失 → 降级为同名的 new。放置决策在互斥锁内做(同名竞态)。
|
|
725
|
-
* D86:placement.zone 由调用方算好传入(main → master 所在 tab;worktree → 目录名 tab)。
|
|
726
|
-
*/
|
|
727
|
-
async function spawnPaneInTaskTab(
|
|
728
|
-
placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
|
|
729
|
-
cwd: string,
|
|
730
|
-
envOver: Record<string, string>,
|
|
731
|
-
launch: string,
|
|
732
|
-
): Promise<{ tabId: string; paneId: string; tabName: string }> {
|
|
733
|
-
const release = await tabMutex.acquire();
|
|
734
|
-
try {
|
|
735
|
-
// D86:main tab = master pane 所在 tab(paneId 反查;HERDR_TAB_ID 注入不可依赖,实测可为空)
|
|
736
|
-
const allPanes = await client.listPanes();
|
|
737
|
-
const mainTabId = allPanes.find((p) => p.paneId === env?.paneId)?.tabId
|
|
738
|
-
?? (env?.tabId ? env.tabId : null);
|
|
739
|
-
let plan: TabPlacementPlan = planTabPlacement({
|
|
740
|
-
desiredTab: placement.desiredTab,
|
|
741
|
-
description: placement.description,
|
|
742
|
-
knownTabs: await liveTabs(),
|
|
743
|
-
zone: placement.zone,
|
|
744
|
-
mainTabId,
|
|
745
|
-
});
|
|
746
|
-
if (plan.mode === 'append' && plan.tabId) {
|
|
747
|
-
// D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
|
|
748
|
-
// board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
|
|
749
|
-
const exclude = new Set(
|
|
750
|
-
allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
|
|
751
|
-
);
|
|
752
|
-
let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
|
|
753
|
-
try {
|
|
754
|
-
const snapshot = await client.exportLayout({ tabId: plan.tabId });
|
|
755
|
-
const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
|
|
756
|
-
if (tree) pick = pickGridSplit(tree, { exclude });
|
|
757
|
-
} catch { /* 布局导出失败 → 回退锚分裂(旧行为) */ }
|
|
758
|
-
// 锚兜底:agent 已知状态的工作 pane 优先(避开 board pane)
|
|
759
|
-
const anchorPaneId = pick?.targetPaneId
|
|
760
|
-
?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
|
|
761
|
-
?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
|
|
762
|
-
if (anchorPaneId) {
|
|
763
|
-
const paneId = await client.splitPane({
|
|
764
|
-
direction: pick?.direction ?? 'down',
|
|
765
|
-
cwd,
|
|
766
|
-
env: envOver,
|
|
767
|
-
targetPaneId: anchorPaneId,
|
|
768
|
-
});
|
|
769
|
-
await client.sendPaneText(paneId, launch);
|
|
770
|
-
return { tabId: plan.tabId!, paneId, tabName: plan.tabName };
|
|
771
|
-
}
|
|
772
|
-
// 锚 pane 缺失(tab 已空/已关)→ 同名新 tab;main tab 消失(罕见)→ 兜底新 tab
|
|
773
|
-
plan = { mode: 'new', tabName: plan.tabName, tabId: null };
|
|
774
|
-
}
|
|
775
|
-
const created = await client.createTab({
|
|
776
|
-
workspaceId: env?.workspaceId ?? '',
|
|
777
|
-
label: plan.tabName,
|
|
778
|
-
cwd,
|
|
779
|
-
env: envOver,
|
|
780
|
-
});
|
|
781
|
-
await client.sendPaneText(created.paneId, launch);
|
|
782
|
-
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
783
|
-
} finally {
|
|
784
|
-
release();
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
|
|
789
|
-
// 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
|
|
790
|
-
// argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
|
|
791
|
-
return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* D86 信任旗标:委派即信任,但仅限 master 自己的检出与其 worktree(同 git 仓库 =
|
|
796
|
-
* 同一批项目文件,master 本就载着它们跑)。外来目录不加 -a —— pi 的 Trust 对话框
|
|
797
|
-
* 成为天然闸门(spawn 会在握手超时处失败,人不点头不执行未知项目扩展)。
|
|
798
|
-
*/
|
|
799
|
-
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
800
|
-
if (isPathUnder(cwd, masterCwd)) return true;
|
|
801
|
-
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await listWorktrees(masterCwd) });
|
|
802
|
-
return zone.zone === 'worktree';
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/* ── GC:turn_start 回收(v1.3 M8:tab 级为主 + pane 级兼容/孤儿路径) ── */
|
|
806
|
-
|
|
807
|
-
let prevTurnStart = Date.now();
|
|
808
|
-
|
|
809
|
-
async function gcPass(): Promise<void> {
|
|
810
|
-
if (subs.size === 0) return;
|
|
811
|
-
// D44:唯一用户可见开关——TTL 秒(默认 600);0 = 不自动关(只由人关)
|
|
812
|
-
const ttlMs = Number(process.env.PI_HERDR_TASK_TAB_TTL ?? 600) * 1000;
|
|
813
|
-
const autoCloseTabs = ttlMs > 0;
|
|
814
|
-
let panesList: Array<{ paneId: string; tabId: string; agentStatus: string }>;
|
|
815
|
-
try {
|
|
816
|
-
panesList = await client.listPanes();
|
|
817
|
-
} catch {
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
// D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
|
|
821
|
-
const termPaneIds = terminalState.activePaneIds();
|
|
822
|
-
// B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
|
|
823
|
-
const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
|
|
824
|
-
|
|
825
|
-
// 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
|
|
826
|
-
const byTab = new Map<string, SubEntry[]>();
|
|
827
|
-
for (const e of subs.values()) {
|
|
828
|
-
if (!e.tabId) continue;
|
|
829
|
-
const arr = byTab.get(e.tabId) ?? [];
|
|
830
|
-
arr.push(e);
|
|
831
|
-
byTab.set(e.tabId, arr);
|
|
832
|
-
}
|
|
833
|
-
const taskTabIds = new Set(byTab.keys());
|
|
834
|
-
// D86 R4:main tab(master 所在)永不整关——它的 consumed 子代理走 pane 级回收
|
|
835
|
-
const mainTabId = env?.tabId ?? '';
|
|
836
|
-
|
|
837
|
-
// 1) tab 级(判定规则在 gc-core.shouldCloseTaskTab,纯函数单测覆盖)
|
|
838
|
-
for (const [tabId, entries] of byTab) {
|
|
839
|
-
if (tabId === mainTabId) continue; // D86 R4:main tab 豁免(防 master 连坐)
|
|
840
|
-
const tabPanes = panesList.filter((p) => p.tabId === tabId);
|
|
841
|
-
if (tabPanes.length === 0) {
|
|
842
|
-
// tab 已消失(人关/自动关)→ 补记 closed
|
|
843
|
-
for (const e of entries) {
|
|
844
|
-
if (e.status !== 'closed') {
|
|
845
|
-
e.status = 'closed';
|
|
846
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
const should = shouldCloseTaskTab({
|
|
852
|
-
entries,
|
|
853
|
-
paneStatuses: tabPanes.map((p) => p.agentStatus),
|
|
854
|
-
ttlMs,
|
|
855
|
-
now: Date.now(),
|
|
856
|
-
});
|
|
857
|
-
if (!autoCloseTabs || !should) continue;
|
|
858
|
-
// B2:结算通知未送达的 pane 豁免(先送达再回收)
|
|
859
|
-
if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
|
|
860
|
-
try {
|
|
861
|
-
await client.tabClose(tabId);
|
|
862
|
-
} catch {
|
|
863
|
-
/* tab 已消失 */
|
|
864
|
-
}
|
|
865
|
-
for (const e of entries) {
|
|
866
|
-
if (e.status !== 'closed') {
|
|
867
|
-
e.status = 'closed';
|
|
868
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// 2) pane 级(v1.2 兼容路径 + 孤儿 + D86 main tab 回收):
|
|
875
|
-
// 不属于任何「可整关任务 tab」的 consumed 短 pane(main tab 也算——R4)
|
|
876
|
-
const closableTaskTabIds = new Set([...taskTabIds].filter((t) => t !== mainTabId));
|
|
877
|
-
const candidates = [...subs.values()].filter(
|
|
878
|
-
(e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
|
|
879
|
-
);
|
|
880
|
-
for (const e of candidates) {
|
|
881
|
-
if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
|
|
882
|
-
if (!shouldClosePane({
|
|
883
|
-
consumedAt: e.consumedAt ?? null,
|
|
884
|
-
herdrStatus: statuses.get(e.paneId),
|
|
885
|
-
prevTurnStart,
|
|
886
|
-
})) continue;
|
|
887
|
-
if (statuses.get(e.paneId) === undefined) {
|
|
888
|
-
// pane 已消失(随 tab 被关)→ 补记 closed
|
|
889
|
-
e.status = 'closed';
|
|
890
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
try {
|
|
894
|
-
await client.closePane(e.paneId);
|
|
895
|
-
} catch {
|
|
896
|
-
/* pane 已消失 */
|
|
897
|
-
}
|
|
898
|
-
e.status = 'closed';
|
|
899
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
900
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
901
|
-
}
|
|
902
|
-
persistSubs();
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/** GC 互斥 + 双驱动:turn_start 与周期 ticker。 */
|
|
906
|
-
let gcRunning = false;
|
|
907
|
-
async function runGcSafely(): Promise<void> {
|
|
908
|
-
if (gcRunning) return;
|
|
909
|
-
gcRunning = true;
|
|
910
|
-
try {
|
|
911
|
-
await gcPass();
|
|
912
|
-
} catch {
|
|
913
|
-
/* GC 失败静默,下轮/tick 重试 */
|
|
914
|
-
} finally {
|
|
915
|
-
gcRunning = false;
|
|
916
|
-
}
|
|
917
|
-
}
|
|
339
|
+
const injectNotice = (content: string): Promise<void> =>
|
|
340
|
+
d.deliverNotice ? d.deliverNotice(content)
|
|
341
|
+
: (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
|
|
918
342
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
343
|
+
const session = createSessionIo({
|
|
344
|
+
client,
|
|
345
|
+
getSessionId: d.getSessionId,
|
|
346
|
+
sessionsDir: defaultAgentSessionsDir,
|
|
347
|
+
});
|
|
348
|
+
const spawn = createSpawner({ client, env, runtime, git });
|
|
349
|
+
const poller = createPoller({
|
|
350
|
+
client,
|
|
351
|
+
sessionRoot,
|
|
352
|
+
subs,
|
|
353
|
+
persistSubs,
|
|
354
|
+
writeHistory,
|
|
355
|
+
blockedGateNotified,
|
|
356
|
+
lastMachineInjectAt,
|
|
357
|
+
session,
|
|
358
|
+
git,
|
|
359
|
+
injectNotice,
|
|
360
|
+
reconcileOnSettlement: d.reconcileOnSettlement,
|
|
361
|
+
withReconcileNotes: d.withReconcileNotes,
|
|
362
|
+
claimSettleNotice: d.claimSettleNotice,
|
|
923
363
|
});
|
|
364
|
+
const gc = createGcController({
|
|
365
|
+
client,
|
|
366
|
+
env,
|
|
367
|
+
subs,
|
|
368
|
+
persistSubs,
|
|
369
|
+
writeHistory,
|
|
370
|
+
terminalState,
|
|
371
|
+
noticePending: d.noticePending,
|
|
372
|
+
pendingIsolateBranches,
|
|
373
|
+
git,
|
|
374
|
+
injectNotice,
|
|
375
|
+
});
|
|
376
|
+
gc.startTicker(ctx);
|
|
377
|
+
scoped.on('turn_start', () => gc.onTurnStart());
|
|
924
378
|
|
|
925
|
-
const
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
ctx.effect(() => () => {
|
|
931
|
-
if (gcTicker) clearInterval(gcTicker);
|
|
932
|
-
}, 'gc-ticker');
|
|
379
|
+
const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = session;
|
|
380
|
+
const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
|
|
381
|
+
const { startPoller, pollers } = poller;
|
|
382
|
+
const runGit = (...args: Parameters<typeof git.runGit>) => git.runGit(...args);
|
|
383
|
+
const listWorktrees = (...args: Parameters<typeof git.listWorktrees>) => git.listWorktrees(...args);
|
|
933
384
|
|
|
934
|
-
/**
|
|
935
|
-
* D94:查找已存在的同 session pane(resume 复用而非重复 spawn)。
|
|
936
|
-
* 返回 paneId + tabId,若无匹配或 agent 已死则 null。
|
|
937
|
-
*/
|
|
938
|
-
async function findExistingPaneWithSession(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
|
|
939
|
-
if (!sessionFile) return null;
|
|
940
|
-
try {
|
|
941
|
-
const agents = await client.listAgents();
|
|
942
|
-
const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
|
|
943
|
-
if (!match) return null;
|
|
944
|
-
// listAgents 不带 tabId,需要 pane.list 补
|
|
945
|
-
const panes = await client.listPanes();
|
|
946
|
-
const pane = panes.find((p) => p.paneId === match.paneId);
|
|
947
|
-
return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
|
|
948
|
-
} catch {
|
|
949
|
-
return null;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/** 复活已关闭任务(resume 工具与 send_message 自动复活共用)。 */
|
|
385
|
+
/** Revive a closed task; resume and automatic send_message revival share this path. */
|
|
954
386
|
async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
|
|
387
|
+
// D98: Do not revive a released isolate worktree because its directory is gone and cwd is invalid.
|
|
388
|
+
if (entry.isolate?.releasedAt != null) {
|
|
389
|
+
throw new Error(`isolate worktree ${entry.isolate.branch} was released (merged) — delegate a new subagent instead`);
|
|
390
|
+
}
|
|
955
391
|
const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
|
|
956
392
|
const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile) ? latest.sessionFile : null;
|
|
957
|
-
// D86
|
|
393
|
+
// D86 trust matches spawn: pass -a only for the master's checkout/worktrees; revive has no tool context, so use process cwd.
|
|
958
394
|
const approve = await approveFor(entry.cwd, process.cwd());
|
|
959
395
|
const spawned = await spawnPaneInTaskTab(
|
|
960
396
|
{ desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
|
|
@@ -985,41 +421,63 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
985
421
|
description: Type.String({ description: 'Short label for this subtask (pane title)' }),
|
|
986
422
|
prompt: Type.String({ description: 'The complete self-contained task for the subagent' }),
|
|
987
423
|
run_in_background: Type.Optional(Type.Boolean({ description: 'Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
|
|
988
|
-
cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add' })),
|
|
989
|
-
|
|
990
|
-
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: 'Extra tools the task needs beyond the role baseline (union; role deny rules always win). Only meaningful with a role profile' })),
|
|
991
|
-
tab: Type.Optional(Type.String({ description: 'Task tab name: join the existing tab with this name, or create a new tab named this. Overrides the default worktree-based placement; omit for automatic placement (same checkout = your tab, separate worktree = its own tab)' })),
|
|
424
|
+
cwd: Type.Optional(Type.String({ description: 'Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
|
|
425
|
+
isolate: Type.Optional(Type.Boolean({ description: 'Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
|
|
992
426
|
}),
|
|
993
427
|
async execute(toolCallId, params, signal, onUpdate, toolCtx) {
|
|
994
428
|
void toolCallId;
|
|
995
429
|
void signal;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
type: 'text',
|
|
1000
|
-
text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
|
|
1001
|
-
}],
|
|
1002
|
-
details: {},
|
|
1003
|
-
};
|
|
430
|
+
const launch = planLaunchValidation(params, client.available);
|
|
431
|
+
if (launch.kind === 'error') {
|
|
432
|
+
return { content: [{ type: 'text', text: launch.text }], details: {} };
|
|
1004
433
|
}
|
|
1005
|
-
const spec:
|
|
1006
|
-
description: String(params?.description ?? 'subagent'),
|
|
1007
|
-
prompt: String(params?.prompt ?? ''),
|
|
1008
|
-
};
|
|
1009
|
-
if (!spec.prompt.trim()) {
|
|
1010
|
-
return {
|
|
1011
|
-
content: [{ type: 'text', text: 'Error: `prompt` must be a non-empty string' }],
|
|
1012
|
-
details: {},
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
const background = params?.run_in_background === true;
|
|
1016
|
-
const kind = normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined);
|
|
434
|
+
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
1017
435
|
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
|
|
436
|
+
// Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
|
|
437
|
+
const taskId = randomUUID();
|
|
438
|
+
// D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
|
|
439
|
+
// socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
|
|
440
|
+
// workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
|
|
441
|
+
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
1021
442
|
let cwd = masterCwd;
|
|
1022
|
-
if (
|
|
443
|
+
if (isolate) {
|
|
444
|
+
const baseSha = await runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
445
|
+
const isoGuard = planIsolateRepoGuard(baseSha);
|
|
446
|
+
if (isoGuard.kind === 'error') {
|
|
447
|
+
return { content: [{ type: 'text', text: isoGuard.text }], details: {} };
|
|
448
|
+
}
|
|
449
|
+
const sha = isoGuard.sha.trim();
|
|
450
|
+
const pierBranches = new Set(
|
|
451
|
+
((await runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
452
|
+
.split('\n').filter(Boolean),
|
|
453
|
+
);
|
|
454
|
+
const worktrees = await listWorktrees(masterCwd);
|
|
455
|
+
const repoName = basename(worktrees[0] ?? masterCwd);
|
|
456
|
+
const plan = planIsolateWorktree({
|
|
457
|
+
description: spec.description,
|
|
458
|
+
taskHex: taskId.slice(0, 6),
|
|
459
|
+
existingPierBranches: pierBranches,
|
|
460
|
+
});
|
|
461
|
+
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
462
|
+
try {
|
|
463
|
+
mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
|
|
464
|
+
} catch { /* Existing directory or permissions failure is handled by worktree add. */ }
|
|
465
|
+
// Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
|
|
466
|
+
// scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
467
|
+
pendingIsolateBranches.add(plan.branch);
|
|
468
|
+
const added = await runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
469
|
+
if (added === null) {
|
|
470
|
+
pendingIsolateBranches.delete(plan.branch);
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: 'text', text: `Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries` }],
|
|
473
|
+
details: {},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
|
|
477
|
+
cwd = wtPath;
|
|
478
|
+
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
479
|
+
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
480
|
+
} else if (cwdParam) {
|
|
1023
481
|
cwd = pathResolve(masterCwd, cwdParam);
|
|
1024
482
|
try {
|
|
1025
483
|
const st = await statAsync(cwd);
|
|
@@ -1031,22 +489,14 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1031
489
|
};
|
|
1032
490
|
}
|
|
1033
491
|
}
|
|
1034
|
-
// D86 R1
|
|
492
|
+
// D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
|
|
1035
493
|
const zone = classifyWorktreeZone({
|
|
1036
494
|
cwd,
|
|
1037
495
|
masterCwd,
|
|
1038
496
|
worktrees: await listWorktrees(masterCwd),
|
|
1039
497
|
});
|
|
1040
|
-
// D86
|
|
498
|
+
// D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
|
|
1041
499
|
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
1042
|
-
// 档2(C7 v2):role 命中档案 → 三态合成 manifest,env 下发 worker。
|
|
1043
|
-
// roles-v1.0(V57/V58):省略 role = worker-default manifest 生效
|
|
1044
|
-
// (默认 executor:能写能执行、禁嵌套 spawn)——kind 历史分类不受影响。
|
|
1045
|
-
const suggested = Array.isArray(params?.allowed_tools)
|
|
1046
|
-
? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
|
1047
|
-
: [];
|
|
1048
|
-
const manifestRole =
|
|
1049
|
-
typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
|
|
1050
500
|
let roleManifestEnv: Record<string, string> = {};
|
|
1051
501
|
let roleModel: string | null = null;
|
|
1052
502
|
try {
|
|
@@ -1061,7 +511,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1061
511
|
services: role.services ?? {},
|
|
1062
512
|
}),
|
|
1063
513
|
};
|
|
1064
|
-
// WS-D10
|
|
514
|
+
// WS-D10: Route the model by role; omission intentionally uses the process default.
|
|
1065
515
|
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
1066
516
|
} catch (err) {
|
|
1067
517
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1074,9 +524,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1074
524
|
let paneId = '';
|
|
1075
525
|
try {
|
|
1076
526
|
const spawnedAt = Date.now();
|
|
1077
|
-
const taskId = randomUUID();
|
|
1078
527
|
const spawned = await spawnPaneInTaskTab(
|
|
1079
|
-
{ desiredTab:
|
|
528
|
+
{ desiredTab: tab, description: spec.description, zone },
|
|
1080
529
|
cwd,
|
|
1081
530
|
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
1082
531
|
launchLine(null, roleModel, approve),
|
|
@@ -1096,6 +545,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1096
545
|
launchCommand: [launchLine(null, roleModel, approve)],
|
|
1097
546
|
createdAt: Date.now(),
|
|
1098
547
|
revivedFrom: null,
|
|
548
|
+
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
1099
549
|
};
|
|
1100
550
|
const ready = await waitSubReady(cwd, paneId);
|
|
1101
551
|
if (!ready) throw new Error(`subagent pane ${paneId} pipe not ready within ${SUB_READY_TIMEOUT_MS}ms`);
|
|
@@ -1103,13 +553,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1103
553
|
subs.set(paneId, entry);
|
|
1104
554
|
persistSubs();
|
|
1105
555
|
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
1106
|
-
// M11
|
|
1107
|
-
// PTY
|
|
1108
|
-
//
|
|
1109
|
-
// prompt
|
|
1110
|
-
//
|
|
556
|
+
// M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
|
|
557
|
+
// reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
|
|
558
|
+
// Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
|
|
559
|
+
// leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
|
|
560
|
+
// running ledger entry). Keep injection and injectTs together in future refactors.
|
|
1111
561
|
const injectTs = Date.now();
|
|
1112
|
-
const injected = await
|
|
562
|
+
const injected = await pipeRequestTo(cwd, paneId, {
|
|
1113
563
|
type: 'prompt',
|
|
1114
564
|
id: `prompt-${taskId}`,
|
|
1115
565
|
text: spec.prompt,
|
|
@@ -1119,18 +569,21 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1119
569
|
if (injected.type !== 'ok') {
|
|
1120
570
|
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
1121
571
|
}
|
|
1122
|
-
lastMachineInjectAt.set(paneId, injectTs); // B4
|
|
1123
|
-
// A1+A2
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
const PATIENCE_MS =
|
|
572
|
+
lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
|
|
573
|
+
// A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
|
|
574
|
+
// Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
|
|
575
|
+
// three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
|
|
576
|
+
const PATIENCE_MS = runtimePolicy.foregroundPatienceMs;
|
|
1127
577
|
const patienceDeadline = Date.now() + PATIENCE_MS;
|
|
1128
578
|
let text: string | null = null;
|
|
1129
579
|
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
1130
580
|
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
1131
581
|
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
1132
|
-
|
|
1133
|
-
|
|
582
|
+
const session = (state === 'idle' || state === 'done')
|
|
583
|
+
? await subSessionState(paneId, cwd, injectTs)
|
|
584
|
+
: { text: null, pendingTool: false, activity: false };
|
|
585
|
+
const tick = planForegroundTick({ state, session });
|
|
586
|
+
if (tick.kind === 'blocked') {
|
|
1134
587
|
const question = await readAskFlag(paneId);
|
|
1135
588
|
entry.background = true;
|
|
1136
589
|
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
@@ -1142,27 +595,25 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1142
595
|
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
1143
596
|
};
|
|
1144
597
|
}
|
|
1145
|
-
if (
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
598
|
+
if (tick.kind === 'settled') {
|
|
599
|
+
text = tick.text;
|
|
600
|
+
settledKind = 'settled';
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
if (tick.kind === 'wait') {
|
|
604
|
+
await new Promise((r) => setTimeout(r, tick.delayMs));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (tick.kind === 'collect-final') {
|
|
1155
608
|
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
1156
609
|
if (text) { settledKind = 'settled'; break; }
|
|
1157
|
-
await new Promise((r) => setTimeout(r,
|
|
610
|
+
await new Promise((r) => setTimeout(r, FOREGROUND_POLL_MS));
|
|
1158
611
|
continue;
|
|
1159
612
|
}
|
|
1160
|
-
// null = 30s 切片超时(working 中)→ 继续循环
|
|
1161
613
|
}
|
|
1162
|
-
// A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
|
|
1163
614
|
if (!text && settledKind !== 'blocked') {
|
|
1164
615
|
const probe = await probeAlive(paneId, cwd);
|
|
1165
|
-
if (isAlive(probe, Date.now())) {
|
|
616
|
+
if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
|
|
1166
617
|
entry.background = true;
|
|
1167
618
|
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1168
619
|
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
@@ -1176,7 +627,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1176
627
|
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
1177
628
|
};
|
|
1178
629
|
}
|
|
1179
|
-
// 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
|
|
1180
630
|
settledKind = 'timeout';
|
|
1181
631
|
}
|
|
1182
632
|
const outcome =
|
|
@@ -1192,13 +642,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1192
642
|
details: { paneId, taskId, background, role: kind },
|
|
1193
643
|
};
|
|
1194
644
|
} catch (err) {
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
645
|
+
// Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
|
|
646
|
+
// otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
|
|
647
|
+
// to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
|
|
1198
648
|
if (paneId) {
|
|
1199
649
|
subs.delete(paneId);
|
|
1200
650
|
persistSubs();
|
|
1201
|
-
void client.closePane(paneId).catch(() => { /*
|
|
651
|
+
void client.closePane(paneId).catch(() => { /* Best effort only. */ });
|
|
652
|
+
}
|
|
653
|
+
// D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
|
|
654
|
+
// exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
|
|
655
|
+
if (isolateMeta) {
|
|
656
|
+
void runGit(masterCwd, ['worktree', 'remove', '--force', isolateMeta.worktreePath])
|
|
657
|
+
.then(() => runGit(masterCwd, ['branch', '-D', isolateMeta!.branch]))
|
|
658
|
+
.catch(() => { /* Best effort only. */ });
|
|
1202
659
|
}
|
|
1203
660
|
return {
|
|
1204
661
|
content: [{
|
|
@@ -1212,11 +669,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1212
669
|
};
|
|
1213
670
|
} finally {
|
|
1214
671
|
release();
|
|
672
|
+
if (isolateMeta) pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
|
|
1215
673
|
}
|
|
1216
674
|
},
|
|
1217
675
|
});
|
|
1218
676
|
|
|
1219
|
-
/* ──
|
|
677
|
+
/* ── Tool: resume_subagent (historical revival, v1.2) ── */
|
|
1220
678
|
|
|
1221
679
|
scoped.registerTool({
|
|
1222
680
|
name: 'resume_subagent',
|
|
@@ -1244,8 +702,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1244
702
|
}
|
|
1245
703
|
const release = await subSemaphore.acquire();
|
|
1246
704
|
try {
|
|
1247
|
-
// D94
|
|
1248
|
-
const existing = await
|
|
705
|
+
// D94: Reuse an existing pane for the same session to avoid competing pi processes.
|
|
706
|
+
const existing = await findExistingPane(latest.sessionFile);
|
|
1249
707
|
if (existing) {
|
|
1250
708
|
const entry: SubEntry = {
|
|
1251
709
|
taskId,
|
|
@@ -1274,7 +732,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1274
732
|
details: { paneId: entry.paneId, taskId },
|
|
1275
733
|
};
|
|
1276
734
|
}
|
|
1277
|
-
//
|
|
735
|
+
// Create a new pane only when no existing one can be reused.
|
|
1278
736
|
const entry: SubEntry = {
|
|
1279
737
|
taskId,
|
|
1280
738
|
kind: latest.kind,
|
|
@@ -1314,7 +772,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1314
772
|
},
|
|
1315
773
|
});
|
|
1316
774
|
|
|
1317
|
-
/* ──
|
|
775
|
+
/* ── Tool: list_agents (background/long-lived subagents only; foreground short panes are transient UI) ── */
|
|
1318
776
|
|
|
1319
777
|
scoped.registerTool({
|
|
1320
778
|
name: 'list_agents',
|
|
@@ -1327,45 +785,47 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1327
785
|
if (listed.length === 0) {
|
|
1328
786
|
return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
|
|
1329
787
|
}
|
|
1330
|
-
//
|
|
788
|
+
// Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
|
|
1331
789
|
const byTask = new Map<string, SubEntry>();
|
|
1332
790
|
for (const sub of listed) {
|
|
1333
791
|
const prev = byTask.get(sub.taskId);
|
|
1334
792
|
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
1335
793
|
}
|
|
1336
|
-
// C1
|
|
794
|
+
// C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
|
|
1337
795
|
const probes = new Map<string, AliveProbe>();
|
|
1338
796
|
await Promise.all([...byTask.values()].map(async (sub) => {
|
|
1339
797
|
if (sub.status === 'closed') return;
|
|
1340
|
-
try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /*
|
|
798
|
+
try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
|
|
1341
799
|
}));
|
|
1342
800
|
const lines: string[] = [];
|
|
1343
801
|
for (const sub of byTask.values()) {
|
|
1344
802
|
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
803
|
+
// D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
|
|
804
|
+
const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
|
|
1345
805
|
if (sub.status === 'closed') {
|
|
1346
|
-
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag} ${sub.description}`);
|
|
806
|
+
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; send_message revives)${tabTag}${wtTag} ${sub.description}`);
|
|
1347
807
|
continue;
|
|
1348
808
|
}
|
|
1349
|
-
//
|
|
809
|
+
// The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
|
|
1350
810
|
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
1351
|
-
// D94
|
|
811
|
+
// D94: Mark when the user has taken over.
|
|
1352
812
|
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
1353
813
|
const probe = probes.get(sub.paneId);
|
|
1354
814
|
const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
|
|
1355
815
|
const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
|
|
1356
|
-
// E
|
|
816
|
+
// E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
|
|
1357
817
|
let gateTag = '';
|
|
1358
818
|
if (probe?.agentStatus === 'blocked') {
|
|
1359
819
|
const q = await readAskFlag(sub.paneId);
|
|
1360
820
|
gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
|
|
1361
821
|
}
|
|
1362
|
-
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
|
|
822
|
+
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
|
|
1363
823
|
}
|
|
1364
824
|
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
1365
825
|
},
|
|
1366
826
|
});
|
|
1367
827
|
|
|
1368
|
-
/* ──
|
|
828
|
+
/* ── Tool: send_message (delivery during steer gaps) ── */
|
|
1369
829
|
|
|
1370
830
|
scoped.registerTool({
|
|
1371
831
|
name: 'send_message',
|
|
@@ -1383,19 +843,18 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1383
843
|
}
|
|
1384
844
|
const spawnedAt = Date.now();
|
|
1385
845
|
try {
|
|
1386
|
-
//
|
|
846
|
+
// A closed task is revived automatically because the pane is only a temporary host.
|
|
1387
847
|
if (entry.status === 'closed') {
|
|
1388
848
|
await reviveEntry(entry);
|
|
1389
849
|
subs.set(entry.paneId, entry);
|
|
1390
850
|
persistSubs();
|
|
1391
851
|
}
|
|
1392
|
-
// M11
|
|
1393
|
-
//
|
|
1394
|
-
// 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
|
|
852
|
+
// M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
|
|
853
|
+
// within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
|
|
1395
854
|
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
1396
855
|
if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
|
|
1397
856
|
const fuId = `fu-${Date.now()}`;
|
|
1398
|
-
const res = await
|
|
857
|
+
const res = await pipeRequestTo(entry.cwd, entry.paneId, {
|
|
1399
858
|
type: 'follow_up',
|
|
1400
859
|
id: fuId,
|
|
1401
860
|
text: String(params?.message ?? ''),
|
|
@@ -1406,11 +865,20 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1406
865
|
if (res.type !== 'ok') {
|
|
1407
866
|
throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1408
867
|
}
|
|
1409
|
-
lastMachineInjectAt.set(entry.paneId, Date.now()); // B4
|
|
868
|
+
lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
|
|
869
|
+
// Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
|
|
870
|
+
// running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
|
|
871
|
+
const prevStatus = entry.status;
|
|
1410
872
|
entry.status = 'running';
|
|
1411
873
|
persistSubs();
|
|
1412
|
-
|
|
1413
|
-
|
|
874
|
+
try {
|
|
875
|
+
lastRequestIdByPane.set(entry.paneId, fuId);
|
|
876
|
+
startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
|
|
877
|
+
} catch (inner) {
|
|
878
|
+
entry.status = prevStatus;
|
|
879
|
+
persistSubs();
|
|
880
|
+
throw inner;
|
|
881
|
+
}
|
|
1414
882
|
return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
|
|
1415
883
|
} catch (err) {
|
|
1416
884
|
return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
|
|
@@ -1418,7 +886,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1418
886
|
},
|
|
1419
887
|
});
|
|
1420
888
|
|
|
1421
|
-
/* ──
|
|
889
|
+
/* ── Tool: interrupt_agent (D48: pipe → child extension ctx.abort(), in-process cancellation) ── */
|
|
1422
890
|
|
|
1423
891
|
scoped.registerTool({
|
|
1424
892
|
name: 'interrupt_agent',
|
|
@@ -1434,18 +902,18 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1434
902
|
return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see list_agents)` }], details: {} };
|
|
1435
903
|
}
|
|
1436
904
|
if (entry.status === 'closed') {
|
|
1437
|
-
// DSH
|
|
905
|
+
// DSH alignment: an idle or finished target is an idempotent no-op.
|
|
1438
906
|
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
|
|
1439
907
|
}
|
|
1440
908
|
try {
|
|
1441
|
-
const res = await
|
|
909
|
+
const res = await pipeRequestTo(entry.cwd, entry.paneId, {
|
|
1442
910
|
type: 'interrupt',
|
|
1443
911
|
id: `int-${Date.now()}`,
|
|
1444
912
|
});
|
|
1445
913
|
if (res.type !== 'ok') {
|
|
1446
914
|
throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1447
915
|
}
|
|
1448
|
-
//
|
|
916
|
+
// Do not send a settlement notice for an interrupted turn because the requester already knows.
|
|
1449
917
|
const lastId = lastRequestIdByPane.get(entry.paneId);
|
|
1450
918
|
if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
|
|
1451
919
|
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
|