@oddessentials/agent-guild 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +9 -6
- package/src/manager/config.mjs +21 -0
- package/src/manager/herdr.mjs +156 -0
- package/src/manager/main.mjs +6 -1
- package/src/manager/providers.mjs +1 -1
- package/src/manager/server.mjs +7 -3
- package/src/manager/session-manager.mjs +229 -16
- package/src/manager/session.mjs +53 -15
- package/src/manager/shells.mjs +63 -7
- package/web/app.js +74 -11
package/README.md
CHANGED
|
@@ -67,6 +67,17 @@ Prompt are also offered when found. On macOS and Linux it follows your login
|
|
|
67
67
|
shell. Pick the default chip to clear a saved choice and follow the default
|
|
68
68
|
again. The choice applies to new sessions.
|
|
69
69
|
|
|
70
|
+
**Work inside tmux or herdr.** When installed, the Shell card also offers
|
|
71
|
+
tmux (3.2 or later, on macOS and Linux) and herdr. A tmux card runs a tmux
|
|
72
|
+
session of its own, with your own configuration, and tools in it report to
|
|
73
|
+
the card as they would in a shell. A herdr card opens your herdr session and
|
|
74
|
+
shows the agents herdr sees in its panes, working, blocked or idle. Stopping
|
|
75
|
+
or closing either card only detaches it, as closing a terminal window would:
|
|
76
|
+
the session keeps running, **Reattach** on the card brings it back, and the
|
|
77
|
+
card names the command that reattaches it from a terminal. Stopping or
|
|
78
|
+
restarting the manager detaches them the same way, and the cards come back,
|
|
79
|
+
closed and ready to reattach, when it starts again.
|
|
80
|
+
|
|
70
81
|
<img src="docs/images/terminal.webp" alt="An open Claude Code session with its helper agents shown in the header">
|
|
71
82
|
|
|
72
83
|
**Agents and models at work.** Helper agents that a tool starts appear on its
|
|
@@ -254,7 +265,9 @@ npm test
|
|
|
254
265
|
|
|
255
266
|
## Current limits
|
|
256
267
|
|
|
257
|
-
* Sessions end when the manager stops or the computer restarts
|
|
268
|
+
* Sessions end when the manager stops or the computer restarts, except a
|
|
269
|
+
Shell session in tmux or herdr, which keeps running in it while the
|
|
270
|
+
manager is stopped.
|
|
258
271
|
* Antigravity CLI and Grok Build have no usage meter.
|
|
259
272
|
* Antigravity CLI has one account per computer user: it keeps its sign-in in
|
|
260
273
|
the system keychain and has no setting for another home folder.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -83,8 +83,9 @@ export function resolveAllCommands(command, env = process.env, platform = proces
|
|
|
83
83
|
return hits;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
/** End a Windows process and, unless `tree` is false, every process it started. */
|
|
87
|
+
export function killWindowsTree(pid, done = () => {}, { tree = true } = {}) {
|
|
88
|
+
execFile('taskkill', ['/PID', String(pid), ...(tree ? ['/T'] : []), '/F'], { windowsHide: true, timeout: 5000 }, (err) => done(err));
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/** Quote one argument for a cmd.exe command line. */
|
|
@@ -121,10 +122,11 @@ export function buildSpawnSpec(resolvedPath, args = [], env = process.env, platf
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
/**
|
|
124
|
-
* Run a spawn spec to completion without a terminal
|
|
125
|
-
* output; rejects with the error carrying stdout
|
|
125
|
+
* Run a spawn spec to completion without a terminal, with `input` on its
|
|
126
|
+
* stdin. Resolves with its output; rejects with the error carrying stdout
|
|
127
|
+
* and stderr.
|
|
126
128
|
*/
|
|
127
|
-
export function runSpec(spec, { env, timeoutMs = 15000, cwd } = {}) {
|
|
129
|
+
export function runSpec(spec, { env, timeoutMs = 15000, cwd, input } = {}) {
|
|
128
130
|
return new Promise((resolve, reject) => {
|
|
129
131
|
const opts = { env, cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 };
|
|
130
132
|
let args = spec.args;
|
|
@@ -133,10 +135,11 @@ export function runSpec(spec, { env, timeoutMs = 15000, cwd } = {}) {
|
|
|
133
135
|
args = [args];
|
|
134
136
|
}
|
|
135
137
|
try {
|
|
136
|
-
execFile(spec.file, args, opts, (err, stdout, stderr) => {
|
|
138
|
+
const child = execFile(spec.file, args, opts, (err, stdout, stderr) => {
|
|
137
139
|
if (err) reject(Object.assign(err, { stdout, stderr }));
|
|
138
140
|
else resolve({ stdout, stderr });
|
|
139
141
|
});
|
|
142
|
+
if (input !== undefined) child.stdin.end(input);
|
|
140
143
|
} catch (err) {
|
|
141
144
|
reject(err);
|
|
142
145
|
}
|
package/src/manager/config.mjs
CHANGED
|
@@ -48,6 +48,8 @@ export const paths = {
|
|
|
48
48
|
/** Launchers for agent-guild-report, put first on every session's PATH. */
|
|
49
49
|
get shims() { return path.join(dataDir(), 'bin'); },
|
|
50
50
|
get reporting() { return path.join(dataDir(), 'reporting'); },
|
|
51
|
+
/** The tmux and herdr cards a restarted manager brings back, report tokens included. */
|
|
52
|
+
get multiplexers() { return path.join(dataDir(), 'multiplexers.json'); },
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
function writePrivate(file, contents) {
|
|
@@ -72,6 +74,25 @@ export function loadOrCreateToken() {
|
|
|
72
74
|
return token;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/** Where the manager keeps its tmux and herdr cards, readable only by the user since they hold report tokens. */
|
|
78
|
+
export const multiplexerStore = {
|
|
79
|
+
load() {
|
|
80
|
+
try {
|
|
81
|
+
const { cards } = JSON.parse(fs.readFileSync(paths.multiplexers, 'utf8'));
|
|
82
|
+
return Array.isArray(cards) ? cards : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
save(cards) {
|
|
88
|
+
ensureDataDir();
|
|
89
|
+
// Written aside and renamed, so a crash never leaves half a file.
|
|
90
|
+
const next = `${paths.multiplexers}.${process.pid}.tmp`;
|
|
91
|
+
writePrivate(next, JSON.stringify({ cards }, null, 2) + '\n');
|
|
92
|
+
fs.renameSync(next, paths.multiplexers);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
75
96
|
export function writeRuntimeFile(info) {
|
|
76
97
|
ensureDataDir();
|
|
77
98
|
writePrivate(paths.runtime, JSON.stringify(info, null, 2) + '\n');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The agents herdr sees in its panes, for a herdr card. herdr tracks every
|
|
2
|
+
// coding agent in its panes and whether it is working, blocked or idle, with
|
|
3
|
+
// no hooks of ours. Its socket API pushes an event when an agent appears or
|
|
4
|
+
// leaves and, for a pane named in the subscription, when its agent changes
|
|
5
|
+
// state; each event triggers a fresh agent.list, as herdr's documentation
|
|
6
|
+
// advises, since events and reads share no sequence.
|
|
7
|
+
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
10
|
+
|
|
11
|
+
const ANY_PANE = ['pane.agent_detected', 'pane.closed', 'pane.exited'];
|
|
12
|
+
const STATUS = { working: 'working', blocked: 'waiting' };
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
14
|
+
|
|
15
|
+
/** Card agent reports for herdr's agent list. Idle, done and unknown agents show as idle. */
|
|
16
|
+
export function herdrAgentReports(agents) {
|
|
17
|
+
return (Array.isArray(agents) ? agents : [])
|
|
18
|
+
.filter((agent) => typeof agent?.pane_id === 'string' && typeof agent.agent === 'string')
|
|
19
|
+
.map((agent) => ({
|
|
20
|
+
agentId: `herdr:${agent.pane_id}`,
|
|
21
|
+
name: agent.agent,
|
|
22
|
+
kind: 'agent',
|
|
23
|
+
status: STATUS[agent.agent_status] ?? 'idle',
|
|
24
|
+
detail: typeof agent.cwd === 'string' ? agent.cwd : '',
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where to reach the herdr session that a client started with `env` uses:
|
|
30
|
+
* the one HERDR_SESSION names, else the default. herdr names its Windows
|
|
31
|
+
* pipe after the socket path.
|
|
32
|
+
*/
|
|
33
|
+
export function herdrSocket(listing, env, platform = process.platform) {
|
|
34
|
+
const sessions = Array.isArray(listing?.sessions) ? listing.sessions : [];
|
|
35
|
+
const session = sessions.find((s) => (env.HERDR_SESSION ? s.name === env.HERDR_SESSION : s.default === true));
|
|
36
|
+
if (!session?.running || typeof session.socket_path !== 'string') return null;
|
|
37
|
+
return platform === 'win32' ? `\\\\.\\pipe\\${session.socket_path}` : session.socket_path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Call `onLine` with each JSON line read from `socket`. */
|
|
41
|
+
function readLines(socket, onLine) {
|
|
42
|
+
let buffer = '';
|
|
43
|
+
socket.setEncoding('utf8');
|
|
44
|
+
socket.on('data', (chunk) => {
|
|
45
|
+
buffer += chunk;
|
|
46
|
+
for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) {
|
|
47
|
+
const line = buffer.slice(0, end).trim();
|
|
48
|
+
buffer = buffer.slice(end + 1);
|
|
49
|
+
if (!line) continue;
|
|
50
|
+
try { onLine(JSON.parse(line)); } catch { /* not JSON */ }
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One request on its own connection: resolves with its result. */
|
|
56
|
+
function request(socketPath, method, params = {}) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const socket = net.connect(socketPath);
|
|
59
|
+
socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.destroy(new Error(`herdr did not answer ${method}`)));
|
|
60
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ id: 'agent-guild', method, params })}\n`));
|
|
61
|
+
readLines(socket, (msg) => {
|
|
62
|
+
socket.end();
|
|
63
|
+
if (msg.error) reject(new Error(msg.error.message || msg.error.code));
|
|
64
|
+
else resolve(msg.result);
|
|
65
|
+
});
|
|
66
|
+
socket.on('error', reject);
|
|
67
|
+
socket.on('close', () => reject(new Error(`herdr closed the connection before answering ${method}`)));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class HerdrAgents {
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {string} opts.herdr the herdr executable
|
|
75
|
+
* @param {object} opts.env the environment the card's herdr client runs with
|
|
76
|
+
* @param {(agents: object[]) => void} opts.onAgents called with herdr's agent list after each change
|
|
77
|
+
*/
|
|
78
|
+
constructor({ herdr, env, platform = process.platform, onAgents }) {
|
|
79
|
+
Object.assign(this, { herdr, env, platform, onAgents });
|
|
80
|
+
this.socketPath = null;
|
|
81
|
+
this.subscription = null;
|
|
82
|
+
this.panes = null;
|
|
83
|
+
this.connecting = false;
|
|
84
|
+
this.reading = false;
|
|
85
|
+
this.readAgain = false;
|
|
86
|
+
this.stopped = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Connect unless connected. A server that was still starting is found on a later call. */
|
|
90
|
+
poke() {
|
|
91
|
+
if (this.stopped || this.subscription || this.connecting) return;
|
|
92
|
+
this.connecting = true;
|
|
93
|
+
this._connect().finally(() => { this.connecting = false; });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stop() {
|
|
97
|
+
this.stopped = true;
|
|
98
|
+
this.subscription?.destroy();
|
|
99
|
+
this.subscription = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async _connect() {
|
|
103
|
+
try {
|
|
104
|
+
const spec = buildSpawnSpec(this.herdr, ['session', 'list', '--json'], this.env, this.platform);
|
|
105
|
+
const { stdout } = await runSpec(spec, { env: this.env, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
106
|
+
this.socketPath = herdrSocket(JSON.parse(stdout), this.env, this.platform);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.socketPath && !this.stopped) this._subscribe([]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Replace the subscription: any pane's agent arriving or leaving, and each of `panes` changing state. */
|
|
114
|
+
_subscribe(panes) {
|
|
115
|
+
this.subscription?.destroy();
|
|
116
|
+
this.panes = panes;
|
|
117
|
+
const socket = net.connect(this.socketPath);
|
|
118
|
+
this.subscription = socket;
|
|
119
|
+
const subscriptions = [...ANY_PANE.map((type) => ({ type })), ...panes.map((pane) => ({ type: 'pane.agent_status_changed', pane_id: pane }))];
|
|
120
|
+
socket.on('connect', () => socket.write(`${JSON.stringify({ id: 'agent-guild', method: 'events.subscribe', params: { subscriptions } })}\n`));
|
|
121
|
+
readLines(socket, (msg) => {
|
|
122
|
+
if (this.subscription !== socket) return;
|
|
123
|
+
// herdr refuses a subscription naming a pane that has closed, and ends one that fell behind;
|
|
124
|
+
// start over from no panes. Anything else, the acknowledgement included, is a reason to read.
|
|
125
|
+
if (msg.error) return panes.length ? this._subscribe([]) : socket.destroy();
|
|
126
|
+
this._read();
|
|
127
|
+
});
|
|
128
|
+
socket.on('error', () => {});
|
|
129
|
+
socket.on('close', () => {
|
|
130
|
+
if (this.subscription === socket) this.subscription = null;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read herdr's agent list, once more if an event arrives meanwhile, and follow the panes it names. */
|
|
135
|
+
async _read() {
|
|
136
|
+
if (this.reading) {
|
|
137
|
+
this.readAgain = true;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.reading = true;
|
|
141
|
+
try {
|
|
142
|
+
do {
|
|
143
|
+
this.readAgain = false;
|
|
144
|
+
const { agents = [] } = (await request(this.socketPath, 'agent.list')) ?? {};
|
|
145
|
+
if (this.stopped) return;
|
|
146
|
+
this.onAgents(agents);
|
|
147
|
+
const panes = agents.map((agent) => agent?.pane_id).filter((pane) => typeof pane === 'string').sort();
|
|
148
|
+
if (this.subscription && panes.join('\n') !== this.panes.join('\n')) this._subscribe(panes);
|
|
149
|
+
} while (this.readAgain && !this.stopped);
|
|
150
|
+
} catch {
|
|
151
|
+
// herdr went away or did not answer; the next event or poke tries again.
|
|
152
|
+
} finally {
|
|
153
|
+
this.reading = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
VERSION,
|
|
26
26
|
ensureDataDir,
|
|
27
27
|
loadOrCreateToken,
|
|
28
|
+
multiplexerStore,
|
|
28
29
|
paths,
|
|
29
30
|
removeRuntimeFile,
|
|
30
31
|
resolvePort,
|
|
@@ -67,7 +68,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
67
68
|
registry.reportingEnabled = (provider) => sessionHooks.enabled(provider);
|
|
68
69
|
sessionHooks.warm();
|
|
69
70
|
const manager = new SessionManager({
|
|
70
|
-
registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks,
|
|
71
|
+
registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore,
|
|
71
72
|
});
|
|
72
73
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
73
74
|
const history = new SessionHistory({ registry, env: baseEnv });
|
|
@@ -146,6 +147,10 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
146
147
|
}
|
|
147
148
|
throw err;
|
|
148
149
|
}
|
|
150
|
+
// tmux and herdr cards come back closed, ready to reattach, before the
|
|
151
|
+
// runtime file announces this manager; after listening, since a card's
|
|
152
|
+
// environment names the API's URL.
|
|
153
|
+
await manager.restore();
|
|
149
154
|
|
|
150
155
|
writeRuntimeFile({
|
|
151
156
|
pid: process.pid,
|
|
@@ -669,7 +669,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
669
669
|
reporting: provider.reporting,
|
|
670
670
|
reportingEnabled: this.reportingEnabled?.(provider) ?? null,
|
|
671
671
|
accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
|
|
672
|
-
shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path })) ?? null,
|
|
672
|
+
shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
|
|
673
673
|
defaultShell: shells?.defaultId ?? null,
|
|
674
674
|
modelPattern: provider.modelPattern,
|
|
675
675
|
color: provider.color,
|
package/src/manager/server.mjs
CHANGED
|
@@ -306,8 +306,9 @@ export function createManagerServer({
|
|
|
306
306
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
307
307
|
}
|
|
308
308
|
if (route === '/shutdown' && method === 'POST') {
|
|
309
|
-
// Stopping the manager ends every session
|
|
310
|
-
// `force` while any is running. The same
|
|
309
|
+
// Stopping the manager ends every session but the tmux and herdr ones,
|
|
310
|
+
// so a client must say `force` while any of those is running. The same
|
|
311
|
+
// guard serves every front end.
|
|
311
312
|
const body = await readJsonBody(req);
|
|
312
313
|
const running = manager.runningCount();
|
|
313
314
|
if (running > 0 && body.force !== true) {
|
|
@@ -328,7 +329,7 @@ export function createManagerServer({
|
|
|
328
329
|
return undefined;
|
|
329
330
|
}
|
|
330
331
|
|
|
331
|
-
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop)?$/);
|
|
332
|
+
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop|\/reattach)?$/);
|
|
332
333
|
if (sessionMatch) {
|
|
333
334
|
const [, id, action] = sessionMatch;
|
|
334
335
|
if (!action && method === 'GET') return sendJson(res, 200, { session: manager.get(id).toJSON() });
|
|
@@ -348,6 +349,9 @@ export function createManagerServer({
|
|
|
348
349
|
if (action === '/stop' && method === 'POST') {
|
|
349
350
|
return sendJson(res, 200, { session: manager.stop(id).toJSON() });
|
|
350
351
|
}
|
|
352
|
+
if (action === '/reattach' && method === 'POST') {
|
|
353
|
+
return sendJson(res, 200, { session: (await manager.reattach(id)).toJSON() });
|
|
354
|
+
}
|
|
351
355
|
}
|
|
352
356
|
throw new HttpError(404, `no route for ${method} ${url.pathname}`, 'not_found');
|
|
353
357
|
}
|
|
@@ -8,6 +8,9 @@ import path from 'node:path';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { Session, newId, clampDimension, cleanName } from './session.mjs';
|
|
10
10
|
import { prependPath } from './report-shims.mjs';
|
|
11
|
+
import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
12
|
+
import { tmuxNewSession } from './shells.mjs';
|
|
13
|
+
import { HerdrAgents, herdrAgentReports, herdrSocket } from './herdr.mjs';
|
|
11
14
|
import { CHANNEL_LABELS } from './install-channels.mjs';
|
|
12
15
|
import { SELF_PROVIDER } from './self-update.mjs';
|
|
13
16
|
import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
|
|
@@ -21,6 +24,10 @@ function httpError(status, message, code) {
|
|
|
21
24
|
return Object.assign(new Error(message), { status, code });
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
function isFolder(dir) {
|
|
28
|
+
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
/**
|
|
25
32
|
* Layer environment objects. On Windows, variable names are
|
|
26
33
|
* case-insensitive, so a later "PATH" must replace an inherited "Path"
|
|
@@ -54,7 +61,7 @@ export class SessionManager extends EventEmitter {
|
|
|
54
61
|
* @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
|
|
55
62
|
* @param {import('./github.mjs').GitHub|null} [opts.github]
|
|
56
63
|
*/
|
|
57
|
-
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null }) {
|
|
64
|
+
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null }) {
|
|
58
65
|
super();
|
|
59
66
|
this.registry = registry;
|
|
60
67
|
this.baseEnv = baseEnv;
|
|
@@ -70,6 +77,13 @@ export class SessionManager extends EventEmitter {
|
|
|
70
77
|
/** True once shutdown has begun; no new session may start after that. */
|
|
71
78
|
this.closing = false;
|
|
72
79
|
this.installing = new Set();
|
|
80
|
+
/** Session id → how a tmux or herdr card attaches again, and whether its multiplexer session is still there. */
|
|
81
|
+
this.multiplexers = new Map();
|
|
82
|
+
/** Session id → the watcher that shows a running herdr card the agents herdr sees. */
|
|
83
|
+
this.watchers = new Map();
|
|
84
|
+
/** Keeps the tmux and herdr cards across restarts: { load(), save(cards) }, or null. */
|
|
85
|
+
this.store = store;
|
|
86
|
+
this.restoring = false;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
list() {
|
|
@@ -110,19 +124,184 @@ export class SessionManager extends EventEmitter {
|
|
|
110
124
|
if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
|
|
111
125
|
throw httpError(409, `${provider.tool} is being installed, updated or removed; start it once that finishes`, 'install_in_progress');
|
|
112
126
|
}
|
|
113
|
-
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
|
|
114
127
|
this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
|
|
115
128
|
const sessionName = cleanName(name)
|
|
116
129
|
|| (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
|
|
117
130
|
|| (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
const options = { provider, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv: runShell?.env };
|
|
132
|
+
if (runShell?.multiplexer) return this._startMultiplexer(options, runShell, args || []);
|
|
133
|
+
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
|
|
134
|
+
const session = this._spawn({ ...options, spawnSpec });
|
|
121
135
|
const model = modelFromArgs([...provider.args, ...(args || [])]);
|
|
122
136
|
if (model) session.setModel({ name: model }, 'args');
|
|
123
137
|
return session;
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A Shell session in tmux or herdr. Its client is the session's process;
|
|
142
|
+
* the multiplexer's server outlives it and gives its own environment to
|
|
143
|
+
* every session it starts later, outside Agent Guild too, so the client
|
|
144
|
+
* never carries this session's identity. A tmux card gets a tmux session
|
|
145
|
+
* of its own, made first with that identity, so tools in it report to the
|
|
146
|
+
* card as they would in a shell. A herdr card shows the agents herdr sees.
|
|
147
|
+
*/
|
|
148
|
+
async _startMultiplexer(options, shell, args) {
|
|
149
|
+
const { provider, account } = options;
|
|
150
|
+
const id = newId();
|
|
151
|
+
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
152
|
+
// A name of its own, so the card can say how to reattach it.
|
|
153
|
+
const muxName = `guild-${newId(3)}`;
|
|
154
|
+
const mux = this._multiplexer({ provider, account, shell, id, reportToken, muxName });
|
|
155
|
+
const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'tmux' ? [] : args, options.resume, [], mux.named);
|
|
156
|
+
if (shell.id === 'tmux') {
|
|
157
|
+
this._assertCanSpawn();
|
|
158
|
+
const own = this._sessionEnv({ id, reportToken, provider, account, extraEnv: shell.env });
|
|
159
|
+
const env = Object.fromEntries(Object.entries(own).filter(([key]) => mux.dropEnv(key) || key.toUpperCase() === 'PATH'));
|
|
160
|
+
const cols = clampDimension(options.cols, 120, 2, 1000);
|
|
161
|
+
const rows = clampDimension(options.rows, 32, 1, 500);
|
|
162
|
+
// Outside the try: a folder or argument tmux cannot take is the request's fault, refused before tmux runs.
|
|
163
|
+
const input = tmuxNewSession({ name: muxName, cwd: options.cwd, cols, rows, env, args });
|
|
164
|
+
try {
|
|
165
|
+
await mux.run(['-u', 'start-server', ';', 'source-file', '-'], { cwd: options.cwd, input });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw httpError(500, `tmux could not make a session: ${(err.stderr || err.message).trim()}`, 'spawn_failed');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let session;
|
|
171
|
+
try {
|
|
172
|
+
session = this._spawn({
|
|
173
|
+
...options, id, reportToken, spawnSpec, dropEnv: mux.dropEnv,
|
|
174
|
+
multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', muxName), reattachable: false },
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (shell.id === 'tmux') mux.run(['kill-session', '-t', `=${muxName}`]).catch(() => {});
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
this._track(session, { spawnSpec, mux, shell, card: { id, reportToken, provider: provider.id, account: account.id, shell: shell.id, muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
|
|
181
|
+
this._watchHerdr(session);
|
|
182
|
+
return session;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How to run a card's tmux or herdr client, and tell whether the multiplexer still has the card's session. */
|
|
186
|
+
_multiplexer({ provider, account, shell, id, reportToken, muxName }) {
|
|
187
|
+
const named = { ...shell, args: shell.args.map((arg) => arg.replaceAll('{name}', muxName)) };
|
|
188
|
+
const dropEnv = (key) => key.startsWith('AGENT_GUILD_');
|
|
189
|
+
const clientEnv = this._sessionEnv({ id, reportToken, provider, account, extraEnv: shell.env, dropEnv });
|
|
190
|
+
const run = (args, extra = {}) => runSpec(buildSpawnSpec(shell.path, args, clientEnv, this.registry.platform), { env: clientEnv, ...extra });
|
|
191
|
+
// tmux keeps the card's own session by name; a herdr card's session is herdr's, there while its server runs.
|
|
192
|
+
const alive = shell.id === 'tmux'
|
|
193
|
+
? () => run(['has-session', '-t', `=${muxName}`]).then(() => true, () => false)
|
|
194
|
+
: () => run(['session', 'list', '--json']).then(({ stdout }) => herdrSocket(JSON.parse(stdout), clientEnv, this.registry.platform) !== null, () => false);
|
|
195
|
+
return { named, dropEnv, clientEnv, run, alive };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Keep a tmux or herdr card for Reattach, here and, through the store, across restarts. */
|
|
199
|
+
_track(session, { spawnSpec, mux, shell, card }) {
|
|
200
|
+
this.multiplexers.set(session.id, { spawnSpec, alive: mux.alive, herdr: shell.id === 'herdr' ? { path: shell.path, env: mux.clientEnv } : null, card });
|
|
201
|
+
this._saveCards();
|
|
202
|
+
session.on('exit', async () => {
|
|
203
|
+
session.multiplexer.reattachable = await mux.alive();
|
|
204
|
+
session._changed();
|
|
205
|
+
});
|
|
206
|
+
session.on('changed', () => {
|
|
207
|
+
if (card.name === session.name) return;
|
|
208
|
+
card.name = session.name;
|
|
209
|
+
this._saveCards();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_saveCards() {
|
|
214
|
+
// A stopping manager leaves the file as it is, for the next one to bring the cards back.
|
|
215
|
+
if (!this.store || this.restoring || this.closing) return;
|
|
216
|
+
try {
|
|
217
|
+
this.store.save([...this.multiplexers.values()].map(({ card }) => card));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn(`[sessions] could not save the tmux and herdr cards: ${err.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Bring back the tmux and herdr cards the previous manager had, closed and
|
|
225
|
+
* ready to reattach, with their own ids and report tokens so whatever runs
|
|
226
|
+
* inside reports to them again. A card whose session is gone stays gone.
|
|
227
|
+
*/
|
|
228
|
+
async restore() {
|
|
229
|
+
this.restoring = true;
|
|
230
|
+
try {
|
|
231
|
+
// All at once, so a multiplexer slow to answer holds up the start once, not once a card.
|
|
232
|
+
await Promise.all((this.store?.load() ?? []).map((card) => this._restoreCard(card).catch((err) => {
|
|
233
|
+
console.warn(`[sessions] did not bring back the card ${card?.name ?? card?.id}: ${err.message}`);
|
|
234
|
+
})));
|
|
235
|
+
} finally {
|
|
236
|
+
this.restoring = false;
|
|
237
|
+
}
|
|
238
|
+
this._saveCards();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _restoreCard(card) {
|
|
242
|
+
if (typeof card?.id !== 'string' || !/^[a-f0-9]{1,32}$/.test(card.id) || this.sessions.has(card.id)) return;
|
|
243
|
+
if (typeof card.reportToken !== 'string' || !/^[a-f0-9]{32}$/.test(card.reportToken)) return;
|
|
244
|
+
if (typeof card.muxName !== 'string' || !/^guild-[0-9a-f]{6}$/.test(card.muxName)) return;
|
|
245
|
+
const provider = this.registry.get(String(card.provider));
|
|
246
|
+
const shell = provider && this.registry.shellsFor(provider)?.shells.find((s) => s.id === card.shell && s.multiplexer);
|
|
247
|
+
if (!shell) return;
|
|
248
|
+
const account = this.registry.account(provider, card.account);
|
|
249
|
+
const mux = this._multiplexer({ provider, account, shell, id: card.id, reportToken: card.reportToken, muxName: card.muxName });
|
|
250
|
+
if (!(await mux.alive()) || this.closing) return;
|
|
251
|
+
const spawnSpec = this.registry.spawnSpec(provider, [], null, [], mux.named);
|
|
252
|
+
const session = this._spawn({
|
|
253
|
+
provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
|
|
254
|
+
id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
|
|
255
|
+
createdAt: typeof card.createdAt === 'string' && !Number.isNaN(Date.parse(card.createdAt)) ? card.createdAt : undefined,
|
|
256
|
+
multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', card.muxName), reattachable: true },
|
|
257
|
+
});
|
|
258
|
+
this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
|
|
262
|
+
async reattach(id) {
|
|
263
|
+
const session = this.get(id);
|
|
264
|
+
const mux = this.multiplexers.get(id);
|
|
265
|
+
if (!mux) throw httpError(400, `${session.name} is not in tmux or herdr`, 'not_reattachable');
|
|
266
|
+
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
267
|
+
if (session.status === 'running') throw httpError(409, `${session.name} is still attached`, 'session_running');
|
|
268
|
+
// As the page offers it: only once the manager has found, after the client closed or at a restart, that the multiplexer still has the session.
|
|
269
|
+
if (!session.multiplexer.reattachable) throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
|
|
270
|
+
const alive = await mux.alive();
|
|
271
|
+
if (this.sessions.get(id) !== session || session.status === 'running') throw httpError(409, `${session.name} changed meanwhile`, 'session_running');
|
|
272
|
+
if (!alive) {
|
|
273
|
+
session.multiplexer.reattachable = false;
|
|
274
|
+
session._changed();
|
|
275
|
+
throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
// The client can run anywhere, and the card's folder may be gone by now.
|
|
279
|
+
session.reattach(mux.spawnSpec, { cwd: isFolder(session.cwd) ? session.cwd : os.homedir() });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw httpError(500, `could not attach to ${session.multiplexer.label}: ${err.message}`, 'spawn_failed');
|
|
282
|
+
}
|
|
283
|
+
session.multiplexer.reattachable = false;
|
|
284
|
+
this._watchHerdr(session);
|
|
285
|
+
return session;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** While a herdr card runs, its agents are the ones herdr reports. */
|
|
289
|
+
_watchHerdr(session) {
|
|
290
|
+
const herdr = this.multiplexers.get(session.id)?.herdr;
|
|
291
|
+
if (!herdr) return;
|
|
292
|
+
this.watchers.get(session.id)?.stop();
|
|
293
|
+
const watcher = new HerdrAgents({ herdr: herdr.path, env: herdr.env, platform: this.registry.platform, onAgents: (agents) => showHerdrAgents(session, agents) });
|
|
294
|
+
this.watchers.set(session.id, watcher);
|
|
295
|
+
// herdr's server may still be starting; each burst of output from the client tries again until connected.
|
|
296
|
+
const poke = () => { if (session.status === 'running') watcher.poke(); };
|
|
297
|
+
session.on('changed', poke);
|
|
298
|
+
session.once('exit', () => {
|
|
299
|
+
session.off('changed', poke);
|
|
300
|
+
watcher.stop();
|
|
301
|
+
if (this.watchers.get(session.id) === watcher) this.watchers.delete(session.id);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
126
305
|
prepareAccount(provider, account, { hooksSupplied = false } = {}) {
|
|
127
306
|
if (!account.dir) return;
|
|
128
307
|
try {
|
|
@@ -242,24 +421,26 @@ export class SessionManager extends EventEmitter {
|
|
|
242
421
|
return n;
|
|
243
422
|
}
|
|
244
423
|
|
|
245
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Sessions whose process is still running, install sessions included,
|
|
426
|
+
* except tmux and herdr ones: stopping the manager only detaches those,
|
|
427
|
+
* and their cards come back.
|
|
428
|
+
*/
|
|
246
429
|
runningCount() {
|
|
247
430
|
let n = 0;
|
|
248
|
-
for (const s of this.sessions.values()) if (s.status === 'running') n++;
|
|
431
|
+
for (const s of this.sessions.values()) if (s.status === 'running' && !s.multiplexer) n++;
|
|
249
432
|
return n;
|
|
250
433
|
}
|
|
251
434
|
|
|
252
|
-
|
|
253
|
-
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
254
|
-
extraEnv = null, dropEnv = null, clone = null, reporting = null,
|
|
255
|
-
}) {
|
|
435
|
+
_assertCanSpawn() {
|
|
256
436
|
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
257
437
|
if (this.sessions.size >= MAX_SESSIONS) {
|
|
258
438
|
throw httpError(429, `session limit reached (${MAX_SESSIONS}); remove finished sessions first`, 'too_many_sessions');
|
|
259
439
|
}
|
|
260
|
-
|
|
261
|
-
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
440
|
+
}
|
|
262
441
|
|
|
442
|
+
/** The environment of a session's process. */
|
|
443
|
+
_sessionEnv({ id, reportToken, provider, account = null, extraEnv = null, dropEnv = null }) {
|
|
263
444
|
// The tool's hooks run `agent-guild-report` by name, so the launchers
|
|
264
445
|
// go first on PATH, after any provider PATH override.
|
|
265
446
|
let env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
|
|
@@ -274,11 +455,25 @@ export class SessionManager extends EventEmitter {
|
|
|
274
455
|
}]), this.shimDir);
|
|
275
456
|
// The tool runs in its own terminal, not in the terminal or multiplexer
|
|
276
457
|
// the manager was started from: Claude Code would otherwise open
|
|
277
|
-
// agent-team panes in that tmux window, outside the page,
|
|
278
|
-
//
|
|
279
|
-
|
|
458
|
+
// agent-team panes in that tmux window, outside the page, tools would
|
|
459
|
+
// tune their output to a terminal program that is not there, and herdr
|
|
460
|
+
// would refuse to start, taking itself to be nested in a herdr pane.
|
|
461
|
+
for (const key of [
|
|
462
|
+
'TMUX', 'TMUX_PANE', 'STY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'ZELLIJ', 'ZELLIJ_SESSION_NAME', 'ZELLIJ_PANE_ID',
|
|
463
|
+
'HERDR_ENV', 'HERDR_PANE_ID', 'HERDR_TAB_ID', 'HERDR_WORKSPACE_ID', 'HERDR_SOCKET_PATH', 'HERDR_BIN_PATH',
|
|
464
|
+
]) delete env[key];
|
|
280
465
|
if (dropEnv) for (const key of Object.keys(env)) if (dropEnv(key)) delete env[key];
|
|
281
466
|
if (extraEnv) env = mergeEnv([env, extraEnv]);
|
|
467
|
+
return env;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_spawn({
|
|
471
|
+
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
472
|
+
extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
|
|
473
|
+
id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt,
|
|
474
|
+
}) {
|
|
475
|
+
this._assertCanSpawn();
|
|
476
|
+
const env = this._sessionEnv({ id, reportToken, provider, account, extraEnv, dropEnv });
|
|
282
477
|
|
|
283
478
|
let session;
|
|
284
479
|
try {
|
|
@@ -298,6 +493,8 @@ export class SessionManager extends EventEmitter {
|
|
|
298
493
|
account: account ? { id: account.id, label: account.label } : null,
|
|
299
494
|
clone,
|
|
300
495
|
reporting,
|
|
496
|
+
multiplexer,
|
|
497
|
+
createdAt,
|
|
301
498
|
});
|
|
302
499
|
} catch (err) {
|
|
303
500
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
|
@@ -327,6 +524,9 @@ export class SessionManager extends EventEmitter {
|
|
|
327
524
|
remove(id) {
|
|
328
525
|
const session = this.get(id);
|
|
329
526
|
this.sessions.delete(id);
|
|
527
|
+
if (this.multiplexers.delete(id)) this._saveCards();
|
|
528
|
+
this.watchers.get(id)?.stop();
|
|
529
|
+
this.watchers.delete(id);
|
|
330
530
|
session._broadcast({ type: 'removed' });
|
|
331
531
|
if (session.status === 'running') {
|
|
332
532
|
this.exiting.add(session);
|
|
@@ -377,6 +577,8 @@ export class SessionManager extends EventEmitter {
|
|
|
377
577
|
*/
|
|
378
578
|
async shutdown({ graceMs = 1500, timeoutMs = 5000 } = {}) {
|
|
379
579
|
this.closing = true;
|
|
580
|
+
for (const watcher of this.watchers.values()) watcher.stop();
|
|
581
|
+
this.watchers.clear();
|
|
380
582
|
const sessions = [...this.sessions.values()];
|
|
381
583
|
this.sessions.clear();
|
|
382
584
|
const pending = new Set([...sessions.filter((s) => s.status === 'running'), ...this.exiting]);
|
|
@@ -393,6 +595,17 @@ export class SessionManager extends EventEmitter {
|
|
|
393
595
|
}
|
|
394
596
|
}
|
|
395
597
|
|
|
598
|
+
/** Show a herdr card the agents herdr lists, and drop the ones it no longer lists. */
|
|
599
|
+
function showHerdrAgents(session, agents) {
|
|
600
|
+
if (session.status !== 'running') return;
|
|
601
|
+
const reports = herdrAgentReports(agents);
|
|
602
|
+
const listed = new Set(reports.map((report) => report.agentId));
|
|
603
|
+
for (const id of [...session.agents.keys()]) if (id.startsWith('herdr:') && !listed.has(id)) session.reportAgent({ agentId: id, remove: true }, 'herdr');
|
|
604
|
+
for (const report of reports) {
|
|
605
|
+
try { session.reportAgent(report, 'herdr'); } catch { /* more agents than a card holds */ }
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
396
609
|
/** The model named by a --model, --model=, or -m argument, or null. */
|
|
397
610
|
export function modelFromArgs(args) {
|
|
398
611
|
for (let i = 0; i < args.length; i++) {
|
package/src/manager/session.mjs
CHANGED
|
@@ -62,6 +62,8 @@ export class Session extends EventEmitter {
|
|
|
62
62
|
* @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, "clone" for a GitHub clone, else null
|
|
63
63
|
* @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
|
|
64
64
|
* @param {{repo: string, path: string, accountId: number}|null} [opts.clone] what a clone session clones, and where
|
|
65
|
+
* @param {{label: string, attach: string, reattachable: boolean}|null} [opts.multiplexer] the multiplexer a Shell session runs in, and the command that reattaches it
|
|
66
|
+
* @param {string} [opts.createdAt] when the card was first made; a restored tmux or herdr card keeps its own. With no spawnSpec, the session is such a card, closed
|
|
65
67
|
* @param {string} opts.reportToken
|
|
66
68
|
* @param {number} [opts.scrollback]
|
|
67
69
|
* @param {number} [opts.activityIdleMs]
|
|
@@ -77,6 +79,7 @@ export class Session extends EventEmitter {
|
|
|
77
79
|
this.task = opts.task ?? null;
|
|
78
80
|
this.account = opts.account ?? null;
|
|
79
81
|
this.clone = opts.clone ?? null;
|
|
82
|
+
this.multiplexer = opts.multiplexer ?? null;
|
|
80
83
|
this.cwd = opts.cwd;
|
|
81
84
|
this.cols = opts.cols;
|
|
82
85
|
this.rows = opts.rows;
|
|
@@ -92,7 +95,9 @@ export class Session extends EventEmitter {
|
|
|
92
95
|
this.shells = new Map();
|
|
93
96
|
this._shellSeq = 0;
|
|
94
97
|
this._endedTasks = new Set();
|
|
95
|
-
this.createdAt = new Date().toISOString();
|
|
98
|
+
this.createdAt = opts.createdAt ?? new Date().toISOString();
|
|
99
|
+
/** When the current process started; later than createdAt once a multiplexer session is reattached. */
|
|
100
|
+
this.startedAt = this.createdAt;
|
|
96
101
|
this.exitedAt = null;
|
|
97
102
|
this.status = 'running';
|
|
98
103
|
this.exitCode = null;
|
|
@@ -144,21 +149,50 @@ export class Session extends EventEmitter {
|
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
this.disposed = false;
|
|
152
|
+
this.env = opts.env;
|
|
153
|
+
if (!opts.spawnSpec) {
|
|
154
|
+
// A tmux or herdr card brought back after the manager restarted: its
|
|
155
|
+
// multiplexer still runs the session, and the card is closed until reattached.
|
|
156
|
+
this.status = 'exited';
|
|
157
|
+
this.exitedAt = new Date().toISOString();
|
|
158
|
+
this.activity = 'quiet';
|
|
159
|
+
this._resolveExited();
|
|
160
|
+
const label = this.multiplexer?.label ?? 'The multiplexer';
|
|
161
|
+
this.term.write(`\x1b[2m[Agent Guild restarted. ${label} still runs this session; Reattach it from its card.]\x1b[0m\r\n`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
147
164
|
try {
|
|
148
|
-
this.
|
|
149
|
-
name: 'xterm-256color',
|
|
150
|
-
cols: this.cols,
|
|
151
|
-
rows: this.rows,
|
|
152
|
-
cwd: this.cwd,
|
|
153
|
-
env: opts.env,
|
|
154
|
-
useConpty: true,
|
|
155
|
-
});
|
|
165
|
+
this._start(opts.spawnSpec);
|
|
156
166
|
} catch (err) {
|
|
157
167
|
this.term.dispose();
|
|
158
168
|
throw err;
|
|
159
169
|
}
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_start(spawnSpec, cwd = this.cwd) {
|
|
173
|
+
const proc = pty.spawn(spawnSpec.file, spawnSpec.args, {
|
|
174
|
+
name: 'xterm-256color',
|
|
175
|
+
cols: this.cols,
|
|
176
|
+
rows: this.rows,
|
|
177
|
+
cwd,
|
|
178
|
+
env: this.env,
|
|
179
|
+
useConpty: true,
|
|
180
|
+
});
|
|
181
|
+
this.pty = proc;
|
|
182
|
+
proc.onData((data) => { if (this.pty === proc) this._onData(data); });
|
|
183
|
+
proc.onExit(({ exitCode, signal }) => { if (this.pty === proc) this._onExit(exitCode, signal); });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run a new process in an exited session, keeping its id, report token and
|
|
188
|
+
* screen: a multiplexer's client attaching to its session again, in `cwd`.
|
|
189
|
+
*/
|
|
190
|
+
reattach(spawnSpec, { cwd = this.cwd } = {}) {
|
|
191
|
+
if (this.disposed || this.status !== 'exited') throw Object.assign(new Error('only an exited session can be reattached'), { status: 409 });
|
|
192
|
+
this._start(spawnSpec, cwd);
|
|
193
|
+
Object.assign(this, { status: 'running', exitCode: null, signal: null, exitedAt: null, activity: 'quiet', startedAt: new Date().toISOString() });
|
|
194
|
+
this.exited = new Promise((resolve) => { this._resolveExited = resolve; });
|
|
195
|
+
this._changed();
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
/**
|
|
@@ -307,9 +341,11 @@ export class Session extends EventEmitter {
|
|
|
307
341
|
|
|
308
342
|
/**
|
|
309
343
|
* End the process. On macOS and Linux: hang-up first, force after a grace
|
|
310
|
-
* period. On Windows: end the whole process tree at once
|
|
311
|
-
*
|
|
312
|
-
*
|
|
344
|
+
* period. On Windows: end the whole process tree at once, except for a
|
|
345
|
+
* multiplexer's client, whose server is its child there and must outlive
|
|
346
|
+
* it. node-pty's own Windows kill first asks a helper process for the
|
|
347
|
+
* console's process list, and when that helper fails it waits a fixed
|
|
348
|
+
* five seconds.
|
|
313
349
|
*/
|
|
314
350
|
kill({ graceMs = this.killGraceMs } = {}) {
|
|
315
351
|
if (this.status !== 'running') return;
|
|
@@ -330,7 +366,7 @@ export class Session extends EventEmitter {
|
|
|
330
366
|
};
|
|
331
367
|
const pid = this.pty.pid;
|
|
332
368
|
if (!pid) return fallback();
|
|
333
|
-
killWindowsTree(pid, (err) => { if (err) fallback(); });
|
|
369
|
+
killWindowsTree(pid, (err) => { if (err) fallback(); }, { tree: !this.multiplexer });
|
|
334
370
|
}
|
|
335
371
|
|
|
336
372
|
/**
|
|
@@ -682,6 +718,7 @@ export class Session extends EventEmitter {
|
|
|
682
718
|
task: this.task,
|
|
683
719
|
account: this.account,
|
|
684
720
|
clone: this.clone,
|
|
721
|
+
multiplexer: this.multiplexer,
|
|
685
722
|
pid: this.pid,
|
|
686
723
|
status: this.status,
|
|
687
724
|
exitCode: this.exitCode,
|
|
@@ -689,6 +726,7 @@ export class Session extends EventEmitter {
|
|
|
689
726
|
activity: this.activity,
|
|
690
727
|
lastOutputAt: this.lastOutputAt ? new Date(this.lastOutputAt).toISOString() : null,
|
|
691
728
|
createdAt: this.createdAt,
|
|
729
|
+
startedAt: this.startedAt,
|
|
692
730
|
exitedAt: this.exitedAt,
|
|
693
731
|
cols: this.cols,
|
|
694
732
|
rows: this.rows,
|
package/src/manager/shells.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
6
7
|
import { resolveCommand } from './command-resolver.mjs';
|
|
7
8
|
|
|
8
9
|
const UNIX_SHELLS = [
|
|
@@ -12,10 +13,34 @@ const UNIX_SHELLS = [
|
|
|
12
13
|
{ id: 'pwsh', label: 'PowerShell' },
|
|
13
14
|
];
|
|
14
15
|
|
|
16
|
+
// Terminal multiplexers, offered after the shells. A session only ever ends
|
|
17
|
+
// its client: the multiplexer's own session keeps running after the card
|
|
18
|
+
// stops, so the card can attach to it again. {name} is that session's name.
|
|
19
|
+
// A tmux card attaches to a tmux session made for it first (tmuxNewSession),
|
|
20
|
+
// so the card's identity reaches that session only; that needs tmux 3.2.
|
|
21
|
+
// tmux gets -u because the page's terminal is always UTF-8, whatever locale
|
|
22
|
+
// the manager inherited; without it tmux draws other characters as "_".
|
|
23
|
+
// A herdr card attaches to herdr's own persistent session; herdr runs on
|
|
24
|
+
// Windows too, tmux does not.
|
|
25
|
+
const TMUX = { id: 'tmux', label: 'tmux', args: ['-u', 'attach-session', '-t', '={name}'], multiplexer: { attach: 'tmux attach -t {name}' } };
|
|
26
|
+
const HERDR = { id: 'herdr', label: 'herdr', args: [], multiplexer: { attach: 'herdr' } };
|
|
27
|
+
|
|
15
28
|
function isFile(file) {
|
|
16
29
|
try { return fs.statSync(file).isFile(); } catch { return false; }
|
|
17
30
|
}
|
|
18
31
|
|
|
32
|
+
/** The output of `<file> -V`, or '' when it cannot run. */
|
|
33
|
+
function readVersion(file) {
|
|
34
|
+
try { return execFileSync(file, ['-V'], { encoding: 'utf8', timeout: 5000, windowsHide: true }); } catch { return ''; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `tmux -V` names 3.2 or later. A build without a number (master, a BSD's own) counts as recent. */
|
|
38
|
+
export function tmuxSupported(versionText) {
|
|
39
|
+
if (!/^tmux /.test(versionText)) return false;
|
|
40
|
+
const [, major, minor] = versionText.match(/(\d+)\.(\d+)/) ?? [];
|
|
41
|
+
return major === undefined || Number(major) > 3 || (Number(major) === 3 && Number(minor) >= 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
function envValue(env, name) {
|
|
20
45
|
const key = Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
|
|
21
46
|
return key ? env[key] : undefined;
|
|
@@ -45,7 +70,16 @@ export function fallbackShell(env, platform) {
|
|
|
45
70
|
return env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
/** Add the installed multiplexers after the shells, unless a shell already has their id. */
|
|
74
|
+
function withMultiplexers(shells, candidates, resolve, version) {
|
|
75
|
+
for (const { id, label, args, multiplexer } of candidates) {
|
|
76
|
+
const found = !shells.some((shell) => shell.id === id) && resolve(id);
|
|
77
|
+
if (found && (id !== 'tmux' || tmuxSupported(version(found)))) shells.push({ id, label, path: found, args, env: {}, multiplexer });
|
|
78
|
+
}
|
|
79
|
+
return shells;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function windowsShells(env, resolve, exists, version) {
|
|
49
83
|
const installed = ['ProgramFiles', 'ProgramW6432'].map((name) => envValue(env, name)).filter(Boolean).map((base) => path.win32.join(base, 'PowerShell', '7', 'pwsh.exe'));
|
|
50
84
|
const pwsh = resolve('pwsh') || installed.find(exists) || null;
|
|
51
85
|
const gitBash = findGitBash(env, resolve, exists);
|
|
@@ -56,10 +90,11 @@ function windowsShells(env, resolve, exists) {
|
|
|
56
90
|
// CHERE_INVOKING keeps a login bash in the session's folder instead of moving to HOME.
|
|
57
91
|
gitBash && { id: 'git-bash', label: 'Git Bash', path: gitBash, args: ['--login', '-i'], env: { CHERE_INVOKING: '1' } },
|
|
58
92
|
].filter((shell) => shell?.path);
|
|
59
|
-
|
|
93
|
+
const defaultId = shells[0]?.id ?? null;
|
|
94
|
+
return { shells: withMultiplexers(shells, [HERDR], resolve, version), defaultId };
|
|
60
95
|
}
|
|
61
96
|
|
|
62
|
-
function unixShells(env, platform, resolve) {
|
|
97
|
+
function unixShells(env, platform, resolve, version) {
|
|
63
98
|
const name = (file) => path.posix.basename(file);
|
|
64
99
|
const own = resolve(env.SHELL || '') || resolve(fallbackShell({}, platform));
|
|
65
100
|
const shells = [];
|
|
@@ -72,11 +107,32 @@ function unixShells(env, platform, resolve) {
|
|
|
72
107
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(defaultId)) defaultId = 'login';
|
|
73
108
|
shells.unshift({ id: defaultId, label: name(own), path: own, args: [], env: {} });
|
|
74
109
|
}
|
|
75
|
-
|
|
110
|
+
defaultId ??= shells[0]?.id ?? null;
|
|
111
|
+
return { shells: withMultiplexers(shells, [TMUX, HERDR], resolve, version), defaultId };
|
|
76
112
|
}
|
|
77
113
|
|
|
78
|
-
/** The installed shells, as { id, label, path, args, env }, and the id of the default one. */
|
|
79
|
-
export function detectShells(env = process.env, platform = process.platform, { exists = isFile, resolve: resolveWith = resolveCommand } = {}) {
|
|
114
|
+
/** The installed shells, as { id, label, path, args, env, multiplexer? }, and the id of the default one. */
|
|
115
|
+
export function detectShells(env = process.env, platform = process.platform, { exists = isFile, resolve: resolveWith = resolveCommand, version = readVersion } = {}) {
|
|
80
116
|
const resolve = (command) => resolveWith(command, env, platform);
|
|
81
|
-
return platform === 'win32' ? windowsShells(env, resolve, exists) : unixShells(env, platform, resolve);
|
|
117
|
+
return platform === 'win32' ? windowsShells(env, resolve, exists, version) : unixShells(env, platform, resolve, version);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Quote one word for tmux's command parser: nothing inside single quotes is expanded. `code` names the field a line break came from. */
|
|
121
|
+
function tmuxQuote(value, code = 'bad_request') {
|
|
122
|
+
const text = String(value);
|
|
123
|
+
if (/[\r\n]/.test(text)) throw Object.assign(new Error('tmux cannot take a value with a line break'), { status: 400, code });
|
|
124
|
+
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The tmux commands, read from stdin, that make a card's own tmux session:
|
|
129
|
+
* detached, `cols` by `rows`, in `cwd`, with `env` as that session's own
|
|
130
|
+
* environment and `args`, if any, as its command. Stdin keeps the card's
|
|
131
|
+
* report token off the command line, where other users could read it.
|
|
132
|
+
*/
|
|
133
|
+
export function tmuxNewSession({ name, cwd, cols, rows, env, args = [] }) {
|
|
134
|
+
const words = ['new-session', '-d', '-s', tmuxQuote(name), '-x', String(cols), '-y', String(rows), '-c', tmuxQuote(cwd, 'bad_cwd')];
|
|
135
|
+
for (const [key, value] of Object.entries(env)) words.push('-e', tmuxQuote(`${key}=${value}`));
|
|
136
|
+
for (const arg of args) words.push(tmuxQuote(arg, 'bad_args'));
|
|
137
|
+
return `${words.join(' ')}\n`;
|
|
82
138
|
}
|
package/web/app.js
CHANGED
|
@@ -580,7 +580,9 @@ function renderShells(card, provider) {
|
|
|
580
580
|
const chip = host.children[i];
|
|
581
581
|
chip.setAttribute('aria-selected', String(shell.id === selected));
|
|
582
582
|
chip.textContent = shell.label;
|
|
583
|
-
chip.title =
|
|
583
|
+
chip.title = shell.multiplexer
|
|
584
|
+
? `Start new sessions inside ${shell.label}. Stopping or closing one detaches it; it keeps running in ${shell.label}.\n${shell.path}`
|
|
585
|
+
: `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
|
|
584
586
|
});
|
|
585
587
|
}
|
|
586
588
|
|
|
@@ -2689,6 +2691,8 @@ function accountLabel(s) {
|
|
|
2689
2691
|
|
|
2690
2692
|
function statusText(s) {
|
|
2691
2693
|
if (s.status === 'exited') {
|
|
2694
|
+
// A multiplexer's client exits 1 when detached by Stop: its exit code tells the user nothing.
|
|
2695
|
+
if (s.multiplexer) return 'Closed';
|
|
2692
2696
|
if (s.signal) return `Exited (${s.signal})`;
|
|
2693
2697
|
return s.exitCode === 0 || s.exitCode === null ? 'Exited' : `Exited (${s.exitCode})`;
|
|
2694
2698
|
}
|
|
@@ -2732,8 +2736,24 @@ function resumable(s) {
|
|
|
2732
2736
|
&& !runningOn(s.provider.id, s.account?.id ?? 'default', id));
|
|
2733
2737
|
}
|
|
2734
2738
|
|
|
2739
|
+
/** A stopped tmux or herdr card whose multiplexer still has its session. */
|
|
2740
|
+
function reattachable(s) {
|
|
2741
|
+
return Boolean(s.status === 'exited' && s.multiplexer?.reattachable);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
async function reattachSession(id) {
|
|
2745
|
+
try {
|
|
2746
|
+
upsertSession((await api('POST', `/sessions/${id}/reattach`)).session);
|
|
2747
|
+
openPanel(id);
|
|
2748
|
+
} catch (err) {
|
|
2749
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
2750
|
+
toast(err.message, 8000);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2735
2754
|
function resumeCard(id) {
|
|
2736
2755
|
const s = state.sessions.get(id);
|
|
2756
|
+
if (s && reattachable(s)) return reattachSession(id);
|
|
2737
2757
|
if (!s || !resumable(s)) return;
|
|
2738
2758
|
const provider = state.providers.find((p) => p.id === s.provider.id);
|
|
2739
2759
|
const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
|
|
@@ -2778,14 +2798,20 @@ function updateCard(node, s) {
|
|
|
2778
2798
|
renderAgents(node.querySelector('.agents'), s.agents, s.shells || []);
|
|
2779
2799
|
paintReporting(node.querySelector('.agents-row'), s);
|
|
2780
2800
|
node.classList.toggle('exited', s.status === 'exited');
|
|
2781
|
-
node.querySelector('.stop')
|
|
2801
|
+
const stop = node.querySelector('.stop');
|
|
2802
|
+
stop.hidden = s.status !== 'running';
|
|
2803
|
+
stop.textContent = s.multiplexer ? 'Detach' : 'Stop';
|
|
2804
|
+
stop.title = s.multiplexer ? `Detach from ${s.multiplexer.label}; it keeps running. Reattach with: ${s.multiplexer.attach}` : '';
|
|
2782
2805
|
node.querySelector('.remove').hidden = s.status === 'running';
|
|
2783
2806
|
const useButton = node.querySelector('.use-folder');
|
|
2784
2807
|
useButton.hidden = !clonedPath(s);
|
|
2785
2808
|
useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
|
|
2786
2809
|
const resume = node.querySelector('.resume');
|
|
2787
|
-
resume.hidden = !resumable(s);
|
|
2788
|
-
resume.
|
|
2810
|
+
resume.hidden = !resumable(s) && !reattachable(s);
|
|
2811
|
+
resume.textContent = s.multiplexer ? 'Reattach' : 'Resume';
|
|
2812
|
+
resume.title = s.multiplexer
|
|
2813
|
+
? `Attach this card to its ${s.multiplexer.label} session again, as ${s.multiplexer.attach} would`
|
|
2814
|
+
: `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
|
|
2789
2815
|
const modelLabel = s.model ? `, model ${modelText(s)}` : '';
|
|
2790
2816
|
const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
|
|
2791
2817
|
const shellCount = (s.shells || []).length;
|
|
@@ -2837,6 +2863,11 @@ function guardLeaving() {
|
|
|
2837
2863
|
}
|
|
2838
2864
|
|
|
2839
2865
|
function upsertSession(session) {
|
|
2866
|
+
// An exit is final for the process it ended. A reply sent before it, such as Stop's, can
|
|
2867
|
+
// arrive after the exit was announced and must not show the session running again; a
|
|
2868
|
+
// reattached tmux or herdr session runs a newer process, started after that exit.
|
|
2869
|
+
const known = state.sessions.get(session.id);
|
|
2870
|
+
if (known?.status === 'exited' && session.status !== 'exited' && !(Date.parse(session.startedAt) > Date.parse(known.exitedAt))) return;
|
|
2840
2871
|
state.sessions.set(session.id, session);
|
|
2841
2872
|
renderSessions();
|
|
2842
2873
|
noticeClone(session);
|
|
@@ -2850,17 +2881,25 @@ function dropSession(id) {
|
|
|
2850
2881
|
renderSessions();
|
|
2851
2882
|
}
|
|
2852
2883
|
|
|
2884
|
+
/** What happens to a running session when it is stopped, for the confirmation. */
|
|
2885
|
+
function stopNote(s) {
|
|
2886
|
+
const mux = s.multiplexer;
|
|
2887
|
+
return mux ? `${mux.label} keeps running it. Reattach it from its card, or in a terminal with: ${mux.attach}` : `The ${s.provider.tool} process will be ended.`;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2853
2890
|
async function stopSession(id) {
|
|
2854
2891
|
const s = state.sessions.get(id);
|
|
2855
2892
|
if (!s || s.status !== 'running') return;
|
|
2856
|
-
if (!confirm(
|
|
2893
|
+
if (!confirm(`${s.multiplexer ? 'Detach' : 'Stop'} "${s.name}"? ${stopNote(s)}`)) return;
|
|
2857
2894
|
try { upsertSession((await api('POST', `/sessions/${id}/stop`)).session); } catch (err) { toast(err.message); }
|
|
2858
2895
|
}
|
|
2859
2896
|
|
|
2860
2897
|
async function removeSession(id) {
|
|
2861
2898
|
const s = state.sessions.get(id);
|
|
2862
2899
|
if (!s) return;
|
|
2863
|
-
|
|
2900
|
+
// The card goes, so only the terminal can reattach it.
|
|
2901
|
+
const question = s.multiplexer ? `Detach it and remove it? ${s.multiplexer.label} keeps running it. Reattach it in a terminal with: ${s.multiplexer.attach}` : 'End it and remove it?';
|
|
2902
|
+
if (s.status === 'running' && !confirm(`"${s.name}" is still running. ${question}`)) return;
|
|
2864
2903
|
try { await api('DELETE', `/sessions/${id}`); dropSession(id); } catch (err) { toast(err.message); }
|
|
2865
2904
|
}
|
|
2866
2905
|
|
|
@@ -3131,6 +3170,23 @@ function suppressQueryReplies(term) {
|
|
|
3131
3170
|
}
|
|
3132
3171
|
}
|
|
3133
3172
|
|
|
3173
|
+
/** The text an OSC 52 sequence ("c;<base64>") asks to copy, or null for a clipboard query or anything unreadable. */
|
|
3174
|
+
function osc52Text(data) {
|
|
3175
|
+
const payload = data.slice(data.indexOf(';') + 1);
|
|
3176
|
+
if (!data.includes(';') || !payload || payload === '?' || payload.length > 4 * 1024 * 1024) return null;
|
|
3177
|
+
try {
|
|
3178
|
+
return new TextDecoder().decode(Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)));
|
|
3179
|
+
} catch {
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/** The line a terminal shows when its process ends. A tmux or herdr client's exit code says nothing about the session it showed. */
|
|
3185
|
+
function exitLine(s, { exitCode, signal }) {
|
|
3186
|
+
if (s?.multiplexer) return '[closed]';
|
|
3187
|
+
return `[process exited with ${signal ? `signal ${signal}` : `code ${exitCode ?? 0}`}]`;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3134
3190
|
class TerminalView {
|
|
3135
3191
|
constructor(sessionId) {
|
|
3136
3192
|
this.id = sessionId;
|
|
@@ -3141,6 +3197,8 @@ class TerminalView {
|
|
|
3141
3197
|
fontSize: coarsePointer.matches ? 14 : 13,
|
|
3142
3198
|
scrollback: 5000,
|
|
3143
3199
|
macOptionIsMeta: true,
|
|
3200
|
+
// Option-drag selects text even while a program reads the mouse, as Shift-drag does elsewhere.
|
|
3201
|
+
macOptionClickForcesSelection: true,
|
|
3144
3202
|
theme: TERMINAL_THEME,
|
|
3145
3203
|
});
|
|
3146
3204
|
this.fit = new window.FitAddon.FitAddon();
|
|
@@ -3148,6 +3206,13 @@ class TerminalView {
|
|
|
3148
3206
|
this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
|
|
3149
3207
|
this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
|
|
3150
3208
|
suppressQueryReplies(this.term);
|
|
3209
|
+
// Programs copy with OSC 52: tmux's copy mode, and herdr where it cannot reach the
|
|
3210
|
+
// system clipboard itself. Only the open terminal may write it; nothing reads it.
|
|
3211
|
+
this.term.parser.registerOscHandler(52, (data) => {
|
|
3212
|
+
const text = osc52Text(data);
|
|
3213
|
+
if (text !== null && state.activeId === this.id) navigator.clipboard?.writeText(text).catch(() => {});
|
|
3214
|
+
return true;
|
|
3215
|
+
});
|
|
3151
3216
|
this.term.onData((data) => {
|
|
3152
3217
|
this.send({ type: 'input', data });
|
|
3153
3218
|
});
|
|
@@ -3198,11 +3263,9 @@ class TerminalView {
|
|
|
3198
3263
|
case 'data':
|
|
3199
3264
|
this.term.write(msg.data);
|
|
3200
3265
|
break;
|
|
3201
|
-
case 'exit':
|
|
3202
|
-
|
|
3203
|
-
this.term.write(`\r\n\x1b[2m[process exited with ${how}]\x1b[0m\r\n`);
|
|
3266
|
+
case 'exit':
|
|
3267
|
+
this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
|
|
3204
3268
|
break;
|
|
3205
|
-
}
|
|
3206
3269
|
default:
|
|
3207
3270
|
break;
|
|
3208
3271
|
}
|
|
@@ -3320,7 +3383,7 @@ function updatePanel() {
|
|
|
3320
3383
|
$('panel-sub').title = modelTitle(s);
|
|
3321
3384
|
renderAgents($('panel-agents'), s.agents, s.shells || []);
|
|
3322
3385
|
const stop = $('panel-stop');
|
|
3323
|
-
stop.textContent = s.status
|
|
3386
|
+
stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
|
|
3324
3387
|
}
|
|
3325
3388
|
|
|
3326
3389
|
// ---- stopping the manager -------------------------------------------------
|