@volter/supercode-terminal 0.2.59 → 0.2.61
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/index.mjs +21 -1
- package/package.json +1 -1
- package/substrate/tmux.d.ts +2 -0
- package/substrate/tmux.js +18 -6
package/index.mjs
CHANGED
|
@@ -157,6 +157,8 @@ export class TerminalWebSocketBridge {
|
|
|
157
157
|
|
|
158
158
|
export class TmuxTerminalHost {
|
|
159
159
|
#allowUnownedControl;
|
|
160
|
+
#generation = 0;
|
|
161
|
+
#listing = null;
|
|
160
162
|
#attachments = new Map();
|
|
161
163
|
#attachSocket;
|
|
162
164
|
#catalog = new Map();
|
|
@@ -186,7 +188,19 @@ export class TmuxTerminalHost {
|
|
|
186
188
|
this.#ttlMs = positiveInteger(options.attachmentTtlMs ?? DEFAULT_ATTACHMENT_TTL_MS, 'attachmentTtlMs');
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
|
|
191
|
+
/**
|
|
192
|
+
* The panes, as tmux lists them. Callers asking while a listing is running share it (one listing is five tmux
|
|
193
|
+
* processes, and a daemon's doors ask together); a change made through this host since starts a fresh one.
|
|
194
|
+
*/
|
|
195
|
+
listSessions() {
|
|
196
|
+
if (this.#listing && this.#listing.generation === this.#generation) return this.#listing.promise;
|
|
197
|
+
const listing = { generation: this.#generation, promise: null };
|
|
198
|
+
listing.promise = this.#listSessions().finally(() => { if (this.#listing === listing) this.#listing = null; });
|
|
199
|
+
this.#listing = listing;
|
|
200
|
+
return listing.promise;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async #listSessions() {
|
|
190
204
|
this.#pruneAttachments();
|
|
191
205
|
const names = this.#sessionNames();
|
|
192
206
|
const liveNames = new Set(names);
|
|
@@ -215,6 +229,8 @@ export class TmuxTerminalHost {
|
|
|
215
229
|
label: name,
|
|
216
230
|
owned: ownedNames.has(name),
|
|
217
231
|
size: size ? Object.freeze({ columns: size.width, rows: size.height }) : null,
|
|
232
|
+
// When the pane last printed, as tmux keeps it (milliseconds): idle time without a screen capture.
|
|
233
|
+
lastOutputAt: size?.activity ? size.activity * 1000 : null,
|
|
218
234
|
});
|
|
219
235
|
});
|
|
220
236
|
}
|
|
@@ -246,6 +262,8 @@ export class TmuxTerminalHost {
|
|
|
246
262
|
settings: TERMINAL_LIKE_SESSION,
|
|
247
263
|
socket: this.#socket,
|
|
248
264
|
});
|
|
265
|
+
// Raised once tmux has the session: a listing started before then cannot be the one that answers below.
|
|
266
|
+
this.#generation += 1;
|
|
249
267
|
const id = this.#catalogId(name);
|
|
250
268
|
const created = (await this.listSessions()).find((session) => session.id === id);
|
|
251
269
|
if (!created) {
|
|
@@ -285,6 +303,7 @@ export class TmuxTerminalHost {
|
|
|
285
303
|
throw new TerminalHostError('bind_not_allowed', 'This host does not own that tmux session.');
|
|
286
304
|
}
|
|
287
305
|
await this.#tmux.setSessionOptionAsync(entry.name, this.#contextOption, contextKey, this.#socket);
|
|
306
|
+
this.#generation += 1;
|
|
288
307
|
const bound = (await this.listSessions()).find((session) => session.id === sessionId);
|
|
289
308
|
if (!bound) {
|
|
290
309
|
throw new TerminalHostError('session_not_found', 'The tmux session exited before it could be bound.');
|
|
@@ -372,6 +391,7 @@ export class TmuxTerminalHost {
|
|
|
372
391
|
throw new TerminalHostError('close_not_allowed', 'This host does not own that tmux session.');
|
|
373
392
|
}
|
|
374
393
|
this.#tmux.killSessionProcessTree({ session: entry.name, socket: this.#socket });
|
|
394
|
+
this.#generation += 1;
|
|
375
395
|
this.#catalog.delete(sessionId);
|
|
376
396
|
this.#idByName.delete(entry.name);
|
|
377
397
|
for (const [id, grant] of this.#attachments) {
|
package/package.json
CHANGED
package/substrate/tmux.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type TmuxPane = {
|
|
|
8
8
|
export type TmuxWindowSize = {
|
|
9
9
|
width: number;
|
|
10
10
|
height: number;
|
|
11
|
+
/** When the window last had output, in Unix seconds (tmux's `window_activity`); absent when tmux did not say. */
|
|
12
|
+
activity?: number;
|
|
11
13
|
};
|
|
12
14
|
export type TmuxCommand = {
|
|
13
15
|
arguments?: string[];
|
package/substrate/tmux.js
CHANGED
|
@@ -59,12 +59,22 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
59
59
|
// set-options just configured. On an already-running server they are simply
|
|
60
60
|
// reapplied.
|
|
61
61
|
const sessionEnv = env ?? {};
|
|
62
|
+
// A tmux server keeps the environment of whoever started it as its global environment, and every pane inherits
|
|
63
|
+
// it. When an agent's session started the server (or is the launcher), that includes the session's own harness
|
|
64
|
+
// identity, so a new pane would resolve as that session. A pane's identity is only what its own session sets:
|
|
65
|
+
// every identity variable the server or the launcher holds and the session does not set is unset in the pane.
|
|
66
|
+
const identity = /^(CLAUDECODE|CLAUDE_CODE_SESSION_ID|CLAUDE_CODE_ENTRYPOINT|CODEX_THREAD_ID|CODEX_SESSION_ID|SUPERCODE_CALLER|SUPERCODE_CALLER_NAME|SUPERCODE_AGENT_[A-Z_]+)$/;
|
|
67
|
+
const serverNames = await runAsync("tmux", tmuxArgs(socket, ["show-environment", "-g"]))
|
|
68
|
+
.then((out) => out.split("\n").map((line) => /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1]).filter((name) => Boolean(name)))
|
|
69
|
+
.catch(() => []); // no server yet: the one this creates starts from this launcher's environment
|
|
70
|
+
const inherited = [...new Set([...serverNames, ...Object.keys(process.env)])]
|
|
71
|
+
.filter((name) => identity.test(name) && !(name in sessionEnv));
|
|
62
72
|
const bootstrapDirectories = [];
|
|
63
73
|
const paneBootstrapArgs = (pane) => {
|
|
64
74
|
const commandArgs = command === undefined ? [] : [command.program, ...(command.arguments ?? [])];
|
|
65
75
|
// With neither command nor overrides, retain tmux's default-shell and
|
|
66
76
|
// default-command behavior rather than choosing a shell for it.
|
|
67
|
-
if (command === undefined && Object.keys(sessionEnv).length === 0)
|
|
77
|
+
if (command === undefined && Object.keys(sessionEnv).length === 0 && inherited.length === 0)
|
|
68
78
|
return [];
|
|
69
79
|
// The complete command rides the private bootstrap file as well as its
|
|
70
80
|
// environment. tmux's command message is bounded (roughly 16 KiB), much
|
|
@@ -72,7 +82,7 @@ export async function createSession({ command, cwd, env, name, owner, ownerOptio
|
|
|
72
82
|
const directory = mkdtempSync(join(tmpdir(), "tmux-session-env-"));
|
|
73
83
|
bootstrapDirectories.push(directory);
|
|
74
84
|
const shell = process.env.SHELL ?? "/bin/sh";
|
|
75
|
-
return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv, commandArgs.length === 0 ? [shell, "-l"] : commandArgs);
|
|
85
|
+
return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv, inherited, commandArgs.length === 0 ? [shell, "-l"] : commandArgs);
|
|
76
86
|
};
|
|
77
87
|
try {
|
|
78
88
|
const startArgs = [...sessionServerOptionArgs(), "new-session", "-d", "-s", name];
|
|
@@ -395,7 +405,7 @@ function tmuxClientEnvironment(source = process.env) {
|
|
|
395
405
|
}
|
|
396
406
|
return environment;
|
|
397
407
|
}
|
|
398
|
-
function writeSessionEnvironmentBootstrap(directory, pane, environment, command) {
|
|
408
|
+
function writeSessionEnvironmentBootstrap(directory, pane, environment, unset, command) {
|
|
399
409
|
const environmentPath = join(directory, `pane-${pane}.env`);
|
|
400
410
|
const wrapperPath = join(directory, `pane-${pane}.sh`);
|
|
401
411
|
const lines = Object.entries(environment).map(([name, value]) => {
|
|
@@ -404,6 +414,7 @@ function writeSessionEnvironmentBootstrap(directory, pane, environment, command)
|
|
|
404
414
|
}
|
|
405
415
|
return `export ${name}=${shellQuote(value)}`;
|
|
406
416
|
});
|
|
417
|
+
lines.unshift(...unset.map((name) => `unset ${name}`));
|
|
407
418
|
writeFileSync(environmentPath, `${lines.join("\n")}\n`, { flag: "wx", mode: 0o600 });
|
|
408
419
|
writeFileSync(wrapperPath, [
|
|
409
420
|
"#!/bin/sh",
|
|
@@ -670,7 +681,7 @@ export async function listWindowSizesAsync(socket) {
|
|
|
670
681
|
"list-windows",
|
|
671
682
|
"-a",
|
|
672
683
|
"-F",
|
|
673
|
-
`#{session_name}${separator}#{window_index}${separator}#{window_width}${separator}#{window_height}`
|
|
684
|
+
`#{session_name}${separator}#{window_index}${separator}#{window_width}${separator}#{window_height}${separator}#{window_activity}`
|
|
674
685
|
]))).trim();
|
|
675
686
|
}
|
|
676
687
|
catch (error) {
|
|
@@ -692,7 +703,7 @@ export async function listWindowSizesAsync(socket) {
|
|
|
692
703
|
return sizes;
|
|
693
704
|
}
|
|
694
705
|
for (const line of output.split("\n")) {
|
|
695
|
-
const [session, windowIndex, width, height] = line.split(separator);
|
|
706
|
+
const [session, windowIndex, width, height, activity] = line.split(separator);
|
|
696
707
|
// Match getWindowSize, which reads window 0; ignore extra windows of a session.
|
|
697
708
|
if (!session || windowIndex !== "0") {
|
|
698
709
|
continue;
|
|
@@ -700,7 +711,8 @@ export async function listWindowSizesAsync(socket) {
|
|
|
700
711
|
const parsedWidth = Number(width);
|
|
701
712
|
const parsedHeight = Number(height);
|
|
702
713
|
if (Number.isInteger(parsedWidth) && Number.isInteger(parsedHeight) && parsedWidth >= 1 && parsedHeight >= 1) {
|
|
703
|
-
|
|
714
|
+
const parsedActivity = Number(activity);
|
|
715
|
+
sizes.set(session, { height: parsedHeight, width: parsedWidth, ...(Number.isInteger(parsedActivity) && parsedActivity > 0 ? { activity: parsedActivity } : {}) });
|
|
704
716
|
}
|
|
705
717
|
}
|
|
706
718
|
return sizes;
|