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,334 @@
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 {
24
+ chmodSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ readdirSync,
29
+ renameSync,
30
+ statSync,
31
+ copyFileSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ accessSync,
35
+ constants,
36
+ } from "node:fs";
37
+ import { basename, delimiter, dirname, join } from "node:path";
38
+ import { resolveRuntimeNamespace, runtimeOwnedPath } from "../../lib/runtime-namespace.ts";
39
+
40
+ export const AGENT_TEAMS_ENV = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS";
41
+ export const DEFAULT_TEAMMATE_MODE = "auto";
42
+ const BACKUP_INFIX = ".tmux-ide-backup-";
43
+
44
+ type Settings = Record<string, unknown>;
45
+
46
+ export type AgentTeamsSetting = "enabled" | "disabled-by-user" | "not-configured";
47
+
48
+ /** PURE — an explicit off value is the user's opt-out. */
49
+ export function isOptOut(value: unknown): boolean {
50
+ if (value === false || value === 0) return true;
51
+ return typeof value === "string" && /^\s*(0|false|off|no)\s*$/iu.test(value);
52
+ }
53
+
54
+ function envOf(settings: Settings): Record<string, unknown> | undefined {
55
+ const env = settings.env;
56
+ if (env === undefined) return undefined;
57
+ if (!env || typeof env !== "object" || Array.isArray(env))
58
+ throw new Error("settings env must be an object");
59
+ return env as Record<string, unknown>;
60
+ }
61
+
62
+ /** PURE — how the settings currently stand for agent teams. */
63
+ export function agentTeamsSetting(settings: Settings): AgentTeamsSetting {
64
+ const value = envOf(settings)?.[AGENT_TEAMS_ENV];
65
+ if (value === undefined) return "not-configured";
66
+ return isOptOut(value) ? "disabled-by-user" : "enabled";
67
+ }
68
+
69
+ export interface AgentTeamsMerge {
70
+ readonly settings: Settings;
71
+ /** Human-readable `key=value` list of what was set; empty when unchanged. */
72
+ readonly changes: readonly string[];
73
+ readonly setting: AgentTeamsSetting;
74
+ }
75
+
76
+ /**
77
+ * PURE — enable agent teams without overriding anything the user chose, unless
78
+ * `overrideOptOut` (the user explicitly asked to enable) replaces their "0".
79
+ */
80
+ export function mergeAgentTeams(
81
+ settings: Settings,
82
+ { overrideOptOut = false }: { readonly overrideOptOut?: boolean } = {},
83
+ ): AgentTeamsMerge {
84
+ const setting = agentTeamsSetting(settings);
85
+ if (setting === "disabled-by-user" && !overrideOptOut) return { settings, changes: [], setting };
86
+ const changes: string[] = [];
87
+ let next: Settings = settings;
88
+ if (setting !== "enabled") {
89
+ next = { ...next, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "1" } };
90
+ changes.push(`env.${AGENT_TEAMS_ENV}=1`);
91
+ }
92
+ if (!("teammateMode" in settings)) {
93
+ next = { ...next, teammateMode: DEFAULT_TEAMMATE_MODE };
94
+ changes.push(`teammateMode=${DEFAULT_TEAMMATE_MODE}`);
95
+ }
96
+ return { settings: next, changes, setting: "enabled" };
97
+ }
98
+
99
+ /** PURE — record an explicit opt-out ("0"), which later enables respect. */
100
+ export function disableAgentTeams(settings: Settings): AgentTeamsMerge {
101
+ if (agentTeamsSetting(settings) === "disabled-by-user")
102
+ return { settings, changes: [], setting: "disabled-by-user" };
103
+ return {
104
+ settings: { ...settings, env: { ...(envOf(settings) ?? {}), [AGENT_TEAMS_ENV]: "0" } },
105
+ changes: [`env.${AGENT_TEAMS_ENV}=0`],
106
+ setting: "disabled-by-user",
107
+ };
108
+ }
109
+
110
+ export interface AgentTeamsPaths {
111
+ /** Claude Code's user settings file. */
112
+ readonly settingsPath: string;
113
+ /** Claude Code's home (~/.claude); its presence means Claude is installed. */
114
+ readonly claudeDir: string;
115
+ }
116
+
117
+ export function agentTeamsPaths(): AgentTeamsPaths {
118
+ const namespace = resolveRuntimeNamespace();
119
+ return { settingsPath: namespace.claudeSettingsPath, claudeDir: namespace.claudeDir };
120
+ }
121
+
122
+ /** `claude` on PATH, without spawning anything. */
123
+ export function claudeOnPath(path = process.env.PATH ?? ""): boolean {
124
+ return path
125
+ .split(delimiter)
126
+ .filter(Boolean)
127
+ .some((dir) => {
128
+ try {
129
+ accessSync(join(dir, "claude"), constants.X_OK);
130
+ return statSync(join(dir, "claude")).isFile();
131
+ } catch {
132
+ return false;
133
+ }
134
+ });
135
+ }
136
+
137
+ export class InvalidClaudeSettingsError extends Error {}
138
+
139
+ function readSettings(path: string): { settings: Settings; exists: boolean } {
140
+ if (!existsSync(path)) return { settings: {}, exists: false };
141
+ try {
142
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
143
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
144
+ throw new Error("not an object");
145
+ envOf(parsed as Settings);
146
+ return { settings: parsed as Settings, exists: true };
147
+ } catch {
148
+ throw new InvalidClaudeSettingsError(
149
+ `${path} is not valid settings JSON — fix or move it, then retry`,
150
+ );
151
+ }
152
+ }
153
+
154
+ /** Existing one-time backup, if tmux-ide already made one for this file. */
155
+ function existingBackup(settingsPath: string): string | null {
156
+ const dir = dirname(settingsPath);
157
+ const prefix = `${basename(settingsPath)}${BACKUP_INFIX}`;
158
+ try {
159
+ const found = readdirSync(dir).find((name) => name.startsWith(prefix));
160
+ return found ? join(dir, found) : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function writeSettings(path: string, settings: Settings, exists: boolean): string | null {
167
+ mkdirSync(dirname(path), { recursive: true });
168
+ let backup: string | null = null;
169
+ if (exists) {
170
+ backup = existingBackup(path);
171
+ if (!backup) {
172
+ const stamp = new Date()
173
+ .toISOString()
174
+ .replace(/[-:]/gu, "")
175
+ .replace(/\.\d+Z$/u, "Z");
176
+ backup = `${path}${BACKUP_INFIX}${stamp}`;
177
+ copyFileSync(path, backup);
178
+ }
179
+ }
180
+ const temporary = join(dirname(path), `.${basename(path)}.tmux-ide-${process.pid}.tmp`);
181
+ try {
182
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
183
+ if (exists) chmodSync(temporary, statSync(path).mode & 0o777);
184
+ renameSync(temporary, path);
185
+ } catch (error) {
186
+ try {
187
+ unlinkSync(temporary);
188
+ } catch {
189
+ /* already renamed or never written */
190
+ }
191
+ throw error;
192
+ }
193
+ return backup;
194
+ }
195
+
196
+ export interface AgentTeamsResult {
197
+ readonly action:
198
+ | "enabled"
199
+ | "disabled"
200
+ | "unchanged"
201
+ | "disabled-by-user"
202
+ | "skipped"
203
+ | "invalid";
204
+ readonly settingsPath: string;
205
+ readonly changes: readonly string[];
206
+ readonly backup: string | null;
207
+ readonly claudeDetected: boolean;
208
+ readonly message: string;
209
+ }
210
+
211
+ function detected(paths: AgentTeamsPaths, onPath: () => boolean): boolean {
212
+ return existsSync(paths.claudeDir) || onPath();
213
+ }
214
+
215
+ function guarded(paths: AgentTeamsPaths | undefined): AgentTeamsPaths {
216
+ if (!paths && resolveRuntimeNamespace().development)
217
+ throw new Error("Development agent-teams changes require explicit fixture paths");
218
+ const resolved = paths ?? agentTeamsPaths();
219
+ runtimeOwnedPath(resolved.settingsPath);
220
+ return resolved;
221
+ }
222
+
223
+ /** Enable agent teams when Claude Code is installed and the user hasn't opted out. */
224
+ export function enableClaudeAgentTeams(
225
+ paths?: AgentTeamsPaths,
226
+ onPath: () => boolean = () => claudeOnPath(),
227
+ { overrideOptOut = false }: { readonly overrideOptOut?: boolean } = {},
228
+ ): AgentTeamsResult {
229
+ const resolved = guarded(paths);
230
+ const { settingsPath } = resolved;
231
+ const base = { settingsPath, changes: [], backup: null } as const;
232
+ if (!detected(resolved, onPath))
233
+ return {
234
+ ...base,
235
+ action: "skipped",
236
+ claudeDetected: false,
237
+ message: `Claude Code not found (no ${resolved.claudeDir}, no claude on PATH); agent teams not configured.`,
238
+ };
239
+ let read: ReturnType<typeof readSettings>;
240
+ try {
241
+ read = readSettings(settingsPath);
242
+ } catch (error) {
243
+ return { ...base, action: "invalid", claudeDetected: true, message: (error as Error).message };
244
+ }
245
+ const merged = mergeAgentTeams(read.settings, { overrideOptOut });
246
+ if (merged.setting === "disabled-by-user")
247
+ return {
248
+ ...base,
249
+ action: "disabled-by-user",
250
+ claudeDetected: true,
251
+ message:
252
+ `Claude Code agent teams are disabled by user in ${settingsPath} (${AGENT_TEAMS_ENV}); ` +
253
+ "left unchanged. Enable explicitly: tmux-ide integration agent-teams enable",
254
+ };
255
+ if (merged.changes.length === 0)
256
+ return {
257
+ ...base,
258
+ action: "unchanged",
259
+ claudeDetected: true,
260
+ message: `Claude Code agent teams already enabled in ${settingsPath}.`,
261
+ };
262
+ const backup = writeSettings(settingsPath, merged.settings, read.exists);
263
+ return {
264
+ settingsPath,
265
+ changes: merged.changes,
266
+ backup,
267
+ action: "enabled",
268
+ claudeDetected: true,
269
+ message:
270
+ `Enabled Claude Code agent teams in ${settingsPath} (${merged.changes.join(", ")}` +
271
+ `${backup ? `; backup: ${backup}` : ""}). Disable: tmux-ide integration agent-teams disable`,
272
+ };
273
+ }
274
+
275
+ /** Record the user's opt-out so neither the installer nor `enable` turns it back on silently. */
276
+ export function disableClaudeAgentTeams(paths?: AgentTeamsPaths): AgentTeamsResult {
277
+ const resolved = guarded(paths);
278
+ const { settingsPath } = resolved;
279
+ const base = { settingsPath, changes: [], backup: null, claudeDetected: true } as const;
280
+ let read: ReturnType<typeof readSettings>;
281
+ try {
282
+ read = readSettings(settingsPath);
283
+ } catch (error) {
284
+ return { ...base, action: "invalid", message: (error as Error).message };
285
+ }
286
+ const next = disableAgentTeams(read.settings);
287
+ if (next.changes.length === 0)
288
+ return {
289
+ ...base,
290
+ action: "unchanged",
291
+ message: `Claude Code agent teams already disabled in ${settingsPath}.`,
292
+ };
293
+ const backup = writeSettings(settingsPath, next.settings, read.exists);
294
+ return {
295
+ ...base,
296
+ changes: next.changes,
297
+ backup,
298
+ action: "disabled",
299
+ message:
300
+ `Disabled Claude Code agent teams in ${settingsPath} (${next.changes.join(", ")}` +
301
+ `${backup ? `; backup: ${backup}` : ""}). Re-enable: tmux-ide integration agent-teams enable`,
302
+ };
303
+ }
304
+
305
+ export interface AgentTeamsStatus {
306
+ readonly settingsPath: string;
307
+ readonly claudeDetected: boolean;
308
+ readonly setting: AgentTeamsSetting | "invalid-settings";
309
+ readonly teammateMode: string | null;
310
+ }
311
+
312
+ export function claudeAgentTeamsStatus(
313
+ paths: AgentTeamsPaths = agentTeamsPaths(),
314
+ onPath: () => boolean = () => claudeOnPath(),
315
+ ): AgentTeamsStatus {
316
+ const claudeDetected = detected(paths, onPath);
317
+ try {
318
+ const { settings } = readSettings(paths.settingsPath);
319
+ const mode = settings.teammateMode;
320
+ return {
321
+ settingsPath: paths.settingsPath,
322
+ claudeDetected,
323
+ setting: agentTeamsSetting(settings),
324
+ teammateMode: typeof mode === "string" ? mode : null,
325
+ };
326
+ } catch {
327
+ return {
328
+ settingsPath: paths.settingsPath,
329
+ claudeDetected,
330
+ setting: "invalid-settings",
331
+ teammateMode: null,
332
+ };
333
+ }
334
+ }
@@ -1,3 +1,4 @@
1
+ import { STOCK_CAPTURE_TAB_UNAVAILABLE } from "@tmux-ide/contracts";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import type {
3
4
  WindowLinkTopology,
@@ -357,7 +358,12 @@ function updateFromDelivery(
357
358
  stateHash: envelope.canonicalStateHash,
358
359
  hashAlgorithm: "fnv1a64-v1" as const,
359
360
  };
360
- if (payload.frame === "seed") {
361
+ // Wire patches may span skipped revisions; ordinary canonical patches may not.
362
+ // Publish the fully verified resulting state at its actual revision.
363
+ if (
364
+ payload.frame === "seed" ||
365
+ (payload.frame === "patch" && payload.revision > payload.baseRevision + 1)
366
+ ) {
361
367
  return Object.freeze({
362
368
  ...common,
363
369
  type: "terminal.seed" as const,
@@ -410,6 +416,7 @@ class WireTerminalEndpoint {
410
416
  #resolveReady!: (ready: boolean) => void;
411
417
  #readySettled = false;
412
418
  #hasCanonicalSeed = false;
419
+ #unavailable: string | null = null;
413
420
 
414
421
  constructor(options: {
415
422
  workspaceName: string;
@@ -450,8 +457,14 @@ class WireTerminalEndpoint {
450
457
  this.#resolveReady(true);
451
458
  }
452
459
 
460
+ get settled(): boolean {
461
+ return this.#unavailable !== null || this.inputReady;
462
+ }
463
+
453
464
  get inputReady(): boolean {
454
- return !this.#closed && this.#hasCanonicalSeed && !this.#reseedRequired;
465
+ return (
466
+ !this.#closed && this.#unavailable === null && this.#hasCanonicalSeed && !this.#reseedRequired
467
+ );
455
468
  }
456
469
 
457
470
  async subscription(): Promise<
@@ -477,6 +490,7 @@ class WireTerminalEndpoint {
477
490
  },
478
491
  );
479
492
  this.#subscription = subscription;
493
+ if (this.#unavailable) subscription.unavailable(this.#unavailable);
480
494
  return subscription;
481
495
  }
482
496
 
@@ -489,6 +503,24 @@ class WireTerminalEndpoint {
489
503
  return;
490
504
  }
491
505
  if (message.type === "terminal.delivery.fault") {
506
+ if (message.deliveryNonce !== this.#negotiated.deliveryNonce) {
507
+ this.#failConnection(new Error("Terminal fault nonce does not match negotiation"));
508
+ return;
509
+ }
510
+ // The exact shared diagnostic is currently the compatibility-preserving discriminator.
511
+ if (message.reason === "source-closed" && message.message === STOCK_CAPTURE_TAB_UNAVAILABLE) {
512
+ this.#unavailable = message.message;
513
+ this.#decodeToken = null;
514
+ this.#assembler = null;
515
+ this.#assemblyStorage = null;
516
+ this.#envelope = null;
517
+ this.#pending = null;
518
+ this.#canonicalSnapshot = null;
519
+ this.#hasCanonicalSeed = false;
520
+ this.#subscription?.unavailable(message.message);
521
+ this.#canonicalSeedReady();
522
+ return;
523
+ }
492
524
  const detail = message.message.trim();
493
525
  this.#failConnection(
494
526
  new Error(
@@ -497,6 +529,7 @@ class WireTerminalEndpoint {
497
529
  );
498
530
  return;
499
531
  }
532
+ if (this.#unavailable) return;
500
533
  if (message.type === "terminal.delivery") {
501
534
  this.#acceptEnvelope(message);
502
535
  return;
@@ -859,6 +892,31 @@ class WireTerminalSubscription implements SessionRuntimeTerminalSubscription<
859
892
  readonly #didClose: () => void;
860
893
  frozen = false;
861
894
  closed = false;
895
+ #unavailable: string | null = null;
896
+ readonly #unavailableListeners = new Set<(message: string) => void>();
897
+ onUnavailable(listener: (message: string) => void): () => void {
898
+ if (this.closed) return () => {};
899
+ this.#unavailableListeners.add(listener);
900
+ if (this.#unavailable) {
901
+ try {
902
+ listener(this.#unavailable);
903
+ } catch {
904
+ /* Availability observers cannot interrupt sibling delivery. */
905
+ }
906
+ }
907
+ return () => this.#unavailableListeners.delete(listener);
908
+ }
909
+ unavailable(message: string): void {
910
+ if (this.closed) return;
911
+ this.#unavailable = message;
912
+ for (const listener of [...this.#unavailableListeners]) {
913
+ try {
914
+ listener(message);
915
+ } catch {
916
+ /* Availability observers cannot interrupt sibling delivery. */
917
+ }
918
+ }
919
+ }
862
920
 
863
921
  constructor(generation: string, flush: () => void, didClose: () => void) {
864
922
  this.generation = generation;
@@ -901,6 +959,7 @@ class WireTerminalSubscription implements SessionRuntimeTerminalSubscription<
901
959
  if (this.closed) return;
902
960
  this.closed = true;
903
961
  this.#listeners.clear();
962
+ this.#unavailableListeners.clear();
904
963
  this.#didClose();
905
964
  }
906
965
  }
@@ -1472,7 +1531,7 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1472
1531
  });
1473
1532
  return request.result;
1474
1533
  }
1475
- if ([...endpoints.values()].some((endpoint) => !endpoint.inputReady))
1534
+ if ([...endpoints.values()].some((endpoint) => !endpoint.settled))
1476
1535
  return Promise.resolve("geometry-authority-conflict");
1477
1536
  return (
1478
1537
  semanticWindowId === undefined
@@ -1525,7 +1584,7 @@ export async function connectOpenTuiWorkspaceRuntimePort(
1525
1584
  if (
1526
1585
  closed ||
1527
1586
  !latestLayoutSnapshot.windowLinks ||
1528
- [...endpoints.values()].some((endpoint) => !endpoint.inputReady)
1587
+ [...endpoints.values()].some((endpoint) => !endpoint.settled)
1529
1588
  )
1530
1589
  throw new Error("Terminal inventory is still receiving canonical state");
1531
1590
  return (await opened.submitIntent(operationId, intent)) ?? undefined;
@@ -709,7 +709,15 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
709
709
  }
710
710
  if (this._needsWalk) {
711
711
  this._needsWalk = false;
712
- this.walk();
712
+ try {
713
+ this.walk();
714
+ } catch (error) {
715
+ // The source may have consumed its dirty rows before a grapheme or
716
+ // presentation pass fails. Rebuild the retained surface on retry.
717
+ this._needsWalk = true;
718
+ this._forceFull = true;
719
+ throw error;
720
+ }
713
721
  } else if (this._needsCursorPresentation) {
714
722
  this.applyCursorOnlyPresentation();
715
723
  }