@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.
- package/dist/account-pool.d.ts +168 -0
- package/dist/account-pool.d.ts.map +1 -0
- package/dist/account-pool.js +385 -0
- package/dist/account-pool.js.map +1 -0
- package/dist/agents-projection.d.ts +56 -0
- package/dist/agents-projection.d.ts.map +1 -0
- package/dist/agents-projection.js +82 -0
- package/dist/agents-projection.js.map +1 -0
- package/dist/attach.d.ts +75 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +432 -0
- package/dist/attach.js.map +1 -0
- package/dist/auth-bundle.d.ts +21 -0
- package/dist/auth-bundle.d.ts.map +1 -0
- package/dist/auth-bundle.js +112 -0
- package/dist/auth-bundle.js.map +1 -0
- package/dist/auth-diagnostics.d.ts +18 -0
- package/dist/auth-diagnostics.d.ts.map +1 -0
- package/dist/auth-diagnostics.js +23 -0
- package/dist/auth-diagnostics.js.map +1 -0
- package/dist/auth-login.d.ts +9 -0
- package/dist/auth-login.d.ts.map +1 -0
- package/dist/auth-login.js +21 -0
- package/dist/auth-login.js.map +1 -0
- package/dist/auth-refresh.d.ts +32 -0
- package/dist/auth-refresh.d.ts.map +1 -0
- package/dist/auth-refresh.js +83 -0
- package/dist/auth-refresh.js.map +1 -0
- package/dist/auth-tools.d.ts +55 -0
- package/dist/auth-tools.d.ts.map +1 -0
- package/dist/auth-tools.js +163 -0
- package/dist/auth-tools.js.map +1 -0
- package/dist/browser-bridge-local.d.ts +27 -0
- package/dist/browser-bridge-local.d.ts.map +1 -0
- package/dist/browser-bridge-local.js +51 -0
- package/dist/browser-bridge-local.js.map +1 -0
- package/dist/browser.d.ts +53 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +61 -0
- package/dist/browser.js.map +1 -0
- package/dist/conductor-launch.d.ts +179 -0
- package/dist/conductor-launch.d.ts.map +1 -0
- package/dist/conductor-launch.js +414 -0
- package/dist/conductor-launch.js.map +1 -0
- package/dist/config.d.ts +182 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +334 -0
- package/dist/config.js.map +1 -0
- package/dist/conv-guard.d.ts +76 -0
- package/dist/conv-guard.d.ts.map +1 -0
- package/dist/conv-guard.js +131 -0
- package/dist/conv-guard.js.map +1 -0
- package/dist/convsync.d.ts +24 -0
- package/dist/convsync.d.ts.map +1 -0
- package/dist/convsync.js +108 -0
- package/dist/convsync.js.map +1 -0
- package/dist/cred-health.d.ts +96 -0
- package/dist/cred-health.d.ts.map +1 -0
- package/dist/cred-health.js +170 -0
- package/dist/cred-health.js.map +1 -0
- package/dist/delegate.d.ts +406 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +739 -0
- package/dist/delegate.js.map +1 -0
- package/dist/enroll.d.ts +91 -0
- package/dist/enroll.d.ts.map +1 -0
- package/dist/enroll.js +248 -0
- package/dist/enroll.js.map +1 -0
- package/dist/fanout.d.ts +63 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +115 -0
- package/dist/fanout.js.map +1 -0
- package/dist/forward.d.ts +66 -0
- package/dist/forward.d.ts.map +1 -0
- package/dist/forward.js +141 -0
- package/dist/forward.js.map +1 -0
- package/dist/gitdiff.d.ts +31 -0
- package/dist/gitdiff.d.ts.map +1 -0
- package/dist/gitdiff.js +133 -0
- package/dist/gitdiff.js.map +1 -0
- package/dist/h2a-bridge.d.ts +102 -0
- package/dist/h2a-bridge.d.ts.map +1 -0
- package/dist/h2a-bridge.js +333 -0
- package/dist/h2a-bridge.js.map +1 -0
- package/dist/h2a-jobs.d.ts +235 -0
- package/dist/h2a-jobs.d.ts.map +1 -0
- package/dist/h2a-jobs.js +388 -0
- package/dist/h2a-jobs.js.map +1 -0
- package/dist/h2a-ping.d.ts +38 -0
- package/dist/h2a-ping.d.ts.map +1 -0
- package/dist/h2a-ping.js +58 -0
- package/dist/h2a-ping.js.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5887 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive-throttle.d.ts +164 -0
- package/dist/interactive-throttle.d.ts.map +1 -0
- package/dist/interactive-throttle.js +194 -0
- package/dist/interactive-throttle.js.map +1 -0
- package/dist/lineage-client.d.ts +77 -0
- package/dist/lineage-client.d.ts.map +1 -0
- package/dist/lineage-client.js +117 -0
- package/dist/lineage-client.js.map +1 -0
- package/dist/lineage-lease.d.ts +152 -0
- package/dist/lineage-lease.d.ts.map +1 -0
- package/dist/lineage-lease.js +303 -0
- package/dist/lineage-lease.js.map +1 -0
- package/dist/llm-gateway-runtime/accounts.d.ts +48 -0
- package/dist/llm-gateway-runtime/accounts.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/accounts.js +226 -0
- package/dist/llm-gateway-runtime/accounts.js.map +1 -0
- package/dist/llm-gateway-runtime/index.d.ts +4 -0
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/index.js +58 -0
- package/dist/llm-gateway-runtime/index.js.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts +36 -0
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/model-catalog.js +146 -0
- package/dist/llm-gateway-runtime/model-catalog.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +3 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js +139 -0
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +117 -0
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/proxy-openai.js +1132 -0
- package/dist/llm-gateway-runtime/proxy-openai.js.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts +32 -0
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/session-ledger.js +62 -0
- package/dist/llm-gateway-runtime/session-ledger.js.map +1 -0
- package/dist/llm-gateway-runtime/sticky.d.ts +40 -0
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -0
- package/dist/llm-gateway-runtime/sticky.js +254 -0
- package/dist/llm-gateway-runtime/sticky.js.map +1 -0
- package/dist/llm-mesh.d.ts +102 -0
- package/dist/llm-mesh.d.ts.map +1 -0
- package/dist/llm-mesh.js +433 -0
- package/dist/llm-mesh.js.map +1 -0
- package/dist/migrate-candidates.d.ts +35 -0
- package/dist/migrate-candidates.d.ts.map +1 -0
- package/dist/migrate-candidates.js +128 -0
- package/dist/migrate-candidates.js.map +1 -0
- package/dist/migrate.d.ts +125 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +561 -0
- package/dist/migrate.js.map +1 -0
- package/dist/plugin-manifest.d.ts +143 -0
- package/dist/plugin-manifest.d.ts.map +1 -0
- package/dist/plugin-manifest.js +233 -0
- package/dist/plugin-manifest.js.map +1 -0
- package/dist/plugin.d.ts +274 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +919 -0
- package/dist/plugin.js.map +1 -0
- package/dist/pod-liveness.d.ts +51 -0
- package/dist/pod-liveness.d.ts.map +1 -0
- package/dist/pod-liveness.js +80 -0
- package/dist/pod-liveness.js.map +1 -0
- package/dist/profile-menu.d.ts +7 -0
- package/dist/profile-menu.d.ts.map +1 -0
- package/dist/profile-menu.js +52 -0
- package/dist/profile-menu.js.map +1 -0
- package/dist/profiles.d.ts +23 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +66 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol-local.d.ts +27 -0
- package/dist/protocol-local.d.ts.map +1 -0
- package/dist/protocol-local.js +12 -0
- package/dist/protocol-local.js.map +1 -0
- package/dist/pty.d.ts +26 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +45 -0
- package/dist/pty.js.map +1 -0
- package/dist/readiness.d.ts +38 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +169 -0
- package/dist/readiness.js.map +1 -0
- package/dist/registry.d.ts +258 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +570 -0
- package/dist/registry.js.map +1 -0
- package/dist/relaunch.d.ts +52 -0
- package/dist/relaunch.d.ts.map +1 -0
- package/dist/relaunch.js +76 -0
- package/dist/relaunch.js.map +1 -0
- package/dist/restore.d.ts +165 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +530 -0
- package/dist/restore.js.map +1 -0
- package/dist/run.d.ts +29 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +206 -0
- package/dist/run.js.map +1 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +98 -0
- package/dist/secrets.js.map +1 -0
- package/dist/session-restore.d.ts +26 -0
- package/dist/session-restore.d.ts.map +1 -0
- package/dist/session-restore.js +122 -0
- package/dist/session-restore.js.map +1 -0
- package/dist/skills-sync.d.ts +126 -0
- package/dist/skills-sync.d.ts.map +1 -0
- package/dist/skills-sync.js +255 -0
- package/dist/skills-sync.js.map +1 -0
- package/dist/smoke.d.ts +23 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +138 -0
- package/dist/smoke.js.map +1 -0
- package/dist/soft-refresh.d.ts +107 -0
- package/dist/soft-refresh.d.ts.map +1 -0
- package/dist/soft-refresh.js +426 -0
- package/dist/soft-refresh.js.map +1 -0
- package/dist/sync-status.d.ts +22 -0
- package/dist/sync-status.d.ts.map +1 -0
- package/dist/sync-status.js +41 -0
- package/dist/sync-status.js.map +1 -0
- package/dist/sync.d.ts +77 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +302 -0
- package/dist/sync.js.map +1 -0
- package/dist/throttle-signatures.d.ts +43 -0
- package/dist/throttle-signatures.d.ts.map +1 -0
- package/dist/throttle-signatures.js +116 -0
- package/dist/throttle-signatures.js.map +1 -0
- package/dist/tmux.d.ts +286 -0
- package/dist/tmux.d.ts.map +1 -0
- package/dist/tmux.js +970 -0
- package/dist/tmux.js.map +1 -0
- package/dist/tunnel.d.ts +25 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +127 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/workspace-merge.d.ts +17 -0
- package/dist/workspace-merge.d.ts.map +1 -0
- package/dist/workspace-merge.js +153 -0
- package/dist/workspace-merge.js.map +1 -0
- package/dist/workspace-sync-incremental.d.ts +64 -0
- package/dist/workspace-sync-incremental.d.ts.map +1 -0
- package/dist/workspace-sync-incremental.js +199 -0
- package/dist/workspace-sync-incremental.js.map +1 -0
- package/dist/workspace-sync.d.ts +3 -0
- package/dist/workspace-sync.d.ts.map +1 -0
- package/dist/workspace-sync.js +187 -0
- package/dist/workspace-sync.js.map +1 -0
- package/dist/workspace.d.ts +70 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +173 -0
- package/dist/workspace.js.map +1 -0
- 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
|