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.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/session-tail.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tail parsing for pi session JSONL (pure
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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'));
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
14
|
+
/** stopReason of the last assistant turn (null = treated as natural completion). */
|
|
23
15
|
lastStopReason: string | null;
|
|
24
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
45
|
-
return {
|
|
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:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
121
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
*
|
|
2
|
+
* Anti-freeze core: a fully completed list that new work never updates must stop impersonating
|
|
3
|
+
* current state.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
|
15
|
+
/** A: turn-based expiry threshold, counted from the last todo write. */
|
|
21
16
|
export const STALE_TURNS = 6;
|
|
22
17
|
|
|
23
|
-
/** B:
|
|
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:
|
|
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
|
|
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
|
|
34
|
+
const c = countTodos(items);
|
|
42
35
|
return c.pending + c.inProgress + c.blocked;
|
|
43
36
|
}
|
|
44
37
|
|
|
45
38
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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`;
|