@sentropic/h2a-runtime 0.81.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 (253) hide show
  1. package/dist/account-pool.d.ts +168 -0
  2. package/dist/account-pool.d.ts.map +1 -0
  3. package/dist/account-pool.js +385 -0
  4. package/dist/account-pool.js.map +1 -0
  5. package/dist/agents-projection.d.ts +56 -0
  6. package/dist/agents-projection.d.ts.map +1 -0
  7. package/dist/agents-projection.js +82 -0
  8. package/dist/agents-projection.js.map +1 -0
  9. package/dist/attach.d.ts +75 -0
  10. package/dist/attach.d.ts.map +1 -0
  11. package/dist/attach.js +432 -0
  12. package/dist/attach.js.map +1 -0
  13. package/dist/auth-bundle.d.ts +21 -0
  14. package/dist/auth-bundle.d.ts.map +1 -0
  15. package/dist/auth-bundle.js +112 -0
  16. package/dist/auth-bundle.js.map +1 -0
  17. package/dist/auth-diagnostics.d.ts +18 -0
  18. package/dist/auth-diagnostics.d.ts.map +1 -0
  19. package/dist/auth-diagnostics.js +23 -0
  20. package/dist/auth-diagnostics.js.map +1 -0
  21. package/dist/auth-login.d.ts +9 -0
  22. package/dist/auth-login.d.ts.map +1 -0
  23. package/dist/auth-login.js +21 -0
  24. package/dist/auth-login.js.map +1 -0
  25. package/dist/auth-refresh.d.ts +32 -0
  26. package/dist/auth-refresh.d.ts.map +1 -0
  27. package/dist/auth-refresh.js +83 -0
  28. package/dist/auth-refresh.js.map +1 -0
  29. package/dist/auth-tools.d.ts +55 -0
  30. package/dist/auth-tools.d.ts.map +1 -0
  31. package/dist/auth-tools.js +163 -0
  32. package/dist/auth-tools.js.map +1 -0
  33. package/dist/browser-bridge-local.d.ts +27 -0
  34. package/dist/browser-bridge-local.d.ts.map +1 -0
  35. package/dist/browser-bridge-local.js +51 -0
  36. package/dist/browser-bridge-local.js.map +1 -0
  37. package/dist/browser.d.ts +53 -0
  38. package/dist/browser.d.ts.map +1 -0
  39. package/dist/browser.js +61 -0
  40. package/dist/browser.js.map +1 -0
  41. package/dist/conductor-launch.d.ts +179 -0
  42. package/dist/conductor-launch.d.ts.map +1 -0
  43. package/dist/conductor-launch.js +414 -0
  44. package/dist/conductor-launch.js.map +1 -0
  45. package/dist/config.d.ts +182 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +334 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/conv-guard.d.ts +76 -0
  50. package/dist/conv-guard.d.ts.map +1 -0
  51. package/dist/conv-guard.js +131 -0
  52. package/dist/conv-guard.js.map +1 -0
  53. package/dist/convsync.d.ts +24 -0
  54. package/dist/convsync.d.ts.map +1 -0
  55. package/dist/convsync.js +108 -0
  56. package/dist/convsync.js.map +1 -0
  57. package/dist/cred-health.d.ts +96 -0
  58. package/dist/cred-health.d.ts.map +1 -0
  59. package/dist/cred-health.js +170 -0
  60. package/dist/cred-health.js.map +1 -0
  61. package/dist/delegate.d.ts +406 -0
  62. package/dist/delegate.d.ts.map +1 -0
  63. package/dist/delegate.js +739 -0
  64. package/dist/delegate.js.map +1 -0
  65. package/dist/enroll.d.ts +91 -0
  66. package/dist/enroll.d.ts.map +1 -0
  67. package/dist/enroll.js +248 -0
  68. package/dist/enroll.js.map +1 -0
  69. package/dist/fanout.d.ts +63 -0
  70. package/dist/fanout.d.ts.map +1 -0
  71. package/dist/fanout.js +115 -0
  72. package/dist/fanout.js.map +1 -0
  73. package/dist/forward.d.ts +66 -0
  74. package/dist/forward.d.ts.map +1 -0
  75. package/dist/forward.js +141 -0
  76. package/dist/forward.js.map +1 -0
  77. package/dist/gitdiff.d.ts +31 -0
  78. package/dist/gitdiff.d.ts.map +1 -0
  79. package/dist/gitdiff.js +133 -0
  80. package/dist/gitdiff.js.map +1 -0
  81. package/dist/h2a-bridge.d.ts +102 -0
  82. package/dist/h2a-bridge.d.ts.map +1 -0
  83. package/dist/h2a-bridge.js +333 -0
  84. package/dist/h2a-bridge.js.map +1 -0
  85. package/dist/h2a-jobs.d.ts +235 -0
  86. package/dist/h2a-jobs.d.ts.map +1 -0
  87. package/dist/h2a-jobs.js +388 -0
  88. package/dist/h2a-jobs.js.map +1 -0
  89. package/dist/h2a-ping.d.ts +38 -0
  90. package/dist/h2a-ping.d.ts.map +1 -0
  91. package/dist/h2a-ping.js +58 -0
  92. package/dist/h2a-ping.js.map +1 -0
  93. package/dist/index.d.ts +98 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +5887 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/interactive-throttle.d.ts +164 -0
  98. package/dist/interactive-throttle.d.ts.map +1 -0
  99. package/dist/interactive-throttle.js +194 -0
  100. package/dist/interactive-throttle.js.map +1 -0
  101. package/dist/lineage-client.d.ts +77 -0
  102. package/dist/lineage-client.d.ts.map +1 -0
  103. package/dist/lineage-client.js +117 -0
  104. package/dist/lineage-client.js.map +1 -0
  105. package/dist/lineage-lease.d.ts +152 -0
  106. package/dist/lineage-lease.d.ts.map +1 -0
  107. package/dist/lineage-lease.js +303 -0
  108. package/dist/lineage-lease.js.map +1 -0
  109. package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
  110. package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
  111. package/dist/llm-gateway-runtime/accounts.js +226 -0
  112. package/dist/llm-gateway-runtime/accounts.js.map +1 -0
  113. package/dist/llm-gateway-runtime/index.d.ts +4 -0
  114. package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
  115. package/dist/llm-gateway-runtime/index.js +58 -0
  116. package/dist/llm-gateway-runtime/index.js.map +1 -0
  117. package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
  118. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
  119. package/dist/llm-gateway-runtime/model-catalog.js +146 -0
  120. package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
  121. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
  122. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
  123. package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
  124. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
  125. package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
  126. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
  127. package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
  128. package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
  129. package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
  130. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
  131. package/dist/llm-gateway-runtime/session-ledger.js +62 -0
  132. package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
  133. package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
  134. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
  135. package/dist/llm-gateway-runtime/sticky.js +254 -0
  136. package/dist/llm-gateway-runtime/sticky.js.map +1 -0
  137. package/dist/llm-mesh.d.ts +102 -0
  138. package/dist/llm-mesh.d.ts.map +1 -0
  139. package/dist/llm-mesh.js +433 -0
  140. package/dist/llm-mesh.js.map +1 -0
  141. package/dist/migrate-candidates.d.ts +35 -0
  142. package/dist/migrate-candidates.d.ts.map +1 -0
  143. package/dist/migrate-candidates.js +128 -0
  144. package/dist/migrate-candidates.js.map +1 -0
  145. package/dist/migrate.d.ts +125 -0
  146. package/dist/migrate.d.ts.map +1 -0
  147. package/dist/migrate.js +561 -0
  148. package/dist/migrate.js.map +1 -0
  149. package/dist/plugin-manifest.d.ts +143 -0
  150. package/dist/plugin-manifest.d.ts.map +1 -0
  151. package/dist/plugin-manifest.js +233 -0
  152. package/dist/plugin-manifest.js.map +1 -0
  153. package/dist/plugin.d.ts +274 -0
  154. package/dist/plugin.d.ts.map +1 -0
  155. package/dist/plugin.js +919 -0
  156. package/dist/plugin.js.map +1 -0
  157. package/dist/pod-liveness.d.ts +51 -0
  158. package/dist/pod-liveness.d.ts.map +1 -0
  159. package/dist/pod-liveness.js +80 -0
  160. package/dist/pod-liveness.js.map +1 -0
  161. package/dist/profile-menu.d.ts +7 -0
  162. package/dist/profile-menu.d.ts.map +1 -0
  163. package/dist/profile-menu.js +52 -0
  164. package/dist/profile-menu.js.map +1 -0
  165. package/dist/profiles.d.ts +23 -0
  166. package/dist/profiles.d.ts.map +1 -0
  167. package/dist/profiles.js +66 -0
  168. package/dist/profiles.js.map +1 -0
  169. package/dist/protocol-local.d.ts +27 -0
  170. package/dist/protocol-local.d.ts.map +1 -0
  171. package/dist/protocol-local.js +12 -0
  172. package/dist/protocol-local.js.map +1 -0
  173. package/dist/pty.d.ts +26 -0
  174. package/dist/pty.d.ts.map +1 -0
  175. package/dist/pty.js +45 -0
  176. package/dist/pty.js.map +1 -0
  177. package/dist/readiness.d.ts +38 -0
  178. package/dist/readiness.d.ts.map +1 -0
  179. package/dist/readiness.js +169 -0
  180. package/dist/readiness.js.map +1 -0
  181. package/dist/registry.d.ts +258 -0
  182. package/dist/registry.d.ts.map +1 -0
  183. package/dist/registry.js +570 -0
  184. package/dist/registry.js.map +1 -0
  185. package/dist/relaunch.d.ts +52 -0
  186. package/dist/relaunch.d.ts.map +1 -0
  187. package/dist/relaunch.js +76 -0
  188. package/dist/relaunch.js.map +1 -0
  189. package/dist/restore.d.ts +165 -0
  190. package/dist/restore.d.ts.map +1 -0
  191. package/dist/restore.js +530 -0
  192. package/dist/restore.js.map +1 -0
  193. package/dist/run.d.ts +29 -0
  194. package/dist/run.d.ts.map +1 -0
  195. package/dist/run.js +206 -0
  196. package/dist/run.js.map +1 -0
  197. package/dist/secrets.d.ts +25 -0
  198. package/dist/secrets.d.ts.map +1 -0
  199. package/dist/secrets.js +98 -0
  200. package/dist/secrets.js.map +1 -0
  201. package/dist/session-restore.d.ts +26 -0
  202. package/dist/session-restore.d.ts.map +1 -0
  203. package/dist/session-restore.js +122 -0
  204. package/dist/session-restore.js.map +1 -0
  205. package/dist/skills-sync.d.ts +126 -0
  206. package/dist/skills-sync.d.ts.map +1 -0
  207. package/dist/skills-sync.js +255 -0
  208. package/dist/skills-sync.js.map +1 -0
  209. package/dist/smoke.d.ts +23 -0
  210. package/dist/smoke.d.ts.map +1 -0
  211. package/dist/smoke.js +138 -0
  212. package/dist/smoke.js.map +1 -0
  213. package/dist/soft-refresh.d.ts +107 -0
  214. package/dist/soft-refresh.d.ts.map +1 -0
  215. package/dist/soft-refresh.js +426 -0
  216. package/dist/soft-refresh.js.map +1 -0
  217. package/dist/sync-status.d.ts +22 -0
  218. package/dist/sync-status.d.ts.map +1 -0
  219. package/dist/sync-status.js +41 -0
  220. package/dist/sync-status.js.map +1 -0
  221. package/dist/sync.d.ts +77 -0
  222. package/dist/sync.d.ts.map +1 -0
  223. package/dist/sync.js +302 -0
  224. package/dist/sync.js.map +1 -0
  225. package/dist/throttle-signatures.d.ts +43 -0
  226. package/dist/throttle-signatures.d.ts.map +1 -0
  227. package/dist/throttle-signatures.js +116 -0
  228. package/dist/throttle-signatures.js.map +1 -0
  229. package/dist/tmux.d.ts +286 -0
  230. package/dist/tmux.d.ts.map +1 -0
  231. package/dist/tmux.js +970 -0
  232. package/dist/tmux.js.map +1 -0
  233. package/dist/tunnel.d.ts +25 -0
  234. package/dist/tunnel.d.ts.map +1 -0
  235. package/dist/tunnel.js +127 -0
  236. package/dist/tunnel.js.map +1 -0
  237. package/dist/workspace-merge.d.ts +17 -0
  238. package/dist/workspace-merge.d.ts.map +1 -0
  239. package/dist/workspace-merge.js +153 -0
  240. package/dist/workspace-merge.js.map +1 -0
  241. package/dist/workspace-sync-incremental.d.ts +64 -0
  242. package/dist/workspace-sync-incremental.d.ts.map +1 -0
  243. package/dist/workspace-sync-incremental.js +199 -0
  244. package/dist/workspace-sync-incremental.js.map +1 -0
  245. package/dist/workspace-sync.d.ts +3 -0
  246. package/dist/workspace-sync.d.ts.map +1 -0
  247. package/dist/workspace-sync.js +187 -0
  248. package/dist/workspace-sync.js.map +1 -0
  249. package/dist/workspace.d.ts +70 -0
  250. package/dist/workspace.d.ts.map +1 -0
  251. package/dist/workspace.js +173 -0
  252. package/dist/workspace.js.map +1 -0
  253. package/package.json +44 -0
package/dist/tmux.js ADDED
@@ -0,0 +1,970 @@
1
+ /**
2
+ * tmux-backed session management.
3
+ *
4
+ * Two uses, one battle-tested multiplexer:
5
+ * - LOCAL sessions: `remote run <profile>` starts the CLI inside a local tmux
6
+ * session (`remote-<slug>`), so `remote ls`/`attach`/`stop` manage local and
7
+ * remote sessions uniformly, and detach/reattach is native.
8
+ * - REMOTE attach via exec: `remote attach <id> --exec` runs
9
+ * `kubectl exec -it … tmux attach` straight into the Pod's tmux session, so
10
+ * the LOCAL terminal owns scrollback + copy (OSC52) with no WS proxy in the
11
+ * middle — this is what fixes "I can't copy the code claude printed".
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { getTmuxProfileConfig } from "./config.js";
15
+ import { readFileSync } from "node:fs";
16
+ import { basename, join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ const TMUX = "tmux";
19
+ /** tmux session-name prefix marking a remote-managed local session. */
20
+ export const LOCAL_PREFIX = "remote-";
21
+ /** Pod tmux session the session-agent runs the CLI in (see agent.ts). */
22
+ export const POD_TMUX_SESSION = "main";
23
+ /** Default remote-owned tmux profile name stored in remote CLI config. */
24
+ export const REMOTE_TMUX_PROFILE_NAME = "remote";
25
+ /**
26
+ * The embedded, scroll-safe tmux profile remote owns. The commands are applied
27
+ * directly to the running tmux server; no user ~/.tmux.conf entry is required.
28
+ */
29
+ export const REMOTE_TMUX_PROFILE = {
30
+ name: REMOTE_TMUX_PROFILE_NAME,
31
+ version: 1,
32
+ invariants: [
33
+ { line: "set -g allow-passthrough on" },
34
+ { line: "set -g history-limit 50000" },
35
+ { line: "set -g default-terminal tmux-256color" },
36
+ { line: "set -g terminal-overrides ,*256col*:Tc,xterm*:Tc,gnome*:Tc" },
37
+ { line: "set -g mouse on" },
38
+ { line: "set -g set-clipboard on" },
39
+ { line: "set -g focus-events on" },
40
+ { line: "set -g set-titles on" },
41
+ { line: "set -g set-titles-string #{pane_title}" },
42
+ { line: "setw -g automatic-rename on" },
43
+ {
44
+ line: "setw -g automatic-rename-format #{?pane_title,#{pane_title},#{pane_current_command}}",
45
+ },
46
+ { line: "setw -g allow-rename off" },
47
+ { prefix: "bind -n WheelUpPane " },
48
+ { line: "bind -n WheelDownPane send-keys -M" },
49
+ { line: "bind -n PPage copy-mode -eu" },
50
+ { prefix: "bind -n C-S-c " },
51
+ { line: "bind -T copy-mode C-S-c send-keys -X copy-pipe-and-cancel" },
52
+ { line: "bind -T copy-mode-vi C-S-c send-keys -X copy-pipe-and-cancel" },
53
+ { prefix: "bind -n C-v if-shell " },
54
+ ],
55
+ };
56
+ /**
57
+ * Persistent-box wrapper (local twin of the Pod one): run the CLI, and when it
58
+ * exits drop into a login shell on the workdir instead of ending the tmux
59
+ * session. Invoked as `bash -lc WRAPPER <relaunch> <cli> <args…>`, so the FIRST
60
+ * positional lands in `$0` (the relaunch hint), the CLI in `$1`, args in `$2…`.
61
+ */
62
+ export const LOCAL_WRAPPER = `relaunch="$0"; cli="$1"; shift
63
+ "$cli" "$@"; code=$?
64
+ printf '\\n[remote] %s exited (code %s) — shell on %s.\\n' "$cli" "$code" "$PWD"
65
+ printf '[remote] relaunch: %s (or Ctrl-D to end this session)\\n' "$relaunch"
66
+ if [ -t 0 ]; then exec /bin/bash -l; else exit "$code"; fi`;
67
+ /**
68
+ * Run-once-exit wrapper for HEADLESS delegated jobs — the OPPOSITE of
69
+ * LOCAL_WRAPPER's drop-to-shell. Redirects the CLI's stdout+stderr to an output
70
+ * log, writes a result.json with the final state + exit code, then lets the
71
+ * tmux session END (no `exec bash`). Invoked as
72
+ * `bash -lc HEADLESS_WRAPPER <resultJson> <outputLog> <cli> <args…>`:
73
+ * `$0`=result.json path, `$1`=output.log path, `$2`=cli, `$3…`=cli args.
74
+ */
75
+ export const HEADLESS_WRAPPER = `result="$0"; log="$1"; cli="$2"; shift 2
76
+ "$cli" "$@" >"$log" 2>&1; code=$?
77
+ if [ "$code" -eq 0 ]; then state=done; else state=failed; fi
78
+ printf '{"state":"%s","exitCode":%s}\\n' "$state" "$code" >"$result"`;
79
+ /**
80
+ * The `remote run …` line that recreates this exact local session — shown when
81
+ * the CLI exits so the user can copy-paste it. Pure, exported for tests.
82
+ */
83
+ export function localRelaunchCommand(profile, cwd, label, resumeArgs = []) {
84
+ // resumeArgs is the CLI-native resume argv (e.g. ["--resume", id] /
85
+ // ["resume", id]); the conversation id is its last token, surfaced as `-r`.
86
+ const convId = resumeArgs.length > 0 ? resumeArgs[resumeArgs.length - 1] : undefined;
87
+ let cmd = `remote run ${profile} ${cwd}`;
88
+ if (label)
89
+ cmd += ` --name ${label}`;
90
+ if (convId && convId !== resumeArgs[0])
91
+ cmd += ` -r ${convId}`;
92
+ return cmd;
93
+ }
94
+ /**
95
+ * Distinct session labels for a fan-out of `count` parallel agents on one base.
96
+ * `count <= 1` → just the base (the normal single-session case). `#k` suffixes
97
+ * keep each tmux session distinct (the slug derives from the label), so you can
98
+ * run more than the per-project layout cap of parallel claude/codex agents.
99
+ */
100
+ export function fanoutLabels(base, count) {
101
+ if (count <= 1)
102
+ return [base];
103
+ return Array.from({ length: count }, (_v, i) => `${base}#${i + 1}`);
104
+ }
105
+ /**
106
+ * Same drop-to-shell contract for SIDE windows (h2a, …), but the command is a
107
+ * single configured shell line (quoting preserved via eval), not cli+args.
108
+ * `$0` is a label, `$1` is the optional agent pane wake target, `$2` is the
109
+ * command line. When present, `TMUX_PANE` is deliberately overridden before
110
+ * `eval` so h2a's local-tmux wake driver targets the agent pane, not the h2a
111
+ * side-window process.
112
+ */
113
+ const WINDOW_WRAPPER = `agent_pane="$1"; cmd="$2"
114
+ if [ -n "$agent_pane" ]; then export TMUX_PANE="$agent_pane"; fi
115
+ eval "$cmd"; code=$?
116
+ printf '\\n[remote] %s exited (code %s) — shell on %s. Re-run it or Ctrl-D to end this window.\\n' "$cmd" "$code" "$PWD"
117
+ if [ -t 0 ]; then exec /bin/bash -l; else exit "$code"; fi`;
118
+ /**
119
+ * Window name for the h2a MCP server side window — the a2a launcher contract:
120
+ * agents live in NAMED tmux windows, with `h2a mcp-serve` running next to them
121
+ * so the agent is reachable/wakeable through ~/h2a-workspace/.h2a.
122
+ */
123
+ export const H2A_WINDOW_NAME = "h2a";
124
+ const AGENT_PANE_OPTION = "@remote_agent_pane";
125
+ const AGENT_HOST_OPTION = "@remote_agent_host";
126
+ const AGENT_CWD_OPTION = "@remote_agent_cwd";
127
+ export function tmuxAvailable() {
128
+ try {
129
+ return spawnSync(TMUX, ["-V"], { stdio: "ignore" }).status === 0;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ export function slugify(p) {
136
+ const base = basename(p)
137
+ .replace(/[^a-zA-Z0-9_.-]/g, "-")
138
+ .replace(/^-+|-+$/g, "");
139
+ return base || "session";
140
+ }
141
+ /** tmux session name for a workdir slug. */
142
+ export function localSessionName(slug) {
143
+ return slug.startsWith(LOCAL_PREFIX) ? slug : `${LOCAL_PREFIX}${slug}`;
144
+ }
145
+ const ANTHROPIC_ENV_KEYS = [
146
+ "ANTHROPIC_BASE_URL",
147
+ "ANTHROPIC_AUTH_TOKEN",
148
+ "ANTHROPIC_API_KEY",
149
+ ];
150
+ function tmuxEnvironmentArgs() {
151
+ const args = [];
152
+ for (const key of ANTHROPIC_ENV_KEYS) {
153
+ const value = process.env[key];
154
+ if (value)
155
+ args.push("-e", `${key}=${value}`);
156
+ }
157
+ return args;
158
+ }
159
+ function anthopicEnvUnsetCommandPrefix() {
160
+ // tmux sessions inherit the tmux *server* environment too. If remote deliberately
161
+ // launches direct/default with no Anthropic env in its own process, scrub stale
162
+ // gateway/API-key variables that an older tmux server may still carry.
163
+ return ANTHROPIC_ENV_KEYS.some((key) => Boolean(process.env[key]))
164
+ ? []
165
+ : ["env", ...ANTHROPIC_ENV_KEYS.flatMap((key) => ["-u", key])];
166
+ }
167
+ function expandHome(p) {
168
+ return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
169
+ }
170
+ function shellSingleQuote(value) {
171
+ return `'${value.replace(/'/g, "'\\''")}'`;
172
+ }
173
+ /** List remote-managed local tmux sessions (best-effort; [] if no server). */
174
+ export function listLocalSessions() {
175
+ if (!tmuxAvailable())
176
+ return [];
177
+ const r = spawnSync(TMUX, [
178
+ "list-sessions",
179
+ "-F",
180
+ "#{session_name}\t#{session_attached}\t#{session_path}\t#{@profile}\t#{@display_name}",
181
+ ], { encoding: "utf8" });
182
+ if (r.status !== 0 || !r.stdout)
183
+ return [];
184
+ const out = [];
185
+ for (const line of r.stdout.split("\n")) {
186
+ if (!line)
187
+ continue;
188
+ const [name, attached, path, profile, displayName] = line.split("\t");
189
+ if (!name || !name.startsWith(LOCAL_PREFIX))
190
+ continue;
191
+ const session = {
192
+ name,
193
+ slug: name.slice(LOCAL_PREFIX.length),
194
+ profile: profile || "?",
195
+ path: path || "",
196
+ attached: Number(attached) > 0,
197
+ };
198
+ if (displayName && displayName.trim()) {
199
+ session.displayName = displayName.trim();
200
+ }
201
+ out.push(session);
202
+ }
203
+ return out;
204
+ }
205
+ /** Find a local session by its full name or its slug. */
206
+ export function findLocalSession(target) {
207
+ const sessions = listLocalSessions();
208
+ return sessions.find((s) => s.name === target || s.slug === target);
209
+ }
210
+ /**
211
+ * Store a custom display name on a local tmux session WITHOUT calling
212
+ * `rename-window`. This avoids tmux's per-window `allow-rename off` side-effect
213
+ * that an explicit `rename-window` triggers — keeping the window name free to
214
+ * follow the agent's live OSC title (activity status). The name is persisted as a
215
+ * tmux session option `@display_name` and surfaced by `listLocalSessions` /
216
+ * `remote ls`.
217
+ */
218
+ export function setLocalSessionDisplayName(session, displayName) {
219
+ const r = spawnSync(TMUX, [
220
+ "set-option",
221
+ "-t",
222
+ exactSessionTarget(session),
223
+ "@display_name",
224
+ displayName,
225
+ ], { stdio: "ignore" });
226
+ return r.status === 0;
227
+ }
228
+ /**
229
+ * Read the custom display name stored on a local tmux session via
230
+ * `setLocalSessionDisplayName`, if any. Returns `undefined` when no display name
231
+ * has been set or the session cannot be reached.
232
+ */
233
+ export function getLocalSessionDisplayName(session) {
234
+ const r = spawnSync(TMUX, ["show-options", "-qv", "-t", exactSessionTarget(session), "@display_name"], { encoding: "utf8" });
235
+ if (r.status !== 0 || r.stdout === undefined)
236
+ return undefined;
237
+ const v = r.stdout.trim();
238
+ return v || undefined;
239
+ }
240
+ /**
241
+ * First clipboard CLI found on PATH, or undefined. With `mouse on`, a mouse
242
+ * drag is captured by tmux (copy-mode) instead of the terminal's NATIVE
243
+ * selection, so Ctrl+Shift+C / right-click-Copy see nothing. tmux's own copy
244
+ * goes through OSC52 (set-clipboard) — but VTE/gnome-terminal silently DROP
245
+ * OSC52 clipboard writes, so the copy lands nowhere. Piping copy-mode to a real
246
+ * clipboard tool (copy-command, tmux ≥3.2) is the reliable local fix; Wayland
247
+ * first, then X11.
248
+ */
249
+ function detectClipboardCommand() {
250
+ const candidates = process.env.WAYLAND_DISPLAY
251
+ ? ["wl-copy", "xclip -selection clipboard", "xsel -ib"]
252
+ : ["xclip -selection clipboard", "xsel -ib", "wl-copy"];
253
+ for (const c of candidates) {
254
+ const bin = c.split(" ")[0];
255
+ if (spawnSync("command", ["-v", bin], { shell: true, stdio: "ignore" })
256
+ .status === 0) {
257
+ return c;
258
+ }
259
+ }
260
+ return undefined;
261
+ }
262
+ /**
263
+ * Pure builder for the global tmux options applied at every run/attach. Split
264
+ * out of `ensureScrollConfig` so the option set (scroll/clipboard AND the
265
+ * title-following options for the GNOME tab) is unit-testable without a tmux
266
+ * server. `clip` is the detected clipboard command (undefined → no copy-command).
267
+ *
268
+ * Title chain (bug #1 — "the tab name doesn't follow the agent"): the agent
269
+ * (claude/codex) emits its live title as an OSC sequence, which tmux records as
270
+ * `pane_title`. tmux only RE-EMITS that title to the OUTER terminal (the GNOME
271
+ * tab) when `set-titles on`; it is OFF by default, so the OSC title was trapped
272
+ * inside tmux and the GNOME tab kept the static launcher `--title`. We turn
273
+ * `set-titles on` and point `set-titles-string` at `#{pane_title}` (the agent's
274
+ * live title) with a friendly fallback (the window name, then the session name)
275
+ * when the agent has not set a title yet. `allow-rename on` lets the window NAME
276
+ * track the OSC title too. We deliberately NEVER touch `automatic-rename`
277
+ * (project rule); it stays at its default `on`, which is what lets the window
278
+ * follow the title.
279
+ */
280
+ export function buildTmuxGlobalOptions(clip, profile = REMOTE_TMUX_PROFILE_NAME) {
281
+ const cmds = [
282
+ // Mark this server as remote-managed so diagnostics can tell user config
283
+ // apart from the embedded profile remote applies idempotently.
284
+ ["set", "-g", "@remote_profile", profile],
285
+ // Match the proven old-PC tmux baseline used by Antoine's remote sessions.
286
+ ["set", "-g", "allow-passthrough", "on"],
287
+ ["set", "-g", "history-limit", "50000"],
288
+ ["set", "-g", "default-terminal", "tmux-256color"],
289
+ ["set", "-g", "terminal-overrides", ",*256col*:Tc,xterm*:Tc,gnome*:Tc"],
290
+ ["set", "-g", "mouse", "on"],
291
+ ["set", "-g", "set-clipboard", "on"],
292
+ ["set", "-g", "focus-events", "on"],
293
+ ["set", "-g", "set-titles", "on"],
294
+ ["set", "-g", "set-titles-string", "#{pane_title}"],
295
+ ["set", "-g", "status-interval", "1"],
296
+ ["setw", "-g", "automatic-rename", "on"],
297
+ [
298
+ "setw",
299
+ "-g",
300
+ "automatic-rename-format",
301
+ "#{?pane_title,#{pane_title},#{pane_current_command}}",
302
+ ],
303
+ // Ignore \033k manual renames so OSC pane_title keeps driving live names.
304
+ ["setw", "-g", "allow-rename", "off"],
305
+ [
306
+ "bind",
307
+ "-n",
308
+ "WheelUpPane",
309
+ "if",
310
+ "-Ft=",
311
+ "#{pane_in_mode}",
312
+ "send-keys -M",
313
+ "copy-mode -e; send-keys -M",
314
+ ],
315
+ ["bind", "-n", "WheelDownPane", "send-keys", "-M"],
316
+ ["bind", "-n", "PPage", "copy-mode", "-eu"],
317
+ // Some terminals/desktop stacks forward Ctrl+Shift+C to tmux when the
318
+ // selection was made inside tmux copy-mode. Bind it as an explicit fallback
319
+ // so the familiar terminal shortcut copies the active tmux selection through
320
+ // copy-command instead of being swallowed. Outside copy-mode, forward the key
321
+ // unchanged so terminals that own Ctrl+Shift+C keep their native behavior.
322
+ [
323
+ "bind",
324
+ "-n",
325
+ "C-S-c",
326
+ "if",
327
+ "-F",
328
+ "#{pane_in_mode}",
329
+ "send-keys -X copy-pipe-and-cancel",
330
+ "send-keys C-S-c",
331
+ ],
332
+ [
333
+ "bind",
334
+ "-T",
335
+ "copy-mode",
336
+ "C-S-c",
337
+ "send-keys",
338
+ "-X",
339
+ "copy-pipe-and-cancel",
340
+ ],
341
+ [
342
+ "bind",
343
+ "-T",
344
+ "copy-mode-vi",
345
+ "C-S-c",
346
+ "send-keys",
347
+ "-X",
348
+ "copy-pipe-and-cancel",
349
+ ],
350
+ buildCodexImagePasteBinding(),
351
+ ];
352
+ // copy-command makes every copy-pipe-and-cancel (mouse drag, double/triple
353
+ // click) land in the real system clipboard. tmux's defaults already use
354
+ // copy-pipe-and-cancel with no argument → they honour copy-command.
355
+ if (clip)
356
+ cmds.push(["set", "-g", "copy-command", clip]);
357
+ return cmds;
358
+ }
359
+ function tmuxCommandLine(args) {
360
+ return args.join(" ");
361
+ }
362
+ export function validateManagedTmuxProfile(cmds) {
363
+ const lines = cmds.map(tmuxCommandLine);
364
+ const missing = [];
365
+ for (const invariant of REMOTE_TMUX_PROFILE.invariants) {
366
+ if ("line" in invariant) {
367
+ if (!lines.includes(invariant.line))
368
+ missing.push(invariant.line);
369
+ }
370
+ else if (!lines.some((line) => line.startsWith(invariant.prefix))) {
371
+ missing.push(`${invariant.prefix}*`);
372
+ }
373
+ }
374
+ return missing;
375
+ }
376
+ /**
377
+ * Wayland image paste bridge for Codex panes. Terminals/tmux cannot paste image
378
+ * bytes into a TTY, so the reliable path is: read the clipboard image with
379
+ * wl-paste, save it under the pane cwd, then paste the resulting file path into
380
+ * Codex. The binding is guarded by the current tmux session/window profile and
381
+ * clipboard MIME type; when the guard fails, Ctrl+V is forwarded unchanged.
382
+ */
383
+ export function buildCodexImagePasteBinding() {
384
+ const condition = [
385
+ "command -v wl-paste >/dev/null 2>&1",
386
+ 'wl-paste --list-types 2>/dev/null | grep -Eq "^(image/png|image/jpeg)$"',
387
+ // Check @profile (set by remote run) OR window_name OR pane_current_command.
388
+ // pane_current_command is often "node" for Codex (not "codex"), so @profile
389
+ // is the reliable discriminant when the session was started via `remote run`.
390
+ 'tmux display-message -p "#{@profile}:#{window_name}:#{pane_current_command}" | grep -Eqi "(^|:)codex(:|$)"',
391
+ ].join(" && ");
392
+ // #{pane_id} is expanded by tmux at binding-fire time before the shell runs,
393
+ // so the send-keys always targets the pane that triggered C-v even when the
394
+ // run-shell -b shell is scheduled after a focus change.
395
+ const script = [
396
+ 'PANE_TARGET="#{pane_id}"',
397
+ 'pane_cwd=$(tmux display-message -p -t "$PANE_TARGET" "#{pane_current_path}")',
398
+ 'dir="$pane_cwd/.remote/images"',
399
+ 'mkdir -p "$dir"',
400
+ "mime=$(wl-paste --list-types | awk '/^image\\/png$/{print; exit} /^image\\/jpeg$/{print; exit}')",
401
+ 'case "$mime" in image/png) ext=png ;; image/jpeg) ext=jpg ;; *) exit 1 ;; esac',
402
+ 'file="$dir/paste-$(date +%Y%m%d-%H%M%S)-$$.$ext"',
403
+ 'wl-paste -t "$mime" > "$file"',
404
+ 'tmux send-keys -t "$PANE_TARGET" -l "$file"',
405
+ ].join("; ");
406
+ return [
407
+ "bind",
408
+ "-n",
409
+ "C-v",
410
+ "if-shell",
411
+ "-b",
412
+ condition,
413
+ `run-shell -b ${shellSingleQuote(script)}`,
414
+ "send-keys C-v",
415
+ ];
416
+ }
417
+ /**
418
+ * Make the LOCAL tmux server scroll the conversation on the wheel AND copy
419
+ * selections to the system clipboard — same scroll settings the Pod image bakes
420
+ * into /etc/tmux.conf. Without `mouse on`, the terminal falls back to
421
+ * alternateScroll (wheel → arrow keys → the CLI's input history), which reads as
422
+ * "scrolling scrolls the input history". With mouse on, a drag is tmux's
423
+ * selection: `copy-command` pipes it to wl-copy/xclip so Ctrl+Shift+V / paste
424
+ * works (VTE drops OSC52, so set-clipboard alone is not enough). Wheel events
425
+ * follow the proven old-PC baseline: enter copy-mode on wheel-up and forward the
426
+ * real mouse event (`send-keys -M`) instead of synthetic copy-mode scroll
427
+ * commands. Native selection (for Ctrl+Shift+C) stays available via Shift+drag;
428
+ * if Ctrl+Shift+C reaches tmux while in copy-mode, remote explicitly maps it to
429
+ * copy-pipe-and-cancel so the same gesture still copies through copy-command.
430
+ * ALSO turns on the
431
+ * title-following options (see buildTmuxGlobalOptions) so the GNOME tab tracks
432
+ * the agent's live title. Global, idempotent, applied at every run/attach so it
433
+ * works even without ~/.tmux.conf.
434
+ */
435
+ export function ensureManagedTmuxProfile(profile = getTmuxProfileConfig().profile) {
436
+ const cmds = buildTmuxGlobalOptions(detectClipboardCommand(), profile);
437
+ for (const args of cmds) {
438
+ // Best-effort: no server yet / old tmux must never fail the caller.
439
+ spawnSync(TMUX, [...args], { stdio: "ignore" });
440
+ }
441
+ }
442
+ /** Backwards-compatible name kept for older callers/tests. */
443
+ export function ensureScrollConfig(profile) {
444
+ ensureManagedTmuxProfile(profile);
445
+ }
446
+ /**
447
+ * Start a CLI in a detached local tmux session. Idempotent on name: if a
448
+ * session with the same slug already exists it is reused (returns it). The slug
449
+ * defaults to the workdir basename; pass `label` to override it (e.g. to keep
450
+ * several sessions of the same project distinct: "sentropic#2").
451
+ */
452
+ export function startLocalSession(profile, command, cwd, args = [], label, tmuxProfile = getTmuxProfileConfig().profile) {
453
+ const slug = slugify(label ?? cwd);
454
+ const name = localSessionName(slug);
455
+ ensureScrollConfig(tmuxProfile);
456
+ if (findLocalSession(name)) {
457
+ persistAgentPaneMetadata(name, profile, cwd);
458
+ return { name, slug };
459
+ }
460
+ const r = spawnSync(TMUX, [
461
+ "new-session",
462
+ "-d",
463
+ ...tmuxEnvironmentArgs(),
464
+ "-s",
465
+ name,
466
+ // Launcher contract (a2a): the agent's window is NAMED after the profile
467
+ // (claude/codex/…). One-shot name at creation only — we never touch the
468
+ // automatic-rename option, so live titles elsewhere keep working.
469
+ "-n",
470
+ profile,
471
+ "-c",
472
+ cwd,
473
+ ...anthopicEnvUnsetCommandPrefix(),
474
+ "/bin/bash",
475
+ "-lc",
476
+ LOCAL_WRAPPER,
477
+ localRelaunchCommand(profile, cwd, label, args),
478
+ command,
479
+ ...args,
480
+ ], { stdio: "inherit" });
481
+ if (r.status !== 0) {
482
+ throw new Error(`tmux new-session failed (exit ${r.status ?? "?"})`);
483
+ }
484
+ // Record the profile as a session option so `remote ls` can show it.
485
+ spawnSync(TMUX, ["set-option", "-t", name, "@profile", profile], {
486
+ stdio: "ignore",
487
+ });
488
+ persistAgentPaneMetadata(name, profile, cwd);
489
+ return { name, slug };
490
+ }
491
+ /**
492
+ * Start a HEADLESS delegated job in a detached local tmux session under the
493
+ * run-once-exit wrapper: the CLI runs, its output is captured to `outputLog`,
494
+ * a `resultJson` is written, then the session ENDS. The task lands as a single
495
+ * argv token inside `args` (no shell concat). Idempotent on slug like
496
+ * startLocalSession. Returns the session name + slug.
497
+ */
498
+ export function startHeadlessSession(profile, command, cwd, args, resultJson, outputLog, label, tmuxProfile = getTmuxProfileConfig().profile) {
499
+ const slug = slugify(label);
500
+ const name = localSessionName(slug);
501
+ ensureScrollConfig(tmuxProfile);
502
+ if (findLocalSession(name))
503
+ return { name, slug };
504
+ const r = spawnSync(TMUX, [
505
+ "new-session",
506
+ "-d",
507
+ ...tmuxEnvironmentArgs(),
508
+ "-s",
509
+ name,
510
+ "-n",
511
+ profile,
512
+ "-c",
513
+ cwd,
514
+ "/bin/bash",
515
+ "-lc",
516
+ HEADLESS_WRAPPER,
517
+ resultJson,
518
+ outputLog,
519
+ command,
520
+ ...args,
521
+ ], { stdio: "ignore" });
522
+ if (r.status !== 0) {
523
+ throw new Error(`tmux new-session failed (exit ${r.status ?? "?"})`);
524
+ }
525
+ spawnSync(TMUX, ["set-option", "-t", name, "@profile", profile], {
526
+ stdio: "ignore",
527
+ });
528
+ return { name, slug };
529
+ }
530
+ /**
531
+ * The raw tmux `#{session_attached}` count for a session: 0 = DETACHED (no client
532
+ * attached), ≥1 = a client (a human terminal) is attached. `undefined` when the
533
+ * session is gone / tmux can't be reached. The interactive throttle auto-resume
534
+ * uses this as its HARD guard — it only ever nudges a pane whose count is 0, so
535
+ * we never send keys into a session a human is driving. Best-effort.
536
+ */
537
+ export function sessionAttachedCount(name) {
538
+ const r = spawnSync(TMUX, ["display", "-p", "-t", `=${name}`, "#{session_attached}"], { encoding: "utf8" });
539
+ if (r.status !== 0 || r.stdout === undefined)
540
+ return undefined;
541
+ const n = Number.parseInt(r.stdout.trim(), 10);
542
+ return Number.isFinite(n) ? n : undefined;
543
+ }
544
+ /** Last `lines` of a session's main pane (interactive job logs). "" if gone. */
545
+ export function capturePane(name, lines = 200) {
546
+ const r = spawnSync(TMUX, ["capture-pane", "-p", "-t", name, "-S", `-${lines}`], { encoding: "utf8" });
547
+ if (r.status !== 0 || !r.stdout)
548
+ return "";
549
+ return r.stdout;
550
+ }
551
+ /**
552
+ * tmux args adding a detached NAMED window to an existing session, running
553
+ * `commandLine` (a shell line, quoting preserved) under the drop-to-shell
554
+ * wrapper. Pure — exported for tests.
555
+ */
556
+ export function buildSessionWindowArgs(session, windowName, cwd, commandLine, agentPane) {
557
+ return [
558
+ "new-window",
559
+ "-d",
560
+ "-t",
561
+ session,
562
+ "-n",
563
+ windowName,
564
+ "-c",
565
+ cwd,
566
+ "/bin/bash",
567
+ "-lc",
568
+ WINDOW_WRAPPER,
569
+ "remote-window",
570
+ agentPane ?? "",
571
+ commandLine,
572
+ ];
573
+ }
574
+ /** Window names of a session (best-effort; [] if tmux/session is gone). */
575
+ export function sessionWindowNames(session) {
576
+ const r = spawnSync(TMUX, ["list-windows", "-t", session, "-F", "#{window_name}"], { encoding: "utf8" });
577
+ if (r.status !== 0 || !r.stdout)
578
+ return [];
579
+ return r.stdout.split("\n").filter(Boolean);
580
+ }
581
+ /** Add a detached named window running `commandLine` to an existing session. */
582
+ export function addSessionWindow(session, windowName, cwd, commandLine, agentPane) {
583
+ const r = spawnSync(TMUX, buildSessionWindowArgs(session, windowName, cwd, commandLine, agentPane), { stdio: "ignore" });
584
+ return r.status === 0;
585
+ }
586
+ /** Is `cmd` resolvable in PATH (login shell, same as the tmux windows use)? */
587
+ export function commandAvailable(cmd) {
588
+ try {
589
+ return (spawnSync("bash", ["-lc", `command -v -- ${cmd}`], { stdio: "ignore" })
590
+ .status === 0);
591
+ }
592
+ catch {
593
+ return false;
594
+ }
595
+ }
596
+ function validTmuxPaneId(value) {
597
+ return value !== undefined && /^%\d+$/.test(value);
598
+ }
599
+ function exactSessionTarget(session) {
600
+ return session.startsWith("=") ? session : `=${session}`;
601
+ }
602
+ function readSessionOption(session, option) {
603
+ const r = spawnSync(TMUX, ["show-options", "-qv", "-t", exactSessionTarget(session), option], { encoding: "utf8" });
604
+ if (r.status !== 0 || r.stdout === undefined)
605
+ return undefined;
606
+ const value = r.stdout.trim();
607
+ return value || undefined;
608
+ }
609
+ function setSessionOption(session, option, value) {
610
+ spawnSync(TMUX, ["set-option", "-t", exactSessionTarget(session), option, value], {
611
+ stdio: "ignore",
612
+ });
613
+ }
614
+ function firstNonH2aPane(session) {
615
+ const r = spawnSync(TMUX, [
616
+ "list-panes",
617
+ "-s",
618
+ "-t",
619
+ exactSessionTarget(session),
620
+ "-F",
621
+ "#{window_name}\t#{pane_id}",
622
+ ], { encoding: "utf8" });
623
+ if (r.status !== 0 || !r.stdout)
624
+ return undefined;
625
+ for (const line of r.stdout.split("\n")) {
626
+ if (!line)
627
+ continue;
628
+ const [windowName, paneId] = line.split("\t");
629
+ if (windowName !== H2A_WINDOW_NAME && validTmuxPaneId(paneId)) {
630
+ return paneId;
631
+ }
632
+ }
633
+ return undefined;
634
+ }
635
+ /**
636
+ * Resolve the durable agent pane for a given h2a instance.
637
+ * Parses host:label[:uuid] → finds the matching managed tmux session
638
+ * (remote-<label>, @remote_agent_host === host) → returns its @remote_agent_pane.
639
+ * Returns undefined if no pane is known for this instance.
640
+ */
641
+ export function resolveAgentPaneForInstance(instance) {
642
+ const parts = instance.split(":");
643
+ if (parts.length < 2)
644
+ return undefined;
645
+ const host = parts[0];
646
+ const label = parts[1];
647
+ if (!host || !label)
648
+ return undefined;
649
+ const sessionName = localSessionName(label);
650
+ const sessions = listLocalSessions();
651
+ const match = sessions.find((s) => s.name === sessionName &&
652
+ readSessionOption(s.name, AGENT_HOST_OPTION) === host);
653
+ if (!match)
654
+ return undefined;
655
+ return resolveAgentPane(match.name);
656
+ }
657
+ /** Agent pane used as h2a local-tmux wake target, persisted on the tmux session. */
658
+ export function resolveAgentPane(session) {
659
+ const stored = readSessionOption(session, AGENT_PANE_OPTION);
660
+ if (validTmuxPaneId(stored))
661
+ return stored;
662
+ const pane = firstNonH2aPane(session);
663
+ if (pane)
664
+ setSessionOption(session, AGENT_PANE_OPTION, pane);
665
+ return pane;
666
+ }
667
+ function persistAgentPaneMetadata(session, profile, cwd) {
668
+ const pane = resolveAgentPane(session);
669
+ if (!pane)
670
+ return undefined;
671
+ setSessionOption(session, AGENT_HOST_OPTION, profile);
672
+ setSessionOption(session, AGENT_CWD_OPTION, cwd);
673
+ return pane;
674
+ }
675
+ function commandNeedsLocalTmuxWake(commandLine) {
676
+ return /(?:^|\s)--wake(?:=|\s+)local-tmux(?:\s|$)/.test(commandLine);
677
+ }
678
+ /**
679
+ * Opt-in launcher contract: start `h2a mcp-serve …` in a side window named
680
+ * "h2a" of the agent's tmux session, so the agent is reachable/wakeable by the
681
+ * h2a file-based network. Never fails the run: a missing h2a binary (or tmux
682
+ * error) is a warning, and an already-present "h2a" window is reused as-is.
683
+ */
684
+ export function startH2aWindow(session, cwd, commandLine, stderr = process.stderr) {
685
+ const bin = commandLine.trim().split(/\s+/)[0] ?? "";
686
+ if (!bin || !commandAvailable(bin)) {
687
+ stderr.write(`[remote] h2a window skipped: \`${bin || commandLine}\` not found in PATH — install h2a (or fix the h2a.command config) and re-run with --h2a.\n`);
688
+ return false;
689
+ }
690
+ const needsLocalTmuxWake = commandNeedsLocalTmuxWake(commandLine);
691
+ if (sessionWindowNames(session).includes(H2A_WINDOW_NAME)) {
692
+ if (needsLocalTmuxWake) {
693
+ stderr.write(`[remote] h2a window already exists in ${session}; wake target may be stale/wrong. Restart that window/session to pick up the agent pane target.\n`);
694
+ }
695
+ return true;
696
+ }
697
+ const agentPane = resolveAgentPane(session);
698
+ if (needsLocalTmuxWake && !agentPane) {
699
+ stderr.write(`[remote] h2a window skipped: agent pane could not be resolved for ${session}; refusing to publish a false --wake local-tmux target.\n`);
700
+ return false;
701
+ }
702
+ if (!addSessionWindow(session, H2A_WINDOW_NAME, cwd, commandLine, agentPane)) {
703
+ stderr.write(`[remote] h2a window failed to start (tmux new-window error on ${session})\n`);
704
+ return false;
705
+ }
706
+ return true;
707
+ }
708
+ /**
709
+ * Attach the real terminal to a local tmux session. Blocks until the user
710
+ * detaches (Ctrl-b d) or the session ends. Returns the tmux exit status.
711
+ */
712
+ export function attachLocalSession(name) {
713
+ ensureScrollConfig();
714
+ const args = process.env.TMUX
715
+ ? ["switch-client", "-t", `=${name}`]
716
+ : ["attach-session", "-t", `=${name}`];
717
+ const r = spawnSync(TMUX, args, { stdio: "inherit" });
718
+ return r.status ?? 0;
719
+ }
720
+ export function currentTmuxSessionIs(name) {
721
+ if (!process.env.TMUX)
722
+ return false;
723
+ const r = spawnSync(TMUX, ["display-message", "-p", "#S"], {
724
+ encoding: "utf8",
725
+ stdio: ["ignore", "pipe", "ignore"],
726
+ });
727
+ return r.status === 0 && r.stdout.trim() === name;
728
+ }
729
+ export function runLocalCliForeground(command, args) {
730
+ const r = spawnSync(command, args, { stdio: "inherit" });
731
+ return r.status ?? 0;
732
+ }
733
+ /** Kill a local tmux session. */
734
+ export function killLocalSession(name) {
735
+ const r = spawnSync(TMUX, ["kill-session", "-t", name], { stdio: "ignore" });
736
+ return r.status === 0;
737
+ }
738
+ /**
739
+ * M3 — is a `remote jobs conduct` conductor process running right now? Used by
740
+ * `jobs ls` to warn when there are queued jobs but nothing to drain them. Matches
741
+ * the conductor's command line via pgrep, NOT a tmux marker (the conductor may run
742
+ * in any window/shell). Best-effort: any error (no pgrep, etc.) returns false so
743
+ * we err toward SHOWING the advisory rather than hiding a real stall. Excludes the
744
+ * current pid so a `conduct` process that itself shells out to `jobs ls` for
745
+ * status doesn't self-detect.
746
+ */
747
+ export function conductorRunning() {
748
+ try {
749
+ const r = spawnSync("pgrep", ["-f", "jobs +conduct"], {
750
+ encoding: "utf8",
751
+ stdio: ["ignore", "pipe", "ignore"],
752
+ });
753
+ if (r.status !== 0 || !r.stdout)
754
+ return false;
755
+ const pids = r.stdout
756
+ .split("\n")
757
+ .map((s) => Number.parseInt(s.trim(), 10))
758
+ .filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
759
+ return pids.length > 0;
760
+ }
761
+ catch {
762
+ return false;
763
+ }
764
+ }
765
+ /**
766
+ * True when a session's main pane is an IDLE shell (its CLI exited and dropped
767
+ * to the wrapper's `bash -l`). Idle = pane command is bash/sh AND that pane
768
+ * process has no child — the relaunch wrapper keeps the CLI as a child of bash,
769
+ * so `pane_current_command` alone reads "bash" even with a live CLI; the child
770
+ * count disambiguates. `/proc` scan because the relaunch wrapper context may
771
+ * lack ps; falls back to ps when /proc is unavailable. Best-effort: on any
772
+ * doubt returns false (treat as live → never disturbed).
773
+ */
774
+ export function localSessionIdle(name) {
775
+ const disp = spawnSync(TMUX, ["display", "-p", "-t", name, "#{pane_pid} #{pane_current_command}"], { encoding: "utf8" });
776
+ if (disp.status !== 0 || !disp.stdout)
777
+ return false;
778
+ const [pidStr, cmd = ""] = disp.stdout.trim().split(/\s+/);
779
+ if (cmd !== "bash" && cmd !== "sh")
780
+ return false;
781
+ const pid = Number(pidStr);
782
+ if (!Number.isInteger(pid) || pid <= 0)
783
+ return false;
784
+ // Count children of the pane shell.
785
+ const children = spawnSync("bash", [
786
+ "-lc",
787
+ `awk -v p="${pid}" '$1=="PPid:" && $2==p {c++} END{print c+0}' /proc/[0-9]*/status 2>/dev/null || ps --ppid ${pid} -o pid= 2>/dev/null | grep -c .`,
788
+ ], { encoding: "utf8" });
789
+ const kids = Number((children.stdout ?? "").trim());
790
+ return Number.isInteger(kids) && kids === 0;
791
+ }
792
+ function directChildPids(pid) {
793
+ const children = spawnSync("ps", ["--ppid", String(pid), "-o", "pid="], {
794
+ encoding: "utf8",
795
+ stdio: ["ignore", "pipe", "ignore"],
796
+ });
797
+ if (children.status !== 0 || !children.stdout)
798
+ return [];
799
+ return children.stdout
800
+ .split("\n")
801
+ .map((s) => Number.parseInt(s.trim(), 10))
802
+ .filter((n) => Number.isInteger(n) && n > 0);
803
+ }
804
+ function readProcessEnvironment(pid) {
805
+ try {
806
+ const raw = readFileSync(`/proc/${pid}/environ`, "utf8");
807
+ const env = {};
808
+ for (const part of raw.split("\0")) {
809
+ const i = part.indexOf("=");
810
+ if (i > 0)
811
+ env[part.slice(0, i)] = part.slice(i + 1);
812
+ }
813
+ return env;
814
+ }
815
+ catch {
816
+ return null;
817
+ }
818
+ }
819
+ /**
820
+ * Inspect the real CLI child process under the tmux wrapper and compare its
821
+ * Anthropic gateway env with the token remote is about to use. Unknown is
822
+ * deliberately non-actionable; callers should only repair missing/stale.
823
+ */
824
+ export function localSessionGatewayEnvStatus(name, expected) {
825
+ const disp = spawnSync(TMUX, ["display", "-p", "-t", name, "#{pane_pid}"], {
826
+ encoding: "utf8",
827
+ });
828
+ if (disp.status !== 0 || !disp.stdout)
829
+ return "unknown";
830
+ const panePid = Number.parseInt(disp.stdout.trim(), 10);
831
+ if (!Number.isInteger(panePid) || panePid <= 0)
832
+ return "unknown";
833
+ const [childPid] = directChildPids(panePid);
834
+ const env = readProcessEnvironment(childPid ?? panePid);
835
+ if (!env)
836
+ return "unknown";
837
+ const baseUrl = env.ANTHROPIC_BASE_URL;
838
+ const authToken = env.ANTHROPIC_AUTH_TOKEN;
839
+ if (!baseUrl || !authToken)
840
+ return "missing";
841
+ if (baseUrl !== expected.baseUrl || authToken !== expected.authToken) {
842
+ return "stale";
843
+ }
844
+ return "current";
845
+ }
846
+ /**
847
+ * Relaunch a CLI inside an EXISTING session's main pane, in situ: send the
848
+ * command to the idle shell (it runs at the prompt; when it exits the shell is
849
+ * still there). Does NOT recreate windows or go through `remote run`/the guard.
850
+ */
851
+ export function relaunchInSession(name, command) {
852
+ const r = spawnSync(TMUX, ["send-keys", "-t", name, command, "Enter"], {
853
+ stdio: "ignore",
854
+ });
855
+ return r.status === 0;
856
+ }
857
+ /**
858
+ * Is a tmux session ATTACHED right now (a client is connected)? Reads
859
+ * `#{session_attached}` for the EXACT session ("=" prefix → no prefix match).
860
+ * Returns true (CONSERVATIVE) on ANY doubt — missing/erroring tmux, unparseable
861
+ * count — so the interactive throttle resume NEVER types into a pane we cannot
862
+ * prove is detached. The throttle-phase-2 HARD GUARD lives here AND in the pure
863
+ * planner; this is the live, last-line-of-defence re-check.
864
+ */
865
+ export function sessionAttached(name) {
866
+ try {
867
+ const r = spawnSync(TMUX, ["display", "-p", "-t", `=${name}`, "#{session_attached}"], { encoding: "utf8" });
868
+ if (r.status !== 0)
869
+ return true; // can't tell → assume attached (never nudge)
870
+ const n = Number((r.stdout ?? "").trim());
871
+ if (!Number.isInteger(n))
872
+ return true; // unparseable → assume attached
873
+ return n !== 0;
874
+ }
875
+ catch {
876
+ return true; // tmux blew up → assume attached (never nudge)
877
+ }
878
+ }
879
+ /**
880
+ * Send a LITERAL line into a session's main pane, then submit it with a real
881
+ * Enter key event. The keys ride `send-keys -l <keys>` as a SINGLE literal
882
+ * argument — tmux does NOT interpret it (no key-name lookup, no shell), so an
883
+ * arbitrary nudge string is safe. Enter is sent as a separate, NON-literal
884
+ * key-name so it is a real carriage return rather than the word "Enter". Used by
885
+ * the interactive throttle auto-resume to un-stick a rate-limited pane. Returns
886
+ * whether tmux accepted both sends.
887
+ */
888
+ export function sendKeysLiteral(name, keys) {
889
+ const typed = spawnSync(TMUX, ["send-keys", "-t", name, "-l", keys], {
890
+ stdio: "ignore",
891
+ });
892
+ if (typed.status !== 0)
893
+ return false;
894
+ const enter = spawnSync(TMUX, ["send-keys", "-t", name, "Enter"], {
895
+ stdio: "ignore",
896
+ });
897
+ return enter.status === 0;
898
+ }
899
+ /**
900
+ * Attach the real terminal straight into the Pod's tmux session via
901
+ * `kubectl exec -it`. The local terminal talks to tmux directly (no WS proxy),
902
+ * so scrollback + copy-to-local-clipboard (OSC52) work natively. Requires a
903
+ * tmux-backed session (Pod started by the tmux-wrapping agent). Blocks until
904
+ * detach/exit; returns the kubectl exit status.
905
+ */
906
+ export function attachPodTmux(tunnel, sessionId) {
907
+ const env = { ...process.env };
908
+ if (tunnel.kubeconfig)
909
+ env.KUBECONFIG = expandHome(tunnel.kubeconfig);
910
+ // The attach CLIENT must be UTF-8 or tmux transcodes accented output to "_"
911
+ // for it: the Pod's default locale is empty (ASCII), so we force it on the
912
+ // exec'd tmux client — `env LANG=C.UTF-8` (so tmux detects UTF-8) + `tmux -u`
913
+ // (force UTF-8 regardless of detection). (capture-pane never transcodes, which
914
+ // is why a capture test looked fine while the interactive attach showed "_".)
915
+ const args = [
916
+ "-n",
917
+ tunnel.namespace,
918
+ "exec",
919
+ "-it",
920
+ `session-${sessionId}`,
921
+ "-c",
922
+ "session-agent",
923
+ "--",
924
+ "env",
925
+ "LANG=C.UTF-8",
926
+ "LC_ALL=C.UTF-8",
927
+ "tmux",
928
+ "-u",
929
+ "new-session",
930
+ "-A",
931
+ "-s",
932
+ POD_TMUX_SESSION,
933
+ ];
934
+ // Long-lived `kubectl exec` streams corrupt over time ("tls: bad record MAC"
935
+ // / "next reader: local error" from the SPDY/WS executor): the terminal fills
936
+ // with garbage and the client dies, dumping the user back to their local
937
+ // shell. The Pod's tmux session SURVIVES that, so we auto-reconnect into it
938
+ // instead of leaving the user stranded — a clean detach (Ctrl-b d) exits the
939
+ // tmux client with status 0 and we stop; any non-zero exit is a dropped
940
+ // stream and we re-exec. If it dies almost instantly several times in a row
941
+ // the Pod is likely gone, so we give up rather than spin forever.
942
+ let quickFailures = 0;
943
+ for (;;) {
944
+ const startedAt = Date.now();
945
+ const r = spawnSync("kubectl", args, { stdio: "inherit", env });
946
+ const status = r.status ?? 0;
947
+ if (status === 0) {
948
+ // Clean detach (Ctrl-b d) or exit: the Pod session keeps running — tell
949
+ // the user how to get back in.
950
+ process.stderr.write(`[remote] detached from ${sessionId} — re-attach: remote attach ${sessionId} --exec\n`);
951
+ return 0;
952
+ }
953
+ const ranMs = Date.now() - startedAt;
954
+ if (ranMs < 3000) {
955
+ quickFailures += 1;
956
+ if (quickFailures >= 5) {
957
+ process.stderr.write(`[remote] exec attach keeps failing immediately (status ${status}) — the Pod may be gone. ` +
958
+ `Re-run \`remote attach ${sessionId} --exec\` once it's back.\n`);
959
+ return status;
960
+ }
961
+ }
962
+ else {
963
+ quickFailures = 0; // a real session that ran a while then dropped
964
+ }
965
+ process.stderr.write(`[remote] exec stream dropped (status ${status} — e.g. "tls: bad record MAC" on a long kubectl exec). ` +
966
+ `Your Pod session is intact; reconnecting to its tmux… (Ctrl-C to stop)\n`);
967
+ spawnSync("sleep", ["1"], { stdio: "ignore" });
968
+ }
969
+ }
970
+ //# sourceMappingURL=tmux.js.map