tmux-ide 2.9.3 → 2.9.5

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +120 -139
  3. package/bin/cli.js +4847 -3585
  4. package/package.json +16 -9
  5. package/packages/contracts/src/session-runtime.ts +2 -0
  6. package/packages/contracts/src/terminal-delivery.ts +4 -0
  7. package/packages/core/src/terminal-compact-capability.ts +31 -1
  8. package/packages/core/src/terminal-delivery.ts +234 -32
  9. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  10. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  11. package/packages/core/src/terminal-replica.ts +43 -21
  12. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  13. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  14. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  20. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  25. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  26. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  32. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  33. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  34. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  35. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  36. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  37. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  41. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  42. package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
  43. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  44. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  45. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  47. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  49. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  50. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  52. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  53. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  55. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  57. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  58. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  59. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  60. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  61. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  62. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  63. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  65. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  66. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  69. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  71. package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
  72. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  73. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  74. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  75. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  76. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  77. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  79. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  80. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  81. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  82. package/packages/daemon-client/src/workspace-client.ts +78 -5
  83. package/packages/tmux-bridge/src/index.ts +1 -0
  84. package/packages/tmux-bridge/src/runner.ts +25 -1
  85. package/skill/SKILL.md +30 -30
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Claude Code agent teams — opt-out enablement in the user's Claude settings.
3
+ *
4
+ * Agent teams let one Claude Code session spawn teammates; with
5
+ * `teammateMode: "auto"` Claude puts them in split panes when it runs inside
6
+ * tmux (in-process elsewhere), which is exactly what tmux-ide shows and
7
+ * routes. The installer enables this by default and `tmux-ide integration
8
+ * agent-teams enable|disable|status` manages it explicitly.
9
+ *
10
+ * Rules (pure, in {@link mergeAgentTeams}):
11
+ * - `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set to "1" only when absent.
12
+ * An explicit off value ("0", "false", false, 0…) is the user's opt-out: the
13
+ * installer never overrides it and touches nothing else. Only an explicit
14
+ * `tmux-ide integration agent-teams enable` (overrideOptOut) turns it back on.
15
+ * - `teammateMode: "auto"` is set only when `teammateMode` is absent.
16
+ * - Every other key is preserved. Nothing changed → nothing written.
17
+ *
18
+ * Writes (io) reuse the hooks integration's settings path resolution, including
19
+ * the `TMUX_IDE_CLAUDE_SETTINGS` override: one-time timestamped backup before
20
+ * the first modification, then an atomic temp-file + rename. Invalid JSON is
21
+ * reported and never rewritten.
22
+ */
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, copyFileSync, unlinkSync, writeFileSync, accessSync, constants, } from "node:fs";
24
+ import { basename, delimiter, dirname, join } from "node:path";
25
+ import { resolveRuntimeNamespace, runtimeOwnedPath } from "../../lib/runtime-namespace.js";
26
+ export const AGENT_TEAMS_ENV = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS";
27
+ export const DEFAULT_TEAMMATE_MODE = "auto";
28
+ const BACKUP_INFIX = ".tmux-ide-backup-";
29
+ /** PURE — an explicit off value is the user's opt-out. */
30
+ export function isOptOut(value) {
31
+ if (value === false || value === 0)
32
+ return true;
33
+ return typeof value === "string" && /^\s*(0|false|off|no)\s*$/iu.test(value);
34
+ }
35
+ function envOf(settings) {
36
+ const env = settings.env;
37
+ if (env === undefined)
38
+ return undefined;
39
+ if (!env || typeof env !== "object" || Array.isArray(env))
40
+ throw new Error("settings env must be an object");
41
+ return env;
42
+ }
43
+ /** PURE — how the settings currently stand for agent teams. */
44
+ export function agentTeamsSetting(settings) {
45
+ const value = envOf(settings)?.[AGENT_TEAMS_ENV];
46
+ if (value === undefined)
47
+ return "not-configured";
48
+ return isOptOut(value) ? "disabled-by-user" : "enabled";
49
+ }
50
+ /**
51
+ * PURE — enable agent teams without overriding anything the user chose, unless
52
+ * `overrideOptOut` (the user explicitly asked to enable) replaces their "0".
53
+ */
54
+ export function mergeAgentTeams(settings, { overrideOptOut = false } = {}) {
55
+ const setting = agentTeamsSetting(settings);
56
+ if (setting === "disabled-by-user" && !overrideOptOut)
57
+ return { settings, changes: [], setting };
58
+ const changes = [];
59
+ let next = settings;
60
+ if (setting !== "enabled") {
61
+ next = { ...next, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "1" } };
62
+ changes.push(`env.${AGENT_TEAMS_ENV}=1`);
63
+ }
64
+ if (!("teammateMode" in settings)) {
65
+ next = { ...next, teammateMode: DEFAULT_TEAMMATE_MODE };
66
+ changes.push(`teammateMode=${DEFAULT_TEAMMATE_MODE}`);
67
+ }
68
+ return { settings: next, changes, setting: "enabled" };
69
+ }
70
+ /** PURE — record an explicit opt-out ("0"), which later enables respect. */
71
+ export function disableAgentTeams(settings) {
72
+ if (agentTeamsSetting(settings) === "disabled-by-user")
73
+ return { settings, changes: [], setting: "disabled-by-user" };
74
+ return {
75
+ settings: { ...settings, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "0" } },
76
+ changes: [`env.${AGENT_TEAMS_ENV}=0`],
77
+ setting: "disabled-by-user",
78
+ };
79
+ }
80
+ export function agentTeamsPaths() {
81
+ const namespace = resolveRuntimeNamespace();
82
+ return { settingsPath: namespace.claudeSettingsPath, claudeDir: namespace.claudeDir };
83
+ }
84
+ /** `claude` on PATH, without spawning anything. */
85
+ export function claudeOnPath(path = process.env.PATH ?? "") {
86
+ return path
87
+ .split(delimiter)
88
+ .filter(Boolean)
89
+ .some((dir) => {
90
+ try {
91
+ accessSync(join(dir, "claude"), constants.X_OK);
92
+ return statSync(join(dir, "claude")).isFile();
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ });
98
+ }
99
+ export class InvalidClaudeSettingsError extends Error {
100
+ }
101
+ function readSettings(path) {
102
+ if (!existsSync(path))
103
+ return { settings: {}, exists: false };
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
106
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
107
+ throw new Error("not an object");
108
+ envOf(parsed);
109
+ return { settings: parsed, exists: true };
110
+ }
111
+ catch {
112
+ throw new InvalidClaudeSettingsError(`${path} is not valid settings JSON — fix or move it, then retry`);
113
+ }
114
+ }
115
+ /** Existing one-time backup, if tmux-ide already made one for this file. */
116
+ function existingBackup(settingsPath) {
117
+ const dir = dirname(settingsPath);
118
+ const prefix = `${basename(settingsPath)}${BACKUP_INFIX}`;
119
+ try {
120
+ const found = readdirSync(dir).find((name) => name.startsWith(prefix));
121
+ return found ? join(dir, found) : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function writeSettings(path, settings, exists) {
128
+ mkdirSync(dirname(path), { recursive: true });
129
+ let backup = null;
130
+ if (exists) {
131
+ backup = existingBackup(path);
132
+ if (!backup) {
133
+ const stamp = new Date()
134
+ .toISOString()
135
+ .replace(/[-:]/gu, "")
136
+ .replace(/\.\d+Z$/u, "Z");
137
+ backup = `${path}${BACKUP_INFIX}${stamp}`;
138
+ copyFileSync(path, backup);
139
+ }
140
+ }
141
+ const temporary = join(dirname(path), `.${basename(path)}.tmux-ide-${process.pid}.tmp`);
142
+ try {
143
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
144
+ if (exists)
145
+ chmodSync(temporary, statSync(path).mode & 0o777);
146
+ renameSync(temporary, path);
147
+ }
148
+ catch (error) {
149
+ try {
150
+ unlinkSync(temporary);
151
+ }
152
+ catch {
153
+ /* already renamed or never written */
154
+ }
155
+ throw error;
156
+ }
157
+ return backup;
158
+ }
159
+ function detected(paths, onPath) {
160
+ return existsSync(paths.claudeDir) || onPath();
161
+ }
162
+ function guarded(paths) {
163
+ if (!paths && resolveRuntimeNamespace().development)
164
+ throw new Error("Development agent-teams changes require explicit fixture paths");
165
+ const resolved = paths ?? agentTeamsPaths();
166
+ runtimeOwnedPath(resolved.settingsPath);
167
+ return resolved;
168
+ }
169
+ /** Enable agent teams when Claude Code is installed and the user hasn't opted out. */
170
+ export function enableClaudeAgentTeams(paths, onPath = () => claudeOnPath(), { overrideOptOut = false } = {}) {
171
+ const resolved = guarded(paths);
172
+ const { settingsPath } = resolved;
173
+ const base = { settingsPath, changes: [], backup: null };
174
+ if (!detected(resolved, onPath))
175
+ return {
176
+ ...base,
177
+ action: "skipped",
178
+ claudeDetected: false,
179
+ message: `Claude Code not found (no ${resolved.claudeDir}, no claude on PATH); agent teams not configured.`,
180
+ };
181
+ let read;
182
+ try {
183
+ read = readSettings(settingsPath);
184
+ }
185
+ catch (error) {
186
+ return { ...base, action: "invalid", claudeDetected: true, message: error.message };
187
+ }
188
+ const merged = mergeAgentTeams(read.settings, { overrideOptOut });
189
+ if (merged.setting === "disabled-by-user")
190
+ return {
191
+ ...base,
192
+ action: "disabled-by-user",
193
+ claudeDetected: true,
194
+ message: `Claude Code agent teams are disabled by user in ${settingsPath} (${AGENT_TEAMS_ENV}); ` +
195
+ "left unchanged. Enable explicitly: tmux-ide integration agent-teams enable",
196
+ };
197
+ if (merged.changes.length === 0)
198
+ return {
199
+ ...base,
200
+ action: "unchanged",
201
+ claudeDetected: true,
202
+ message: `Claude Code agent teams already enabled in ${settingsPath}.`,
203
+ };
204
+ const backup = writeSettings(settingsPath, merged.settings, read.exists);
205
+ return {
206
+ settingsPath,
207
+ changes: merged.changes,
208
+ backup,
209
+ action: "enabled",
210
+ claudeDetected: true,
211
+ message: `Enabled Claude Code agent teams in ${settingsPath} (${merged.changes.join(", ")}` +
212
+ `${backup ? `; backup: ${backup}` : ""}). Disable: tmux-ide integration agent-teams disable`,
213
+ };
214
+ }
215
+ /** Record the user's opt-out so neither the installer nor `enable` turns it back on silently. */
216
+ export function disableClaudeAgentTeams(paths) {
217
+ const resolved = guarded(paths);
218
+ const { settingsPath } = resolved;
219
+ const base = { settingsPath, changes: [], backup: null, claudeDetected: true };
220
+ let read;
221
+ try {
222
+ read = readSettings(settingsPath);
223
+ }
224
+ catch (error) {
225
+ return { ...base, action: "invalid", message: error.message };
226
+ }
227
+ const next = disableAgentTeams(read.settings);
228
+ if (next.changes.length === 0)
229
+ return {
230
+ ...base,
231
+ action: "unchanged",
232
+ message: `Claude Code agent teams already disabled in ${settingsPath}.`,
233
+ };
234
+ const backup = writeSettings(settingsPath, next.settings, read.exists);
235
+ return {
236
+ ...base,
237
+ changes: next.changes,
238
+ backup,
239
+ action: "disabled",
240
+ message: `Disabled Claude Code agent teams in ${settingsPath} (${next.changes.join(", ")}` +
241
+ `${backup ? `; backup: ${backup}` : ""}). Re-enable: tmux-ide integration agent-teams enable`,
242
+ };
243
+ }
244
+ export function claudeAgentTeamsStatus(paths = agentTeamsPaths(), onPath = () => claudeOnPath()) {
245
+ const claudeDetected = detected(paths, onPath);
246
+ try {
247
+ const { settings } = readSettings(paths.settingsPath);
248
+ const mode = settings.teammateMode;
249
+ return {
250
+ settingsPath: paths.settingsPath,
251
+ claudeDetected,
252
+ setting: agentTeamsSetting(settings),
253
+ teammateMode: typeof mode === "string" ? mode : null,
254
+ };
255
+ }
256
+ catch {
257
+ return {
258
+ settingsPath: paths.settingsPath,
259
+ claudeDetected,
260
+ setting: "invalid-settings",
261
+ teammateMode: null,
262
+ };
263
+ }
264
+ }
@@ -1,3 +1,4 @@
1
+ import { STOCK_CAPTURE_TAB_UNAVAILABLE } from "@tmux-ide/contracts";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { TerminalDeliveryAssembler, decodeVerifiedCompactSemanticTerminalUpdate, decodeVerifiedCompactSemanticTerminalUpdateCooperatively, decodeVerifiedLegacySemanticTerminalUpdate, terminalDeliveryEncodingAccepted, } from "@tmux-ide/core";
3
4
  import { PaneStreamOperationError, } from "@tmux-ide/daemon-client/pane-stream-client";
@@ -193,7 +194,10 @@ function updateFromDelivery(envelope, payload, canonicalSnapshot, cols, rows) {
193
194
  stateHash: envelope.canonicalStateHash,
194
195
  hashAlgorithm: "fnv1a64-v1",
195
196
  };
196
- if (payload.frame === "seed") {
197
+ // Wire patches may span skipped revisions; ordinary canonical patches may not.
198
+ // Publish the fully verified resulting state at its actual revision.
199
+ if (payload.frame === "seed" ||
200
+ (payload.frame === "patch" && payload.revision > payload.baseRevision + 1)) {
197
201
  return Object.freeze({
198
202
  ...common,
199
203
  type: "terminal.seed",
@@ -245,6 +249,7 @@ class WireTerminalEndpoint {
245
249
  #resolveReady;
246
250
  #readySettled = false;
247
251
  #hasCanonicalSeed = false;
252
+ #unavailable = null;
248
253
  constructor(options) {
249
254
  this.#workspaceName = options.workspaceName;
250
255
  this.#semanticPaneId = options.semanticPaneId;
@@ -272,8 +277,11 @@ class WireTerminalEndpoint {
272
277
  this.#readySettled = true;
273
278
  this.#resolveReady(true);
274
279
  }
280
+ get settled() {
281
+ return this.#unavailable !== null || this.inputReady;
282
+ }
275
283
  get inputReady() {
276
- return !this.#closed && this.#hasCanonicalSeed && !this.#reseedRequired;
284
+ return (!this.#closed && this.#unavailable === null && this.#hasCanonicalSeed && !this.#reseedRequired);
277
285
  }
278
286
  async subscription() {
279
287
  const ready = await this.#ready;
@@ -291,6 +299,8 @@ class WireTerminalEndpoint {
291
299
  this.#subscription = null;
292
300
  });
293
301
  this.#subscription = subscription;
302
+ if (this.#unavailable)
303
+ subscription.unavailable(this.#unavailable);
294
304
  return subscription;
295
305
  }
296
306
  accept(message) {
@@ -301,10 +311,30 @@ class WireTerminalEndpoint {
301
311
  return;
302
312
  }
303
313
  if (message.type === "terminal.delivery.fault") {
314
+ if (message.deliveryNonce !== this.#negotiated.deliveryNonce) {
315
+ this.#failConnection(new Error("Terminal fault nonce does not match negotiation"));
316
+ return;
317
+ }
318
+ // The exact shared diagnostic is currently the compatibility-preserving discriminator.
319
+ if (message.reason === "source-closed" && message.message === STOCK_CAPTURE_TAB_UNAVAILABLE) {
320
+ this.#unavailable = message.message;
321
+ this.#decodeToken = null;
322
+ this.#assembler = null;
323
+ this.#assemblyStorage = null;
324
+ this.#envelope = null;
325
+ this.#pending = null;
326
+ this.#canonicalSnapshot = null;
327
+ this.#hasCanonicalSeed = false;
328
+ this.#subscription?.unavailable(message.message);
329
+ this.#canonicalSeedReady();
330
+ return;
331
+ }
304
332
  const detail = message.message.trim();
305
333
  this.#failConnection(new Error(`Terminal delivery failed for ${this.#semanticPaneId}: ${message.reason}${detail ? ` (${detail})` : ""}`));
306
334
  return;
307
335
  }
336
+ if (this.#unavailable)
337
+ return;
308
338
  if (message.type === "terminal.delivery") {
309
339
  this.#acceptEnvelope(message);
310
340
  return;
@@ -601,6 +631,35 @@ class WireTerminalSubscription {
601
631
  #didClose;
602
632
  frozen = false;
603
633
  closed = false;
634
+ #unavailable = null;
635
+ #unavailableListeners = new Set();
636
+ onUnavailable(listener) {
637
+ if (this.closed)
638
+ return () => { };
639
+ this.#unavailableListeners.add(listener);
640
+ if (this.#unavailable) {
641
+ try {
642
+ listener(this.#unavailable);
643
+ }
644
+ catch {
645
+ /* Availability observers cannot interrupt sibling delivery. */
646
+ }
647
+ }
648
+ return () => this.#unavailableListeners.delete(listener);
649
+ }
650
+ unavailable(message) {
651
+ if (this.closed)
652
+ return;
653
+ this.#unavailable = message;
654
+ for (const listener of [...this.#unavailableListeners]) {
655
+ try {
656
+ listener(message);
657
+ }
658
+ catch {
659
+ /* Availability observers cannot interrupt sibling delivery. */
660
+ }
661
+ }
662
+ }
604
663
  constructor(generation, flush, didClose) {
605
664
  this.generation = generation;
606
665
  this.#flush = flush;
@@ -635,6 +694,7 @@ class WireTerminalSubscription {
635
694
  return;
636
695
  this.closed = true;
637
696
  this.#listeners.clear();
697
+ this.#unavailableListeners.clear();
638
698
  this.#didClose();
639
699
  }
640
700
  }
@@ -1170,7 +1230,7 @@ export async function connectOpenTuiWorkspaceRuntimePort(options) {
1170
1230
  });
1171
1231
  return request.result;
1172
1232
  }
1173
- if ([...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1233
+ if ([...endpoints.values()].some((endpoint) => !endpoint.settled))
1174
1234
  return Promise.resolve("geometry-authority-conflict");
1175
1235
  return (semanticWindowId === undefined
1176
1236
  ? opened.fitViewport(cols, rows)
@@ -1219,7 +1279,7 @@ export async function connectOpenTuiWorkspaceRuntimePort(options) {
1219
1279
  // until a different topology may be current; fail closed during seeding.
1220
1280
  if (closed ||
1221
1281
  !latestLayoutSnapshot.windowLinks ||
1222
- [...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1282
+ [...endpoints.values()].some((endpoint) => !endpoint.settled))
1223
1283
  throw new Error("Terminal inventory is still receiving canonical state");
1224
1284
  return (await opened.submitIntent(operationId, intent)) ?? undefined;
1225
1285
  },
@@ -496,7 +496,16 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
496
496
  }
497
497
  if (this._needsWalk) {
498
498
  this._needsWalk = false;
499
- this.walk();
499
+ try {
500
+ this.walk();
501
+ }
502
+ catch (error) {
503
+ // The source may have consumed its dirty rows before a grapheme or
504
+ // presentation pass fails. Rebuild the retained surface on retry.
505
+ this._needsWalk = true;
506
+ this._forceFull = true;
507
+ throw error;
508
+ }
500
509
  }
501
510
  else if (this._needsCursorPresentation) {
502
511
  this.applyCursorOnlyPresentation();
@@ -154,13 +154,13 @@ export function createReferenceTraceWriter(stream, options = {}) {
154
154
  firstDroppedRecord ??= droppedRecordKind(value);
155
155
  return;
156
156
  }
157
- const line = `${JSON.stringify(value)}\n`;
158
- const bytes = Buffer.byteLength(line);
159
- if (bytes > MAX_TRACE_RECORD_BYTES) {
160
- oversizedRecords += 1;
161
- return;
162
- }
163
157
  try {
158
+ const line = `${JSON.stringify(value)}\n`;
159
+ const bytes = Buffer.byteLength(line);
160
+ if (bytes > MAX_TRACE_RECORD_BYTES) {
161
+ oversizedRecords += 1;
162
+ return;
163
+ }
164
164
  if (!saturated && pendingCount() === 0) {
165
165
  writeLine(line);
166
166
  return;
@@ -233,7 +233,15 @@ export function createReferenceTraceWriter(stream, options = {}) {
233
233
  return Object.freeze({ append, appendCritical, snapshot, close });
234
234
  }
235
235
  function droppedRecordKind(value) {
236
- const field = (key) => typeof value[key] === "string" ? value[key].slice(0, 64) : null;
236
+ const field = (key) => {
237
+ try {
238
+ const result = value[key];
239
+ return typeof result === "string" ? result.slice(0, 64) : null;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ };
237
245
  return Object.freeze({
238
246
  type: field("type"),
239
247
  stage: field("stage"),
@@ -258,7 +266,12 @@ export function createReferencePerformanceTraceSink(options) {
258
266
  const inputs = new Map();
259
267
  let droppedInputs = 0;
260
268
  let closed = false;
261
- const snapshot = () => Object.freeze({ pendingInputs: inputs.size, droppedInputs });
269
+ const attempts = { begun: 0, completed: 0, superseded: 0, expired: 0, cancelled: 0 };
270
+ const snapshot = () => Object.freeze({
271
+ pendingInputs: inputs.size,
272
+ droppedInputs,
273
+ inputAttempts: Object.freeze({ ...attempts, pending: inputs.size }),
274
+ });
262
275
  return Object.freeze({
263
276
  detailedWindowPresentationFrames: detailed ? true : undefined,
264
277
  frame: (intervalMs, window) => {
@@ -464,67 +477,79 @@ export function createReferencePerformanceTraceSink(options) {
464
477
  }
465
478
  : {}),
466
479
  beginTerminalInput: (origin) => {
467
- const startedAtMicros = nowMicros();
468
- expireInputs(inputs, startedAtMicros);
469
- // The transport carries one latest-only performance trace id. Once a
470
- // newer input is admitted, an older probe can no longer be attributed to
471
- // the exact changed-cell paint without lying. Retire it as superseded;
472
- // `droppedInputs` is reserved for actual capacity loss.
473
- inputs.clear();
474
- while (inputs.size >= MAX_PENDING_INPUTS) {
475
- inputs.delete(inputs.keys().next().value);
476
- droppedInputs += 1;
480
+ if (closed)
481
+ return Object.freeze({ traceId: "", finish: () => { }, cancel: () => { } });
482
+ attempts.begun += 1;
483
+ let traceId;
484
+ try {
485
+ const startedAtMicros = nowMicros();
486
+ attempts.expired += expireInputs(inputs, startedAtMicros);
487
+ // The transport carries one latest-only performance trace id. Once a
488
+ // newer input is admitted, an older probe can no longer be attributed to
489
+ // the exact changed-cell paint without lying. Retire it as superseded;
490
+ // `droppedInputs` is reserved for actual capacity loss.
491
+ attempts.superseded += inputs.size;
492
+ inputs.clear();
493
+ while (inputs.size >= MAX_PENDING_INPUTS) {
494
+ inputs.delete(inputs.keys().next().value);
495
+ droppedInputs += 1;
496
+ attempts.cancelled += 1;
497
+ }
498
+ traceId = createTraceId();
499
+ if ((detailed || options.inputOrigin) &&
500
+ origin &&
501
+ !closed &&
502
+ typeof options.inputFingerprintKey === "string" &&
503
+ options.inputFingerprintKey.length >= 32) {
504
+ const payload = Buffer.from(origin.payload);
505
+ const event = {
506
+ processId,
507
+ clockId: "opentui-performance-now",
508
+ clockKind: "performance-now",
509
+ atMicros: Number.isSafeInteger(origin.ingressAtMicros) &&
510
+ origin.ingressAtMicros <= startedAtMicros
511
+ ? origin.ingressAtMicros
512
+ : startedAtMicros,
513
+ origin: origin.origin,
514
+ payloadByteCount: payload.byteLength,
515
+ payloadFingerprint: createHmac("sha256", options.inputFingerprintKey)
516
+ .update(traceId)
517
+ .update("\0")
518
+ .update(payload)
519
+ .digest("hex"),
520
+ parserConsumption: origin.origin === "keyboard"
521
+ ? "keyboard-event"
522
+ : origin.origin === "bracketed-paste"
523
+ ? "paste-event"
524
+ : "pointer-event",
525
+ ...(origin.gestureId ? { gestureId: origin.gestureId } : {}),
526
+ ...(origin.pointerAction ? { pointerAction: origin.pointerAction } : {}),
527
+ ...(Number.isSafeInteger(origin.pointerColumn)
528
+ ? { pointerColumn: origin.pointerColumn }
529
+ : {}),
530
+ ...(Number.isSafeInteger(origin.pointerRow) ? { pointerRow: origin.pointerRow } : {}),
531
+ ...(origin.pointerButton === null || Number.isSafeInteger(origin.pointerButton)
532
+ ? { pointerButton: origin.pointerButton }
533
+ : {}),
534
+ traceId,
535
+ semanticPaneId: origin.semanticPaneId,
536
+ generation: origin.generation,
537
+ incarnation: origin.incarnation,
538
+ revision: origin.revision,
539
+ stateHash: origin.stateHash,
540
+ };
541
+ options.append({ version: 1, type: "performance.input-origin", ...event });
542
+ }
543
+ inputs.set(traceId, {
544
+ startedAtMicros,
545
+ expiresAtMicros: startedAtMicros + INPUT_EXPIRY_MICROS,
546
+ endedAtMicros: null,
547
+ });
477
548
  }
478
- const traceId = createTraceId();
479
- if ((detailed || options.inputOrigin) &&
480
- origin &&
481
- !closed &&
482
- typeof options.inputFingerprintKey === "string" &&
483
- options.inputFingerprintKey.length >= 32) {
484
- const payload = Buffer.from(origin.payload);
485
- const event = {
486
- processId,
487
- clockId: "opentui-performance-now",
488
- clockKind: "performance-now",
489
- atMicros: Number.isSafeInteger(origin.ingressAtMicros) &&
490
- origin.ingressAtMicros <= startedAtMicros
491
- ? origin.ingressAtMicros
492
- : startedAtMicros,
493
- origin: origin.origin,
494
- payloadByteCount: payload.byteLength,
495
- payloadFingerprint: createHmac("sha256", options.inputFingerprintKey)
496
- .update(traceId)
497
- .update("\0")
498
- .update(payload)
499
- .digest("hex"),
500
- parserConsumption: origin.origin === "keyboard"
501
- ? "keyboard-event"
502
- : origin.origin === "bracketed-paste"
503
- ? "paste-event"
504
- : "pointer-event",
505
- ...(origin.gestureId ? { gestureId: origin.gestureId } : {}),
506
- ...(origin.pointerAction ? { pointerAction: origin.pointerAction } : {}),
507
- ...(Number.isSafeInteger(origin.pointerColumn)
508
- ? { pointerColumn: origin.pointerColumn }
509
- : {}),
510
- ...(Number.isSafeInteger(origin.pointerRow) ? { pointerRow: origin.pointerRow } : {}),
511
- ...(origin.pointerButton === null || Number.isSafeInteger(origin.pointerButton)
512
- ? { pointerButton: origin.pointerButton }
513
- : {}),
514
- traceId,
515
- semanticPaneId: origin.semanticPaneId,
516
- generation: origin.generation,
517
- incarnation: origin.incarnation,
518
- revision: origin.revision,
519
- stateHash: origin.stateHash,
520
- };
521
- options.append({ version: 1, type: "performance.input-origin", ...event });
549
+ catch (error) {
550
+ attempts.cancelled += 1;
551
+ throw error;
522
552
  }
523
- inputs.set(traceId, {
524
- startedAtMicros,
525
- expiresAtMicros: startedAtMicros + INPUT_EXPIRY_MICROS,
526
- endedAtMicros: null,
527
- });
528
553
  let finished = false;
529
554
  return Object.freeze({
530
555
  traceId,
@@ -536,10 +561,19 @@ export function createReferencePerformanceTraceSink(options) {
536
561
  if (input)
537
562
  input.endedAtMicros = nowMicros();
538
563
  },
539
- cancel: () => inputs.delete(traceId),
564
+ cancel: () => {
565
+ if (inputs.delete(traceId))
566
+ attempts.cancelled += 1;
567
+ },
540
568
  });
541
569
  },
542
- terminalTraceSpan: (paint) => recordCompletedTrace(inputs, paint, options.append),
570
+ terminalTraceSpan: (paint) => {
571
+ if (closed || paint.processId !== processId)
572
+ return;
573
+ const outcome = recordCompletedTrace(inputs, paint, options.append);
574
+ if (outcome)
575
+ attempts[outcome] += 1;
576
+ },
543
577
  terminalTraceStage: (event) => {
544
578
  if ((detailed || options.inputDetail) && !closed)
545
579
  options.append({ version: 1, type: "performance.stage", ...event });
@@ -560,6 +594,7 @@ export function createReferencePerformanceTraceSink(options) {
560
594
  // Shutdown cancels probes that can no longer observe a future paint.
561
595
  // They are not backpressure loss and must not survive as phantom work in
562
596
  // a closed collector summary.
597
+ attempts.cancelled += inputs.size;
563
598
  inputs.clear();
564
599
  return snapshot();
565
600
  },
@@ -568,10 +603,17 @@ export function createReferencePerformanceTraceSink(options) {
568
603
  function recordCompletedTrace(inputs, paint, appendRecord) {
569
604
  const input = inputs.get(paint.traceId);
570
605
  if (!input || input.endedAtMicros === null)
571
- return;
606
+ return null;
607
+ if (paint.clockId !== "opentui-performance-now" ||
608
+ paint.clockKind !== "performance-now" ||
609
+ !Number.isFinite(paint.startedAtMicros) ||
610
+ !Number.isFinite(paint.endedAtMicros) ||
611
+ paint.startedAtMicros < input.endedAtMicros ||
612
+ paint.endedAtMicros < paint.startedAtMicros)
613
+ return null;
572
614
  if (paint.startedAtMicros > input.expiresAtMicros) {
573
615
  inputs.delete(paint.traceId);
574
- return;
616
+ return "expired";
575
617
  }
576
618
  inputs.delete(paint.traceId);
577
619
  appendRecord({
@@ -588,11 +630,15 @@ function recordCompletedTrace(inputs, paint, appendRecord) {
588
630
  authority: { generation: paint.generation, incarnation: paint.incarnation },
589
631
  });
590
632
  appendRecord({ version: 1, type: "performance.stage", ...paint });
633
+ return "completed";
591
634
  }
592
635
  function expireInputs(inputs, nowMicros) {
636
+ let expired = 0;
593
637
  for (const [traceId, input] of inputs) {
594
638
  if (input.expiresAtMicros > nowMicros)
595
639
  break;
596
640
  inputs.delete(traceId);
641
+ expired += 1;
597
642
  }
643
+ return expired;
598
644
  }