pi-pier 0.3.0 → 0.4.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 +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/index.ts
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
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';
|
|
13
|
+
import { Type } from 'typebox';
|
|
18
14
|
import {
|
|
19
15
|
ASK_PARAMETERS,
|
|
20
16
|
ASK_PROMPT_GUIDELINES,
|
|
@@ -27,46 +23,49 @@ import {
|
|
|
27
23
|
runAsk,
|
|
28
24
|
} from './ask-user.ts';
|
|
29
25
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
currentActivity,
|
|
33
|
-
} from './todo-core.ts';
|
|
34
|
-
|
|
35
|
-
import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
|
|
26
|
+
import { TODO_EDIT_CUSTOM_TYPE, currentActivity } from './todo-core.ts';
|
|
27
|
+
import { installRenderers } from './renderers.ts';
|
|
36
28
|
import { createHerdrClient } from './herdr-client.ts';
|
|
37
|
-
|
|
38
|
-
// session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
|
|
39
|
-
import { lastAssistantText, readSessionFile } from './session-tail.ts';
|
|
29
|
+
import { bareSessionId, lastAssistantText, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
|
|
40
30
|
import { fileURLToPath } from 'node:url';
|
|
41
|
-
import {
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { platformPaths } from './platform-paths.ts';
|
|
35
|
+
import { TodosService, type TodoCompletionSource } from './todos-service.ts';
|
|
42
36
|
import { reconcileTodos } from './reconcile-core.ts';
|
|
43
|
-
import type { TodoUiSlot } from './
|
|
44
|
-
import {
|
|
45
|
-
estimateEta,
|
|
46
|
-
formatProgressSuffix,
|
|
47
|
-
planToolBadge,
|
|
48
|
-
progressOf,
|
|
49
|
-
} from './progress-core.ts';
|
|
37
|
+
import type { TodoUiSlot } from './plugins/todo.ts';
|
|
38
|
+
import { estimateEta, formatProgressSuffix, planToolBadge, progressOf } from './progress-core.ts';
|
|
50
39
|
import { WRITE_LOCK_ENV } from './lock-core.ts';
|
|
51
|
-
import { ABORT_STOP_REASON, planSettleWake } from './settle-wake-core.ts';
|
|
52
|
-
import {
|
|
53
|
-
import { parseRuntimeManifest, planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
|
|
54
|
-
|
|
40
|
+
import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
|
|
41
|
+
import { parseRuntimeManifest } from './tool-gate.ts';
|
|
55
42
|
import { formatPaneTitle } from './pane-title.ts';
|
|
56
43
|
import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
|
|
57
|
-
import { planIndexMode } from './index-runtime.ts';
|
|
58
|
-
import { emptySubagentPortBox } from './subagent-
|
|
44
|
+
import { applyRestoredWorkerMode, planIndexMode } from './index-runtime.ts';
|
|
45
|
+
import { buildResumeArgv, emptySubagentPortBox, PROGRESS_CUSTOM_TYPE } from './subagent-core.ts';
|
|
46
|
+
import { hasWorkerMarker, writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
|
|
47
|
+
import {
|
|
48
|
+
DELEGATION_CUSTOM_TYPE,
|
|
49
|
+
applyWorkerTag,
|
|
50
|
+
buildDelegationSection,
|
|
51
|
+
foldDelegation,
|
|
52
|
+
parseDelegationContext,
|
|
53
|
+
type DelegationContext,
|
|
54
|
+
} from './delegation-core.ts';
|
|
55
|
+
import { formatDelegationTree, type WorkerTodoSnapshot, type WorkerWidgetRow } from './tree-view.ts';
|
|
59
56
|
import { createNoticeBuffer } from './index-notices.ts';
|
|
60
|
-
import { handlePipeRequest } from './index-pipe.ts';
|
|
57
|
+
import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
|
|
61
58
|
import { installWriteLocks } from './index-locks.ts';
|
|
62
59
|
import { installDashboardCommand } from './dashboard-command.ts';
|
|
63
|
-
import {
|
|
60
|
+
import { openPierPanel } from './panel-open.ts';
|
|
61
|
+
import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './plugins/observation.ts';
|
|
64
62
|
import { createJevRuntime } from './jev-client.ts';
|
|
65
63
|
import {
|
|
66
64
|
NOTICE_RANK_MIN_CONFIDENCE,
|
|
67
65
|
buildExcerptWindows,
|
|
68
66
|
composeNoticeRanking,
|
|
69
67
|
evaluateExcerptPick,
|
|
68
|
+
excerptAskIsSafe,
|
|
70
69
|
excerptPickRequest,
|
|
71
70
|
noticeRankRequest,
|
|
72
71
|
} from './jev-core.ts';
|
|
@@ -74,93 +73,57 @@ import { completeLineExcerpt } from './observation-core.ts';
|
|
|
74
73
|
import { installConfigCommand } from './config-command.ts';
|
|
75
74
|
import { CompactCoordinator } from './compact-coordinator.ts';
|
|
76
75
|
import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
|
|
77
|
-
import {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} from './
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} from './efficiency-config-core.ts';
|
|
86
|
-
import {
|
|
87
|
-
resolveDefaultFocusPollMs,
|
|
88
|
-
parseFocusSample,
|
|
89
|
-
spawnReflow,
|
|
90
|
-
startFocusPoller,
|
|
91
|
-
type FocusPoller,
|
|
92
|
-
} from './focus-poller.ts';
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* D-4: focus sampling cadence. `PIER_FOCUS_POLL_MS=0` disables the poller (heat layout then only
|
|
96
|
-
* reacts to herdr events). Invalid values fall back to the version default: 1500ms on Herdr <0.9.1,
|
|
97
|
-
* 0 (event-first) on 0.9.1+ where native pane.focused is authoritative.
|
|
98
|
-
*/
|
|
99
|
-
function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
|
|
100
|
-
const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
|
|
101
|
-
const raw = env.PIER_FOCUS_POLL_MS;
|
|
102
|
-
if (raw === undefined || raw.trim() === '') return defaultMs;
|
|
103
|
-
const ms = Number(raw);
|
|
104
|
-
return Number.isFinite(ms) && ms >= 0 ? ms : defaultMs;
|
|
76
|
+
import { appendEfficiencyLog, efficiencyLogPath, pruneSessionObjects, resolveSessionRoot } from './efficiency-store.ts';
|
|
77
|
+
import type { RoutingTelemetryRecord, RoutingTelemetryRow } from './routing-telemetry.ts';
|
|
78
|
+
import { loadEfficiencyConfigFromDisk, resolveEfficiencyConfig, type EfficiencyConfig } from './efficiency-config-core.ts';
|
|
79
|
+
import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
|
|
80
|
+
import { createGateRuntime } from './index-gates.ts';
|
|
81
|
+
|
|
82
|
+
function agentSessionsDir(): string {
|
|
83
|
+
return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
|
|
105
84
|
}
|
|
106
85
|
|
|
107
|
-
/**
|
|
108
|
-
* WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
|
|
109
|
-
* (gate, visible layer, badge, and session record); the baseline includes our core plus inspect/execute tools.
|
|
110
|
-
* Match only a herdr pane (HERDR_ENV=1) that is not a subagent and has no explicitly supplied manifest.
|
|
111
|
-
* Standalone pi sessions without herdr remain full-featured for backward compatibility.
|
|
112
|
-
* A malformed master.json fails open to no-role state, matching malformed env semantics.
|
|
113
|
-
*/
|
|
114
|
-
function composeMasterRuntime(): RuntimeRoleManifest | null {
|
|
115
|
-
try {
|
|
116
|
-
// 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.
|
|
117
|
-
const { role, manifest } = composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } });
|
|
118
|
-
return {
|
|
119
|
-
role: role.role,
|
|
120
|
-
version: role.version,
|
|
121
|
-
tools: manifest.tools,
|
|
122
|
-
permissions: manifest.permissions,
|
|
123
|
-
unknownTools: manifest.unknownTools,
|
|
124
|
-
services: role.services ?? {},
|
|
125
|
-
};
|
|
126
|
-
} catch (err) {
|
|
127
|
-
console.error(`[pi-herdr] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
// C3: cordis belongs only in the master process; subagent-scope includes @deepseek-ai/cordis.
|
|
132
|
-
// Dynamically import it on the master branch so worker processes never load the module.
|
|
133
|
-
// Subagent constants/helpers moved with the family to core/subagent.ts.
|
|
134
|
-
|
|
135
86
|
export default async function (pi: ExtensionAPI) {
|
|
136
|
-
const mode = planIndexMode();
|
|
87
|
+
const mode = applyRestoredWorkerMode(planIndexMode(), process.env.PI_SESSION_FILE ?? null, hasWorkerMarker);
|
|
137
88
|
const isSubagent = mode.isSubagent;
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const { client, env } = createHerdrClient();
|
|
89
|
+
const roleBase = process.env.PI_HERDR_ROLE_BASE || process.cwd();
|
|
90
|
+
|
|
91
|
+
let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
|
|
92
|
+
let sessionRoot: string | null = null;
|
|
143
93
|
let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
|
|
144
94
|
const coordinator = new CompactCoordinator();
|
|
145
|
-
|
|
95
|
+
const { client, env } = createHerdrClient();
|
|
96
|
+
/** RFC rfc-delegation-identity §2.1: resolved env → marker → branch fold; worker panes only. */
|
|
97
|
+
let delegation: DelegationContext | null = parseDelegationContext(process.env.PI_HERDR_DELEGATION);
|
|
98
|
+
const initialManifest =
|
|
99
|
+
parseRuntimeManifest(process.env.PI_HERDR_ROLE_MANIFEST) ??
|
|
100
|
+
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
101
|
+
const todos = new TodosService(TodosService.configFromRuntime(initialManifest, isSubagent));
|
|
102
|
+
|
|
103
|
+
/** Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): best-effort append; never gates anything. */
|
|
104
|
+
const appendRoutingLog = (row: RoutingTelemetryRecord): void => {
|
|
105
|
+
const root = sessionRoot;
|
|
106
|
+
if (!root) return;
|
|
107
|
+
const stamped: RoutingTelemetryRow = { ...row, sessionId: sessionId || 'unknown' };
|
|
108
|
+
void appendEfficiencyLog(efficiencyLogPath(root, 'routing'), stamped).catch(() => {});
|
|
109
|
+
};
|
|
146
110
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
const jevMinConfidence = (): number => effConfig.jev.minConfidence;
|
|
111
|
+
const roles = createRoleRuntime({ pi, client, roleBase, initialManifest, isSubagent, todos, appendRoutingLog });
|
|
112
|
+
|
|
113
|
+
/* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
|
|
114
|
+
* abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
|
|
115
|
+
const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
|
|
153
116
|
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
|
|
154
117
|
const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
|
|
155
118
|
if (!jevRuntime.available) return null;
|
|
156
119
|
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
157
120
|
const windows = buildExcerptWindows(text, halfBudget);
|
|
158
|
-
if (windows.length === 0) return null; //
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
);
|
|
121
|
+
if (windows.length === 0) return null; // degenerate budget only
|
|
122
|
+
const headExcerpt = completeLineExcerpt(text, halfBudget, false);
|
|
123
|
+
const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
|
|
124
|
+
// Credential-shaped text never leaves the process (§8 privacy boundary).
|
|
125
|
+
if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
|
|
126
|
+
const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
|
|
164
127
|
if (!request) return null;
|
|
165
128
|
const result = await jevRuntime.ask(request, {
|
|
166
129
|
questionId: 'obs-excerpt-window',
|
|
@@ -171,95 +134,148 @@ export default async function (pi: ExtensionAPI) {
|
|
|
171
134
|
},
|
|
172
135
|
});
|
|
173
136
|
if (!result.ok) return null;
|
|
174
|
-
const picked = evaluateExcerptPick(result.answers,
|
|
137
|
+
const picked = evaluateExcerptPick(result.answers, effConfig.jev.minConfidence);
|
|
175
138
|
const window = picked === null ? undefined : windows.find((w) => w.id === picked);
|
|
176
139
|
return window ? { text: window.text, label: window.label } : null;
|
|
177
140
|
};
|
|
178
|
-
|
|
179
|
-
// best-effort (older pi builds without the renderer API simply keep the raw rows).
|
|
141
|
+
|
|
180
142
|
const rendererTypes = installRenderers(pi);
|
|
181
143
|
if (process.env.PI_HERDR_TRACE) {
|
|
182
144
|
console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
|
|
183
145
|
}
|
|
184
146
|
|
|
185
|
-
let
|
|
186
|
-
|
|
147
|
+
let agentActive = false;
|
|
148
|
+
let lastStopReason: string | null = null;
|
|
149
|
+
let latestCtx: { abort?: () => void } | null = null;
|
|
187
150
|
const completedStamps: number[] = [];
|
|
151
|
+
const subagentPort = emptySubagentPortBox();
|
|
152
|
+
let d96NoticeKey: string | null = null;
|
|
153
|
+
let d96NoticeAt = 0;
|
|
188
154
|
|
|
189
|
-
|
|
155
|
+
const todoUi: TodoUiSlot = { renderWidget: () => { /* filled after plugin mounting */ } };
|
|
156
|
+
const gates = createGateRuntime({
|
|
157
|
+
pi,
|
|
158
|
+
client,
|
|
159
|
+
rerenderWidget: () => todoUi.rerenderWidget?.(),
|
|
160
|
+
isAgentActive: () => agentActive,
|
|
161
|
+
idleMessage: () => roles.idleBadge(),
|
|
162
|
+
workingMessage: () => currentActivity(todos.items),
|
|
163
|
+
});
|
|
190
164
|
|
|
191
|
-
|
|
165
|
+
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
|
|
166
|
+
function resolveSessionId(ctx: unknown): string {
|
|
192
167
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
168
|
+
const sm = (ctx as {
|
|
169
|
+
sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
|
|
170
|
+
}).sessionManager;
|
|
171
|
+
return bareSessionId(
|
|
172
|
+
sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
|
|
173
|
+
);
|
|
197
174
|
} catch {
|
|
198
|
-
|
|
175
|
+
return bareSessionId(process.env.PI_SESSION_FILE ?? '');
|
|
199
176
|
}
|
|
200
177
|
}
|
|
201
178
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
/** Tier 2: worker role badge parsed from the env manifest and kept visible while idle. */
|
|
205
|
-
let roleBadge: string | null = null;
|
|
206
|
-
/** Human-gate depth; ask_user_question and external herdr:blocked events share it. */
|
|
207
|
-
let blockedDepth = 0;
|
|
208
|
-
|
|
209
|
-
function reportAgent(state: 'working' | 'idle' | 'blocked', activity: string | null): void {
|
|
210
|
-
if (!client.available) return;
|
|
211
|
-
// While a human gate is open, never let tool-badge / turn_start / settle overwrite blocked.
|
|
212
|
-
// Needed when herdr:pi is absent (pi-herdr is the authority); no-op under full-lifecycle herdr:pi.
|
|
213
|
-
if (blockedDepth > 0 && state !== 'blocked') return;
|
|
214
|
-
const message = activity ?? (state === 'idle' ? roleBadge : null);
|
|
215
|
-
client.reportAgent(state, message).catch(() => {});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
|
|
219
|
-
function resolveSessionId(ctx: unknown): string {
|
|
179
|
+
/** Branch reconstruction: todos + OCC debt state (branch correctness from the last snapshot on the branch). */
|
|
180
|
+
function rebuildFromBranch(ctx: unknown): void {
|
|
220
181
|
try {
|
|
221
|
-
const
|
|
222
|
-
sessionManager
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
};
|
|
226
|
-
}).sessionManager;
|
|
227
|
-
return (
|
|
228
|
-
sm?.getSessionFile?.() ??
|
|
229
|
-
sm?.getSessionId?.() ??
|
|
230
|
-
process.env.PI_SESSION_FILE ??
|
|
231
|
-
process.env.PI_SESSION_ID ??
|
|
232
|
-
''
|
|
233
|
-
);
|
|
182
|
+
const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
183
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
184
|
+
todos.rebuild(entries);
|
|
185
|
+
coordinator.rebuildFromBranch(entries);
|
|
234
186
|
} catch {
|
|
235
|
-
|
|
187
|
+
/* A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors. */
|
|
236
188
|
}
|
|
237
189
|
}
|
|
238
190
|
|
|
239
191
|
function mirrorTodos(): void {
|
|
240
192
|
if (!client.available) return;
|
|
241
193
|
const label = sessionId || (env ? `pane:${env.paneId}` : '');
|
|
242
|
-
// M16: progress badge (conservative N/M plus confidence ETA; fall back to a plain count when estimates are unreliable).
|
|
243
194
|
const p = progressOf(todos.items);
|
|
244
195
|
const eta = estimateEta({ completedAt: completedStamps, total: p.total, now: Date.now() });
|
|
245
196
|
const suffix = formatProgressSuffix({ completed: p.completed, total: p.total, eta });
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
// D97: overlay content shares the todo snapshot; thinking tokens cannot reach it.
|
|
197
|
+
const title = applyWorkerTag(
|
|
198
|
+
formatPaneTitle(todos.items, null, { progressSuffix: suffix, lastWriteAt: todos.lastWriteAt }),
|
|
199
|
+
delegation?.description ?? null,
|
|
200
|
+
);
|
|
252
201
|
updateSlimFrame({ title, items: todos.items, lastWriteAt: todos.lastWriteAt });
|
|
253
202
|
client.reportMetadata({
|
|
254
203
|
session: label,
|
|
255
204
|
items: todos.items,
|
|
256
205
|
progressSuffix: suffix,
|
|
257
206
|
lastWriteAt: todos.lastWriteAt,
|
|
207
|
+
workerTag: delegation?.description ?? null,
|
|
258
208
|
}).catch(() => {});
|
|
209
|
+
// D113: delegated workers also push the full list to the master's pipe so its resident widget
|
|
210
|
+
// and /pier-tree stay live without polling. Best effort — a lost push self-heals on the next write.
|
|
211
|
+
if (isSubagent && delegation?.parentPipe) {
|
|
212
|
+
void pipeRequest(delegation.parentPipe, {
|
|
213
|
+
type: 'todo_sync',
|
|
214
|
+
id: `tsync-${Date.now()}`,
|
|
215
|
+
paneId: env?.paneId ?? '',
|
|
216
|
+
items: todos.items.map((it) => ({ content: it.content, status: it.status, ...(it.blocker ? { blocker: it.blocker } : {}) })),
|
|
217
|
+
summary: title ?? delegation.description,
|
|
218
|
+
}, 3000).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** D113: per-worker todo cache (pipe pushes; summary fallback after a master resume). */
|
|
223
|
+
const workerTodos = new Map<string, WorkerTodoSnapshot>();
|
|
224
|
+
function applyWorkerTodos(workerPaneId: string, items: WorkerTodoSnapshot['items'], summary: string): void {
|
|
225
|
+
if (!workerPaneId) return;
|
|
226
|
+
workerTodos.set(workerPaneId, { items, summary, ts: Date.now() });
|
|
227
|
+
todoUi.rerenderWidget?.();
|
|
228
|
+
refreshTreeSnapshot();
|
|
229
|
+
}
|
|
230
|
+
/** Running rows joined with their cached todos — feeds the unified widget and /pier-tree. */
|
|
231
|
+
function widgetWorkers(): WorkerWidgetRow[] {
|
|
232
|
+
return (subagentPort.current?.listRunningSubs() ?? []).map((s) => ({
|
|
233
|
+
...s,
|
|
234
|
+
todos: workerTodos.get(s.paneId) ?? null,
|
|
235
|
+
}));
|
|
259
236
|
}
|
|
260
237
|
|
|
261
|
-
/* ──
|
|
238
|
+
/* ── /pier-tree panel: the pi process owns the data, the popup/overlay owns the surface. ── */
|
|
239
|
+
/** Set while a panel is open; todo/worker updates republish the snapshot it polls. */
|
|
240
|
+
let treeSnapshot: { path: string } | null = null;
|
|
241
|
+
|
|
242
|
+
function renderTreeLines(): string[] {
|
|
243
|
+
const rows = subagentPort.current?.listRows() ?? [];
|
|
244
|
+
return formatDelegationTree({
|
|
245
|
+
selfItems: todos.items,
|
|
246
|
+
workers: widgetWorkers(),
|
|
247
|
+
settled: rows
|
|
248
|
+
.filter((r) => r.status === 'settled' || r.status === 'consumed')
|
|
249
|
+
.map((r) => ({ paneId: r.paneId, slug: r.slug, description: r.description, settledAt: r.settledAt })),
|
|
250
|
+
now: Date.now(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
262
253
|
|
|
254
|
+
function treeSnapshotPath(): string {
|
|
255
|
+
const id = (sessionId || (env ? `pane-${env.paneId}` : 'session')).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
|
256
|
+
return join(tmpdir(), 'pier-tree', `${id}.txt`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function publishTreeSnapshot(): string {
|
|
260
|
+
const path = treeSnapshotPath();
|
|
261
|
+
mkdirSync(join(tmpdir(), 'pier-tree'), { recursive: true });
|
|
262
|
+
writeFileSync(path, `${renderTreeLines().join('\n')}\n`);
|
|
263
|
+
treeSnapshot = { path };
|
|
264
|
+
return path;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function refreshTreeSnapshot(): void {
|
|
268
|
+
if (!treeSnapshot) return;
|
|
269
|
+
try {
|
|
270
|
+
writeFileSync(treeSnapshot.path, `${renderTreeLines().join('\n')}\n`);
|
|
271
|
+
} catch {
|
|
272
|
+
/* The panel may have been closed and its temp dir swept; the next command republishes. */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
todos.on('todo.updated', () => refreshTreeSnapshot());
|
|
277
|
+
|
|
278
|
+
/** M17 settlement reconciliation: auto-complete settled todos / unblock on failure notes (idempotent). */
|
|
263
279
|
function reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[] {
|
|
264
280
|
if (!description) return [];
|
|
265
281
|
try {
|
|
@@ -271,7 +287,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
271
287
|
{ version: 1, edits: plan.edits, ts: Date.now() },
|
|
272
288
|
);
|
|
273
289
|
} catch {
|
|
274
|
-
/* Best effort persistence; in-memory state still advances. */
|
|
275
290
|
}
|
|
276
291
|
todos.applyEdits(plan.edits, { source: 'reconcile' });
|
|
277
292
|
mirrorTodos();
|
|
@@ -282,71 +297,200 @@ export default async function (pi: ExtensionAPI) {
|
|
|
282
297
|
}
|
|
283
298
|
}
|
|
284
299
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return notes.length ? `${base}\n${notes.join('\n')}` : base;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
|
|
300
|
+
const withReconcileNotes = (base: string, notes: readonly string[]): string =>
|
|
301
|
+
notes.length ? `${base}\n${notes.join('\n')}` : base;
|
|
291
302
|
|
|
292
|
-
todos.on('todo.completed', (e: { count: number; at: number; source?:
|
|
303
|
+
todos.on('todo.completed', (e: { count: number; at: number; source?: TodoCompletionSource }) => {
|
|
293
304
|
for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
|
|
294
305
|
coordinator.recordBoundaryCompleted(e.count, e.source);
|
|
295
306
|
mirrorTodos();
|
|
296
307
|
});
|
|
297
308
|
|
|
298
|
-
|
|
309
|
+
/* ── tool badges: parallel calls start in source order, end in completion order ── */
|
|
299
310
|
const runningTools = new Map<string, string>(); // toolCallId → toolName
|
|
300
311
|
let lastToolBadge: string | null = null;
|
|
301
312
|
function reportToolBadge(): void {
|
|
302
313
|
const badge = planToolBadge([...new Set(runningTools.values())]);
|
|
303
|
-
if (badge === lastToolBadge) return; //
|
|
314
|
+
if (badge === lastToolBadge) return; // report only changes (M13 rendering discipline)
|
|
304
315
|
lastToolBadge = badge;
|
|
305
|
-
if (badge)
|
|
306
|
-
else
|
|
316
|
+
if (badge) gates.report('working', badge);
|
|
317
|
+
else gates.report(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
|
|
307
318
|
}
|
|
308
|
-
|
|
309
319
|
pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
|
|
310
320
|
if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') {
|
|
311
321
|
runningTools.set(event.toolCallId, event.toolName);
|
|
312
322
|
reportToolBadge();
|
|
313
323
|
}
|
|
314
324
|
});
|
|
315
|
-
pi.on('tool_execution_end', async (event: { toolCallId?: string }) => {
|
|
316
|
-
if (typeof event?.toolCallId
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
pi.on('tool_execution_end', async (event: { toolCallId?: string; toolName?: string; durationMs?: number }) => {
|
|
326
|
+
if (typeof event?.toolCallId !== 'string') return;
|
|
327
|
+
const tool = typeof event.toolName === 'string' ? event.toolName : runningTools.get(event.toolCallId) ?? null;
|
|
328
|
+
runningTools.delete(event.toolCallId);
|
|
329
|
+
reportToolBadge();
|
|
330
|
+
// pi ≥ 1.1 records the real execution time on the event; the tools ledger replaces wall-clock
|
|
331
|
+
// estimates in the efficiency trial with measured numbers. Absent on older pi — nothing logged.
|
|
332
|
+
if (typeof event.durationMs === 'number' && sessionRoot) {
|
|
333
|
+
try {
|
|
334
|
+
await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'tools'), {
|
|
335
|
+
sessionId: sessionId || 'unknown',
|
|
336
|
+
ts: Date.now(),
|
|
337
|
+
tool,
|
|
338
|
+
durationMs: event.durationMs,
|
|
339
|
+
});
|
|
340
|
+
} catch {
|
|
341
|
+
/* ledger is observability only */
|
|
342
|
+
}
|
|
319
343
|
}
|
|
320
344
|
});
|
|
321
345
|
|
|
322
|
-
/* ── D102
|
|
346
|
+
/* ── D102 evidence-preserving reducer (registered before append-handlers like write-locks) ── */
|
|
323
347
|
pi.on('tool_result', async (event, ctx) => {
|
|
324
348
|
if (!effConfig.evidencePreservingReducer.enabled) return;
|
|
325
349
|
if (!event || typeof event !== 'object') return;
|
|
326
350
|
return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
|
|
327
351
|
epoch: coordinator.state.epoch,
|
|
328
|
-
jev: { ask: jevRuntime.ask, getMinConfidence:
|
|
352
|
+
jev: { ask: jevRuntime.ask, getMinConfidence: () => effConfig.jev.minConfidence },
|
|
329
353
|
});
|
|
330
354
|
});
|
|
331
355
|
|
|
332
356
|
let locksHandle = { getHeldLocks: () => [] as readonly string[] };
|
|
333
357
|
if (env) {
|
|
334
|
-
locksHandle = installWriteLocks(pi, {
|
|
335
|
-
client,
|
|
336
|
-
env,
|
|
337
|
-
hard: process.env[WRITE_LOCK_ENV] === '1',
|
|
338
|
-
});
|
|
358
|
+
locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
|
|
339
359
|
}
|
|
340
360
|
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
361
|
+
/** pi's sendUserMessage is fire-and-forget (void); callers of this wrapper still get a promise. */
|
|
362
|
+
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> => {
|
|
363
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => void })
|
|
364
|
+
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
|
|
365
|
+
return Promise.resolve();
|
|
344
366
|
};
|
|
345
367
|
|
|
346
|
-
|
|
368
|
+
/** Phase 2 (RFC rfc-delegation-identity §3.2): a worker's progress note enters master context as
|
|
369
|
+
* a display-less custom message — no turn, no visible chat line; renderers.ts gives it a card. */
|
|
370
|
+
const deliverProgress = (workerPaneId: string, text: string): Promise<void> => {
|
|
371
|
+
(pi as unknown as {
|
|
372
|
+
sendMessage?: (msg: { customType: string; content: string; display: boolean; details?: Record<string, unknown> }, opts?: { triggerTurn?: boolean }) => void;
|
|
373
|
+
}).sendMessage?.(
|
|
374
|
+
{ customType: PROGRESS_CUSTOM_TYPE, content: text.slice(0, 2000), display: false, details: { paneId: workerPaneId } },
|
|
375
|
+
{ triggerTurn: false },
|
|
376
|
+
);
|
|
377
|
+
return Promise.resolve();
|
|
378
|
+
};
|
|
379
|
+
const notices = createNoticeBuffer({
|
|
380
|
+
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
381
|
+
send: sendUserMessageAs,
|
|
382
|
+
// P0-2: rank collapsed batches by relevance to the master's in-progress work.
|
|
383
|
+
rank: async (contents) => {
|
|
384
|
+
if (!jevRuntime.available) return null;
|
|
385
|
+
const inProgressTodos = todos.items.filter((item) => item.status === 'in_progress').map((item) => item.content);
|
|
386
|
+
let rankedOrder: number[] | null | undefined;
|
|
387
|
+
const result = await jevRuntime.ask(
|
|
388
|
+
noticeRankRequest({ inProgressTodos, notices: [...contents] }),
|
|
389
|
+
{
|
|
390
|
+
questionId: 'notice-rank',
|
|
391
|
+
sessionId,
|
|
392
|
+
extra: { site: 'notice-rank', noticeCount: contents.length },
|
|
393
|
+
enrich: ({ answers }) => {
|
|
394
|
+
if (!answers) return {};
|
|
395
|
+
rankedOrder = composeNoticeRanking(
|
|
396
|
+
contents.length,
|
|
397
|
+
answers,
|
|
398
|
+
Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
|
|
399
|
+
);
|
|
400
|
+
const values = Array.from({ length: contents.length }, (_, i) => {
|
|
401
|
+
const rank = answers[`notice_${i}_rank`];
|
|
402
|
+
const fail = answers[`notice_${i}_fail`];
|
|
403
|
+
return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
|
|
404
|
+
? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
|
|
405
|
+
: '?';
|
|
406
|
+
});
|
|
407
|
+
return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
if (!result.ok) return null;
|
|
412
|
+
return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
const deliverNotice = notices.deliverNotice;
|
|
416
|
+
|
|
417
|
+
/* ── pipe channel: this pane's NDJSON server + the settle fast-path push (D48/D49/D50) ── */
|
|
418
|
+
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
419
|
+
let pendingMachineRequest: MachineRequest | null = null;
|
|
420
|
+
/** Dedup latch for settle notices: one per paneId+request id per round. */
|
|
421
|
+
const settleNoticeLatch = new Set<string>();
|
|
422
|
+
function claimSettleNotice(key: string): boolean {
|
|
423
|
+
if (settleNoticeLatch.has(key)) return false;
|
|
424
|
+
settleNoticeLatch.add(key);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function startPipeServerFor(cwd: string): void {
|
|
429
|
+
const paneId = env?.paneId ?? '';
|
|
430
|
+
if (!paneId) return;
|
|
431
|
+
if (pipeServerBox.current) {
|
|
432
|
+
try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
|
|
433
|
+
pipeServerBox.current = null;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
pipeServerBox.current = startPipeServer(pipeNameFor(cwd, paneId), async (req) => handlePipeRequest(req, {
|
|
437
|
+
paneId,
|
|
438
|
+
port: subagentPort,
|
|
439
|
+
claimSettleNotice,
|
|
440
|
+
deliverNotice,
|
|
441
|
+
sendUserMessageIn: (content) => sendUserMessageAs(content, 'followUp'),
|
|
442
|
+
sendUserMessageAs,
|
|
443
|
+
abort: () => { latestCtx?.abort?.(); },
|
|
444
|
+
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
445
|
+
applyRoleSwitch: roles.applyRoleSwitch,
|
|
446
|
+
deliverProgress,
|
|
447
|
+
applyWorkerTodos,
|
|
448
|
+
}));
|
|
449
|
+
} catch {
|
|
450
|
+
/* Pipe name collision (rare): this session has no channel; callers report after ping timeout. */
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function pushSettleReply(aborted = false): Promise<void> {
|
|
455
|
+
const req = pendingMachineRequest;
|
|
456
|
+
if (!req || !req.push || !req.from) return;
|
|
457
|
+
// An aborted settle is not a settlement: OCC's abort means the continuation turn is coming, and
|
|
458
|
+
// answering now would burn the one-shot request + the parent's claim latch on a null-text reply
|
|
459
|
+
// ("left no closing message") — the real closing message then never gets delivered. Keep the
|
|
460
|
+
// request armed; the post-compaction settle pushes it (settle-wake-core.ts).
|
|
461
|
+
if (!shouldPushSettleReply({
|
|
462
|
+
intentionalAbort: coordinator.intentionalAbort,
|
|
463
|
+
aborted,
|
|
464
|
+
compactionInFlight: coordinator.compactionInFlight,
|
|
465
|
+
lastStopReason,
|
|
466
|
+
})) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
pendingMachineRequest = null;
|
|
470
|
+
try {
|
|
471
|
+
const ownFile = sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null;
|
|
472
|
+
let text: string | null = null;
|
|
473
|
+
if (ownFile) {
|
|
474
|
+
const entries = readSessionFile(ownFile);
|
|
475
|
+
if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
|
|
476
|
+
}
|
|
477
|
+
await pipeRequest(req.from, {
|
|
478
|
+
type: 'reply',
|
|
479
|
+
id: req.id,
|
|
480
|
+
paneId: env?.paneId ?? '',
|
|
481
|
+
text,
|
|
482
|
+
// Push the resolved PATH when readable; fall back to the bare id for the master-side mapping.
|
|
483
|
+
sessionFile: ownFile ?? (sessionId || null),
|
|
484
|
+
}, 5000);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
/* The requester's pollLoop is the fallback; surface the dead letter for triage. */
|
|
487
|
+
console.error(`pier: settle reply push to ${req.from} failed: ${(err as Error)?.message ?? err}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
347
490
|
|
|
348
491
|
pi.on('session_start', async (event, ctx) => {
|
|
349
492
|
sessionId = resolveSessionId(ctx);
|
|
493
|
+
latestCtx = ctx as { abort?: () => void } | null;
|
|
350
494
|
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
351
495
|
const isTrusted =
|
|
352
496
|
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
@@ -357,117 +501,95 @@ export default async function (pi: ExtensionAPI) {
|
|
|
357
501
|
(ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
|
|
358
502
|
sessionId,
|
|
359
503
|
);
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (reason !== 'resume') todoUi.renderWidget(ctx);
|
|
365
|
-
// D97: narrow-frame overlay is meaningful only inside herdr; covering an interactive narrow terminal is unsafe,
|
|
366
|
-
// and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
|
|
504
|
+
// On resume pi restores widget state from the session; calling setWidget again
|
|
505
|
+
// breaks the TUI '/' command-panel route (v1.3 M9).
|
|
506
|
+
if ((event as { reason?: string } | undefined)?.reason !== 'resume') todoUi.renderWidget(ctx);
|
|
507
|
+
// D97: narrow-frame overlay is meaningful only inside herdr; re-register (session switching resets it).
|
|
367
508
|
if (env) registerSlimFrame(ctx);
|
|
509
|
+
// P2-5: resume folds todos/compaction state from the branch like session_tree; a fresh session folds nothing.
|
|
510
|
+
rebuildFromBranch(ctx);
|
|
368
511
|
mirrorTodos();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
// D77 visible layer: remove tools outside the manifest from the model's view after all plugins load at session_start.
|
|
392
|
-
// Intersection semantics prevent clearing everything; master without a manifest stays full. Missing APIs are skipped for old pi compatibility.
|
|
393
|
-
const piTools = pi as { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
394
|
-
if (typeof piTools.getActiveTools === 'function' && typeof piTools.setActiveTools === 'function') {
|
|
512
|
+
roles.syncFromBranch(ctx);
|
|
513
|
+
if (mode.composeMaster && sessionRoot) roles.scanRoleUsage();
|
|
514
|
+
gates.report('idle', null);
|
|
515
|
+
// Self-healing: the ask marker lives in herdr with a 24h TTL; a session killed
|
|
516
|
+
// while a dialog was open must not keep the pane looking blocked forever.
|
|
517
|
+
void client.reportAskFlag(null).catch(() => {});
|
|
518
|
+
if (isSubagent) {
|
|
519
|
+
// herdr 0.9.2 restore identity: the marker re-derives worker mode for the resumed boot
|
|
520
|
+
// (resume_argv cannot carry spawn env), and the self-reported command replays this exact
|
|
521
|
+
// session — extension, role model, approve, fullscreen — instead of a bare `pi --resume`.
|
|
522
|
+
const sm = typeof ctx === 'object' && ctx !== null && 'sessionManager' in ctx ? ctx.sessionManager : undefined;
|
|
523
|
+
const sessionFile = (typeof sm?.getSessionFile === 'function' ? sm.getSessionFile() : undefined)
|
|
524
|
+
?? process.env.PI_SESSION_FILE ?? null;
|
|
525
|
+
// RFC rfc-delegation-identity §2.1: env (spawn) > marker (herdr restart) > branch fold (/resume).
|
|
526
|
+
if (!delegation) delegation = readWorkerMarker(sessionFile)?.delegation ?? null;
|
|
527
|
+
const branchEntries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
528
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
529
|
+
const folded = foldDelegation(branchEntries as Parameters<typeof foldDelegation>[0]);
|
|
530
|
+
if (!delegation && folded) delegation = folded;
|
|
531
|
+
if (delegation && !folded) {
|
|
532
|
+
// First-frame card: write once — the entry doubles as the /resume replay source.
|
|
395
533
|
try {
|
|
396
|
-
|
|
397
|
-
const vis = planActiveTools(runtimeManifest.tools, active, {
|
|
398
|
-
unknownTools: runtimeManifest.unknownTools,
|
|
399
|
-
permissions: runtimeManifest.permissions,
|
|
400
|
-
});
|
|
401
|
-
if (vis?.changed) {
|
|
402
|
-
piTools.setActiveTools?.(vis.next);
|
|
403
|
-
console.error(`[pi-herdr] D77 visible-layer: role ${runtimeManifest.role} tools ${active.length} → ${vis.next.length}`);
|
|
404
|
-
}
|
|
534
|
+
pi.appendEntry(DELEGATION_CUSTOM_TYPE, { version: 1, ...delegation, ts: Date.now() });
|
|
405
535
|
} catch {
|
|
406
|
-
/* Best effort
|
|
536
|
+
/* Best effort; the marker already carries the identity. */
|
|
407
537
|
}
|
|
408
538
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
/* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
|
|
418
|
-
* deny / outside manifest → block (reason is given to the model); ask → v1 allows with stderr logging
|
|
419
|
-
* (V56 acceptance anchor). Master and label-only workers without a manifest remain open.
|
|
420
|
-
* Rate limiting was removed (WS-D6): we own the permission boundary; plugin integrators own resource quotas. */
|
|
421
|
-
pi.on('tool_call', async (event: { toolName?: string }) => {
|
|
422
|
-
if (!runtimeManifest) return;
|
|
423
|
-
const tool = typeof event?.toolName === 'string' ? event.toolName : '';
|
|
424
|
-
const gate = planToolGate(tool, runtimeManifest);
|
|
425
|
-
if (gate.kind === 'deny') {
|
|
426
|
-
// terminate (pi 0.84.1+) stops a batch whose results are all terminating without another
|
|
427
|
-
// model call, so a blocked worker tool no longer costs an extra round trip.
|
|
428
|
-
return { block: true, reason: gate.reason, terminate: true };
|
|
429
|
-
}
|
|
430
|
-
if (gate.kind === 'ask') {
|
|
431
|
-
console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
|
|
432
|
-
// Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
|
|
433
|
-
try {
|
|
434
|
-
(pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
|
|
435
|
-
APPROVAL_NEEDED_CUSTOM_TYPE,
|
|
436
|
-
{ role: runtimeManifest.role, tool, ts: Date.now() },
|
|
437
|
-
);
|
|
438
|
-
} catch {
|
|
439
|
-
/* Best effort. */
|
|
539
|
+
if (delegation) void client.reportDelegationTokens(delegation).catch(() => {});
|
|
540
|
+
if (sessionRoot) {
|
|
541
|
+
writeWorkerMarker(sessionFile, roles.state.manifest?.role, delegation ?? undefined);
|
|
542
|
+
const resumeArgv = buildResumeArgv(process.argv);
|
|
543
|
+
if (resumeArgv && client.available) {
|
|
544
|
+
void client.reportAgentSessionResume(sessionId, resumeArgv).catch(() => {});
|
|
545
|
+
}
|
|
440
546
|
}
|
|
441
547
|
}
|
|
548
|
+
|
|
549
|
+
// Focus heat is event-driven on herdr 0.9.5+: the workbench receives pane.focused directly,
|
|
550
|
+
// so no per-pane layout sampler is started here.
|
|
551
|
+
startPipeServerFor(cwd);
|
|
442
552
|
});
|
|
443
553
|
|
|
554
|
+
/* RFC rfc-delegation-identity §2.2: worker identity + delivery contract ride the prompt
|
|
555
|
+
* sections each turn (same diff discipline as pier-role; removal cleans up). */
|
|
556
|
+
if (isSubagent) {
|
|
557
|
+
pi.on('before_agent_start', async (event) => {
|
|
558
|
+
const sections = event.systemPromptOptions?.sections;
|
|
559
|
+
if (!sections) return;
|
|
560
|
+
if (delegation) {
|
|
561
|
+
const text = buildDelegationSection(delegation, env?.paneId ?? '');
|
|
562
|
+
if (sections['pier-delegation'] !== text) sections['pier-delegation'] = text;
|
|
563
|
+
} else {
|
|
564
|
+
delete sections['pier-delegation'];
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
444
568
|
|
|
445
|
-
// Session-tree navigation (/tree, /fork): reconstruction preserves branch correctness.
|
|
446
569
|
pi.on('session_tree', async (_event, ctx) => {
|
|
447
570
|
sessionId = resolveSessionId(ctx);
|
|
448
571
|
rebuildFromBranch(ctx);
|
|
572
|
+
// pi reverts the transcript loadout at the navigated branch point; the role
|
|
573
|
+
// runtime follows the same point (same-origin, RFC §4.3).
|
|
574
|
+
roles.syncFromBranch(ctx);
|
|
449
575
|
todoUi.renderWidget(ctx);
|
|
450
576
|
mirrorTodos();
|
|
451
577
|
});
|
|
452
578
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
// 'aborted' means the user explicitly pressed ESC, so settlement must inject no wake-up message.
|
|
460
|
-
let lastStopReason: string | null = null;
|
|
579
|
+
/* pi 1.2 counts the run as busy from `before_agent_start`, not `turn_start`: a settlement
|
|
580
|
+
* notice arriving in that window must enter the D92 buffer instead of being sent immediately. */
|
|
581
|
+
pi.on('before_agent_start', async () => {
|
|
582
|
+
agentActive = true;
|
|
583
|
+
});
|
|
584
|
+
|
|
461
585
|
pi.on('turn_start', async () => {
|
|
462
586
|
agentActive = true;
|
|
463
|
-
//
|
|
464
|
-
// does not taint subsequent turns or suppress settlement wake / notice flush.
|
|
587
|
+
// A11: reset so a previous turn's abort cannot taint this one.
|
|
465
588
|
lastStopReason = null;
|
|
466
|
-
// Safety fallback:
|
|
467
|
-
// ensure flags are reset. Pi core prohibits prompts while compaction is active, so this is safe.
|
|
589
|
+
// Safety fallback: pi core prohibits prompts while compaction is active.
|
|
468
590
|
coordinator.compactionInFlight = false;
|
|
469
591
|
coordinator.intentionalAbort = false;
|
|
470
|
-
|
|
592
|
+
gates.report('working', currentActivity(todos.items));
|
|
471
593
|
});
|
|
472
594
|
|
|
473
595
|
pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
|
|
@@ -486,29 +608,37 @@ export default async function (pi: ExtensionAPI) {
|
|
|
486
608
|
pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
|
|
487
609
|
|
|
488
610
|
pi.on('turn_end', async (event: unknown, ctx) => {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
611
|
+
// Master registered this twice: the lifecycle handler skipped malformed events, the notice
|
|
612
|
+
// flush ran regardless. The merged handler keeps both semantics.
|
|
613
|
+
const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
|
|
614
|
+
if (msg !== null && typeof msg === 'object') {
|
|
615
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
616
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
617
|
+
lastStopReason = stopReason;
|
|
618
|
+
}
|
|
619
|
+
if (ctx && typeof ctx === 'object') {
|
|
620
|
+
coordinator.onTurnEnd({
|
|
621
|
+
ctx,
|
|
622
|
+
todos: todos.items,
|
|
623
|
+
config: effConfig.onlineContextCompact,
|
|
624
|
+
cancelReminder: todoUi.cancelReminder,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
503
627
|
}
|
|
628
|
+
void notices.flush('steer');
|
|
504
629
|
});
|
|
505
630
|
|
|
506
|
-
pi.on('agent_settled', async (
|
|
631
|
+
pi.on('agent_settled', async (event: unknown, ctx) => {
|
|
507
632
|
agentActive = false;
|
|
508
|
-
|
|
633
|
+
gates.report('idle', null);
|
|
634
|
+
// pi ≥ 1.1 stamps cancelled runs on the event itself; stopReason tracking misses ESC mid-tool
|
|
635
|
+
// (no final assistant message), so the event field is authoritative when present.
|
|
636
|
+
const aborted = event !== null && typeof event === 'object' && 'aborted' in event && event.aborted === true;
|
|
509
637
|
const plan = planSettleWake({
|
|
510
638
|
lastStopReason,
|
|
511
639
|
intentionalAbort: coordinator.intentionalAbort,
|
|
640
|
+
aborted,
|
|
641
|
+
compactionPending: coordinator.selectedCompaction !== null || coordinator.compactionInFlight,
|
|
512
642
|
running: subagentPort.current?.listRunningSubs() ?? [],
|
|
513
643
|
lastNoticeKey: d96NoticeKey,
|
|
514
644
|
lastNoticeAt: d96NoticeAt,
|
|
@@ -516,19 +646,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
516
646
|
});
|
|
517
647
|
d96NoticeKey = plan.noticeKey;
|
|
518
648
|
d96NoticeAt = plan.noticeAt;
|
|
519
|
-
if (!plan.wake) {
|
|
520
|
-
// Stay silent after a user abort while retaining the settlement buffer (do not clear pendingSettleNotices);
|
|
521
|
-
// deliver it on the next natural run's turn_end steer or natural settlement.
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
649
|
// D96: master settled while background subagents still run → remind (worker port is unbound).
|
|
525
|
-
if (plan.notice && !isSubagent) {
|
|
650
|
+
if (plan.wake && plan.notice && !isSubagent) {
|
|
526
651
|
const running = subagentPort.current?.listRunningSubs() ?? [];
|
|
527
652
|
const brief = running.map((s) => `${s.paneId} (${s.description})`).join('、');
|
|
528
653
|
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
|
|
529
|
-
void
|
|
654
|
+
void sendUserMessageAs(notice, 'followUp');
|
|
530
655
|
}
|
|
531
|
-
if (ctx && typeof ctx === 'object') {
|
|
656
|
+
if (plan.compact && ctx && typeof ctx === 'object') {
|
|
532
657
|
void coordinator.onAgentSettled({
|
|
533
658
|
ctx,
|
|
534
659
|
todos: todos.items,
|
|
@@ -537,117 +662,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
537
662
|
cancelReminder: todoUi.cancelReminder,
|
|
538
663
|
onBeforeCompact: createCompactionBatchPackHook({
|
|
539
664
|
getObsConfig: () => effConfig.observationPack,
|
|
540
|
-
getManifest: () =>
|
|
665
|
+
getManifest: () => roles.state.manifest,
|
|
541
666
|
getSessionId: () => sessionId,
|
|
542
667
|
pickMiddleExcerpt,
|
|
543
668
|
}),
|
|
544
669
|
});
|
|
545
670
|
}
|
|
671
|
+
if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
|
|
672
|
+
void pushSettleReply(aborted);
|
|
546
673
|
});
|
|
547
674
|
|
|
548
|
-
|
|
549
|
-
// only on the herdr:blocked event (pi core never emits it). Custom source=pi-herdr
|
|
550
|
-
// pane.report_agent is ignored while that authority is live
|
|
551
|
-
// (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
|
|
552
|
-
// herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
|
|
553
|
-
/**
|
|
554
|
-
* Open one human gate. Nested gates (pier's ask tool plus pi's own ctx.ui prompt, or an
|
|
555
|
-
* external herdr:blocked producer) are coalesced: only the 0→1 transition reports blocked,
|
|
556
|
-
* marks the ask flag and folds the widget. Returns true when this call opened the outer gate.
|
|
557
|
-
*/
|
|
558
|
-
function enterBlocked(label: string | null): boolean {
|
|
559
|
-
blockedDepth += 1;
|
|
560
|
-
if (blockedDepth > 1) return false;
|
|
561
|
-
reportAgent('blocked', label);
|
|
562
|
-
// D95: human-gate marker lets the workbench heatmap distinguish ask from block.
|
|
563
|
-
if (label) void client.reportAskFlag(label).catch(() => {});
|
|
564
|
-
// Gate open collapses the todo widget to one line — the question owns the fixed area.
|
|
565
|
-
todoUi.rerenderWidget?.();
|
|
566
|
-
return true;
|
|
567
|
-
}
|
|
568
|
-
/** Close one gate; only the 1→0 transition restores the pre-gate state. Returns true when closed.
|
|
569
|
-
* An unmatched release (no gate open) is ignored rather than reported, so a stray
|
|
570
|
-
* ui_prompt_end cannot emit a false "no longer blocked" edge. */
|
|
571
|
-
function exitBlocked(): boolean {
|
|
572
|
-
if (blockedDepth === 0) return false;
|
|
573
|
-
blockedDepth -= 1;
|
|
574
|
-
// Gate closed → restore the full widget window.
|
|
575
|
-
todoUi.rerenderWidget?.();
|
|
576
|
-
if (blockedDepth > 0) return false;
|
|
577
|
-
reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
|
|
578
|
-
// D95: gate released → clear marker.
|
|
579
|
-
void client.reportAskFlag(null).catch(() => {});
|
|
580
|
-
return true;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
584
|
-
let publishingHerdrBlocked = false;
|
|
585
|
-
/** Publish the herdr:blocked edge for pi-herdr's own gates; no-op re-entry is prevented by the flag. */
|
|
586
|
-
function emitHerdrBlocked(active: boolean, label: string | null): void {
|
|
587
|
-
publishingHerdrBlocked = true;
|
|
588
|
-
try {
|
|
589
|
-
pi.events.emit(
|
|
590
|
-
'herdr:blocked',
|
|
591
|
-
active ? { active: true, ...(label ? { label } : {}) } : { active: false },
|
|
592
|
-
);
|
|
593
|
-
} finally {
|
|
594
|
-
publishingHerdrBlocked = false;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
function publishHerdrBlocked(active: boolean, label: string | null): void {
|
|
598
|
-
if (active) enterBlocked(label);
|
|
599
|
-
else exitBlocked();
|
|
600
|
-
emitHerdrBlocked(active, label);
|
|
601
|
-
}
|
|
602
|
-
pi.events.on('herdr:blocked', (data) => {
|
|
603
|
-
if (publishingHerdrBlocked) return;
|
|
604
|
-
if (!data || typeof data !== 'object') {
|
|
605
|
-
exitBlocked();
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
if (!('active' in data) || data.active !== true) {
|
|
609
|
-
exitBlocked();
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
/* ── Generic human-gate reporting (pi 0.84.4+) ─────────────────────────
|
|
616
|
-
* Why: `ui_prompt_start` / `ui_prompt_end` fire around blocking ctx.ui dialogs.
|
|
617
|
-
* Scope: only the inherently human-blocking kinds (select / confirm / input / editor).
|
|
618
|
-
* `custom` is deliberately excluded: pi routes both modals and *persistent overlays*
|
|
619
|
-
* through ctx.ui.custom, and pier itself registers a resident slim-frame overlay that
|
|
620
|
-
* never calls done() — treating it as a gate left the pane blocked (and the ask marker
|
|
621
|
-
* set) for the entire session while the agent kept working. Note that a resident overlay
|
|
622
|
-
* also holds pi's own prompt span open, so these events generally stop firing in herdr
|
|
623
|
-
* panes anyway; pier's ask tool opens its gate explicitly and is the reliable path there.
|
|
624
|
-
* Probed through a structural type because the events postdate our pinned pi devDependency. */
|
|
625
|
-
const uiPromptEvents = pi as unknown as {
|
|
626
|
-
on?: (name: 'ui_prompt_start' | 'ui_prompt_end', handler: (event: unknown) => void) => void;
|
|
627
|
-
};
|
|
628
|
-
const GATING_PROMPT_KINDS = new Set(['select', 'confirm', 'input', 'editor']);
|
|
629
|
-
uiPromptEvents.on?.('ui_prompt_start', (event) => {
|
|
630
|
-
const rec = (event ?? {}) as { kind?: unknown; title?: unknown };
|
|
631
|
-
const title = typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : null;
|
|
632
|
-
const kind = typeof rec.kind === 'string' ? rec.kind : 'prompt';
|
|
633
|
-
if (!GATING_PROMPT_KINDS.has(kind)) return;
|
|
634
|
-
// Normalize once so the gate report and the published edge carry the same label.
|
|
635
|
-
const label = title ?? kind;
|
|
636
|
-
// The ask tool opens its own gate first, so this is usually a nested (no-op) transition;
|
|
637
|
-
// prompts from other extensions or pi core open the gate here.
|
|
638
|
-
if (enterBlocked(label)) emitHerdrBlocked(true, label);
|
|
639
|
-
});
|
|
640
|
-
uiPromptEvents.on?.('ui_prompt_end', () => {
|
|
641
|
-
if (exitBlocked()) emitHerdrBlocked(false, null);
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
645
|
-
|
|
646
|
-
/* ── D101: ObservationPack (obs_recall tool & context projector) ── */
|
|
675
|
+
/* ── D101 ObservationPack (obs_recall tool & context projector) ── */
|
|
647
676
|
registerObservationPack({
|
|
648
677
|
pi,
|
|
649
678
|
getConfig: () => effConfig,
|
|
650
|
-
getRuntimeManifest: () =>
|
|
679
|
+
getRuntimeManifest: () => roles.state.manifest,
|
|
651
680
|
pickMiddleExcerpt,
|
|
652
681
|
getRemainingHorizon: () => {
|
|
653
682
|
const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
|
|
@@ -655,10 +684,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
655
684
|
},
|
|
656
685
|
});
|
|
657
686
|
|
|
658
|
-
/* ── D104: /pier-config (read-only config guide + guided-change handoff) ──
|
|
659
|
-
* Supersedes the former /efficiency command: the index line reports OCC/OBS/EPR state and
|
|
660
|
-
* `show efficiency` lists every D100-D103 knob with its effective value and source. */
|
|
661
|
-
|
|
662
687
|
installConfigCommand({ pi });
|
|
663
688
|
installDashboardCommand({
|
|
664
689
|
pi,
|
|
@@ -666,8 +691,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
666
691
|
env,
|
|
667
692
|
getTodoItems: () => todos.items,
|
|
668
693
|
getHeldLocks: () => locksHandle.getHeldLocks(),
|
|
694
|
+
prepareTree: () => publishTreeSnapshot(),
|
|
669
695
|
});
|
|
670
696
|
|
|
697
|
+
/* ── ask_user_question (v1.3 M8 human gate; master and subagents) ── */
|
|
671
698
|
pi.registerTool({
|
|
672
699
|
name: ASK_TOOL_NAME,
|
|
673
700
|
label: 'Ask User',
|
|
@@ -679,184 +706,85 @@ export default async function (pi: ExtensionAPI) {
|
|
|
679
706
|
if (!prepared.ok) return prepared.result;
|
|
680
707
|
const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
|
|
681
708
|
if (!hasAskUi(ui)) return noUiResult();
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
//
|
|
685
|
-
// blockedDepth > 0, and pi's ui_prompt_start/end events keep the depth honest for prompts
|
|
686
|
-
// that do not come from this tool. (The old 5s refresh interval is obsolete.)
|
|
709
|
+
gates.publish(true, gateLabel(prepared.spec));
|
|
710
|
+
// The gate stays reported for the whole wait: gates.report drops
|
|
711
|
+
// working/idle while blocked, and pi's ui_prompt events keep the depth honest.
|
|
687
712
|
try {
|
|
688
713
|
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
689
714
|
} finally {
|
|
690
|
-
|
|
715
|
+
gates.publish(false, null);
|
|
691
716
|
}
|
|
692
717
|
},
|
|
693
718
|
});
|
|
694
719
|
|
|
720
|
+
/* ── report_progress (Phase 2, RFC rfc-delegation-identity §3.2): worker → master checkpoint ── */
|
|
721
|
+
if (isSubagent) {
|
|
722
|
+
pi.registerTool({
|
|
723
|
+
name: 'report_progress',
|
|
724
|
+
label: 'Report Progress',
|
|
725
|
+
description:
|
|
726
|
+
'Send the delegating master a short progress update without finishing the task. Use at meaningful checkpoints (a module done, a test went green, a blocker found) — the master sees it in its context but is not interrupted.',
|
|
727
|
+
promptSnippet: 'report_progress: mid-task checkpoint to the delegating master (does not end the task).',
|
|
728
|
+
promptGuidelines: [
|
|
729
|
+
'Call at meaningful checkpoints only — a milestone reached, a risk discovered, a decision the master should know early — not after every tool call.',
|
|
730
|
+
'One or two sentences per call; the final answer still goes in your normal closing message when the task finishes.',
|
|
731
|
+
],
|
|
732
|
+
parameters: Type.Object({
|
|
733
|
+
message: Type.String({ description: 'Short checkpoint update for the delegating master.' }),
|
|
734
|
+
}),
|
|
735
|
+
async execute(_tc, params) {
|
|
736
|
+
const parentPipe = delegation?.parentPipe;
|
|
737
|
+
const message = typeof (params as { message?: unknown })?.message === 'string'
|
|
738
|
+
? (params as { message: string }).message.trim()
|
|
739
|
+
: '';
|
|
740
|
+
if (!message) {
|
|
741
|
+
return { content: [{ type: 'text', text: 'Error: `message` is required (one or two sentences).' }], details: {} };
|
|
742
|
+
}
|
|
743
|
+
if (!parentPipe) {
|
|
744
|
+
return { content: [{ type: 'text', text: 'Error: no delegating master pipe is known to this session; progress cannot be delivered.' }], details: {} };
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const res = await pipeRequest(parentPipe, {
|
|
748
|
+
type: 'progress',
|
|
749
|
+
id: `prog-${Date.now()}`,
|
|
750
|
+
paneId: env?.paneId ?? '',
|
|
751
|
+
text: message,
|
|
752
|
+
}, 5000);
|
|
753
|
+
return res.type === 'ok'
|
|
754
|
+
? { content: [{ type: 'text', text: 'Progress delivered to the master (visible in its context, no interruption).' }], details: {} }
|
|
755
|
+
: { content: [{ type: 'text', text: `Error: master rejected progress report: ${res.message}` }], details: {} };
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return { content: [{ type: 'text', text: `Error: could not reach the master pipe: ${(err as Error)?.message ?? err}` }], details: {} };
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
695
763
|
pi.on('session_shutdown', async () => {
|
|
696
764
|
coordinator.compactionInFlight = false;
|
|
697
765
|
coordinator.intentionalAbort = false;
|
|
766
|
+
if (pipeServerBox.current) {
|
|
767
|
+
try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
|
|
768
|
+
pipeServerBox.current = null;
|
|
769
|
+
}
|
|
698
770
|
if (sessionRoot) {
|
|
699
771
|
try {
|
|
700
772
|
await pruneSessionObjects(sessionRoot);
|
|
701
773
|
} catch {
|
|
702
|
-
/* ignore shutdown pruning errors */
|
|
703
774
|
}
|
|
704
775
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const settleNoticeLatch = new Set<string>();
|
|
711
|
-
/** Deduplicate settlement notices: choose push fast path or pollLoop fallback; one notice per paneId+request ID per round. */
|
|
712
|
-
function claimSettleNotice(key: string): boolean {
|
|
713
|
-
if (settleNoticeLatch.has(key)) return false;
|
|
714
|
-
settleNoticeLatch.add(key);
|
|
715
|
-
return true;
|
|
716
|
-
}
|
|
717
|
-
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
718
|
-
/** D-4: focus sampler for this pane (started on session_start, stopped on session_shutdown). */
|
|
719
|
-
const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
|
|
720
|
-
let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
|
|
721
|
-
let latestCtx: { abort?: () => void } | null = null;
|
|
722
|
-
|
|
723
|
-
// triggerTurn:true — idle followUp must start a new turn or settlement is lost.
|
|
724
|
-
const sendUserMessageIn = (content: string): Promise<void> =>
|
|
725
|
-
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
726
|
-
.sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
|
|
727
|
-
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
|
|
728
|
-
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
729
|
-
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
|
|
730
|
-
|
|
731
|
-
const notices = createNoticeBuffer({
|
|
732
|
-
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
733
|
-
send: sendUserMessageAs,
|
|
734
|
-
// P0-2: rank collapsed batches by relevance to the master's in-progress work
|
|
735
|
-
rank: async (contents) => {
|
|
736
|
-
if (!jevRuntime.available) return null;
|
|
737
|
-
const inProgressTodos = todos.items
|
|
738
|
-
.filter((item) => item.status === 'in_progress')
|
|
739
|
-
.map((item) => item.content);
|
|
740
|
-
let rankedOrder: number[] | null | undefined;
|
|
741
|
-
const result = await jevRuntime.ask(
|
|
742
|
-
noticeRankRequest({ inProgressTodos, notices: [...contents] }),
|
|
743
|
-
{
|
|
744
|
-
questionId: 'notice-rank',
|
|
745
|
-
sessionId,
|
|
746
|
-
extra: { site: 'notice-rank', noticeCount: contents.length },
|
|
747
|
-
enrich: ({ answers }) => {
|
|
748
|
-
if (!answers) return {};
|
|
749
|
-
rankedOrder = composeNoticeRanking(
|
|
750
|
-
contents.length,
|
|
751
|
-
answers,
|
|
752
|
-
Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
|
|
753
|
-
);
|
|
754
|
-
const values = Array.from({ length: contents.length }, (_, i) => {
|
|
755
|
-
const rank = answers[`notice_${i}_rank`];
|
|
756
|
-
const fail = answers[`notice_${i}_fail`];
|
|
757
|
-
return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
|
|
758
|
-
? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
|
|
759
|
-
: '?';
|
|
760
|
-
});
|
|
761
|
-
return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
|
|
762
|
-
},
|
|
763
|
-
},
|
|
764
|
-
);
|
|
765
|
-
if (!result.ok) return null;
|
|
766
|
-
return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
|
|
767
|
-
},
|
|
768
|
-
});
|
|
769
|
-
const deliverNotice = notices.deliverNotice;
|
|
770
|
-
pi.on('turn_end', () => {
|
|
771
|
-
void notices.flush('steer');
|
|
772
|
-
});
|
|
773
|
-
pi.on('agent_settled', () => {
|
|
774
|
-
if (lastStopReason === ABORT_STOP_REASON) return;
|
|
775
|
-
void notices.flush('followUp');
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
pi.on('session_start', async (_event, ctx) => {
|
|
779
|
-
latestCtx = ctx as { abort?: () => void } | null;
|
|
780
|
-
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
781
|
-
const paneId = env?.paneId ?? '';
|
|
782
|
-
// D-4: herdr 0.9.0 never delivered pane.focused to plugins; 0.9.1+ does. Poller is a
|
|
783
|
-
// fallback for old servers (see resolveDefaultFocusPollMs). One poller per pane, self-scoped.
|
|
784
|
-
if (sessionFocusPoller.current) {
|
|
785
|
-
sessionFocusPoller.current.stop();
|
|
786
|
-
sessionFocusPoller.current = null;
|
|
787
|
-
}
|
|
788
|
-
const serverVer = await client.getServerVersion();
|
|
789
|
-
const pollMs = focusPollIntervalMs(process.env, serverVer);
|
|
790
|
-
if (paneId && client.available && pollMs > 0) {
|
|
791
|
-
sessionFocusPoller.current = startFocusPoller({
|
|
792
|
-
myPaneId: paneId,
|
|
793
|
-
intervalMs: pollMs,
|
|
794
|
-
sample: async () => {
|
|
795
|
-
// layout.export reports the focused pane of our own tab. Without that field (older herdr)
|
|
796
|
-
// the tick stays silent instead of guessing focus from the tree.
|
|
797
|
-
const layout = await client.exportLayout({ paneId });
|
|
798
|
-
const sample = parseFocusSample(layout);
|
|
799
|
-
return sample && sample.focusedPaneId !== null ? sample : null;
|
|
800
|
-
},
|
|
801
|
-
fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
if (!paneId) return;
|
|
805
|
-
const name = pipeNameFor(cwd, paneId);
|
|
806
|
-
if (pipeServerBox.current) {
|
|
807
|
-
try { pipeServerBox.current.close(); } catch { /* Previous instance. */ }
|
|
808
|
-
pipeServerBox.current = null;
|
|
809
|
-
}
|
|
810
|
-
try {
|
|
811
|
-
pipeServerBox.current = startPipeServer(name, async (req) => handlePipeRequest(req, {
|
|
812
|
-
paneId,
|
|
813
|
-
port: subagentPort,
|
|
814
|
-
claimSettleNotice,
|
|
815
|
-
deliverNotice,
|
|
816
|
-
sendUserMessageIn,
|
|
817
|
-
sendUserMessageAs,
|
|
818
|
-
abort: () => { latestCtx?.abort?.(); },
|
|
819
|
-
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
820
|
-
}));
|
|
821
|
-
} catch {
|
|
822
|
-
/* Pipe name collision (rare): this session has no channel; callers report an error after ping times out. */
|
|
823
|
-
}
|
|
824
|
-
});
|
|
825
|
-
pi.on('session_shutdown', () => {
|
|
826
|
-
sessionFocusPoller.current?.stop();
|
|
827
|
-
sessionFocusPoller.current = null;
|
|
828
|
-
if (pipeServerBox.current) {
|
|
829
|
-
try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
|
|
830
|
-
pipeServerBox.current = null;
|
|
831
|
-
}
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
// D50: when this pane settles, push a summary and session path to any machine request awaiting a reply.
|
|
835
|
-
pi.on('agent_settled', async () => {
|
|
836
|
-
const req = pendingMachineRequest;
|
|
837
|
-
if (!req || !req.push || !req.from) return;
|
|
838
|
-
pendingMachineRequest = null;
|
|
839
|
-
try {
|
|
840
|
-
let text: string | null = null;
|
|
841
|
-
if (sessionId && /\.jsonl$/.test(sessionId)) {
|
|
842
|
-
const entries = readSessionFile(sessionId);
|
|
843
|
-
if (entries) text = lastAssistantText(entries, { sinceTs: req.sinceTs })?.text ?? null;
|
|
776
|
+
if (treeSnapshot) {
|
|
777
|
+
try {
|
|
778
|
+
unlinkSync(treeSnapshot.path);
|
|
779
|
+
} catch {
|
|
780
|
+
/* Already swept. */
|
|
844
781
|
}
|
|
845
|
-
|
|
846
|
-
type: 'reply',
|
|
847
|
-
id: req.id,
|
|
848
|
-
paneId: env?.paneId ?? '',
|
|
849
|
-
text,
|
|
850
|
-
sessionFile: sessionId || null,
|
|
851
|
-
}, 5000);
|
|
852
|
-
} catch {
|
|
853
|
-
/* Push failed silently; the requester's pollLoop is the fallback. */
|
|
782
|
+
treeSnapshot = null;
|
|
854
783
|
}
|
|
784
|
+
client.close();
|
|
855
785
|
});
|
|
856
786
|
|
|
857
|
-
/*
|
|
858
|
-
* Bare pi and worker panes mount todo only so unused tools never appear.
|
|
859
|
-
* Dynamic import: worker/bare-pi must never load bootstrap/subagent-scope (C3). */
|
|
787
|
+
/* ── master vs worker mount (dynamic: workers never load the cordis tree, C3) ── */
|
|
860
788
|
if (mode.composeMaster) {
|
|
861
789
|
const { mountMasterPlugins } = await import('./index-master.ts');
|
|
862
790
|
await mountMasterPlugins({
|
|
@@ -872,13 +800,76 @@ export default async function (pi: ExtensionAPI) {
|
|
|
872
800
|
deliverNotice,
|
|
873
801
|
noticePending: notices.noticePending,
|
|
874
802
|
getSessionId: () => sessionId,
|
|
875
|
-
getBlockedDepth: () =>
|
|
803
|
+
getBlockedDepth: () => gates.depth(),
|
|
804
|
+
getWorkers: widgetWorkers,
|
|
876
805
|
reconcileOnSettlement,
|
|
877
806
|
withReconcileNotes,
|
|
878
807
|
claimSettleNotice,
|
|
879
808
|
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
880
809
|
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
810
|
+
jev: { ask: (request, meta) => jevRuntime.ask(request, meta), getMinConfidence: () => effConfig.jev.minConfidence },
|
|
811
|
+
appendRoutingLog,
|
|
881
812
|
});
|
|
813
|
+
|
|
814
|
+
/* ── /pier-tree (D113): full todo × subagent delegation tree; the resident widget is minimal ── */
|
|
815
|
+
pi.registerCommand('pier-tree', {
|
|
816
|
+
description: 'Show the full delegation tree: your todos plus every subagent\'s todos and settled nodes',
|
|
817
|
+
handler: async (args: unknown, ctx: unknown) => {
|
|
818
|
+
const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
819
|
+
const lines = renderTreeLines();
|
|
820
|
+
const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
|
|
821
|
+
|
|
822
|
+
if (client.available && env) {
|
|
823
|
+
const treeFile = publishTreeSnapshot();
|
|
824
|
+
if (overlay) {
|
|
825
|
+
try {
|
|
826
|
+
const res = await client.openPluginPane({
|
|
827
|
+
pluginId: 'pier.workbench',
|
|
828
|
+
entrypoint: 'tree',
|
|
829
|
+
placement: 'overlay',
|
|
830
|
+
focus: true,
|
|
831
|
+
env: { PIER_PANEL: 'tree', PIER_TREE_FILE: treeFile },
|
|
832
|
+
});
|
|
833
|
+
if (res.mode === 'pane') return;
|
|
834
|
+
} catch {
|
|
835
|
+
// Plugin missing / older herdr: fall back to the in-pane notify view below.
|
|
836
|
+
}
|
|
837
|
+
} else {
|
|
838
|
+
const res = await openPierPanel({ client, env }, { panel: 'tree', treeFile });
|
|
839
|
+
if (res === 'popup') return;
|
|
840
|
+
if (res === 'tab') {
|
|
841
|
+
ui?.notify?.('Herdr < 0.9.1: Pier Tree opened in new tab', 'info');
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (res === 'busy') {
|
|
845
|
+
ui?.notify?.('Another popup is already open — close it and run /pier-tree again.', 'warning');
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// 'unavailable' / failed overlay → the old in-pane view.
|
|
850
|
+
}
|
|
851
|
+
treeSnapshot = null;
|
|
852
|
+
ui?.notify?.(lines.join('\n'), 'info');
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
/** D113 resync: a resumed master rebuilds worker summaries from herdr pi-todo tokens until the
|
|
857
|
+
* next live push replaces them with full lists. */
|
|
858
|
+
const resyncWorkerTodos = async (): Promise<void> => {
|
|
859
|
+
try {
|
|
860
|
+
const agents = await client.listAgents();
|
|
861
|
+
for (const a of agents) {
|
|
862
|
+
const summary = a.tokens?.['pi-todo'];
|
|
863
|
+
if (typeof summary === 'string' && summary && !workerTodos.has(a.paneId)) {
|
|
864
|
+
workerTodos.set(a.paneId, { summary, ts: Date.now() });
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
todoUi.rerenderWidget?.();
|
|
868
|
+
} catch {
|
|
869
|
+
/* herdr absent or unreachable: live pushes remain the source */
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
pi.on('session_start', () => { void resyncWorkerTodos(); });
|
|
882
873
|
} else {
|
|
883
874
|
const { mountTodoOnly } = await import('./index-worker.ts');
|
|
884
875
|
await mountTodoOnly({
|
|
@@ -886,12 +877,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
886
877
|
todos,
|
|
887
878
|
todoUi,
|
|
888
879
|
mirrorTodos,
|
|
889
|
-
getBlockedDepth: () =>
|
|
880
|
+
getBlockedDepth: () => gates.depth(),
|
|
890
881
|
...(isSubagent
|
|
891
882
|
? {}
|
|
892
883
|
: {
|
|
893
884
|
stopReminder: {
|
|
894
|
-
getBlockedDepth: () =>
|
|
885
|
+
getBlockedDepth: () => gates.depth(),
|
|
895
886
|
getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
|
|
896
887
|
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
897
888
|
isIntentionalAbort: () => coordinator.intentionalAbort,
|