@gajae-code/coding-agent 0.9.5 → 0.10.0

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 (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/daemon-cli.d.ts +2 -0
  4. package/dist/types/cli/initial-message.d.ts +1 -1
  5. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  6. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  7. package/dist/types/cli/notify-cli.d.ts +3 -1
  8. package/dist/types/cli/session-picker.d.ts +4 -3
  9. package/dist/types/cli/update-cli.d.ts +22 -3
  10. package/dist/types/cli.d.ts +4 -0
  11. package/dist/types/commands/daemon.d.ts +6 -2
  12. package/dist/types/commands/launch.d.ts +1 -0
  13. package/dist/types/commands/notify.d.ts +6 -0
  14. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  15. package/dist/types/commit/analysis/summary.d.ts +1 -1
  16. package/dist/types/commit/changelog/generate.d.ts +1 -1
  17. package/dist/types/commit/changelog/index.d.ts +2 -2
  18. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  19. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  20. package/dist/types/config/keybindings.d.ts +5 -0
  21. package/dist/types/config/model-profile-activation.d.ts +1 -0
  22. package/dist/types/config/settings-schema.d.ts +47 -7
  23. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  24. package/dist/types/coordinator-mcp/server.d.ts +67 -6
  25. package/dist/types/daemon/control-types.d.ts +10 -0
  26. package/dist/types/daemon/operator-contract.d.ts +47 -0
  27. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  28. package/dist/types/discovery/helpers.d.ts +1 -1
  29. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  30. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  31. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  32. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  33. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  36. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  37. package/dist/types/main.d.ts +66 -2
  38. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  39. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  40. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  42. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  43. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  44. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  45. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  46. package/dist/types/modes/components/session-selector.d.ts +22 -9
  47. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  49. package/dist/types/modes/components/user-message.d.ts +2 -1
  50. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  51. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  52. package/dist/types/modes/interactive-mode.d.ts +24 -2
  53. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  54. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  56. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  57. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  58. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  59. package/dist/types/modes/types.d.ts +28 -2
  60. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  61. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  62. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  63. package/dist/types/notifications/config.d.ts +34 -0
  64. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  65. package/dist/types/notifications/index.d.ts +27 -0
  66. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  67. package/dist/types/notifications/notification-service.d.ts +144 -0
  68. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  69. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  70. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  71. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  72. package/dist/types/notifications/topic-registry.d.ts +24 -4
  73. package/dist/types/registry/agent-registry.d.ts +2 -0
  74. package/dist/types/rlm/index.d.ts +1 -1
  75. package/dist/types/sdk.d.ts +2 -0
  76. package/dist/types/secrets/obfuscator.d.ts +1 -1
  77. package/dist/types/session/agent-session.d.ts +26 -2
  78. package/dist/types/session/cache-economics.d.ts +79 -0
  79. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  80. package/dist/types/session/session-manager.d.ts +124 -4
  81. package/dist/types/session/session-storage.d.ts +155 -13
  82. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  83. package/dist/types/task/executor.d.ts +3 -0
  84. package/dist/types/task/receipt.d.ts +3 -1
  85. package/dist/types/task/types.d.ts +8 -0
  86. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  87. package/dist/types/tools/ask.d.ts +25 -1
  88. package/dist/types/tools/bash.d.ts +1 -1
  89. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  90. package/dist/types/tools/index.d.ts +58 -6
  91. package/dist/types/tools/telegram-send.d.ts +7 -0
  92. package/dist/types/utils/external-editor.d.ts +2 -0
  93. package/package.json +9 -9
  94. package/scripts/compile-args.ts +8 -2
  95. package/scripts/g004-tmux-smoke.ts +196 -36
  96. package/src/cli/daemon-cli.ts +19 -29
  97. package/src/cli/malloc-env-guard.test.ts +104 -0
  98. package/src/cli/malloc-env-guard.ts +149 -0
  99. package/src/cli/notify-cli.ts +78 -14
  100. package/src/cli/session-picker.ts +23 -40
  101. package/src/cli/update-cli.ts +56 -37
  102. package/src/cli.ts +50 -10
  103. package/src/commands/daemon.ts +19 -5
  104. package/src/commands/harness.ts +515 -47
  105. package/src/commands/launch.ts +11 -3
  106. package/src/commands/notify.ts +5 -1
  107. package/src/commands/session.ts +6 -1
  108. package/src/config/keybindings.ts +5 -0
  109. package/src/config/model-profile-activation.ts +1 -1
  110. package/src/config/model-profiles.ts +23 -23
  111. package/src/config/settings-schema.ts +41 -1
  112. package/src/coordinator-mcp/model-preset.ts +100 -0
  113. package/src/coordinator-mcp/server.ts +2653 -425
  114. package/src/daemon/control-types.ts +11 -0
  115. package/src/daemon/operator-contract.ts +121 -0
  116. package/src/deep-interview/render-middleware.ts +67 -2
  117. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  118. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  119. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  120. package/src/gjc-runtime/launch-tmux.ts +702 -135
  121. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  122. package/src/gjc-runtime/team-runtime.ts +6 -0
  123. package/src/gjc-runtime/tmux-common.ts +26 -7
  124. package/src/gjc-runtime/tmux-gc.ts +52 -9
  125. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  126. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  127. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  128. package/src/internal-urls/docs-index.generated.ts +9 -8
  129. package/src/internal-urls/local-protocol.ts +29 -10
  130. package/src/main.ts +298 -75
  131. package/src/modes/acp/acp-agent.ts +728 -98
  132. package/src/modes/acp/acp-mode.ts +29 -2
  133. package/src/modes/components/assistant-message.ts +32 -3
  134. package/src/modes/components/bash-execution.ts +43 -13
  135. package/src/modes/components/custom-editor.ts +5 -0
  136. package/src/modes/components/gajae-pet-widget.ts +360 -0
  137. package/src/modes/components/hook-selector.ts +12 -0
  138. package/src/modes/components/irc-sidebar.ts +206 -0
  139. package/src/modes/components/pet-selector.ts +55 -0
  140. package/src/modes/components/session-selector.ts +222 -221
  141. package/src/modes/components/settings-selector.ts +10 -0
  142. package/src/modes/components/tool-execution.ts +47 -6
  143. package/src/modes/components/user-message.ts +26 -9
  144. package/src/modes/controllers/command-controller.ts +28 -2
  145. package/src/modes/controllers/event-controller.ts +159 -36
  146. package/src/modes/controllers/extension-ui-controller.ts +36 -17
  147. package/src/modes/controllers/input-controller.ts +8 -2
  148. package/src/modes/controllers/selector-controller.ts +62 -15
  149. package/src/modes/interactive-mode.ts +183 -7
  150. package/src/modes/irc-observation-ledger.ts +127 -0
  151. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  152. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  153. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  154. package/src/modes/types.ts +34 -2
  155. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  156. package/src/modes/utils/irc-message.ts +138 -0
  157. package/src/modes/utils/ui-helpers.ts +216 -50
  158. package/src/notifications/config.ts +44 -0
  159. package/src/notifications/daemon-paths.ts +8 -0
  160. package/src/notifications/index.ts +535 -75
  161. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  162. package/src/notifications/notification-service.ts +710 -0
  163. package/src/notifications/rate-limit-pool.ts +46 -12
  164. package/src/notifications/telegram-daemon-cli.ts +2 -0
  165. package/src/notifications/telegram-daemon-control.ts +27 -2
  166. package/src/notifications/telegram-daemon.ts +635 -94
  167. package/src/notifications/telegram-reference.ts +73 -13
  168. package/src/notifications/topic-registry.ts +86 -9
  169. package/src/prompts/system/irc-peer-roster.md +1 -0
  170. package/src/prompts/system/subagent-system-prompt.md +1 -0
  171. package/src/prompts/system/system-prompt.md +1 -0
  172. package/src/prompts/tools/irc.md +1 -1
  173. package/src/registry/agent-registry.ts +3 -0
  174. package/src/sdk.ts +52 -1
  175. package/src/secrets/obfuscator.ts +4 -2
  176. package/src/session/agent-session.ts +396 -145
  177. package/src/session/cache-economics.ts +275 -0
  178. package/src/session/provider-safety-stop.ts +9 -0
  179. package/src/session/session-dump-format.ts +6 -0
  180. package/src/session/session-manager.ts +809 -39
  181. package/src/session/session-storage.ts +582 -38
  182. package/src/slash-commands/builtin-registry.ts +143 -9
  183. package/src/task/executor.ts +12 -3
  184. package/src/task/index.ts +8 -0
  185. package/src/task/receipt.ts +7 -2
  186. package/src/task/types.ts +26 -0
  187. package/src/tools/ask-answer-registry.ts +25 -0
  188. package/src/tools/ask.ts +355 -33
  189. package/src/tools/browser/tab-supervisor.ts +63 -6
  190. package/src/tools/browser.ts +8 -3
  191. package/src/tools/index.ts +81 -6
  192. package/src/tools/sqlite-reader.ts +8 -4
  193. package/src/tui/output-block.ts +18 -6
  194. package/src/utils/external-editor.ts +6 -1
@@ -1,11 +1,18 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { VERSION } from "@gajae-code/utils/dirs";
5
6
  import { safeStderrWrite } from "@gajae-code/utils/safe-stderr";
6
7
  import type { Args } from "../cli/args";
7
8
  import { tmuxRuntimeSessionPath } from "./session-layout";
8
- import { GJC_COORDINATOR_SESSION_ID_ENV, GJC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
9
+ import {
10
+ GJC_COORDINATOR_SESSION_ID_ENV,
11
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
12
+ GJC_TMUX_OWNER_GENERATION_ENV,
13
+ GJC_TMUX_OWNER_SERVER_KEY_ENV,
14
+ GJC_TMUX_OWNER_STATE_DIR_ENV,
15
+ } from "./session-state-sidecar";
9
16
  import {
10
17
  buildGjcTmuxExactOptionTarget,
11
18
  buildGjcTmuxExactSessionTarget,
@@ -22,7 +29,23 @@ import {
22
29
  resolveGjcTmuxBinary,
23
30
  resolveGjcTmuxCommand,
24
31
  } from "./tmux-common";
25
- import { findGjcTmuxSessionByName, findGjcTmuxSessionByScope, type GjcTmuxSessionStatus } from "./tmux-sessions";
32
+ import {
33
+ captureOwnerGenerationBaselineSync,
34
+ classifyCgroup,
35
+ executeTmuxOwnerIsolationPlanSync,
36
+ isOwnerGenerationBaselineCurrentSync,
37
+ lifecyclePaths,
38
+ type OwnerIsolationProbeSync,
39
+ planTmuxOwnerIsolationSync,
40
+ replaceOwnerGenerationSync,
41
+ type TmuxServerProof,
42
+ } from "./tmux-owner-isolation";
43
+ import {
44
+ findGjcTmuxSessionByName,
45
+ findGjcTmuxSessionByScope,
46
+ type GjcTmuxSessionStatus,
47
+ proveGjcTmuxSessionMutationTarget,
48
+ } from "./tmux-sessions";
26
49
 
27
50
  export {
28
51
  buildGjcTmuxExactSessionTarget,
@@ -37,7 +60,7 @@ export {
37
60
  export const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
38
61
  export const GJC_LAUNCH_POLICY_ENV = "GJC_LAUNCH_POLICY";
39
62
  export const GJC_TMUX_WINDOW_LABEL_MAX_WIDTH = 48;
40
- export const GJC_PSMUX_PROFILE_FORCE_ENV = "GJC_PSMUX_PROFILE_FORCE";
63
+
41
64
  const WINDOWS_PSMUX_ATTACH_RETRY_DELAY_MS = 100;
42
65
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
43
66
 
@@ -67,12 +90,20 @@ export interface TmuxLaunchContext {
67
90
  existingBranchSessionName?: string | null;
68
91
  project?: string | null;
69
92
  diagnosticWriter?: (message: string) => void;
93
+ /**
94
+ * Synchronous owner-isolation proof boundary for managed tmux creation.
95
+ * Production uses Linux /proc probes; callers may inject deterministic proofs.
96
+ */
97
+ ownerIsolationProbe?: OwnerIsolationProbeSync;
98
+ /** Test seam for deterministic default-probe caller cgroup classification. */
99
+ callerCgroupReader?: () => string | null;
70
100
  }
71
101
 
72
102
  export interface TmuxSpawnResult {
73
103
  exitCode: number | null;
74
104
  signalCode?: string | null;
75
105
  stderr?: string;
106
+ stdout?: string;
76
107
  }
77
108
  export interface TmuxTerminalSize {
78
109
  columns: number;
@@ -88,14 +119,12 @@ export interface TmuxSpawnOptions {
88
119
  stdout: "inherit" | "pipe";
89
120
  stderr: "inherit" | "pipe";
90
121
  /**
91
- * When true, the spawn captures stderr into a buffer and forwards it to
92
- * the parent stderr so the user still sees the live output. The captured
93
- * text is also returned in TmuxSpawnResult.stderr for diagnostic
94
- * surfacing in "attach failed" / "profile tagging failed" messages.
95
- * Defaults to false to preserve the previous PTY-binding behavior for
96
- * attach-session and other interactive commands.
122
+ * Captures control-plane stderr for sanitized, bounded diagnostics. PTY-bound
123
+ * commands retain inherited stderr for multiplexer compatibility.
97
124
  */
98
125
  captureStderr?: boolean;
126
+ /** Internal scoped-bootstrap input; never used for ordinary tmux commands. */
127
+ stdinLine?: string;
99
128
  }
100
129
 
101
130
  export interface TmuxLaunchPlan {
@@ -110,6 +139,14 @@ export interface TmuxLaunchPlan {
110
139
  project?: string | null;
111
140
  sessionId?: string | null;
112
141
  sessionStateFile?: string | null;
142
+ /** Immutable Linux-managed owner provenance, assigned immediately before creation. */
143
+ ownerGeneration?: string;
144
+ /** Generation state captured before owner-isolation planning; required for publication CAS. */
145
+ ownerGenerationBaseline?: import("./tmux-owner-isolation").OwnerGenerationBaseline;
146
+ /** Native tmux session identity emitted atomically by `new-session -P -F`. */
147
+ createdSessionId?: string;
148
+ /** Safe server identity proven immediately after creation. */
149
+ createdServerIdentity?: { pid: number; startTime: string };
113
150
  isPsmux: boolean;
114
151
  platform: NodeJS.Platform;
115
152
  }
@@ -155,6 +192,8 @@ export interface GjcTmuxProfileContext {
155
192
  project?: string | null;
156
193
  sessionId?: string | null;
157
194
  sessionStateFile?: string | null;
195
+ ownerGeneration?: string | null;
196
+ ownerServerKey?: string | null;
158
197
  version?: string | null;
159
198
  }
160
199
 
@@ -222,12 +261,29 @@ function isBunVirtualPath(value: string | undefined): boolean {
222
261
  return value?.startsWith("/$bunfs/") === true;
223
262
  }
224
263
 
264
+ const MAX_TMUX_DIAGNOSTIC_DETAIL_CODE_POINTS = 240;
265
+ const TERMINAL_DIAGNOSTIC_CONTROLS =
266
+ /(?:\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[PX^_][^\x1b]*(?:\x1b\\)?|.)?|\u009b[0-?]*[ -/]*[@-~]|\u009d[^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)?|[\u0000-\u001f\u007f-\u009f])/g;
267
+
268
+ function sanitizeTmuxDiagnostic(stderr: string | undefined): string {
269
+ const detail = stderr?.replace(TERMINAL_DIAGNOSTIC_CONTROLS, "").trim() ?? "";
270
+ return Array.from(detail).slice(0, MAX_TMUX_DIAGNOSTIC_DETAIL_CODE_POINTS).join("");
271
+ }
272
+
225
273
  function formatTmuxLaunchDiagnostic(stage: string, stderr?: string): string {
226
- const detail = stderr?.trim();
227
- const suffix = detail ? ` ${detail.slice(0, 240)}` : "";
274
+ const detail = sanitizeTmuxDiagnostic(stderr);
275
+ const suffix = detail ? ` ${detail}` : "";
228
276
  return `gjc --tmux failed after creating tmux session: ${stage}.${suffix}\n`;
229
277
  }
230
278
 
279
+ function failedRetryDiagnostic(retry: TmuxSpawnResult, retryProbe: TmuxSpawnResult): string | undefined {
280
+ return retry.exitCode !== 0 ? retry.stderr : retryProbe.stderr;
281
+ }
282
+
283
+ function isExplicitTmuxRequest(context: TmuxLaunchContext): boolean {
284
+ return context.parsed.tmux === true && context.rawArgs.includes("--tmux");
285
+ }
286
+
231
287
  /**
232
288
  * Detect a corrupted gjc.cmd / gjc.bat wrapper at well-known PATH locations.
233
289
  * On Windows, `gjc.cmd` / `gjc.bat` files at the front of PATH that turn out
@@ -270,16 +326,16 @@ function detectCorruptedGjcWrapper(): string | null {
270
326
  }
271
327
  return null;
272
328
  }
273
- function formatTmuxUnavailableDiagnostic(platform: NodeJS.Platform, tmuxCommand: string): string {
329
+ function formatTmuxUnavailableDiagnostic(platform: NodeJS.Platform): string {
274
330
  if (platform === "win32") {
275
331
  return (
276
- `gjc --tmux requested but no tmux executable was found; starting without a tmux-backed session. ` +
277
- `GJC searched for psmux, pmux, and tmux on PATH (got \`${tmuxCommand}\`). ` +
332
+ `gjc --tmux requested but no tmux executable was found; cannot continue without a tmux-backed session. ` +
333
+ "GJC searched for psmux, pmux, and tmux on PATH. " +
278
334
  "Install psmux (https://github.com/psmux/psmux) for native Windows tmux support, or use WSL with real tmux. " +
279
335
  "You can also point GJC at a specific binary via GJC_TMUX_COMMAND.\n"
280
336
  );
281
337
  }
282
- return `gjc --tmux requested but no ${tmuxCommand} executable was found; starting without a tmux-backed session.\n`;
338
+ return "gjc --tmux requested but no tmux executable was found; cannot continue without a tmux-backed session.\n";
283
339
  }
284
340
 
285
341
  function shellQuote(value: string): string {
@@ -359,6 +415,8 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
359
415
  project: context.project ?? null,
360
416
  sessionId: context.sessionId ?? env[GJC_COORDINATOR_SESSION_ID_ENV] ?? null,
361
417
  sessionStateFile: context.sessionStateFile ?? env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV] ?? null,
418
+ ownerGeneration: context.ownerGeneration ?? null,
419
+ ownerServerKey: context.ownerServerKey ?? null,
362
420
  version: context.version ?? null,
363
421
  },
364
422
  { tmuxCommand: context.tmuxCommand },
@@ -369,9 +427,9 @@ export function applyGjcTmuxProfile(context: GjcTmuxProfileContext): GjcTmuxProf
369
427
  const options: TmuxSpawnOptions = {
370
428
  cwd,
371
429
  env,
372
- stdin: "inherit",
373
- stdout: "inherit",
374
- stderr: "inherit",
430
+ stdin: "pipe",
431
+ stdout: "pipe",
432
+ stderr: "pipe",
375
433
  captureStderr: true,
376
434
  };
377
435
  const failures: GjcTmuxProfileResult["failures"] = [];
@@ -524,13 +582,13 @@ function buildGjcTmuxRootTerminalTitleCommands(
524
582
  function applyGjcTmuxRootTerminalTitleProfile(context: {
525
583
  tmuxCommand: string;
526
584
  target: string;
585
+ sessionName: string;
527
586
  title: string | undefined;
528
587
  spawnSync: TmuxSpawnSync;
529
588
  options: TmuxSpawnOptions;
530
589
  }): void {
531
590
  if (!context.title) return;
532
- const optionTarget = buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env });
533
- for (const command of buildGjcTmuxRootTerminalTitleCommands(optionTarget, context.target, context.title)) {
591
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(context.target, context.sessionName, context.title)) {
534
592
  context.spawnSync(context.tmuxCommand, command.args, context.options);
535
593
  }
536
594
  }
@@ -577,9 +635,10 @@ function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
577
635
  renameTmuxWindow(tmuxCommand, title, spawnSync, {
578
636
  cwd,
579
637
  env,
580
- stdin: "inherit",
581
- stdout: "inherit",
582
- stderr: "inherit",
638
+ stdin: "pipe",
639
+ stdout: "pipe",
640
+ stderr: "pipe",
641
+ captureStderr: true,
583
642
  });
584
643
  }
585
644
 
@@ -598,21 +657,110 @@ function readCurrentBranch(cwd: string): string | null {
598
657
  }
599
658
  }
600
659
 
601
- function cleanupCreatedTmuxSession(plan: TmuxLaunchPlan, spawnSync: TmuxSpawnSync, options: TmuxSpawnOptions): void {
602
- spawnSync(
660
+ function createdSessionTarget(plan: TmuxLaunchPlan): string {
661
+ return plan.createdSessionId ?? plan.sessionName;
662
+ }
663
+
664
+ function createdSessionExactTarget(plan: TmuxLaunchPlan, env: NodeJS.ProcessEnv): string {
665
+ return plan.createdSessionId ?? buildGjcTmuxExactSessionTarget(plan.sessionName, { env });
666
+ }
667
+
668
+ function createdSessionOptionTarget(plan: TmuxLaunchPlan, env: NodeJS.ProcessEnv): string {
669
+ // Native tmux assigns `$N` atomically during creation. Keep that immutable
670
+ // identity (with its required empty-window suffix) for every later option
671
+ // mutation; a reusable session name could resolve to a different session.
672
+ if (plan.createdSessionId) return `${plan.createdSessionId}:`;
673
+ return buildGjcTmuxExactOptionTarget(plan.sessionName, { env });
674
+ }
675
+
676
+ function cleanupCreatedTmuxSession(
677
+ plan: TmuxLaunchPlan,
678
+ spawnSync: TmuxSpawnSync,
679
+ options: TmuxSpawnOptions,
680
+ probe: OwnerIsolationProbeSync,
681
+ ): void {
682
+ if (!isCreatedTmuxSessionIdentityStable(plan, spawnSync, options, probe))
683
+ throw new Error("gjc_tmux_exact_cleanup_uncertain");
684
+ const nativeSessionId = plan.createdSessionId!;
685
+ const expectedPid = plan.createdServerIdentity!.pid;
686
+ const guarded = spawnSync(
603
687
  plan.tmuxCommand,
604
- ["kill-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
688
+ [
689
+ "if-shell",
690
+ "-t",
691
+ nativeSessionId,
692
+ "-F",
693
+ `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${plan.sessionName}}}}`,
694
+ `kill-session -t ${nativeSessionId} \\; display-message -p __gjc_tmux_guarded_cleanup_ok__`,
695
+ "display-message -p __gjc_tmux_guarded_cleanup_refused__",
696
+ ],
605
697
  options,
606
698
  );
699
+ if (guarded.exitCode !== 0 || guarded.stdout?.trim() !== "__gjc_tmux_guarded_cleanup_ok__")
700
+ throw new Error("gjc_tmux_exact_cleanup_uncertain");
701
+ }
702
+
703
+ function cleanupCreatedTmuxSessionAfterFailure(
704
+ plan: TmuxLaunchPlan,
705
+ spawnSync: TmuxSpawnSync,
706
+ options: TmuxSpawnOptions,
707
+ probe: OwnerIsolationProbeSync,
708
+ primary: Error,
709
+ ): void {
710
+ try {
711
+ cleanupCreatedTmuxSession(plan, spawnSync, options, probe);
712
+ } catch (cleanupError) {
713
+ throw new AggregateError([primary, cleanupError], "gjc_tmux_exact_cleanup_uncertain");
714
+ }
715
+ }
716
+
717
+ function isCreatedTmuxSessionIdentityStable(
718
+ plan: TmuxLaunchPlan,
719
+ spawnSync: TmuxSpawnSync,
720
+ options: TmuxSpawnOptions,
721
+ probe: OwnerIsolationProbeSync,
722
+ ): boolean {
723
+ if (plan.isPsmux || !plan.createdSessionId || !plan.createdServerIdentity) return false;
724
+ try {
725
+ const before = probe.probeServer(plan.tmuxCommand);
726
+ if (
727
+ before.state !== "safe" ||
728
+ before.pid !== plan.createdServerIdentity.pid ||
729
+ before.startTime !== plan.createdServerIdentity.startTime
730
+ )
731
+ return false;
732
+ const binding = spawnSync(
733
+ plan.tmuxCommand,
734
+ ["display-message", "-p", "-t", plan.createdSessionId, "#{session_id}\t#{session_name}"],
735
+ options,
736
+ );
737
+ const after = probe.probeServer(plan.tmuxCommand);
738
+ return (
739
+ binding.exitCode === 0 &&
740
+ binding.stdout?.trim() === `${plan.createdSessionId}\t${plan.sessionName}` &&
741
+ after.state === "safe" &&
742
+ after.pid === before.pid &&
743
+ after.startTime === before.startTime
744
+ );
745
+ } catch {
746
+ return false;
747
+ }
607
748
  }
608
749
  function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
609
750
  if (result.signalCode === "SIGHUP") return true;
610
- const stderr = result.stderr?.toLowerCase() ?? "";
611
- return stderr.includes("eio") || stderr.includes("input/output error");
751
+ return /\b(?:EIO|input\/output error)\b/i.test(result.stderr ?? "");
612
752
  }
613
753
  function isWindowsPsmuxAttachConnectionRefused(plan: TmuxLaunchPlan, result: TmuxSpawnResult): boolean {
614
754
  if (plan.platform !== "win32" || !plan.isPsmux) return false;
615
- return result.stderr?.toLowerCase().includes("os error 10061") === true;
755
+ return /\bos error 10061\b/i.test(result.stderr ?? "");
756
+ }
757
+
758
+ function isWindowsPsmuxMissingSessionRegistrationRace(
759
+ plan: TmuxLaunchPlan,
760
+ result: { exitCode?: number | null; stderr?: string },
761
+ ): boolean {
762
+ if (plan.platform !== "win32" || !plan.isPsmux || result.exitCode === 0) return false;
763
+ return result.stderr?.trim() === `psmux: can't find session '=${plan.sessionName}' (no server running)`;
616
764
  }
617
765
  function waitForWindowsPsmuxAttachRetry(): void {
618
766
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, WINDOWS_PSMUX_ATTACH_RETRY_DELAY_MS);
@@ -682,7 +830,7 @@ function ensureCreatedTmuxWindowTracksCallerTerminal(
682
830
  options: TmuxSpawnOptions,
683
831
  ): void {
684
832
  if (!plan.initialSize) return;
685
- const target = buildGjcTmuxExactOptionTarget(plan.sessionName, { env: options.env });
833
+ const target = createdSessionOptionTarget(plan, options.env);
686
834
  if (plan.isPsmux) {
687
835
  spawnSync(
688
836
  plan.tmuxCommand,
@@ -728,7 +876,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
728
876
  tmuxRuntimeSessionPath(cwd, gjcSessionId, buildGjcTmuxSessionSlug(sessionName));
729
877
  const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
730
878
  if (!tmuxAvailable) {
731
- (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxUnavailableDiagnostic(platform, tmuxCommand));
879
+ (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxUnavailableDiagnostic(platform));
732
880
  return undefined;
733
881
  }
734
882
  const existingSessionName = allowsExistingTmuxAttach(context.parsed, env)
@@ -778,6 +926,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
778
926
  sessionName,
779
927
  "-c",
780
928
  cwd,
929
+ ...(resolvedBinary.isPsmux ? [] : ["-P", "-F", "#{session_id}"]),
781
930
  innerCommand,
782
931
  ],
783
932
  initialSize,
@@ -789,63 +938,306 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
789
938
  };
790
939
  }
791
940
 
941
+ function prepareManagedOwnerLifecycle(plan: TmuxLaunchPlan, context: TmuxLaunchContext): void {
942
+ if (plan.ownerGeneration) return;
943
+ const sessionId = plan.sessionId ?? plan.sessionName;
944
+ const stateDir = path.dirname(plan.sessionStateFile ?? path.join(plan.cwd, ".gjc", "runtime"));
945
+ const generation = crypto.randomUUID();
946
+ // Stage the generation in the child command. It becomes current only after
947
+ // immutable creation proof and ownership tagging complete.
948
+
949
+ plan.ownerGeneration = generation;
950
+ const innerCommand = buildInnerCommand(
951
+ {
952
+ cwd: plan.cwd,
953
+ argv: context.argv ?? process.argv,
954
+ execPath: context.execPath ?? process.execPath,
955
+ extraEnv: {
956
+ [GJC_COORDINATOR_SESSION_ID_ENV]: sessionId,
957
+ [GJC_COORDINATOR_SESSION_STATE_FILE_ENV]: plan.sessionStateFile ?? "",
958
+ [GJC_TMUX_ACTIVE_SESSION_ENV]: plan.sessionName,
959
+ [GJC_TMUX_OWNER_GENERATION_ENV]: generation,
960
+ [GJC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
961
+ [GJC_TMUX_OWNER_SERVER_KEY_ENV]: plan.tmuxCommand,
962
+ },
963
+ // Linux managed owner close signals the pane PID. Do not place the exit-marker shell
964
+ // in front of it; `buildInnerCommand` therefore execs the GJC owner directly.
965
+ tmuxExitMarkerPath: plan.platform === "linux" ? undefined : tmuxExitMarkerPath(plan.sessionStateFile ?? ""),
966
+ platform: plan.platform,
967
+ },
968
+ context.rawArgs,
969
+ );
970
+ plan.innerCommand = innerCommand;
971
+ plan.newSessionArgs = [...plan.newSessionArgs.slice(0, -1), innerCommand];
972
+ }
973
+
792
974
  function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOptions): TmuxSpawnResult {
793
- // When captureStderr is set on the options, route stderr through a
794
- // pipe so we can both forward it to the parent stderr (so the user
795
- // still sees the live output) and retain it in TmuxSpawnResult.stderr
796
- // for diagnostic surfacing. PTY-bound commands (attach-session) keep
797
- // stderr: "inherit" because psmux needs a real terminal handle.
798
- const stdio = options.captureStderr
799
- ? { stdin: options.stdin, stdout: options.stdout, stderr: "pipe" as const }
800
- : { stdin: options.stdin, stdout: options.stdout, stderr: options.stderr };
975
+ // Only attach-session is interactive. Every other command is control-plane
976
+ // traffic and must not write unbounded, untrusted terminal bytes directly.
977
+ const interactiveAttach = args[0] === "attach-session";
978
+ const stdin = options.stdinLine === undefined ? options.stdin : Buffer.from(`${options.stdinLine}\n`);
979
+ const stdio = interactiveAttach
980
+ ? { stdin, stdout: options.stdout, stderr: options.stderr }
981
+ : { stdin, stdout: options.stdout, stderr: "pipe" as const };
801
982
  const result = Bun.spawnSync({
802
983
  cmd: [command, ...args],
803
984
  cwd: options.cwd,
804
985
  env: options.env,
805
986
  ...stdio,
806
987
  });
807
- let stderrText: string | undefined;
808
- if (options.captureStderr) {
809
- const stderrBytes = result.stderr;
810
- stderrText = stderrBytes ? new TextDecoder().decode(stderrBytes) : "";
811
- if (stderrText.length > 0) {
988
+ const stderrText = stdio.stderr === "pipe" ? new TextDecoder().decode(result.stderr) : undefined;
989
+ return {
990
+ exitCode: result.exitCode,
991
+ signalCode: result.signalCode,
992
+ stderr: stderrText,
993
+ stdout: result.stdout ? new TextDecoder().decode(result.stdout) : "",
994
+ };
995
+ }
996
+
997
+ function defaultOwnerIsolationProbe(
998
+ plan: TmuxLaunchPlan,
999
+ env: NodeJS.ProcessEnv,
1000
+ spawn: TmuxSpawnSync,
1001
+ callerCgroupReader?: () => string | null,
1002
+ ): OwnerIsolationProbeSync {
1003
+ const stateDir = path.dirname(plan.sessionStateFile ?? path.join(plan.cwd, ".gjc", "runtime"));
1004
+ const probeServer = (): TmuxServerProof => {
1005
+ if (plan.platform !== "linux") {
1006
+ return { state: "safe", pid: 1, startTime: "not-applicable", cgroup: { classification: "not_applicable" } };
1007
+ }
1008
+ const probe = spawn(plan.tmuxCommand, ["display-message", "-p", "#{pid}"], {
1009
+ cwd: plan.cwd,
1010
+ env,
1011
+ stdin: "pipe",
1012
+ stdout: "pipe",
1013
+ stderr: "pipe",
1014
+ });
1015
+ if (probe.exitCode !== 0) {
1016
+ return /no server running|failed to connect|error connecting/.test(probe.stderr ?? "")
1017
+ ? { state: "absent" }
1018
+ : { state: "unverifiable" };
1019
+ }
1020
+ const pid = Number(probe.stdout?.trim());
1021
+ let startTime: string | undefined;
1022
+ let cgroupText: string | null = null;
1023
+ try {
1024
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
1025
+ startTime = stat
1026
+ .slice(stat.lastIndexOf(")") + 2)
1027
+ .trim()
1028
+ .split(/\s+/)[19];
1029
+ cgroupText = fs.readFileSync(`/proc/${pid}/cgroup`, "utf8");
1030
+ } catch {}
1031
+ if (!startTime) return { state: "unverifiable" };
1032
+ const cgroup = classifyCgroup({ platform: plan.platform, cgroupText });
1033
+ return {
1034
+ state:
1035
+ cgroup.classification === "safe"
1036
+ ? "safe"
1037
+ : cgroup.classification === "unsafe_service"
1038
+ ? "unsafe"
1039
+ : "unverifiable",
1040
+ pid,
1041
+ startTime,
1042
+ cgroup,
1043
+ };
1044
+ };
1045
+ return {
1046
+ readCallerCgroup:
1047
+ plan.platform !== "linux"
1048
+ ? () => null
1049
+ : (callerCgroupReader ??
1050
+ (() => {
1051
+ try {
1052
+ return fs.readFileSync("/proc/self/cgroup", "utf8");
1053
+ } catch {
1054
+ return null;
1055
+ }
1056
+ })),
1057
+ probeServer,
1058
+ recordAttempt: ({ attempt }) => {
1059
+ const generation = plan.ownerGeneration ?? plan.sessionName;
1060
+ const root = lifecyclePaths(stateDir, plan.sessionId ?? plan.sessionName, generation).root;
1061
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
1062
+ const file = path.join(root, `attempt-${attempt.token}.json`);
1063
+ let descriptor: number | undefined;
812
1064
  try {
813
- process.stderr.write(stderrText);
814
- } catch {
815
- // parent stderr already closed during shutdown; ignore.
1065
+ descriptor = fs.openSync(file, "wx", 0o600);
1066
+ fs.writeFileSync(
1067
+ descriptor,
1068
+ `${JSON.stringify({
1069
+ schema_version: 1,
1070
+ generation,
1071
+ session_id: plan.sessionId ?? plan.sessionName,
1072
+ ...attempt,
1073
+ created_at: new Date().toISOString(),
1074
+ })}\n`,
1075
+ );
1076
+ fs.fsyncSync(descriptor);
1077
+ fs.closeSync(descriptor);
1078
+ descriptor = undefined;
1079
+ const directory = fs.openSync(root, "r");
1080
+ try {
1081
+ fs.fsyncSync(directory);
1082
+ } finally {
1083
+ fs.closeSync(directory);
1084
+ }
1085
+ } finally {
1086
+ if (descriptor !== undefined) fs.closeSync(descriptor);
816
1087
  }
817
- }
1088
+ },
1089
+ };
1090
+ }
1091
+
1092
+ function createIsolatedTmuxSession(
1093
+ plan: TmuxLaunchPlan,
1094
+ spawn: TmuxSpawnSync,
1095
+ options: TmuxSpawnOptions,
1096
+ diagnostic: (message: string) => void,
1097
+ probe: OwnerIsolationProbeSync,
1098
+ ): TmuxSpawnResult {
1099
+ const sessionId = plan.sessionId ?? plan.sessionName;
1100
+ const stateDir = path.dirname(plan.sessionStateFile ?? path.join(plan.cwd, ".gjc", "runtime"));
1101
+ const baseline = captureOwnerGenerationBaselineSync(stateDir, sessionId);
1102
+ plan.ownerGenerationBaseline = baseline;
1103
+ const ownerPlan = planTmuxOwnerIsolationSync(
1104
+ {
1105
+ schema_version: 1,
1106
+ op: "plan",
1107
+ platform: plan.platform,
1108
+ session_id: sessionId,
1109
+ owner_generation: plan.ownerGeneration ?? plan.sessionName,
1110
+ baseline,
1111
+ cwd: plan.cwd,
1112
+ state_dir: stateDir,
1113
+ socket_key: plan.tmuxCommand,
1114
+ tmux_argv: [plan.tmuxCommand, ...plan.newSessionArgs],
1115
+ },
1116
+ probe,
1117
+ );
1118
+ let executed: TmuxSpawnResult | undefined;
1119
+ const outcome = executeTmuxOwnerIsolationPlanSync(ownerPlan, {
1120
+ socketKey: plan.tmuxCommand,
1121
+ spawn: (argv, stdinLine) => {
1122
+ executed = spawn(argv[0]!, argv.slice(1), {
1123
+ ...options,
1124
+ stdin: stdinLine ? "pipe" : options.stdin,
1125
+ stdinLine,
1126
+ });
1127
+ if (!plan.isPsmux) {
1128
+ const nativeSessionId = executed.stdout?.trim();
1129
+ if (/^\$\d+$/.test(nativeSessionId ?? "")) plan.createdSessionId = nativeSessionId;
1130
+ }
1131
+ return { exitCode: executed.exitCode, stdout: executed.stdout };
1132
+ },
1133
+ probeServer: probe.probeServer,
1134
+ isCurrentGeneration: () => isOwnerGenerationBaselineCurrentSync(stateDir, sessionId, baseline),
1135
+ cleanupSpawned: ({ nativeSessionId, server }) => {
1136
+ plan.createdSessionId = nativeSessionId;
1137
+ plan.createdServerIdentity = { pid: server.pid!, startTime: server.startTime! };
1138
+ cleanupCreatedTmuxSessionAfterFailure(plan, spawn, options, probe, new Error("owner_generation_stale"));
1139
+ },
1140
+ });
1141
+ // A failed planned spawn is the new-session failure. Let its established
1142
+ // diagnostic path report it exactly once instead of replacing it with an
1143
+ // isolation diagnostic.
1144
+ if (executed && executed.exitCode !== 0) return executed;
1145
+ if (!outcome.ok) {
1146
+ diagnostic(`tmux owner isolation failed: ${outcome.code}`);
1147
+ return { exitCode: 1, stderr: outcome.diagnostic };
1148
+ }
1149
+ if (!plan.isPsmux && outcome.native_session_id) plan.createdSessionId = outcome.native_session_id;
1150
+ plan.createdServerIdentity = { pid: outcome.server_pid, startTime: outcome.server_start_time };
1151
+ return executed ?? { exitCode: 1, stderr: "tmux owner isolation did not execute" };
1152
+ }
1153
+
1154
+ function requiredProfileFailure(profile: GjcTmuxProfileResult): GjcTmuxProfileResult["failures"][number] | undefined {
1155
+ const requiredOptions = new Set([
1156
+ "@gjc-profile",
1157
+ "@gjc-session-id",
1158
+ "@gjc-session-state-file",
1159
+ "@gjc-owner-generation",
1160
+ "@gjc-owner-server-key",
1161
+ ]);
1162
+ return profile.failures.find(item => requiredOptions.has(String(item.command.args[item.command.args.length - 2])));
1163
+ }
1164
+
1165
+ function emitOptionalProfileDiagnostics(profile: GjcTmuxProfileResult, diagnostic: (message: string) => void): void {
1166
+ for (const failure of profile.failures) {
1167
+ if (requiredProfileFailure({ ...profile, failures: [failure] })) continue;
1168
+ diagnostic("optional tmux profile command failed");
818
1169
  }
819
- return { exitCode: result.exitCode, signalCode: result.signalCode, stderr: stderrText };
820
1170
  }
821
1171
 
822
1172
  export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
1173
+ const env = context.env ?? process.env;
1174
+ // Planning performs only applicability checks. It must precede both the ambient
1175
+ // window rename and provider refusal so inapplicable root launches reach main
1176
+ // unchanged, while unsupported managed launches fail before any tmux mutation.
1177
+ const plan = buildDefaultTmuxLaunchPlan(context);
1178
+ if (plan?.isPsmux) {
1179
+ (context.diagnosticWriter ?? safeStderrWrite)(
1180
+ "psmux cannot provide immutable owner identity; refusing managed session creation.\n",
1181
+ );
1182
+ throw new Error("gjc_tmux_owner_isolation_native_session_identity_unavailable");
1183
+ }
1184
+ // Direct launches inside an ambient tmux session retain their existing title
1185
+ // behavior, but only after managed-launch applicability was ruled out.
823
1186
  renameExistingTmuxWindowIfNeeded(context);
1187
+ if (!plan) {
1188
+ const env = context.env ?? process.env;
1189
+ const tty = context.tty ?? {
1190
+ stdin: Boolean(process.stdin.isTTY),
1191
+ stdout: Boolean(process.stdout.isTTY),
1192
+ };
1193
+ const platform = context.platform ?? process.platform;
1194
+ const tmuxCommand = resolveGjcTmuxBinary({ platform, env }).command;
1195
+ const tmuxAvailable = context.tmuxAvailable ?? Bun.which(tmuxCommand) !== null;
1196
+ if (
1197
+ isExplicitTmuxRequest(context) &&
1198
+ parseLaunchPolicy(env) !== "direct" &&
1199
+ !env.TMUX &&
1200
+ env[GJC_TMUX_LAUNCHED_ENV] !== "1" &&
1201
+ isInteractiveRootLaunch(context.parsed, tty) &&
1202
+ !tmuxAvailable
1203
+ )
1204
+ return true;
1205
+ return false;
1206
+ }
824
1207
 
825
- const plan = buildDefaultTmuxLaunchPlan(context);
826
- if (!plan) return false;
827
- const env = context.env ?? process.env;
828
- const spawnSync = context.spawnSync ?? defaultSpawnSync;
1208
+ const rawSpawnSync = context.spawnSync ?? defaultSpawnSync;
829
1209
  const options: TmuxSpawnOptions = {
830
1210
  cwd: plan.cwd,
831
1211
  env,
1212
+
832
1213
  stdin: "inherit",
833
1214
  stdout: "inherit",
834
1215
  stderr: "inherit",
835
1216
  };
1217
+ const ownerIsolationProbe =
1218
+ context.ownerIsolationProbe ?? defaultOwnerIsolationProbe(plan, env, rawSpawnSync, context.callerCgroupReader);
1219
+ const spawnSync = rawSpawnSync;
836
1220
  const attachOptions: TmuxSpawnOptions = { ...options };
837
- const controlOptions: TmuxSpawnOptions = { ...options, captureStderr: true };
838
- // has-session / new-session retry / profile-tagging probe share these
839
- // pipe-stdio options. PTY-bound commands (attach-session, rename-window,
840
- // set-option in applyGjcTmuxProfile when we explicitly need the live TTY)
841
- // use `options` or `attachOptions` instead.
842
- const probeOptions: TmuxSpawnOptions = {
1221
+ // Psmux is rejected before planning any tmux mutation, including an ambient
1222
+ // TMUX window rename. Keep this defense in depth guard for future plan seams.
1223
+ if (plan.isPsmux) {
1224
+ (context.diagnosticWriter ?? safeStderrWrite)(
1225
+ "psmux cannot provide immutable owner identity; refusing managed session creation.\n",
1226
+ );
1227
+ throw new Error("gjc_tmux_owner_isolation_native_session_identity_unavailable");
1228
+ }
1229
+ const controlOptions: TmuxSpawnOptions = {
843
1230
  ...options,
844
1231
  stdin: "pipe",
845
1232
  stdout: "pipe",
846
1233
  stderr: "pipe",
847
1234
  captureStderr: true,
848
1235
  };
1236
+ // has-session / new-session retry / profile-tagging probe share these
1237
+ // pipe-stdio options. Only attach-session inherits the interactive terminal.
1238
+ const probeOptions: TmuxSpawnOptions = {
1239
+ ...controlOptions,
1240
+ };
849
1241
  // new-session needs pipe stdio (not inherit) because the user terminal must
850
1242
  // remain untouched until attach-session takes over. Inheriting psmux's
851
1243
  // stdout/stderr for new-session can corrupt the terminal state or race with
@@ -867,7 +1259,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
867
1259
  : undefined;
868
1260
  const buildProfileInputs = (): GjcTmuxProfileContext => ({
869
1261
  tmuxCommand: plan.tmuxCommand,
870
- target: plan.sessionName,
1262
+ target: createdSessionTarget(plan),
871
1263
  cwd: plan.cwd,
872
1264
  env,
873
1265
  spawnSync,
@@ -875,67 +1267,133 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
875
1267
  project: plan.project,
876
1268
  sessionId: plan.sessionId ?? null,
877
1269
  sessionStateFile: plan.sessionStateFile ?? null,
1270
+ ownerGeneration: plan.ownerGeneration ?? null,
1271
+ ownerServerKey: plan.tmuxCommand,
878
1272
  version: VERSION,
879
1273
  });
880
1274
  const probeHasSession = (): TmuxSpawnResult =>
881
- spawnSync(
882
- plan.tmuxCommand,
883
- ["has-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
884
- probeOptions,
885
- );
1275
+ spawnSync(plan.tmuxCommand, ["has-session", "-t", createdSessionExactTarget(plan, env)], probeOptions);
886
1276
  const attachCreatedSession = (): TmuxSpawnResult =>
887
- spawnSync(
888
- plan.tmuxCommand,
889
- ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.sessionName, { env })],
890
- attachOptions,
891
- );
1277
+ spawnSync(plan.tmuxCommand, ["attach-session", "-t", createdSessionExactTarget(plan, env)], attachOptions);
892
1278
 
893
1279
  if (plan.attachSessionName) {
894
- applyGjcTmuxRootTerminalTitleProfile({
895
- tmuxCommand: plan.tmuxCommand,
896
- target: plan.attachSessionName,
897
- title: rootTerminalTitle,
898
- spawnSync,
899
- options,
900
- });
901
- const attached = spawnSync(
902
- plan.tmuxCommand,
903
- ["attach-session", "-t", buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env })],
904
- attachOptions,
905
- );
1280
+ let existingTarget: string;
1281
+ let existingProof: ReturnType<typeof proveGjcTmuxSessionMutationTarget> | undefined;
1282
+ if (plan.platform !== "linux") {
1283
+ existingTarget = buildGjcTmuxExactSessionTarget(plan.attachSessionName, { env });
1284
+ } else {
1285
+ try {
1286
+ existingProof = proveGjcTmuxSessionMutationTarget(plan.attachSessionName, env);
1287
+ existingTarget = existingProof.nativeSessionId;
1288
+ } catch {
1289
+ (context.diagnosticWriter ?? safeStderrWrite)(
1290
+ "tmux existing session proof failed; preserving session without mutation.\n",
1291
+ );
1292
+ return true;
1293
+ }
1294
+ }
1295
+ if (!plan.isPsmux)
1296
+ applyGjcTmuxRootTerminalTitleProfile({
1297
+ tmuxCommand: plan.tmuxCommand,
1298
+ target:
1299
+ plan.platform === "linux" && existingTarget.startsWith("$")
1300
+ ? `${existingTarget}:`
1301
+ : buildGjcTmuxExactOptionTarget(plan.attachSessionName, { env }),
1302
+ sessionName: plan.attachSessionName,
1303
+ title: rootTerminalTitle,
1304
+ spawnSync,
1305
+ options: controlOptions,
1306
+ });
1307
+ if (plan.platform === "linux") {
1308
+ try {
1309
+ const proof = proveGjcTmuxSessionMutationTarget(plan.attachSessionName, env);
1310
+ if (
1311
+ !existingProof ||
1312
+ proof.nativeSessionId !== existingProof.nativeSessionId ||
1313
+ proof.serverPid !== existingProof.serverPid ||
1314
+ proof.serverStartTime !== existingProof.serverStartTime
1315
+ )
1316
+ throw new Error("tmux_session_identity_changed");
1317
+ } catch {
1318
+ (context.diagnosticWriter ?? safeStderrWrite)(
1319
+ "tmux existing session proof failed; preserving session without mutation.\n",
1320
+ );
1321
+ return true;
1322
+ }
1323
+ }
1324
+ const attached = spawnSync(plan.tmuxCommand, ["attach-session", "-t", existingTarget], attachOptions);
906
1325
  if (attached.exitCode === 0) return true;
907
1326
  }
908
- const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
1327
+ try {
1328
+ prepareManagedOwnerLifecycle(plan, context);
1329
+ } catch (error) {
1330
+ (context.diagnosticWriter ?? safeStderrWrite)(`tmux owner lifecycle publication failed: ${String(error)}`);
1331
+ return true;
1332
+ }
1333
+ if (!plan.sessionId || !plan.sessionStateFile || !plan.ownerGeneration || !plan.tmuxCommand) {
1334
+ (context.diagnosticWriter ?? safeStderrWrite)("tmux required ownership metadata was unavailable");
1335
+ return true;
1336
+ }
1337
+ const created = createIsolatedTmuxSession(
1338
+ plan,
1339
+ rawSpawnSync,
1340
+ newSessionOptions,
1341
+ context.diagnosticWriter ?? safeStderrWrite,
1342
+ ownerIsolationProbe,
1343
+ );
1344
+ if (created.exitCode === 0 && !plan.isPsmux && !plan.createdSessionId) {
1345
+ // Native tmux must atomically disclose its immutable `$N` identity. Do not
1346
+ // downgrade to the reusable session name or mutate the unidentified session.
1347
+ (context.diagnosticWriter ?? safeStderrWrite)(
1348
+ "gjc --tmux failed after creating tmux session: native session identity was unavailable; preserving session for recovery.\n",
1349
+ );
1350
+ return true;
1351
+ }
909
1352
  if (created.exitCode === 0) {
910
- // On Windows + psmux 3.3.0/3.3.6, new-session can return exit 0
911
- // before the psmux server finishes registering the session on its
912
- // control socket. The follow-up set-option / attach-session then
913
- // fails with "can't find session / no server running". Probe the
914
- // server first, and if the race fired, retry new-session before
915
- // we hand the session to rename-window / applyGjcTmuxProfile so the
916
- // profile-tagging path always sees a registered session.
1353
+ // psmux on Windows can return before it registers its new session. Retry
1354
+ // only its documented missing-session registration race; other failed
1355
+ // proofs are preserved without cleanup or a second creation attempt.
917
1356
  const probeResult = probeHasSession();
918
1357
  if (probeResult.exitCode !== 0) {
919
- const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
1358
+ if (!isWindowsPsmuxMissingSessionRegistrationRace(plan, probeResult)) {
1359
+ (context.diagnosticWriter ?? safeStderrWrite)(
1360
+ formatTmuxLaunchDiagnostic("session registration probe failed", probeResult.stderr),
1361
+ );
1362
+ return true;
1363
+ }
1364
+ const retry = createIsolatedTmuxSession(
1365
+ plan,
1366
+ rawSpawnSync,
1367
+ newSessionOptions,
1368
+ context.diagnosticWriter ?? safeStderrWrite,
1369
+ ownerIsolationProbe,
1370
+ );
920
1371
  const retryProbe = probeHasSession();
921
1372
  if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
922
1373
  (context.diagnosticWriter ?? safeStderrWrite)(
923
1374
  formatTmuxLaunchDiagnostic(
924
1375
  "new-session retry failed after missing session",
925
- retry.stderr ?? retryProbe.stderr ?? created.stderr,
1376
+ failedRetryDiagnostic(retry, retryProbe),
926
1377
  ),
927
1378
  );
928
- cleanupCreatedTmuxSession(plan, spawnSync, options);
929
- return false;
1379
+ cleanupCreatedTmuxSessionAfterFailure(
1380
+ plan,
1381
+ spawnSync,
1382
+ options,
1383
+ ownerIsolationProbe,
1384
+ new Error("new-session retry failed after missing session"),
1385
+ );
1386
+
1387
+ return true;
930
1388
  }
931
1389
  }
932
- renameTmuxWindow(
933
- plan.tmuxCommand,
934
- windowTitle,
935
- spawnSync,
936
- controlOptions,
937
- buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
938
- );
1390
+ if (!isCreatedTmuxSessionIdentityStable(plan, spawnSync, controlOptions, ownerIsolationProbe)) {
1391
+ (context.diagnosticWriter ?? safeStderrWrite)(
1392
+ "tmux created session proof failed; preserving session without mutation.\n",
1393
+ );
1394
+ return true;
1395
+ }
1396
+ renameTmuxWindow(plan.tmuxCommand, windowTitle, spawnSync, controlOptions, createdSessionExactTarget(plan, env));
939
1397
  const profile = applyGjcTmuxProfile(buildProfileInputs());
940
1398
  // If the @gjc-profile ownership write failed, the cause can be
941
1399
  // either (a) a real psmux persistence-tag rejection (e.g.
@@ -945,34 +1403,56 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
945
1403
  // "can't find session". Distinguish the two: re-probe; if the
946
1404
  // session is genuinely missing, retry new-session and re-apply the
947
1405
  // profile. Otherwise, surface the persistence-tag failure.
948
- const ownershipFailure = profile.failures.find(item => item.command.args.includes("@gjc-profile"));
1406
+ const ownershipFailure = requiredProfileFailure(profile);
1407
+ emitOptionalProfileDiagnostics(profile, context.diagnosticWriter ?? safeStderrWrite);
949
1408
  if (ownershipFailure) {
950
1409
  const probeAfterOwnership = probeHasSession();
951
- if (probeAfterOwnership.exitCode === 0) {
952
- // Session is present; tagging failed for a real reason.
953
- cleanupCreatedTmuxSession(plan, spawnSync, options);
1410
+ if (
1411
+ !isWindowsPsmuxMissingSessionRegistrationRace(plan, ownershipFailure) ||
1412
+ !isWindowsPsmuxMissingSessionRegistrationRace(plan, probeAfterOwnership)
1413
+ ) {
954
1414
  (context.diagnosticWriter ?? safeStderrWrite)(
955
1415
  formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
956
1416
  );
957
1417
  return true;
958
1418
  }
959
- // Session is missing — retry.
960
- const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
1419
+ const retry = createIsolatedTmuxSession(
1420
+ plan,
1421
+ rawSpawnSync,
1422
+ newSessionOptions,
1423
+ context.diagnosticWriter ?? safeStderrWrite,
1424
+ ownerIsolationProbe,
1425
+ );
961
1426
  const retryProbe = probeHasSession();
962
1427
  if (retry.exitCode !== 0 || retryProbe.exitCode !== 0) {
963
- cleanupCreatedTmuxSession(plan, spawnSync, options);
1428
+ cleanupCreatedTmuxSessionAfterFailure(
1429
+ plan,
1430
+ spawnSync,
1431
+ options,
1432
+ ownerIsolationProbe,
1433
+ new Error("new-session retry failed after ownership failure"),
1434
+ );
1435
+
964
1436
  (context.diagnosticWriter ?? safeStderrWrite)(
965
1437
  formatTmuxLaunchDiagnostic(
966
1438
  "new-session retry failed after ownership failure",
967
- retry.stderr ?? retryProbe.stderr ?? ownershipFailure.stderr,
1439
+ failedRetryDiagnostic(retry, retryProbe),
968
1440
  ),
969
1441
  );
970
1442
  return true;
971
1443
  }
972
1444
  const retryProfile = applyGjcTmuxProfile(buildProfileInputs());
973
- const retryOwnershipFailure = retryProfile.failures.find(item => item.command.args.includes("@gjc-profile"));
1445
+ const retryOwnershipFailure = requiredProfileFailure(retryProfile);
1446
+ emitOptionalProfileDiagnostics(retryProfile, context.diagnosticWriter ?? safeStderrWrite);
974
1447
  if (retryOwnershipFailure) {
975
- cleanupCreatedTmuxSession(plan, spawnSync, options);
1448
+ cleanupCreatedTmuxSessionAfterFailure(
1449
+ plan,
1450
+ spawnSync,
1451
+ options,
1452
+ ownerIsolationProbe,
1453
+ new Error("profile tagging failed after retry"),
1454
+ );
1455
+
976
1456
  (context.diagnosticWriter ?? safeStderrWrite)(
977
1457
  formatTmuxLaunchDiagnostic("profile tagging failed after retry", retryOwnershipFailure.stderr),
978
1458
  );
@@ -983,10 +1463,11 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
983
1463
  ensureCreatedTmuxWindowTracksCallerTerminal(plan, spawnSync, controlOptions);
984
1464
  applyGjcTmuxRootTerminalTitleProfile({
985
1465
  tmuxCommand: plan.tmuxCommand,
986
- target: plan.sessionName,
1466
+ target: createdSessionOptionTarget(plan, env),
1467
+ sessionName: plan.sessionName,
987
1468
  title: rootTerminalTitle,
988
1469
  spawnSync,
989
- options,
1470
+ options: controlOptions,
990
1471
  });
991
1472
  }
992
1473
  const probeWarning = detectCorruptedGjcWrapper();
@@ -1001,7 +1482,32 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
1001
1482
  const stderr = created.stderr;
1002
1483
  const suffix = probeWarning ? ` Wrapper warning: ${probeWarning}` : "";
1003
1484
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("new-session failed", stderr) + suffix);
1004
- return false;
1485
+ return true;
1486
+ }
1487
+ if (!isCreatedTmuxSessionIdentityStable(plan, spawnSync, controlOptions, ownerIsolationProbe)) {
1488
+ (context.diagnosticWriter ?? safeStderrWrite)(
1489
+ "tmux created session proof failed; preserving session without attach.\n",
1490
+ );
1491
+ return true;
1492
+ }
1493
+ try {
1494
+ replaceOwnerGenerationSync(
1495
+ path.dirname(plan.sessionStateFile!),
1496
+ plan.sessionId!,
1497
+ plan.ownerGeneration!,
1498
+ plan.ownerGenerationBaseline!,
1499
+ );
1500
+ } catch (error) {
1501
+ const publicationError = error instanceof Error ? error : new Error(String(error));
1502
+ cleanupCreatedTmuxSessionAfterFailure(plan, spawnSync, options, ownerIsolationProbe, publicationError);
1503
+ (context.diagnosticWriter ?? safeStderrWrite)(`tmux owner lifecycle publication failed: ${String(error)}`);
1504
+ return true;
1505
+ }
1506
+ if (!isCreatedTmuxSessionIdentityStable(plan, spawnSync, controlOptions, ownerIsolationProbe)) {
1507
+ (context.diagnosticWriter ?? safeStderrWrite)(
1508
+ "tmux created session proof failed after lifecycle publication; preserving session without attach.\n",
1509
+ );
1510
+ return true;
1005
1511
  }
1006
1512
  // attach-session needs PTY inherit for the user-facing attach; keep it unchanged.
1007
1513
  const attached = attachCreatedSession();
@@ -1022,8 +1528,32 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
1022
1528
  );
1023
1529
  return true;
1024
1530
  }
1531
+ cleanupCreatedTmuxSessionAfterFailure(
1532
+ plan,
1533
+ spawnSync,
1534
+ options,
1535
+ ownerIsolationProbe,
1536
+ new Error("attach retry failed"),
1537
+ );
1538
+
1539
+ (context.diagnosticWriter ?? safeStderrWrite)(
1540
+ formatTmuxLaunchDiagnostic("attach retry failed", retryAttached.stderr),
1541
+ );
1542
+ return true;
1025
1543
  } else {
1026
- const retry = spawnSync(plan.tmuxCommand, plan.newSessionArgs, newSessionOptions);
1544
+ if (!isWindowsPsmuxMissingSessionRegistrationRace(plan, probeAfterAttach)) {
1545
+ (context.diagnosticWriter ?? safeStderrWrite)(
1546
+ formatTmuxLaunchDiagnostic("attach recovery probe failed", probeAfterAttach.stderr),
1547
+ );
1548
+ return true;
1549
+ }
1550
+ const retry = createIsolatedTmuxSession(
1551
+ plan,
1552
+ rawSpawnSync,
1553
+ newSessionOptions,
1554
+ context.diagnosticWriter ?? safeStderrWrite,
1555
+ ownerIsolationProbe,
1556
+ );
1027
1557
  const retryProbe = probeHasSession();
1028
1558
  if (retry.exitCode === 0 && retryProbe.exitCode === 0) {
1029
1559
  renameTmuxWindow(
@@ -1031,31 +1561,68 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
1031
1561
  windowTitle,
1032
1562
  spawnSync,
1033
1563
  controlOptions,
1034
- buildGjcTmuxExactSessionTarget(plan.sessionName, { env }),
1564
+ createdSessionExactTarget(plan, env),
1035
1565
  );
1036
1566
  const retryProfile = applyGjcTmuxProfile(buildProfileInputs());
1037
- const retryOwnershipFailure = retryProfile.failures.find(item =>
1038
- item.command.args.includes("@gjc-profile"),
1039
- );
1040
- if (!retryOwnershipFailure) {
1041
- ensureCreatedTmuxWindowTracksCallerTerminal(plan, spawnSync, controlOptions);
1042
- applyGjcTmuxRootTerminalTitleProfile({
1043
- tmuxCommand: plan.tmuxCommand,
1044
- target: plan.sessionName,
1045
- title: rootTerminalTitle,
1567
+ const retryOwnershipFailure = requiredProfileFailure(retryProfile);
1568
+ emitOptionalProfileDiagnostics(retryProfile, context.diagnosticWriter ?? safeStderrWrite);
1569
+ if (retryOwnershipFailure) {
1570
+ cleanupCreatedTmuxSessionAfterFailure(
1571
+ plan,
1046
1572
  spawnSync,
1047
1573
  options,
1048
- });
1049
- const retryAttached = attachCreatedSession();
1050
- if (retryAttached.exitCode === 0) return true;
1051
- if (isTmuxAttachDisconnectError(retryAttached)) {
1052
- (context.diagnosticWriter ?? safeStderrWrite)(
1053
- formatTmuxLaunchDiagnostic("attach disconnected", retryAttached.stderr),
1054
- );
1055
- return true;
1056
- }
1574
+ ownerIsolationProbe,
1575
+ new Error("profile tagging failed after retry"),
1576
+ );
1577
+
1578
+ (context.diagnosticWriter ?? safeStderrWrite)(
1579
+ formatTmuxLaunchDiagnostic("profile tagging failed after retry", retryOwnershipFailure.stderr),
1580
+ );
1581
+ return true;
1582
+ }
1583
+ ensureCreatedTmuxWindowTracksCallerTerminal(plan, spawnSync, controlOptions);
1584
+ applyGjcTmuxRootTerminalTitleProfile({
1585
+ tmuxCommand: plan.tmuxCommand,
1586
+ target: createdSessionOptionTarget(plan, env),
1587
+ sessionName: plan.sessionName,
1588
+ title: rootTerminalTitle,
1589
+ spawnSync,
1590
+ options: controlOptions,
1591
+ });
1592
+ const retryAttached = attachCreatedSession();
1593
+ if (retryAttached.exitCode === 0) return true;
1594
+ if (isTmuxAttachDisconnectError(retryAttached)) {
1595
+ (context.diagnosticWriter ?? safeStderrWrite)(
1596
+ formatTmuxLaunchDiagnostic("attach disconnected", retryAttached.stderr),
1597
+ );
1598
+ return true;
1057
1599
  }
1600
+ cleanupCreatedTmuxSessionAfterFailure(
1601
+ plan,
1602
+ spawnSync,
1603
+ options,
1604
+ ownerIsolationProbe,
1605
+ new Error("attach failed after recovery"),
1606
+ );
1607
+
1608
+ (context.diagnosticWriter ?? safeStderrWrite)(
1609
+ formatTmuxLaunchDiagnostic("attach failed after recovery", retryAttached.stderr),
1610
+ );
1611
+ return true;
1058
1612
  }
1613
+ cleanupCreatedTmuxSessionAfterFailure(
1614
+ plan,
1615
+ spawnSync,
1616
+ options,
1617
+ ownerIsolationProbe,
1618
+ new Error("attach recovery recreate failed"),
1619
+ );
1620
+
1621
+ const recoveryFailure = retry.exitCode !== 0 ? retry.stderr : retryProbe.stderr;
1622
+ (context.diagnosticWriter ?? safeStderrWrite)(
1623
+ formatTmuxLaunchDiagnostic("attach recovery recreate failed", recoveryFailure),
1624
+ );
1625
+ return true;
1059
1626
  }
1060
1627
  }
1061
1628
  // Closing an SSH/Windows Terminal tab can make `tmux attach-session`
@@ -1073,7 +1640,7 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
1073
1640
  return true;
1074
1641
  }
1075
1642
  }
1076
- cleanupCreatedTmuxSession(plan, spawnSync, options);
1643
+ cleanupCreatedTmuxSessionAfterFailure(plan, spawnSync, options, ownerIsolationProbe, new Error("attach failed"));
1077
1644
  (context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
1078
1645
  return true;
1079
1646
  }