pi-pier 0.2.7 → 0.3.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/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/index.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-herdr extension entry point
|
|
3
|
-
* DESIGN.md §12 option C).
|
|
2
|
+
* pi-herdr extension entry point and composition root.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
4
|
+
* Layout: index-runtime.ts picks the process mode (worker / bare pi / herdr master);
|
|
5
|
+
* index-roles.ts the role runtime, index-gates.ts human-gate reporting, index-pipe.ts the
|
|
6
|
+
* pipe protocol handler, index-notices.ts the notice buffer, index-locks.ts write locks;
|
|
7
|
+
* index-master.ts / index-worker.ts are the dynamic master / todo-only mounts.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and branches automatically roll back in DSH-aligned semantics.
|
|
10
|
-
* - `subagent` / `terminal`: herdr-master only. Bare pi does not register them.
|
|
11
|
-
* - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
|
|
12
|
-
*
|
|
13
|
-
* pi 0.84.2 contract (validated):
|
|
14
|
-
* - onUpdate must have AgentToolResult shape; a string causes the TUI to crash and exit pi;
|
|
15
|
-
* - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
|
|
9
|
+
* pi contract: onUpdate must have AgentToolResult shape (a string crashes the TUI); tool-result
|
|
10
|
+
* details persist in session JSONL and getBranch() replay implements branch rollback.
|
|
16
11
|
*/
|
|
17
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
18
13
|
import {
|
|
@@ -27,149 +22,174 @@ import {
|
|
|
27
22
|
runAsk,
|
|
28
23
|
} from './ask-user.ts';
|
|
29
24
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
currentActivity,
|
|
33
|
-
} from './todo-core.ts';
|
|
34
|
-
|
|
35
|
-
/** Tier 2: record the synthesized manifest that governed a worker session (D38-compatible and branch-replayable). */
|
|
36
|
-
const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
37
|
-
|
|
25
|
+
import { TODO_EDIT_CUSTOM_TYPE, currentActivity } from './todo-core.ts';
|
|
26
|
+
import { installRenderers } from './renderers.ts';
|
|
38
27
|
import { createHerdrClient } from './herdr-client.ts';
|
|
39
|
-
|
|
40
|
-
// session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
|
|
41
|
-
import { lastAssistantText, readSessionFile } from './session-tail.ts';
|
|
28
|
+
import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
|
|
42
29
|
import { fileURLToPath } from 'node:url';
|
|
43
|
-
import {
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { platformPaths } from './platform-paths.ts';
|
|
32
|
+
import { TodosService, type TodoCompletionSource } from './todos-service.ts';
|
|
44
33
|
import { reconcileTodos } from './reconcile-core.ts';
|
|
45
|
-
import type { TodoUiSlot } from './
|
|
46
|
-
import {
|
|
47
|
-
estimateEta,
|
|
48
|
-
formatProgressSuffix,
|
|
49
|
-
planToolBadge,
|
|
50
|
-
progressOf,
|
|
51
|
-
} from './progress-core.ts';
|
|
34
|
+
import type { TodoUiSlot } from './plugins/todo.ts';
|
|
35
|
+
import { estimateEta, formatProgressSuffix, planToolBadge, progressOf } from './progress-core.ts';
|
|
52
36
|
import { WRITE_LOCK_ENV } from './lock-core.ts';
|
|
53
|
-
import { ABORT_STOP_REASON, planSettleWake } from './settle-wake-core.ts';
|
|
54
|
-
import {
|
|
55
|
-
import { parseRuntimeManifest, planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
|
|
56
|
-
|
|
37
|
+
import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
|
|
38
|
+
import { parseRuntimeManifest } from './tool-gate.ts';
|
|
57
39
|
import { formatPaneTitle } from './pane-title.ts';
|
|
58
40
|
import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
|
|
59
41
|
import { planIndexMode } from './index-runtime.ts';
|
|
60
|
-
import { emptySubagentPortBox } from './subagent-
|
|
42
|
+
import { emptySubagentPortBox } from './subagent-core.ts';
|
|
61
43
|
import { createNoticeBuffer } from './index-notices.ts';
|
|
62
|
-
import { handlePipeRequest } from './index-pipe.ts';
|
|
44
|
+
import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
|
|
63
45
|
import { installWriteLocks } from './index-locks.ts';
|
|
46
|
+
import { installDashboardCommand } from './dashboard-command.ts';
|
|
47
|
+
import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
|
|
48
|
+
import { createJevRuntime } from './jev-client.ts';
|
|
49
|
+
import {
|
|
50
|
+
NOTICE_RANK_MIN_CONFIDENCE,
|
|
51
|
+
buildExcerptWindows,
|
|
52
|
+
composeNoticeRanking,
|
|
53
|
+
evaluateExcerptPick,
|
|
54
|
+
excerptAskIsSafe,
|
|
55
|
+
excerptPickRequest,
|
|
56
|
+
noticeRankRequest,
|
|
57
|
+
} from './jev-core.ts';
|
|
58
|
+
import { completeLineExcerpt } from './observation-core.ts';
|
|
59
|
+
import { installConfigCommand } from './config-command.ts';
|
|
60
|
+
import { CompactCoordinator } from './compact-coordinator.ts';
|
|
61
|
+
import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
|
|
62
|
+
import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
|
|
63
|
+
import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
|
|
64
|
+
import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
|
|
65
|
+
import { resolveDefaultFocusPollMs, parseFocusSample, spawnReflow, startFocusPoller, type FocusPoller } from './focus-poller.ts';
|
|
66
|
+
import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
|
|
67
|
+
import { createGateRuntime } from './index-gates.ts';
|
|
68
|
+
|
|
69
|
+
/** D-4: PIER_FOCUS_POLL_MS=0 disables the poller; invalid values fall back to the version default. */
|
|
70
|
+
function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
|
|
71
|
+
const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
|
|
72
|
+
const ms = Number(env.PIER_FOCUS_POLL_MS);
|
|
73
|
+
return env.PIER_FOCUS_POLL_MS !== undefined && env.PIER_FOCUS_POLL_MS.trim() !== '' && Number.isFinite(ms) && ms >= 0
|
|
74
|
+
? ms
|
|
75
|
+
: defaultMs;
|
|
76
|
+
}
|
|
64
77
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* (gate, visible layer, badge, and session record); the baseline includes our core plus inspect/execute tools.
|
|
68
|
-
* Match only a herdr pane (HERDR_ENV=1) that is not a subagent and has no explicitly supplied manifest.
|
|
69
|
-
* Standalone pi sessions without herdr remain full-featured for backward compatibility.
|
|
70
|
-
* A malformed master.json fails open to no-role state, matching malformed env semantics.
|
|
71
|
-
*/
|
|
72
|
-
function composeMasterRuntime(): RuntimeRoleManifest | null {
|
|
73
|
-
try {
|
|
74
|
-
// v1.1: read the built-in name directly so a workspace master.json decoy cannot affect self-application; the reserved-name check remains for loud spawn errors.
|
|
75
|
-
const { role, manifest } = composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } });
|
|
76
|
-
return {
|
|
77
|
-
role: role.role,
|
|
78
|
-
version: role.version,
|
|
79
|
-
tools: manifest.tools,
|
|
80
|
-
permissions: manifest.permissions,
|
|
81
|
-
unknownTools: manifest.unknownTools,
|
|
82
|
-
services: role.services ?? {},
|
|
83
|
-
};
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error(`[pi-herdr] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
78
|
+
function agentSessionsDir(): string {
|
|
79
|
+
return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
|
|
88
80
|
}
|
|
89
|
-
// C3: cordis belongs only in the master process; subagent-scope includes @deepseek-ai/cordis.
|
|
90
|
-
// Dynamically import it on the master branch so worker processes never load the module.
|
|
91
|
-
// Subagent constants/helpers moved with the family to core/subagent.ts.
|
|
92
81
|
|
|
93
82
|
export default async function (pi: ExtensionAPI) {
|
|
94
83
|
const mode = planIndexMode();
|
|
95
84
|
const isSubagent = mode.isSubagent;
|
|
96
|
-
const
|
|
97
|
-
parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
|
|
98
|
-
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
99
|
-
const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
|
|
100
|
-
const { client, env } = createHerdrClient();
|
|
85
|
+
const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
|
|
101
86
|
|
|
102
87
|
let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
function rebuildFromBranch(ctx: unknown): void {
|
|
109
|
-
try {
|
|
110
|
-
const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
111
|
-
?.sessionManager?.getBranch?.() ?? [];
|
|
112
|
-
todos.rebuild(entries);
|
|
113
|
-
} catch {
|
|
114
|
-
// A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
|
|
115
|
-
}
|
|
116
|
-
}
|
|
88
|
+
let sessionRoot: string | null = null;
|
|
89
|
+
let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
|
|
90
|
+
const coordinator = new CompactCoordinator();
|
|
91
|
+
const { client, env } = createHerdrClient();
|
|
117
92
|
|
|
118
|
-
|
|
93
|
+
const initialManifest =
|
|
94
|
+
parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
|
|
95
|
+
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
96
|
+
const todos = new TodosService(TodosService.configFromRuntime(initialManifest, isSubagent));
|
|
97
|
+
|
|
98
|
+
/** Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): best-effort append; never gates anything. */
|
|
99
|
+
const appendRoutingLog = (row: RoutingTelemetryRecord): void => {
|
|
100
|
+
const root = sessionRoot;
|
|
101
|
+
if (!root) return;
|
|
102
|
+
const stamped: RoutingTelemetryRow = { ...row, sessionId: sessionId || 'unknown' };
|
|
103
|
+
void appendEfficiencyLog(efficiencyLogPath(root, 'routing'), stamped).catch(() => {});
|
|
104
|
+
};
|
|
119
105
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
106
|
+
const roles = createRoleRuntime({ pi, client, roleBase, initialManifest, isSubagent, todos, appendRoutingLog });
|
|
107
|
+
|
|
108
|
+
/* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
|
|
109
|
+
* abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
|
|
110
|
+
const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
|
|
111
|
+
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
|
|
112
|
+
const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
|
|
113
|
+
if (!jevRuntime.available) return null;
|
|
114
|
+
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
115
|
+
const windows = buildExcerptWindows(text, halfBudget);
|
|
116
|
+
if (windows.length === 0) return null; // degenerate budget only
|
|
117
|
+
const headExcerpt = completeLineExcerpt(text, halfBudget, false);
|
|
118
|
+
const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
|
|
119
|
+
// Credential-shaped text never leaves the process (§8 privacy boundary).
|
|
120
|
+
if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
|
|
121
|
+
const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
|
|
122
|
+
if (!request) return null;
|
|
123
|
+
const result = await jevRuntime.ask(request, {
|
|
124
|
+
questionId: 'obs-excerpt-window',
|
|
125
|
+
extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
|
|
126
|
+
enrich: ({ answers }) => {
|
|
127
|
+
if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
|
|
128
|
+
return { picked: answers.window.choice, confidence: answers.window.confidence };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (!result.ok) return null;
|
|
132
|
+
const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
|
|
133
|
+
const window = picked === null ? undefined : windows.find((w) => w.id === picked);
|
|
134
|
+
return window ? { text: window.text, label: window.label } : null;
|
|
135
|
+
};
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
|
|
129
|
-
if (blockedDepth > 0 && state !== 'blocked') return;
|
|
130
|
-
const message = activity ?? (state === 'idle' ? roleBadge : null);
|
|
131
|
-
client.reportAgent(state, message).catch(() => {});
|
|
137
|
+
const rendererTypes = installRenderers(pi);
|
|
138
|
+
if (process.env.PI_HERDR_TRACE) {
|
|
139
|
+
console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
142
|
+
let agentActive = false;
|
|
143
|
+
let lastStopReason: string | null = null;
|
|
144
|
+
let latestCtx: { abort?: () => void } | null = null;
|
|
145
|
+
const completedStamps: number[] = [];
|
|
146
|
+
const subagentPort = emptySubagentPortBox();
|
|
147
|
+
let d96NoticeKey: string | null = null;
|
|
148
|
+
let d96NoticeAt = 0;
|
|
138
149
|
|
|
139
|
-
|
|
150
|
+
const todoUi: TodoUiSlot = { renderWidget: () => { /* filled after plugin mounting */ } };
|
|
151
|
+
const gates = createGateRuntime({
|
|
152
|
+
pi,
|
|
153
|
+
client,
|
|
154
|
+
rerenderWidget: () => todoUi.rerenderWidget?.(),
|
|
155
|
+
isAgentActive: () => agentActive,
|
|
156
|
+
idleMessage: () => roles.idleBadge(),
|
|
157
|
+
workingMessage: () => currentActivity(todos.items),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
|
|
140
161
|
function resolveSessionId(ctx: unknown): string {
|
|
141
162
|
try {
|
|
142
163
|
const sm = (ctx as {
|
|
143
|
-
sessionManager?: {
|
|
144
|
-
getSessionFile?: () => string | undefined;
|
|
145
|
-
getSessionId?: () => string;
|
|
146
|
-
};
|
|
164
|
+
sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
|
|
147
165
|
}).sessionManager;
|
|
148
|
-
return (
|
|
149
|
-
sm?.getSessionFile?.() ??
|
|
150
|
-
sm?.getSessionId?.() ??
|
|
151
|
-
process.env.PI_SESSION_FILE ??
|
|
152
|
-
process.env.PI_SESSION_ID ??
|
|
153
|
-
''
|
|
166
|
+
return bareSessionId(
|
|
167
|
+
sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
|
|
154
168
|
);
|
|
155
169
|
} catch {
|
|
156
|
-
return process.env.PI_SESSION_FILE ?? '';
|
|
170
|
+
return bareSessionId(process.env.PI_SESSION_FILE ?? '');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Branch reconstruction: todos + OCC debt state (branch correctness from the last snapshot on the branch). */
|
|
175
|
+
function rebuildFromBranch(ctx: unknown): void {
|
|
176
|
+
try {
|
|
177
|
+
const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
178
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
179
|
+
todos.rebuild(entries);
|
|
180
|
+
coordinator.rebuildFromBranch(entries);
|
|
181
|
+
} catch {
|
|
182
|
+
/* A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors. */
|
|
157
183
|
}
|
|
158
184
|
}
|
|
159
185
|
|
|
160
186
|
function mirrorTodos(): void {
|
|
161
187
|
if (!client.available) return;
|
|
162
188
|
const label = sessionId || (env ? `pane:${env.paneId}` : '');
|
|
163
|
-
// M16: progress badge (conservative N/M plus confidence ETA; fall back to a plain count when estimates are unreliable).
|
|
164
189
|
const p = progressOf(todos.items);
|
|
165
190
|
const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
|
|
166
191
|
const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
|
|
167
|
-
|
|
168
|
-
const title = formatPaneTitle(todos.items, null, {
|
|
169
|
-
progressSuffix: suffix,
|
|
170
|
-
lastWriteAt: todos.lastWriteAt,
|
|
171
|
-
});
|
|
172
|
-
// D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
|
|
192
|
+
const title = formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt });
|
|
173
193
|
updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
|
|
174
194
|
client.reportMetadata({
|
|
175
195
|
session: label,
|
|
@@ -179,8 +199,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
179
199
|
}).catch(() => {});
|
|
180
200
|
}
|
|
181
201
|
|
|
182
|
-
|
|
183
|
-
|
|
202
|
+
/** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
|
|
184
203
|
function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
|
|
185
204
|
if (!description) return [];
|
|
186
205
|
try {
|
|
@@ -192,9 +211,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
192
211
|
{ version: 1, edits: plan.edits, ts: Date.now() },
|
|
193
212
|
);
|
|
194
213
|
} catch {
|
|
195
|
-
/* Best effort persistence; in-memory state still advances. */
|
|
196
214
|
}
|
|
197
|
-
todos.applyEdits(plan.edits);
|
|
215
|
+
todos.applyEdits(plan.edits, { source: 'reconcile' });
|
|
198
216
|
mirrorTodos();
|
|
199
217
|
}
|
|
200
218
|
return plan.noteLines;
|
|
@@ -203,29 +221,25 @@ export default async function (pi: ExtensionAPI) {
|
|
|
203
221
|
}
|
|
204
222
|
}
|
|
205
223
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return notes.length ? `${base}\n${notes.join('\n')}` : base;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
|
|
224
|
+
const withReconcileNotes = (base: string, notes: readonly string[]): string =>
|
|
225
|
+
notes.length ? `${base}\n${notes.join('\n')}` : base;
|
|
212
226
|
|
|
213
|
-
todos.on('todo.completed', (e: { count: number; at: number }) => {
|
|
227
|
+
todos.on('todo.completed', (e: { count: number; at: number; source?: TodoCompletionSource }) => {
|
|
214
228
|
for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
|
|
229
|
+
coordinator.recordBoundaryCompleted(e.count, e.source);
|
|
215
230
|
mirrorTodos();
|
|
216
231
|
});
|
|
217
232
|
|
|
218
|
-
|
|
233
|
+
/* ── tool badges: parallel calls start in source order, end in completion order ── */
|
|
219
234
|
const runningTools = new Map<string, string>(); // toolCallId → toolName
|
|
220
235
|
let lastToolBadge: string | null = null;
|
|
221
236
|
function reportToolBadge(): void {
|
|
222
237
|
const badge = planToolBadge([...new Set(runningTools.values())]);
|
|
223
|
-
if (badge === lastToolBadge) return; //
|
|
238
|
+
if (badge === lastToolBadge) return; // report only changes (M13 rendering discipline)
|
|
224
239
|
lastToolBadge = badge;
|
|
225
|
-
if (badge)
|
|
226
|
-
else
|
|
240
|
+
if (badge) gates.report('working', badge);
|
|
241
|
+
else gates.report(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
|
|
227
242
|
}
|
|
228
|
-
|
|
229
243
|
pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
|
|
230
244
|
if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') {
|
|
231
245
|
runningTools.set(event.toolCallId, event.toolName);
|
|
@@ -239,135 +253,253 @@ export default async function (pi: ExtensionAPI) {
|
|
|
239
253
|
}
|
|
240
254
|
});
|
|
241
255
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
/* ── D102 evidence-preserving reducer (registered before append-handlers like write-locks) ── */
|
|
257
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
258
|
+
if (!effConfig.evidencePreservingReducer.enabled) return;
|
|
259
|
+
if (!event || typeof event !== 'object') return;
|
|
260
|
+
return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
|
|
261
|
+
epoch: coordinator.state.epoch,
|
|
262
|
+
jev: { ask: jevRuntime.ask, getMinConfidence: () => effConfig.jev.minConfidence },
|
|
247
263
|
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let locksHandle = { getHeldLocks: () => [] as readonly string[] };
|
|
267
|
+
if (env) {
|
|
268
|
+
locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
|
|
248
269
|
}
|
|
249
270
|
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
271
|
+
/** pi's sendUserMessage is fire-and-forget (void); callers of this wrapper still get a promise. */
|
|
272
|
+
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> => {
|
|
273
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => void })
|
|
274
|
+
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
|
|
275
|
+
return Promise.resolve();
|
|
253
276
|
};
|
|
277
|
+
const notices = createNoticeBuffer({
|
|
278
|
+
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
279
|
+
send: sendUserMessageAs,
|
|
280
|
+
// P0-2: rank collapsed batches by relevance to the master's in-progress work.
|
|
281
|
+
rank: async (contents) => {
|
|
282
|
+
if (!jevRuntime.available) return null;
|
|
283
|
+
const inProgressTodos = todos.items.filter((item) => item.status === 'in_progress').map((item) => item.content);
|
|
284
|
+
let rankedOrder: number[] | null | undefined;
|
|
285
|
+
const result = await jevRuntime.ask(
|
|
286
|
+
noticeRankRequest({ inProgressTodos, notices: [...contents] }),
|
|
287
|
+
{
|
|
288
|
+
questionId: 'notice-rank',
|
|
289
|
+
sessionId,
|
|
290
|
+
extra: { site: 'notice-rank', noticeCount: contents.length },
|
|
291
|
+
enrich: ({ answers }) => {
|
|
292
|
+
if (!answers) return {};
|
|
293
|
+
rankedOrder = composeNoticeRanking(
|
|
294
|
+
contents.length,
|
|
295
|
+
answers,
|
|
296
|
+
Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
|
|
297
|
+
);
|
|
298
|
+
const values = Array.from({ length: contents.length }, (_, i) => {
|
|
299
|
+
const rank = answers[`notice_${i}_rank`];
|
|
300
|
+
const fail = answers[`notice_${i}_fail`];
|
|
301
|
+
return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
|
|
302
|
+
? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
|
|
303
|
+
: '?';
|
|
304
|
+
});
|
|
305
|
+
return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
if (!result.ok) return null;
|
|
310
|
+
return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
const deliverNotice = notices.deliverNotice;
|
|
254
314
|
|
|
255
|
-
/* ──
|
|
315
|
+
/* ── pipe channel: this pane's NDJSON server + the settle fast-path push (D48/D49/D50) ── */
|
|
316
|
+
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
317
|
+
let pendingMachineRequest: MachineRequest | null = null;
|
|
318
|
+
/** Dedup latch for settle notices: one per paneId+request id per round. */
|
|
319
|
+
const settleNoticeLatch = new Set<string>();
|
|
320
|
+
function claimSettleNotice(key: string): boolean {
|
|
321
|
+
if (settleNoticeLatch.has(key)) return false;
|
|
322
|
+
settleNoticeLatch.add(key);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
256
325
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (reason !== 'resume') todoUi.renderWidget(ctx);
|
|
264
|
-
// D97: narrow-frame overlay is meaningful only inside herdr; covering an interactive narrow terminal is unsafe,
|
|
265
|
-
// and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
|
|
266
|
-
if (env) registerSlimFrame(ctx);
|
|
267
|
-
mirrorTodos();
|
|
268
|
-
reportSession(sessionId);
|
|
269
|
-
// D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
|
|
270
|
-
// master → 'master'; worker → manifest.role (prettify worker-default as worker).
|
|
271
|
-
// A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
|
|
272
|
-
if (runtimeManifest) {
|
|
273
|
-
const roleDisplay = runtimeManifest.role === 'worker-default' ? 'worker' : runtimeManifest.role;
|
|
274
|
-
void client.reportDisplayAgent(roleDisplay);
|
|
326
|
+
function startPipeServerFor(cwd: string): void {
|
|
327
|
+
const paneId = env?.paneId ?? '';
|
|
328
|
+
if (!paneId) return;
|
|
329
|
+
if (pipeServerBox.current) {
|
|
330
|
+
try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
|
|
331
|
+
pipeServerBox.current = null;
|
|
275
332
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const piTools = pi as { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
291
|
-
if (typeof piTools.getActiveTools === 'function' && typeof piTools.setActiveTools === 'function') {
|
|
292
|
-
try {
|
|
293
|
-
const active = piTools.getActiveTools();
|
|
294
|
-
const vis = planActiveTools(runtimeManifest.tools, active, {
|
|
295
|
-
unknownTools: runtimeManifest.unknownTools,
|
|
296
|
-
permissions: runtimeManifest.permissions,
|
|
297
|
-
});
|
|
298
|
-
if (vis?.changed) {
|
|
299
|
-
piTools.setActiveTools?.(vis.next);
|
|
300
|
-
console.error(`[pi-herdr] D77 visible-layer: role ${runtimeManifest.role} tools ${active.length} → ${vis.next.length}`);
|
|
301
|
-
}
|
|
302
|
-
} catch {
|
|
303
|
-
/* Best effort visible layer; the mandatory layer remains active. */
|
|
304
|
-
}
|
|
305
|
-
}
|
|
333
|
+
try {
|
|
334
|
+
pipeServerBox.current = startPipeServer(pipeNameFor(cwd, paneId), async (req) => handlePipeRequest(req, {
|
|
335
|
+
paneId,
|
|
336
|
+
port: subagentPort,
|
|
337
|
+
claimSettleNotice,
|
|
338
|
+
deliverNotice,
|
|
339
|
+
sendUserMessageIn: (content) => sendUserMessageAs(content, 'followUp'),
|
|
340
|
+
sendUserMessageAs,
|
|
341
|
+
abort: () => { latestCtx?.abort?.(); },
|
|
342
|
+
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
343
|
+
applyRoleSwitch: roles.applyRoleSwitch,
|
|
344
|
+
}));
|
|
345
|
+
} catch {
|
|
346
|
+
/* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
|
|
306
347
|
}
|
|
307
|
-
|
|
308
|
-
});
|
|
348
|
+
}
|
|
309
349
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
350
|
+
async function pushSettleReply(): Promise<void> {
|
|
351
|
+
const req = pendingMachineRequest;
|
|
352
|
+
if (!req || !req.push || !req.from) return;
|
|
353
|
+
// An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
|
|
354
|
+
// answering now would burn the one-shot request + the parent's claim latch on a null-text reply
|
|
355
|
+
// ("left no closing message") — the real closing message then never gets delivered. Keep the
|
|
356
|
+
// request armed; the post-compaction settle pushes it (settle-wake-core.ts).
|
|
357
|
+
if (!shouldPushSettleReply({
|
|
358
|
+
intentionalAbort: coordinator.intentionalAbort,
|
|
359
|
+
compactionInFlight: coordinator.compactionInFlight,
|
|
360
|
+
lastStopReason,
|
|
361
|
+
})) {
|
|
362
|
+
return;
|
|
320
363
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
);
|
|
329
|
-
} catch {
|
|
330
|
-
/* Best effort. */
|
|
364
|
+
pendingMachineRequest = null;
|
|
365
|
+
try {
|
|
366
|
+
const ownFile = sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null;
|
|
367
|
+
let text: string | null = null;
|
|
368
|
+
if (ownFile) {
|
|
369
|
+
const entries = readSessionFile(ownFile);
|
|
370
|
+
if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
|
|
331
371
|
}
|
|
372
|
+
await pipeRequest(req.from, {
|
|
373
|
+
type: 'reply',
|
|
374
|
+
id: req.id,
|
|
375
|
+
paneId: env?.paneId ?? '',
|
|
376
|
+
text,
|
|
377
|
+
// Push the resolved PATH when readable; fall back to the bare id for the master-side mapping.
|
|
378
|
+
sessionFile: ownFile ?? (sessionId || null),
|
|
379
|
+
}, 5000);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
/* The requester's pollLoop is the fallback; surface the dead letter for triage. */
|
|
382
|
+
console.error(`pier: settle reply push to ${req.from} failed: ${(err as Error)?.message ?? err}`);
|
|
332
383
|
}
|
|
333
|
-
}
|
|
384
|
+
}
|
|
334
385
|
|
|
386
|
+
const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
|
|
387
|
+
|
|
388
|
+
pi.on('session_start', async (event, ctx) => {
|
|
389
|
+
sessionId = resolveSessionId(ctx);
|
|
390
|
+
latestCtx = ctx as { abort?: () => void } | null;
|
|
391
|
+
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
392
|
+
const isTrusted =
|
|
393
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
394
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
395
|
+
: false;
|
|
396
|
+
effConfig = loadEfficiencyConfigFromDisk({ cwd, isProjectTrusted: isTrusted });
|
|
397
|
+
sessionRoot = resolveSessionRoot(
|
|
398
|
+
(ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
|
|
399
|
+
sessionId,
|
|
400
|
+
);
|
|
401
|
+
// On resume pi restores widget state from the session; calling setWidget again
|
|
402
|
+
// breaks the TUI '/' command-panel route (v1.3 M9).
|
|
403
|
+
if ((event as { reason?: string } | undefined)?.reason !== 'resume') todoUi.renderWidget(ctx);
|
|
404
|
+
// D97: narrow-frame overlay is meaningful only inside herdr; re-register (session switching resets it).
|
|
405
|
+
if (env) registerSlimFrame(ctx);
|
|
406
|
+
// P2-5: resume folds todos/compaction state from the branch like session_tree; a fresh session folds nothing.
|
|
407
|
+
rebuildFromBranch(ctx);
|
|
408
|
+
mirrorTodos();
|
|
409
|
+
roles.syncFromBranch(ctx);
|
|
410
|
+
if (mode.composeMaster && sessionRoot) roles.scanRoleUsage();
|
|
411
|
+
gates.report('idle', null);
|
|
412
|
+
// Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
|
|
413
|
+
// while a dialog was open must not keep the pane looking blocked forever.
|
|
414
|
+
void client.reportAskFlag(null).catch(() => {});
|
|
415
|
+
|
|
416
|
+
// D-4 focus sampling: herdr <0.9.1 never delivered pane.focused to plugins;
|
|
417
|
+
// 0.9.1+ is event-first (pollMs 0). One poller per pane, self-scoped.
|
|
418
|
+
if (sessionFocusPoller.current) sessionFocusPoller.current.stop();
|
|
419
|
+
sessionFocusPoller.current = null;
|
|
420
|
+
const paneId = env?.paneId ?? '';
|
|
421
|
+
const serverVer = await client.getServerVersion();
|
|
422
|
+
const pollMs = focusPollIntervalMs(process.env, serverVer);
|
|
423
|
+
if (paneId && client.available && pollMs > 0) {
|
|
424
|
+
sessionFocusPoller.current = startFocusPoller({
|
|
425
|
+
myPaneId: paneId,
|
|
426
|
+
intervalMs: pollMs,
|
|
427
|
+
sample: async () => {
|
|
428
|
+
// layout.export reports the focused pane of our own tab; without the
|
|
429
|
+
// field (older herdr) the tick stays silent instead of guessing.
|
|
430
|
+
const layout = await client.exportLayout({ paneId });
|
|
431
|
+
const sample = parseFocusSample(layout);
|
|
432
|
+
return sample && sample.focusedPaneId !== null ? sample : null;
|
|
433
|
+
},
|
|
434
|
+
fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
startPipeServerFor(cwd);
|
|
438
|
+
});
|
|
335
439
|
|
|
336
|
-
// Session-tree navigation (/tree, /fork): reconstruction preserves branch correctness.
|
|
337
440
|
pi.on('session_tree', async (_event, ctx) => {
|
|
338
441
|
sessionId = resolveSessionId(ctx);
|
|
339
442
|
rebuildFromBranch(ctx);
|
|
443
|
+
// pi reverts the transcript loadout at the navigated branch point; the role
|
|
444
|
+
// runtime follows the same point (same-origin, RFC §4.3).
|
|
445
|
+
roles.syncFromBranch(ctx);
|
|
340
446
|
todoUi.renderWidget(ctx);
|
|
341
447
|
mirrorTodos();
|
|
342
448
|
});
|
|
343
449
|
|
|
344
|
-
let agentActive = false;
|
|
345
|
-
const subagentPort = emptySubagentPortBox();
|
|
346
|
-
// D96 state, anchoring settle-wake-core deduplication and cooldown.
|
|
347
|
-
let d96NoticeKey: string | null = null;
|
|
348
|
-
let d96NoticeAt = 0;
|
|
349
|
-
// Prevent wake-up storms by tracking the last assistant turn's stopReason;
|
|
350
|
-
// 'aborted' means the user explicitly pressed ESC, so settlement must inject no wake-up message.
|
|
351
|
-
let lastStopReason: string | null = null;
|
|
352
450
|
pi.on('turn_start', async () => {
|
|
353
451
|
agentActive = true;
|
|
354
|
-
|
|
452
|
+
// A11: reset so a previous turn's abort cannot taint this one.
|
|
453
|
+
lastStopReason = null;
|
|
454
|
+
// Safety fallback: pi core prohibits prompts while compaction is active.
|
|
455
|
+
coordinator.compactionInFlight = false;
|
|
456
|
+
coordinator.intentionalAbort = false;
|
|
457
|
+
gates.report('working', currentActivity(todos.items));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
|
|
461
|
+
const usage = (ctx as { getContextUsage?: () => { tokens?: number | null } })?.getContextUsage?.();
|
|
462
|
+
if (typeof usage?.tokens === 'number') {
|
|
463
|
+
coordinator.onBeforeProviderRequest(usage.tokens);
|
|
464
|
+
}
|
|
355
465
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
466
|
+
|
|
467
|
+
pi.on('input', (event: unknown) => {
|
|
468
|
+
if (event && typeof event === 'object') {
|
|
469
|
+
coordinator.onInput(event as { text?: string; source?: string; streamingBehavior?: string });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
|
|
474
|
+
|
|
475
|
+
pi.on('turn_end', async (event: unknown, ctx) => {
|
|
476
|
+
// Master registered this twice: the lifecycle handler skipped malformed events, the notice
|
|
477
|
+
// flush ran regardless. The merged handler keeps both semantics.
|
|
478
|
+
const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
|
|
479
|
+
if (msg !== null && typeof msg === 'object') {
|
|
480
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
481
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
482
|
+
lastStopReason = stopReason;
|
|
483
|
+
}
|
|
484
|
+
if (ctx && typeof ctx === 'object') {
|
|
485
|
+
coordinator.onTurnEnd({
|
|
486
|
+
ctx,
|
|
487
|
+
todos: todos.items,
|
|
488
|
+
config: effConfig.onlineContextCompact,
|
|
489
|
+
cancelReminder: todoUi.cancelReminder,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
363
492
|
}
|
|
493
|
+
void notices.flush('steer');
|
|
364
494
|
});
|
|
365
495
|
|
|
366
|
-
pi.on('agent_settled', async () => {
|
|
496
|
+
pi.on('agent_settled', async (_event: unknown, ctx) => {
|
|
367
497
|
agentActive = false;
|
|
368
|
-
|
|
498
|
+
gates.report('idle', null);
|
|
369
499
|
const plan = planSettleWake({
|
|
370
500
|
lastStopReason,
|
|
501
|
+
intentionalAbort: coordinator.intentionalAbort,
|
|
502
|
+
compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
|
|
371
503
|
running: subagentPort.current?.listRunningSubs() ?? [],
|
|
372
504
|
lastNoticeKey: d96NoticeKey,
|
|
373
505
|
lastNoticeAt: d96NoticeAt,
|
|
@@ -375,74 +507,54 @@ export default async function (pi: ExtensionAPI) {
|
|
|
375
507
|
});
|
|
376
508
|
d96NoticeKey = plan.noticeKey;
|
|
377
509
|
d96NoticeAt = plan.noticeAt;
|
|
378
|
-
if (!plan.wake) {
|
|
379
|
-
// Stay silent after a user abort while retaining the settlement buffer (do not clear pendingSettleNotices);
|
|
380
|
-
// deliver it on the next natural run's turn_end steer or natural settlement.
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
510
|
// D96: master settled while background subagents still run → remind (worker port is unbound).
|
|
384
|
-
if (plan.notice && !isSubagent) {
|
|
511
|
+
if (plan.wake && plan.notice && !isSubagent) {
|
|
385
512
|
const running = subagentPort.current?.listRunningSubs() ?? [];
|
|
386
513
|
const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
|
|
387
514
|
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
|
|
388
|
-
void
|
|
515
|
+
void sendUserMessageAs(notice, 'followUp');
|
|
389
516
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
function exitBlocked(): void {
|
|
406
|
-
blockedDepth = Math.max(0, blockedDepth - 1);
|
|
407
|
-
if (blockedDepth === 0) {
|
|
408
|
-
reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
|
|
409
|
-
// D95: gate released → clear marker.
|
|
410
|
-
void client.reportAskFlag(null).catch(() => {});
|
|
517
|
+
if (plan.compact && ctx && typeof ctx === 'object') {
|
|
518
|
+
void coordinator.onAgentSettled({
|
|
519
|
+
ctx,
|
|
520
|
+
todos: todos.items,
|
|
521
|
+
pi,
|
|
522
|
+
config: effConfig.onlineContextCompact,
|
|
523
|
+
cancelReminder: todoUi.cancelReminder,
|
|
524
|
+
onBeforeCompact: createCompactionBatchPackHook({
|
|
525
|
+
getObsConfig: () => effConfig.observationPack,
|
|
526
|
+
getManifest: () => roles.state.manifest,
|
|
527
|
+
getSessionId: () => sessionId,
|
|
528
|
+
pickMiddleExcerpt,
|
|
529
|
+
}),
|
|
530
|
+
});
|
|
411
531
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
532
|
+
if (lastStopReason !== ABORT_STOP_REASON) void notices.flush('followUp');
|
|
533
|
+
void pushSettleReply();
|
|
534
|
+
});
|
|
415
535
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
);
|
|
427
|
-
} finally {
|
|
428
|
-
publishingHerdrBlocked = false;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
pi.events.on('herdr:blocked', (data) => {
|
|
432
|
-
if (publishingHerdrBlocked) return;
|
|
433
|
-
if (!data || typeof data !== 'object') {
|
|
434
|
-
exitBlocked();
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
if (!('active' in data) || data.active !== true) {
|
|
438
|
-
exitBlocked();
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
|
|
536
|
+
/* ── D101 ObservationPack (obs_recall tool & context projector) ── */
|
|
537
|
+
registerObservationPack({
|
|
538
|
+
pi,
|
|
539
|
+
getConfig: () => effConfig,
|
|
540
|
+
getRuntimeManifest: () => roles.state.manifest,
|
|
541
|
+
pickMiddleExcerpt,
|
|
542
|
+
getRemainingHorizon: () => {
|
|
543
|
+
const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
|
|
544
|
+
return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
|
|
545
|
+
},
|
|
442
546
|
});
|
|
443
547
|
|
|
444
|
-
|
|
548
|
+
installConfigCommand({ pi });
|
|
549
|
+
installDashboardCommand({
|
|
550
|
+
pi,
|
|
551
|
+
client,
|
|
552
|
+
env,
|
|
553
|
+
getTodoItems: () => todos.items,
|
|
554
|
+
getHeldLocks: () => locksHandle.getHeldLocks(),
|
|
555
|
+
});
|
|
445
556
|
|
|
557
|
+
/* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
|
|
446
558
|
pi.registerTool({
|
|
447
559
|
name: ASK_TOOL_NAME,
|
|
448
560
|
label: 'Ask User',
|
|
@@ -454,117 +566,36 @@ export default async function (pi: ExtensionAPI) {
|
|
|
454
566
|
if (!prepared.ok) return prepared.result;
|
|
455
567
|
const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
|
|
456
568
|
if (!hasAskUi(ui)) return noUiResult();
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
//
|
|
460
|
-
// to later working reports; refresh while the gate is open. Do not re-emit
|
|
461
|
-
// herdr:blocked here — official blockedCount is edge-triggered.
|
|
462
|
-
const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
|
|
569
|
+
gates.publish(true, gateLabel(prepared.spec));
|
|
570
|
+
// The gate stays reported for the whole wait: gates.report drops
|
|
571
|
+
// working/idle while blocked, and pi's ui_prompt events keep the depth honest.
|
|
463
572
|
try {
|
|
464
573
|
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
465
574
|
} finally {
|
|
466
|
-
|
|
467
|
-
publishHerdrBlocked(false, null);
|
|
575
|
+
gates.publish(false, null);
|
|
468
576
|
}
|
|
469
577
|
},
|
|
470
578
|
});
|
|
471
579
|
|
|
472
580
|
pi.on('session_shutdown', async () => {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const settleNoticeLatch = new Set<string>();
|
|
479
|
-
/** Deduplicate settlement notices: choose push fast path or pollLoop fallback; one notice per paneId+request ID per round. */
|
|
480
|
-
function claimSettleNotice(key: string): boolean {
|
|
481
|
-
if (settleNoticeLatch.has(key)) return false;
|
|
482
|
-
settleNoticeLatch.add(key);
|
|
483
|
-
return true;
|
|
484
|
-
}
|
|
485
|
-
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
486
|
-
let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
|
|
487
|
-
let latestCtx: { abort?: () => void } | null = null;
|
|
488
|
-
|
|
489
|
-
// triggerTurn:true — idle followUp must start a new turn or settlement is lost.
|
|
490
|
-
const sendUserMessageIn = (content: string): Promise<void> =>
|
|
491
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
492
|
-
.sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
|
|
493
|
-
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
|
|
494
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
495
|
-
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
|
|
496
|
-
|
|
497
|
-
const notices = createNoticeBuffer({
|
|
498
|
-
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
499
|
-
send: sendUserMessageAs,
|
|
500
|
-
});
|
|
501
|
-
const deliverNotice = notices.deliverNotice;
|
|
502
|
-
pi.on('turn_end', () => {
|
|
503
|
-
void notices.flush('steer');
|
|
504
|
-
});
|
|
505
|
-
pi.on('agent_settled', () => {
|
|
506
|
-
if (lastStopReason === ABORT_STOP_REASON) return;
|
|
507
|
-
void notices.flush('followUp');
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
pi.on('session_start', async (_event, ctx) => {
|
|
511
|
-
latestCtx = ctx as { abort?: () => void } | null;
|
|
512
|
-
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
513
|
-
const paneId = env?.paneId ?? '';
|
|
514
|
-
if (!paneId) return;
|
|
515
|
-
const name = pipeNameFor(cwd, paneId);
|
|
516
|
-
if (pipeServerBox.current) {
|
|
517
|
-
try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
|
|
518
|
-
pipeServerBox.current = null;
|
|
519
|
-
}
|
|
520
|
-
try {
|
|
521
|
-
pipeServerBox.current = startPipeServer(name, async (req) => handlePipeRequest(req, {
|
|
522
|
-
paneId,
|
|
523
|
-
port: subagentPort,
|
|
524
|
-
claimSettleNotice,
|
|
525
|
-
deliverNotice,
|
|
526
|
-
sendUserMessageIn,
|
|
527
|
-
sendUserMessageAs,
|
|
528
|
-
abort: () => { latestCtx?.abort?.(); },
|
|
529
|
-
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
530
|
-
}));
|
|
531
|
-
} catch {
|
|
532
|
-
/* Pipe name collision (rare): this session has no channel; callers report an error after ping times out. */
|
|
533
|
-
}
|
|
534
|
-
});
|
|
535
|
-
pi.on('session_shutdown', () => {
|
|
581
|
+
coordinator.compactionInFlight = false;
|
|
582
|
+
coordinator.intentionalAbort = false;
|
|
583
|
+
sessionFocusPoller.current?.stop();
|
|
584
|
+
sessionFocusPoller.current = null;
|
|
536
585
|
if (pipeServerBox.current) {
|
|
537
586
|
try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
|
|
538
587
|
pipeServerBox.current = null;
|
|
539
588
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const req = pendingMachineRequest;
|
|
545
|
-
if (!req || !req.push || !req.from) return;
|
|
546
|
-
pendingMachineRequest = null;
|
|
547
|
-
try {
|
|
548
|
-
let text: string | null = null;
|
|
549
|
-
if (sessionId && /\.jsonl$/.test(sessionId)) {
|
|
550
|
-
const entries = readSessionFile(sessionId);
|
|
551
|
-
if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
|
|
589
|
+
if (sessionRoot) {
|
|
590
|
+
try {
|
|
591
|
+
await pruneSessionObjects(sessionRoot);
|
|
592
|
+
} catch {
|
|
552
593
|
}
|
|
553
|
-
await pipeRequest(req.from, {
|
|
554
|
-
type: 'reply',
|
|
555
|
-
id: req.id,
|
|
556
|
-
paneId: env?.paneId ?? '',
|
|
557
|
-
text,
|
|
558
|
-
sessionFile: sessionId || null,
|
|
559
|
-
}, 5000);
|
|
560
|
-
} catch {
|
|
561
|
-
/* Push failed silently; the requester's pollLoop is the fallback. */
|
|
562
594
|
}
|
|
595
|
+
client.close();
|
|
563
596
|
});
|
|
564
597
|
|
|
565
|
-
/*
|
|
566
|
-
* Bare pi and worker panes mount todo only so unused tools never appear.
|
|
567
|
-
* Dynamic import: worker/bare-pi must never load bootstrap/subagent-scope (C3). */
|
|
598
|
+
/* ── master vs worker mount (dynamic: workers never load the cordis tree, C3) ── */
|
|
568
599
|
if (mode.composeMaster) {
|
|
569
600
|
const { mountMasterPlugins } = await import('./index-master.ts');
|
|
570
601
|
await mountMasterPlugins({
|
|
@@ -580,10 +611,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
580
611
|
deliverNotice,
|
|
581
612
|
noticePending: notices.noticePending,
|
|
582
613
|
getSessionId: () => sessionId,
|
|
583
|
-
getBlockedDepth: () =>
|
|
614
|
+
getBlockedDepth: () => gates.depth(),
|
|
584
615
|
reconcileOnSettlement,
|
|
585
616
|
withReconcileNotes,
|
|
586
617
|
claimSettleNotice,
|
|
618
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
619
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
620
|
+
jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
|
|
621
|
+
appendRoutingLog,
|
|
587
622
|
});
|
|
588
623
|
} else {
|
|
589
624
|
const { mountTodoOnly } = await import('./index-worker.ts');
|
|
@@ -592,13 +627,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
592
627
|
todos,
|
|
593
628
|
todoUi,
|
|
594
629
|
mirrorTodos,
|
|
595
|
-
getBlockedDepth: () =>
|
|
630
|
+
getBlockedDepth: () => gates.depth(),
|
|
596
631
|
...(isSubagent
|
|
597
632
|
? {}
|
|
598
633
|
: {
|
|
599
634
|
stopReminder: {
|
|
600
|
-
getBlockedDepth: () =>
|
|
635
|
+
getBlockedDepth: () => gates.depth(),
|
|
601
636
|
getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
|
|
637
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
638
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
602
639
|
},
|
|
603
640
|
}),
|
|
604
641
|
});
|