@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.
Files changed (64) hide show
  1. package/dist/api/dto/nodes.d.ts +7 -1
  2. package/dist/api/dto/profiles.d.ts +3 -0
  3. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  4. package/dist/clients/attach/render/crtr-output.js +25 -13
  5. package/dist/clients/attach/viewer.js +531 -561
  6. package/dist/commands/memory/write.js +13 -12
  7. package/dist/commands/node/create.js +7 -3
  8. package/dist/commands/profile/list.js +3 -1
  9. package/dist/commands/profile/new.js +4 -1
  10. package/dist/commands/profile/project.js +23 -9
  11. package/dist/commands/profile/show.js +3 -1
  12. package/dist/commands/profile.js +2 -2
  13. package/dist/commands/sys/logs.js +4 -2
  14. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  15. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  16. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  17. package/dist/core/canvas/extensions.d.ts +1 -1
  18. package/dist/core/canvas/extensions.js +7 -1
  19. package/dist/core/command-plugins/transport/http-invoke.js +10 -2
  20. package/dist/core/command.js +3 -1
  21. package/dist/core/config.js +4 -27
  22. package/dist/core/exclusive-lock.d.ts +14 -0
  23. package/dist/core/exclusive-lock.js +124 -0
  24. package/dist/core/human/claim.js +2 -1
  25. package/dist/core/human/convention.d.ts +0 -10
  26. package/dist/core/human/convention.js +1 -123
  27. package/dist/core/io.d.ts +23 -0
  28. package/dist/core/io.js +60 -0
  29. package/dist/core/preview-registry.d.ts +10 -5
  30. package/dist/core/preview-registry.js +136 -2537
  31. package/dist/core/profiles/manifest.d.ts +12 -1
  32. package/dist/core/profiles/manifest.js +57 -48
  33. package/dist/core/review/store.d.ts +4 -1
  34. package/dist/core/review/store.js +10 -0
  35. package/dist/core/runtime/branded-host.js +6 -84
  36. package/dist/core/runtime/broker-extension-render.js +1 -1
  37. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  38. package/dist/core/runtime/canvas-extensions.js +2 -0
  39. package/dist/core/runtime/tmux-bindings.js +1 -1
  40. package/dist/core/runtime/warm-pool.d.ts +9 -8
  41. package/dist/core/runtime/warm-pool.js +58 -62
  42. package/dist/core/substrate/index.d.ts +2 -1
  43. package/dist/core/substrate/index.js +2 -1
  44. package/dist/core/substrate/on-read-node.d.ts +7 -0
  45. package/dist/core/substrate/on-read-node.js +37 -0
  46. package/dist/core/substrate/on-read.d.ts +7 -5
  47. package/dist/core/substrate/on-read.js +15 -23
  48. package/dist/core/termrender/termrender.js +16 -96
  49. package/dist/core/worktree.js +11 -100
  50. package/dist/daemon/api/handlers/nodes.js +32 -9
  51. package/dist/daemon/api/handlers/review-comments.js +5 -44
  52. package/dist/daemon/api/handlers/reviews.js +5 -53
  53. package/dist/daemon/api/handlers/validate.d.ts +22 -0
  54. package/dist/daemon/api/handlers/validate.js +59 -0
  55. package/dist/daemon/api/map.js +1 -0
  56. package/dist/daemon/review/finish.js +1 -10
  57. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  58. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  59. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  60. package/dist/types.d.ts +4 -0
  61. package/package.json +1 -1
  62. package/runtime.lock.json +2 -2
  63. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  64. 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
+ }
@@ -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, withExclusiveDirectoryLock } from './convention.js';
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, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
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
- /** Dispatch a command's argv tokens + its raw result text to the summarizer
27
- * that owns that leaf path (longest-registered-prefix match, same shape as
28
- * suppressOutputForPath), or null when no command owns a summary — the
29
- * caller falls through to generic truncation in that case. */
30
- export declare function summarizePath(tokens: readonly string[], rawText: string): SummaryLine[] | null;
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;