tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21

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 (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -0,0 +1,199 @@
1
+ /**
2
+ * PURE — credential redaction, byte budgets and reserved-metadata protection
3
+ * for the shared structured logger (`log.ts`).
4
+ *
5
+ * Every record the daemon emits passes through `sanitizeLogPayload` before it
6
+ * reaches the ring buffer, subscribers or the wire. The rules are deliberately
7
+ * conservative: a value that looks like a credential is replaced, oversized
8
+ * payloads are truncated with an explicit marker, and caller data can never
9
+ * overwrite the identity fields (`ts`, `level`, `component`, `msg`, `pid`,
10
+ * `instanceId`, `version`) that incident triage relies on.
11
+ */
12
+ export const REDACTED = "[redacted]";
13
+ /** Wire keys owned by the logger; caller payloads with these keys are renamed. */
14
+ export const RESERVED_LOG_KEYS = Object.freeze([
15
+ "ts",
16
+ "level",
17
+ "component",
18
+ "msg",
19
+ "pid",
20
+ "instanceId",
21
+ "version",
22
+ ]);
23
+ const RESERVED = new Set(RESERVED_LOG_KEYS);
24
+ /** Prefix applied to caller keys that collide with reserved metadata. */
25
+ export const RESERVED_COLLISION_PREFIX = "payload_";
26
+ export const DEFAULT_LOG_BUDGET = Object.freeze({
27
+ maxDataBytes: 8 * 1024,
28
+ maxStringBytes: 2 * 1024,
29
+ maxMessageBytes: 4 * 1024,
30
+ maxDepth: 6,
31
+ maxContainerSize: 64,
32
+ });
33
+ /** Total serialized bytes the in-memory ring may retain across all entries. */
34
+ export const DEFAULT_LOG_RING_BYTES = 512 * 1024;
35
+ /**
36
+ * Keys whose values are credentials regardless of shape. Matched on the key
37
+ * with separators removed and case folded so `auth_token`, `AuthToken`,
38
+ * `X-API-Key` and `authorization` all hit.
39
+ */
40
+ const SECRET_KEY = /(token|secret|password|passwd|authorization|cookie|apikey|credential|capability|privatekey|bearer|ticket|lease)/u;
41
+ export function isSecretKey(key) {
42
+ return SECRET_KEY.test(key.toLowerCase().replace(/[^a-z0-9]/gu, ""));
43
+ }
44
+ const BEARER = /\bBearer\s+[^\s"',;]+/giu;
45
+ const BASIC = /\bBasic\s+[A-Za-z0-9+/=]{8,}/gu;
46
+ const URL_QUERY_SECRET = /([?&](?:[A-Za-z0-9_-]*(?:token|ticket|lease|capability|password|secret|auth|key|signature)[A-Za-z0-9_-]*)=)[^&#\s"']*/giu;
47
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s@"']+)@/giu;
48
+ // Values already handled by the header/URL passes (Bearer/Basic schemes, an
49
+ // earlier redaction marker) are skipped so a second pass cannot eat the
50
+ // surrounding text.
51
+ const KEY_VALUE_SECRET = /\b((?:auth[-_]?token|access[-_]?token|refresh[-_]?token|authorization|capability|password|passwd|secret|token|ticket|lease|api[-_]?key)\s*[:=]\s*["']?)(?!Bearer\b|Basic\b|\[redacted\])[^\s,;"'}]+/giu;
52
+ /** Redact credential-shaped substrings inside free text (URLs, headers, k=v). */
53
+ export function redactText(text, secrets = EMPTY) {
54
+ let out = text;
55
+ for (const secret of secrets)
56
+ if (secret && out.includes(secret))
57
+ out = out.split(secret).join(REDACTED);
58
+ return out
59
+ .replace(BEARER, `Bearer ${REDACTED}`)
60
+ .replace(BASIC, `Basic ${REDACTED}`)
61
+ .replace(URL_USERINFO, `$1${REDACTED}@`)
62
+ .replace(URL_QUERY_SECRET, `$1${REDACTED}`)
63
+ .replace(KEY_VALUE_SECRET, `$1${REDACTED}`);
64
+ }
65
+ const EMPTY = new Set();
66
+ function byteLength(text) {
67
+ return Buffer.byteLength(text, "utf8");
68
+ }
69
+ function truncateString(text, maxBytes) {
70
+ if (byteLength(text) <= maxBytes)
71
+ return text;
72
+ // Trim on code points so a multi-byte character is never split.
73
+ let kept = "";
74
+ let used = 0;
75
+ for (const char of text) {
76
+ const size = byteLength(char);
77
+ if (used + size > maxBytes)
78
+ break;
79
+ kept += char;
80
+ used += size;
81
+ }
82
+ return `${kept}…[truncated ${byteLength(text) - used} bytes]`;
83
+ }
84
+ function describeError(error) {
85
+ const out = { name: error.name, message: error.message };
86
+ const code = error.code;
87
+ if (code !== undefined)
88
+ out.code = code;
89
+ if (error.stack)
90
+ out.stack = error.stack;
91
+ return out;
92
+ }
93
+ function redactValue(value, depth, options, seen) {
94
+ const { budget, secrets } = options;
95
+ if (value === null || value === undefined)
96
+ return value;
97
+ if (typeof value === "string")
98
+ return truncateString(redactText(value, secrets), budget.maxStringBytes);
99
+ if (typeof value === "number" || typeof value === "boolean")
100
+ return value;
101
+ if (typeof value === "bigint")
102
+ return value.toString();
103
+ if (typeof value === "symbol" || typeof value === "function")
104
+ return `[${typeof value}]`;
105
+ if (value instanceof Date)
106
+ return value.toISOString();
107
+ if (value instanceof Error)
108
+ return redactValue(describeError(value), depth, options, seen);
109
+ if (Buffer.isBuffer(value) || ArrayBuffer.isView(value))
110
+ return `[binary ${value.byteLength} bytes]`;
111
+ if (typeof value !== "object")
112
+ return String(value);
113
+ if (seen.has(value))
114
+ return "[circular]";
115
+ if (depth >= budget.maxDepth)
116
+ return "[depth exceeded]";
117
+ seen.add(value);
118
+ try {
119
+ if (Array.isArray(value)) {
120
+ const items = value
121
+ .slice(0, budget.maxContainerSize)
122
+ .map((item) => redactValue(item, depth + 1, options, seen));
123
+ if (value.length > budget.maxContainerSize)
124
+ items.push(`[+${value.length - budget.maxContainerSize} items]`);
125
+ return items;
126
+ }
127
+ if (value instanceof Map)
128
+ return redactValue(Object.fromEntries(value), depth, options, seen);
129
+ if (value instanceof Set)
130
+ return redactValue([...value], depth, options, seen);
131
+ const out = {};
132
+ const keys = Object.keys(value);
133
+ for (const key of keys.slice(0, budget.maxContainerSize)) {
134
+ const child = value[key];
135
+ out[key] =
136
+ isSecretKey(key) && child != null ? REDACTED : redactValue(child, depth + 1, options, seen);
137
+ }
138
+ if (keys.length > budget.maxContainerSize)
139
+ out["[+keys]"] = keys.length - budget.maxContainerSize;
140
+ return out;
141
+ }
142
+ finally {
143
+ seen.delete(value);
144
+ }
145
+ }
146
+ /** Redact one arbitrary value (strings, nested objects, errors) under the default budget. */
147
+ export function redactLogValue(value, secrets = EMPTY, budget = DEFAULT_LOG_BUDGET) {
148
+ return redactValue(value, 0, { budget, secrets }, new WeakSet());
149
+ }
150
+ /**
151
+ * Redact, protect reserved keys and bound a caller payload. Deterministic:
152
+ * the same input always yields the same output, so tests can assert exact
153
+ * shapes.
154
+ */
155
+ export function sanitizeLogPayload(data, options = {}) {
156
+ if (!data)
157
+ return { data: undefined, bytes: 0, truncated: false, renamedKeys: [] };
158
+ const budget = options.budget ?? DEFAULT_LOG_BUDGET;
159
+ const secrets = options.secrets ?? EMPTY;
160
+ const redacted = redactValue(data, 0, { budget, secrets }, new WeakSet());
161
+ const renamedKeys = [];
162
+ const protectedData = {};
163
+ for (const [key, value] of Object.entries(redacted)) {
164
+ if (RESERVED.has(key)) {
165
+ renamedKeys.push(key);
166
+ protectedData[`${RESERVED_COLLISION_PREFIX}${key}`] = value;
167
+ }
168
+ else {
169
+ protectedData[key] = value;
170
+ }
171
+ }
172
+ let serialized = safeStringify(protectedData);
173
+ let bytes = byteLength(serialized);
174
+ if (bytes <= budget.maxDataBytes) {
175
+ return { data: protectedData, bytes, truncated: false, renamedKeys };
176
+ }
177
+ // Over budget even after per-string limits: keep the key list so the record
178
+ // stays diagnosable, and say how much was dropped.
179
+ const summary = {
180
+ truncated: true,
181
+ droppedBytes: bytes,
182
+ keys: Object.keys(protectedData).slice(0, budget.maxContainerSize),
183
+ };
184
+ serialized = safeStringify(summary);
185
+ bytes = byteLength(serialized);
186
+ return { data: summary, bytes, truncated: true, renamedKeys };
187
+ }
188
+ /** Bound a log message: redact and truncate to the message budget. */
189
+ export function sanitizeLogMessage(message, secrets = EMPTY, budget = DEFAULT_LOG_BUDGET) {
190
+ return truncateString(redactText(String(message), secrets), budget.maxMessageBytes);
191
+ }
192
+ function safeStringify(value) {
193
+ try {
194
+ return JSON.stringify(value) ?? "null";
195
+ }
196
+ catch {
197
+ return '"[unserializable]"';
198
+ }
199
+ }
@@ -1,16 +1,67 @@
1
- /** Minimal structured logger for tmux-ide daemon processes. */
1
+ /**
2
+ * Minimal structured logger for tmux-ide daemon processes.
3
+ *
4
+ * Every record is sanitized centrally (`log-sanitize.ts`): credential-shaped
5
+ * keys and substrings are redacted, payloads are bounded per record and for
6
+ * the in-memory ring, and the identity metadata a triage relies on (`ts`,
7
+ * `level`, `component`, `msg`, `pid`, `instanceId`, `version`) can never be
8
+ * overwritten by caller data. Stream write failures (closed pipe, full disk)
9
+ * degrade to ring-only logging with one explicit warning; they never crash
10
+ * the process.
11
+ */
12
+ import { DEFAULT_LOG_BUDGET, DEFAULT_LOG_RING_BYTES, sanitizeLogMessage, sanitizeLogPayload, } from "./log-sanitize.js";
2
13
  const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
3
14
  let minLevel = process.env.LOG_LEVEL ?? "info";
4
15
  export function setLogLevel(level) {
5
16
  minLevel = level;
6
17
  }
18
+ let identity = {};
19
+ const secrets = new Set();
20
+ /** Stamp reserved identity metadata onto every subsequent record. */
21
+ export function setLogIdentity(next) {
22
+ identity = next ? { ...next } : {};
23
+ }
24
+ export function getLogIdentity() {
25
+ return { ...identity };
26
+ }
27
+ /** Register a credential literal to redact wherever it appears in a record. */
28
+ export function registerLogSecret(secret) {
29
+ if (typeof secret === "string" && secret.length >= 8)
30
+ secrets.add(secret);
31
+ }
32
+ /** @internal test seam */
33
+ export function _resetLogStateForTests() {
34
+ identity = {};
35
+ secrets.clear();
36
+ logBuffer.length = 0;
37
+ ringBytes = 0;
38
+ streamState.stdout = { failed: false, listening: false };
39
+ streamState.stderr = { failed: false, listening: false };
40
+ budget = DEFAULT_LOG_BUDGET;
41
+ ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
42
+ }
43
+ /** @internal test seam — shrink budgets to make bounds observable. */
44
+ export function _setLogBudgetForTests(next) {
45
+ const { ringBytes, ...rest } = next;
46
+ budget = { ...DEFAULT_LOG_BUDGET, ...rest };
47
+ if (ringBytes !== undefined)
48
+ ringBudgetBytes = ringBytes;
49
+ }
7
50
  const LOG_BUFFER_SIZE = 1_000;
8
51
  const logBuffer = [];
52
+ const entryBytes = new WeakMap();
53
+ let ringBytes = 0;
54
+ let budget = DEFAULT_LOG_BUDGET;
55
+ let ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
9
56
  const subscribers = new Set();
10
57
  /** Snapshot the current ring buffer; useful for SSE backfill. */
11
58
  export function getLogBuffer() {
12
59
  return logBuffer.slice();
13
60
  }
61
+ /** Ring occupancy for diagnostics: entry count and retained serialized bytes. */
62
+ export function getLogBufferStats() {
63
+ return { entries: logBuffer.length, bytes: ringBytes, budgetBytes: ringBudgetBytes };
64
+ }
14
65
  /**
15
66
  * Subscribe to live log entries. Returns an unsubscribe function. The
16
67
  * handler runs synchronously inside the writer — keep it cheap; throw
@@ -23,20 +74,75 @@ export function subscribeLogs(handler) {
23
74
  subscribers.delete(handler);
24
75
  };
25
76
  }
77
+ const streamState = {
78
+ stdout: { failed: false, listening: false },
79
+ stderr: { failed: false, listening: false },
80
+ };
81
+ /** Which process streams the logger has given up on (diagnostics only). */
82
+ export function getLogStreamFailures() {
83
+ return Object.keys(streamState).filter((name) => streamState[name].failed);
84
+ }
85
+ function markStreamFailed(name, error) {
86
+ if (streamState[name].failed)
87
+ return;
88
+ streamState[name].failed = true;
89
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
90
+ const warning = `[log.ts] ${name} write failed (${detail}); further records are retained in memory only\n`;
91
+ const other = name === "stdout" ? "stderr" : "stdout";
92
+ if (!streamState[other].failed) {
93
+ try {
94
+ process[other].write(warning);
95
+ }
96
+ catch {
97
+ streamState[other].failed = true;
98
+ }
99
+ }
100
+ }
101
+ function writeToStream(name, line) {
102
+ const state = streamState[name];
103
+ if (state.failed)
104
+ return;
105
+ const stream = process[name];
106
+ if (!state.listening) {
107
+ state.listening = true;
108
+ // A closed pipe (EPIPE) or full disk (ENOSPC) surfaces asynchronously as
109
+ // an 'error' event; without a listener Node raises it as an uncaught
110
+ // exception and the daemon dies mid-triage.
111
+ stream.on("error", (error) => markStreamFailed(name, error));
112
+ }
113
+ try {
114
+ stream.write(line);
115
+ }
116
+ catch (error) {
117
+ markStreamFailed(name, error);
118
+ }
119
+ }
26
120
  function writeStructuredLog(level, component, message, data) {
27
121
  if (LEVEL_RANK[level] < LEVEL_RANK[minLevel])
28
122
  return;
123
+ const payload = sanitizeLogPayload(data, { secrets, budget });
29
124
  const entry = {
30
125
  ts: new Date().toISOString(),
31
126
  level,
32
- component,
33
- msg: message,
34
- ...(data ? { data } : {}),
127
+ component: sanitizeLogMessage(String(component), secrets, budget).slice(0, 128),
128
+ msg: sanitizeLogMessage(message, secrets, budget),
129
+ pid: process.pid,
130
+ ...(identity.instanceId ? { instanceId: identity.instanceId } : {}),
131
+ ...(identity.version ? { version: identity.version } : {}),
132
+ ...(payload.data ? { data: payload.data } : {}),
35
133
  };
36
- // Push to ring + drop oldest when over budget.
134
+ const size = payload.bytes + Buffer.byteLength(entry.msg, "utf8") + 96;
135
+ entryBytes.set(entry, size);
136
+ // Push to ring + drop oldest while over either budget.
37
137
  logBuffer.push(entry);
38
- if (logBuffer.length > LOG_BUFFER_SIZE)
39
- logBuffer.shift();
138
+ ringBytes += size;
139
+ while (logBuffer.length > 0 &&
140
+ (logBuffer.length > LOG_BUFFER_SIZE || ringBytes > ringBudgetBytes)) {
141
+ const evicted = logBuffer.shift();
142
+ ringBytes -= entryBytes.get(evicted) ?? 0;
143
+ if (logBuffer.length === 0)
144
+ ringBytes = 0;
145
+ }
40
146
  // Fan out to subscribers; isolate failures.
41
147
  for (const sub of subscribers) {
42
148
  try {
@@ -44,21 +150,25 @@ function writeStructuredLog(level, component, message, data) {
44
150
  }
45
151
  catch (err) {
46
152
  // Avoid recursion via the logger; write directly to stderr.
47
- process.stderr.write(`[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`);
153
+ writeToStream("stderr", `[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`);
48
154
  }
49
155
  }
50
156
  // Stream form for humans tailing the process. The data field is
51
- // serialized inline (matches the previous wire shape).
157
+ // serialized inline (matches the previous wire shape); reserved metadata
158
+ // is written last so a caller payload can never shadow it.
52
159
  const wire = {
160
+ ...(entry.data ?? {}),
53
161
  ts: entry.ts,
54
162
  level: entry.level,
55
163
  component: entry.component,
56
164
  msg: entry.msg,
165
+ pid: entry.pid,
166
+ ...(entry.instanceId ? { instanceId: entry.instanceId } : {}),
167
+ ...(entry.version ? { version: entry.version } : {}),
57
168
  };
58
- if (data)
59
- Object.assign(wire, data);
60
- const out = level === "error" ? process.stderr : process.stdout;
61
- out.write(JSON.stringify(wire) + "\n");
169
+ if (payload.renamedKeys.length > 0)
170
+ wire.reservedKeysRenamed = payload.renamedKeys;
171
+ writeToStream(level === "error" ? "stderr" : "stdout", JSON.stringify(wire) + "\n");
62
172
  }
63
173
  export const logger = {
64
174
  debug: (component, msg, data) => writeStructuredLog("debug", component, msg, data),
@@ -0,0 +1,124 @@
1
+ /** Pure, allowlisted evidence projection. Never retain response bodies or errors. */
2
+ export const DIAGNOSTICS_MAX_BYTES = 16_384;
3
+ export const RESOURCE_KEYS = [
4
+ "Timeout",
5
+ "Immediate",
6
+ "TCPServerWrap",
7
+ "TCPSocketWrap",
8
+ "PipeWrap",
9
+ "ProcessWrap",
10
+ "FSEventWrap",
11
+ "other",
12
+ ];
13
+ export const MEMORY_KEYS = ["rss", "heapTotal", "heapUsed", "external", "arrayBuffers"];
14
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
15
+ const number = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
16
+ const text = (v) => typeof v === "string" && v.length > 0 && v.length <= 256;
17
+ function numeric(v, keys, integer = false) {
18
+ if (!object(v) || !keys.every((k) => number(v[k]) && (!integer || Number.isSafeInteger(v[k]))))
19
+ return null;
20
+ return Object.fromEntries(keys.map((k) => [k, v[k]]));
21
+ }
22
+ export function parseSoakDiagnostics(body, status, expected) {
23
+ const fail = (status) => ({
24
+ status,
25
+ sample: null,
26
+ });
27
+ if (status === 404)
28
+ return fail("unsupported-endpoint");
29
+ if (status !== 200)
30
+ return fail("http-error");
31
+ if (body === null || body === "")
32
+ return fail("missing");
33
+ if (new TextEncoder().encode(body).length > DIAGNOSTICS_MAX_BYTES)
34
+ return fail("malformed");
35
+ let raw;
36
+ try {
37
+ raw = JSON.parse(body);
38
+ }
39
+ catch {
40
+ return fail("malformed");
41
+ }
42
+ if (!object(raw) || !object(raw.daemon))
43
+ return fail("malformed");
44
+ const d = raw.daemon;
45
+ if (!Number.isSafeInteger(d.protocolVersion) ||
46
+ !number(d.protocolVersion) ||
47
+ !text(d.productVersion) ||
48
+ !text(d.instanceId) ||
49
+ !text(d.startedAt) ||
50
+ !Number.isFinite(Date.parse(d.startedAt)) ||
51
+ (d.environmentId !== undefined && !text(d.environmentId)) ||
52
+ !Number.isSafeInteger(raw.pid) ||
53
+ !number(raw.pid) ||
54
+ raw.pid === 0)
55
+ return fail("malformed");
56
+ if (["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some((k) => d[k] !== expected[k]) ||
57
+ raw.pid !== expected.pid)
58
+ return fail("identity-mismatch");
59
+ const memory = numeric(raw.memory, MEMORY_KEYS, true);
60
+ const cpu = numeric(raw.cpu, ["user", "system"], true);
61
+ const eventLoop = numeric(raw.eventLoop, ["idle", "active", "utilization"]);
62
+ const activeResources = raw.activeResources === null ? null : numeric(raw.activeResources, RESOURCE_KEYS, true);
63
+ if (!memory ||
64
+ !cpu ||
65
+ !eventLoop ||
66
+ eventLoop.utilization > 1 ||
67
+ !number(raw.uptimeMs) ||
68
+ !number(raw.sampledAtMs) ||
69
+ (raw.activeResources !== null && !activeResources))
70
+ return fail("malformed");
71
+ return {
72
+ status: "ok",
73
+ sample: {
74
+ daemon: {
75
+ protocolVersion: d.protocolVersion,
76
+ productVersion: d.productVersion,
77
+ instanceId: d.instanceId,
78
+ startedAt: d.startedAt,
79
+ ...(d.environmentId !== undefined ? { environmentId: d.environmentId } : {}),
80
+ },
81
+ pid: raw.pid,
82
+ uptimeMs: raw.uptimeMs,
83
+ sampledAtMs: raw.sampledAtMs,
84
+ memory,
85
+ cpu,
86
+ eventLoop,
87
+ activeResources,
88
+ },
89
+ };
90
+ }
91
+ /** Uptime is monotonic; wall-clock jumps do not change interval rates. */
92
+ export function diagnosticsDelta(previous, current) {
93
+ if (!previous)
94
+ return { status: "missing-baseline" };
95
+ if (previous.pid !== current.pid ||
96
+ ["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some((key) => previous.daemon[key] !==
97
+ current.daemon[key]))
98
+ return { status: "identity-mismatch" };
99
+ const intervalMs = current.uptimeMs - previous.uptimeMs;
100
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0)
101
+ return { status: "invalid-interval" };
102
+ const cpuUserMicros = current.cpu.user - previous.cpu.user;
103
+ const cpuSystemMicros = current.cpu.system - previous.cpu.system;
104
+ const eventLoopIdleMs = current.eventLoop.idle - previous.eventLoop.idle;
105
+ const eventLoopActiveMs = current.eventLoop.active - previous.eventLoop.active;
106
+ if (![cpuUserMicros, cpuSystemMicros, eventLoopIdleMs, eventLoopActiveMs].every(number))
107
+ return { status: "counter-regression" };
108
+ const cpuPercent = (cpuUserMicros + cpuSystemMicros) / (intervalMs * 10);
109
+ if (!Number.isFinite(cpuPercent))
110
+ return { status: "invalid-interval" };
111
+ const eventTotal = eventLoopIdleMs + eventLoopActiveMs;
112
+ if (!Number.isFinite(eventTotal))
113
+ return { status: "invalid-interval" };
114
+ return {
115
+ status: "ok",
116
+ intervalMs,
117
+ cpuUserMicros,
118
+ cpuSystemMicros,
119
+ cpuPercent,
120
+ eventLoopIdleMs,
121
+ eventLoopActiveMs,
122
+ eventLoopUtilization: eventTotal === 0 ? null : eventLoopActiveMs / eventTotal,
123
+ };
124
+ }