@north-light/crouter 0.3.174 → 0.3.176
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/dist/api/dto/nodes.d.ts +7 -1
- package/dist/api/dto/profiles.d.ts +3 -0
- package/dist/clients/attach/render/crtr-output.d.ts +3 -3
- package/dist/clients/attach/render/crtr-output.js +25 -13
- package/dist/clients/attach/viewer.js +531 -561
- package/dist/commands/memory/write.js +13 -12
- package/dist/commands/node/create.js +7 -3
- package/dist/commands/profile/list.js +3 -1
- package/dist/commands/profile/new.js +4 -1
- package/dist/commands/profile/project.js +23 -9
- package/dist/commands/profile/show.js +3 -1
- package/dist/commands/profile.js +2 -2
- package/dist/commands/sys/logs.js +4 -2
- package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/command-plugins/transport/http-invoke.js +10 -2
- package/dist/core/command.js +3 -1
- package/dist/core/config.js +4 -27
- package/dist/core/exclusive-lock.d.ts +14 -0
- package/dist/core/exclusive-lock.js +124 -0
- package/dist/core/human/claim.js +2 -1
- package/dist/core/human/convention.d.ts +0 -10
- package/dist/core/human/convention.js +1 -123
- package/dist/core/io.d.ts +23 -0
- package/dist/core/io.js +60 -0
- package/dist/core/preview-registry.d.ts +10 -5
- package/dist/core/preview-registry.js +136 -2537
- package/dist/core/profiles/manifest.d.ts +12 -1
- package/dist/core/profiles/manifest.js +57 -48
- package/dist/core/review/store.d.ts +4 -1
- package/dist/core/review/store.js +10 -0
- package/dist/core/runtime/branded-host.js +6 -84
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/runtime/warm-pool.d.ts +9 -8
- package/dist/core/runtime/warm-pool.js +58 -62
- package/dist/core/substrate/index.d.ts +2 -1
- package/dist/core/substrate/index.js +2 -1
- package/dist/core/substrate/on-read-node.d.ts +7 -0
- package/dist/core/substrate/on-read-node.js +37 -0
- package/dist/core/substrate/on-read.d.ts +7 -5
- package/dist/core/substrate/on-read.js +15 -23
- package/dist/core/termrender/termrender.js +16 -96
- package/dist/core/worktree.js +11 -100
- package/dist/daemon/api/handlers/nodes.js +32 -9
- package/dist/daemon/api/handlers/review-comments.js +5 -44
- package/dist/daemon/api/handlers/reviews.js +5 -53
- package/dist/daemon/api/handlers/validate.d.ts +22 -0
- package/dist/daemon/api/handlers/validate.js +59 -0
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/review/finish.js +1 -10
- package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
- package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
- package/dist/pi-extensions/canvas-preview-result.js +60 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/on-read-pure.d.ts +0 -7
- package/dist/core/substrate/on-read-pure.js +0 -272
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
const MARKER_PREFIX = 'owner.';
|
|
4
|
+
const POLL_MS = 10;
|
|
5
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
|
+
function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
8
|
+
function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
|
|
9
|
+
function observedMarkerToken(path) {
|
|
10
|
+
try {
|
|
11
|
+
return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The token opens with the owner's pid, so liveness reads off the name. */
|
|
18
|
+
function ownerIsAlive(token) {
|
|
19
|
+
const pid = Number(token.split('.', 1)[0]);
|
|
20
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return error.code === 'EPERM';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isEmptyDirectory(path) {
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(path).length === 0;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function ageMs(path) {
|
|
39
|
+
try {
|
|
40
|
+
return Date.now() - statSync(path).mtimeMs;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Reclamation is bound to the exact instance it observed: it unlinks ONLY that
|
|
47
|
+
// token's marker, then removes the now-empty directory with an empty-guarded
|
|
48
|
+
// rmdir. A successor holds a different random token — a different marker name —
|
|
49
|
+
// so a lagging reclaimer that saw the old dead lock can never strip a live
|
|
50
|
+
// successor: its unlink targets a name that no longer exists.
|
|
51
|
+
function reclaimIfOwnerGone(path, staleMs) {
|
|
52
|
+
const token = observedMarkerToken(path);
|
|
53
|
+
if (token !== null) {
|
|
54
|
+
if (ownerIsAlive(token))
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(markerPath(path, token));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 'ENOENT')
|
|
61
|
+
return;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Nothing names an owner. A live holder always has its marker, so this is a
|
|
67
|
+
// crash between mkdir and the marker write, or a lock left in some older
|
|
68
|
+
// shape; age is the only evidence, and an empty-guarded rmdir still cannot
|
|
69
|
+
// take a successor that has since claimed the name.
|
|
70
|
+
if (ageMs(path) <= staleMs)
|
|
71
|
+
return;
|
|
72
|
+
if (!isEmptyDirectory(path)) {
|
|
73
|
+
rmSync(path, { recursive: true, force: true });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
rmdirSync(path);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const code = error.code;
|
|
82
|
+
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function tryAcquire(path, staleMs) {
|
|
87
|
+
const token = `${process.pid}.${randomUUID()}`;
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(path, { mode: 0o700 });
|
|
90
|
+
writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
|
|
91
|
+
return { path, token };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code !== 'EEXIST')
|
|
95
|
+
throw error;
|
|
96
|
+
reclaimIfOwnerGone(path, staleMs);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run a short filesystem transition under a token-checked, crash-reclaimable
|
|
102
|
+
* directory lock. A holder that dies releases immediately — the next contender
|
|
103
|
+
* sees a dead pid in the marker name — while a live holder is never stolen from,
|
|
104
|
+
* however long its operation runs.
|
|
105
|
+
*/
|
|
106
|
+
export function withExclusiveDirectoryLock(path, operation, options = {}) {
|
|
107
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
108
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
109
|
+
let lock = tryAcquire(path, staleMs);
|
|
110
|
+
while (lock === null) {
|
|
111
|
+
if (Date.now() >= deadline) {
|
|
112
|
+
throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
pause(POLL_MS);
|
|
115
|
+
lock = tryAcquire(path, staleMs);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return operation();
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (existsSync(markerPath(lock.path, lock.token)))
|
|
122
|
+
rmSync(lock.path, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/core/human/claim.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { claimPath, deckPath, readJson, responsePath, reviewPath
|
|
4
|
+
import { claimPath, deckPath, readJson, responsePath, reviewPath } from './convention.js';
|
|
5
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
5
6
|
const REMOTE_STALE_MS = 30_000;
|
|
6
7
|
function parseClaim(raw) {
|
|
7
8
|
if (typeof raw !== 'object' || raw === null)
|
|
@@ -15,16 +15,6 @@ export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
|
15
15
|
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
16
16
|
export declare function publishJsonExclusive(path: string, value: unknown): boolean;
|
|
17
17
|
export declare function readJson<T>(path: string): T | null;
|
|
18
|
-
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
19
|
-
export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
|
|
20
|
-
staleMs?: number;
|
|
21
|
-
timeoutMs?: number;
|
|
22
|
-
}): T;
|
|
23
|
-
/** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
|
|
24
|
-
export declare function withExclusiveDirectoryLockAsync<T>(path: string, operation: () => Promise<T>, options?: {
|
|
25
|
-
staleMs?: number;
|
|
26
|
-
timeoutMs?: number;
|
|
27
|
-
}): Promise<T>;
|
|
28
18
|
export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
|
|
29
19
|
export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
|
|
30
20
|
export declare function clearProgress(dir: string): void;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, linkSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
3
|
import { buildSummary } from './summary.js';
|
|
5
4
|
export function deckPath(dir) { return `${dir}/deck.json`; }
|
|
6
5
|
export function reviewPath(dir) { return `${dir}/review.json`; }
|
|
@@ -62,127 +61,6 @@ export function readJson(path) {
|
|
|
62
61
|
return null;
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
|
-
function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
66
|
-
// The holder's identity lives in the NAME of its single marker file. The marker's
|
|
67
|
-
// mtime is the heartbeat and its presence is ownership, so the observed token, the
|
|
68
|
-
// staleness clock, and the reclaim gate are one atomic fact.
|
|
69
|
-
const MARKER_PREFIX = 'owner.';
|
|
70
|
-
function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
|
|
71
|
-
function observedMarkerToken(path) {
|
|
72
|
-
try {
|
|
73
|
-
return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function ownsLock(lock) { return existsSync(markerPath(lock.path, lock.token)); }
|
|
80
|
-
function releaseDirectoryLock(lock) { if (ownsLock(lock))
|
|
81
|
-
rmSync(lock.path, { recursive: true, force: true }); }
|
|
82
|
-
function lockAge(path, token) {
|
|
83
|
-
const target = token !== null ? markerPath(path, token) : path;
|
|
84
|
-
try {
|
|
85
|
-
return Date.now() - statSync(existsSync(target) ? target : path).mtimeMs;
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return 0;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// Reclamation is bound to the exact instance it observed: it unlinks ONLY that
|
|
92
|
-
// token's marker, then removes the now-empty directory with an empty-guarded
|
|
93
|
-
// rmdir. A successor lock holds a different random token (a different marker
|
|
94
|
-
// name), so a lagging reclaimer that saw the old stale lock can never strip a
|
|
95
|
-
// live successor — its unlink targets a name that no longer exists.
|
|
96
|
-
function reclaimIfStale(path, staleMs) {
|
|
97
|
-
const token = observedMarkerToken(path);
|
|
98
|
-
if (lockAge(path, token) <= staleMs)
|
|
99
|
-
return;
|
|
100
|
-
if (token !== null) {
|
|
101
|
-
try {
|
|
102
|
-
unlinkSync(markerPath(path, token));
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
if (error.code === 'ENOENT')
|
|
106
|
-
return;
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
rmdirSync(path);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
const code = error.code;
|
|
115
|
-
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
function tryAcquireDirectoryLock(path, staleMs) {
|
|
120
|
-
const token = randomUUID();
|
|
121
|
-
try {
|
|
122
|
-
mkdirSync(path, { mode: 0o700 });
|
|
123
|
-
writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
|
|
124
|
-
return { path, token };
|
|
125
|
-
}
|
|
126
|
-
catch (error) {
|
|
127
|
-
if (error.code !== 'EEXIST')
|
|
128
|
-
throw error;
|
|
129
|
-
reclaimIfStale(path, staleMs);
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
function acquireDirectoryLock(path, staleMs, timeoutMs) {
|
|
134
|
-
const startedAt = Date.now();
|
|
135
|
-
while (true) {
|
|
136
|
-
const lock = tryAcquireDirectoryLock(path, staleMs);
|
|
137
|
-
if (lock !== null)
|
|
138
|
-
return lock;
|
|
139
|
-
if (Date.now() - startedAt >= timeoutMs)
|
|
140
|
-
throw new Error('exclusive operation lock acquisition timed out');
|
|
141
|
-
pause(5);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
async function acquireDirectoryLockAsync(path, staleMs, timeoutMs) {
|
|
145
|
-
const startedAt = Date.now();
|
|
146
|
-
while (true) {
|
|
147
|
-
const lock = tryAcquireDirectoryLock(path, staleMs);
|
|
148
|
-
if (lock !== null)
|
|
149
|
-
return lock;
|
|
150
|
-
if (Date.now() - startedAt >= timeoutMs)
|
|
151
|
-
throw new Error('exclusive operation lock acquisition timed out');
|
|
152
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
156
|
-
export function withExclusiveDirectoryLock(path, operation, options = {}) {
|
|
157
|
-
const lock = acquireDirectoryLock(path, options.staleMs ?? 30_000, options.timeoutMs ?? 5_000);
|
|
158
|
-
try {
|
|
159
|
-
return operation();
|
|
160
|
-
}
|
|
161
|
-
finally {
|
|
162
|
-
releaseDirectoryLock(lock);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
/** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
|
|
166
|
-
export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
|
|
167
|
-
const staleMs = options.staleMs ?? 35_000;
|
|
168
|
-
const lock = await acquireDirectoryLockAsync(path, staleMs, options.timeoutMs ?? staleMs + 5_000);
|
|
169
|
-
const heartbeat = setInterval(() => {
|
|
170
|
-
if (ownsLock(lock)) {
|
|
171
|
-
try {
|
|
172
|
-
utimesSync(markerPath(lock.path, lock.token), new Date(), new Date());
|
|
173
|
-
}
|
|
174
|
-
catch { /* a reclaimed lock is no longer ours */ }
|
|
175
|
-
}
|
|
176
|
-
}, 1_000);
|
|
177
|
-
try {
|
|
178
|
-
return await operation();
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
clearInterval(heartbeat);
|
|
182
|
-
releaseDirectoryLock(lock);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// Kept for the existing panel until H2 routes all finalization through tickets.ts.
|
|
186
64
|
export function writeResponse(dir, responses, completedAt, deck) {
|
|
187
65
|
const summary = deck === undefined ? '' : buildSummary(deck, responses);
|
|
188
66
|
atomicWriteJson(responsePath(dir), { schema: 'humanloop.response/v2', kind: 'deck', responses, summary, completedAt });
|
package/dist/core/io.d.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { CrtrError } from './errors.js';
|
|
2
2
|
import { type ExitCodeValue } from '../types.js';
|
|
3
3
|
import { ApiError } from '../api/index.js';
|
|
4
|
+
/** The private result mirror for one crtr CLI invocation launched by the
|
|
5
|
+
* canvas bash-preview extension. This deliberately never enters stdout: bash
|
|
6
|
+
* text is model context, while Pi tool-result details are viewer-only. */
|
|
7
|
+
export interface CrtrPreviewRecord {
|
|
8
|
+
path: string;
|
|
9
|
+
result?: Record<string, unknown>;
|
|
10
|
+
jsonl?: Record<string, unknown>[];
|
|
11
|
+
error?: ErrorPayload;
|
|
12
|
+
}
|
|
13
|
+
/** Begin collecting the current leaf's structured response, if its caller
|
|
14
|
+
* supplied the private per-tool-call result path. */
|
|
15
|
+
export declare function beginPreview(path: string): void;
|
|
16
|
+
/** Publish the single-object or collected JSONL result after leaf dispatch. */
|
|
17
|
+
export declare function publishPreviewResult(result: Record<string, unknown> | void): void;
|
|
18
|
+
/** Mirror a structured record whose caller renders it itself instead of using
|
|
19
|
+
* the generic dispatcher (JSONL and event-stream leaves). */
|
|
20
|
+
export declare function recordPreviewLine(obj: Record<string, unknown>): void;
|
|
21
|
+
/** Preserve a verbatim JSONL stream's structured frames without changing the
|
|
22
|
+
* bytes written to stdout. Invalid third-party lines remain raw-only. */
|
|
23
|
+
export declare function recordPreviewJsonLine(line: string): void;
|
|
24
|
+
/** Record a terminal streaming failure that correctly remains off stdout
|
|
25
|
+
* because prior frames have already been delivered. */
|
|
26
|
+
export declare function recordPreviewError(payload: ErrorPayload): void;
|
|
4
27
|
/** Set by the dispatcher when `--json` is present anywhere in argv. */
|
|
5
28
|
export declare function setJsonOutput(v: boolean): void;
|
|
6
29
|
/** True when the caller asked for raw JSON instead of rendered prose. */
|
package/dist/core/io.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the model, not data it parses); structured errors; stderr is diagnostics only
|
|
4
4
|
// and never carries the result. The raw JSON object is available behind the
|
|
5
5
|
// `--json` global for tooling. See the cli-design reference.
|
|
6
|
+
import { renameSync, writeFileSync } from 'node:fs';
|
|
6
7
|
import { CrtrError } from './errors.js';
|
|
7
8
|
import { ExitCode } from '../types.js';
|
|
8
9
|
import { renderError } from './render.js';
|
|
@@ -11,6 +12,62 @@ import { ApiError } from '../api/index.js';
|
|
|
11
12
|
// output mode — prose (default) vs raw JSON (--json global, for tooling)
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
let jsonOutput = false;
|
|
15
|
+
let preview;
|
|
16
|
+
/** Begin collecting the current leaf's structured response, if its caller
|
|
17
|
+
* supplied the private per-tool-call result path. */
|
|
18
|
+
export function beginPreview(path) {
|
|
19
|
+
const resultPath = process.env['CRTR_PREVIEW_RESULT_PATH'];
|
|
20
|
+
preview = resultPath === undefined || resultPath === '' ? undefined : { path, jsonl: [] };
|
|
21
|
+
}
|
|
22
|
+
function publishPreview(record) {
|
|
23
|
+
const resultPath = process.env['CRTR_PREVIEW_RESULT_PATH'];
|
|
24
|
+
if (resultPath === undefined || resultPath === '')
|
|
25
|
+
return;
|
|
26
|
+
const tempPath = `${resultPath}.${process.pid}.tmp`;
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(tempPath, `${JSON.stringify({ path: preview?.path ?? '', ...record })}\n`, 'utf8');
|
|
29
|
+
renameSync(tempPath, resultPath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Preview transport is optional UI metadata. Its failure must never alter
|
|
33
|
+
// the CLI's stdout/error contract.
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(tempPath, '', 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch { /* nothing to clean */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Publish the single-object or collected JSONL result after leaf dispatch. */
|
|
41
|
+
export function publishPreviewResult(result) {
|
|
42
|
+
if (preview === undefined)
|
|
43
|
+
return;
|
|
44
|
+
if (result !== undefined && result !== null)
|
|
45
|
+
publishPreview({ result });
|
|
46
|
+
else if (preview.jsonl.length > 0 || preview.error !== undefined) {
|
|
47
|
+
publishPreview({ ...(preview.jsonl.length > 0 ? { jsonl: preview.jsonl } : {}), ...(preview.error === undefined ? {} : { error: preview.error }) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Mirror a structured record whose caller renders it itself instead of using
|
|
51
|
+
* the generic dispatcher (JSONL and event-stream leaves). */
|
|
52
|
+
export function recordPreviewLine(obj) {
|
|
53
|
+
preview?.jsonl.push(obj);
|
|
54
|
+
}
|
|
55
|
+
/** Preserve a verbatim JSONL stream's structured frames without changing the
|
|
56
|
+
* bytes written to stdout. Invalid third-party lines remain raw-only. */
|
|
57
|
+
export function recordPreviewJsonLine(line) {
|
|
58
|
+
try {
|
|
59
|
+
const value = JSON.parse(line);
|
|
60
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
61
|
+
recordPreviewLine(value);
|
|
62
|
+
}
|
|
63
|
+
catch { /* a malformed remote frame remains the command's raw truth */ }
|
|
64
|
+
}
|
|
65
|
+
/** Record a terminal streaming failure that correctly remains off stdout
|
|
66
|
+
* because prior frames have already been delivered. */
|
|
67
|
+
export function recordPreviewError(payload) {
|
|
68
|
+
if (preview !== undefined)
|
|
69
|
+
preview.error = payload;
|
|
70
|
+
}
|
|
14
71
|
/** Set by the dispatcher when `--json` is present anywhere in argv. */
|
|
15
72
|
export function setJsonOutput(v) {
|
|
16
73
|
jsonOutput = v;
|
|
@@ -88,6 +145,7 @@ export function emit(obj) {
|
|
|
88
145
|
}
|
|
89
146
|
/** One JSONL record. Call per event in a stream; partial reads stay parseable. */
|
|
90
147
|
export function emitLine(obj) {
|
|
148
|
+
recordPreviewLine(obj);
|
|
91
149
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
92
150
|
}
|
|
93
151
|
/**
|
|
@@ -234,6 +292,8 @@ export function handle(e) {
|
|
|
234
292
|
}
|
|
235
293
|
if (e instanceof CrtrError) {
|
|
236
294
|
const payload = payloadOf(e);
|
|
295
|
+
if (preview !== undefined)
|
|
296
|
+
publishPreview({ error: payload });
|
|
237
297
|
const out = jsonOutput
|
|
238
298
|
? JSON.stringify(payload, null, 2)
|
|
239
299
|
: renderError(payload);
|
|
@@ -23,8 +23,13 @@ export interface SummaryLine {
|
|
|
23
23
|
text: string;
|
|
24
24
|
tone?: SummaryTone;
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
export interface CrtrPreview {
|
|
27
|
+
path: string;
|
|
28
|
+
result?: Record<string, unknown>;
|
|
29
|
+
jsonl?: Record<string, unknown>[];
|
|
30
|
+
error?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
/** Summarize an exact CLI result record. The recorded path is authoritative:
|
|
33
|
+
* shell tokenization is used only for an invocation's static icon/help chrome,
|
|
34
|
+
* never to identify or decode the returned leaf data. */
|
|
35
|
+
export declare function summarizePath(preview: CrtrPreview, tokens?: readonly string[]): SummaryLine[] | null;
|