very-happy-cli 0.2.96 → 0.2.98
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-B5Eh8G3W.mjs → AcpBackend-BXiGiFC7.mjs} +1 -1
- package/dist/{AcpBackend-BaCt4_zS.cjs → AcpBackend-DhR8ml8L.cjs} +1 -1
- package/dist/{AcpSessionManager-grr_aJEI.mjs → AcpSessionManager-CDnrxAfQ.mjs} +1 -1
- package/dist/{AcpSessionManager-CvgmPlXB.cjs → AcpSessionManager-Ckc0NFmf.cjs} +1 -1
- package/dist/{config-B69TZlJj.mjs → config-B3pA9YsN.mjs} +3 -3
- package/dist/{config-BjsUeQPA.cjs → config-GJQc_kpD.cjs} +3 -3
- package/dist/{index-DID1FV_n.cjs → index-BBm2b34F.cjs} +5 -5
- package/dist/{index-C-uJXSm3.mjs → index-Bh5Rcy0s.mjs} +6 -6
- package/dist/{index-uc-dtu-3.cjs → index-CO1gNtuP.cjs} +528 -25
- package/dist/{index-BMmbxCTq.mjs → index-CuUVSZnK.mjs} +527 -24
- package/dist/index.cjs +3 -3
- package/dist/index.mjs +3 -3
- package/dist/{installTerminalHooks-CepXxAM_.cjs → installTerminalHooks-Cp9JVxQ-.cjs} +3 -3
- package/dist/{installTerminalHooks-CBHjEQ-a.mjs → installTerminalHooks-Ru0dn_zF.mjs} +3 -3
- package/dist/lib.cjs +1 -1
- package/dist/lib.d.cts +77 -0
- package/dist/lib.d.mts +77 -0
- package/dist/lib.mjs +1 -1
- package/dist/{mcp-CAtbtTXF.cjs → mcp-B5HMF24K.cjs} +3 -3
- package/dist/{mcp-BdSHqi2d.mjs → mcp-CQoVN0u2.mjs} +3 -3
- package/dist/{runGemini-qStPY5fH.cjs → runGemini-BBzPVzuc.cjs} +5 -5
- package/dist/{runGemini-cMZ3_CRf.mjs → runGemini-CwZXQsgu.mjs} +5 -5
- package/dist/{runOpenClaw-B8HR6GnX.cjs → runOpenClaw-B4nC6XGL.cjs} +4 -4
- package/dist/{runOpenClaw-BJ36wEbk.mjs → runOpenClaw-C-jz4rWr.mjs} +4 -4
- package/dist/{send-6eY55o3C.cjs → send-CWgaINHC.cjs} +3 -3
- package/dist/{send-TX13v8Xe.mjs → send-DN-nVBEI.mjs} +3 -3
- package/dist/{spawn-vOZA0kOi.cjs → spawn-C0NSP84Y.cjs} +3 -3
- package/dist/{spawn-CfCE6JAv.mjs → spawn-CqcfbOnk.mjs} +3 -3
- package/dist/{types-okYCRlgl.cjs → types-BP-4phS_.cjs} +358 -72
- package/dist/{types-CLRl0ET3.mjs → types-Cl4vWuDY.mjs} +356 -72
- package/package.json +7 -7
- package/scripts/shims/keychain-off/security +51 -0
|
@@ -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.98";
|
|
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.98",
|
|
168
|
+
"very-happy-tools-x64-darwin": "0.2.98",
|
|
169
|
+
"very-happy-tools-arm64-linux": "0.2.98",
|
|
170
|
+
"very-happy-tools-x64-linux": "0.2.98",
|
|
171
|
+
"very-happy-tools-arm64-win32": "0.2.98",
|
|
172
|
+
"very-happy-tools-x64-win32": "0.2.98"
|
|
173
173
|
};
|
|
174
174
|
var devDependencies = {
|
|
175
175
|
"@slopus/happy-wire": "workspace:*",
|
|
@@ -1002,6 +1002,22 @@ z.z.object({
|
|
|
1002
1002
|
detectedAt: z.z.number()
|
|
1003
1003
|
}).optional()
|
|
1004
1004
|
});
|
|
1005
|
+
const ClaudeAuthStateSchema = z.z.object({
|
|
1006
|
+
probeVersion: z.z.number(),
|
|
1007
|
+
daemonPid: z.z.number(),
|
|
1008
|
+
status: z.z.union([z.z.enum(["ok", "not-logged-in", "unknown", "error", "claude-missing"]), z.z.string()]),
|
|
1009
|
+
authMethod: z.z.string().optional(),
|
|
1010
|
+
subscriptionType: z.z.string().optional(),
|
|
1011
|
+
diagnosis: z.z.string().optional(),
|
|
1012
|
+
detail: z.z.string().optional(),
|
|
1013
|
+
repairable: z.z.string().optional(),
|
|
1014
|
+
context: z.z.object({
|
|
1015
|
+
platform: z.z.string(),
|
|
1016
|
+
lineage: z.z.string(),
|
|
1017
|
+
credentialStore: z.z.string()
|
|
1018
|
+
}),
|
|
1019
|
+
checkedAt: z.z.number()
|
|
1020
|
+
});
|
|
1005
1021
|
const CliUpdateStateSchema = z.z.object({
|
|
1006
1022
|
currentVersion: z.z.string(),
|
|
1007
1023
|
recommendedVersion: z.z.string().nullable(),
|
|
@@ -1025,7 +1041,9 @@ const WebTerminalListItemSchema = z.z.object({
|
|
|
1025
1041
|
* conversation carried over, the processes did not. Cleared once opened. */
|
|
1026
1042
|
restoredAt: z.z.number().optional(),
|
|
1027
1043
|
/** B-265: @vh_title_manual is set (user renamed it). */
|
|
1028
|
-
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()
|
|
1029
1047
|
});
|
|
1030
1048
|
const ClosedTerminalRecordSchema = z.z.object({
|
|
1031
1049
|
id: z.z.string(),
|
|
@@ -1042,6 +1060,8 @@ const ClosedTerminalRecordSchema = z.z.object({
|
|
|
1042
1060
|
/** B-265: tags + manual-rename flag at close time (restored verbatim). */
|
|
1043
1061
|
tags: z.z.array(z.z.string()).optional(),
|
|
1044
1062
|
manual: z.z.boolean().optional(),
|
|
1063
|
+
/** B-273: attached user tmux session name — restore re-attaches. */
|
|
1064
|
+
attachTmux: z.z.string().optional(),
|
|
1045
1065
|
closedAt: z.z.number()
|
|
1046
1066
|
});
|
|
1047
1067
|
z.z.object({
|
|
@@ -1095,8 +1115,26 @@ z.z.object({
|
|
|
1095
1115
|
rpcAvailable: z.z.boolean(),
|
|
1096
1116
|
detectedAt: z.z.number()
|
|
1097
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
|
+
}).optional(),
|
|
1098
1128
|
/** Relay-owned CLI compatibility/update policy last checked by this daemon. */
|
|
1099
|
-
cliUpdate: CliUpdateStateSchema.optional()
|
|
1129
|
+
cliUpdate: CliUpdateStateSchema.optional(),
|
|
1130
|
+
/**
|
|
1131
|
+
* B-276: result of the daemon-context Claude auth preflight (spec
|
|
1132
|
+
* 2026-09-claude-auth-preflight). Trust rule for consumers:
|
|
1133
|
+
* `claudeAuth.daemonPid === daemonState.pid` — daemonState survives daemon
|
|
1134
|
+
* restarts (the server returns the existing row and connect spreads it
|
|
1135
|
+
* forward), so a downgraded daemon carries this field along unchanged.
|
|
1136
|
+
*/
|
|
1137
|
+
claudeAuth: ClaudeAuthStateSchema.optional()
|
|
1100
1138
|
});
|
|
1101
1139
|
const MessageMetaSchema = z.z.object({
|
|
1102
1140
|
sentFrom: z.z.string().optional(),
|
|
@@ -1545,7 +1583,7 @@ class RpcHandlerManager {
|
|
|
1545
1583
|
}
|
|
1546
1584
|
}
|
|
1547
1585
|
|
|
1548
|
-
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-
|
|
1586
|
+
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-BP-4phS_.cjs', document.baseURI).href))));
|
|
1549
1587
|
function projectPath() {
|
|
1550
1588
|
const path = path$1.resolve(__dirname$1, "..");
|
|
1551
1589
|
return path;
|
|
@@ -4788,7 +4826,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
|
|
|
4788
4826
|
const rows = [];
|
|
4789
4827
|
for (const [id, value] of Object.entries(raw)) {
|
|
4790
4828
|
if (!id || !value || typeof value !== "object") continue;
|
|
4791
|
-
const { title, cwd, seenAt, tags, manual } = value;
|
|
4829
|
+
const { title, cwd, seenAt, tags, manual, attachTmux } = value;
|
|
4792
4830
|
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
|
|
4793
4831
|
if (now - seenAt >= ttlMs) continue;
|
|
4794
4832
|
const tagList = sanitizeTagList(tags);
|
|
@@ -4797,6 +4835,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
|
|
|
4797
4835
|
cwd: typeof cwd === "string" && cwd ? cwd : void 0,
|
|
4798
4836
|
...tagList !== void 0 ? { tags: tagList } : {},
|
|
4799
4837
|
...manual === true ? { manual: true } : {},
|
|
4838
|
+
...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
|
|
4800
4839
|
seenAt
|
|
4801
4840
|
}]);
|
|
4802
4841
|
}
|
|
@@ -4817,6 +4856,7 @@ function liveSnapshotChanged(prev, next) {
|
|
|
4817
4856
|
if (!before) return true;
|
|
4818
4857
|
if (before.title !== info.title || before.cwd !== info.cwd) return true;
|
|
4819
4858
|
if (!!before.manual !== !!info.manual) return true;
|
|
4859
|
+
if ((before.attachTmux ?? "") !== (info.attachTmux ?? "")) return true;
|
|
4820
4860
|
if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
|
|
4821
4861
|
}
|
|
4822
4862
|
return false;
|
|
@@ -4842,9 +4882,89 @@ function pickMirrorForTerminal(sessions, terminalId) {
|
|
|
4842
4882
|
return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
|
|
4843
4883
|
}
|
|
4844
4884
|
|
|
4885
|
+
function shellescape(s) {
|
|
4886
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
4887
|
+
}
|
|
4888
|
+
|
|
4889
|
+
const USER_SESSION_FIELD_SEP = "<~|~>";
|
|
4890
|
+
const USER_SESSIONS_FORMAT = [
|
|
4891
|
+
"#{session_id}",
|
|
4892
|
+
"#{session_name}",
|
|
4893
|
+
"#{session_windows}",
|
|
4894
|
+
"#{session_attached}",
|
|
4895
|
+
"#{session_activity}",
|
|
4896
|
+
"#{session_created}",
|
|
4897
|
+
"#{pane_current_command}",
|
|
4898
|
+
"#{pane_current_path}"
|
|
4899
|
+
].join(USER_SESSION_FIELD_SEP);
|
|
4900
|
+
const TMUX_SESSION_ID_RE = /^\$\d{1,9}$/;
|
|
4901
|
+
const TMUX_SESSION_NAME_MAX = 128;
|
|
4902
|
+
function isSafeTmuxSessionName(name) {
|
|
4903
|
+
if (typeof name !== "string") return false;
|
|
4904
|
+
if (name.length === 0 || name.length > TMUX_SESSION_NAME_MAX) return false;
|
|
4905
|
+
return !/[\x00-\x1f\x7f]/.test(name);
|
|
4906
|
+
}
|
|
4907
|
+
function isVhSessionName(name) {
|
|
4908
|
+
return name.startsWith("vh-");
|
|
4909
|
+
}
|
|
4910
|
+
function parseUserSessionLine(line) {
|
|
4911
|
+
if (!line) return void 0;
|
|
4912
|
+
const parts = line.split(USER_SESSION_FIELD_SEP);
|
|
4913
|
+
if (parts.length < 8) return void 0;
|
|
4914
|
+
const [id, name, windows, attached, activity, created, command] = parts;
|
|
4915
|
+
if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name)) return void 0;
|
|
4916
|
+
const cwd = parts.slice(7).join(USER_SESSION_FIELD_SEP);
|
|
4917
|
+
const num = (v) => {
|
|
4918
|
+
const n = Number(v);
|
|
4919
|
+
return Number.isFinite(n) ? n : void 0;
|
|
4920
|
+
};
|
|
4921
|
+
return {
|
|
4922
|
+
id,
|
|
4923
|
+
name,
|
|
4924
|
+
windows: Math.max(0, Math.floor(num(windows) ?? 0)),
|
|
4925
|
+
attached: (num(attached) ?? 0) > 0,
|
|
4926
|
+
activityAt: activity ? (num(activity) ?? 0) * 1e3 || void 0 : void 0,
|
|
4927
|
+
createdAt: created ? (num(created) ?? 0) * 1e3 || void 0 : void 0,
|
|
4928
|
+
command: command.trim() || void 0,
|
|
4929
|
+
cwd: cwd || void 0
|
|
4930
|
+
};
|
|
4931
|
+
}
|
|
4932
|
+
const USER_SESSIONS_MAX = 50;
|
|
4933
|
+
function parseUserSessions(stdout, max = USER_SESSIONS_MAX) {
|
|
4934
|
+
const out = [];
|
|
4935
|
+
for (const line of stdout.split("\n")) {
|
|
4936
|
+
const s = parseUserSessionLine(line);
|
|
4937
|
+
if (!s || isVhSessionName(s.name)) continue;
|
|
4938
|
+
out.push(s);
|
|
4939
|
+
}
|
|
4940
|
+
out.sort((a, b) => (b.activityAt ?? 0) - (a.activityAt ?? 0));
|
|
4941
|
+
return out.slice(0, max);
|
|
4942
|
+
}
|
|
4943
|
+
function attachStartupCommand(sessionId, socket) {
|
|
4944
|
+
if (!TMUX_SESSION_ID_RE.test(sessionId)) throw new Error(`not a tmux session id: ${sessionId}`);
|
|
4945
|
+
const sock = socket ? ` -S ${shellescape(socket)}` : "";
|
|
4946
|
+
return ` TMUX= tmux${sock} attach-session -t ${shellescape(sessionId)}`;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4845
4949
|
const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
4846
4950
|
function planTerminalRestore(record, facts) {
|
|
4847
4951
|
if (facts.tmuxAlive) return { kind: "already-live" };
|
|
4952
|
+
if (record.attachTmux) {
|
|
4953
|
+
const matches = (facts.userSessions ?? []).filter((s) => s.name === record.attachTmux);
|
|
4954
|
+
if (matches.length !== 1) return { kind: "error", reason: "tmux-session-gone" };
|
|
4955
|
+
const cwd = record.cwd && facts.cwdExists(record.cwd) ? record.cwd : facts.homeDir;
|
|
4956
|
+
if (!cwd) return { kind: "error", reason: "missing-cwd" };
|
|
4957
|
+
return {
|
|
4958
|
+
kind: "create",
|
|
4959
|
+
terminalId: record.id,
|
|
4960
|
+
cwd,
|
|
4961
|
+
title: record.title ?? record.attachTmux,
|
|
4962
|
+
manual: true,
|
|
4963
|
+
tags: record.tags,
|
|
4964
|
+
command: attachStartupCommand(matches[0].id, facts.attachSocket),
|
|
4965
|
+
attachTmux: record.attachTmux
|
|
4966
|
+
};
|
|
4967
|
+
}
|
|
4848
4968
|
if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
|
|
4849
4969
|
const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
|
|
4850
4970
|
return {
|
|
@@ -4858,6 +4978,20 @@ function planTerminalRestore(record, facts) {
|
|
|
4858
4978
|
};
|
|
4859
4979
|
}
|
|
4860
4980
|
|
|
4981
|
+
const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
|
|
4982
|
+
function tmuxSocketFlags(env = process.env) {
|
|
4983
|
+
const socket = env[VH_TMUX_SOCKET_ENV];
|
|
4984
|
+
return socket ? ["-S", socket] : [];
|
|
4985
|
+
}
|
|
4986
|
+
function tmuxArgs(args, env = process.env) {
|
|
4987
|
+
return [...tmuxSocketFlags(env), ...args];
|
|
4988
|
+
}
|
|
4989
|
+
function scrubTmuxClientEnv(env) {
|
|
4990
|
+
delete env.TMUX;
|
|
4991
|
+
delete env.TMUX_PANE;
|
|
4992
|
+
return env;
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4861
4995
|
function getProjectPath(workingDirectory) {
|
|
4862
4996
|
const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
|
|
4863
4997
|
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
@@ -9191,20 +9325,6 @@ function previewAscii(line) {
|
|
|
9191
9325
|
return line.length > 80 ? `${out}\u2026` : out;
|
|
9192
9326
|
}
|
|
9193
9327
|
|
|
9194
|
-
const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
|
|
9195
|
-
function tmuxSocketFlags(env = process.env) {
|
|
9196
|
-
const socket = env[VH_TMUX_SOCKET_ENV];
|
|
9197
|
-
return socket ? ["-S", socket] : [];
|
|
9198
|
-
}
|
|
9199
|
-
function tmuxArgs(args, env = process.env) {
|
|
9200
|
-
return [...tmuxSocketFlags(env), ...args];
|
|
9201
|
-
}
|
|
9202
|
-
function scrubTmuxClientEnv(env) {
|
|
9203
|
-
delete env.TMUX;
|
|
9204
|
-
delete env.TMUX_PANE;
|
|
9205
|
-
return env;
|
|
9206
|
-
}
|
|
9207
|
-
|
|
9208
9328
|
const CONTROL_CLIENT_KILL_GRACE_MS = 2e3;
|
|
9209
9329
|
const CONTROL_COMMAND_TIMEOUT_MS = 1e4;
|
|
9210
9330
|
function isSafeControlCommand(command) {
|
|
@@ -10022,7 +10142,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
|
|
|
10022
10142
|
const out = [];
|
|
10023
10143
|
for (const item of raw) {
|
|
10024
10144
|
if (!item || typeof item !== "object") continue;
|
|
10025
|
-
const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
|
|
10145
|
+
const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual, attachTmux } = item;
|
|
10026
10146
|
if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
|
|
10027
10147
|
if (seen.has(id)) continue;
|
|
10028
10148
|
seen.add(id);
|
|
@@ -10036,6 +10156,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
|
|
|
10036
10156
|
reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
|
|
10037
10157
|
...tagList !== void 0 ? { tags: tagList } : {},
|
|
10038
10158
|
...manual === true ? { manual: true } : {},
|
|
10159
|
+
...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
|
|
10039
10160
|
closedAt
|
|
10040
10161
|
});
|
|
10041
10162
|
}
|
|
@@ -10160,7 +10281,7 @@ function startupInjectionArgs(tmuxSession, command) {
|
|
|
10160
10281
|
];
|
|
10161
10282
|
}
|
|
10162
10283
|
function seenInfoOf(t) {
|
|
10163
|
-
return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
|
|
10284
|
+
return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual, attachTmux: t.attachTmux };
|
|
10164
10285
|
}
|
|
10165
10286
|
function terminalListSignature(items) {
|
|
10166
10287
|
const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
|
|
@@ -10173,7 +10294,8 @@ function terminalListSignature(items) {
|
|
|
10173
10294
|
t.agentState ?? "",
|
|
10174
10295
|
// B-105: a mirror binding appearing/disappearing MUST push the
|
|
10175
10296
|
// list, or the web never learns the toggle became available.
|
|
10176
|
-
t.mirrorSessionId ?? ""
|
|
10297
|
+
t.mirrorSessionId ?? "",
|
|
10298
|
+
t.attachTmux ?? ""
|
|
10177
10299
|
]);
|
|
10178
10300
|
return JSON.stringify(canon);
|
|
10179
10301
|
}
|
|
@@ -10192,7 +10314,7 @@ function deriveAutoTitle(paneTitle, hostname) {
|
|
|
10192
10314
|
const chars = Array.from(t);
|
|
10193
10315
|
return chars.length > TITLE_MAX_CHARS ? chars.slice(0, TITLE_MAX_CHARS).join("") : t;
|
|
10194
10316
|
}
|
|
10195
|
-
const LIST_FIELD_SEP = "
|
|
10317
|
+
const LIST_FIELD_SEP = "<~|~>";
|
|
10196
10318
|
const LIST_SESSIONS_FORMAT = [
|
|
10197
10319
|
"#{session_name}",
|
|
10198
10320
|
"#{session_created}",
|
|
@@ -10201,6 +10323,9 @@ const LIST_SESSIONS_FORMAT = [
|
|
|
10201
10323
|
"#{@vh_title}",
|
|
10202
10324
|
"#{@vh_title_manual}",
|
|
10203
10325
|
"#{@vh_tags}",
|
|
10326
|
+
// B-273: the user tmux session this terminal was opened to attach (name),
|
|
10327
|
+
// carried into close records so a restore can re-attach.
|
|
10328
|
+
"#{@vh_attach}",
|
|
10204
10329
|
// B-121: the control-mode client has no `pty.process`, so the agent-state
|
|
10205
10330
|
// fast path lost its live `#{pane_current_command}` equivalent. Carry it in
|
|
10206
10331
|
// the ONE list-sessions call the tracker already makes — the value goes
|
|
@@ -10215,8 +10340,8 @@ const LIST_SESSIONS_FORMAT = [
|
|
|
10215
10340
|
function parseSessionListLine(line) {
|
|
10216
10341
|
if (!line) return void 0;
|
|
10217
10342
|
const parts = line.split(LIST_FIELD_SEP);
|
|
10218
|
-
if (parts.length <
|
|
10219
|
-
const [name, created, activity, cwd, vhTitle, manual, vhTags, paneCommand] = parts;
|
|
10343
|
+
if (parts.length < 10) return void 0;
|
|
10344
|
+
const [name, created, activity, cwd, vhTitle, manual, vhTags, vhAttach, paneCommand] = parts;
|
|
10220
10345
|
if (!name) return void 0;
|
|
10221
10346
|
return {
|
|
10222
10347
|
name,
|
|
@@ -10227,9 +10352,12 @@ function parseSessionListLine(line) {
|
|
|
10227
10352
|
manual: manual.trim().length > 0,
|
|
10228
10353
|
tags: parseTerminalTags(vhTags),
|
|
10229
10354
|
paneCurrentCommand: paneCommand.trim() || void 0,
|
|
10230
|
-
//
|
|
10355
|
+
// Verbatim (no trim): tmux allows edge spaces in a session name and the
|
|
10356
|
+
// restore lookup / attach echo compare it exactly.
|
|
10357
|
+
attachTmux: isSafeTmuxSessionName(vhAttach) ? vhAttach : void 0,
|
|
10358
|
+
// pane_title is last, so anything after field 9 is title content that
|
|
10231
10359
|
// contained the separator — rejoin it rather than dropping it.
|
|
10232
|
-
paneTitle: parts.slice(
|
|
10360
|
+
paneTitle: parts.slice(9).join(LIST_FIELD_SEP) || void 0
|
|
10233
10361
|
};
|
|
10234
10362
|
}
|
|
10235
10363
|
const TERMINAL_TAG_MAX_COUNT = 64;
|
|
@@ -10286,12 +10414,12 @@ function isClaudeConfident(tail) {
|
|
|
10286
10414
|
const last15 = lines.slice(-15).join("\n");
|
|
10287
10415
|
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");
|
|
10288
10416
|
}
|
|
10289
|
-
function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24) {
|
|
10417
|
+
function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24, nestedTmux = false) {
|
|
10290
10418
|
const count = Math.min(Math.abs(Math.trunc(lines)), 200);
|
|
10291
10419
|
if (count === 0) return { kind: "none" };
|
|
10292
10420
|
const up = lines > 0;
|
|
10293
10421
|
if (paneInMode) return { kind: "copy-scroll", dir: up ? "up" : "down", count };
|
|
10294
|
-
if (alternateOn && paneWantsMouse) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
|
|
10422
|
+
if (alternateOn && (paneWantsMouse || nestedTmux)) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
|
|
10295
10423
|
if (alternateOn && claudeLike) {
|
|
10296
10424
|
const halfPage = Math.max(1, Math.floor(paneRows / 2));
|
|
10297
10425
|
const pages = Math.max(1, Math.round(count / halfPage));
|
|
@@ -10406,6 +10534,9 @@ class TerminalSession {
|
|
|
10406
10534
|
id;
|
|
10407
10535
|
tmuxSession;
|
|
10408
10536
|
transport;
|
|
10537
|
+
/** B-273: the user tmux session this terminal was created to attach —
|
|
10538
|
+
* echoed to any later open of the same live session. */
|
|
10539
|
+
attachedTmux;
|
|
10409
10540
|
/**
|
|
10410
10541
|
* The pane this terminal follows, latched from the open capture's
|
|
10411
10542
|
* `list-panes` (first pane of the window). `%output` from any OTHER pane is
|
|
@@ -10916,6 +11047,7 @@ class WebTerminalManager {
|
|
|
10916
11047
|
cwd: info.cwd,
|
|
10917
11048
|
tags: info.tags,
|
|
10918
11049
|
manual: info.manual,
|
|
11050
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
10919
11051
|
// Live binding first (authoritative while it exists), persisted
|
|
10920
11052
|
// metadata as the fallback once the mirror has been torn down.
|
|
10921
11053
|
mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
|
|
@@ -10968,6 +11100,7 @@ class WebTerminalManager {
|
|
|
10968
11100
|
cwd: info.cwd,
|
|
10969
11101
|
tags: info.tags,
|
|
10970
11102
|
manual: info.manual,
|
|
11103
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
10971
11104
|
mirrorSessionId: mirror?.sessionId,
|
|
10972
11105
|
claudeSessionId: mirror?.claudeSessionId,
|
|
10973
11106
|
reason: "daemon-gap",
|
|
@@ -11087,12 +11220,17 @@ class WebTerminalManager {
|
|
|
11087
11220
|
return false;
|
|
11088
11221
|
}
|
|
11089
11222
|
},
|
|
11090
|
-
conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
|
|
11223
|
+
conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`)),
|
|
11224
|
+
// B-273: only consulted for records that were attach terminals.
|
|
11225
|
+
// Unbounded: the panel's 50-row cap must not hide a restore target.
|
|
11226
|
+
userSessions: record.attachTmux ? this.listUserTmuxSessions(Infinity) : [],
|
|
11227
|
+
attachSocket: process.env[VH_TMUX_SOCKET_ENV],
|
|
11228
|
+
homeDir: os.homedir()
|
|
11091
11229
|
});
|
|
11092
11230
|
if (plan.kind === "already-live") return { type: "success", terminalId };
|
|
11093
11231
|
if (plan.kind === "error") return { type: "error", reason: plan.reason };
|
|
11094
11232
|
if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
|
|
11095
|
-
logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
|
|
11233
|
+
logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.attachTmux ? ` (attached tmux session ${plan.attachTmux})` : plan.command ? " (conversation resumed)" : ""}`);
|
|
11096
11234
|
this.kickListRefresh();
|
|
11097
11235
|
return { type: "success", terminalId };
|
|
11098
11236
|
}
|
|
@@ -11152,6 +11290,13 @@ class WebTerminalManager {
|
|
|
11152
11290
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11153
11291
|
);
|
|
11154
11292
|
}
|
|
11293
|
+
if (plan.attachTmux) {
|
|
11294
|
+
node_child_process.spawnSync(
|
|
11295
|
+
"tmux",
|
|
11296
|
+
tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_attach", plan.attachTmux]),
|
|
11297
|
+
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11298
|
+
);
|
|
11299
|
+
}
|
|
11155
11300
|
if (plan.command) {
|
|
11156
11301
|
for (const argv of startupInjectionArgs(name, plan.command)) {
|
|
11157
11302
|
node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
@@ -11164,7 +11309,7 @@ class WebTerminalManager {
|
|
|
11164
11309
|
* would care about changed (see liveSnapshotChanged). */
|
|
11165
11310
|
persistLiveSnapshot(live, now) {
|
|
11166
11311
|
const candidate = /* @__PURE__ */ new Map();
|
|
11167
|
-
for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
|
|
11312
|
+
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 });
|
|
11168
11313
|
if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
|
|
11169
11314
|
this.lastPersistedSnapshot = candidate;
|
|
11170
11315
|
saveLiveSnapshot(candidate);
|
|
@@ -11262,6 +11407,7 @@ class WebTerminalManager {
|
|
|
11262
11407
|
const lines = opts.streamMode === "lines";
|
|
11263
11408
|
const existing = this.terminals.get(id);
|
|
11264
11409
|
if (existing) {
|
|
11410
|
+
const echo2 = opts.attachTmux && !(opts.attachOnly || opts.resub) && existing.attachedTmux?.name === opts.attachTmux.name ? { attachedTmux: existing.attachedTmux } : {};
|
|
11265
11411
|
if (!opts.resub || existing.subscribers === 0) existing.subscribers += 1;
|
|
11266
11412
|
existing.lastTouch = Date.now();
|
|
11267
11413
|
this.applyResize(existing, cols, rows);
|
|
@@ -11274,15 +11420,16 @@ class WebTerminalManager {
|
|
|
11274
11420
|
seq: existing.seq,
|
|
11275
11421
|
mode: "replay",
|
|
11276
11422
|
chunks,
|
|
11277
|
-
...lines ? { streamMode: "lines" } : {}
|
|
11423
|
+
...lines ? { streamMode: "lines" } : {},
|
|
11424
|
+
...echo2
|
|
11278
11425
|
};
|
|
11279
11426
|
}
|
|
11280
11427
|
if (lines && existing.transport.kind === "control") {
|
|
11281
|
-
return await this.linesSnapshotResponse(id, existing);
|
|
11428
|
+
return { ...await this.linesSnapshotResponse(id, existing), ...echo2 };
|
|
11282
11429
|
}
|
|
11283
11430
|
const state = existing.subscribeState(opts.fromSeq);
|
|
11284
11431
|
logger.debug(`[WEB TERMINAL] re-subscribed ${id} (subs=${existing.subscribers}, mode=${state.mode}, seq=${state.seq})`);
|
|
11285
|
-
return { terminalId: id, tmuxSession: existing.tmuxSession, ...state };
|
|
11432
|
+
return { terminalId: id, tmuxSession: existing.tmuxSession, ...state, ...echo2 };
|
|
11286
11433
|
}
|
|
11287
11434
|
if (opts.attachOnly || opts.resub) {
|
|
11288
11435
|
const alive = isTmuxAvailable() && node_child_process.spawnSync(
|
|
@@ -11307,6 +11454,10 @@ class WebTerminalManager {
|
|
|
11307
11454
|
let file = defaultShell();
|
|
11308
11455
|
let args = [];
|
|
11309
11456
|
let tmuxSession;
|
|
11457
|
+
let attachedTmux;
|
|
11458
|
+
if (opts.attachTmux && !(opts.attachOnly || opts.resub) && !isTmuxAvailable()) {
|
|
11459
|
+
throw new Error("tmux-unavailable");
|
|
11460
|
+
}
|
|
11310
11461
|
if (isTmuxAvailable()) {
|
|
11311
11462
|
tmuxSession = `vh-${id}`;
|
|
11312
11463
|
let createdNew = false;
|
|
@@ -11319,6 +11470,7 @@ class WebTerminalManager {
|
|
|
11319
11470
|
`VH_HAPPY_HOME_DIR=${configuration.happyHomeDir}`
|
|
11320
11471
|
] : [];
|
|
11321
11472
|
const attachOnly = !!(opts.attachOnly || opts.resub);
|
|
11473
|
+
const attachTarget = !attachOnly && opts.attachTmux ? this.resolveAttachTarget(opts.attachTmux) : void 0;
|
|
11322
11474
|
if (!attachOnly) {
|
|
11323
11475
|
try {
|
|
11324
11476
|
const created = node_child_process.spawnSync(
|
|
@@ -11360,16 +11512,15 @@ class WebTerminalManager {
|
|
|
11360
11512
|
}
|
|
11361
11513
|
}
|
|
11362
11514
|
if (createdNew) {
|
|
11363
|
-
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
|
|
11367
|
-
|
|
11368
|
-
|
|
11369
|
-
|
|
11370
|
-
|
|
11371
|
-
|
|
11372
|
-
}
|
|
11515
|
+
attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
|
|
11516
|
+
} else if (opts.attachTmux && !attachOnly) {
|
|
11517
|
+
const stored = node_child_process.spawnSync(
|
|
11518
|
+
"tmux",
|
|
11519
|
+
tmuxArgs(["show-options", "-qv", "-t", `=${tmuxSession}:`, "@vh_attach"]),
|
|
11520
|
+
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11521
|
+
);
|
|
11522
|
+
const name = stored.status === 0 && typeof stored.stdout === "string" ? stored.stdout.replace(/\r?\n$/, "") : "";
|
|
11523
|
+
if (name && name === opts.attachTmux.name) attachedTmux = { id: opts.attachTmux.id, name };
|
|
11373
11524
|
}
|
|
11374
11525
|
if (!attachOnly && !createdNew) {
|
|
11375
11526
|
const alive = node_child_process.spawnSync(
|
|
@@ -11384,17 +11535,7 @@ class WebTerminalManager {
|
|
|
11384
11535
|
{ stdio: "ignore", timeout: TMUX_CREATE_TIMEOUT_MS, env }
|
|
11385
11536
|
);
|
|
11386
11537
|
createdNew = retry.status === 0;
|
|
11387
|
-
if (createdNew)
|
|
11388
|
-
const startup = normalizeStartupCommand(opts.startupCommand);
|
|
11389
|
-
if (startup) {
|
|
11390
|
-
for (const a of startupInjectionArgs(tmuxSession, startup)) {
|
|
11391
|
-
try {
|
|
11392
|
-
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11393
|
-
} catch {
|
|
11394
|
-
}
|
|
11395
|
-
}
|
|
11396
|
-
}
|
|
11397
|
-
}
|
|
11538
|
+
if (createdNew) attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
|
|
11398
11539
|
}
|
|
11399
11540
|
}
|
|
11400
11541
|
if (!attachOnly && !createdNew) {
|
|
@@ -11404,6 +11545,7 @@ class WebTerminalManager {
|
|
|
11404
11545
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11405
11546
|
).status === 0;
|
|
11406
11547
|
if (!alive) {
|
|
11548
|
+
if (attachTarget) throw new Error("tmux-unavailable");
|
|
11407
11549
|
logger.info(`[WEB TERMINAL] tmux session creation unavailable; using direct PTY for ${id}`);
|
|
11408
11550
|
tmuxSession = void 0;
|
|
11409
11551
|
}
|
|
@@ -11486,20 +11628,110 @@ class WebTerminalManager {
|
|
|
11486
11628
|
session = created;
|
|
11487
11629
|
}
|
|
11488
11630
|
session.subscribers = 1;
|
|
11631
|
+
if (attachedTmux) session.attachedTmux = attachedTmux;
|
|
11489
11632
|
this.terminals.set(id, session);
|
|
11490
11633
|
session.onTitleChange(() => this.kickListRefresh());
|
|
11491
11634
|
logger.debug(`[WEB TERMINAL] opened ${id} (${tmuxSession ? "control" : "pty"}) ${cols}x${rows} cwd=${cwd}`);
|
|
11492
11635
|
this.kickListRefresh();
|
|
11636
|
+
const echo = attachedTmux ? { attachedTmux } : {};
|
|
11493
11637
|
if (session.transport.kind !== "control") {
|
|
11494
11638
|
const state = session.subscribeState(void 0);
|
|
11495
|
-
return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {} };
|
|
11639
|
+
return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {}, ...echo };
|
|
11496
11640
|
}
|
|
11497
11641
|
const restored = await this.captureRestore(session, true);
|
|
11498
11642
|
if (!lines) {
|
|
11499
11643
|
const state = session.subscribeState(void 0);
|
|
11500
|
-
return { terminalId: id, tmuxSession, ...state };
|
|
11644
|
+
return { terminalId: id, tmuxSession, ...state, ...echo };
|
|
11645
|
+
}
|
|
11646
|
+
return { ...this.linesResponse(id, session, restored), ...echo };
|
|
11647
|
+
}
|
|
11648
|
+
/**
|
|
11649
|
+
* Post-create injection shared by BOTH creation sites in open(): an attach
|
|
11650
|
+
* request (B-273) pins the title to the user session's name (the inner
|
|
11651
|
+
* tmux's pane title is not reliable), stores `@vh_attach` for close
|
|
11652
|
+
* records / restore and types the attach line; otherwise the ordinary
|
|
11653
|
+
* startup command runs. Returns what to echo as `attachedTmux`.
|
|
11654
|
+
*/
|
|
11655
|
+
injectIntoCreated(tmuxSession, attachTarget, startupCommand, env) {
|
|
11656
|
+
if (attachTarget) {
|
|
11657
|
+
for (const a of [
|
|
11658
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_title", attachTarget.name],
|
|
11659
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_title_manual", "1"],
|
|
11660
|
+
["set-option", "-t", `=${tmuxSession}:`, "@vh_attach", attachTarget.name],
|
|
11661
|
+
...startupInjectionArgs(tmuxSession, attachStartupCommand(attachTarget.id, process.env[VH_TMUX_SOCKET_ENV]))
|
|
11662
|
+
]) {
|
|
11663
|
+
try {
|
|
11664
|
+
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11665
|
+
} catch {
|
|
11666
|
+
}
|
|
11667
|
+
}
|
|
11668
|
+
logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
|
|
11669
|
+
return { id: attachTarget.id, name: attachTarget.name };
|
|
11670
|
+
}
|
|
11671
|
+
const startup = normalizeStartupCommand(startupCommand);
|
|
11672
|
+
if (startup) {
|
|
11673
|
+
try {
|
|
11674
|
+
for (const a of startupInjectionArgs(tmuxSession, startup)) {
|
|
11675
|
+
node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
11676
|
+
}
|
|
11677
|
+
logger.debug(`[WEB TERMINAL] injected startup command into new session ${tmuxSession}`);
|
|
11678
|
+
} catch {
|
|
11679
|
+
}
|
|
11680
|
+
}
|
|
11681
|
+
return void 0;
|
|
11682
|
+
}
|
|
11683
|
+
/**
|
|
11684
|
+
* B-273: check an attach request against the live tmux server. The id
|
|
11685
|
+
* must be a real `$N` (session ids only reset when the tmux server
|
|
11686
|
+
* restarts, so a stale id from an older list simply does not exist)
|
|
11687
|
+
* whose CURRENT name equals the name the web saw — the name cross-check
|
|
11688
|
+
* is what refuses a target that was killed and re-created in between —
|
|
11689
|
+
* and must not be a vh-* web terminal. Throws the 'tmux-session-gone'
|
|
11690
|
+
* contract string. Note `display-message -t '$N'` exits 0 with an EMPTY
|
|
11691
|
+
* line for an unknown id (its target lookup may fail silently), which is
|
|
11692
|
+
* why existence is probed with `has-session` first.
|
|
11693
|
+
*/
|
|
11694
|
+
resolveAttachTarget(req) {
|
|
11695
|
+
const id = typeof req?.id === "string" ? req.id : "";
|
|
11696
|
+
const name = typeof req?.name === "string" ? req.name : "";
|
|
11697
|
+
if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name) || isVhSessionName(name)) throw new Error("tmux-session-gone");
|
|
11698
|
+
const env = ptyEnv();
|
|
11699
|
+
const exists = node_child_process.spawnSync("tmux", tmuxArgs(["has-session", "-t", id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status === 0;
|
|
11700
|
+
if (!exists) throw new Error("tmux-session-gone");
|
|
11701
|
+
const r = node_child_process.spawnSync(
|
|
11702
|
+
"tmux",
|
|
11703
|
+
tmuxArgs(["display-message", "-p", "-t", id, "#{session_name}"]),
|
|
11704
|
+
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
11705
|
+
);
|
|
11706
|
+
const current = r.status === 0 && typeof r.stdout === "string" ? r.stdout.replace(/\r?\n$/, "") : void 0;
|
|
11707
|
+
if (current !== name) throw new Error("tmux-session-gone");
|
|
11708
|
+
return { id, name };
|
|
11709
|
+
}
|
|
11710
|
+
/** B-273: the user's own tmux sessions (never vh-*), newest activity
|
|
11711
|
+
* first. Empty when tmux is missing or no server runs. */
|
|
11712
|
+
/** Last raw outcome of the list-tmux-sessions spawn (diagnostics only). */
|
|
11713
|
+
lastUserSessionsProbe;
|
|
11714
|
+
listUserTmuxSessions(max) {
|
|
11715
|
+
if (!isTmuxAvailable()) {
|
|
11716
|
+
this.lastUserSessionsProbe = { status: null, error: "tmux-unavailable", stdoutBytes: 0 };
|
|
11717
|
+
return [];
|
|
11718
|
+
}
|
|
11719
|
+
try {
|
|
11720
|
+
const r = node_child_process.spawnSync(
|
|
11721
|
+
"tmux",
|
|
11722
|
+
tmuxArgs(["list-sessions", "-F", USER_SESSIONS_FORMAT]),
|
|
11723
|
+
{ encoding: "utf8", timeout: TMUX_CREATE_TIMEOUT_MS, env: ptyEnv() }
|
|
11724
|
+
);
|
|
11725
|
+
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 };
|
|
11726
|
+
if (r.status !== 0 || typeof r.stdout !== "string") {
|
|
11727
|
+
logger.debug(`[WEB TERMINAL] list-tmux-sessions failed: ${JSON.stringify(this.lastUserSessionsProbe)}`);
|
|
11728
|
+
return [];
|
|
11729
|
+
}
|
|
11730
|
+
return parseUserSessions(r.stdout, max);
|
|
11731
|
+
} catch (e) {
|
|
11732
|
+
this.lastUserSessionsProbe = { status: null, error: e instanceof Error ? e.message : String(e), stdoutBytes: 0 };
|
|
11733
|
+
return [];
|
|
11501
11734
|
}
|
|
11502
|
-
return this.linesResponse(id, session, restored);
|
|
11503
11735
|
}
|
|
11504
11736
|
/**
|
|
11505
11737
|
* Run the opening/catch-up capture for a control session and prime the
|
|
@@ -11741,6 +11973,7 @@ class WebTerminalManager {
|
|
|
11741
11973
|
cwd: info.cwd,
|
|
11742
11974
|
tags: info.tags,
|
|
11743
11975
|
manual: info.manual,
|
|
11976
|
+
...info.attachTmux ? { attachTmux: info.attachTmux } : {},
|
|
11744
11977
|
mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
|
|
11745
11978
|
claudeSessionId: mirror?.claudeSessionId,
|
|
11746
11979
|
reason: "closed",
|
|
@@ -11829,7 +12062,8 @@ class WebTerminalManager {
|
|
|
11829
12062
|
// Daemon-internal (not pushed to web / not in the signature):
|
|
11830
12063
|
// the mirror reconciler's stricter "claude is really here" gate.
|
|
11831
12064
|
claudeConfident: probe.claudeConfident,
|
|
11832
|
-
manual: s.manual
|
|
12065
|
+
manual: s.manual,
|
|
12066
|
+
...s.attachTmux ? { attachTmux: s.attachTmux } : {}
|
|
11833
12067
|
});
|
|
11834
12068
|
}
|
|
11835
12069
|
return out;
|
|
@@ -11896,18 +12130,19 @@ class WebTerminalManager {
|
|
|
11896
12130
|
try {
|
|
11897
12131
|
const probe = node_child_process.spawnSync(
|
|
11898
12132
|
"tmux",
|
|
11899
|
-
tmuxArgs(["display-message", "-p", "-t", name, "#{pane_in_mode}
|
|
12133
|
+
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)]),
|
|
11900
12134
|
{ encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() }
|
|
11901
12135
|
);
|
|
11902
12136
|
if (probe.status !== 0 || typeof probe.stdout !== "string") return;
|
|
11903
|
-
const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(
|
|
12137
|
+
const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(LIST_FIELD_SEP);
|
|
11904
12138
|
const action = planScrollAction(
|
|
11905
12139
|
inMode === "1",
|
|
11906
12140
|
altOn === "1",
|
|
11907
12141
|
wantsMouse === "1",
|
|
11908
12142
|
lines,
|
|
11909
12143
|
looksLikeClaudeCommand(paneCmd || ""),
|
|
11910
|
-
Number(paneH) || 24
|
|
12144
|
+
Number(paneH) || 24,
|
|
12145
|
+
(paneCmd || "").trim() === "tmux"
|
|
11911
12146
|
);
|
|
11912
12147
|
if (action.kind === "none") return;
|
|
11913
12148
|
if (action.kind === "mouse-wheel") {
|
|
@@ -13781,6 +14016,7 @@ class ApiMachineClient {
|
|
|
13781
14016
|
lastKnownResumeSupport = null;
|
|
13782
14017
|
cliUpdateState = null;
|
|
13783
14018
|
cliUpdatePushChain = Promise.resolve();
|
|
14019
|
+
claudeAuthState = null;
|
|
13784
14020
|
rpcHandlerManager;
|
|
13785
14021
|
resumeSessionHandler = null;
|
|
13786
14022
|
restartSessionHandler = null;
|
|
@@ -13847,6 +14083,44 @@ class ApiMachineClient {
|
|
|
13847
14083
|
if (!this.socket?.connected) return;
|
|
13848
14084
|
this.cliUpdatePushChain = this.cliUpdatePushChain.then(() => this.updateDaemonState((current) => withCurrentCliUpdateState(current, this.cliUpdateState))).catch((error) => logger.debug("[API MACHINE] Failed to publish CLI update policy:", error));
|
|
13849
14085
|
}
|
|
14086
|
+
/**
|
|
14087
|
+
* B-276: publish the daemon-context Claude auth preflight into daemonState
|
|
14088
|
+
* (same serialized push chain as the CLI update policy). Resolves true when
|
|
14089
|
+
* the server acknowledged the write with our value in it, false otherwise
|
|
14090
|
+
* (rate-limit `result:'error'` is swallowed by updateDaemonState, so the
|
|
14091
|
+
* caller checks the echoed state and re-sends on the next probe).
|
|
14092
|
+
*/
|
|
14093
|
+
setClaudeAuthState(state) {
|
|
14094
|
+
this.claudeAuthState = state;
|
|
14095
|
+
if (!this.socket?.connected) return Promise.resolve(false);
|
|
14096
|
+
const attempt = this.cliUpdatePushChain.then(() => this.updateDaemonState((current) => ({
|
|
14097
|
+
...current ?? {},
|
|
14098
|
+
status: current?.status ?? "running",
|
|
14099
|
+
claudeAuth: state
|
|
14100
|
+
}))).then(() => this.machine.daemonState?.claudeAuth?.checkedAt === state.checkedAt && this.machine.daemonState?.claudeAuth?.daemonPid === state.daemonPid).catch((error) => {
|
|
14101
|
+
logger.debug("[API MACHINE] Failed to publish Claude auth state:", error);
|
|
14102
|
+
return false;
|
|
14103
|
+
});
|
|
14104
|
+
this.cliUpdatePushChain = attempt.then(() => void 0);
|
|
14105
|
+
return attempt;
|
|
14106
|
+
}
|
|
14107
|
+
/** B-276 machine RPCs. Responses always carry `claudeAuth` so the web can refresh in place. */
|
|
14108
|
+
setClaudeAuthHandlers(handlers) {
|
|
14109
|
+
this.rpcHandlerManager.registerHandler("claude-auth-probe", async () => {
|
|
14110
|
+
const claudeAuth = await handlers.probe();
|
|
14111
|
+
return { claudeAuth };
|
|
14112
|
+
});
|
|
14113
|
+
this.rpcHandlerManager.registerHandler("claude-auth-repair", async (params) => {
|
|
14114
|
+
const action = typeof params?.action === "string" ? params.action : "";
|
|
14115
|
+
return handlers.repair(action);
|
|
14116
|
+
});
|
|
14117
|
+
this.rpcHandlerManager.registerHandler("claude-auth-set-store", async (params) => {
|
|
14118
|
+
const store = params?.store === "file" ? "file" : params?.store === "auto" ? "auto" : null;
|
|
14119
|
+
if (!store) return { error: "invalid-store", claudeAuth: this.claudeAuthState };
|
|
14120
|
+
const claudeAuth = await handlers.setStore(store);
|
|
14121
|
+
return { claudeAuth };
|
|
14122
|
+
});
|
|
14123
|
+
}
|
|
13850
14124
|
setRPCHandlers({
|
|
13851
14125
|
spawnSession,
|
|
13852
14126
|
resumeSession,
|
|
@@ -13880,7 +14154,8 @@ class ApiMachineClient {
|
|
|
13880
14154
|
this.syncResumeSessionRpcRegistration();
|
|
13881
14155
|
this.syncRestartSessionRpcRegistration();
|
|
13882
14156
|
this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
|
|
13883
|
-
const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
|
|
14157
|
+
const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode, attachTmux } = params || {};
|
|
14158
|
+
const attach = attachTmux && typeof attachTmux === "object" && typeof attachTmux.id === "string" && typeof attachTmux.name === "string" ? { id: attachTmux.id, name: attachTmux.name } : void 0;
|
|
13884
14159
|
const result = await this.webTerminal.open({
|
|
13885
14160
|
terminalId,
|
|
13886
14161
|
cols,
|
|
@@ -13890,7 +14165,8 @@ class ApiMachineClient {
|
|
|
13890
14165
|
startupCommand,
|
|
13891
14166
|
resub,
|
|
13892
14167
|
attachOnly,
|
|
13893
|
-
streamMode: streamMode === "lines" ? "lines" : void 0
|
|
14168
|
+
streamMode: streamMode === "lines" ? "lines" : void 0,
|
|
14169
|
+
...attach ? { attachTmux: attach } : {}
|
|
13894
14170
|
});
|
|
13895
14171
|
if (encStream) {
|
|
13896
14172
|
this.encTerminals.add(result.terminalId);
|
|
@@ -13965,6 +14241,9 @@ class ApiMachineClient {
|
|
|
13965
14241
|
this.rpcHandlerManager.registerHandler("list-terminals", async () => {
|
|
13966
14242
|
return { type: "success", terminals: this.webTerminal.buildTerminalList() };
|
|
13967
14243
|
});
|
|
14244
|
+
this.rpcHandlerManager.registerHandler("list-tmux-sessions", async () => {
|
|
14245
|
+
return { type: "success", sessions: this.webTerminal.listUserTmuxSessions() };
|
|
14246
|
+
});
|
|
13968
14247
|
this.rpcHandlerManager.registerHandler("set-terminal-title", async (params) => {
|
|
13969
14248
|
const { terminalId, title, ifAbsent } = params || {};
|
|
13970
14249
|
if (!terminalId || typeof title !== "string") {
|
|
@@ -14358,10 +14637,15 @@ class ApiMachineClient {
|
|
|
14358
14637
|
// B-265 capability flag; restamped every connect so the
|
|
14359
14638
|
// web's `detectedAt >= startedAt` trust rule holds.
|
|
14360
14639
|
terminalRestore: { rpcAvailable: true, detectedAt: now },
|
|
14640
|
+
// B-273 capability flag (same restamp discipline).
|
|
14641
|
+
tmuxSessions: { rpcAvailable: true, detectedAt: now },
|
|
14361
14642
|
// B-084: closed records survive daemon restarts (persisted
|
|
14362
14643
|
// in closed-terminals.json), so the connect snapshot ships
|
|
14363
14644
|
// them too — not just the incremental pushes.
|
|
14364
|
-
closedTerminals: this.webTerminal.getClosedTerminals()
|
|
14645
|
+
closedTerminals: this.webTerminal.getClosedTerminals(),
|
|
14646
|
+
// B-276: re-ship the last preflight so a reconnect never
|
|
14647
|
+
// leaves a stale-pid value visible longer than needed.
|
|
14648
|
+
...this.claudeAuthState ? { claudeAuth: this.claudeAuthState } : {}
|
|
14365
14649
|
};
|
|
14366
14650
|
});
|
|
14367
14651
|
this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
|
|
@@ -15764,6 +16048,7 @@ exports.releaseDaemonLock = releaseDaemonLock;
|
|
|
15764
16048
|
exports.resolveCliUpdateCheckInterval = resolveCliUpdateCheckInterval;
|
|
15765
16049
|
exports.scrubTmuxClientEnv = scrubTmuxClientEnv;
|
|
15766
16050
|
exports.sendToVhTerminal = sendToVhTerminal;
|
|
16051
|
+
exports.shellescape = shellescape;
|
|
15767
16052
|
exports.startOfflineReconnection = startOfflineReconnection;
|
|
15768
16053
|
exports.summarizeSpawnSessionForLog = summarizeSpawnSessionForLog;
|
|
15769
16054
|
exports.tmuxArgs = tmuxArgs;
|
|
@@ -15773,3 +16058,4 @@ exports.writeCredentialsDataKey = writeCredentialsDataKey;
|
|
|
15773
16058
|
exports.writeCredentialsLegacy = writeCredentialsLegacy;
|
|
15774
16059
|
exports.writeDaemonState = writeDaemonState;
|
|
15775
16060
|
exports.writePrivateFileSync = writePrivateFileSync;
|
|
16061
|
+
exports.writeSettings = writeSettings;
|