pi-pier 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +121 -47
- package/src/core/todo.ts +25 -11
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +4 -2
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +82 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/index-pipe.ts
CHANGED
|
@@ -53,6 +53,11 @@ export async function handlePipeRequest(
|
|
|
53
53
|
return { type: 'ok', id: req.id };
|
|
54
54
|
case 'reply': {
|
|
55
55
|
s.port.current?.applyReplySession(req.paneId, req.sessionFile);
|
|
56
|
+
// B8: the claim key is `${paneId}:${requestId}` and the poll loop claims exactly the same shape
|
|
57
|
+
// (subagent-poll-loop.ts settle path). The two agree because a child echoes the id of the pipe
|
|
58
|
+
// request it answered (`id: req.id` in index.ts's settle push), and the parent hands that same
|
|
59
|
+
// id to startPoller (`prompt-<taskId>` / `fu-<ts>` in core/subagent.ts). Keep both sides in step:
|
|
60
|
+
// a mismatch here surfaces as two "finished" notices for one settlement.
|
|
56
61
|
if (s.claimSettleNotice(`${req.paneId}:${req.id}`)) {
|
|
57
62
|
const notes = s.port.current?.reconcileOnReply(req.paneId) ?? [];
|
|
58
63
|
const statLine = await s.port.current?.settleStatLine(req.paneId) ?? null;
|
package/src/index-worker.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface TodoOnlyMount {
|
|
|
22
22
|
stopReminder?: {
|
|
23
23
|
getBlockedDepth: () => number;
|
|
24
24
|
getRunningSubs: () => number;
|
|
25
|
+
isCompactionInFlight?: () => boolean;
|
|
26
|
+
isIntentionalAbort?: () => boolean;
|
|
25
27
|
};
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -35,7 +37,7 @@ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
|
|
|
35
37
|
maxItems: 15,
|
|
36
38
|
mirrorTodos: m.mirrorTodos,
|
|
37
39
|
appendEntry: (customType: string, data: unknown) => {
|
|
38
|
-
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType,
|
|
40
|
+
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
|
|
39
41
|
},
|
|
40
42
|
state: m.todoUi,
|
|
41
43
|
...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
|
package/src/index.ts
CHANGED
|
@@ -32,9 +32,7 @@ import {
|
|
|
32
32
|
currentActivity,
|
|
33
33
|
} from './todo-core.ts';
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
37
|
-
|
|
35
|
+
import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
|
|
38
36
|
import { createHerdrClient } from './herdr-client.ts';
|
|
39
37
|
// The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
|
|
40
38
|
// session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
|
|
@@ -61,6 +59,50 @@ import { emptySubagentPortBox } from './subagent-port.ts';
|
|
|
61
59
|
import { createNoticeBuffer } from './index-notices.ts';
|
|
62
60
|
import { handlePipeRequest } from './index-pipe.ts';
|
|
63
61
|
import { installWriteLocks } from './index-locks.ts';
|
|
62
|
+
import { installDashboardCommand } from './dashboard-command.ts';
|
|
63
|
+
import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './core/observation.ts';
|
|
64
|
+
import { createJevRuntime } from './jev-client.ts';
|
|
65
|
+
import {
|
|
66
|
+
NOTICE_RANK_MIN_CONFIDENCE,
|
|
67
|
+
buildExcerptWindows,
|
|
68
|
+
composeNoticeRanking,
|
|
69
|
+
evaluateExcerptPick,
|
|
70
|
+
excerptPickRequest,
|
|
71
|
+
noticeRankRequest,
|
|
72
|
+
} from './jev-core.ts';
|
|
73
|
+
import { completeLineExcerpt } from './observation-core.ts';
|
|
74
|
+
import { installConfigCommand } from './config-command.ts';
|
|
75
|
+
import { CompactCoordinator } from './compact-coordinator.ts';
|
|
76
|
+
import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
|
|
77
|
+
import {
|
|
78
|
+
pruneSessionObjects,
|
|
79
|
+
resolveSessionRoot,
|
|
80
|
+
} from './efficiency-store.ts';
|
|
81
|
+
import {
|
|
82
|
+
loadEfficiencyConfigFromDisk,
|
|
83
|
+
resolveEfficiencyConfig,
|
|
84
|
+
type EfficiencyConfig,
|
|
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;
|
|
105
|
+
}
|
|
64
106
|
|
|
65
107
|
/**
|
|
66
108
|
* WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
|
|
@@ -98,6 +140,47 @@ export default async function (pi: ExtensionAPI) {
|
|
|
98
140
|
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
99
141
|
const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
|
|
100
142
|
const { client, env } = createHerdrClient();
|
|
143
|
+
let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
|
|
144
|
+
const coordinator = new CompactCoordinator();
|
|
145
|
+
let sessionRoot: string | null = null;
|
|
146
|
+
|
|
147
|
+
/* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget abort,
|
|
148
|
+
* fail-open at every site. Disabled or keyless => behavior is byte-identical to before. ── */
|
|
149
|
+
const jevRuntime = createJevRuntime(() => effConfig.jev, {
|
|
150
|
+
getSessionRoot: () => sessionRoot,
|
|
151
|
+
});
|
|
152
|
+
const jevMinConfidence = (): number => effConfig.jev.minConfidence;
|
|
153
|
+
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
|
|
154
|
+
const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
|
|
155
|
+
if (!jevRuntime.available) return null;
|
|
156
|
+
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
157
|
+
const windows = buildExcerptWindows(text, halfBudget);
|
|
158
|
+
if (windows.length === 0) return null; // no failure-signal lines -> keep halves, no API call
|
|
159
|
+
const request = excerptPickRequest(
|
|
160
|
+
windows,
|
|
161
|
+
completeLineExcerpt(text, halfBudget, false),
|
|
162
|
+
completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true),
|
|
163
|
+
);
|
|
164
|
+
if (!request) return null;
|
|
165
|
+
const result = await jevRuntime.ask(request, {
|
|
166
|
+
questionId: 'obs-excerpt-window',
|
|
167
|
+
extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
|
|
168
|
+
enrich: ({ answers }) => {
|
|
169
|
+
if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
|
|
170
|
+
return { picked: answers.window.choice, confidence: answers.window.confidence };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
if (!result.ok) return null;
|
|
174
|
+
const picked = evaluateExcerptPick(result.answers, jevMinConfidence());
|
|
175
|
+
const window = picked === null ? undefined : windows.find((w) => w.id === picked);
|
|
176
|
+
return window ? { text: window.text, label: window.label } : null;
|
|
177
|
+
};
|
|
178
|
+
// Transcript polish for pier's own custom entries and reminder messages; display-only and
|
|
179
|
+
// best-effort (older pi builds without the renderer API simply keep the raw rows).
|
|
180
|
+
const rendererTypes = installRenderers(pi);
|
|
181
|
+
if (process.env.PI_HERDR_TRACE) {
|
|
182
|
+
console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
|
|
183
|
+
}
|
|
101
184
|
|
|
102
185
|
let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
|
|
103
186
|
/** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
|
|
@@ -110,6 +193,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
110
193
|
const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
111
194
|
?.sessionManager?.getBranch?.() ?? [];
|
|
112
195
|
todos.rebuild(entries);
|
|
196
|
+
coordinator.rebuildFromBranch(entries);
|
|
113
197
|
} catch {
|
|
114
198
|
// A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
|
|
115
199
|
}
|
|
@@ -131,11 +215,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
131
215
|
client.reportAgent(state, message).catch(() => {});
|
|
132
216
|
}
|
|
133
217
|
|
|
134
|
-
function reportSession(path: string): void {
|
|
135
|
-
if (!client.available) return;
|
|
136
|
-
client.reportAgentSession(path).catch(() => {});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
218
|
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
|
|
140
219
|
function resolveSessionId(ctx: unknown): string {
|
|
141
220
|
try {
|
|
@@ -194,7 +273,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
194
273
|
} catch {
|
|
195
274
|
/* Best effort persistence; in-memory state still advances. */
|
|
196
275
|
}
|
|
197
|
-
todos.applyEdits(plan.edits);
|
|
276
|
+
todos.applyEdits(plan.edits, { source: 'reconcile' });
|
|
198
277
|
mirrorTodos();
|
|
199
278
|
}
|
|
200
279
|
return plan.noteLines;
|
|
@@ -210,8 +289,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
210
289
|
|
|
211
290
|
/* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
|
|
212
291
|
|
|
213
|
-
todos.on('todo.completed', (e: { count: number; at: number }) => {
|
|
292
|
+
todos.on('todo.completed', (e: { count: number; at: number; source?: import('./todos-service.ts').TodoCompletionSource }) => {
|
|
214
293
|
for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
|
|
294
|
+
coordinator.recordBoundaryCompleted(e.count, e.source);
|
|
215
295
|
mirrorTodos();
|
|
216
296
|
});
|
|
217
297
|
|
|
@@ -239,8 +319,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
239
319
|
}
|
|
240
320
|
});
|
|
241
321
|
|
|
322
|
+
/* ── D102: Evidence-Preserving Reducer (runs before append-handlers like write-locks) ── */
|
|
323
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
324
|
+
if (!effConfig.evidencePreservingReducer.enabled) return;
|
|
325
|
+
if (!event || typeof event !== 'object') return;
|
|
326
|
+
return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
|
|
327
|
+
epoch: coordinator.state.epoch,
|
|
328
|
+
jev: { ask: jevRuntime.ask, getMinConfidence: jevMinConfidence },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let locksHandle = { getHeldLocks: () => [] as readonly string[] };
|
|
242
333
|
if (env) {
|
|
243
|
-
installWriteLocks(pi, {
|
|
334
|
+
locksHandle = installWriteLocks(pi, {
|
|
244
335
|
client,
|
|
245
336
|
env,
|
|
246
337
|
hard: process.env[WRITE_LOCK_ENV] === '1',
|
|
@@ -256,6 +347,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
256
347
|
|
|
257
348
|
pi.on('session_start', async (event, ctx) => {
|
|
258
349
|
sessionId = resolveSessionId(ctx);
|
|
350
|
+
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
351
|
+
const isTrusted =
|
|
352
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
353
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
354
|
+
: false;
|
|
355
|
+
effConfig = loadEfficiencyConfigFromDisk({ cwd, isProjectTrusted: isTrusted });
|
|
356
|
+
sessionRoot = resolveSessionRoot(
|
|
357
|
+
(ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
|
|
358
|
+
sessionId,
|
|
359
|
+
);
|
|
259
360
|
rebuildFromBranch(ctx);
|
|
260
361
|
// v1.3 M9 fix (observed): on resume pi restores widget state from the session;
|
|
261
362
|
// calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
|
|
@@ -265,7 +366,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
265
366
|
// and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
|
|
266
367
|
if (env) registerSlimFrame(ctx);
|
|
267
368
|
mirrorTodos();
|
|
268
|
-
|
|
369
|
+
// D-2: the session path is NOT reported here. herdr's own pi integration publishes it
|
|
370
|
+
// (agent.list/agent.pane show `session_source: "herdr:pi"` on every pi pane, incl. pier subagents),
|
|
371
|
+
// so pier's pane.report_agent_session was a pure duplicate write of the same field by a second source.
|
|
269
372
|
// D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
|
|
270
373
|
// master → 'master'; worker → manifest.role (prettify worker-default as worker).
|
|
271
374
|
// A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
|
|
@@ -305,6 +408,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
305
408
|
}
|
|
306
409
|
}
|
|
307
410
|
reportAgent('idle', null);
|
|
411
|
+
// Self-healing: a gate does not survive a process restart, but the ask marker lives in herdr
|
|
412
|
+
// with a 24h TTL, so a session killed while a dialog was open would otherwise keep the pane
|
|
413
|
+
// looking blocked forever. Depth is 0 on session_start, so this cannot race a live gate.
|
|
414
|
+
void client.reportAskFlag(null).catch(() => {});
|
|
308
415
|
});
|
|
309
416
|
|
|
310
417
|
/* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
|
|
@@ -316,14 +423,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
316
423
|
const tool = typeof event?.toolName === 'string' ? event.toolName : '';
|
|
317
424
|
const gate = planToolGate(tool, runtimeManifest);
|
|
318
425
|
if (gate.kind === 'deny') {
|
|
319
|
-
|
|
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 };
|
|
320
429
|
}
|
|
321
430
|
if (gate.kind === 'ask') {
|
|
322
431
|
console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
|
|
323
432
|
// Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
|
|
324
433
|
try {
|
|
325
434
|
(pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
|
|
326
|
-
|
|
435
|
+
APPROVAL_NEEDED_CUSTOM_TYPE,
|
|
327
436
|
{ role: runtimeManifest.role, tool, ts: Date.now() },
|
|
328
437
|
);
|
|
329
438
|
} catch {
|
|
@@ -351,9 +460,32 @@ export default async function (pi: ExtensionAPI) {
|
|
|
351
460
|
let lastStopReason: string | null = null;
|
|
352
461
|
pi.on('turn_start', async () => {
|
|
353
462
|
agentActive = true;
|
|
463
|
+
// Why (A11): Reset lastStopReason on turn_start so a previous turn's abort
|
|
464
|
+
// does not taint subsequent turns or suppress settlement wake / notice flush.
|
|
465
|
+
lastStopReason = null;
|
|
466
|
+
// Safety fallback: if previous compaction finished without clean callback settlement,
|
|
467
|
+
// ensure flags are reset. Pi core prohibits prompts while compaction is active, so this is safe.
|
|
468
|
+
coordinator.compactionInFlight = false;
|
|
469
|
+
coordinator.intentionalAbort = false;
|
|
354
470
|
reportAgent('working', currentActivity(todos.items));
|
|
355
471
|
});
|
|
356
|
-
|
|
472
|
+
|
|
473
|
+
pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
|
|
474
|
+
const usage = (ctx as { getContextUsage?: () => { tokens?: number | null } })?.getContextUsage?.();
|
|
475
|
+
if (typeof usage?.tokens === 'number') {
|
|
476
|
+
coordinator.onBeforeProviderRequest(usage.tokens);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
pi.on('input', (event: unknown) => {
|
|
481
|
+
if (event && typeof event === 'object') {
|
|
482
|
+
coordinator.onInput(event as { text?: string; source?: string; streamingBehavior?: string });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
|
|
487
|
+
|
|
488
|
+
pi.on('turn_end', async (event: unknown, ctx) => {
|
|
357
489
|
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
358
490
|
const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
|
|
359
491
|
if (msg === null || typeof msg !== 'object') return;
|
|
@@ -361,13 +493,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
361
493
|
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
362
494
|
lastStopReason = stopReason;
|
|
363
495
|
}
|
|
496
|
+
if (ctx && typeof ctx === 'object') {
|
|
497
|
+
coordinator.onTurnEnd({
|
|
498
|
+
ctx,
|
|
499
|
+
todos: todos.items,
|
|
500
|
+
config: effConfig.onlineContextCompact,
|
|
501
|
+
cancelReminder: todoUi.cancelReminder,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
364
504
|
});
|
|
365
505
|
|
|
366
|
-
pi.on('agent_settled', async () => {
|
|
506
|
+
pi.on('agent_settled', async (_event: unknown, ctx) => {
|
|
367
507
|
agentActive = false;
|
|
368
508
|
reportAgent('idle', null);
|
|
369
509
|
const plan = planSettleWake({
|
|
370
510
|
lastStopReason,
|
|
511
|
+
intentionalAbort: coordinator.intentionalAbort,
|
|
371
512
|
running: subagentPort.current?.listRunningSubs() ?? [],
|
|
372
513
|
lastNoticeKey: d96NoticeKey,
|
|
373
514
|
lastNoticeAt: d96NoticeAt,
|
|
@@ -387,6 +528,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
387
528
|
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
|
|
388
529
|
void sendUserMessageIn(notice);
|
|
389
530
|
}
|
|
531
|
+
if (ctx && typeof ctx === 'object') {
|
|
532
|
+
void coordinator.onAgentSettled({
|
|
533
|
+
ctx,
|
|
534
|
+
todos: todos.items,
|
|
535
|
+
pi,
|
|
536
|
+
config: effConfig.onlineContextCompact,
|
|
537
|
+
cancelReminder: todoUi.cancelReminder,
|
|
538
|
+
onBeforeCompact: createCompactionBatchPackHook({
|
|
539
|
+
getObsConfig: () => effConfig.observationPack,
|
|
540
|
+
getManifest: () => runtimeManifest,
|
|
541
|
+
getSessionId: () => sessionId,
|
|
542
|
+
pickMiddleExcerpt,
|
|
543
|
+
}),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
390
546
|
});
|
|
391
547
|
|
|
392
548
|
// Official herdr:pi is full-lifecycle authority when installed: it reports blocked
|
|
@@ -394,32 +550,42 @@ export default async function (pi: ExtensionAPI) {
|
|
|
394
550
|
// pane.report_agent is ignored while that authority is live
|
|
395
551
|
// (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
|
|
396
552
|
// herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
|
|
397
|
-
|
|
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 {
|
|
398
559
|
blockedDepth += 1;
|
|
560
|
+
if (blockedDepth > 1) return false;
|
|
399
561
|
reportAgent('blocked', label);
|
|
400
562
|
// D95: human-gate marker lets the workbench heatmap distinguish ask from block.
|
|
401
563
|
if (label) void client.reportAskFlag(label).catch(() => {});
|
|
402
564
|
// Gate open collapses the todo widget to one line — the question owns the fixed area.
|
|
403
565
|
todoUi.rerenderWidget?.();
|
|
566
|
+
return true;
|
|
404
567
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
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;
|
|
412
574
|
// Gate closed → restore the full widget window.
|
|
413
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;
|
|
414
581
|
}
|
|
415
582
|
|
|
416
583
|
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
417
584
|
let publishingHerdrBlocked = false;
|
|
418
|
-
|
|
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 {
|
|
419
587
|
publishingHerdrBlocked = true;
|
|
420
588
|
try {
|
|
421
|
-
if (active) enterBlocked(label);
|
|
422
|
-
else exitBlocked();
|
|
423
589
|
pi.events.emit(
|
|
424
590
|
'herdr:blocked',
|
|
425
591
|
active ? { active: true, ...(label ? { label } : {}) } : { active: false },
|
|
@@ -428,6 +594,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
428
594
|
publishingHerdrBlocked = false;
|
|
429
595
|
}
|
|
430
596
|
}
|
|
597
|
+
function publishHerdrBlocked(active: boolean, label: string | null): void {
|
|
598
|
+
if (active) enterBlocked(label);
|
|
599
|
+
else exitBlocked();
|
|
600
|
+
emitHerdrBlocked(active, label);
|
|
601
|
+
}
|
|
431
602
|
pi.events.on('herdr:blocked', (data) => {
|
|
432
603
|
if (publishingHerdrBlocked) return;
|
|
433
604
|
if (!data || typeof data !== 'object') {
|
|
@@ -441,8 +612,62 @@ export default async function (pi: ExtensionAPI) {
|
|
|
441
612
|
enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
|
|
442
613
|
});
|
|
443
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
|
+
|
|
444
644
|
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
445
645
|
|
|
646
|
+
/* ── D101: ObservationPack (obs_recall tool & context projector) ── */
|
|
647
|
+
registerObservationPack({
|
|
648
|
+
pi,
|
|
649
|
+
getConfig: () => effConfig,
|
|
650
|
+
getRuntimeManifest: () => runtimeManifest,
|
|
651
|
+
pickMiddleExcerpt,
|
|
652
|
+
getRemainingHorizon: () => {
|
|
653
|
+
const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
|
|
654
|
+
return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
|
|
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
|
+
installConfigCommand({ pi });
|
|
663
|
+
installDashboardCommand({
|
|
664
|
+
pi,
|
|
665
|
+
client,
|
|
666
|
+
env,
|
|
667
|
+
getTodoItems: () => todos.items,
|
|
668
|
+
getHeldLocks: () => locksHandle.getHeldLocks(),
|
|
669
|
+
});
|
|
670
|
+
|
|
446
671
|
pi.registerTool({
|
|
447
672
|
name: ASK_TOOL_NAME,
|
|
448
673
|
label: 'Ask User',
|
|
@@ -456,20 +681,27 @@ export default async function (pi: ExtensionAPI) {
|
|
|
456
681
|
if (!hasAskUi(ui)) return noUiResult();
|
|
457
682
|
const label = gateLabel(prepared.spec);
|
|
458
683
|
publishHerdrBlocked(true, label);
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
|
|
684
|
+
// The gate stays reported for the whole wait: reportAgent() drops working/idle reports while
|
|
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.)
|
|
463
687
|
try {
|
|
464
688
|
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
465
689
|
} finally {
|
|
466
|
-
clearInterval(hb);
|
|
467
690
|
publishHerdrBlocked(false, null);
|
|
468
691
|
}
|
|
469
692
|
},
|
|
470
693
|
});
|
|
471
694
|
|
|
472
695
|
pi.on('session_shutdown', async () => {
|
|
696
|
+
coordinator.compactionInFlight = false;
|
|
697
|
+
coordinator.intentionalAbort = false;
|
|
698
|
+
if (sessionRoot) {
|
|
699
|
+
try {
|
|
700
|
+
await pruneSessionObjects(sessionRoot);
|
|
701
|
+
} catch {
|
|
702
|
+
/* ignore shutdown pruning errors */
|
|
703
|
+
}
|
|
704
|
+
}
|
|
473
705
|
client.close();
|
|
474
706
|
});
|
|
475
707
|
|
|
@@ -483,20 +715,56 @@ export default async function (pi: ExtensionAPI) {
|
|
|
483
715
|
return true;
|
|
484
716
|
}
|
|
485
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 };
|
|
486
720
|
let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
|
|
487
721
|
let latestCtx: { abort?: () => void } | null = null;
|
|
488
722
|
|
|
489
723
|
// triggerTurn:true — idle followUp must start a new turn or settlement is lost.
|
|
490
724
|
const sendUserMessageIn = (content: string): Promise<void> =>
|
|
491
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
725
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
492
726
|
.sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
|
|
493
727
|
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
|
|
494
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
728
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
495
729
|
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
|
|
496
730
|
|
|
497
731
|
const notices = createNoticeBuffer({
|
|
498
732
|
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
499
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
|
+
},
|
|
500
768
|
});
|
|
501
769
|
const deliverNotice = notices.deliverNotice;
|
|
502
770
|
pi.on('turn_end', () => {
|
|
@@ -511,6 +779,28 @@ export default async function (pi: ExtensionAPI) {
|
|
|
511
779
|
latestCtx = ctx as { abort?: () => void } | null;
|
|
512
780
|
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
513
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
|
+
}
|
|
514
804
|
if (!paneId) return;
|
|
515
805
|
const name = pipeNameFor(cwd, paneId);
|
|
516
806
|
if (pipeServerBox.current) {
|
|
@@ -533,6 +823,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
533
823
|
}
|
|
534
824
|
});
|
|
535
825
|
pi.on('session_shutdown', () => {
|
|
826
|
+
sessionFocusPoller.current?.stop();
|
|
827
|
+
sessionFocusPoller.current = null;
|
|
536
828
|
if (pipeServerBox.current) {
|
|
537
829
|
try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
|
|
538
830
|
pipeServerBox.current = null;
|
|
@@ -584,6 +876,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
584
876
|
reconcileOnSettlement,
|
|
585
877
|
withReconcileNotes,
|
|
586
878
|
claimSettleNotice,
|
|
879
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
880
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
587
881
|
});
|
|
588
882
|
} else {
|
|
589
883
|
const { mountTodoOnly } = await import('./index-worker.ts');
|
|
@@ -599,6 +893,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
599
893
|
stopReminder: {
|
|
600
894
|
getBlockedDepth: () => blockedDepth,
|
|
601
895
|
getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
|
|
896
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
897
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
602
898
|
},
|
|
603
899
|
}),
|
|
604
900
|
});
|