very-happy-cli 0.2.97 → 0.2.99
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/{AcpBackend-CTUM4dvG.mjs → AcpBackend-C9LtzYSx.mjs} +1 -1
- package/dist/{AcpBackend-DrwdVSeJ.cjs → AcpBackend-DkvV2nhl.cjs} +1 -1
- package/dist/{AcpSessionManager-BoBuHee3.mjs → AcpSessionManager-B5rJcE82.mjs} +1 -1
- package/dist/{AcpSessionManager-BieE187D.cjs → AcpSessionManager-gsp_-PGy.cjs} +1 -1
- package/dist/{config-DCH33LOM.mjs → config-BcCVR-Wx.mjs} +2 -2
- package/dist/{config-COq6X7U7.cjs → config-Bidi0Yeq.cjs} +2 -2
- package/dist/{index-Bcgquogx.mjs → index-B87z2DOo.mjs} +12 -15
- package/dist/{index-Dn0iOBFd.cjs → index-CxdGnaov.cjs} +4 -4
- package/dist/{index-BD2fHJ8o.cjs → index-Dp1EYEyF.cjs} +18 -21
- package/dist/{index-DkhMvofH.mjs → index-Wfds4scC.mjs} +5 -5
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{installTerminalHooks-DqNtW6a2.mjs → installTerminalHooks-DkOh088o.mjs} +2 -2
- package/dist/{installTerminalHooks-H3kpFEZS.cjs → installTerminalHooks-jaCruGfR.cjs} +2 -2
- package/dist/lib.cjs +1 -1
- package/dist/lib.d.cts +11 -0
- package/dist/lib.d.mts +11 -0
- package/dist/lib.mjs +1 -1
- package/dist/{mcp-BKAuzaLX.mjs → mcp-DhftF_XE.mjs} +2 -2
- package/dist/{mcp-BwNc5eRs.cjs → mcp-ZQbu8tL1.cjs} +2 -2
- package/dist/{runGemini-Brp0UmLx.mjs → runGemini-3-xWWBRA.mjs} +4 -4
- package/dist/{runGemini-w35qiFBB.cjs → runGemini-Ce7Maf-D.cjs} +4 -4
- package/dist/{runOpenClaw-BvPRFx-L.mjs → runOpenClaw-DZhGhihh.mjs} +3 -3
- package/dist/{runOpenClaw-CkHtTCpI.cjs → runOpenClaw-q3inEsDr.cjs} +3 -3
- package/dist/{send-c-wEmh5U.mjs → send-B3NOqi9-.mjs} +2 -2
- package/dist/{send-tyKG13xt.cjs → send-BdEJeVye.cjs} +2 -2
- package/dist/{spawn-yS6yY8Xi.mjs → spawn-CCYAeBfN.mjs} +2 -2
- package/dist/{spawn-xdCsG2RT.cjs → spawn-DEVUh4iW.cjs} +2 -2
- package/dist/{types-xZOYW0_G.mjs → types-CrJj-daH.mjs} +302 -73
- package/dist/{types-DoGaKVfK.cjs → types-DhxxJFD0.cjs} +303 -73
- package/package.json +7 -7
|
@@ -51,7 +51,7 @@ var z__namespace = /*#__PURE__*/_interopNamespaceDefault(z);
|
|
|
51
51
|
var pty__namespace = /*#__PURE__*/_interopNamespaceDefault(pty);
|
|
52
52
|
|
|
53
53
|
var name = "very-happy-cli";
|
|
54
|
-
var version = "0.2.
|
|
54
|
+
var version = "0.2.99";
|
|
55
55
|
var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
|
|
56
56
|
var author = "Kirill Dubovitskiy";
|
|
57
57
|
var contributors = [
|
|
@@ -164,12 +164,12 @@ var dependencies = {
|
|
|
164
164
|
zod: "^4.0.0"
|
|
165
165
|
};
|
|
166
166
|
var optionalDependencies = {
|
|
167
|
-
"very-happy-tools-arm64-darwin": "0.2.
|
|
168
|
-
"very-happy-tools-x64-darwin": "0.2.
|
|
169
|
-
"very-happy-tools-arm64-linux": "0.2.
|
|
170
|
-
"very-happy-tools-x64-linux": "0.2.
|
|
171
|
-
"very-happy-tools-arm64-win32": "0.2.
|
|
172
|
-
"very-happy-tools-x64-win32": "0.2.
|
|
167
|
+
"very-happy-tools-arm64-darwin": "0.2.99",
|
|
168
|
+
"very-happy-tools-x64-darwin": "0.2.99",
|
|
169
|
+
"very-happy-tools-arm64-linux": "0.2.99",
|
|
170
|
+
"very-happy-tools-x64-linux": "0.2.99",
|
|
171
|
+
"very-happy-tools-arm64-win32": "0.2.99",
|
|
172
|
+
"very-happy-tools-x64-win32": "0.2.99"
|
|
173
173
|
};
|
|
174
174
|
var devDependencies = {
|
|
175
175
|
"@slopus/happy-wire": "workspace:*",
|
|
@@ -1041,7 +1041,9 @@ const WebTerminalListItemSchema = z.z.object({
|
|
|
1041
1041
|
* conversation carried over, the processes did not. Cleared once opened. */
|
|
1042
1042
|
restoredAt: z.z.number().optional(),
|
|
1043
1043
|
/** B-265: @vh_title_manual is set (user renamed it). */
|
|
1044
|
-
manual: z.z.boolean().optional()
|
|
1044
|
+
manual: z.z.boolean().optional(),
|
|
1045
|
+
/** B-273: opened attached to the user's tmux session of this name. */
|
|
1046
|
+
attachTmux: z.z.string().optional()
|
|
1045
1047
|
});
|
|
1046
1048
|
const ClosedTerminalRecordSchema = z.z.object({
|
|
1047
1049
|
id: z.z.string(),
|
|
@@ -1058,6 +1060,8 @@ const ClosedTerminalRecordSchema = z.z.object({
|
|
|
1058
1060
|
/** B-265: tags + manual-rename flag at close time (restored verbatim). */
|
|
1059
1061
|
tags: z.z.array(z.z.string()).optional(),
|
|
1060
1062
|
manual: z.z.boolean().optional(),
|
|
1063
|
+
/** B-273: attached user tmux session name — restore re-attaches. */
|
|
1064
|
+
attachTmux: z.z.string().optional(),
|
|
1061
1065
|
closedAt: z.z.number()
|
|
1062
1066
|
});
|
|
1063
1067
|
z.z.object({
|
|
@@ -1111,6 +1115,18 @@ z.z.object({
|
|
|
1111
1115
|
rpcAvailable: z.z.boolean(),
|
|
1112
1116
|
detectedAt: z.z.number()
|
|
1113
1117
|
}).optional(),
|
|
1118
|
+
/**
|
|
1119
|
+
* B-273 capability flag: this daemon answers `list-tmux-sessions` and
|
|
1120
|
+
* honours `open-terminal.attachTmux`. Same trust rule (`detectedAt >=
|
|
1121
|
+
* startedAt`). Stamped unconditionally — on a machine without tmux the
|
|
1122
|
+
* RPC just answers an empty list.
|
|
1123
|
+
*/
|
|
1124
|
+
tmuxSessions: z.z.object({
|
|
1125
|
+
rpcAvailable: z.z.boolean(),
|
|
1126
|
+
detectedAt: z.z.number(),
|
|
1127
|
+
/** B-282: `kill-terminal` honours `alsoAttached`. */
|
|
1128
|
+
killAttached: z.z.boolean().optional()
|
|
1129
|
+
}).optional(),
|
|
1114
1130
|
/** Relay-owned CLI compatibility/update policy last checked by this daemon. */
|
|
1115
1131
|
cliUpdate: CliUpdateStateSchema.optional(),
|
|
1116
1132
|
/**
|
|
@@ -1569,7 +1585,7 @@ class RpcHandlerManager {
|
|
|
1569
1585
|
}
|
|
1570
1586
|
}
|
|
1571
1587
|
|
|
1572
|
-
const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-
|
|
1588
|
+
const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-DhxxJFD0.cjs', document.baseURI).href))));
|
|
1573
1589
|
function projectPath() {
|
|
1574
1590
|
const path = path$1.resolve(__dirname$1, "..");
|
|
1575
1591
|
return path;
|
|
@@ -4812,7 +4828,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
|
|
|
4812
4828
|
const rows = [];
|
|
4813
4829
|
for (const [id, value] of Object.entries(raw)) {
|
|
4814
4830
|
if (!id || !value || typeof value !== "object") continue;
|
|
4815
|
-
const { title, cwd, seenAt, tags, manual } = value;
|
|
4831
|
+
const { title, cwd, seenAt, tags, manual, attachTmux } = value;
|
|
4816
4832
|
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
|
|
4817
4833
|
if (now - seenAt >= ttlMs) continue;
|
|
4818
4834
|
const tagList = sanitizeTagList(tags);
|
|
@@ -4821,6 +4837,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
|
|
|
4821
4837
|
cwd: typeof cwd === "string" && cwd ? cwd : void 0,
|
|
4822
4838
|
...tagList !== void 0 ? { tags: tagList } : {},
|
|
4823
4839
|
...manual === true ? { manual: true } : {},
|
|
4840
|
+
...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
|
|
4824
4841
|
seenAt
|
|
4825
4842
|
}]);
|
|
4826
4843
|
}
|
|
@@ -4841,6 +4858,7 @@ function liveSnapshotChanged(prev, next) {
|
|
|
4841
4858
|
if (!before) return true;
|
|
4842
4859
|
if (before.title !== info.title || before.cwd !== info.cwd) return true;
|
|
4843
4860
|
if (!!before.manual !== !!info.manual) return true;
|
|
4861
|
+
if ((before.attachTmux ?? "") !== (info.attachTmux ?? "")) return true;
|
|
4844
4862
|
if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
|
|
4845
4863
|
}
|
|
4846
4864
|
return false;
|
|
@@ -4866,9 +4884,89 @@ function pickMirrorForTerminal(sessions, terminalId) {
|
|
|
4866
4884
|
return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
|
|
4867
4885
|
}
|
|
4868
4886
|
|
|
4887
|
+
function shellescape(s) {
|
|
4888
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
4889
|
+
}
|
|
4890
|
+
|
|
4891
|
+
const USER_SESSION_FIELD_SEP = "<~|~>";
|
|
4892
|
+
const USER_SESSIONS_FORMAT = [
|
|
4893
|
+
"#{session_id}",
|
|
4894
|
+
"#{session_name}",
|
|
4895
|
+
"#{session_windows}",
|
|
4896
|
+
"#{session_attached}",
|
|
4897
|
+
"#{session_activity}",
|
|
4898
|
+
"#{session_created}",
|
|
4899
|
+
"#{pane_current_command}",
|
|
4900
|
+
"#{pane_current_path}"
|
|
4901
|
+
].join(USER_SESSION_FIELD_SEP);
|
|
4902
|
+
const TMUX_SESSION_ID_RE = /^\$\d{1,9}$/;
|
|
4903
|
+
const TMUX_SESSION_NAME_MAX = 128;
|
|
4904
|
+
function isSafeTmuxSessionName(name) {
|
|
4905
|
+
if (typeof name !== "string") return false;
|
|
4906
|
+
if (name.length === 0 || name.length > TMUX_SESSION_NAME_MAX) return false;
|
|
4907
|
+
return !/[\x00-\x1f\x7f]/.test(name);
|
|
4908
|
+
}
|
|
4909
|
+
function isVhSessionName(name) {
|
|
4910
|
+
return name.startsWith("vh-");
|
|
4911
|
+
}
|
|
4912
|
+
function parseUserSessionLine(line) {
|
|
4913
|
+
if (!line) return void 0;
|
|
4914
|
+
const parts = line.split(USER_SESSION_FIELD_SEP);
|
|
4915
|
+
if (parts.length < 8) return void 0;
|
|
4916
|
+
const [id, name, windows, attached, activity, created, command] = parts;
|
|
4917
|
+
if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name)) return void 0;
|
|
4918
|
+
const cwd = parts.slice(7).join(USER_SESSION_FIELD_SEP);
|
|
4919
|
+
const num = (v) => {
|
|
4920
|
+
const n = Number(v);
|
|
4921
|
+
return Number.isFinite(n) ? n : void 0;
|
|
4922
|
+
};
|
|
4923
|
+
return {
|
|
4924
|
+
id,
|
|
4925
|
+
name,
|
|
4926
|
+
windows: Math.max(0, Math.floor(num(windows) ?? 0)),
|
|
4927
|
+
attached: (num(attached) ?? 0) > 0,
|
|
4928
|
+
activityAt: activity ? (num(activity) ?? 0) * 1e3 || void 0 : void 0,
|
|
4929
|
+
createdAt: created ? (num(created) ?? 0) * 1e3 || void 0 : void 0,
|
|
4930
|
+
command: command.trim() || void 0,
|
|
4931
|
+
cwd: cwd || void 0
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
const USER_SESSIONS_MAX = 50;
|
|
4935
|
+
function parseUserSessions(stdout, max = USER_SESSIONS_MAX) {
|
|
4936
|
+
const out = [];
|
|
4937
|
+
for (const line of stdout.split("\n")) {
|
|
4938
|
+
const s = parseUserSessionLine(line);
|
|
4939
|
+
if (!s || isVhSessionName(s.name)) continue;
|
|
4940
|
+
out.push(s);
|
|
4941
|
+
}
|
|
4942
|
+
out.sort((a, b) => (b.activityAt ?? 0) - (a.activityAt ?? 0));
|
|
4943
|
+
return out.slice(0, max);
|
|
4944
|
+
}
|
|
4945
|
+
function attachStartupCommand(sessionId, socket) {
|
|
4946
|
+
if (!TMUX_SESSION_ID_RE.test(sessionId)) throw new Error(`not a tmux session id: ${sessionId}`);
|
|
4947
|
+
const sock = socket ? ` -S ${shellescape(socket)}` : "";
|
|
4948
|
+
return ` TMUX= tmux${sock} attach-session -t ${shellescape(sessionId)}`;
|
|
4949
|
+
}
|
|
4950
|
+
|
|
4869
4951
|
const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
4870
4952
|
function planTerminalRestore(record, facts) {
|
|
4871
4953
|
if (facts.tmuxAlive) return { kind: "already-live" };
|
|
4954
|
+
if (record.attachTmux) {
|
|
4955
|
+
const matches = (facts.userSessions ?? []).filter((s) => s.name === record.attachTmux);
|
|
4956
|
+
if (matches.length !== 1) return { kind: "error", reason: "tmux-session-gone" };
|
|
4957
|
+
const cwd = record.cwd && facts.cwdExists(record.cwd) ? record.cwd : facts.homeDir;
|
|
4958
|
+
if (!cwd) return { kind: "error", reason: "missing-cwd" };
|
|
4959
|
+
return {
|
|
4960
|
+
kind: "create",
|
|
4961
|
+
terminalId: record.id,
|
|
4962
|
+
cwd,
|
|
4963
|
+
title: record.title ?? record.attachTmux,
|
|
4964
|
+
manual: true,
|
|
4965
|
+
tags: record.tags,
|
|
4966
|
+
command: attachStartupCommand(matches[0].id, facts.attachSocket),
|
|
4967
|
+
attachTmux: record.attachTmux
|
|
4968
|
+
};
|
|
4969
|
+
}
|
|
4872
4970
|
if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
|
|
4873
4971
|
const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
|
|
4874
4972
|
return {
|
|
@@ -4882,6 +4980,20 @@ function planTerminalRestore(record, facts) {
|
|
|
4882
4980
|
};
|
|
4883
4981
|
}
|
|
4884
4982
|
|
|
4983
|
+
const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
|
|
4984
|
+
function tmuxSocketFlags(env = process.env) {
|
|
4985
|
+
const socket = env[VH_TMUX_SOCKET_ENV];
|
|
4986
|
+
return socket ? ["-S", socket] : [];
|
|
4987
|
+
}
|
|
4988
|
+
function tmuxArgs(args, env = process.env) {
|
|
4989
|
+
return [...tmuxSocketFlags(env), ...args];
|
|
4990
|
+
}
|
|
4991
|
+
function scrubTmuxClientEnv(env) {
|
|
4992
|
+
delete env.TMUX;
|
|
4993
|
+
delete env.TMUX_PANE;
|
|
4994
|
+
return env;
|
|
4995
|
+
}
|
|
4996
|
+
|
|
4885
4997
|
function getProjectPath(workingDirectory) {
|
|
4886
4998
|
const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
|
|
4887
4999
|
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
@@ -9215,20 +9327,6 @@ function previewAscii(line) {
|
|
|
9215
9327
|
return line.length > 80 ? `${out}\u2026` : out;
|
|
9216
9328
|
}
|
|
9217
9329
|
|
|
9218
|
-
const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
|
|
9219
|
-
function tmuxSocketFlags(env = process.env) {
|
|
9220
|
-
const socket = env[VH_TMUX_SOCKET_ENV];
|
|
9221
|
-
return socket ? ["-S", socket] : [];
|
|
9222
|
-
}
|
|
9223
|
-
function tmuxArgs(args, env = process.env) {
|
|
9224
|
-
return [...tmuxSocketFlags(env), ...args];
|
|
9225
|
-
}
|
|
9226
|
-
function scrubTmuxClientEnv(env) {
|
|
9227
|
-
delete env.TMUX;
|
|
9228
|
-
delete env.TMUX_PANE;
|
|
9229
|
-
return env;
|
|
9230
|
-
}
|
|
9231
|
-
|
|
9232
9330
|
const CONTROL_CLIENT_KILL_GRACE_MS = 2e3;
|
|
9233
9331
|
const CONTROL_COMMAND_TIMEOUT_MS = 1e4;
|
|
9234
9332
|
function isSafeControlCommand(command) {
|
|
@@ -10046,7 +10144,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
|
|
|
10046
10144
|
const out = [];
|
|
10047
10145
|
for (const item of raw) {
|
|
10048
10146
|
if (!item || typeof item !== "object") continue;
|
|
10049
|
-
const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
|
|
10147
|
+
const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual, attachTmux } = item;
|
|
10050
10148
|
if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
|
|
10051
10149
|
if (seen.has(id)) continue;
|
|
10052
10150
|
seen.add(id);
|
|
@@ -10060,6 +10158,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
|
|
|
10060
10158
|
reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
|
|
10061
10159
|
...tagList !== void 0 ? { tags: tagList } : {},
|
|
10062
10160
|
...manual === true ? { manual: true } : {},
|
|
10161
|
+
...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
|
|
10063
10162
|
closedAt
|
|
10064
10163
|
});
|
|
10065
10164
|
}
|
|
@@ -10184,7 +10283,7 @@ function startupInjectionArgs(tmuxSession, command) {
|
|
|
10184
10283
|
];
|
|
10185
10284
|
}
|
|
10186
10285
|
function seenInfoOf(t) {
|
|
10187
|
-
return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
|
|
10286
|
+
return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual, attachTmux: t.attachTmux };
|
|
10188
10287
|
}
|
|
10189
10288
|
function terminalListSignature(items) {
|
|
10190
10289
|
const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
|
|
@@ -10197,7 +10296,8 @@ function terminalListSignature(items) {
|
|
|
10197
10296
|
t.agentState ?? "",
|
|
10198
10297
|
// B-105: a mirror binding appearing/disappearing MUST push the
|
|
10199
10298
|
// list, or the web never learns the toggle became available.
|
|
10200
|
-
t.mirrorSessionId ?? ""
|
|
10299
|
+
t.mirrorSessionId ?? "",
|
|
10300
|
+
t.attachTmux ?? ""
|
|
10201
10301
|
]);
|
|
10202
10302
|
return JSON.stringify(canon);
|
|
10203
10303
|
}
|
|
@@ -10216,7 +10316,7 @@ function deriveAutoTitle(paneTitle, hostname) {
|
|
|
10216
10316
|
const chars = Array.from(t);
|
|
10217
10317
|
return chars.length > TITLE_MAX_CHARS ? chars.slice(0, TITLE_MAX_CHARS).join("") : t;
|
|
10218
10318
|
}
|
|
10219
|
-
const LIST_FIELD_SEP = "
|
|
10319
|
+
const LIST_FIELD_SEP = "<~|~>";
|
|
10220
10320
|
const LIST_SESSIONS_FORMAT = [
|
|
10221
10321
|
"#{session_name}",
|
|
10222
10322
|
"#{session_created}",
|
|
@@ -10225,6 +10325,9 @@ const LIST_SESSIONS_FORMAT = [
|
|
|
10225
10325
|
"#{@vh_title}",
|
|
10226
10326
|
"#{@vh_title_manual}",
|
|
10227
10327
|
"#{@vh_tags}",
|
|
10328
|
+
// B-273: the user tmux session this terminal was opened to attach (name),
|
|
10329
|
+
// carried into close records so a restore can re-attach.
|
|
10330
|
+
"#{@vh_attach}",
|
|
10228
10331
|
// B-121: the control-mode client has no `pty.process`, so the agent-state
|
|
10229
10332
|
// fast path lost its live `#{pane_current_command}` equivalent. Carry it in
|
|
10230
10333
|
// the ONE list-sessions call the tracker already makes — the value goes
|
|
@@ -10239,8 +10342,8 @@ const LIST_SESSIONS_FORMAT = [
|
|
|
10239
10342
|
function parseSessionListLine(line) {
|
|
10240
10343
|
if (!line) return void 0;
|
|
10241
10344
|
const parts = line.split(LIST_FIELD_SEP);
|
|
10242
|
-
if (parts.length <
|
|
10243
|
-
const [name, created, activity, cwd, vhTitle, manual, vhTags, paneCommand] = parts;
|
|
10345
|
+
if (parts.length < 10) return void 0;
|
|
10346
|
+
const [name, created, activity, cwd, vhTitle, manual, vhTags, vhAttach, paneCommand] = parts;
|
|
10244
10347
|
if (!name) return void 0;
|
|
10245
10348
|
return {
|
|
10246
10349
|
name,
|
|
@@ -10251,9 +10354,12 @@ function parseSessionListLine(line) {
|
|
|
10251
10354
|
manual: manual.trim().length > 0,
|
|
10252
10355
|
tags: parseTerminalTags(vhTags),
|
|
10253
10356
|
paneCurrentCommand: paneCommand.trim() || void 0,
|
|
10254
|
-
//
|
|
10357
|
+
// Verbatim (no trim): tmux allows edge spaces in a session name and the
|
|
10358
|
+
// restore lookup / attach echo compare it exactly.
|
|
10359
|
+
attachTmux: isSafeTmuxSessionName(vhAttach) ? vhAttach : void 0,
|
|
10360
|
+
// pane_title is last, so anything after field 9 is title content that
|
|
10255
10361
|
// contained the separator — rejoin it rather than dropping it.
|
|
10256
|
-
paneTitle: parts.slice(
|
|
10362
|
+
paneTitle: parts.slice(9).join(LIST_FIELD_SEP) || void 0
|
|
10257
10363
|
};
|
|
10258
10364
|
}
|
|
10259
10365
|
const TERMINAL_TAG_MAX_COUNT = 64;
|
|
@@ -10310,12 +10416,12 @@ function isClaudeConfident(tail) {
|
|
|
10310
10416
|
const last15 = lines.slice(-15).join("\n");
|
|
10311
10417
|
return last15.includes("Do you want") || last15.includes("Would you like to proceed") || /^[\s│]*[❯>]\s*1\.\s/m.test(last15) || text.includes("esc to interrupt") || text.includes("? for shortcuts") || text.includes("bypass permissions on") || text.includes("\u23F5\u23F5");
|
|
10312
10418
|
}
|
|
10313
|
-
function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24) {
|
|
10419
|
+
function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24, nestedTmux = false) {
|
|
10314
10420
|
const count = Math.min(Math.abs(Math.trunc(lines)), 200);
|
|
10315
10421
|
if (count === 0) return { kind: "none" };
|
|
10316
10422
|
const up = lines > 0;
|
|
10317
10423
|
if (paneInMode) return { kind: "copy-scroll", dir: up ? "up" : "down", count };
|
|
10318
|
-
if (alternateOn && paneWantsMouse) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
|
|
10424
|
+
if (alternateOn && (paneWantsMouse || nestedTmux)) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
|
|
10319
10425
|
if (alternateOn && claudeLike) {
|
|
10320
10426
|
const halfPage = Math.max(1, Math.floor(paneRows / 2));
|
|
10321
10427
|
const pages = Math.max(1, Math.round(count / halfPage));
|
|
@@ -10430,6 +10536,9 @@ class TerminalSession {
|
|
|
10430
10536
|
id;
|
|
10431
10537
|
tmuxSession;
|
|
10432
10538
|
transport;
|
|
10539
|
+
/** B-273: the user tmux session this terminal was created to attach —
|
|
10540
|
+
* echoed to any later open of the same live session. */
|
|
10541
|
+
attachedTmux;
|
|
10433
10542
|
/**
|
|
10434
10543
|
* The pane this terminal follows, latched from the open capture's
|
|
10435
10544
|
* `list-panes` (first pane of the window). `%output` from any OTHER pane is
|
|
@@ -10940,6 +11049,7 @@ class WebTerminalManager {
|
|
|
10940
11049
|
cwd: info.cwd,
|
|
10941
11050
|
tags: info.tags,
|
|
10942
11051
|
manual: info.manual,
|
|
11052
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
10943
11053
|
// Live binding first (authoritative while it exists), persisted
|
|
10944
11054
|
// metadata as the fallback once the mirror has been torn down.
|
|
10945
11055
|
mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
|
|
@@ -10992,6 +11102,7 @@ class WebTerminalManager {
|
|
|
10992
11102
|
cwd: info.cwd,
|
|
10993
11103
|
tags: info.tags,
|
|
10994
11104
|
manual: info.manual,
|
|
11105
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
10995
11106
|
mirrorSessionId: mirror?.sessionId,
|
|
10996
11107
|
claudeSessionId: mirror?.claudeSessionId,
|
|
10997
11108
|
reason: "daemon-gap",
|
|
@@ -11111,12 +11222,17 @@ class WebTerminalManager {
|
|
|
11111
11222
|
return false;
|
|
11112
11223
|
}
|
|
11113
11224
|
},
|
|
11114
|
-
conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
|
|
11225
|
+
conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`)),
|
|
11226
|
+
// B-273: only consulted for records that were attach terminals.
|
|
11227
|
+
// Unbounded: the panel's 50-row cap must not hide a restore target.
|
|
11228
|
+
userSessions: record.attachTmux ? this.listUserTmuxSessions(Infinity) : [],
|
|
11229
|
+
attachSocket: process.env[VH_TMUX_SOCKET_ENV],
|
|
11230
|
+
homeDir: os.homedir()
|
|
11115
11231
|
});
|
|
11116
11232
|
if (plan.kind === "already-live") return { type: "success", terminalId };
|
|
11117
11233
|
if (plan.kind === "error") return { type: "error", reason: plan.reason };
|
|
11118
11234
|
if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
|
|
11119
|
-
logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
|
|
11235
|
+
logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.attachTmux ? ` (attached tmux session ${plan.attachTmux})` : plan.command ? " (conversation resumed)" : ""}`);
|
|
11120
11236
|
this.kickListRefresh();
|
|
11121
11237
|
return { type: "success", terminalId };
|
|
11122
11238
|
}
|
|
@@ -11176,6 +11292,13 @@ class WebTerminalManager {
|
|
|
11176
11292
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11177
11293
|
);
|
|
11178
11294
|
}
|
|
11295
|
+
if (plan.attachTmux) {
|
|
11296
|
+
node_child_process.spawnSync(
|
|
11297
|
+
"tmux",
|
|
11298
|
+
tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_attach", plan.attachTmux]),
|
|
11299
|
+
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11300
|
+
);
|
|
11301
|
+
}
|
|
11179
11302
|
if (plan.command) {
|
|
11180
11303
|
for (const argv of startupInjectionArgs(name, plan.command)) {
|
|
11181
11304
|
node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
@@ -11188,7 +11311,7 @@ class WebTerminalManager {
|
|
|
11188
11311
|
* would care about changed (see liveSnapshotChanged). */
|
|
11189
11312
|
persistLiveSnapshot(live, now) {
|
|
11190
11313
|
const candidate = /* @__PURE__ */ new Map();
|
|
11191
|
-
for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
|
|
11314
|
+
for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, ...info.attachTmux ? { attachTmux: info.attachTmux } : {}, seenAt: now });
|
|
11192
11315
|
if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
|
|
11193
11316
|
this.lastPersistedSnapshot = candidate;
|
|
11194
11317
|
saveLiveSnapshot(candidate);
|
|
@@ -11286,6 +11409,7 @@ class WebTerminalManager {
|
|
|
11286
11409
|
const lines = opts.streamMode === "lines";
|
|
11287
11410
|
const existing = this.terminals.get(id);
|
|
11288
11411
|
if (existing) {
|
|
11412
|
+
const echo2 = opts.attachTmux && !(opts.attachOnly || opts.resub) && existing.attachedTmux?.name === opts.attachTmux.name ? { attachedTmux: existing.attachedTmux } : {};
|
|
11289
11413
|
if (!opts.resub || existing.subscribers === 0) existing.subscribers += 1;
|
|
11290
11414
|
existing.lastTouch = Date.now();
|
|
11291
11415
|
this.applyResize(existing, cols, rows);
|
|
@@ -11298,15 +11422,16 @@ class WebTerminalManager {
|
|
|
11298
11422
|
seq: existing.seq,
|
|
11299
11423
|
mode: "replay",
|
|
11300
11424
|
chunks,
|
|
11301
|
-
...lines ? { streamMode: "lines" } : {}
|
|
11425
|
+
...lines ? { streamMode: "lines" } : {},
|
|
11426
|
+
...echo2
|
|
11302
11427
|
};
|
|
11303
11428
|
}
|
|
11304
11429
|
if (lines && existing.transport.kind === "control") {
|
|
11305
|
-
return await this.linesSnapshotResponse(id, existing);
|
|
11430
|
+
return { ...await this.linesSnapshotResponse(id, existing), ...echo2 };
|
|
11306
11431
|
}
|
|
11307
11432
|
const state = existing.subscribeState(opts.fromSeq);
|
|
11308
11433
|
logger.debug(`[WEB TERMINAL] re-subscribed ${id} (subs=${existing.subscribers}, mode=${state.mode}, seq=${state.seq})`);
|
|
11309
|
-
return { terminalId: id, tmuxSession: existing.tmuxSession, ...state };
|
|
11434
|
+
return { terminalId: id, tmuxSession: existing.tmuxSession, ...state, ...echo2 };
|
|
11310
11435
|
}
|
|
11311
11436
|
if (opts.attachOnly || opts.resub) {
|
|
11312
11437
|
const alive = isTmuxAvailable() && node_child_process.spawnSync(
|
|
@@ -11331,6 +11456,10 @@ class WebTerminalManager {
|
|
|
11331
11456
|
let file = defaultShell();
|
|
11332
11457
|
let args = [];
|
|
11333
11458
|
let tmuxSession;
|
|
11459
|
+
let attachedTmux;
|
|
11460
|
+
if (opts.attachTmux && !(opts.attachOnly || opts.resub) && !isTmuxAvailable()) {
|
|
11461
|
+
throw new Error("tmux-unavailable");
|
|
11462
|
+
}
|
|
11334
11463
|
if (isTmuxAvailable()) {
|
|
11335
11464
|
tmuxSession = `vh-${id}`;
|
|
11336
11465
|
let createdNew = false;
|
|
@@ -11343,6 +11472,7 @@ class WebTerminalManager {
|
|
|
11343
11472
|
`VH_HAPPY_HOME_DIR=${configuration.happyHomeDir}`
|
|
11344
11473
|
] : [];
|
|
11345
11474
|
const attachOnly = !!(opts.attachOnly || opts.resub);
|
|
11475
|
+
const attachTarget = !attachOnly && opts.attachTmux ? this.resolveAttachTarget(opts.attachTmux) : void 0;
|
|
11346
11476
|
if (!attachOnly) {
|
|
11347
11477
|
try {
|
|
11348
11478
|
const created = node_child_process.spawnSync(
|
|
@@ -11384,16 +11514,15 @@ class WebTerminalManager {
|
|
|
11384
11514
|
}
|
|
11385
11515
|
}
|
|
11386
11516
|
if (createdNew) {
|
|
11387
|
-
|
|
11388
|
-
|
|
11389
|
-
|
|
11390
|
-
|
|
11391
|
-
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
|
|
11395
|
-
|
|
11396
|
-
}
|
|
11517
|
+
attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
|
|
11518
|
+
} else if (opts.attachTmux && !attachOnly) {
|
|
11519
|
+
const stored = node_child_process.spawnSync(
|
|
11520
|
+
"tmux",
|
|
11521
|
+
tmuxArgs(["show-options", "-qv", "-t", `=${tmuxSession}:`, "@vh_attach"]),
|
|
11522
|
+
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11523
|
+
);
|
|
11524
|
+
const name = stored.status === 0 && typeof stored.stdout === "string" ? stored.stdout.replace(/\r?\n$/, "") : "";
|
|
11525
|
+
if (name && name === opts.attachTmux.name) attachedTmux = { id: opts.attachTmux.id, name };
|
|
11397
11526
|
}
|
|
11398
11527
|
if (!attachOnly && !createdNew) {
|
|
11399
11528
|
const alive = node_child_process.spawnSync(
|
|
@@ -11408,17 +11537,7 @@ class WebTerminalManager {
|
|
|
11408
11537
|
{ stdio: "ignore", timeout: TMUX_CREATE_TIMEOUT_MS, env }
|
|
11409
11538
|
);
|
|
11410
11539
|
createdNew = retry.status === 0;
|
|
11411
|
-
if (createdNew)
|
|
11412
|
-
const startup = normalizeStartupCommand(opts.startupCommand);
|
|
11413
|
-
if (startup) {
|
|
11414
|
-
for (const a of startupInjectionArgs(tmuxSession, startup)) {
|
|
11415
|
-
try {
|
|
11416
|
-
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11417
|
-
} catch {
|
|
11418
|
-
}
|
|
11419
|
-
}
|
|
11420
|
-
}
|
|
11421
|
-
}
|
|
11540
|
+
if (createdNew) attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
|
|
11422
11541
|
}
|
|
11423
11542
|
}
|
|
11424
11543
|
if (!attachOnly && !createdNew) {
|
|
@@ -11428,6 +11547,7 @@ class WebTerminalManager {
|
|
|
11428
11547
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11429
11548
|
).status === 0;
|
|
11430
11549
|
if (!alive) {
|
|
11550
|
+
if (attachTarget) throw new Error("tmux-unavailable");
|
|
11431
11551
|
logger.info(`[WEB TERMINAL] tmux session creation unavailable; using direct PTY for ${id}`);
|
|
11432
11552
|
tmuxSession = void 0;
|
|
11433
11553
|
}
|
|
@@ -11510,20 +11630,110 @@ class WebTerminalManager {
|
|
|
11510
11630
|
session = created;
|
|
11511
11631
|
}
|
|
11512
11632
|
session.subscribers = 1;
|
|
11633
|
+
if (attachedTmux) session.attachedTmux = attachedTmux;
|
|
11513
11634
|
this.terminals.set(id, session);
|
|
11514
11635
|
session.onTitleChange(() => this.kickListRefresh());
|
|
11515
11636
|
logger.debug(`[WEB TERMINAL] opened ${id} (${tmuxSession ? "control" : "pty"}) ${cols}x${rows} cwd=${cwd}`);
|
|
11516
11637
|
this.kickListRefresh();
|
|
11638
|
+
const echo = attachedTmux ? { attachedTmux } : {};
|
|
11517
11639
|
if (session.transport.kind !== "control") {
|
|
11518
11640
|
const state = session.subscribeState(void 0);
|
|
11519
|
-
return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {} };
|
|
11641
|
+
return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {}, ...echo };
|
|
11520
11642
|
}
|
|
11521
11643
|
const restored = await this.captureRestore(session, true);
|
|
11522
11644
|
if (!lines) {
|
|
11523
11645
|
const state = session.subscribeState(void 0);
|
|
11524
|
-
return { terminalId: id, tmuxSession, ...state };
|
|
11646
|
+
return { terminalId: id, tmuxSession, ...state, ...echo };
|
|
11647
|
+
}
|
|
11648
|
+
return { ...this.linesResponse(id, session, restored), ...echo };
|
|
11649
|
+
}
|
|
11650
|
+
/**
|
|
11651
|
+
* Post-create injection shared by BOTH creation sites in open(): an attach
|
|
11652
|
+
* request (B-273) pins the title to the user session's name (the inner
|
|
11653
|
+
* tmux's pane title is not reliable), stores `@vh_attach` for close
|
|
11654
|
+
* records / restore and types the attach line; otherwise the ordinary
|
|
11655
|
+
* startup command runs. Returns what to echo as `attachedTmux`.
|
|
11656
|
+
*/
|
|
11657
|
+
injectIntoCreated(tmuxSession, attachTarget, startupCommand, env) {
|
|
11658
|
+
if (attachTarget) {
|
|
11659
|
+
for (const a of [
|
|
11660
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_title", attachTarget.name],
|
|
11661
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_title_manual", "1"],
|
|
11662
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_attach", attachTarget.name],
|
|
11663
|
+
...startupInjectionArgs(tmuxSession, attachStartupCommand(attachTarget.id, process.env[VH_TMUX_SOCKET_ENV]))
|
|
11664
|
+
]) {
|
|
11665
|
+
try {
|
|
11666
|
+
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11667
|
+
} catch {
|
|
11668
|
+
}
|
|
11669
|
+
}
|
|
11670
|
+
logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
|
|
11671
|
+
return { id: attachTarget.id, name: attachTarget.name };
|
|
11672
|
+
}
|
|
11673
|
+
const startup = normalizeStartupCommand(startupCommand);
|
|
11674
|
+
if (startup) {
|
|
11675
|
+
try {
|
|
11676
|
+
for (const a of startupInjectionArgs(tmuxSession, startup)) {
|
|
11677
|
+
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11678
|
+
}
|
|
11679
|
+
logger.debug(`[WEB TERMINAL] injected startup command into new session ${tmuxSession}`);
|
|
11680
|
+
} catch {
|
|
11681
|
+
}
|
|
11682
|
+
}
|
|
11683
|
+
return void 0;
|
|
11684
|
+
}
|
|
11685
|
+
/**
|
|
11686
|
+
* B-273: check an attach request against the live tmux server. The id
|
|
11687
|
+
* must be a real `$N` (session ids only reset when the tmux server
|
|
11688
|
+
* restarts, so a stale id from an older list simply does not exist)
|
|
11689
|
+
* whose CURRENT name equals the name the web saw — the name cross-check
|
|
11690
|
+
* is what refuses a target that was killed and re-created in between —
|
|
11691
|
+
* and must not be a vh-* web terminal. Throws the 'tmux-session-gone'
|
|
11692
|
+
* contract string. Note `display-message -t '$N'` exits 0 with an EMPTY
|
|
11693
|
+
* line for an unknown id (its target lookup may fail silently), which is
|
|
11694
|
+
* why existence is probed with `has-session` first.
|
|
11695
|
+
*/
|
|
11696
|
+
resolveAttachTarget(req) {
|
|
11697
|
+
const id = typeof req?.id === "string" ? req.id : "";
|
|
11698
|
+
const name = typeof req?.name === "string" ? req.name : "";
|
|
11699
|
+
if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name) || isVhSessionName(name)) throw new Error("tmux-session-gone");
|
|
11700
|
+
const env = ptyEnv();
|
|
11701
|
+
const exists = node_child_process.spawnSync("tmux", tmuxArgs(["has-session", "-t", id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status === 0;
|
|
11702
|
+
if (!exists) throw new Error("tmux-session-gone");
|
|
11703
|
+
const r = node_child_process.spawnSync(
|
|
11704
|
+
"tmux",
|
|
11705
|
+
tmuxArgs(["display-message", "-p", "-t", id, "#{session_name}"]),
|
|
11706
|
+
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11707
|
+
);
|
|
11708
|
+
const current = r.status === 0 && typeof r.stdout === "string" ? r.stdout.replace(/\r?\n$/, "") : void 0;
|
|
11709
|
+
if (current !== name) throw new Error("tmux-session-gone");
|
|
11710
|
+
return { id, name };
|
|
11711
|
+
}
|
|
11712
|
+
/** B-273: the user's own tmux sessions (never vh-*), newest activity
|
|
11713
|
+
* first. Empty when tmux is missing or no server runs. */
|
|
11714
|
+
/** Last raw outcome of the list-tmux-sessions spawn (diagnostics only). */
|
|
11715
|
+
lastUserSessionsProbe;
|
|
11716
|
+
listUserTmuxSessions(max) {
|
|
11717
|
+
if (!isTmuxAvailable()) {
|
|
11718
|
+
this.lastUserSessionsProbe = { status: null, error: "tmux-unavailable", stdoutBytes: 0 };
|
|
11719
|
+
return [];
|
|
11720
|
+
}
|
|
11721
|
+
try {
|
|
11722
|
+
const r = node_child_process.spawnSync(
|
|
11723
|
+
"tmux",
|
|
11724
|
+
tmuxArgs(["list-sessions", "-F", USER_SESSIONS_FORMAT]),
|
|
11725
|
+
{ encoding: "utf8", timeout: TMUX_CREATE_TIMEOUT_MS, env: ptyEnv() }
|
|
11726
|
+
);
|
|
11727
|
+
this.lastUserSessionsProbe = { status: r.status, error: r.error?.message, stderr: (r.stderr || "").toString().trim() || void 0, stdoutBytes: typeof r.stdout === "string" ? r.stdout.length : 0 };
|
|
11728
|
+
if (r.status !== 0 || typeof r.stdout !== "string") {
|
|
11729
|
+
logger.debug(`[WEB TERMINAL] list-tmux-sessions failed: ${JSON.stringify(this.lastUserSessionsProbe)}`);
|
|
11730
|
+
return [];
|
|
11731
|
+
}
|
|
11732
|
+
return parseUserSessions(r.stdout, max);
|
|
11733
|
+
} catch (e) {
|
|
11734
|
+
this.lastUserSessionsProbe = { status: null, error: e instanceof Error ? e.message : String(e), stdoutBytes: 0 };
|
|
11735
|
+
return [];
|
|
11525
11736
|
}
|
|
11526
|
-
return this.linesResponse(id, session, restored);
|
|
11527
11737
|
}
|
|
11528
11738
|
/**
|
|
11529
11739
|
* Run the opening/catch-up capture for a control session and prime the
|
|
@@ -11748,7 +11958,7 @@ class WebTerminalManager {
|
|
|
11748
11958
|
/** Permanently destroy the terminal: detach the pty AND kill the tmux
|
|
11749
11959
|
* session (so a local `tmux attach` won't find it either). Used when the
|
|
11750
11960
|
* user deletes the terminal from the sidebar. */
|
|
11751
|
-
killSession(terminalId) {
|
|
11961
|
+
killSession(terminalId, opts) {
|
|
11752
11962
|
const info = this.lastSeenInfo.get(terminalId) ?? this.listSessions().find((t) => t.id === terminalId);
|
|
11753
11963
|
const target = `=vh-${terminalId}:`;
|
|
11754
11964
|
const killed = node_child_process.spawnSync("tmux", tmuxArgs(["kill-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
|
|
@@ -11757,6 +11967,15 @@ class WebTerminalManager {
|
|
|
11757
11967
|
logger.debug(`[WEB TERMINAL] failed to verify tmux session removal ${target}`);
|
|
11758
11968
|
return false;
|
|
11759
11969
|
}
|
|
11970
|
+
if (opts?.alsoAttached && info?.attachTmux) {
|
|
11971
|
+
const matches = this.listUserTmuxSessions(Infinity).filter((s) => s.name === info.attachTmux);
|
|
11972
|
+
if (matches.length === 1) {
|
|
11973
|
+
node_child_process.spawnSync("tmux", tmuxArgs(["kill-session", "-t", matches[0].id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() });
|
|
11974
|
+
logger.info(`[WEB TERMINAL] killed attached user tmux session ${matches[0].id} (${info.attachTmux}) with ${terminalId}`);
|
|
11975
|
+
} else {
|
|
11976
|
+
logger.debug(`[WEB TERMINAL] alsoAttached: no unique session named ${info.attachTmux} (${matches.length} matches)`);
|
|
11977
|
+
}
|
|
11978
|
+
}
|
|
11760
11979
|
if (info) {
|
|
11761
11980
|
const mirror = pickMirrorForTerminal(readPersistedSessions(), terminalId);
|
|
11762
11981
|
this.recordClosed({
|
|
@@ -11765,6 +11984,7 @@ class WebTerminalManager {
|
|
|
11765
11984
|
cwd: info.cwd,
|
|
11766
11985
|
tags: info.tags,
|
|
11767
11986
|
manual: info.manual,
|
|
11987
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
11768
11988
|
mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
|
|
11769
11989
|
claudeSessionId: mirror?.claudeSessionId,
|
|
11770
11990
|
reason: "closed",
|
|
@@ -11853,7 +12073,8 @@ class WebTerminalManager {
|
|
|
11853
12073
|
// Daemon-internal (not pushed to web / not in the signature):
|
|
11854
12074
|
// the mirror reconciler's stricter "claude is really here" gate.
|
|
11855
12075
|
claudeConfident: probe.claudeConfident,
|
|
11856
|
-
manual: s.manual
|
|
12076
|
+
manual: s.manual,
|
|
12077
|
+
...s.attachTmux ? { attachTmux: s.attachTmux } : {}
|
|
11857
12078
|
});
|
|
11858
12079
|
}
|
|
11859
12080
|
return out;
|
|
@@ -11920,18 +12141,19 @@ class WebTerminalManager {
|
|
|
11920
12141
|
try {
|
|
11921
12142
|
const probe = node_child_process.spawnSync(
|
|
11922
12143
|
"tmux",
|
|
11923
|
-
tmuxArgs(["display-message", "-p", "-t", name, "#{pane_in_mode}
|
|
12144
|
+
tmuxArgs(["display-message", "-p", "-t", name, ["#{pane_in_mode}", "#{alternate_on}", "#{mouse_any_flag}", "#{pane_width}", "#{pane_height}", "#{pane_current_command}"].join(LIST_FIELD_SEP)]),
|
|
11924
12145
|
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() }
|
|
11925
12146
|
);
|
|
11926
12147
|
if (probe.status !== 0 || typeof probe.stdout !== "string") return;
|
|
11927
|
-
const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(
|
|
12148
|
+
const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(LIST_FIELD_SEP);
|
|
11928
12149
|
const action = planScrollAction(
|
|
11929
12150
|
inMode === "1",
|
|
11930
12151
|
altOn === "1",
|
|
11931
12152
|
wantsMouse === "1",
|
|
11932
12153
|
lines,
|
|
11933
12154
|
looksLikeClaudeCommand(paneCmd || ""),
|
|
11934
|
-
Number(paneH) || 24
|
|
12155
|
+
Number(paneH) || 24,
|
|
12156
|
+
(paneCmd || "").trim() === "tmux"
|
|
11935
12157
|
);
|
|
11936
12158
|
if (action.kind === "none") return;
|
|
11937
12159
|
if (action.kind === "mouse-wheel") {
|
|
@@ -13943,7 +14165,8 @@ class ApiMachineClient {
|
|
|
13943
14165
|
this.syncResumeSessionRpcRegistration();
|
|
13944
14166
|
this.syncRestartSessionRpcRegistration();
|
|
13945
14167
|
this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
|
|
13946
|
-
const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
|
|
14168
|
+
const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode, attachTmux } = params || {};
|
|
14169
|
+
const attach = attachTmux && typeof attachTmux === "object" && typeof attachTmux.id === "string" && typeof attachTmux.name === "string" ? { id: attachTmux.id, name: attachTmux.name } : void 0;
|
|
13947
14170
|
const result = await this.webTerminal.open({
|
|
13948
14171
|
terminalId,
|
|
13949
14172
|
cols,
|
|
@@ -13953,7 +14176,8 @@ class ApiMachineClient {
|
|
|
13953
14176
|
startupCommand,
|
|
13954
14177
|
resub,
|
|
13955
14178
|
attachOnly,
|
|
13956
|
-
streamMode: streamMode === "lines" ? "lines" : void 0
|
|
14179
|
+
streamMode: streamMode === "lines" ? "lines" : void 0,
|
|
14180
|
+
...attach ? { attachTmux: attach } : {}
|
|
13957
14181
|
});
|
|
13958
14182
|
if (encStream) {
|
|
13959
14183
|
this.encTerminals.add(result.terminalId);
|
|
@@ -14007,11 +14231,11 @@ class ApiMachineClient {
|
|
|
14007
14231
|
return { type: "success" };
|
|
14008
14232
|
});
|
|
14009
14233
|
this.rpcHandlerManager.registerHandler("kill-terminal", async (params) => {
|
|
14010
|
-
const { terminalId } = params || {};
|
|
14234
|
+
const { terminalId, alsoAttached } = params || {};
|
|
14011
14235
|
if (typeof terminalId !== "string" || !terminalId) {
|
|
14012
14236
|
return { type: "error", errorMessage: "Terminal ID is required" };
|
|
14013
14237
|
}
|
|
14014
|
-
if (!this.webTerminal.killSession(terminalId)) {
|
|
14238
|
+
if (!this.webTerminal.killSession(terminalId, { alsoAttached: alsoAttached === true })) {
|
|
14015
14239
|
return { type: "error", errorMessage: "tmux session is still running" };
|
|
14016
14240
|
}
|
|
14017
14241
|
this.encTerminals.delete(terminalId);
|
|
@@ -14028,6 +14252,9 @@ class ApiMachineClient {
|
|
|
14028
14252
|
this.rpcHandlerManager.registerHandler("list-terminals", async () => {
|
|
14029
14253
|
return { type: "success", terminals: this.webTerminal.buildTerminalList() };
|
|
14030
14254
|
});
|
|
14255
|
+
this.rpcHandlerManager.registerHandler("list-tmux-sessions", async () => {
|
|
14256
|
+
return { type: "success", sessions: this.webTerminal.listUserTmuxSessions() };
|
|
14257
|
+
});
|
|
14031
14258
|
this.rpcHandlerManager.registerHandler("set-terminal-title", async (params) => {
|
|
14032
14259
|
const { terminalId, title, ifAbsent } = params || {};
|
|
14033
14260
|
if (!terminalId || typeof title !== "string") {
|
|
@@ -14421,6 +14648,8 @@ class ApiMachineClient {
|
|
|
14421
14648
|
// B-265 capability flag; restamped every connect so the
|
|
14422
14649
|
// web's `detectedAt >= startedAt` trust rule holds.
|
|
14423
14650
|
terminalRestore: { rpcAvailable: true, detectedAt: now },
|
|
14651
|
+
// B-273 capability flag (same restamp discipline).
|
|
14652
|
+
tmuxSessions: { rpcAvailable: true, detectedAt: now, killAttached: true },
|
|
14424
14653
|
// B-084: closed records survive daemon restarts (persisted
|
|
14425
14654
|
// in closed-terminals.json), so the connect snapshot ships
|
|
14426
14655
|
// them too — not just the incremental pushes.
|
|
@@ -15830,6 +16059,7 @@ exports.releaseDaemonLock = releaseDaemonLock;
|
|
|
15830
16059
|
exports.resolveCliUpdateCheckInterval = resolveCliUpdateCheckInterval;
|
|
15831
16060
|
exports.scrubTmuxClientEnv = scrubTmuxClientEnv;
|
|
15832
16061
|
exports.sendToVhTerminal = sendToVhTerminal;
|
|
16062
|
+
exports.shellescape = shellescape;
|
|
15833
16063
|
exports.startOfflineReconnection = startOfflineReconnection;
|
|
15834
16064
|
exports.summarizeSpawnSessionForLog = summarizeSpawnSessionForLog;
|
|
15835
16065
|
exports.tmuxArgs = tmuxArgs;
|