akemon 0.3.6 → 0.3.7
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/DATA_POLICY.md +11 -3
- package/README.md +133 -21
- package/dist/akemon-home.js +56 -0
- package/dist/akemon-message.js +107 -0
- package/dist/best-effort.js +8 -0
- package/dist/cli.js +1188 -100
- package/dist/cognitive-artifact-store.js +101 -0
- package/dist/cognitive-event-log.js +47 -0
- package/dist/config.js +45 -9
- package/dist/context.js +27 -6
- package/dist/core/contracts/layers.js +1 -0
- package/dist/core/contracts/permission.js +1 -0
- package/dist/core/contracts/workspace.js +1 -0
- package/dist/core-cognitive-module.js +768 -0
- package/dist/engine-peripheral.js +127 -26
- package/dist/engine-routing.js +58 -17
- package/dist/interactive-session.js +361 -0
- package/dist/local-interconnect.js +156 -0
- package/dist/local-registry.js +178 -0
- package/dist/mcp-server.js +4 -1
- package/dist/memory-proposal.js +379 -0
- package/dist/memory-recorder.js +368 -0
- package/dist/orphan-scan.js +36 -24
- package/dist/passive-reflection-cognitive-module.js +172 -0
- package/dist/peripheral-registry.js +235 -0
- package/dist/permission-audit.js +132 -0
- package/dist/relay-client.js +68 -9
- package/dist/relay-mode.js +34 -0
- package/dist/relay-peripheral.js +139 -49
- package/dist/runtime-platform.js +122 -0
- package/dist/secretariat/client.js +87 -0
- package/dist/self.js +15 -6
- package/dist/server.js +3675 -512
- package/dist/social-discovery.js +231 -0
- package/dist/software-agent-peripheral.js +185 -244
- package/dist/software-agent-transport.js +177 -0
- package/dist/task-module.js +243 -0
- package/dist/task-registry.js +756 -0
- package/dist/vendor/xterm/addon-fit.js +2 -0
- package/dist/vendor/xterm/addon-search.js +2 -0
- package/dist/vendor/xterm/addon-web-links.js +2 -0
- package/dist/vendor/xterm/xterm.css +285 -0
- package/dist/vendor/xterm/xterm.js +2 -0
- package/dist/work-memory.js +59 -15
- package/dist/workbench-peripheral-guide.js +79 -0
- package/dist/workbench-session.js +1074 -0
- package/dist/workbench.html +4011 -0
- package/package.json +8 -3
- package/scripts/build.cjs +24 -0
- package/scripts/check-architecture-baseline.cjs +68 -0
- package/scripts/test.cjs +38 -0
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
3
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
|
|
4
|
+
import { isAbsolute, join, relative, resolve as resolvePath } from "path";
|
|
5
|
+
import { StreamingRedactor, redactSecrets } from "./redaction.js";
|
|
6
|
+
import { getRuntimePlatform } from "./runtime-platform.js";
|
|
7
|
+
import { logBestEffortError } from "./best-effort.js";
|
|
8
|
+
import { resolveWorkdirSafety } from "./software-agent-peripheral.js";
|
|
9
|
+
const DEFAULT_MAX_SESSIONS = 3;
|
|
10
|
+
const DEFAULT_MAX_COMPLETED_SESSIONS = 20;
|
|
11
|
+
const DEFAULT_MAX_BUFFER_CHARS = 200_000;
|
|
12
|
+
const DEFAULT_FORCE_KILL_AFTER_MS = 3_000;
|
|
13
|
+
const DEFAULT_COLS = 100;
|
|
14
|
+
const DEFAULT_ROWS = 30;
|
|
15
|
+
const TMUX_CAPTURE_BUFFER_MULTIPLIER = 4;
|
|
16
|
+
const TMUX_CAPTURE_MIN_BUFFER_BYTES = 1_000_000;
|
|
17
|
+
const TMUX_CAPTURE_MAX_BUFFER_BYTES = 16_000_000;
|
|
18
|
+
const LOG_TAIL_READ_MULTIPLIER = 8;
|
|
19
|
+
const LOG_TAIL_MIN_READ_BYTES = 256_000;
|
|
20
|
+
const LOG_TAIL_MAX_READ_BYTES = 4_000_000;
|
|
21
|
+
const SPLIT_SUBMIT_DELAY_MS = 35;
|
|
22
|
+
const DEFAULT_TUI_INPUT_TOOLS = new Set(["codex", "cursor"]);
|
|
23
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
24
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
25
|
+
const DEFAULT_WORKBENCH_ENV_ALLOWLIST = [
|
|
26
|
+
"CI",
|
|
27
|
+
"COLORTERM",
|
|
28
|
+
"COMSPEC",
|
|
29
|
+
"ComSpec",
|
|
30
|
+
"HOME",
|
|
31
|
+
"HOMEDRIVE",
|
|
32
|
+
"HOMEPATH",
|
|
33
|
+
"LANG",
|
|
34
|
+
"LC_ALL",
|
|
35
|
+
"LC_CTYPE",
|
|
36
|
+
"LOCALAPPDATA",
|
|
37
|
+
"LOGNAME",
|
|
38
|
+
"PATH",
|
|
39
|
+
"Path",
|
|
40
|
+
"PATHEXT",
|
|
41
|
+
"PROGRAMFILES",
|
|
42
|
+
"ProgramFiles",
|
|
43
|
+
"PROGRAMFILES(X86)",
|
|
44
|
+
"ProgramFiles(x86)",
|
|
45
|
+
"PSModulePath",
|
|
46
|
+
"SHELL",
|
|
47
|
+
"SystemDrive",
|
|
48
|
+
"SystemRoot",
|
|
49
|
+
"TEMP",
|
|
50
|
+
"TERM",
|
|
51
|
+
"TMP",
|
|
52
|
+
"TMPDIR",
|
|
53
|
+
"USER",
|
|
54
|
+
"USERDOMAIN",
|
|
55
|
+
"USERNAME",
|
|
56
|
+
"USERPROFILE",
|
|
57
|
+
"WINDIR",
|
|
58
|
+
"windir",
|
|
59
|
+
];
|
|
60
|
+
const DIRECT_PTY_WORKBENCH_BACKEND = {
|
|
61
|
+
kind: "direct-pty",
|
|
62
|
+
recoverable: false,
|
|
63
|
+
async start(request) {
|
|
64
|
+
return request.ptyFactory(request.record.command, request.record.args, {
|
|
65
|
+
name: "xterm-256color",
|
|
66
|
+
cols: request.cols,
|
|
67
|
+
rows: request.rows,
|
|
68
|
+
cwd: request.cwd,
|
|
69
|
+
env: request.env,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
stop(session, mode, runtime) {
|
|
73
|
+
killPtyProcess(session.pty, mode, runtime);
|
|
74
|
+
},
|
|
75
|
+
detach(session, runtime) {
|
|
76
|
+
killPtyProcess(session.pty, "term", runtime);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const TMUX_WORKBENCH_BACKEND = {
|
|
80
|
+
kind: "tmux",
|
|
81
|
+
recoverable: true,
|
|
82
|
+
async start(request) {
|
|
83
|
+
if (request.checkBackendAvailability)
|
|
84
|
+
assertTmuxAvailable();
|
|
85
|
+
if (!request.record.backendSessionId) {
|
|
86
|
+
throw new Error("tmux Workbench sessions require backendSessionId");
|
|
87
|
+
}
|
|
88
|
+
return request.ptyFactory("tmux", [
|
|
89
|
+
"new-session",
|
|
90
|
+
"-A",
|
|
91
|
+
"-s",
|
|
92
|
+
request.record.backendSessionId,
|
|
93
|
+
"-c",
|
|
94
|
+
request.cwd,
|
|
95
|
+
"--",
|
|
96
|
+
request.record.command,
|
|
97
|
+
...request.record.args,
|
|
98
|
+
], {
|
|
99
|
+
name: "xterm-256color",
|
|
100
|
+
cols: request.cols,
|
|
101
|
+
rows: request.rows,
|
|
102
|
+
cwd: request.cwd,
|
|
103
|
+
env: request.env,
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
async reattach(request) {
|
|
107
|
+
if (request.checkBackendAvailability)
|
|
108
|
+
assertTmuxAvailable();
|
|
109
|
+
if (!request.record.backendSessionId)
|
|
110
|
+
return null;
|
|
111
|
+
if (request.checkBackendAvailability && !hasTmuxSession(request.record.backendSessionId))
|
|
112
|
+
return null;
|
|
113
|
+
return request.ptyFactory("tmux", [
|
|
114
|
+
"attach-session",
|
|
115
|
+
"-t",
|
|
116
|
+
request.record.backendSessionId,
|
|
117
|
+
], {
|
|
118
|
+
name: "xterm-256color",
|
|
119
|
+
cols: request.cols,
|
|
120
|
+
rows: request.rows,
|
|
121
|
+
cwd: request.cwd,
|
|
122
|
+
env: request.env,
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
stop(session, mode, runtime) {
|
|
126
|
+
if (session.record.backendSessionId) {
|
|
127
|
+
try {
|
|
128
|
+
execFileSync("tmux", ["kill-session", "-t", session.record.backendSessionId], { stdio: "ignore" });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
logBestEffortError("workbench tmux kill-session", error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
killPtyProcess(session.pty, mode, runtime);
|
|
135
|
+
},
|
|
136
|
+
detach(session, runtime) {
|
|
137
|
+
killPtyProcess(session.pty, "term", runtime);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
export class WorkbenchSessionManager {
|
|
141
|
+
config;
|
|
142
|
+
sessions = new Map();
|
|
143
|
+
subscribers = new Set();
|
|
144
|
+
readyPromise;
|
|
145
|
+
activeSessionId = "";
|
|
146
|
+
constructor(config) {
|
|
147
|
+
if (config.envAllowlist !== undefined && config.envFilter !== undefined) {
|
|
148
|
+
throw new Error("Workbench env configuration is ambiguous: pass either envAllowlist or envFilter, not both");
|
|
149
|
+
}
|
|
150
|
+
this.config = {
|
|
151
|
+
...config,
|
|
152
|
+
baseWorkdir: resolvePath(config.baseWorkdir),
|
|
153
|
+
maxSessions: config.maxSessions || DEFAULT_MAX_SESSIONS,
|
|
154
|
+
maxCompletedSessions: config.maxCompletedSessions ?? DEFAULT_MAX_COMPLETED_SESSIONS,
|
|
155
|
+
maxBufferChars: config.maxBufferChars || DEFAULT_MAX_BUFFER_CHARS,
|
|
156
|
+
forceKillAfterMs: config.forceKillAfterMs ?? DEFAULT_FORCE_KILL_AFTER_MS,
|
|
157
|
+
sourceEnv: config.sourceEnv || process.env,
|
|
158
|
+
backend: config.backend || resolveDefaultWorkbenchBackend(config),
|
|
159
|
+
};
|
|
160
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
161
|
+
setImmediate(() => {
|
|
162
|
+
this.recoverPersistedSessions().then(resolve, reject);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async ready() {
|
|
167
|
+
await this.readyPromise;
|
|
168
|
+
}
|
|
169
|
+
async startSession(request) {
|
|
170
|
+
await this.readyPromise;
|
|
171
|
+
const runningCount = Array.from(this.sessions.values())
|
|
172
|
+
.filter((session) => session.record.status === "running").length;
|
|
173
|
+
if (runningCount >= this.config.maxSessions) {
|
|
174
|
+
throw new Error(`Workbench session limit reached (${this.config.maxSessions})`);
|
|
175
|
+
}
|
|
176
|
+
const runtime = this.config.runtime || getRuntimePlatform();
|
|
177
|
+
if (!runtime.capabilities.canStartPty) {
|
|
178
|
+
throw new Error(`Workbench PTY sessions are not supported on platform: ${runtime.platform}`);
|
|
179
|
+
}
|
|
180
|
+
const workdirSafety = resolveWorkdirSafety(this.config.baseWorkdir, request.workdir || this.config.baseWorkdir, request.allowOutsideWorkdir === true);
|
|
181
|
+
const command = resolveWorkbenchCommand(request, runtime);
|
|
182
|
+
const factory = await this.resolvePtyFactory();
|
|
183
|
+
const sessionId = `wb_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
184
|
+
const backend = this.resolveBackend(this.config.backend);
|
|
185
|
+
const backendSessionId = backend.kind === "tmux" ? `akemon_${safeWorkbenchSessionFilename(sessionId)}` : undefined;
|
|
186
|
+
const cols = clampTerminalDimension(request.cols, DEFAULT_COLS);
|
|
187
|
+
const rows = clampTerminalDimension(request.rows, DEFAULT_ROWS);
|
|
188
|
+
const inputMode = request.inputMode || defaultWorkbenchInputMode(request.tool);
|
|
189
|
+
const startedAt = new Date().toISOString();
|
|
190
|
+
const absoluteLogPath = join(this.sessionDirectory(), `${safeWorkbenchSessionFilename(sessionId)}.jsonl`);
|
|
191
|
+
const logPath = this.relativeSessionPath(absoluteLogPath);
|
|
192
|
+
const childEnvironment = buildWorkbenchChildEnvironment(this.config.sourceEnv, {
|
|
193
|
+
allowlist: this.config.envAllowlist,
|
|
194
|
+
filter: this.config.envFilter,
|
|
195
|
+
});
|
|
196
|
+
mkdirSync(this.sessionDirectory(), { recursive: true });
|
|
197
|
+
const record = {
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
sessionId,
|
|
200
|
+
label: normalizeOptionalString(request.label),
|
|
201
|
+
tool: request.tool,
|
|
202
|
+
status: "running",
|
|
203
|
+
workdir: workdirSafety.effectiveWorkdir,
|
|
204
|
+
workdirSafety,
|
|
205
|
+
backend: backend.kind,
|
|
206
|
+
backendSessionId,
|
|
207
|
+
recoverable: backend.recoverable,
|
|
208
|
+
command: command.command,
|
|
209
|
+
args: command.args,
|
|
210
|
+
commandLineDisplay: formatCommandLineDisplay(command.command, command.args),
|
|
211
|
+
inputMode,
|
|
212
|
+
cols,
|
|
213
|
+
rows,
|
|
214
|
+
startedAt,
|
|
215
|
+
updatedAt: startedAt,
|
|
216
|
+
logPath,
|
|
217
|
+
transmittedEnvKeys: childEnvironment.transmittedEnvKeys,
|
|
218
|
+
ownerDirectInputCount: 0,
|
|
219
|
+
ownerDirectInputBytes: 0,
|
|
220
|
+
outputChars: 0,
|
|
221
|
+
};
|
|
222
|
+
let ptyProcess;
|
|
223
|
+
try {
|
|
224
|
+
ptyProcess = await backend.start({
|
|
225
|
+
record,
|
|
226
|
+
ptyFactory: factory,
|
|
227
|
+
checkBackendAvailability: !this.config.ptyFactory,
|
|
228
|
+
cols,
|
|
229
|
+
rows,
|
|
230
|
+
cwd: workdirSafety.effectiveWorkdir,
|
|
231
|
+
env: childEnvironment.env,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
throw new Error(`Failed to start Workbench session: ${err.message || String(err)}`);
|
|
236
|
+
}
|
|
237
|
+
const session = {
|
|
238
|
+
record,
|
|
239
|
+
pty: ptyProcess,
|
|
240
|
+
backend,
|
|
241
|
+
rollingBuffer: "",
|
|
242
|
+
outputRedactor: new StreamingRedactor(),
|
|
243
|
+
};
|
|
244
|
+
this.sessions.set(sessionId, session);
|
|
245
|
+
this.activeSessionId = sessionId;
|
|
246
|
+
this.writeLog(session, {
|
|
247
|
+
type: "start",
|
|
248
|
+
tool: record.tool,
|
|
249
|
+
commandLineDisplay: record.commandLineDisplay,
|
|
250
|
+
inputMode: record.inputMode,
|
|
251
|
+
backend: record.backend,
|
|
252
|
+
recoverable: record.recoverable,
|
|
253
|
+
workdir: record.workdir,
|
|
254
|
+
label: record.label,
|
|
255
|
+
transmittedEnvKeys: record.transmittedEnvKeys,
|
|
256
|
+
});
|
|
257
|
+
this.writeMetadata(record);
|
|
258
|
+
this.emitStreamEvent({ type: "status", sessionId, snapshot: this.snapshot(session) });
|
|
259
|
+
this.attachPtyHandlers(session);
|
|
260
|
+
if (request.initialInput) {
|
|
261
|
+
this.writeInput(sessionId, { input: request.initialInput });
|
|
262
|
+
}
|
|
263
|
+
return this.snapshot(session);
|
|
264
|
+
}
|
|
265
|
+
listSessions() {
|
|
266
|
+
return Array.from(this.sessions.values())
|
|
267
|
+
.sort((left, right) => right.record.startedAt.localeCompare(left.record.startedAt))
|
|
268
|
+
.map((session) => this.snapshot(session));
|
|
269
|
+
}
|
|
270
|
+
getSession(sessionId) {
|
|
271
|
+
const session = this.sessions.get(sessionId);
|
|
272
|
+
return session ? this.snapshot(session) : null;
|
|
273
|
+
}
|
|
274
|
+
getActiveSession() {
|
|
275
|
+
if (!this.activeSessionId)
|
|
276
|
+
return null;
|
|
277
|
+
const session = this.sessions.get(this.activeSessionId);
|
|
278
|
+
if (!session) {
|
|
279
|
+
this.activeSessionId = "";
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return this.snapshot(session);
|
|
283
|
+
}
|
|
284
|
+
getActiveSessionId() {
|
|
285
|
+
return this.getActiveSession()?.sessionId || null;
|
|
286
|
+
}
|
|
287
|
+
setActiveSession(sessionId) {
|
|
288
|
+
const session = this.requireSession(sessionId);
|
|
289
|
+
this.activeSessionId = sessionId;
|
|
290
|
+
this.refreshRecoverableDisplayBuffer(session);
|
|
291
|
+
return this.snapshot(session);
|
|
292
|
+
}
|
|
293
|
+
setInputMode(sessionId, inputMode) {
|
|
294
|
+
const session = this.requireSession(sessionId);
|
|
295
|
+
session.record.inputMode = inputMode;
|
|
296
|
+
session.record.updatedAt = new Date().toISOString();
|
|
297
|
+
this.writeLog(session, {
|
|
298
|
+
type: "input_mode",
|
|
299
|
+
inputMode,
|
|
300
|
+
});
|
|
301
|
+
this.writeMetadata(session.record);
|
|
302
|
+
this.emitStreamEvent({ type: "status", sessionId, snapshot: this.snapshot(session) });
|
|
303
|
+
return this.snapshot(session);
|
|
304
|
+
}
|
|
305
|
+
subscribe(callback) {
|
|
306
|
+
this.subscribers.add(callback);
|
|
307
|
+
return () => {
|
|
308
|
+
this.subscribers.delete(callback);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
writeInput(sessionId, request) {
|
|
312
|
+
const session = this.requireSession(sessionId);
|
|
313
|
+
if (session.record.status !== "running") {
|
|
314
|
+
throw new Error(`Workbench session ${sessionId} is not running`);
|
|
315
|
+
}
|
|
316
|
+
if (!session.pty) {
|
|
317
|
+
throw new Error(`Workbench session ${sessionId} is not attached`);
|
|
318
|
+
}
|
|
319
|
+
if (typeof request.input !== "string" || request.input.length === 0) {
|
|
320
|
+
throw new Error("Missing required string field: input");
|
|
321
|
+
}
|
|
322
|
+
writeInputToPty(session, request.input, request.inputMode);
|
|
323
|
+
session.record.ownerDirectInputCount += 1;
|
|
324
|
+
session.record.ownerDirectInputBytes += Buffer.byteLength(request.input, "utf8");
|
|
325
|
+
session.record.updatedAt = new Date().toISOString();
|
|
326
|
+
this.writeLog(session, {
|
|
327
|
+
type: "owner_input",
|
|
328
|
+
mode: "owner-direct",
|
|
329
|
+
inputMode: request.inputMode || session.record.inputMode,
|
|
330
|
+
summary: summarizeOwnerInput(request.input),
|
|
331
|
+
});
|
|
332
|
+
this.writeMetadata(session.record);
|
|
333
|
+
this.emitStreamEvent({ type: "status", sessionId, snapshot: this.snapshot(session) });
|
|
334
|
+
return this.snapshot(session);
|
|
335
|
+
}
|
|
336
|
+
resizeSession(sessionId, request) {
|
|
337
|
+
const session = this.requireSession(sessionId);
|
|
338
|
+
if (session.record.status !== "running") {
|
|
339
|
+
throw new Error(`Workbench session ${sessionId} is not running`);
|
|
340
|
+
}
|
|
341
|
+
if (!session.pty) {
|
|
342
|
+
throw new Error(`Workbench session ${sessionId} is not attached`);
|
|
343
|
+
}
|
|
344
|
+
const cols = clampTerminalDimension(request.cols, DEFAULT_COLS);
|
|
345
|
+
const rows = clampTerminalDimension(request.rows, DEFAULT_ROWS);
|
|
346
|
+
session.pty.resize(cols, rows);
|
|
347
|
+
session.record.cols = cols;
|
|
348
|
+
session.record.rows = rows;
|
|
349
|
+
session.record.updatedAt = new Date().toISOString();
|
|
350
|
+
this.writeLog(session, { type: "resize", cols, rows });
|
|
351
|
+
this.writeMetadata(session.record);
|
|
352
|
+
this.emitStreamEvent({ type: "status", sessionId, snapshot: this.snapshot(session) });
|
|
353
|
+
return this.snapshot(session);
|
|
354
|
+
}
|
|
355
|
+
stopSession(sessionId) {
|
|
356
|
+
const session = this.requireSession(sessionId);
|
|
357
|
+
if (session.record.status === "running") {
|
|
358
|
+
this.stopRunningSession(session, "owner-request");
|
|
359
|
+
}
|
|
360
|
+
return this.snapshot(session);
|
|
361
|
+
}
|
|
362
|
+
async stopAll() {
|
|
363
|
+
for (const session of this.sessions.values()) {
|
|
364
|
+
if (session.record.status === "running") {
|
|
365
|
+
try {
|
|
366
|
+
this.stopRunningSession(session, "shutdown");
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
logBestEffortError("workbench stop session", error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async shutdown() {
|
|
375
|
+
for (const session of this.sessions.values()) {
|
|
376
|
+
if (session.record.status !== "running")
|
|
377
|
+
continue;
|
|
378
|
+
try {
|
|
379
|
+
if (session.record.recoverable) {
|
|
380
|
+
session.record.updatedAt = new Date().toISOString();
|
|
381
|
+
this.writeLog(session, {
|
|
382
|
+
type: "detach",
|
|
383
|
+
reason: "server-shutdown",
|
|
384
|
+
backend: session.record.backend,
|
|
385
|
+
});
|
|
386
|
+
session.detaching = true;
|
|
387
|
+
session.backend.detach(session, this.config.runtime || getRuntimePlatform());
|
|
388
|
+
session.pty = undefined;
|
|
389
|
+
session.detaching = false;
|
|
390
|
+
this.writeMetadata(session.record);
|
|
391
|
+
this.emitStreamEvent({
|
|
392
|
+
type: "status",
|
|
393
|
+
sessionId: session.record.sessionId,
|
|
394
|
+
snapshot: this.snapshot(session),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
this.stopRunningSession(session, "shutdown");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
logBestEffortError("workbench shutdown session", error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async recoverPersistedSessions() {
|
|
407
|
+
const sessionDir = this.sessionDirectory();
|
|
408
|
+
if (!existsSync(sessionDir))
|
|
409
|
+
return;
|
|
410
|
+
let files;
|
|
411
|
+
try {
|
|
412
|
+
files = readdirSync(sessionDir)
|
|
413
|
+
.filter((file) => file.endsWith(".json"))
|
|
414
|
+
.sort();
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
logBestEffortError("workbench session recovery scan", error);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
let ptyFactory = null;
|
|
421
|
+
let childEnvironment = null;
|
|
422
|
+
const records = [];
|
|
423
|
+
for (const file of files) {
|
|
424
|
+
const record = this.readMetadataFile(join(sessionDir, file));
|
|
425
|
+
if (!record || this.sessions.has(record.sessionId))
|
|
426
|
+
continue;
|
|
427
|
+
records.push(record);
|
|
428
|
+
}
|
|
429
|
+
for (const record of this.selectRecordsForRecovery(records)) {
|
|
430
|
+
const backend = this.resolveBackend(record.backend || "direct-pty");
|
|
431
|
+
const session = {
|
|
432
|
+
record: {
|
|
433
|
+
...record,
|
|
434
|
+
backend: record.backend || "direct-pty",
|
|
435
|
+
recoverable: record.recoverable === true,
|
|
436
|
+
},
|
|
437
|
+
backend,
|
|
438
|
+
rollingBuffer: this.readTailFromLog(record),
|
|
439
|
+
outputRedactor: new StreamingRedactor(),
|
|
440
|
+
};
|
|
441
|
+
this.sessions.set(record.sessionId, session);
|
|
442
|
+
if (session.record.status !== "running")
|
|
443
|
+
continue;
|
|
444
|
+
if (!session.record.recoverable || !backend.reattach) {
|
|
445
|
+
this.markRecoveredSessionFailed(session, "not-recoverable");
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
if (!ptyFactory)
|
|
450
|
+
ptyFactory = await this.resolvePtyFactory();
|
|
451
|
+
if (!childEnvironment) {
|
|
452
|
+
childEnvironment = buildWorkbenchChildEnvironment(this.config.sourceEnv, {
|
|
453
|
+
allowlist: this.config.envAllowlist,
|
|
454
|
+
filter: this.config.envFilter,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const pty = await backend.reattach({
|
|
458
|
+
record: session.record,
|
|
459
|
+
ptyFactory,
|
|
460
|
+
checkBackendAvailability: !this.config.ptyFactory,
|
|
461
|
+
cols: session.record.cols,
|
|
462
|
+
rows: session.record.rows,
|
|
463
|
+
cwd: session.record.workdir,
|
|
464
|
+
env: childEnvironment.env,
|
|
465
|
+
});
|
|
466
|
+
if (!pty) {
|
|
467
|
+
this.markRecoveredSessionFailed(session, "backend-session-not-found");
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
session.pty = pty;
|
|
471
|
+
session.record.updatedAt = new Date().toISOString();
|
|
472
|
+
this.writeLog(session, {
|
|
473
|
+
type: "reattach",
|
|
474
|
+
backend: session.record.backend,
|
|
475
|
+
backendSessionId: session.record.backendSessionId,
|
|
476
|
+
});
|
|
477
|
+
this.writeMetadata(session.record);
|
|
478
|
+
this.attachPtyHandlers(session);
|
|
479
|
+
if (!this.activeSessionId)
|
|
480
|
+
this.activeSessionId = session.record.sessionId;
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
this.markRecoveredSessionFailed(session, error?.message || "reattach-failed");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
this.evictCompletedSessions();
|
|
487
|
+
}
|
|
488
|
+
selectRecordsForRecovery(records) {
|
|
489
|
+
const running = records.filter((record) => record.status === "running");
|
|
490
|
+
const completed = records
|
|
491
|
+
.filter((record) => record.status !== "running")
|
|
492
|
+
.sort(compareSessionRecordNewestFirst)
|
|
493
|
+
.slice(0, this.config.maxCompletedSessions);
|
|
494
|
+
return [...running, ...completed].sort(compareSessionRecordOldestFirst);
|
|
495
|
+
}
|
|
496
|
+
attachPtyHandlers(session) {
|
|
497
|
+
if (!session.pty)
|
|
498
|
+
return;
|
|
499
|
+
const { sessionId } = session.record;
|
|
500
|
+
session.pty.onData((data) => {
|
|
501
|
+
if (session.record.status !== "running")
|
|
502
|
+
return;
|
|
503
|
+
const safeData = session.outputRedactor.push(data);
|
|
504
|
+
if (!safeData)
|
|
505
|
+
return;
|
|
506
|
+
session.record.outputChars += safeData.length;
|
|
507
|
+
session.record.updatedAt = new Date().toISOString();
|
|
508
|
+
session.rollingBuffer = appendRollingBuffer(session.rollingBuffer, safeData, this.config.maxBufferChars);
|
|
509
|
+
this.writeLog(session, { type: "output", data: safeData });
|
|
510
|
+
this.writeMetadata(session.record);
|
|
511
|
+
this.emitStreamEvent({
|
|
512
|
+
type: "output",
|
|
513
|
+
sessionId,
|
|
514
|
+
chunk: safeData,
|
|
515
|
+
snapshot: this.snapshot(session),
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
session.pty.onExit((event) => {
|
|
519
|
+
if (session.forceKillTimer) {
|
|
520
|
+
clearTimeout(session.forceKillTimer);
|
|
521
|
+
session.forceKillTimer = undefined;
|
|
522
|
+
}
|
|
523
|
+
if (session.detaching && session.record.recoverable) {
|
|
524
|
+
session.pty = undefined;
|
|
525
|
+
session.detaching = false;
|
|
526
|
+
session.record.updatedAt = new Date().toISOString();
|
|
527
|
+
this.writeMetadata(session.record);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const flushed = session.outputRedactor.flush();
|
|
531
|
+
if (flushed) {
|
|
532
|
+
session.record.outputChars += flushed.length;
|
|
533
|
+
session.rollingBuffer = appendRollingBuffer(session.rollingBuffer, flushed, this.config.maxBufferChars);
|
|
534
|
+
this.writeLog(session, { type: "output", data: flushed });
|
|
535
|
+
this.emitStreamEvent({
|
|
536
|
+
type: "output",
|
|
537
|
+
sessionId,
|
|
538
|
+
chunk: flushed,
|
|
539
|
+
snapshot: this.snapshot(session),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
session.pty = undefined;
|
|
543
|
+
session.record.status = session.record.status === "stopped"
|
|
544
|
+
? "stopped"
|
|
545
|
+
: event.exitCode === 0 ? "exited" : "failed";
|
|
546
|
+
session.record.exitCode = event.exitCode;
|
|
547
|
+
session.record.signal = event.signal;
|
|
548
|
+
session.record.endedAt = new Date().toISOString();
|
|
549
|
+
session.record.updatedAt = session.record.endedAt;
|
|
550
|
+
this.writeLog(session, {
|
|
551
|
+
type: "exit",
|
|
552
|
+
exitCode: event.exitCode,
|
|
553
|
+
signal: event.signal,
|
|
554
|
+
});
|
|
555
|
+
this.writeMetadata(session.record);
|
|
556
|
+
this.emitStreamEvent({
|
|
557
|
+
type: "exit",
|
|
558
|
+
sessionId,
|
|
559
|
+
exitCode: event.exitCode,
|
|
560
|
+
signal: event.signal,
|
|
561
|
+
snapshot: this.snapshot(session),
|
|
562
|
+
});
|
|
563
|
+
this.evictCompletedSessions();
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
stopRunningSession(session, reason) {
|
|
567
|
+
session.record.status = "stopped";
|
|
568
|
+
session.record.updatedAt = new Date().toISOString();
|
|
569
|
+
this.writeLog(session, { type: "stop", reason });
|
|
570
|
+
this.killSessionPty(session, "term");
|
|
571
|
+
this.scheduleForceKill(session, reason);
|
|
572
|
+
this.writeMetadata(session.record);
|
|
573
|
+
this.emitStreamEvent({
|
|
574
|
+
type: "status",
|
|
575
|
+
sessionId: session.record.sessionId,
|
|
576
|
+
snapshot: this.snapshot(session),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
markRecoveredSessionFailed(session, reason) {
|
|
580
|
+
const now = new Date().toISOString();
|
|
581
|
+
session.record.status = "failed";
|
|
582
|
+
session.record.error = `Workbench session could not be reattached: ${reason}`;
|
|
583
|
+
session.record.endedAt = now;
|
|
584
|
+
session.record.updatedAt = now;
|
|
585
|
+
this.writeLog(session, {
|
|
586
|
+
type: "recovery_failed",
|
|
587
|
+
reason,
|
|
588
|
+
backend: session.record.backend,
|
|
589
|
+
backendSessionId: session.record.backendSessionId,
|
|
590
|
+
});
|
|
591
|
+
this.writeMetadata(session.record);
|
|
592
|
+
}
|
|
593
|
+
requireSession(sessionId) {
|
|
594
|
+
const session = this.sessions.get(sessionId);
|
|
595
|
+
if (!session)
|
|
596
|
+
throw new Error(`Workbench session not found: ${sessionId}`);
|
|
597
|
+
return session;
|
|
598
|
+
}
|
|
599
|
+
snapshot(session) {
|
|
600
|
+
return {
|
|
601
|
+
...redactSecrets(session.record),
|
|
602
|
+
tail: session.rollingBuffer,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
refreshRecoverableDisplayBuffer(session) {
|
|
606
|
+
if (session.record.status !== "running")
|
|
607
|
+
return;
|
|
608
|
+
if (session.record.backend !== "tmux" || !session.record.backendSessionId)
|
|
609
|
+
return;
|
|
610
|
+
if (this.config.ptyFactory && !this.config.tmuxCapturePane)
|
|
611
|
+
return;
|
|
612
|
+
const capturePane = this.config.tmuxCapturePane || captureTmuxPaneHistory;
|
|
613
|
+
const captured = capturePane(session.record.backendSessionId, this.config.maxBufferChars);
|
|
614
|
+
if (!captured)
|
|
615
|
+
return;
|
|
616
|
+
session.rollingBuffer = capturedPaneToDisplayBuffer(captured, this.config.maxBufferChars);
|
|
617
|
+
}
|
|
618
|
+
sessionDirectory() {
|
|
619
|
+
return this.config.sessionDir
|
|
620
|
+
|| join(this.config.baseWorkdir, ".akemon", "agents", this.config.agentName, "workbench", "sessions");
|
|
621
|
+
}
|
|
622
|
+
relativeSessionPath(absolutePath) {
|
|
623
|
+
const rel = relative(this.config.baseWorkdir, absolutePath);
|
|
624
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel))
|
|
625
|
+
return absolutePath;
|
|
626
|
+
return rel;
|
|
627
|
+
}
|
|
628
|
+
absoluteSessionPath(storedPath) {
|
|
629
|
+
return isAbsolute(storedPath) ? storedPath : join(this.config.baseWorkdir, storedPath);
|
|
630
|
+
}
|
|
631
|
+
resolveBackend(kind) {
|
|
632
|
+
return kind === "tmux" ? TMUX_WORKBENCH_BACKEND : DIRECT_PTY_WORKBENCH_BACKEND;
|
|
633
|
+
}
|
|
634
|
+
async resolvePtyFactory() {
|
|
635
|
+
if (this.config.ptyFactory)
|
|
636
|
+
return this.config.ptyFactory;
|
|
637
|
+
const pty = await import("node-pty");
|
|
638
|
+
return (command, args, options) => pty.spawn(command, args, options);
|
|
639
|
+
}
|
|
640
|
+
writeLog(session, event) {
|
|
641
|
+
try {
|
|
642
|
+
appendFileSync(this.absoluteSessionPath(session.record.logPath), `${JSON.stringify(redactSecrets({
|
|
643
|
+
schemaVersion: 1,
|
|
644
|
+
ts: new Date().toISOString(),
|
|
645
|
+
sessionId: session.record.sessionId,
|
|
646
|
+
...event,
|
|
647
|
+
}))}\n`);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
logBestEffortError("workbench log append", error);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
writeMetadata(record) {
|
|
654
|
+
try {
|
|
655
|
+
const file = join(this.sessionDirectory(), `${safeWorkbenchSessionFilename(record.sessionId)}.json`);
|
|
656
|
+
writeFileSync(file, `${JSON.stringify(redactSecrets(record), null, 2)}\n`);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
logBestEffortError("workbench metadata write", error);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
readMetadataFile(file) {
|
|
663
|
+
try {
|
|
664
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
665
|
+
if (!parsed || parsed.schemaVersion !== 1 || typeof parsed.sessionId !== "string")
|
|
666
|
+
return null;
|
|
667
|
+
return parsed;
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
logBestEffortError("workbench metadata read", error);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
readTailFromLog(record) {
|
|
675
|
+
try {
|
|
676
|
+
const file = this.absoluteSessionPath(record.logPath);
|
|
677
|
+
if (!existsSync(file))
|
|
678
|
+
return "";
|
|
679
|
+
const chunks = [];
|
|
680
|
+
let recoveredChars = 0;
|
|
681
|
+
const lines = this.readLogTailText(file).split(/\r?\n/);
|
|
682
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
683
|
+
const line = lines[index];
|
|
684
|
+
if (!line.trim())
|
|
685
|
+
continue;
|
|
686
|
+
try {
|
|
687
|
+
const event = JSON.parse(line);
|
|
688
|
+
if (event?.type === "output" && typeof event.data === "string") {
|
|
689
|
+
chunks.push(event.data);
|
|
690
|
+
recoveredChars += event.data.length;
|
|
691
|
+
if (recoveredChars >= this.config.maxBufferChars)
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// Ignore corrupt log lines; Workbench logs are best-effort observability.
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
let tail = "";
|
|
700
|
+
for (let index = chunks.length - 1; index >= 0; index -= 1) {
|
|
701
|
+
tail = appendRollingBuffer(tail, chunks[index], this.config.maxBufferChars);
|
|
702
|
+
}
|
|
703
|
+
return tail;
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
logBestEffortError("workbench log tail read", error);
|
|
707
|
+
return "";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
readLogTailText(file) {
|
|
711
|
+
const stats = statSync(file);
|
|
712
|
+
if (stats.size <= 0)
|
|
713
|
+
return "";
|
|
714
|
+
const readLimit = Math.min(Math.max(this.config.maxBufferChars * LOG_TAIL_READ_MULTIPLIER, LOG_TAIL_MIN_READ_BYTES), LOG_TAIL_MAX_READ_BYTES);
|
|
715
|
+
const readBytes = Math.min(stats.size, readLimit);
|
|
716
|
+
const start = stats.size - readBytes;
|
|
717
|
+
const fd = openSync(file, "r");
|
|
718
|
+
try {
|
|
719
|
+
const buffer = Buffer.allocUnsafe(readBytes);
|
|
720
|
+
const bytesRead = readSync(fd, buffer, 0, readBytes, start);
|
|
721
|
+
let text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
722
|
+
if (start > 0) {
|
|
723
|
+
const firstLineEnd = text.indexOf("\n");
|
|
724
|
+
text = firstLineEnd >= 0 ? text.slice(firstLineEnd + 1) : "";
|
|
725
|
+
}
|
|
726
|
+
return text;
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
closeSync(fd);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
scheduleForceKill(session, reason) {
|
|
733
|
+
if (session.forceKillTimer || session.record.endedAt)
|
|
734
|
+
return;
|
|
735
|
+
session.forceKillTimer = setTimeout(() => {
|
|
736
|
+
session.forceKillTimer = undefined;
|
|
737
|
+
if (session.record.endedAt)
|
|
738
|
+
return;
|
|
739
|
+
try {
|
|
740
|
+
this.writeLog(session, { type: "force_kill", reason, signal: "SIGKILL" });
|
|
741
|
+
this.killSessionPty(session, "kill");
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
logBestEffortError("workbench force kill", error);
|
|
745
|
+
}
|
|
746
|
+
}, this.config.forceKillAfterMs);
|
|
747
|
+
session.forceKillTimer.unref?.();
|
|
748
|
+
}
|
|
749
|
+
evictCompletedSessions() {
|
|
750
|
+
const completed = Array.from(this.sessions.values())
|
|
751
|
+
.filter((session) => session.record.status !== "running")
|
|
752
|
+
.sort((left, right) => left.record.updatedAt.localeCompare(right.record.updatedAt));
|
|
753
|
+
const overflow = completed.length - this.config.maxCompletedSessions;
|
|
754
|
+
if (overflow <= 0)
|
|
755
|
+
return;
|
|
756
|
+
for (const session of completed.slice(0, overflow)) {
|
|
757
|
+
this.sessions.delete(session.record.sessionId);
|
|
758
|
+
if (this.activeSessionId === session.record.sessionId)
|
|
759
|
+
this.activeSessionId = "";
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
emitStreamEvent(event) {
|
|
763
|
+
for (const subscriber of this.subscribers) {
|
|
764
|
+
try {
|
|
765
|
+
subscriber(event);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
logBestEffortError("workbench stream subscriber", error);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
killSessionPty(session, mode) {
|
|
773
|
+
session.backend.stop(session, mode, this.config.runtime || getRuntimePlatform());
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function resolveDefaultWorkbenchBackend(config) {
|
|
777
|
+
if (config.backend)
|
|
778
|
+
return config.backend;
|
|
779
|
+
const configured = normalizeOptionalWorkbenchBackendKind(process.env.AKEMON_WORKBENCH_BACKEND);
|
|
780
|
+
if (configured)
|
|
781
|
+
return configured;
|
|
782
|
+
if (config.ptyFactory)
|
|
783
|
+
return "direct-pty";
|
|
784
|
+
const runtime = config.runtime || getRuntimePlatform();
|
|
785
|
+
if (runtime.platform !== "win32" && isTmuxAvailable())
|
|
786
|
+
return "tmux";
|
|
787
|
+
return "direct-pty";
|
|
788
|
+
}
|
|
789
|
+
function normalizeOptionalWorkbenchBackendKind(value) {
|
|
790
|
+
if (typeof value !== "string")
|
|
791
|
+
return undefined;
|
|
792
|
+
const normalized = value.trim().toLowerCase();
|
|
793
|
+
if (!normalized)
|
|
794
|
+
return undefined;
|
|
795
|
+
if (normalized === "direct" || normalized === "direct-pty" || normalized === "pty")
|
|
796
|
+
return "direct-pty";
|
|
797
|
+
if (normalized === "tmux")
|
|
798
|
+
return "tmux";
|
|
799
|
+
throw new Error("Invalid Workbench backend: expected direct-pty or tmux");
|
|
800
|
+
}
|
|
801
|
+
function isTmuxAvailable() {
|
|
802
|
+
try {
|
|
803
|
+
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function assertTmuxAvailable() {
|
|
811
|
+
if (!isTmuxAvailable()) {
|
|
812
|
+
throw new Error("tmux Workbench backend requires tmux to be installed and available on PATH");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function hasTmuxSession(sessionName) {
|
|
816
|
+
try {
|
|
817
|
+
execFileSync("tmux", ["has-session", "-t", sessionName], { stdio: "ignore" });
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function captureTmuxPaneHistory(sessionName, maxChars) {
|
|
825
|
+
try {
|
|
826
|
+
const maxBuffer = Math.min(Math.max(maxChars * TMUX_CAPTURE_BUFFER_MULTIPLIER, TMUX_CAPTURE_MIN_BUFFER_BYTES), TMUX_CAPTURE_MAX_BUFFER_BYTES);
|
|
827
|
+
return execFileSync("tmux", [
|
|
828
|
+
"capture-pane",
|
|
829
|
+
"-p",
|
|
830
|
+
"-S",
|
|
831
|
+
"-",
|
|
832
|
+
"-t",
|
|
833
|
+
sessionName,
|
|
834
|
+
], {
|
|
835
|
+
encoding: "utf8",
|
|
836
|
+
maxBuffer,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
logBestEffortError("workbench tmux capture-pane", error);
|
|
841
|
+
return "";
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function capturedPaneToDisplayBuffer(captured, maxBufferChars) {
|
|
845
|
+
if (!captured)
|
|
846
|
+
return "";
|
|
847
|
+
const display = captured.endsWith("\n") ? captured : `${captured}\n`;
|
|
848
|
+
return appendRollingBuffer("", display, maxBufferChars);
|
|
849
|
+
}
|
|
850
|
+
function killPtyProcess(pty, mode, runtime) {
|
|
851
|
+
if (!pty)
|
|
852
|
+
return;
|
|
853
|
+
if (runtime.platform === "win32") {
|
|
854
|
+
pty.kill();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
pty.kill(mode === "kill" ? "SIGKILL" : "SIGTERM");
|
|
858
|
+
}
|
|
859
|
+
export function resolveWorkbenchCommand(request, runtime = getRuntimePlatform()) {
|
|
860
|
+
const args = normalizeStringArray(request.args, "args");
|
|
861
|
+
const command = normalizeOptionalString(request.command);
|
|
862
|
+
switch (request.tool) {
|
|
863
|
+
case "codex": {
|
|
864
|
+
const resolvedCommand = command || "codex";
|
|
865
|
+
return {
|
|
866
|
+
command: resolvedCommand,
|
|
867
|
+
args: commandLooksLikeCodex(resolvedCommand) ? withCodexNoAltScreen(args) : args,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
case "claude":
|
|
871
|
+
return { command: command || "claude", args };
|
|
872
|
+
case "cursor":
|
|
873
|
+
return { command: command || "agent", args };
|
|
874
|
+
case "shell":
|
|
875
|
+
return { command: command || runtime.defaultShell, args };
|
|
876
|
+
case "custom":
|
|
877
|
+
if (!command)
|
|
878
|
+
throw new Error("Custom Workbench sessions require command");
|
|
879
|
+
return { command, args };
|
|
880
|
+
default:
|
|
881
|
+
throw new Error(`Invalid Workbench tool: ${String(request.tool)}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function commandLooksLikeCodex(command) {
|
|
885
|
+
const basename = command.split(/[\\/]/).pop() || command;
|
|
886
|
+
return basename === "codex";
|
|
887
|
+
}
|
|
888
|
+
function withCodexNoAltScreen(args) {
|
|
889
|
+
if (args.includes("--no-alt-screen"))
|
|
890
|
+
return args;
|
|
891
|
+
return ["--no-alt-screen", ...args];
|
|
892
|
+
}
|
|
893
|
+
export function normalizeWorkbenchTool(value) {
|
|
894
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
895
|
+
if (normalized === "codex"
|
|
896
|
+
|| normalized === "claude"
|
|
897
|
+
|| normalized === "cursor"
|
|
898
|
+
|| normalized === "shell"
|
|
899
|
+
|| normalized === "custom") {
|
|
900
|
+
return normalized;
|
|
901
|
+
}
|
|
902
|
+
throw new Error("Invalid tool: expected codex, claude, cursor, shell, or custom");
|
|
903
|
+
}
|
|
904
|
+
export function normalizeWorkbenchStartRequest(body) {
|
|
905
|
+
return {
|
|
906
|
+
tool: normalizeWorkbenchTool(body?.tool),
|
|
907
|
+
command: normalizeOptionalString(body?.command),
|
|
908
|
+
args: normalizeStringArray(body?.args, "args"),
|
|
909
|
+
workdir: normalizeOptionalString(body?.workdir),
|
|
910
|
+
allowOutsideWorkdir: body?.allowOutsideWorkdir === true,
|
|
911
|
+
cols: normalizeOptionalPositiveInt(body?.cols, "cols"),
|
|
912
|
+
rows: normalizeOptionalPositiveInt(body?.rows, "rows"),
|
|
913
|
+
initialInput: normalizeOptionalRawString(body?.initialInput, "initialInput"),
|
|
914
|
+
label: normalizeOptionalString(body?.label),
|
|
915
|
+
inputMode: normalizeOptionalWorkbenchInputMode(body?.inputMode),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
export function normalizeWorkbenchInputRequest(body) {
|
|
919
|
+
const input = typeof body?.input === "string" ? body.input : "";
|
|
920
|
+
if (!input)
|
|
921
|
+
throw new Error("Missing required string field: input");
|
|
922
|
+
return {
|
|
923
|
+
input,
|
|
924
|
+
inputMode: normalizeOptionalWorkbenchInputMode(body?.inputMode),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
export function normalizeWorkbenchInputMode(value) {
|
|
928
|
+
if (value === "line" || value === "tui")
|
|
929
|
+
return value;
|
|
930
|
+
throw new Error("Invalid inputMode: expected line or tui");
|
|
931
|
+
}
|
|
932
|
+
function normalizeOptionalWorkbenchInputMode(value) {
|
|
933
|
+
if (value === undefined || value === null || value === "")
|
|
934
|
+
return undefined;
|
|
935
|
+
return normalizeWorkbenchInputMode(value);
|
|
936
|
+
}
|
|
937
|
+
export function normalizeWorkbenchResizeRequest(body) {
|
|
938
|
+
return {
|
|
939
|
+
cols: normalizeRequiredPositiveInt(body?.cols, "cols"),
|
|
940
|
+
rows: normalizeRequiredPositiveInt(body?.rows, "rows"),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function normalizeOptionalString(value) {
|
|
944
|
+
if (value === undefined || value === null)
|
|
945
|
+
return undefined;
|
|
946
|
+
if (typeof value !== "string")
|
|
947
|
+
throw new Error("Invalid string field");
|
|
948
|
+
const trimmed = value.trim();
|
|
949
|
+
return trimmed || undefined;
|
|
950
|
+
}
|
|
951
|
+
function normalizeOptionalRawString(value, field) {
|
|
952
|
+
if (value === undefined || value === null)
|
|
953
|
+
return undefined;
|
|
954
|
+
if (typeof value !== "string")
|
|
955
|
+
throw new Error(`Invalid ${field}: expected string`);
|
|
956
|
+
return value.length > 0 ? value : undefined;
|
|
957
|
+
}
|
|
958
|
+
function normalizeStringArray(value, field) {
|
|
959
|
+
if (value === undefined || value === null)
|
|
960
|
+
return [];
|
|
961
|
+
if (!Array.isArray(value))
|
|
962
|
+
throw new Error(`Invalid ${field}: expected string array`);
|
|
963
|
+
return value.map((item) => {
|
|
964
|
+
if (typeof item !== "string")
|
|
965
|
+
throw new Error(`Invalid ${field}: expected string array`);
|
|
966
|
+
return item;
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
function normalizeOptionalPositiveInt(value, field) {
|
|
970
|
+
if (value === undefined || value === null)
|
|
971
|
+
return undefined;
|
|
972
|
+
return normalizeRequiredPositiveInt(value, field);
|
|
973
|
+
}
|
|
974
|
+
function normalizeRequiredPositiveInt(value, field) {
|
|
975
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
976
|
+
throw new Error(`Invalid ${field}: expected positive integer`);
|
|
977
|
+
}
|
|
978
|
+
return value;
|
|
979
|
+
}
|
|
980
|
+
function clampTerminalDimension(value, fallback) {
|
|
981
|
+
if (!Number.isInteger(value) || (value || 0) <= 0)
|
|
982
|
+
return fallback;
|
|
983
|
+
return Math.min(Math.max(value || fallback, 10), 500);
|
|
984
|
+
}
|
|
985
|
+
function compareSessionRecordNewestFirst(left, right) {
|
|
986
|
+
return sessionRecordActivityKey(right).localeCompare(sessionRecordActivityKey(left))
|
|
987
|
+
|| right.sessionId.localeCompare(left.sessionId);
|
|
988
|
+
}
|
|
989
|
+
function compareSessionRecordOldestFirst(left, right) {
|
|
990
|
+
return sessionRecordActivityKey(left).localeCompare(sessionRecordActivityKey(right))
|
|
991
|
+
|| left.sessionId.localeCompare(right.sessionId);
|
|
992
|
+
}
|
|
993
|
+
function sessionRecordActivityKey(record) {
|
|
994
|
+
return record.updatedAt || record.startedAt || record.sessionId;
|
|
995
|
+
}
|
|
996
|
+
function appendRollingBuffer(current, chunk, maxChars) {
|
|
997
|
+
const next = current + chunk;
|
|
998
|
+
if (next.length <= maxChars)
|
|
999
|
+
return next;
|
|
1000
|
+
return next.slice(-maxChars);
|
|
1001
|
+
}
|
|
1002
|
+
function safeWorkbenchSessionFilename(sessionId) {
|
|
1003
|
+
return sessionId.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
1004
|
+
}
|
|
1005
|
+
function defaultWorkbenchInputMode(tool) {
|
|
1006
|
+
return DEFAULT_TUI_INPUT_TOOLS.has(tool) ? "tui" : "line";
|
|
1007
|
+
}
|
|
1008
|
+
function writeInputToPty(session, input, inputMode = session.record.inputMode) {
|
|
1009
|
+
const pty = session.pty;
|
|
1010
|
+
if (!pty)
|
|
1011
|
+
throw new Error(`Workbench session ${session.record.sessionId} is not attached`);
|
|
1012
|
+
const splitSubmit = splitTuiSubmitInput(inputMode, input);
|
|
1013
|
+
if (!splitSubmit) {
|
|
1014
|
+
pty.write(input);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
pty.write(formatTuiSubmitText(splitSubmit.text));
|
|
1018
|
+
setTimeout(() => {
|
|
1019
|
+
if (session.record.status !== "running")
|
|
1020
|
+
return;
|
|
1021
|
+
try {
|
|
1022
|
+
pty.write(splitSubmit.submit);
|
|
1023
|
+
}
|
|
1024
|
+
catch (err) {
|
|
1025
|
+
logBestEffortError("workbench delayed submit write", err);
|
|
1026
|
+
}
|
|
1027
|
+
}, SPLIT_SUBMIT_DELAY_MS);
|
|
1028
|
+
}
|
|
1029
|
+
function splitTuiSubmitInput(inputMode, input) {
|
|
1030
|
+
if (inputMode !== "tui")
|
|
1031
|
+
return null;
|
|
1032
|
+
const match = input.match(/(\r\n|\r|\n)$/);
|
|
1033
|
+
if (!match)
|
|
1034
|
+
return null;
|
|
1035
|
+
const submit = match[1];
|
|
1036
|
+
const text = input.slice(0, -submit.length);
|
|
1037
|
+
if (!text)
|
|
1038
|
+
return null;
|
|
1039
|
+
return { text, submit };
|
|
1040
|
+
}
|
|
1041
|
+
function formatTuiSubmitText(text) {
|
|
1042
|
+
if (text.includes("\x1b"))
|
|
1043
|
+
return text;
|
|
1044
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
1045
|
+
}
|
|
1046
|
+
function summarizeOwnerInput(input) {
|
|
1047
|
+
const preview = input.replace(/\r\n|\r|\n/g, "\\n").slice(0, 160);
|
|
1048
|
+
return redactSecrets({
|
|
1049
|
+
chars: input.length,
|
|
1050
|
+
bytes: Buffer.byteLength(input, "utf8"),
|
|
1051
|
+
lines: input ? input.split(/\r\n|\r|\n/).length : 0,
|
|
1052
|
+
preview,
|
|
1053
|
+
truncated: input.replace(/\r\n|\r|\n/g, "\\n").length > 160,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
export function buildWorkbenchChildEnvironment(sourceEnv, options = {}) {
|
|
1057
|
+
const allowlist = new Set(options.allowlist || DEFAULT_WORKBENCH_ENV_ALLOWLIST);
|
|
1058
|
+
const entries = Object.entries(sourceEnv)
|
|
1059
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
1060
|
+
.filter(([key, value]) => options.filter ? options.filter(key, value) : allowlist.has(key))
|
|
1061
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
1062
|
+
return {
|
|
1063
|
+
env: Object.fromEntries(entries),
|
|
1064
|
+
transmittedEnvKeys: entries.map(([key]) => key),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function formatCommandLineDisplay(command, args) {
|
|
1068
|
+
return [command, ...args].map(quoteCommandLinePart).join(" ");
|
|
1069
|
+
}
|
|
1070
|
+
function quoteCommandLinePart(part) {
|
|
1071
|
+
if (/^[a-zA-Z0-9_./:=@+-]+$/.test(part))
|
|
1072
|
+
return part;
|
|
1073
|
+
return JSON.stringify(part);
|
|
1074
|
+
}
|