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/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`: foreground/background delegation plus resume/list/send/interrupt actions.
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 { Type } from 'typebox';
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
- installWriteLocks(pi, {
235
- client,
236
- env,
237
- hard: process.env[WRITE_LOCK_ENV] === '1',
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: { renderWidget: (ctx: unknown) => void } = {
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: 'ask_user_question',
447
+ name: ASK_TOOL_NAME,
434
448
  label: 'Ask User',
435
- description: [
436
- 'Ask the human a question and wait for their typed answer.',
437
- 'Use this when you genuinely need a human decision (approval, direction, trade-off choice) — not for information you can find yourself.',
438
- 'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
439
- 'The answer comes back as the tool result; then continue your work.',
440
- ].join(' '),
441
- parameters: Type.Object({
442
- question: Type.String({ description: 'The question to ask the human' }),
443
- }),
444
- async execute(_tc, params, _sig, _upd, ctx) {
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', question); }, 5000);
462
+ const hb = setInterval(() => { reportAgent('blocked', label); }, 5000);
457
463
  try {
458
- const answer = await ui?.input?.(question, 'your answer');
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
- /* subagent tools are master-only (depth 1). C3: worker never loads bootstrap. */
564
- if (!isSubagent) {
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 { mountWorkerTodo } = await import('./index-worker.ts');
586
- await mountWorkerTodo({ pi, todos, todoUi, mirrorTodos });
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
  },
@@ -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
- readTail: typeof t.readTail === 'string' ? t.readTail : '',
326
- readEoTail: typeof t.readEoTail === 'string' ? t.readEoTail : '',
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
+ }