pi-crew 0.9.44 → 0.9.46
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/CHANGELOG.md +76 -0
- package/README.md +22 -1
- package/dist/build-meta.json +68 -47
- package/dist/index.mjs +486 -237
- package/dist/index.mjs.map +3 -3
- package/package.json +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/extension/registration/lifecycle-handlers.ts +34 -3
- package/src/extension/registration/subagent-manager-setup.ts +178 -59
- package/src/extension/run-import.ts +21 -1
- package/src/extension/team-tool/api.ts +4 -2
- package/src/runtime/async-runner.ts +9 -1
- package/src/runtime/manifest-cache.ts +30 -0
- package/src/runtime/resilient-edit.ts +16 -15
- package/src/runtime/role-permission.ts +27 -2
- package/src/runtime/run-coalesced-task-group.ts +72 -15
- package/src/runtime/task-packet.ts +1 -1
- package/src/state/event-log.ts +88 -34
- package/src/state/locks.ts +53 -0
- package/src/state/mailbox.ts +165 -4
- package/src/state/run-metrics.ts +40 -12
- package/src/ui/live-run-sidebar.ts +1 -9
- package/src/ui/run-dashboard.ts +1 -9
- package/src/utils/incremental-reader.ts +105 -0
- package/src/utils/visual.ts +27 -91
- package/src/runtime/auto-resume.ts +0 -100
- package/src/runtime/notebook-helpers.ts +0 -88
- package/src/runtime/orphan-sentinel.ts +0 -7
package/src/state/run-metrics.ts
CHANGED
|
@@ -107,6 +107,16 @@ export function loadRunMetrics(cwd: string, runId: string): RunMetrics | undefin
|
|
|
107
107
|
* List recent metrics files up to `limit` entries (newest first).
|
|
108
108
|
* Returns an array of { runId, timestamp, taskCount, completedCount, failedCount, totalTokens, totalCost, durationMs, consistencyScore }.
|
|
109
109
|
* Gracefully skips files that cannot be read or parsed.
|
|
110
|
+
*
|
|
111
|
+
* FIND-04 perf: sort dirents by mtime descending BEFORE reading any of
|
|
112
|
+
* them, then read at most `limit` files. Malformed files within that
|
|
113
|
+
* selected window reduce the returned count; the scan does not continue
|
|
114
|
+
* past the window to backfill them. Previously the function read up
|
|
115
|
+
* to MAX_METRIC_FILES_TO_SCAN (500) files via loadRunMetrics() and only
|
|
116
|
+
* THEN sorted+sliced to `limit` (default 25) — wasting 475 readFileSync +
|
|
117
|
+
* JSON.parse calls on the hot dashboard path. The sort is O(N log N) and
|
|
118
|
+
* uses the run-id timestamp prefix as a cheap secondary signal when
|
|
119
|
+
* mtimes tie (e.g. multiple files saved in the same millisecond).
|
|
110
120
|
*/
|
|
111
121
|
export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
112
122
|
const dir = metricsDir(cwd);
|
|
@@ -117,21 +127,39 @@ export function getRunMetricsSummary(cwd: string, limit = 25): RunMetrics[] {
|
|
|
117
127
|
return [];
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
// Cap the directory-order candidate set before collecting mtimes. This
|
|
131
|
+
// is a safety valve against unbounded scan cost; the mtime sort below
|
|
132
|
+
// only reorders entries within this bounded candidate window.
|
|
133
|
+
const cap = Math.max(0, limit);
|
|
134
|
+
const sorted = entries
|
|
135
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
136
|
+
.slice(0, MAX_METRIC_FILES_TO_SCAN)
|
|
137
|
+
.map((entry) => {
|
|
138
|
+
const fullPath = path.join(dir, entry.name);
|
|
139
|
+
let mtimeMs = 0;
|
|
140
|
+
try {
|
|
141
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
142
|
+
} catch {
|
|
143
|
+
// stat failure: fall through with mtimeMs=0; the sort still
|
|
144
|
+
// produces a deterministic order (filenames tiebreak).
|
|
145
|
+
}
|
|
146
|
+
return { name: entry.name, mtimeMs };
|
|
147
|
+
})
|
|
148
|
+
.sort((a, b) => {
|
|
149
|
+
const diff = b.mtimeMs - a.mtimeMs;
|
|
150
|
+
if (diff !== 0) return diff;
|
|
151
|
+
// Filename tiebreaker: the run-id prefix is a YYYYMMDDhhmmss
|
|
152
|
+
// timestamp (e.g. team_20260720050617_abc123) so a descending
|
|
153
|
+
// string compare yields newest-first within a tie group.
|
|
154
|
+
return b.name.localeCompare(a.name);
|
|
155
|
+
});
|
|
156
|
+
|
|
120
157
|
const metrics: RunMetrics[] = [];
|
|
121
|
-
for (const
|
|
122
|
-
|
|
123
|
-
const runId = entry.name.replace(/\.json$/, "");
|
|
158
|
+
for (const { name } of sorted.slice(0, cap)) {
|
|
159
|
+
const runId = name.replace(/\.json$/, "");
|
|
124
160
|
const m = loadRunMetrics(cwd, runId);
|
|
125
161
|
if (m) metrics.push(m);
|
|
126
|
-
if (metrics.length >= MAX_METRIC_FILES_TO_SCAN) break;
|
|
127
162
|
}
|
|
128
163
|
|
|
129
|
-
|
|
130
|
-
metrics.sort((a, b) => {
|
|
131
|
-
const diff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
132
|
-
if (diff !== 0) return diff;
|
|
133
|
-
return b.runId.localeCompare(a.runId);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
return metrics.slice(0, limit);
|
|
164
|
+
return metrics;
|
|
137
165
|
}
|
|
@@ -8,7 +8,6 @@ import type { TeamTaskState } from "../state/types.ts";
|
|
|
8
8
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
9
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
10
10
|
import { pad, truncate } from "../utils/visual.ts";
|
|
11
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
12
11
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
13
12
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
14
13
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
@@ -16,17 +15,10 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
16
15
|
import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
|
|
17
16
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
18
17
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
18
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
19
19
|
|
|
20
20
|
const TASK_READ_TTL_MS = 200;
|
|
21
21
|
|
|
22
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
23
|
-
const box = new Box(0, 0);
|
|
24
|
-
for (const line of lines) {
|
|
25
|
-
box.addChild(new Text(line));
|
|
26
|
-
}
|
|
27
|
-
return box.render(width);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
22
|
type Done = (value: undefined) => void;
|
|
31
23
|
|
|
32
24
|
function line(text: string, width: number): string {
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { renderProgressPane } from "./dashboard-panes/progress-pane.ts";
|
|
|
19
19
|
import { renderTranscriptPane } from "./dashboard-panes/transcript-pane.ts";
|
|
20
20
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
21
21
|
import { dashboardActionForKey } from "./keybinding-map.ts";
|
|
22
|
-
import { Box, Text } from "./layout-primitives.ts";
|
|
23
22
|
import { HelpOverlay } from "./overlays/help-overlay.ts";
|
|
24
23
|
import { RenderScheduler } from "./render-scheduler.ts";
|
|
25
24
|
import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
|
|
@@ -28,6 +27,7 @@ import { spinnerBucket, spinnerFrame } from "./spinner.ts";
|
|
|
28
27
|
import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus } from "./status-colors.ts";
|
|
29
28
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
30
29
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
30
|
+
import { renderLines } from "./widget/widget-renderer.ts";
|
|
31
31
|
|
|
32
32
|
/** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */
|
|
33
33
|
function safeRenderPane(name: string, fn: () => string[]): string[] {
|
|
@@ -159,14 +159,6 @@ function formatAge(iso: string | undefined): string | undefined {
|
|
|
159
159
|
return `${Math.floor(ms / 3_600_000)}h`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function renderLines(lines: string[], width: number): string[] {
|
|
163
|
-
const box = new Box(0, 0);
|
|
164
|
-
for (const line of lines) {
|
|
165
|
-
box.addChild(new Text(line));
|
|
166
|
-
}
|
|
167
|
-
return box.render(width);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
162
|
function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?: RunSnapshotCache): string[] {
|
|
171
163
|
// P0-6: prefer the snapshot's `recentOutputLines` (no disk I/O) over reading the
|
|
172
164
|
// progress artifact on every render. The progress artifact content is captured
|
|
@@ -13,6 +13,111 @@ export interface IncrementalReadResult {
|
|
|
13
13
|
|
|
14
14
|
const CHUNK_SIZE = 64 * 1024;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Read the last `tailBytes` bytes of a file and parse it as JSONL,
|
|
18
|
+
* returning each successfully-parsed item in file order. If the file is
|
|
19
|
+
* shorter than `tailBytes`, the entire file is read from offset 0.
|
|
20
|
+
*
|
|
21
|
+
* Returns:
|
|
22
|
+
* - items: parsed JSONL items (T may be any deserializable type)
|
|
23
|
+
* - fileSize: total file size in bytes
|
|
24
|
+
* - bytesRead: number of bytes actually read (≤ min(fileSize, tailBytes))
|
|
25
|
+
* - truncated: true when `bytesRead < fileSize` (a prefix was dropped)
|
|
26
|
+
*
|
|
27
|
+
* The function bounds the read to `tailBytes`, so CPU is O(tail bytes)
|
|
28
|
+
* regardless of how large the file grows. Used by the event-log tail
|
|
29
|
+
* read path (FIND-05) to avoid the O(total events) cost of the legacy
|
|
30
|
+
* `readFileSync`+`split("\n")`+`JSON.parse` approach.
|
|
31
|
+
*/
|
|
32
|
+
export function readJsonlTail<T>(
|
|
33
|
+
filePath: string,
|
|
34
|
+
tailBytes: number,
|
|
35
|
+
): {
|
|
36
|
+
items: T[];
|
|
37
|
+
fileSize: number;
|
|
38
|
+
bytesRead: number;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
} {
|
|
41
|
+
const limit = Math.max(0, Math.floor(tailBytes));
|
|
42
|
+
let stat: fs.Stats;
|
|
43
|
+
try {
|
|
44
|
+
stat = fs.statSync(filePath);
|
|
45
|
+
} catch {
|
|
46
|
+
return { items: [], fileSize: 0, bytesRead: 0, truncated: false };
|
|
47
|
+
}
|
|
48
|
+
const fileSize = stat.size;
|
|
49
|
+
if (fileSize === 0 || limit === 0) {
|
|
50
|
+
return { items: [], fileSize, bytesRead: 0, truncated: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const startOffset = Math.max(0, fileSize - limit);
|
|
54
|
+
const bytesToRead = fileSize - startOffset;
|
|
55
|
+
const truncated = startOffset > 0;
|
|
56
|
+
|
|
57
|
+
let fd: number | undefined;
|
|
58
|
+
try {
|
|
59
|
+
// FIND-05 follow-up: O_NOFOLLOW refuses symlinks (defense-in-depth,
|
|
60
|
+
// consistent with atomicWriteFile / resolveRealContainedPath). The
|
|
61
|
+
// eventsPath is validated inside stateRoot, but this guards against a
|
|
62
|
+
// symlinked file appearing after validation. O_NOFOLLOW on a symlink
|
|
63
|
+
// yields ELOOP, which the catch below treats like ENOENT (return empty).
|
|
64
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
65
|
+
} catch {
|
|
66
|
+
return { items: [], fileSize, bytesRead: 0, truncated: false };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
70
|
+
let totalRead = 0;
|
|
71
|
+
while (totalRead < bytesToRead) {
|
|
72
|
+
const chunkSize = Math.min(CHUNK_SIZE, bytesToRead - totalRead);
|
|
73
|
+
const bytesRead = fs.readSync(fd, buf, totalRead, chunkSize, startOffset + totalRead);
|
|
74
|
+
if (bytesRead === 0) break;
|
|
75
|
+
totalRead += bytesRead;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Decode the slice we actually read; the buffer may be partially
|
|
79
|
+
// filled on short reads. We use totalRead (not bytesToRead) so the
|
|
80
|
+
// JSONL split lands on actual data, not a zero-padded tail.
|
|
81
|
+
const content = buf.toString("utf-8", 0, totalRead);
|
|
82
|
+
|
|
83
|
+
// If we truncated the file, the first decoded line may be a
|
|
84
|
+
// half-line from the cut. Drop it — we can't trust the JSON.
|
|
85
|
+
// Use a single indexOf("\n") to find the first newline; if there
|
|
86
|
+
// is none, the whole tail is one partial line and we drop it.
|
|
87
|
+
let body = content;
|
|
88
|
+
if (truncated) {
|
|
89
|
+
const firstNewline = body.indexOf("\n");
|
|
90
|
+
if (firstNewline < 0) {
|
|
91
|
+
// No newline at all in the tail → the entire tail is one
|
|
92
|
+
// partial line. Return no items; the caller should fall
|
|
93
|
+
// back to a wider tail or full read.
|
|
94
|
+
return { items: [], fileSize, bytesRead: totalRead, truncated: true };
|
|
95
|
+
}
|
|
96
|
+
body = body.slice(firstNewline + 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const items: T[] = [];
|
|
100
|
+
for (const line of body.split("\n")) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed) continue;
|
|
103
|
+
try {
|
|
104
|
+
items.push(JSON.parse(trimmed) as T);
|
|
105
|
+
} catch {
|
|
106
|
+
// Skip malformed lines (mirrors readJsonlSince's behavior).
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { items, fileSize, bytesRead: totalRead, truncated };
|
|
110
|
+
} finally {
|
|
111
|
+
if (fd !== undefined) {
|
|
112
|
+
try {
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
16
121
|
/**
|
|
17
122
|
* Read new lines from a text file since last known byte offset.
|
|
18
123
|
* Uses fs.openSync + fs.readSync for efficient incremental reading.
|
package/src/utils/visual.ts
CHANGED
|
@@ -1,98 +1,28 @@
|
|
|
1
|
+
import { visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
1
3
|
export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
2
4
|
|
|
3
5
|
const WIDTH_CACHE_LIMIT = 256;
|
|
4
6
|
const widthCache = new Map<string, number>();
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Hangul Syllables
|
|
15
|
-
[0xac00, 0xd7af],
|
|
16
|
-
// CJK Symbols and Punctuation, Hiragana, Katakana
|
|
17
|
-
[0x3000, 0x33ff],
|
|
18
|
-
// Fullwidth forms
|
|
19
|
-
[0xff01, 0xff60],
|
|
20
|
-
// Emoji blocks
|
|
21
|
-
// Emoji-presentation codepoints in 0x2600-0x27BF (narrow chars like ✓✗★ excluded)
|
|
22
|
-
[0x2615, 0x2615],
|
|
23
|
-
[0x2648, 0x2653],
|
|
24
|
-
[0x267f, 0x267f],
|
|
25
|
-
[0x2693, 0x2693],
|
|
26
|
-
[0x26a1, 0x26a1],
|
|
27
|
-
[0x26aa, 0x26ab],
|
|
28
|
-
[0x26bd, 0x26be],
|
|
29
|
-
[0x26c4, 0x26c5],
|
|
30
|
-
[0x26ce, 0x26ce],
|
|
31
|
-
[0x26d4, 0x26d4],
|
|
32
|
-
[0x26ea, 0x26ea],
|
|
33
|
-
[0x26f2, 0x26f3],
|
|
34
|
-
[0x26f5, 0x26f5],
|
|
35
|
-
[0x26fa, 0x26fa],
|
|
36
|
-
[0x26fd, 0x26fd],
|
|
37
|
-
[0x2702, 0x2702],
|
|
38
|
-
[0x2705, 0x2705],
|
|
39
|
-
[0x2708, 0x270d],
|
|
40
|
-
[0x270f, 0x270f],
|
|
41
|
-
[0x2712, 0x2712],
|
|
42
|
-
[0x2714, 0x2714],
|
|
43
|
-
[0x2716, 0x2716],
|
|
44
|
-
[0x271d, 0x271d],
|
|
45
|
-
[0x2721, 0x2721],
|
|
46
|
-
[0x2728, 0x2728],
|
|
47
|
-
[0x2733, 0x2734],
|
|
48
|
-
[0x2744, 0x2744],
|
|
49
|
-
[0x2747, 0x2747],
|
|
50
|
-
[0x274c, 0x274c],
|
|
51
|
-
[0x274e, 0x274e],
|
|
52
|
-
[0x2753, 0x2755],
|
|
53
|
-
[0x2757, 0x2757],
|
|
54
|
-
[0x2763, 0x2764],
|
|
55
|
-
[0x2795, 0x2797],
|
|
56
|
-
[0x27a1, 0x27a1],
|
|
57
|
-
[0x27b0, 0x27b0],
|
|
58
|
-
[0x27bf, 0x27bf],
|
|
59
|
-
// Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
|
|
60
|
-
// width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
|
|
61
|
-
// terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
|
|
62
|
-
// its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
|
|
63
|
-
// re-measured the padded line at 160 because ⬜ counts as 2 upstream.
|
|
64
|
-
[0x2b1b, 0x2b1c], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
|
|
65
|
-
[0x1f300, 0x1f9ff], // Misc Symbols, Emoticons, Transport, Map, Supplement
|
|
66
|
-
[0x1fa00, 0x1faff], // Symbols Extended-A
|
|
67
|
-
[0x1f000, 0x1f02f], // Mahjong, Dominos
|
|
68
|
-
[0xfe00, 0xfe0f], // Variation Selectors (emoji presentation)
|
|
69
|
-
];
|
|
70
|
-
// NOTE: U+200D (Zero Width Joiner, ZWJ) is intentionally NOT listed as wide.
|
|
71
|
-
// ZWJ has zero advance width by Unicode definition; miscounting it as 2 caused
|
|
72
|
-
// slight over-truncation/over-padding of compound-emoji goals (T-1).
|
|
73
|
-
|
|
74
|
-
function isWideCodePoint(code: number): boolean {
|
|
75
|
-
for (const [lo, hi] of WIDE_RANGES) {
|
|
76
|
-
if (code >= lo && code <= hi) return true;
|
|
77
|
-
}
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
8
|
+
// NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
|
|
9
|
+
// pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
|
|
10
|
+
// terminal width by ITS measure, so our truncate/pad/wrap MUST agree with it.
|
|
11
|
+
// A previous hand-maintained WIDE_RANGES table diverged from pi-tui's
|
|
12
|
+
// grapheme+RGI-emoji model on codepoints like ⏳ U+23F3 (hourglass: we counted
|
|
13
|
+
// 1, pi-tui counts 2) → lines we believed fit were rejected →
|
|
14
|
+
// "Rendered line N exceeds terminal width (160 > 159)" crash. Delegating makes
|
|
15
|
+
// divergence structurally impossible.
|
|
80
16
|
|
|
81
17
|
export function visibleWidth(value: string): number {
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
return length;
|
|
89
|
-
}
|
|
18
|
+
// Delegate to pi-tui's authoritative width model so our truncate/pad/wrap
|
|
19
|
+
// can never disagree with the renderer's doRender measurement. Keep our own
|
|
20
|
+
// LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
|
|
21
|
+
// hot render path).
|
|
22
|
+
if (value.length > 4096) return tuiVisibleWidth(value);
|
|
90
23
|
const cached = widthCache.get(value);
|
|
91
24
|
if (cached !== undefined) return cached;
|
|
92
|
-
|
|
93
|
-
for (const char of value.replace(ANSI_PATTERN, "")) {
|
|
94
|
-
if (char !== "\n") length += isWideCodePoint(char.codePointAt(0) ?? 0) ? 2 : 1;
|
|
95
|
-
}
|
|
25
|
+
const length = tuiVisibleWidth(value);
|
|
96
26
|
if (widthCache.size >= WIDTH_CACHE_LIMIT) {
|
|
97
27
|
const firstKey = widthCache.keys().next().value;
|
|
98
28
|
if (firstKey !== undefined) widthCache.delete(firstKey);
|
|
@@ -132,15 +62,21 @@ export function truncateToWidth(value: string, width: number, ellipsis = "…"):
|
|
|
132
62
|
if (width <= ellipsis.length) return ellipsis.slice(0, width);
|
|
133
63
|
let output = "";
|
|
134
64
|
let renderedWidth = 0;
|
|
135
|
-
for (let i = 0; i < value.length;
|
|
65
|
+
for (let i = 0; i < value.length; ) {
|
|
136
66
|
const ansiLen = consumeAnsi(value, i);
|
|
137
67
|
if (ansiLen) {
|
|
138
68
|
output += value.slice(i, i + ansiLen);
|
|
139
|
-
i += ansiLen
|
|
69
|
+
i += ansiLen;
|
|
140
70
|
continue;
|
|
141
71
|
}
|
|
142
|
-
|
|
143
|
-
|
|
72
|
+
// Read the codepoint at position i on the FULL string — NOT
|
|
73
|
+
// `value[i].codePointAt(0)`, which for a surrogate pair returns the
|
|
74
|
+
// lone half (≤ 0xFFFF) and slices each half separately. pi-tui's
|
|
75
|
+
// visibleWidth zero-widths lone `\p{Surrogate}` halves, so 🤖 would
|
|
76
|
+
// undercount as 0 and truncation would never cut (latent bug unmasked
|
|
77
|
+
// once visibleWidth delegated to pi-tui).
|
|
78
|
+
const codepoint = value.codePointAt(i) ?? 0;
|
|
79
|
+
const nextIndex = codepoint > 0xffff ? i + 2 : i + 1;
|
|
144
80
|
const segment = value.slice(i, nextIndex);
|
|
145
81
|
const charWidth = visibleWidth(segment);
|
|
146
82
|
if (renderedWidth + charWidth > width - ellipsis.length) {
|
|
@@ -148,7 +84,7 @@ export function truncateToWidth(value: string, width: number, ellipsis = "…"):
|
|
|
148
84
|
}
|
|
149
85
|
output += segment;
|
|
150
86
|
renderedWidth += charWidth;
|
|
151
|
-
i = nextIndex
|
|
87
|
+
i = nextIndex;
|
|
152
88
|
}
|
|
153
89
|
return output;
|
|
154
90
|
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-resume controller with settle window — prevents premature context injection
|
|
3
|
-
* by waiting for Pi's setTimeout(0) + setTimeout(100) to drain before resuming.
|
|
4
|
-
*
|
|
5
|
-
* Distilled from pi-autoresearch's auto-resume / settle-window pattern.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** Time to wait before auto-resuming (ms). Outlasts Pi's setTimeout(0) + setTimeout(100). */
|
|
9
|
-
export const SETTLE_WINDOW_MS = 800;
|
|
10
|
-
|
|
11
|
-
/** Maximum number of auto-resume turns before forcing a stop. */
|
|
12
|
-
export const MAX_AUTORESUME_TURNS = 20;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Controller for scheduling auto-resume actions with a settle window.
|
|
16
|
-
*
|
|
17
|
-
* The settle window ensures that pending async operations (e.g., Pi's
|
|
18
|
-
* internal setTimeout(0) and setTimeout(100) callbacks) have completed
|
|
19
|
-
* before the resume callback fires.
|
|
20
|
-
*/
|
|
21
|
-
export class AutoResumeController {
|
|
22
|
-
private timerId: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
-
private turnCount = 0;
|
|
24
|
-
private cancelled = false;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Get the current auto-resume turn count.
|
|
28
|
-
*/
|
|
29
|
-
get currentTurnCount(): number {
|
|
30
|
-
return this.turnCount;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get the maximum allowed auto-resume turns.
|
|
35
|
-
*/
|
|
36
|
-
get maxTurns(): number {
|
|
37
|
-
return MAX_AUTORESUME_TURNS;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Schedule an auto-resume callback after the settle window elapses.
|
|
42
|
-
*
|
|
43
|
-
* If a resume is already pending, it is cancelled first (debounce behavior).
|
|
44
|
-
* If the turn limit has been reached, the callback is not scheduled.
|
|
45
|
-
*
|
|
46
|
-
* @param message - Description of why the resume is being scheduled
|
|
47
|
-
* @param callback - Function to call after the settle window
|
|
48
|
-
*/
|
|
49
|
-
scheduleResume(message: string, callback: () => void): void {
|
|
50
|
-
// Cancel any existing pending resume
|
|
51
|
-
this.cancelResume();
|
|
52
|
-
|
|
53
|
-
// Enforce turn limit
|
|
54
|
-
if (this.turnCount >= MAX_AUTORESUME_TURNS) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.cancelled = false;
|
|
59
|
-
this.turnCount++;
|
|
60
|
-
|
|
61
|
-
this.timerId = setTimeout(() => {
|
|
62
|
-
if (!this.cancelled) {
|
|
63
|
-
this.timerId = null;
|
|
64
|
-
callback();
|
|
65
|
-
}
|
|
66
|
-
}, SETTLE_WINDOW_MS);
|
|
67
|
-
|
|
68
|
-
// Prevent the timer from keeping the process alive
|
|
69
|
-
if (this.timerId && typeof this.timerId === "object" && "unref" in this.timerId) {
|
|
70
|
-
this.timerId.unref();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Cancel any pending auto-resume.
|
|
76
|
-
*/
|
|
77
|
-
cancelResume(): void {
|
|
78
|
-
if (this.timerId !== null) {
|
|
79
|
-
clearTimeout(this.timerId);
|
|
80
|
-
this.timerId = null;
|
|
81
|
-
}
|
|
82
|
-
this.cancelled = true;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Check whether an auto-resume is currently pending.
|
|
87
|
-
*/
|
|
88
|
-
hasPendingResume(): boolean {
|
|
89
|
-
return this.timerId !== null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Reset the turn counter. Called when a new agent starts processing
|
|
94
|
-
* to allow a fresh set of auto-resume turns.
|
|
95
|
-
*/
|
|
96
|
-
resetTurnCount(): void {
|
|
97
|
-
this.turnCount = 0;
|
|
98
|
-
this.cancelled = false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
export interface NotebookCell {
|
|
2
|
-
index: number;
|
|
3
|
-
cellType: "code" | "markdown" | "raw";
|
|
4
|
-
source: string;
|
|
5
|
-
outputs?: unknown[];
|
|
6
|
-
metadata?: Record<string, unknown>;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface Notebook {
|
|
10
|
-
cells: NotebookCell[];
|
|
11
|
-
metadata?: Record<string, unknown>;
|
|
12
|
-
nbformat: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Check if a file path is a notebook */
|
|
16
|
-
export function isNotebookPath(filePath: string): boolean {
|
|
17
|
-
return filePath.toLowerCase().endsWith(".ipynb");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function normalizeSource(source: unknown): string {
|
|
21
|
-
if (typeof source === "string") return source;
|
|
22
|
-
if (Array.isArray(source)) return source.join("");
|
|
23
|
-
return "";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Parse a .ipynb JSON file into a Notebook structure */
|
|
27
|
-
export function parseNotebook(content: string): Notebook {
|
|
28
|
-
let raw: unknown;
|
|
29
|
-
try {
|
|
30
|
-
raw = JSON.parse(content);
|
|
31
|
-
} catch {
|
|
32
|
-
return { cells: [], nbformat: 4 };
|
|
33
|
-
}
|
|
34
|
-
if (typeof raw !== "object" || raw === null) return { cells: [], nbformat: 4 };
|
|
35
|
-
const obj = raw as Record<string, unknown>;
|
|
36
|
-
const nbformat = typeof obj.nbformat === "number" ? obj.nbformat : 4;
|
|
37
|
-
const rawCells = Array.isArray(obj.cells) ? obj.cells : [];
|
|
38
|
-
const cells: NotebookCell[] = rawCells
|
|
39
|
-
.map((c: unknown, i: number): NotebookCell | null => {
|
|
40
|
-
if (typeof c !== "object" || c === null) return null;
|
|
41
|
-
const cell = c as Record<string, unknown>;
|
|
42
|
-
const cellType = cell.cell_type;
|
|
43
|
-
if (cellType !== "code" && cellType !== "markdown" && cellType !== "raw") return null;
|
|
44
|
-
return {
|
|
45
|
-
index: i,
|
|
46
|
-
cellType,
|
|
47
|
-
source: normalizeSource(cell.source),
|
|
48
|
-
outputs: Array.isArray(cell.outputs) ? cell.outputs : undefined,
|
|
49
|
-
metadata:
|
|
50
|
-
cell.metadata && typeof cell.metadata === "object" && !Array.isArray(cell.metadata)
|
|
51
|
-
? (cell.metadata as Record<string, unknown>)
|
|
52
|
-
: undefined,
|
|
53
|
-
};
|
|
54
|
-
})
|
|
55
|
-
.filter((c: NotebookCell | null): c is NotebookCell => c !== null);
|
|
56
|
-
const metadata =
|
|
57
|
-
obj.metadata && typeof obj.metadata === "object" && !Array.isArray(obj.metadata)
|
|
58
|
-
? (obj.metadata as Record<string, unknown>)
|
|
59
|
-
: undefined;
|
|
60
|
-
return { cells, metadata, nbformat };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Get a specific cell by index */
|
|
64
|
-
export function getCell(notebook: Notebook, index: number): NotebookCell | undefined {
|
|
65
|
-
return notebook.cells.find((c) => c.index === index);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Update a cell's source content, returning a new Notebook */
|
|
69
|
-
export function updateCell(notebook: Notebook, index: number, source: string): Notebook {
|
|
70
|
-
const cells = notebook.cells.map((c) => (c.index === index ? { ...c, source } : c));
|
|
71
|
-
return { ...notebook, cells };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Serialize a Notebook back to .ipynb JSON string */
|
|
75
|
-
export function serializeNotebook(notebook: Notebook): string {
|
|
76
|
-
const raw = {
|
|
77
|
-
nbformat: notebook.nbformat,
|
|
78
|
-
nbformat_minor: 5,
|
|
79
|
-
metadata: notebook.metadata ?? {},
|
|
80
|
-
cells: notebook.cells.map((c) => ({
|
|
81
|
-
cell_type: c.cellType,
|
|
82
|
-
source: c.source,
|
|
83
|
-
metadata: c.metadata ?? {},
|
|
84
|
-
...(c.cellType === "code" ? { outputs: c.outputs ?? [], execution_count: null } : {}),
|
|
85
|
-
})),
|
|
86
|
-
};
|
|
87
|
-
return JSON.stringify(raw, null, 2) + "\n";
|
|
88
|
-
}
|