pi-pier 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,15 +1,13 @@
1
1
  /**
2
- * Tail parsing for pi session JSONL (pure library, v1.1 result channel).
3
- *
4
- * Since v1.1 (DESIGN.md §12), child-agent results come from the child session file
5
- * rather than pane text, keeping the authoritative machine-readable source free of
6
- * echo contamination and scrollback blind spots. This module depends only on the
7
- * empirically observed pi session format (session-format documentation):
2
+ * Tail parsing for pi session JSONL (pure): child-agent results come from the child session file,
3
+ * not pane text. Depends only on the pi session format:
8
4
  * line = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
9
5
  */
10
6
  import * as fs from 'node:fs';
11
7
  import * as path from 'node:path';
12
- import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
8
+ import { piSessionDirCandidates, sessionDirName } from './storage-layout.ts';
9
+ import { isValidSessionId } from './efficiency-store.ts';
10
+ import { COMPACTION_INFLIGHT_TYPE, COMPACTION_SETTLED_TYPE } from './compact-coordinator.ts';
13
11
 
14
12
  export { sessionDirName };
15
13
 
@@ -25,7 +23,6 @@ export interface SessionEntryLike {
25
23
  message?: SessionMessageLike | unknown;
26
24
  [k: string]: unknown;
27
25
  }
28
- /** Parse line by line tolerantly, skipping malformed or non-JSON records. */
29
26
  export function parseSessionEntries(text: string): SessionEntryLike[] {
30
27
  const entries: SessionEntryLike[] = [];
31
28
  for (const line of (text ?? '').split(/\r?\n/)) {
@@ -35,7 +32,6 @@ export function parseSessionEntries(text: string): SessionEntryLike[] {
35
32
  const obj = JSON.parse(t);
36
33
  if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
37
34
  } catch {
38
- /* Skip truncated or malformed records. */
39
35
  }
40
36
  }
41
37
  return entries;
@@ -61,10 +57,8 @@ function textOf(m: SessionMessageLike): string {
61
57
  .join('\n')
62
58
  .trim();
63
59
  }
64
- /**
65
- * Return the latest finalized assistant text (stopReason === 'stop'), excluding toolUse intermediates.
66
- * sinceTs filters out messages that predate the injection point.
67
- */
60
+ /** Latest finalized assistant text (`stopReason === 'stop'`), excluding toolUse intermediates;
61
+ * `sinceTs` filters out messages predating the injection point. */
68
62
  export function lastAssistantText(
69
63
  entries: readonly SessionEntryLike[],
70
64
  opts: { sinceTs?: number } = {},
@@ -91,10 +85,29 @@ export function hasAssistantAfter(
91
85
  });
92
86
  }
93
87
  /**
94
- * Return whether an initiated toolCall still lacks a result after injection (for example,
95
- * ask_user_question waiting on human input), meaning settlement has not actually completed
96
- * (v1.3 M8 settlement-race fix). Parallel calls are tracked by depth.
88
+ * Whether the NEWEST assistant message after `sinceTs` ended its turn: a worker between tool calls
89
+ * has "an assistant message" but has NOT finished, so announcing it settled loses supervision of
90
+ * live work. `stopReason === 'toolUse'` means tools were requested; a non-string stopReason (still
91
+ * streaming / older shapes) counts as "not ended" on purpose.
97
92
  */
93
+ export function lastAssistantTurnEnded(
94
+ entries: readonly SessionEntryLike[],
95
+ sinceTs: number,
96
+ ): boolean {
97
+ let newest: { ts: number; stopReason: unknown } | null = null;
98
+ for (const entry of entries) {
99
+ const m = messageOf(entry);
100
+ if (!m || m.role !== 'assistant') continue;
101
+ const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
102
+ if (ts < sinceTs) continue;
103
+ if (!newest || ts >= newest.ts) newest = { ts, stopReason: m.stopReason };
104
+ }
105
+ if (!newest) return false;
106
+ const sr = newest.stopReason;
107
+ return typeof sr === 'string' && sr !== 'toolUse' && sr !== 'pending';
108
+ }
109
+ /** Whether an initiated toolCall still lacks a result after injection (e.g. ask_user_question
110
+ * waiting on a human), meaning settlement has not completed. */
98
111
  export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
99
112
  let depth = 0;
100
113
  for (const entry of entries) {
@@ -113,12 +126,62 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
113
126
  return depth > 0;
114
127
  }
115
128
 
129
+ /** Derived settlement state of one child session, from the inject point onward. */
130
+ export interface SubSessionState {
131
+ text: string | null;
132
+ pendingTool: boolean;
133
+ activity: boolean;
134
+ turnEnded: boolean;
135
+ /** True while the child is inside an OCC compaction cycle: from the inflight marker until an
136
+ * assistant message follows the settled marker. OCC's intentional abort lands as stopReason
137
+ * 'error', which the turnEnded check alone would misread as finished. */
138
+ compacting: boolean;
139
+ }
140
+
141
+ /**
142
+ * Whether the transcript currently sits inside an OCC compaction cycle. Last marker wins: inflight
143
+ * last means the summary request is running; settled last means the cycle is over — unless no
144
+ * assistant message follows it yet, in which case the worker is still machine-paused (not settled,
145
+ * not a user takeover). A cancelled cycle sends no continuation, so it is over at the marker.
146
+ */
147
+ export function compactionBusy(entries: readonly SessionEntryLike[]): boolean {
148
+ for (let i = entries.length - 1; i >= 0; i--) {
149
+ const entry = entries[i];
150
+ if (!entry || entry.type !== 'custom') continue;
151
+ const customType = entry.customType;
152
+ if (customType === COMPACTION_INFLIGHT_TYPE) return true;
153
+ if (customType === COMPACTION_SETTLED_TYPE) {
154
+ // A cancel sends no continuation turn, so waiting for one would pin the worker as busy forever.
155
+ if ((entry.data as { outcome?: unknown } | undefined)?.outcome === 'cancelled') return false;
156
+ for (let j = i + 1; j < entries.length; j++) {
157
+ if (messageOf(entries[j])?.role === 'assistant') return false;
158
+ }
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
116
164
 
165
+ /** Closing text is terminal by construction (lastAssistantText requires stopReason 'stop'); an
166
+ * assistant message alone is not a settlement — the turn must have ENDED. */
167
+ export function deriveSubSessionState(
168
+ entries: readonly SessionEntryLike[],
169
+ sinceTs: number,
170
+ ): SubSessionState {
171
+ const compacting = compactionBusy(entries);
172
+ const r = lastAssistantText(entries, { sinceTs });
173
+ if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true, compacting };
174
+ if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false, compacting };
175
+ if (hasAssistantAfter(entries, sinceTs)) {
176
+ return { text: null, pendingTool: false, activity: true, turnEnded: lastAssistantTurnEnded(entries, sinceTs), compacting };
177
+ }
178
+ return { text: null, pendingTool: false, activity: false, turnEnded: false, compacting };
179
+ }
117
180
 
118
- /** Newest `limit` session files under cwd's session dir (new encoding, then legacy). */
181
+ /** Newest `limit` session files under cwd's session dir (pi core's name first, then pier's old encodings). */
119
182
  export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
120
183
  const files: Array<{ file: string; mtime: number }> = [];
121
- for (const name of sessionDirCandidates(cwd)) {
184
+ for (const name of piSessionDirCandidates(cwd)) {
122
185
  const dir = path.join(agentDir, name);
123
186
  let names: string[];
124
187
  try {
@@ -140,13 +203,11 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
140
203
  return files.slice(0, limit).map((f) => f.file);
141
204
  }
142
205
 
143
- /**
144
- * Locate a session file by id (`<ts>_<id>.jsonl`).
145
- * Searches the new encoding dir first, then the legacy flattened dir.
146
- */
206
+ /** Locate a session file by id (`<ts>_<id>.jsonl`): pi core's directory name first, then pier's
207
+ * legacy flattened dirs. */
147
208
  export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
148
209
  const suffix = `_${id}.jsonl`;
149
- for (const name of sessionDirCandidates(cwd)) {
210
+ for (const name of piSessionDirCandidates(cwd)) {
150
211
  const dir = path.join(agentDir, name);
151
212
  let names: string[];
152
213
  try {
@@ -161,6 +222,24 @@ export function sessionFileById(cwd: string, agentDir: string, id: string): stri
161
222
  return null;
162
223
  }
163
224
 
225
+ /**
226
+ * Normalize a session path or id to the bare id used across herdr-pi state (`<timestamp>_<uuid>.jsonl`
227
+ * → `<uuid>`; `sub-session.jsonl` stays whole). Only pi's real transcript prefix is stripped, so
228
+ * arbitrary ids like PI_SESSION_FILE=sess_idx_prune survive untouched. Same-normalization comparisons
229
+ * (own session vs candidate paths) must go through here.
230
+ */
231
+ export function bareSessionId(raw: string): string {
232
+ const base = raw.replaceAll('\\', '/').split('/').pop()!.replace(/\.jsonl$/, '');
233
+ const stripped = base.replace(/^\d{4}-\d{2}-\d{2}T[\d-]+Z_/, '');
234
+ return isValidSessionId(stripped) ? stripped : base;
235
+ }
236
+
237
+ /** Herdr reports session ids and paths interchangeably: accept a `.jsonl` path as-is, else map the id. */
238
+ export function resolveSessionFileValue(cwd: string, agentDir: string, value: string | null | undefined): string | null {
239
+ if (!value) return null;
240
+ return /\.jsonl$/i.test(value) ? value : sessionFileById(cwd, agentDir, value);
241
+ }
242
+
164
243
  export function readSessionFile(file: string): SessionEntryLike[] | null {
165
244
  try {
166
245
  return parseSessionEntries(fs.readFileSync(file, 'utf8'));
@@ -1,16 +1,8 @@
1
1
  /**
2
- * Pure core for the settled-wake decision (prevents wake storms; evidenced by session 01a03bf0).
3
- *
4
- * Evidence chain: ESC abort → agent_settled → D96 "background subagents still running" notice
5
- * → sendUserMessage(followUp) (always starts a new run while idle) → master restarts → user presses ESC again → …
6
- * After 30 aborts, ESC never breaks the cycle; only Ctrl+C stops the process.
7
- * Natural settlement also forms a self-triggering loop: settle → notice → new run → settle → notice …
8
- *
9
- * Rules:
10
- * - Abort suppression: when the previous assistant stopReason === 'aborted', this settled event injects no
11
- * wake-up message (the user explicitly stopped it; settlement-buffered content waits for the next natural turn);
12
- * - D96 deduplication: notify once for a given running paneId set; any set change (new subagent/settlement) resets it;
13
- * - D96 cooldown: repeat a notice for the same set only after REPEAT_NOTICE_MS (otherwise the unchanged set is noise).
2
+ * Settled-wake decision (prevents wake storms): a settle wake that injects messages creates a
3
+ * self-triggering loop (settle → notice → new run → settle → …) that even ESC cannot break, so an
4
+ * aborted previous turn injects nothing and a running set is announced once, re-announced only after
5
+ * D96_REPEAT_NOTICE_MS or when the set changes.
14
6
  */
15
7
 
16
8
  export const ABORT_STOP_REASON = 'aborted';
@@ -19,37 +11,78 @@ export const ABORT_STOP_REASON = 'aborted';
19
11
  export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
20
12
 
21
13
  export interface SettleWakeInput {
22
- /** stopReason of the last assistant turn before this settlement (unknown = null, treated as natural completion). */
14
+ /** stopReason of the last assistant turn (null = treated as natural completion). */
23
15
  lastStopReason: string | null;
24
- /** Background subagents still running (empty means none). */
16
+ /** True when abort was triggered intentionally by internal mechanisms like OCC. */
17
+ intentionalAbort?: boolean;
25
18
  running: ReadonlyArray<{ paneId: string }>;
26
- /** Set key recorded for the previous D96 notice (none = null). */
27
19
  lastNoticeKey: string | null;
28
- /** Timestamp of the previous D96 notice (epoch ms). */
29
20
  lastNoticeAt: number;
30
21
  now: number;
22
+ /**
23
+ * A compaction abort is already selected. turn_start clears intentionalAbort before agent_settled
24
+ * reads it, so this is the durable signal: do not inject a running-sub notice that compaction will abort.
25
+ */
26
+ compactionPending?: boolean;
31
27
  }
32
28
 
33
29
  export interface SettleWakePlan {
34
30
  /** false means this settlement stays silent and emits no wake-up message. */
35
31
  wake: boolean;
36
- /** Whether to inject the D96 "still running" notice. */
32
+ /**
33
+ * Hand the settlement to the compaction coordinator. An OCC abort is silent (no wake) yet is the
34
+ * very settlement the selected compaction waits for; only a foreign (ESC) abort skips it.
35
+ */
36
+ compact: boolean;
37
37
  notice: boolean;
38
- /** New set key (updated when notifying; empty running set → null). */
38
+ /** New set key; empty running set → null. */
39
39
  noticeKey: string | null;
40
40
  noticeAt: number;
41
41
  }
42
42
 
43
43
  export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
44
- if (input.lastStopReason === ABORT_STOP_REASON) {
45
- return { wake: false, notice: false, noticeKey: input.lastNoticeKey, noticeAt: input.lastNoticeAt };
44
+ if (input.compactionPending) {
45
+ return {
46
+ wake: false,
47
+ compact: true,
48
+ notice: false,
49
+ noticeKey: input.lastNoticeKey,
50
+ noticeAt: input.lastNoticeAt,
51
+ };
52
+ }
53
+ if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
54
+ return {
55
+ wake: false,
56
+ compact: input.intentionalAbort === true,
57
+ notice: false,
58
+ noticeKey: input.lastNoticeKey,
59
+ noticeAt: input.lastNoticeAt,
60
+ };
46
61
  }
47
62
  const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
48
63
  if (key === null) {
49
- return { wake: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
64
+ return { wake: true, compact: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
50
65
  }
51
66
  const newSet = key !== input.lastNoticeKey;
52
67
  const cooled = input.now - input.lastNoticeAt >= D96_REPEAT_NOTICE_MS;
53
68
  const notice = newSet || cooled;
54
- return { wake: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
69
+ return { wake: true, compact: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
70
+ }
71
+
72
+ /**
73
+ * Whether a just-settled run may answer its pending machine request (the pipe settle fast-path).
74
+ * An OCC intentional abort and a still-running compaction both mean the continuation turn is
75
+ * coming: answering now reports "finished, no closing message" for live work and — worse —
76
+ * consumes the one-shot request plus the parent's claim latch, so the real closing message can
77
+ * never be delivered (observed: a worker mid-compaction announced finished; its final report
78
+ * never reached the parent). A user abort ('aborted') likewise settles a run that may re-run.
79
+ */
80
+ export function shouldPushSettleReply(input: {
81
+ intentionalAbort?: boolean;
82
+ compactionInFlight?: boolean;
83
+ lastStopReason: string | null;
84
+ }): boolean {
85
+ return !input.intentionalAbort
86
+ && !input.compactionInFlight
87
+ && input.lastStopReason !== ABORT_STOP_REASON;
55
88
  }
package/src/slim-frame.ts CHANGED
@@ -1,38 +1,25 @@
1
1
  /**
2
- * D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
3
- * cover it with a non-capturing overlay. Content is three-tier: full TUI (overlay
4
- * hidden), an activity-anchored todo window when ≥3 rows fit, else the pane title.
2
+ * D97 slim frame: a pane too narrow/short for the minimum usable TUI gets a non-capturing overlay
3
+ * instead of a continuously repainting one — full TUI when it fits, an activity-anchored todo
4
+ * window when ≥3 rows fit, else the pane title. Content updates only on todo/status reports, so
5
+ * streaming thinking tokens can never reach it and the alt-screen diff produces no PTY output.
5
6
  *
6
- * Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
7
- * continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
8
- * todo_write/status reports (sharing arguments and source with formatPaneTitle), so thinking tokens cannot reach it;
9
- * alt-screen row-level diffing then produces zero PTY output and physically eliminates the flicker.
7
+ * Requires fullscreen TUI mode (buildLaunchParts injects it): the composite layer sits above the
8
+ * document, so only the alt-screen path can be covered. Registered only inside a herdr pane
9
+ * (PIER_SLIM_FRAME=0 opts out), as a process-local singleton whose overlay stays resident —
10
+ * `done()` is never called, so the pending promise is swallowed and never awaited.
10
11
  *
11
- * Prerequisite: worker/master run with --tui-mode fullscreen (injected by default by buildLaunchParts).
12
- * The regular main-screen full dump path (tui-main-screen firstChanged < viewportTop → full redraw) cannot be
13
- * covered by the overlay: composite is above the document, while the dump is inside the document.
14
- *
15
- * Registration gate: only inside a herdr pane (index.ts checks the environment), because covering an interactive
16
- * normal terminal window would be hazardous. Clicking a narrow herdr pane focuses it and the heat view expands it;
17
- * the visible predicate re-evaluates on every frame as SIGWINCH arrives, so the frame disappears automatically and
18
- * the real TUI remains underneath (agent/session/widget execution never stops).
19
- *
20
- * Lifecycle: one process-local singleton, registered once on session_start (not resume); the overlay stays resident
21
- * (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
22
- * pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
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().
12
+ * Resize watchdog (D98): a SIGWINCH listener plus a 1 s poll, since long-running sessions stop
13
+ * re-rendering on resize; sizes are read live per render, both stop in dispose().
28
14
  */
29
15
 
30
16
  import { isArchived } from './stale-core.ts';
31
17
  import type { TodoItem } from './vocab.ts';
32
18
  import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
19
+ import { pierOption } from './pier-options.ts';
20
+ import { styledWidth, wrapStyled } from './ansi-text.ts';
33
21
 
34
- /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
35
- * Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
22
+ /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing). */
36
23
 
37
24
  export const SLIM_MIN_COLS = 24;
38
25
  export const SLIM_MIN_ROWS = 12;
@@ -50,107 +37,37 @@ export function isSlimFrame(cols: number, rows: number): boolean {
50
37
  return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
51
38
  }
52
39
 
53
- /* ── Width-aware wrapping (conservative East Asian width; ambiguous glyphs such as ▶○■✓ count as 1, affecting only break positions) ── */
54
-
55
- function isWide(cp: number): boolean {
56
- return (
57
- (cp >= 0x1100 && cp <= 0x115f) // Hangul compatibility jamo
58
- || (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals through Yi (including kana)
59
- || (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
60
- || (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
61
- || (cp >= 0xfe30 && cp <= 0xfe4f) // CJK compatibility forms
62
- || (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
63
- || (cp >= 0xffe0 && cp <= 0xffe6)
64
- );
65
- }
66
-
67
- export function displayWidth(s: string): number {
68
- let w = 0;
69
- for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
70
- return w;
71
- }
72
-
73
- /**
74
- * Wrap at visible width: prefer word breaks (retreat to the last space instead of cutting an English phrase);
75
- * CJK text without spaces hard-wraps naturally. Consume the space at the break point.
76
- */
77
- export function wrapByWidth(text: string, width: number): string[] {
78
- const w = Math.max(1, Math.floor(width));
79
- const lines: string[] = [];
80
- let line = '';
81
- let lineW = 0;
82
- const push = () => {
83
- lines.push(line);
84
- line = '';
85
- lineW = 0;
86
- };
87
- for (const ch of text) {
88
- const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
89
- if (lineW + cw > w) {
90
- const lastSpace = line.lastIndexOf(' ');
91
- if (lastSpace > 0) {
92
- lines.push(line.slice(0, lastSpace));
93
- line = line.slice(lastSpace + 1) + ch;
94
- lineW = displayWidth(line);
95
- } else {
96
- push();
97
- line = ch;
98
- lineW = cw;
99
- }
100
- } else {
101
- line += ch;
102
- lineW += cw;
103
- }
104
- }
105
- if (line !== '' || lines.length === 0) lines.push(line);
106
- return lines;
107
- }
108
-
109
- /**
110
- * Frame lines: wrap the title, center it vertically, and clamp to rows. Pad every line to full visible width:
111
- * row-wise overlay compositing would let the TUI underneath show through blank lines (leaking flicker),
112
- * while full-width spaces make the freeze genuinely opaque. Without a title, center one `·` (alive, no plan).
113
- */
40
+ /** Frame lines: wrap the title, center it vertically, clamp to rows. Every line is padded to the
41
+ * full visible width — row-wise compositing would otherwise let the TUI underneath show through
42
+ * blank lines and leak the flicker this frame exists to hide. No title → one centered `·`. */
114
43
  export function frameLines(
115
44
  text: string,
116
45
  opts: { width: number; rows: number; colorize?: (s: string) => string },
117
46
  ): string[] {
118
47
  const width = Math.max(1, Math.floor(opts.width));
119
48
  const rows = Math.max(1, Math.floor(opts.rows));
120
- const colorize = opts.colorize ?? ((s: string) => s);
121
- const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
122
-
123
- const body = text.trim()
124
- ? wrapByWidth(text.trim(), width).slice(0, rows)
125
- : ['·'];
126
- const top = Math.max(0, Math.floor((rows - body.length) / 2));
127
- const out: string[] = [];
128
- for (let i = 0; i < rows; i++) {
129
- const idx = i - top;
130
- out.push(idx >= 0 && idx < body.length ? pad(colorize(body[idx])) : ' '.repeat(width));
131
- }
132
- return out;
49
+ const body = text.trim() ? wrapStyled(text.trim(), width).slice(0, rows) : ['·'];
50
+ return padFrame(body, { width, rows, colorize: opts.colorize ?? ((s: string) => s), vAlign: 'center' });
133
51
  }
134
52
 
135
53
  function padFrame(
136
54
  content: readonly string[],
137
55
  opts: { width: number; rows: number; colorize: (s: string) => string; vAlign: 'center' | 'top' },
138
56
  ): string[] {
139
- const width = Math.max(1, Math.floor(opts.width));
140
- const rows = Math.max(1, Math.floor(opts.rows));
141
- const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
57
+ const { width, rows } = opts;
142
58
  const body = content.slice(0, rows);
143
59
  const top = opts.vAlign === 'center' ? Math.max(0, Math.floor((rows - body.length) / 2)) : 0;
144
60
  const out: string[] = [];
145
61
  for (let i = 0; i < rows; i++) {
146
62
  const idx = i - top;
147
- out.push(idx >= 0 && idx < body.length ? pad(opts.colorize(body[idx])) : ' '.repeat(width));
63
+ const line = idx >= 0 && idx < body.length ? opts.colorize(body[idx]) : '';
64
+ out.push(line + ' '.repeat(Math.max(0, width - styledWidth(line))));
148
65
  }
149
66
  return out;
150
67
  }
151
68
 
152
69
  function wrapTodoBody(items: readonly TodoItem[], width: number): string[] {
153
- return renderTodoGroups(items).flatMap((line) => wrapByWidth(line, width));
70
+ return renderTodoGroups(items).flatMap((line) => wrapStyled(line, width));
154
71
  }
155
72
 
156
73
  function itemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
@@ -172,10 +89,6 @@ export interface SlimFrameInput {
172
89
  now?: number;
173
90
  }
174
91
 
175
- /**
176
- * Choose overlay content: todo window when the pane can show ≥3 wrapped rows at ≥16 cols;
177
- * otherwise the pane title (or SLIM_EMPTY_COPY when the list is empty).
178
- */
179
92
  export function slimContentLines(
180
93
  input: SlimFrameInput & { width: number; rows: number; colorize?: (s: string) => string },
181
94
  ): string[] {
@@ -191,7 +104,7 @@ export function slimContentLines(
191
104
  if (items.length === 0) return titleFrame();
192
105
  if (isArchived(items, input.lastWriteAt ?? null, input.now ?? Date.now())) return titleFrame();
193
106
 
194
- const header = wrapByWidth(formatTodoSummary(items), width);
107
+ const header = wrapStyled(formatTodoSummary(items), width);
195
108
  const fullBody = wrapTodoBody(items, width);
196
109
  if (header.length + fullBody.length <= rows) {
197
110
  const packed = [...header, ...fullBody];
@@ -199,7 +112,7 @@ export function slimContentLines(
199
112
  return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
200
113
  }
201
114
 
202
- const footerProbe = wrapByWidth(' +99 hidden (99✓) · /todos', width);
115
+ const footerProbe = wrapStyled(' +99 hidden (99✓) · /todos', width);
203
116
  const itemBudget = rows - header.length - footerProbe.length;
204
117
  if (itemBudget < 1) return titleFrame();
205
118
 
@@ -213,14 +126,12 @@ export function slimContentLines(
213
126
 
214
127
  const hidden = items.filter((_, i) => i < start || i >= end);
215
128
  const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
216
- const footer = wrapByWidth(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
129
+ const footer = wrapStyled(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
217
130
  const packed = [...header, ...body, ...footer];
218
131
  if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
219
132
  return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
220
133
  }
221
134
 
222
- /* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
223
-
224
135
  interface FrameTui {
225
136
  requestRender(): void;
226
137
  }
@@ -300,7 +211,6 @@ class SlimFrameComponent {
300
211
  this.tui.requestRender();
301
212
  };
302
213
 
303
- /** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
304
214
  startResizeWatchdog(): void {
305
215
  if (this.poll) return;
306
216
  const size = this.viewportSize();
@@ -328,7 +238,6 @@ class SlimFrameComponent {
328
238
  }
329
239
  }
330
240
 
331
-
332
241
  /** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
333
242
  export function resetForTest(): void {
334
243
  active?.dispose();
@@ -337,13 +246,10 @@ export function resetForTest(): void {
337
246
 
338
247
  let active: SlimFrameComponent | null = null;
339
248
 
340
- /**
341
- * Register the static-frame overlay (idempotent: once per process). eventCtx is the pi lifecycle event context
342
- * (the session_start ctx, sourced from the same place as the todo widget's widgetUi).
343
- */
249
+ /** Register the static-frame overlay (idempotent, once per process) from the session_start ctx. */
344
250
  export function registerSlimFrame(eventCtx: unknown): void {
345
251
  if (active) return;
346
- if (process.env.PI_HERDR_SLIM_FRAME === '0') return;
252
+ if (pierOption('PIER_SLIM_FRAME') === '0') return;
347
253
  const ui = eventCtx !== null && typeof eventCtx === 'object' && 'ui' in eventCtx
348
254
  ? eventCtx.ui
349
255
  : null;
@@ -353,8 +259,9 @@ export function registerSlimFrame(eventCtx: unknown): void {
353
259
  try {
354
260
  // overlay: true keeps the floating layer from clearing the screen (pi ctx.ui.custom replaces the editor area by default, so cover it explicitly);
355
261
  // nonCapturing leaves the keyboard with the editor (typing in a focused narrow pane remains possible, and the expanded heat view hides the frame).
262
+ if (!custom) throw new Error('pi ui.custom unavailable');
356
263
  const pending = custom(
357
- (tui, theme) => {
264
+ (tui: FrameTui, theme: { fg(color: string, text: string): string }) => {
358
265
  const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
359
266
  comp.startResizeWatchdog();
360
267
  active = comp;
@@ -371,14 +278,12 @@ export function registerSlimFrame(eventCtx: unknown): void {
371
278
  },
372
279
  },
373
280
  );
374
- // Never call done(), leaving the Promise pending so the overlay stays resident; swallow rejection and never await it.
375
281
  Promise.resolve(pending).catch(() => {});
376
282
  } catch {
377
283
  /* Silently degrade on pi versions without overlay support. */
378
284
  }
379
285
  }
380
286
 
381
- /** Update overlay snapshot. Title remains the fallback; items drive the todo window. */
382
287
  export function updateSlimFrame(input: SlimFrameInput): void {
383
288
  active?.setSnapshot(input.title ?? '', input.items ?? [], input.lastWriteAt ?? null);
384
289
  }
package/src/stale-core.ts CHANGED
@@ -1,50 +1,43 @@
1
1
  /**
2
- * Pure core for todo staleness (anti-freeze behavior, evidenced by session 01a03253).
2
+ * Anti-freeze core: a fully completed list that new work never updates must stop impersonating
3
+ * current state.
3
4
  *
4
- * Evidence: a fully completed list (3✓) froze for 16h / 37 turns while the model continued new work but made no
5
- * todo_write calls; rereading the dead list every read hook falsely endorsed "nothing to track," and the title
6
- * rendered dead counts at full weight. This module reduces whether a list still reflects reality to pure functions
7
- * consumed in three places:
8
- * - stale (A): open==0 with ≥ STALE_TURNS turns since the last write → the read hook injects a rate-limited,
9
- * capped stale warning;
10
- * - archived (B): open==0 with wall-clock age ≥ STALE_CLOCK_MS since the last write → treat it as absent
11
- * (injection/projection no longer rereads details; entries remain queryable through /todos; session JSONL stays authoritative);
12
- * - title (D): when archived, pane/sidebar renders `✓N done <age>` instead of impersonating current state.
5
+ * - stale (A): open==0 and ≥ STALE_TURNS turns since the last write → the read hook injects a
6
+ * rate-limited, capped warning;
7
+ * - archived (B): open==0 and age ≥ STALE_CLOCK_MS → the list is treated as absent;
8
+ * - title (D): an archived list renders `✓N done <age>` instead of impersonating current state.
13
9
  *
14
- * A list with open items (pending/in_progress/blocked) is never stale: agent_settled continues to cover unfinished
15
- * work. When lastWriteAt is unknown (old sessions lack timestamps), conservatively do not archive on the clock axis;
16
- * the turns axis remains unaffected.
10
+ * A list with open items is never stale — agent_settled covers unfinished work. Unknown
11
+ * lastWriteAt (old sessions) conservatively suppresses the clock axis; turns is unaffected.
17
12
  */
18
13
  import { countTodos, type TodoItem } from './vocab.ts';
19
14
 
20
- /** A: turn-based expiry threshold (user turns counted from the last todo write). */
15
+ /** A: turn-based expiry threshold, counted from the last todo write. */
21
16
  export const STALE_TURNS = 6;
22
17
 
23
- /** B: wall-clock archive threshold (age of the last write for a completed list). */
18
+ /** B: age of the last write above which a completed list is archived. */
24
19
  export const STALE_CLOCK_MS = 60 * 60 * 1000;
25
20
 
26
- /** A: maximum stale warnings injected during one stalled period (prevent spin noise; archived changes to archive-notice cadence). */
21
+ /** A: cap on stale warnings injected during one stalled period. */
27
22
  export const STALE_NOTICE_MAX = 3;
28
23
 
29
24
  export type StalenessKind = 'fresh' | 'stale' | 'archived';
30
25
 
31
26
  export interface Staleness {
32
27
  kind: StalenessKind;
33
- /** open = pending + in_progress + blocked (abandoned does not count as unfinished). */
28
+ /** open = pending + in_progress + blocked (abandoned is not unfinished). */
34
29
  open: number;
35
- /** now - lastWriteAt; unknown lastWriteAt → null. */
36
30
  ageMs: number | null;
37
31
  }
38
32
 
39
- /** Count open items: stale/archived apply only to fully completed (or fully abandoned) lists with open==0. */
40
33
  export function openTodos(items: readonly TodoItem[]): number {
41
- const c = countTodos(items as TodoItem[]);
34
+ const c = countTodos(items);
42
35
  return c.pending + c.inProgress + c.blocked;
43
36
  }
44
37
 
45
38
  /**
46
- * Determine staleness (two conditions: turns → stale; wall clock → archived, with the clock taking precedence).
47
- * Empty list → fresh (a separate guard owns empty-list handling); null lastWriteAt → never stale (conservative).
39
+ * Clock (archived) takes precedence over turns (stale). Empty list → fresh (a separate guard owns
40
+ * empty-list handling); null lastWriteAt → never stale.
48
41
  */
49
42
  export function evaluateStaleness(opts: {
50
43
  items: readonly TodoItem[];
@@ -61,7 +54,7 @@ export function evaluateStaleness(opts: {
61
54
  return st;
62
55
  }
63
56
 
64
- /** For title/mirror paths without turn information: only evaluate wall-clock archive status. */
57
+ /** Title/mirror paths have no turn information: only the wall-clock archive axis. */
65
58
  export function isArchived(
66
59
  items: readonly TodoItem[],
67
60
  lastWriteAt: number | null,
@@ -70,7 +63,7 @@ export function isArchived(
70
63
  return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
71
64
  }
72
65
 
73
- /** Age display: <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
66
+ /** <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
74
67
  export function formatAge(ms: number): string {
75
68
  if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
76
69
  if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;