@crouter/api 0.3.387 → 0.3.389

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 (131) hide show
  1. package/dist/api/__tests__/integration/client.test.js +97 -0
  2. package/dist/api/client.d.ts +7 -0
  3. package/dist/api/client.js +40 -21
  4. package/dist/api/dto/config.d.ts +11 -1
  5. package/dist/core/asset-root.d.ts +7 -0
  6. package/dist/core/asset-root.js +18 -0
  7. package/dist/core/canvas/boot-id.d.ts +6 -0
  8. package/dist/core/canvas/boot-id.js +26 -0
  9. package/dist/core/canvas/paths.d.ts +72 -0
  10. package/dist/core/canvas/paths.js +163 -0
  11. package/dist/core/canvas/pid.d.ts +391 -0
  12. package/dist/core/canvas/pid.js +948 -0
  13. package/dist/core/command-plugins/bundle.d.ts +149 -0
  14. package/dist/core/command-plugins/bundle.js +588 -0
  15. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  16. package/dist/core/command-plugins/endpoint.js +51 -0
  17. package/dist/core/config.d.ts +233 -0
  18. package/dist/core/config.js +1120 -0
  19. package/dist/core/env-name.d.ts +6 -0
  20. package/dist/core/env-name.js +9 -0
  21. package/dist/core/errors.d.ts +38 -0
  22. package/dist/core/errors.js +90 -0
  23. package/dist/core/events/emit.d.ts +6 -0
  24. package/dist/core/events/emit.js +42 -0
  25. package/dist/core/events/envelope.d.ts +2 -0
  26. package/dist/core/events/envelope.js +84 -0
  27. package/dist/core/events/errors.d.ts +4 -0
  28. package/dist/core/events/errors.js +69 -0
  29. package/dist/core/events/operation-id.d.ts +4 -0
  30. package/dist/core/events/operation-id.js +24 -0
  31. package/dist/core/events/serialize.d.ts +4 -0
  32. package/dist/core/events/serialize.js +199 -0
  33. package/dist/core/events/source.d.ts +16 -0
  34. package/dist/core/events/source.js +31 -0
  35. package/dist/core/events/types.d.ts +68 -0
  36. package/dist/core/events/types.js +11 -0
  37. package/dist/core/exclusive-lock.d.ts +34 -0
  38. package/dist/core/exclusive-lock.js +197 -0
  39. package/dist/core/fs-utils.d.ts +44 -0
  40. package/dist/core/fs-utils.js +208 -0
  41. package/dist/core/help.d.ts +309 -0
  42. package/dist/core/help.js +406 -0
  43. package/dist/core/human/page-catalog.d.ts +57 -0
  44. package/dist/core/human/page-catalog.js +172 -0
  45. package/dist/core/installed-plugins.d.ts +2 -0
  46. package/dist/core/installed-plugins.js +79 -0
  47. package/dist/core/io.d.ts +122 -0
  48. package/dist/core/io.js +373 -0
  49. package/dist/core/keybindings/attach-control.d.ts +49 -0
  50. package/dist/core/keybindings/attach-control.js +42 -0
  51. package/dist/core/keybindings/catalog.d.ts +18 -0
  52. package/dist/core/keybindings/catalog.js +257 -0
  53. package/dist/core/keybindings/types.d.ts +42 -0
  54. package/dist/core/keybindings/types.js +1 -0
  55. package/dist/core/layout.d.ts +26 -0
  56. package/dist/core/layout.js +94 -0
  57. package/dist/core/locked-file.d.ts +27 -0
  58. package/dist/core/locked-file.js +118 -0
  59. package/dist/core/log.d.ts +9 -0
  60. package/dist/core/log.js +89 -0
  61. package/dist/core/manifest.d.ts +5 -0
  62. package/dist/core/manifest.js +15 -0
  63. package/dist/core/plugin-env.d.ts +8 -0
  64. package/dist/core/plugin-env.js +31 -0
  65. package/dist/core/plugin-extensions.d.ts +29 -0
  66. package/dist/core/plugin-extensions.js +191 -0
  67. package/dist/core/plugin-swap-lock.d.ts +9 -0
  68. package/dist/core/plugin-swap-lock.js +31 -0
  69. package/dist/core/preview-result-path.d.ts +4 -0
  70. package/dist/core/preview-result-path.js +26 -0
  71. package/dist/core/profiles/env-store.d.ts +22 -0
  72. package/dist/core/profiles/env-store.js +163 -0
  73. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  74. package/dist/core/profiles/fuzzy-match.js +92 -0
  75. package/dist/core/profiles/manifest.d.ts +120 -0
  76. package/dist/core/profiles/manifest.js +529 -0
  77. package/dist/core/rate-limit-scope.d.ts +25 -0
  78. package/dist/core/rate-limit-scope.js +64 -0
  79. package/dist/core/render.d.ts +12 -0
  80. package/dist/core/render.js +138 -0
  81. package/dist/core/resolver.d.ts +14 -0
  82. package/dist/core/resolver.js +111 -0
  83. package/dist/core/runtime/branded-host.d.ts +25 -0
  84. package/dist/core/runtime/branded-host.js +264 -0
  85. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  86. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  87. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  88. package/dist/core/runtime/broker/signal-stream.js +149 -0
  89. package/dist/core/scope.d.ts +32 -0
  90. package/dist/core/scope.js +184 -0
  91. package/dist/core/scoped-state/db.d.ts +17 -0
  92. package/dist/core/scoped-state/db.js +247 -0
  93. package/dist/core/scoped-state/migrate.d.ts +8 -0
  94. package/dist/core/scoped-state/migrate.js +187 -0
  95. package/dist/core/scoped-state/paths.d.ts +9 -0
  96. package/dist/core/scoped-state/paths.js +27 -0
  97. package/dist/core/scoped-state/profiles.d.ts +27 -0
  98. package/dist/core/scoped-state/profiles.js +93 -0
  99. package/dist/core/scoped-state/providers.d.ts +24 -0
  100. package/dist/core/scoped-state/providers.js +19 -0
  101. package/dist/core/scoped-state/schema.d.ts +6 -0
  102. package/dist/core/scoped-state/schema.js +43 -0
  103. package/dist/core/scoped-state/settings.d.ts +28 -0
  104. package/dist/core/scoped-state/settings.js +83 -0
  105. package/dist/core/spaces/open-beneath.d.ts +71 -0
  106. package/dist/core/spaces/open-beneath.js +581 -0
  107. package/dist/core/sqlite-statements.d.ts +4 -0
  108. package/dist/core/sqlite-statements.js +17 -0
  109. package/dist/core/subscription-state.d.ts +121 -0
  110. package/dist/core/subscription-state.js +287 -0
  111. package/dist/core/user-settings.d.ts +377 -0
  112. package/dist/core/user-settings.js +458 -0
  113. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  114. package/dist/daemon/broker-signals/bus.js +87 -0
  115. package/dist/daemon/manage.d.ts +176 -0
  116. package/dist/daemon/manage.js +664 -0
  117. package/dist/daemon/pidfile.d.ts +8 -0
  118. package/dist/daemon/pidfile.js +37 -0
  119. package/dist/daemon/startup-policy.d.ts +1 -0
  120. package/dist/daemon/startup-policy.js +1 -0
  121. package/dist/native/linux.d.ts +29 -0
  122. package/dist/native/linux.js +20 -0
  123. package/dist/shared/env.d.ts +116 -0
  124. package/dist/shared/env.js +271 -0
  125. package/dist/shared/inbox-entry-body.d.ts +22 -0
  126. package/dist/shared/inbox-entry-body.js +116 -0
  127. package/dist/shared/working-activity.d.ts +9 -0
  128. package/dist/shared/working-activity.js +27 -0
  129. package/dist/types.d.ts +562 -0
  130. package/dist/types.js +186 -0
  131. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ // installed-plugins.ts — the LEAF-LEVEL installed-plugin reader: list the
2
+ // plugin packages physically present under one scope root, with their
3
+ // manifests and enabled-state from that root's config.json `plugins` block.
4
+ //
5
+ // This lives below resolver.ts (which re-exports it for its existing callers)
6
+ // so that config.ts can layer plugin-declared launch config (`kinds`) into
7
+ // `readMergedLaunchConfig` without an import cycle: resolver.ts imports
8
+ // config.ts, so config.ts can never import resolver.ts. Imports here stay
9
+ // leaf-safe: types, fs-utils, manifest only.
10
+ import { join } from 'node:path';
11
+ import { CONFIG_FILE } from '../types.js';
12
+ import { listDirs, pathExists, readJsonIfExists } from './fs-utils.js';
13
+ import { readPluginManifest } from './manifest.js';
14
+ import { awaitBundleSwap, bundleSwapInFlightElsewhere } from './plugin-swap-lock.js';
15
+ import { userStore } from './scoped-state/paths.js';
16
+ import { readRawSettings } from './scoped-state/settings.js';
17
+ import { pluginsDir } from './scope.js';
18
+ function pluginConfigForRoot(scope, root) {
19
+ const store = scope === 'user' ? userStore(root) : undefined;
20
+ const cfg = store
21
+ ? readRawSettings({ store, key: 'user', kind: 'user', root: store.root })
22
+ : readJsonIfExists(join(root, CONFIG_FILE));
23
+ return cfg && cfg.plugins && typeof cfg.plugins === 'object' ? cfg.plugins : {};
24
+ }
25
+ /**
26
+ * A bundle-plugin swap replaces `<plugins>/<name>` with two renames, so there
27
+ * is a window in which the config names a plugin whose directory is not there.
28
+ * A list taken inside that window would drop a plugin that is installed and
29
+ * about to be present again — the caller would build a command tree missing it.
30
+ *
31
+ * That exact signal — named in config, absent on disk — is what we re-check,
32
+ * and only when another process's swap lock says a swap is actually running: a
33
+ * genuinely deleted directory holds no lock and is reported as gone at once.
34
+ * Every normal invocation, where each config-named plugin has its directory,
35
+ * pays one Set lookup per name and nothing else.
36
+ */
37
+ function swapsToWaitOut(scopeRootPath, cfg, present) {
38
+ const names = Object.keys(cfg);
39
+ if (names.every((name) => present.has(name)))
40
+ return [];
41
+ return names.filter((name) => !present.has(name) && bundleSwapInFlightElsewhere(scopeRootPath, name));
42
+ }
43
+ export function listInstalledPluginsInRoot(scope, scopeRootPath) {
44
+ const dir = scope === 'user' ? pluginsDir('user') : join(scopeRootPath, 'plugins');
45
+ if (!pathExists(dir))
46
+ return [];
47
+ const cfg = pluginConfigForRoot(scope, scopeRootPath);
48
+ const first = collectInstalledPlugins(scope, dir, cfg);
49
+ const waiting = swapsToWaitOut(scopeRootPath, cfg, new Set(first.map((plugin) => plugin.name)));
50
+ if (waiting.length === 0)
51
+ return first;
52
+ for (const name of waiting)
53
+ awaitBundleSwap(scopeRootPath, name);
54
+ // One re-list, never a loop: the second read is taken after every swap we
55
+ // observed has finished. A swap that starts after it is a package this
56
+ // invocation was never going to see anyway.
57
+ return collectInstalledPlugins(scope, dir, cfg);
58
+ }
59
+ function collectInstalledPlugins(scope, dir, cfg) {
60
+ const out = [];
61
+ for (const name of listDirs(dir)) {
62
+ const root = join(dir, name);
63
+ const manifest = readPluginManifest(root);
64
+ if (!manifest)
65
+ continue;
66
+ const entry = cfg[name];
67
+ const version = typeof entry?.version === 'string' ? entry.version : manifest.version;
68
+ out.push({
69
+ name,
70
+ scope,
71
+ root,
72
+ manifest,
73
+ enabled: typeof entry?.enabled === 'boolean' ? entry.enabled : true,
74
+ sourceMarketplace: typeof entry?.source_marketplace === 'string' ? entry.source_marketplace : undefined,
75
+ version,
76
+ });
77
+ }
78
+ return out;
79
+ }
@@ -0,0 +1,122 @@
1
+ import { CrtrError } from './errors.js';
2
+ import { type ExitCodeValue } from '../types.js';
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
+ *
8
+ * One bash tool call may run several crtr invocations (`read a && read b`), so
9
+ * the mirror file is JSONL: one record per invocation, appended in order. Each
10
+ * invocation is its own process and `beginPreview` resets this module's state,
11
+ * so the appends compose across them with no shared state or locking. */
12
+ export interface CrtrPreviewRecord {
13
+ path: string;
14
+ result?: Record<string, unknown>;
15
+ jsonl?: Record<string, unknown>[];
16
+ error?: ErrorPayload;
17
+ }
18
+ /** A preview is bounded UI metadata, never a bulk-data channel. One
19
+ * `crtr integration run` once mirrored a 3 MB CRM dump into every copy of the
20
+ * transcript that contains its tool result — the session file, every replay
21
+ * frame, and the host product's stored conversation — because pi's 50 KB
22
+ * stdout truncation never sees this channel. The mirror write is the one
23
+ * choke point every record passes, so the bound lives here: an oversized
24
+ * record is dropped, and the viewer falls back to rendering the agent-facing
25
+ * stdout text, which pi bounds itself.
26
+ *
27
+ * Because records accumulate, this bounds the FILE TOTAL, not one line: a
28
+ * record that would overrun the remaining budget is dropped whole, so every
29
+ * line on disk stays valid JSON. */
30
+ export declare const PREVIEW_RESULT_MAX_BYTES: number;
31
+ /** Begin collecting the current leaf's structured response, if its caller
32
+ * supplied the private per-tool-call result path. */
33
+ export declare function beginPreview(path: string): void;
34
+ /** Publish the single-object or collected JSONL result after leaf dispatch. */
35
+ export declare function publishPreviewResult(result: Record<string, unknown> | void): void;
36
+ /** Mirror a structured record whose caller renders it itself instead of using
37
+ * the generic dispatcher (JSONL and event-stream leaves). */
38
+ export declare function recordPreviewLine(obj: Record<string, unknown>): void;
39
+ /** Preserve a verbatim JSONL stream's structured frames without changing the
40
+ * bytes written to stdout. Invalid third-party lines remain raw-only. */
41
+ export declare function recordPreviewJsonLine(line: string): void;
42
+ /** Record a terminal streaming failure that correctly remains off stdout
43
+ * because prior frames have already been delivered. */
44
+ export declare function recordPreviewError(payload: ErrorPayload): void;
45
+ /** Set by the dispatcher when `--json` is present anywhere in argv. */
46
+ export declare function setJsonOutput(v: boolean): void;
47
+ /** True when the caller asked for raw JSON instead of rendered prose. */
48
+ export declare function isJsonOutput(): boolean;
49
+ /** Structured error payload. `error` is a stable code the agent branches on;
50
+ * `next` is the recovery road sign. */
51
+ interface ErrorPayloadBase {
52
+ error: string;
53
+ message: string;
54
+ received?: unknown;
55
+ field?: string;
56
+ /** Transport status of a failed backend call (HTTP-transport plugins). It is kept out
57
+ * of `received` on purpose: `received` names the caller's offending VALUE, and
58
+ * reporting an HTTP status there is a false claim about what was sent. */
59
+ http_status?: number;
60
+ next: string;
61
+ }
62
+ export type ErrorPayload = ErrorPayloadBase & ({
63
+ primaryCompleted: true;
64
+ result: Record<string, unknown>;
65
+ } | {
66
+ primaryCompleted?: never;
67
+ result?: never;
68
+ });
69
+ /** A command-level failure: surfaces as the JSON response on stdout. */
70
+ export declare class InputError extends CrtrError {
71
+ payload: ErrorPayload;
72
+ constructor(payload: ErrorPayload, exitCode?: ExitCodeValue);
73
+ }
74
+ /** Read raw stdin to EOF. Returns empty string when stdin is a TTY (no pipe).
75
+ * Called by the argv parser for leaves declaring a `stdin` parameter. */
76
+ export declare function readStdinRaw(): Promise<string>;
77
+ /** Best-effort, non-hanging peek at whatever stdin bytes are already
78
+ * buffered — used ONLY to detect a positional/stdin conflict, never to
79
+ * consume the leaf's actual required stdin body. Genuine piped/redirected
80
+ * content (a heredoc, `< file`, `cat x |`) is fully written into the pipe
81
+ * before this process starts reading, so it resolves within one tick; a
82
+ * non-TTY stdin that's simply inherited and held open with nothing written
83
+ * to it (e.g. a caller subprocess that never closes its own stdin) would
84
+ * otherwise hang `readStdinRaw()` forever — this bounds that wait instead
85
+ * of blocking the whole invocation on an ambiguous pipe. Returns '' on a
86
+ * TTY (nothing can be piped) or once the bound elapses with no bytes. */
87
+ export declare function peekStdinRaw(timeoutMs?: number): Promise<string>;
88
+ /** Raw-JSON mirror of a single-shot response (the `--json` escape hatch). The
89
+ * default path renders the result as prose instead — see render.ts. */
90
+ export declare function emit(obj: Record<string, unknown>): void;
91
+ /** One JSONL record. Call per event in a stream; partial reads stay parseable. */
92
+ export declare function emitLine(obj: Record<string, unknown>): void;
93
+ /**
94
+ * Write to stdout and resolve true ONLY once the bytes are confirmed flushed to
95
+ * a connected reader. Resolves false if the consumer is gone (EPIPE) or the
96
+ * write fails. This is the reliable "the caller actually received it" signal:
97
+ * use it to gate side effects that must only happen on genuine delivery (e.g.
98
+ * acking a collected result). A killed process never resolves at all — also
99
+ * safe, since the gated side effect then never runs.
100
+ */
101
+ export declare function writeStdout(s: string): Promise<boolean>;
102
+ export declare function diag(message: string): void;
103
+ /** Translate an `ApiError` into the clean CLI error a leaf should surface.
104
+ * Three tiers, in priority order:
105
+ * 1. Daemon-down (`daemon_unavailable`) → a `network`-class `CrtrError`
106
+ * so the exit code and next-hint match the operational failure it is.
107
+ * 2. A server-thrown `InputError` round-trips its FULL structured payload
108
+ * through `toErrorBody` into `ApiError.details` (`{error,message,next,…}`) —
109
+ * reconstruct it verbatim so a gate/guard error (revive reopen-gate, push
110
+ * worktree/finalization guard, …) surfaces byte-for-byte as the leaf raised
111
+ * it before the re-plumb, preserving its SPECIFIC code + next.
112
+ * 3. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
113
+ * and a caller-supplied-or-default `next`. */
114
+ export declare function apiErrorToCliError(err: ApiError, next?: string): CrtrError;
115
+ /** Terminal error handler. Command-level failures (bad input, not-found,
116
+ * ambiguous) surface as the JSON response on stdout so the caller parses one
117
+ * contract. A daemon `ApiError` is translated to that same clean contract
118
+ * (`apiErrorToCliError`) as a safety net for any leaf that lets it propagate.
119
+ * Runtime/internal failures go to stderr as `{error:"internal"}` — raw traces
120
+ * never reach the agent. Exits non-zero either way. */
121
+ export declare function handle(e: unknown): void;
122
+ export {};
@@ -0,0 +1,373 @@
1
+ // The agent-facing I/O contract. Flags and positional args on input; stdout is
2
+ // agent-ready markdown/XML the caller acts on directly (the result rendered FOR
3
+ // the model, not data it parses); structured errors; stderr is diagnostics only
4
+ // and never carries the result. The raw JSON object is available behind the
5
+ // `--json` global for tooling. See the cli-design reference.
6
+ import { appendFileSync, statSync } from 'node:fs';
7
+ import { CrtrError } from './errors.js';
8
+ import { ExitCode } from '../types.js';
9
+ import { PREVIEW_RESULT_PATH_ENV } from './preview-result-path.js';
10
+ import { renderError } from './render.js';
11
+ import { ApiError } from '../api/index.js';
12
+ // output mode — prose (default) vs raw JSON (--json global, for tooling)
13
+ let jsonOutput = false;
14
+ let preview;
15
+ /** A preview is bounded UI metadata, never a bulk-data channel. One
16
+ * `crtr integration run` once mirrored a 3 MB CRM dump into every copy of the
17
+ * transcript that contains its tool result — the session file, every replay
18
+ * frame, and the host product's stored conversation — because pi's 50 KB
19
+ * stdout truncation never sees this channel. The mirror write is the one
20
+ * choke point every record passes, so the bound lives here: an oversized
21
+ * record is dropped, and the viewer falls back to rendering the agent-facing
22
+ * stdout text, which pi bounds itself.
23
+ *
24
+ * Because records accumulate, this bounds the FILE TOTAL, not one line: a
25
+ * record that would overrun the remaining budget is dropped whole, so every
26
+ * line on disk stays valid JSON. */
27
+ export const PREVIEW_RESULT_MAX_BYTES = 256 * 1024;
28
+ /** Begin collecting the current leaf's structured response, if its caller
29
+ * supplied the private per-tool-call result path. */
30
+ export function beginPreview(path) {
31
+ const resultPath = process.env[PREVIEW_RESULT_PATH_ENV];
32
+ preview = resultPath === undefined || resultPath === '' ? undefined : { path, jsonl: [] };
33
+ }
34
+ function publishPreview(record) {
35
+ const resultPath = process.env[PREVIEW_RESULT_PATH_ENV];
36
+ if (resultPath === undefined || resultPath === '')
37
+ return;
38
+ const line = `${JSON.stringify({ path: preview?.path ?? '', ...record })}\n`;
39
+ try {
40
+ const spent = statSync(resultPath, { throwIfNoEntry: false })?.size ?? 0;
41
+ if (spent + Buffer.byteLength(line, 'utf8') > PREVIEW_RESULT_MAX_BYTES)
42
+ return;
43
+ appendFileSync(resultPath, line, 'utf8');
44
+ }
45
+ catch {
46
+ // Preview transport is optional UI metadata. Its failure must never alter
47
+ // the CLI's stdout/error contract.
48
+ }
49
+ }
50
+ /** Publish the single-object or collected JSONL result after leaf dispatch. */
51
+ export function publishPreviewResult(result) {
52
+ if (preview === undefined)
53
+ return;
54
+ if (result !== undefined && result !== null)
55
+ publishPreview({ result });
56
+ else if (preview.jsonl.length > 0 || preview.error !== undefined) {
57
+ publishPreview({ ...(preview.jsonl.length > 0 ? { jsonl: preview.jsonl } : {}), ...(preview.error === undefined ? {} : { error: preview.error }) });
58
+ }
59
+ }
60
+ /** Mirror a structured record whose caller renders it itself instead of using
61
+ * the generic dispatcher (JSONL and event-stream leaves). */
62
+ export function recordPreviewLine(obj) {
63
+ preview?.jsonl.push(obj);
64
+ }
65
+ /** Preserve a verbatim JSONL stream's structured frames without changing the
66
+ * bytes written to stdout. Invalid third-party lines remain raw-only. */
67
+ export function recordPreviewJsonLine(line) {
68
+ try {
69
+ const value = JSON.parse(line);
70
+ if (value !== null && typeof value === 'object' && !Array.isArray(value))
71
+ recordPreviewLine(value);
72
+ }
73
+ catch { /* a malformed remote frame remains the command's raw truth */ }
74
+ }
75
+ /** Record a terminal streaming failure that correctly remains off stdout
76
+ * because prior frames have already been delivered. */
77
+ export function recordPreviewError(payload) {
78
+ if (preview !== undefined)
79
+ preview.error = payload;
80
+ }
81
+ /** Set by the dispatcher when `--json` is present anywhere in argv. */
82
+ export function setJsonOutput(v) {
83
+ jsonOutput = v;
84
+ }
85
+ /** True when the caller asked for raw JSON instead of rendered prose. */
86
+ export function isJsonOutput() {
87
+ return jsonOutput;
88
+ }
89
+ /** A command-level failure: surfaces as the JSON response on stdout. */
90
+ export class InputError extends CrtrError {
91
+ payload;
92
+ constructor(payload, exitCode = ExitCode.USAGE) {
93
+ super(payload.error, payload.message, exitCode, { ...payload });
94
+ this.name = 'InputError';
95
+ this.payload = payload;
96
+ }
97
+ }
98
+ // stdin
99
+ /** Read raw stdin to EOF. Returns empty string when stdin is a TTY (no pipe).
100
+ * Called by the argv parser for leaves declaring a `stdin` parameter. */
101
+ export async function readStdinRaw() {
102
+ if (process.stdin.isTTY)
103
+ return '';
104
+ const chunks = [];
105
+ for await (const chunk of process.stdin)
106
+ chunks.push(chunk);
107
+ return Buffer.concat(chunks).toString('utf8');
108
+ }
109
+ /** Best-effort, non-hanging peek at whatever stdin bytes are already
110
+ * buffered — used ONLY to detect a positional/stdin conflict, never to
111
+ * consume the leaf's actual required stdin body. Genuine piped/redirected
112
+ * content (a heredoc, `< file`, `cat x |`) is fully written into the pipe
113
+ * before this process starts reading, so it resolves within one tick; a
114
+ * non-TTY stdin that's simply inherited and held open with nothing written
115
+ * to it (e.g. a caller subprocess that never closes its own stdin) would
116
+ * otherwise hang `readStdinRaw()` forever — this bounds that wait instead
117
+ * of blocking the whole invocation on an ambiguous pipe. Returns '' on a
118
+ * TTY (nothing can be piped) or once the bound elapses with no bytes. */
119
+ export async function peekStdinRaw(timeoutMs = 200) {
120
+ if (process.stdin.isTTY)
121
+ return '';
122
+ return await new Promise((resolve) => {
123
+ const chunks = [];
124
+ let settled = false;
125
+ const finish = () => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ process.stdin.removeListener('data', onData);
131
+ process.stdin.removeListener('end', onEnd);
132
+ process.stdin.removeListener('error', onError);
133
+ process.stdin.pause();
134
+ resolve(Buffer.concat(chunks).toString('utf8'));
135
+ };
136
+ const onData = (chunk) => chunks.push(chunk);
137
+ const onEnd = () => finish();
138
+ const onError = () => finish();
139
+ const timer = setTimeout(finish, timeoutMs);
140
+ process.stdin.on('data', onData);
141
+ process.stdin.on('end', onEnd);
142
+ process.stdin.on('error', onError);
143
+ process.stdin.resume();
144
+ });
145
+ }
146
+ // stdout — the result, nothing else
147
+ /** Raw-JSON mirror of a single-shot response (the `--json` escape hatch). The
148
+ * default path renders the result as prose instead — see render.ts. */
149
+ export function emit(obj) {
150
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
151
+ }
152
+ /** One JSONL record. Call per event in a stream; partial reads stay parseable. */
153
+ export function emitLine(obj) {
154
+ recordPreviewLine(obj);
155
+ process.stdout.write(JSON.stringify(obj) + '\n');
156
+ }
157
+ /**
158
+ * Write to stdout and resolve true ONLY once the bytes are confirmed flushed to
159
+ * a connected reader. Resolves false if the consumer is gone (EPIPE) or the
160
+ * write fails. This is the reliable "the caller actually received it" signal:
161
+ * use it to gate side effects that must only happen on genuine delivery (e.g.
162
+ * acking a collected result). A killed process never resolves at all — also
163
+ * safe, since the gated side effect then never runs.
164
+ */
165
+ export function writeStdout(s) {
166
+ return new Promise((resolve) => {
167
+ let settled = false;
168
+ const onErr = (e) => {
169
+ if (e.code === 'EPIPE')
170
+ finish(false);
171
+ };
172
+ const finish = (ok) => {
173
+ if (settled)
174
+ return;
175
+ settled = true;
176
+ process.stdout.off('error', onErr);
177
+ resolve(ok);
178
+ };
179
+ process.stdout.on('error', onErr);
180
+ try {
181
+ process.stdout.write(s, (err) => finish(err === null || err === undefined));
182
+ }
183
+ catch {
184
+ finish(false);
185
+ }
186
+ });
187
+ }
188
+ // stderr — diagnostics the agent MAY capture, never the result
189
+ export function diag(message) {
190
+ process.stderr.write(message + '\n');
191
+ }
192
+ // errors
193
+ function payloadOf(e) {
194
+ if (e instanceof InputError)
195
+ return e.payload;
196
+ const d = (e.details !== undefined ? e.details : {});
197
+ const result = d.primaryCompleted === true
198
+ && d.result !== null
199
+ && typeof d.result === 'object'
200
+ && !Array.isArray(d.result)
201
+ ? jsonSafeResult(d.result)
202
+ : undefined;
203
+ const next = d.next !== undefined
204
+ ? d.next
205
+ : 'Inspect the error and adjust the call. See -h for the schema.';
206
+ const base = {
207
+ error: e.code,
208
+ message: e.message,
209
+ received: d.received,
210
+ field: d.field,
211
+ ...(typeof d.http_status === 'number' ? { http_status: d.http_status } : {}),
212
+ };
213
+ return result === undefined
214
+ ? { ...base, next }
215
+ : { ...base, primaryCompleted: true, result, next };
216
+ }
217
+ /** Detach completed results from getters/prototypes and keep every error sink
218
+ * JSON-safe. Tagged values preserve a truthful representation when a trusted
219
+ * primary violates its declared JSON result contract. */
220
+ function jsonSafeResult(result) {
221
+ const seen = new WeakSet();
222
+ try {
223
+ const encoded = JSON.stringify(result, (_key, value) => {
224
+ if (typeof value === 'bigint')
225
+ return { $crtrType: 'bigint', value: value.toString() };
226
+ if (typeof value === 'number' && !Number.isFinite(value))
227
+ return { $crtrType: 'number', value: String(value) };
228
+ if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') {
229
+ return { $crtrType: typeof value };
230
+ }
231
+ if (value !== null && typeof value === 'object') {
232
+ if (seen.has(value))
233
+ return { $crtrType: 'circular' };
234
+ seen.add(value);
235
+ }
236
+ return value;
237
+ });
238
+ if (encoded !== undefined) {
239
+ const decoded = JSON.parse(encoded);
240
+ if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
241
+ return decoded;
242
+ }
243
+ }
244
+ }
245
+ catch { /* a throwing getter/toJSON must not suppress the partial failure */ }
246
+ return {
247
+ $crtrType: 'unavailable',
248
+ message: 'The completed primary result could not be represented as JSON.',
249
+ };
250
+ }
251
+ // ApiError → clean CLI error — the ONE shared translation (plan C-5)
252
+ //
253
+ // A daemon `ApiError` is NOT a `CrtrError`, so left unhandled it falls through
254
+ // `handle()` as an "internal crtr bug" — the wrong UX for an operational
255
+ // failure. This is the single place that maps it back to the clean, structured
256
+ // error the pre-API leaves surfaced, so every re-plumbed verb shares one
257
+ // contract instead of re-implementing the mapping per command. io.ts importing
258
+ // `ApiError` from `src/api` is canvas-clean (src/api is Node-built-ins only).
259
+ /** Map a daemon HTTP status to the stable CLI error code the pre-API leaves
260
+ * used, so agent-facing error contracts survive the re-plumb. Used only as the
261
+ * fallback when the server did NOT round-trip a full structured payload. */
262
+ const STATUS_TO_CLI_CODE = {
263
+ 400: 'invalid_request',
264
+ 404: 'not_found',
265
+ 409: 'conflict',
266
+ 422: 'unprocessable',
267
+ };
268
+ /** Translate an `ApiError` into the clean CLI error a leaf should surface.
269
+ * Three tiers, in priority order:
270
+ * 1. Daemon-down (`daemon_unavailable`) → a `network`-class `CrtrError`
271
+ * so the exit code and next-hint match the operational failure it is.
272
+ * 2. A server-thrown `InputError` round-trips its FULL structured payload
273
+ * through `toErrorBody` into `ApiError.details` (`{error,message,next,…}`) —
274
+ * reconstruct it verbatim so a gate/guard error (revive reopen-gate, push
275
+ * worktree/finalization guard, …) surfaces byte-for-byte as the leaf raised
276
+ * it before the re-plumb, preserving its SPECIFIC code + next.
277
+ * 3. Otherwise map status→code (`STATUS_TO_CLI_CODE`) with the server message
278
+ * and a caller-supplied-or-default `next`. */
279
+ export function apiErrorToCliError(err, next) {
280
+ // A handover is NOT a down daemon: the successor is already up (the client
281
+ // waited for its /healthz), so telling anyone to start one is wrong — the
282
+ // only thing left is to re-issue the call.
283
+ if (err.code === 'daemon_restarting') {
284
+ return new CrtrError('daemon_restarting', err.message, ExitCode.NETWORK, {
285
+ next: 'crtrd is now on the new generation — retry the command.',
286
+ });
287
+ }
288
+ if (err.code === 'layout_migration_required') {
289
+ return new CrtrError('layout_migration_required', err.message, ExitCode.NETWORK, {
290
+ next: 'Run `crtr sys migrate` while the runtime is offline, then restart it.',
291
+ });
292
+ }
293
+ if (err.code === 'daemon_unavailable') {
294
+ return new CrtrError('daemon_unavailable', err.message, ExitCode.NETWORK, {
295
+ next: 'Start it with `crtr sys daemon start`, then retry.',
296
+ });
297
+ }
298
+ // A responding daemon can relay an unavailable provider or storage service.
299
+ // HTTP 503 alone does not mean that crtrd is down.
300
+ if (err.status === 503) {
301
+ return new CrtrError(err.code, err.message, ExitCode.NETWORK, {
302
+ next: next ?? 'Retry the command; if it persists, inspect the unavailable service named in the error.',
303
+ });
304
+ }
305
+ const d = err.details;
306
+ // A `/v1` request validator reports each bad field in `details.violations`;
307
+ // the message alone only says the request was invalid.
308
+ const violations = Array.isArray(d?.violations)
309
+ ? d.violations
310
+ .map((v) => `${String(v.field)} ${String(v.problem)}${v.received !== undefined ? ` (received ${JSON.stringify(v.received)})` : ''}`)
311
+ : [];
312
+ const payload = d && typeof d.error === 'string' && typeof d.message === 'string' && typeof d.next === 'string'
313
+ ? d
314
+ : {
315
+ error: STATUS_TO_CLI_CODE[err.status] ?? err.code,
316
+ message: violations.length > 0 ? `${err.message}: ${violations.join('; ')}` : err.message,
317
+ next: next ??
318
+ (err.status >= 500
319
+ ? 'This is a crtr bug, not a bad call. Retry; if it persists, report it (the daemon log holds the full cause).'
320
+ : err.status === 404
321
+ ? 'List nodes with `crtr node inspect list`.'
322
+ : 'Inspect the error and adjust the call. See -h for the schema.'),
323
+ };
324
+ // Preserve the exit-code class the server leaf carried instead of collapsing
325
+ // every non-503 to USAGE(2): a not-found (404 / `not_found`) surfaces exit 3
326
+ // and an ambiguous match exit 4, matching the codes the local-only leaves
327
+ // (memory/pkg/sys) still emit so the taxonomy stays uniform across the CLI.
328
+ return new InputError(payload, exitForCliCode(payload.error, err.status));
329
+ }
330
+ /** The `ExitCode` class a reconstructed API error should carry, keyed off the
331
+ * round-tripped CLI code (and the 404 status as the not-found signal — the
332
+ * server maps NOT_FOUND's exit class to status 404, but folds AMBIGUOUS and
333
+ * USAGE both onto 400, so `ambiguous` is recoverable only from the code). */
334
+ function exitForCliCode(code, status) {
335
+ if (code === 'not_found' || status === 404)
336
+ return ExitCode.NOT_FOUND;
337
+ if (code === 'ambiguous')
338
+ return ExitCode.AMBIGUOUS;
339
+ return ExitCode.USAGE;
340
+ }
341
+ /** Terminal error handler. Command-level failures (bad input, not-found,
342
+ * ambiguous) surface as the JSON response on stdout so the caller parses one
343
+ * contract. A daemon `ApiError` is translated to that same clean contract
344
+ * (`apiErrorToCliError`) as a safety net for any leaf that lets it propagate.
345
+ * Runtime/internal failures go to stderr as `{error:"internal"}` — raw traces
346
+ * never reach the agent. Exits non-zero either way. */
347
+ export function handle(e) {
348
+ if (e instanceof ApiError) {
349
+ handle(apiErrorToCliError(e));
350
+ return;
351
+ }
352
+ if (e instanceof CrtrError) {
353
+ const payload = payloadOf(e);
354
+ if (preview !== undefined)
355
+ publishPreview({ error: payload });
356
+ const out = jsonOutput
357
+ ? JSON.stringify(payload, null, 2)
358
+ : renderError(payload);
359
+ process.stdout.write(out + '\n');
360
+ process.exitCode = e.exitCode;
361
+ return;
362
+ }
363
+ const err = e;
364
+ const message = err !== null && err !== undefined && typeof err.message === 'string'
365
+ ? err.message
366
+ : String(e);
367
+ process.stderr.write(JSON.stringify({
368
+ error: 'internal',
369
+ message,
370
+ next: 'This is a crtr bug, not a bad call. Retry; if it persists, report it.',
371
+ }, null, 2) + '\n');
372
+ process.exitCode = ExitCode.GENERAL;
373
+ }
@@ -0,0 +1,49 @@
1
+ export declare const ATTACH_CONTROL_BINDINGS: readonly [{
2
+ readonly menuId: "crtr.tmux.menu.attach.detach";
3
+ readonly actionId: "crtr.attach.detach";
4
+ }, {
5
+ readonly menuId: "crtr.tmux.menu.attach.clear-or-detach";
6
+ readonly actionId: "crtr.attach.clear-or-detach";
7
+ }, {
8
+ readonly menuId: "crtr.tmux.menu.attach.graph.toggle";
9
+ readonly actionId: "crtr.attach.graph.toggle";
10
+ }, {
11
+ readonly menuId: "crtr.tmux.menu.attach.help.toggle";
12
+ readonly actionId: "crtr.attach.help.toggle";
13
+ }, {
14
+ readonly menuId: "crtr.tmux.menu.attach.inbox.toggle";
15
+ readonly actionId: "crtr.attach.inbox.toggle";
16
+ }, {
17
+ readonly menuId: "crtr.tmux.menu.attach.inbox-strip";
18
+ readonly actionId: "crtr.attach.inbox-strip";
19
+ }, {
20
+ readonly menuId: "crtr.tmux.menu.attach.model-ladder.next";
21
+ readonly actionId: "crtr.attach.model-ladder.next";
22
+ }, {
23
+ readonly menuId: "crtr.tmux.menu.attach.model-ladder.previous";
24
+ readonly actionId: "crtr.attach.model-ladder.previous";
25
+ }, {
26
+ readonly menuId: "crtr.tmux.menu.attach.command.inspect";
27
+ readonly actionId: "crtr.attach.command.inspect";
28
+ }, {
29
+ readonly menuId: "crtr.tmux.menu.attach.file-review";
30
+ readonly actionId: "crtr.attach.file-review";
31
+ }, {
32
+ readonly menuId: "crtr.tmux.menu.attach.profile-files";
33
+ readonly actionId: "crtr.attach.profile-files";
34
+ }, {
35
+ readonly menuId: "crtr.tmux.menu.attach.search";
36
+ readonly actionId: "crtr.attach.search";
37
+ }, {
38
+ readonly menuId: "crtr.tmux.menu.attach.whip";
39
+ readonly actionId: "crtr.attach.whip";
40
+ }];
41
+ export type AttachBindingId = typeof ATTACH_CONTROL_BINDINGS[number]['actionId'];
42
+ export type AttachMenuBindingId = typeof ATTACH_CONTROL_BINDINGS[number]['menuId'];
43
+ export interface ExtractedAttachControlInput {
44
+ readonly actionId: AttachBindingId;
45
+ readonly remaining: string;
46
+ }
47
+ export declare function decodeAttachControlInput(data: string): AttachBindingId | undefined;
48
+ export declare function encodeAttachControlInput(actionId: AttachBindingId): string;
49
+ export declare function extractAttachControlInput(data: string): ExtractedAttachControlInput | undefined;
@@ -0,0 +1,42 @@
1
+ export const ATTACH_CONTROL_BINDINGS = [
2
+ { menuId: 'crtr.tmux.menu.attach.detach', actionId: 'crtr.attach.detach' },
3
+ { menuId: 'crtr.tmux.menu.attach.clear-or-detach', actionId: 'crtr.attach.clear-or-detach' },
4
+ { menuId: 'crtr.tmux.menu.attach.graph.toggle', actionId: 'crtr.attach.graph.toggle' },
5
+ { menuId: 'crtr.tmux.menu.attach.help.toggle', actionId: 'crtr.attach.help.toggle' },
6
+ { menuId: 'crtr.tmux.menu.attach.inbox.toggle', actionId: 'crtr.attach.inbox.toggle' },
7
+ { menuId: 'crtr.tmux.menu.attach.inbox-strip', actionId: 'crtr.attach.inbox-strip' },
8
+ { menuId: 'crtr.tmux.menu.attach.model-ladder.next', actionId: 'crtr.attach.model-ladder.next' },
9
+ { menuId: 'crtr.tmux.menu.attach.model-ladder.previous', actionId: 'crtr.attach.model-ladder.previous' },
10
+ { menuId: 'crtr.tmux.menu.attach.command.inspect', actionId: 'crtr.attach.command.inspect' },
11
+ { menuId: 'crtr.tmux.menu.attach.file-review', actionId: 'crtr.attach.file-review' },
12
+ { menuId: 'crtr.tmux.menu.attach.profile-files', actionId: 'crtr.attach.profile-files' },
13
+ { menuId: 'crtr.tmux.menu.attach.search', actionId: 'crtr.attach.search' },
14
+ { menuId: 'crtr.tmux.menu.attach.whip', actionId: 'crtr.attach.whip' },
15
+ ];
16
+ const ATTACH_CONTROL_PREFIX = '\x1b_crtr;';
17
+ const ATTACH_CONTROL_SUFFIX = '\x1b\\';
18
+ const ATTACH_BINDING_IDS = new Set(ATTACH_CONTROL_BINDINGS.map(({ actionId }) => actionId));
19
+ export function decodeAttachControlInput(data) {
20
+ if (!data.startsWith(ATTACH_CONTROL_PREFIX) || !data.endsWith(ATTACH_CONTROL_SUFFIX))
21
+ return undefined;
22
+ const actionId = data.slice(ATTACH_CONTROL_PREFIX.length, -ATTACH_CONTROL_SUFFIX.length);
23
+ return ATTACH_BINDING_IDS.has(actionId) ? actionId : undefined;
24
+ }
25
+ export function encodeAttachControlInput(actionId) {
26
+ return `${ATTACH_CONTROL_PREFIX}${actionId}${ATTACH_CONTROL_SUFFIX}`;
27
+ }
28
+ export function extractAttachControlInput(data) {
29
+ let match;
30
+ for (const { actionId } of ATTACH_CONTROL_BINDINGS) {
31
+ const encoded = encodeAttachControlInput(actionId);
32
+ const index = data.indexOf(encoded);
33
+ if (index >= 0 && (match === undefined || index < match.index))
34
+ match = { actionId, encoded, index };
35
+ }
36
+ if (match === undefined)
37
+ return undefined;
38
+ return {
39
+ actionId: match.actionId,
40
+ remaining: data.slice(0, match.index) + data.slice(match.index + match.encoded.length),
41
+ };
42
+ }