pi-pier 0.2.5 → 0.2.7
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 +35 -5
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +1 -1
- package/src/ask-user.ts +408 -0
- package/src/core/subagent.ts +1 -71
- package/src/core/terminal.ts +165 -16
- package/src/core/todo.ts +91 -6
- package/src/index-master.ts +6 -3
- package/src/index-runtime.ts +6 -2
- package/src/index-worker.ts +15 -6
- package/src/index.ts +58 -40
- package/src/slim-frame.ts +56 -1
- package/src/terminal-core.ts +86 -7
package/src/index.ts
CHANGED
|
@@ -7,10 +7,7 @@
|
|
|
7
7
|
* Capabilities:
|
|
8
8
|
* - `todo_write`: replace the complete todo list; the current pi session JSONL is authoritative,
|
|
9
9
|
* and branches automatically roll back in DSH-aligned semantics.
|
|
10
|
-
* - `subagent
|
|
11
|
-
* Each child pane is an interactive pi TUI with an independent session/context that humans
|
|
12
|
-
* can enter directly; the controller uses herdr for state-gated injection (idle only),
|
|
13
|
-
* waiting (agent.wait), and JSONL result reads.
|
|
10
|
+
* - `subagent` / `terminal`: herdr-master only. Bare pi does not register them.
|
|
14
11
|
* - `/todos`, TUI widget, and herdr title projection with graceful degradation without herdr.
|
|
15
12
|
*
|
|
16
13
|
* pi 0.84.2 contract (validated):
|
|
@@ -18,7 +15,17 @@
|
|
|
18
15
|
* - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
|
|
19
16
|
*/
|
|
20
17
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
21
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ASK_PARAMETERS,
|
|
20
|
+
ASK_PROMPT_GUIDELINES,
|
|
21
|
+
ASK_TOOL_DESCRIPTION,
|
|
22
|
+
ASK_TOOL_NAME,
|
|
23
|
+
gateLabel,
|
|
24
|
+
hasAskUi,
|
|
25
|
+
noUiResult,
|
|
26
|
+
prepareAsk,
|
|
27
|
+
runAsk,
|
|
28
|
+
} from './ask-user.ts';
|
|
22
29
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
23
30
|
import {
|
|
24
31
|
TODO_EDIT_CUSTOM_TYPE,
|
|
@@ -35,6 +42,7 @@ import { lastAssistantText, readSessionFile } from './session-tail.ts';
|
|
|
35
42
|
import { fileURLToPath } from 'node:url';
|
|
36
43
|
import { TodosService } from './todos-service.ts';
|
|
37
44
|
import { reconcileTodos } from './reconcile-core.ts';
|
|
45
|
+
import type { TodoUiSlot } from './core/todo.ts';
|
|
38
46
|
import {
|
|
39
47
|
estimateEta,
|
|
40
48
|
formatProgressSuffix,
|
|
@@ -231,14 +239,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
231
239
|
}
|
|
232
240
|
});
|
|
233
241
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
242
|
+
if (env) {
|
|
243
|
+
installWriteLocks(pi, {
|
|
244
|
+
client,
|
|
245
|
+
env,
|
|
246
|
+
hard: process.env[WRITE_LOCK_ENV] === '1',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
239
249
|
|
|
240
250
|
/* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
|
|
241
|
-
const todoUi:
|
|
251
|
+
const todoUi: TodoUiSlot = {
|
|
242
252
|
renderWidget: () => { /* No-op before plugin mounting; filled after mounting. */ },
|
|
243
253
|
};
|
|
244
254
|
|
|
@@ -389,6 +399,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
389
399
|
reportAgent('blocked', label);
|
|
390
400
|
// D95: human-gate marker lets the workbench heatmap distinguish ask from block.
|
|
391
401
|
if (label) void client.reportAskFlag(label).catch(() => {});
|
|
402
|
+
// Gate open collapses the todo widget to one line — the question owns the fixed area.
|
|
403
|
+
todoUi.rerenderWidget?.();
|
|
392
404
|
}
|
|
393
405
|
function exitBlocked(): void {
|
|
394
406
|
blockedDepth = Math.max(0, blockedDepth - 1);
|
|
@@ -397,6 +409,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
397
409
|
// D95: gate released → clear marker.
|
|
398
410
|
void client.reportAskFlag(null).catch(() => {});
|
|
399
411
|
}
|
|
412
|
+
// Gate closed → restore the full widget window.
|
|
413
|
+
todoUi.rerenderWidget?.();
|
|
400
414
|
}
|
|
401
415
|
|
|
402
416
|
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
@@ -430,36 +444,24 @@ export default async function (pi: ExtensionAPI) {
|
|
|
430
444
|
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
431
445
|
|
|
432
446
|
pi.registerTool({
|
|
433
|
-
name:
|
|
447
|
+
name: ASK_TOOL_NAME,
|
|
434
448
|
label: 'Ask User',
|
|
435
|
-
description:
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const question = String(params?.question ?? '').trim();
|
|
446
|
-
if (!question) {
|
|
447
|
-
return { content: [{ type: 'text', text: 'Error: `question` must be a non-empty string' }], details: {} };
|
|
448
|
-
}
|
|
449
|
-
const ui = (ctx as {
|
|
450
|
-
ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
|
|
451
|
-
}).ui;
|
|
452
|
-
publishHerdrBlocked(true, question);
|
|
449
|
+
description: ASK_TOOL_DESCRIPTION,
|
|
450
|
+
promptGuidelines: ASK_PROMPT_GUIDELINES,
|
|
451
|
+
parameters: ASK_PARAMETERS,
|
|
452
|
+
async execute(_tc, params, signal, _upd, ctx) {
|
|
453
|
+
const prepared = prepareAsk(params);
|
|
454
|
+
if (!prepared.ok) return prepared.result;
|
|
455
|
+
const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
|
|
456
|
+
if (!hasAskUi(ui)) return noUiResult();
|
|
457
|
+
const label = gateLabel(prepared.spec);
|
|
458
|
+
publishHerdrBlocked(true, label);
|
|
453
459
|
// Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
|
|
454
460
|
// to later working reports; refresh while the gate is open. Do not re-emit
|
|
455
461
|
// herdr:blocked here — official blockedCount is edge-triggered.
|
|
456
|
-
const hb = setInterval(() => { reportAgent('blocked',
|
|
462
|
+
const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
|
|
457
463
|
try {
|
|
458
|
-
|
|
459
|
-
return {
|
|
460
|
-
content: [{ type: 'text', text: `The human answered: ${answer ?? '(no answer given)'}` }],
|
|
461
|
-
details: { question, answer: answer ?? null },
|
|
462
|
-
};
|
|
464
|
+
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
463
465
|
} finally {
|
|
464
466
|
clearInterval(hb);
|
|
465
467
|
publishHerdrBlocked(false, null);
|
|
@@ -560,8 +562,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
560
562
|
}
|
|
561
563
|
});
|
|
562
564
|
|
|
563
|
-
/*
|
|
564
|
-
|
|
565
|
+
/* Workbench plugins (subagent/terminal + cordis) are herdr-master only.
|
|
566
|
+
* Bare pi and worker panes mount todo only so unused tools never appear.
|
|
567
|
+
* Dynamic import: worker/bare-pi must never load bootstrap/subagent-scope (C3). */
|
|
568
|
+
if (mode.composeMaster) {
|
|
565
569
|
const { mountMasterPlugins } = await import('./index-master.ts');
|
|
566
570
|
await mountMasterPlugins({
|
|
567
571
|
pi,
|
|
@@ -582,7 +586,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
582
586
|
claimSettleNotice,
|
|
583
587
|
});
|
|
584
588
|
} else {
|
|
585
|
-
const {
|
|
586
|
-
await
|
|
589
|
+
const { mountTodoOnly } = await import('./index-worker.ts');
|
|
590
|
+
await mountTodoOnly({
|
|
591
|
+
pi,
|
|
592
|
+
todos,
|
|
593
|
+
todoUi,
|
|
594
|
+
mirrorTodos,
|
|
595
|
+
getBlockedDepth: () => blockedDepth,
|
|
596
|
+
...(isSubagent
|
|
597
|
+
? {}
|
|
598
|
+
: {
|
|
599
|
+
stopReminder: {
|
|
600
|
+
getBlockedDepth: () => blockedDepth,
|
|
601
|
+
getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
|
|
602
|
+
},
|
|
603
|
+
}),
|
|
604
|
+
});
|
|
587
605
|
}
|
|
588
606
|
}
|
package/src/slim-frame.ts
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
|
|
22
22
|
* pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
|
|
23
23
|
* flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
|
|
24
|
+
* Resize watchdog (D98): the component owns a SIGWINCH listener plus a 1s size poll that call tui.requestRender() on
|
|
25
|
+
* any PTY size change. Why: long-running sessions stop re-rendering on resize (observed in herdr — an idle master
|
|
26
|
+
* shrunk by heat reflow froze on a stale clipped frame: no todo, no title), so pi's own resize wiring cannot be relied
|
|
27
|
+
* on; columns/rows are read live per render, so one render is the whole fix. Stopped in dispose().
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
import { isArchived } from './stale-core.ts';
|
|
@@ -38,6 +42,8 @@ export const SLIM_TODO_MIN_ROWS = 3;
|
|
|
38
42
|
export const SLIM_TODO_MIN_COLS = 16;
|
|
39
43
|
/** Empty-list copy: the pane is alive but has no plan yet (replaces a lone ·). */
|
|
40
44
|
export const SLIM_EMPTY_COPY = 'no todos yet';
|
|
45
|
+
/** D98: watchdog poll period — SIGWINCH covers the delivered signal; the poll covers lost ones (e.g. after suspend). */
|
|
46
|
+
export const RESIZE_WATCHDOG_MS = 1_000;
|
|
41
47
|
|
|
42
48
|
/** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
|
|
43
49
|
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
@@ -227,6 +233,11 @@ class SlimFrameComponent {
|
|
|
227
233
|
private cache: { width: number; lines: string[] } | null = null;
|
|
228
234
|
private readonly tui: FrameTui;
|
|
229
235
|
private readonly colorize: (s: string) => string;
|
|
236
|
+
/** D98 watchdog: last PTY size seen by onMaybeResized (re-seeded at watchdog start). */
|
|
237
|
+
private seenCols = 0;
|
|
238
|
+
private seenRows = 0;
|
|
239
|
+
private sigwinch: (() => void) | null = null;
|
|
240
|
+
private poll: NodeJS.Timeout | null = null;
|
|
230
241
|
|
|
231
242
|
constructor(tui: FrameTui, colorize: (s: string) => string) {
|
|
232
243
|
this.tui = tui;
|
|
@@ -271,13 +282,56 @@ class SlimFrameComponent {
|
|
|
271
282
|
|
|
272
283
|
/** Reclaim the module singleton when pi tears down the overlay (session switch/exit), allowing re-registration. */
|
|
273
284
|
dispose(): void {
|
|
285
|
+
this.stopResizeWatchdog();
|
|
274
286
|
if (active === this) active = null;
|
|
275
287
|
}
|
|
288
|
+
|
|
289
|
+
/** Live PTY size (pi-tui reads the same source on every render; an instance method so tests can stub it). */
|
|
290
|
+
viewportSize(): { cols: number; rows: number } {
|
|
291
|
+
return { cols: process.stdout.columns ?? 0, rows: process.stdout.rows ?? 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** D98: SIGWINCH handler and poll tick — exactly one requestRender per actual size change; the next render reads live sizes. */
|
|
295
|
+
readonly onMaybeResized = (): void => {
|
|
296
|
+
const { cols, rows } = this.viewportSize();
|
|
297
|
+
if (cols === this.seenCols && rows === this.seenRows) return;
|
|
298
|
+
this.seenCols = cols;
|
|
299
|
+
this.seenRows = rows;
|
|
300
|
+
this.tui.requestRender();
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
|
|
304
|
+
startResizeWatchdog(): void {
|
|
305
|
+
if (this.poll) return;
|
|
306
|
+
const size = this.viewportSize();
|
|
307
|
+
this.seenCols = size.cols;
|
|
308
|
+
this.seenRows = size.rows;
|
|
309
|
+
try {
|
|
310
|
+
process.on('SIGWINCH', this.onMaybeResized);
|
|
311
|
+
this.sigwinch = this.onMaybeResized;
|
|
312
|
+
} catch {
|
|
313
|
+
/* Platforms without a SIGWINCH mapping keep the poll only. */
|
|
314
|
+
}
|
|
315
|
+
this.poll = setInterval(this.onMaybeResized, RESIZE_WATCHDOG_MS);
|
|
316
|
+
this.poll.unref?.();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private stopResizeWatchdog(): void {
|
|
320
|
+
if (this.sigwinch) {
|
|
321
|
+
process.removeListener('SIGWINCH', this.sigwinch);
|
|
322
|
+
this.sigwinch = null;
|
|
323
|
+
}
|
|
324
|
+
if (this.poll) {
|
|
325
|
+
clearInterval(this.poll);
|
|
326
|
+
this.poll = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
276
329
|
}
|
|
277
330
|
|
|
278
331
|
|
|
279
|
-
/** Test injection point (reset module state). */
|
|
332
|
+
/** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
|
|
280
333
|
export function resetForTest(): void {
|
|
334
|
+
active?.dispose();
|
|
281
335
|
active = null;
|
|
282
336
|
}
|
|
283
337
|
|
|
@@ -302,6 +356,7 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
302
356
|
const pending = custom(
|
|
303
357
|
(tui, theme) => {
|
|
304
358
|
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
359
|
+
comp.startResizeWatchdog();
|
|
305
360
|
active = comp;
|
|
306
361
|
return comp;
|
|
307
362
|
},
|
package/src/terminal-core.ts
CHANGED
|
@@ -57,13 +57,10 @@ export interface TerminalEntry {
|
|
|
57
57
|
label: string | null;
|
|
58
58
|
createdAt: number;
|
|
59
59
|
lastActivityAt: number;
|
|
60
|
+
/** Idle-nudge bookkeeping: set when this terminal has been nudged once (per-entry, persisted). */
|
|
61
|
+
nudgedAt: number | null;
|
|
60
62
|
status: 'open' | 'closed';
|
|
61
63
|
closedAt: number | null;
|
|
62
|
-
/** Why: Preserve the established compatibility and safety behavior. */
|
|
63
|
-
readRevision: number | null;
|
|
64
|
-
readLen: number;
|
|
65
|
-
readTail: string;
|
|
66
|
-
readEoTail: string;
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
export interface TerminalsRegistry {
|
|
@@ -107,6 +104,7 @@ export function registerTerminal(
|
|
|
107
104
|
label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
|
|
108
105
|
createdAt: opts.createdAt,
|
|
109
106
|
lastActivityAt: opts.createdAt,
|
|
107
|
+
nudgedAt: null,
|
|
110
108
|
status: 'open',
|
|
111
109
|
closedAt: null,
|
|
112
110
|
readRevision: null,
|
|
@@ -322,9 +320,90 @@ export function foldTerminalsRegistry(entries: readonly BranchEntryLike3[]): Ter
|
|
|
322
320
|
closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
|
|
323
321
|
readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
|
|
324
322
|
readLen: typeof t.readLen === 'number' ? t.readLen : 0,
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
|
|
324
|
+
nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
|
|
325
|
+
status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
|
|
327
326
|
}));
|
|
328
327
|
}
|
|
329
328
|
return found;
|
|
330
329
|
}
|
|
330
|
+
|
|
331
|
+
/* ── Idle-terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
|
|
332
|
+
* Observed (wF:p7 orphan): a master opened a resident terminal for a one-shot background
|
|
333
|
+
* compile, the job finished, and nothing ever closed the pane — terminals are persistent by
|
|
334
|
+
* design, so the shell lingered as a dead split in the main tab. Decision core mirrors the
|
|
335
|
+
* todo stop-reminder: pure plan here, delivery wiring in core/terminal. */
|
|
336
|
+
|
|
337
|
+
/** Cap idle-terminal nudges for the lifetime of the process (same shape as TODO_REMINDERS_MAX). */
|
|
338
|
+
export const TERM_REMINDERS_MAX = 2;
|
|
339
|
+
|
|
340
|
+
/** Idle threshold before a nudge is due; PI_HERDR_TERM_IDLE_MS is read per call so tests can use small values. */
|
|
341
|
+
export function terminalIdleMs(): number {
|
|
342
|
+
return Number(process.env.PI_HERDR_TERM_IDLE_MS ?? 30 * 60_000) || 30 * 60_000;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
|
|
346
|
+
export function terminalReminderGraceMs(): number {
|
|
347
|
+
return Number(process.env.PI_HERDR_TERM_GRACE_MS ?? 30_000) || 30_000;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Custom message type for the idle-terminal nudge (registerMessageRenderer may restyle it). */
|
|
351
|
+
export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
|
|
352
|
+
|
|
353
|
+
export interface IdleTerminalInput {
|
|
354
|
+
terminalId: string;
|
|
355
|
+
cwd: string;
|
|
356
|
+
label: string | null;
|
|
357
|
+
lastActivityAt: number;
|
|
358
|
+
/** Set once this terminal has been nudged; a nudged terminal is never nudged again (01a06ae3 goodbye-loop guard). */
|
|
359
|
+
nudgedAt?: number | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface IdleTerminalReminderPlan {
|
|
363
|
+
/** Whether a nudge should be injected. */
|
|
364
|
+
due: boolean;
|
|
365
|
+
/** Complete injected content, or null when due is false. */
|
|
366
|
+
content: string | null;
|
|
367
|
+
/** Nudge count after successful injection, or the original count when due is false. */
|
|
368
|
+
nextReminders: number;
|
|
369
|
+
/** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
|
|
370
|
+
ids: string[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function noIdleInject(reminders: number): IdleTerminalReminderPlan {
|
|
374
|
+
return { due: false, content: null, nextReminders: reminders, ids: [] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Due only when an OPEN terminal is idle past the threshold AND has never been nudged
|
|
379
|
+
* (01a06ae3: a per-process counter alone let nudges resume after restarts and looped a
|
|
380
|
+
* farewell exchange 52 times — a nudged terminal must stay nudged, persisted in the ledger).
|
|
381
|
+
* Shells still hosting long-running work were touched recently, so the idle filter is what
|
|
382
|
+
* keeps legitimate dev-server terminals out of the nudge.
|
|
383
|
+
*/
|
|
384
|
+
export function planIdleTerminalReminder(
|
|
385
|
+
input: { open: readonly IdleTerminalInput[]; now: number; reminders: number },
|
|
386
|
+
): IdleTerminalReminderPlan {
|
|
387
|
+
const idleMs = terminalIdleMs();
|
|
388
|
+
const idle = input.open.filter(
|
|
389
|
+
(t) => !t.nudgedAt && input.now - t.lastActivityAt >= idleMs,
|
|
390
|
+
);
|
|
391
|
+
if (idle.length === 0 || input.reminders >= TERM_REMINDERS_MAX) {
|
|
392
|
+
return noIdleInject(input.reminders);
|
|
393
|
+
}
|
|
394
|
+
const list = idle.map((t) => `- ${t.terminalId} (${t.label ?? t.cwd})`).join('\n');
|
|
395
|
+
const content = [
|
|
396
|
+
`<system-reminder>Open terminal(s) idle for a while (nudge ${input.reminders + 1}/${TERM_REMINDERS_MAX}):`,
|
|
397
|
+
list,
|
|
398
|
+
'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
|
|
399
|
+
'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
|
|
400
|
+
'Keep a terminal only if a long-running process still needs that shell.',
|
|
401
|
+
'</system-reminder>',
|
|
402
|
+
].join('\n');
|
|
403
|
+
return {
|
|
404
|
+
due: true,
|
|
405
|
+
content,
|
|
406
|
+
nextReminders: input.reminders + 1,
|
|
407
|
+
ids: idle.map((t) => t.terminalId),
|
|
408
|
+
};
|
|
409
|
+
}
|