@platless/axom-cli 0.3.13 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/axom-patch/cli/README.md +30 -8
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +33 -6
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +117 -38
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Daemon-side: keeper-terminal generations (Task 26, spec §3.1, §8.3). One keeper-terminal per build ("generation") may run; after a
|
|
2
|
+
// CLI upgrade an OLDER generation can still host PTYs and execs while a newer one takes new work. Nothing here starts an older
|
|
3
|
+
// generation: only the current build's client launches keeper-terminal. Exempt from the keeper-side import guard (daemon module).
|
|
4
|
+
//
|
|
5
|
+
// - New work (a `terminal.create`, an exec start) goes to the SERVING generation: the current build's, unless the replacement policy
|
|
6
|
+
// (keepers/policy.ts) keeps an older, still compatible generation serving (`replacement: 'available'` and nobody asked to replace
|
|
7
|
+
// it — spec §8.3: an available update never does anything by itself).
|
|
8
|
+
// - Everything else is routed by OWNER: a PTY id or an exec id is served by the generation that runs it, through a client bound to
|
|
9
|
+
// that generation (tools/terminalRouter.ts, exec/policy.ts).
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { readKeeperManifest } from '../kit/bundle.js';
|
|
12
|
+
import { TerminalKeeperClient } from './client.js';
|
|
13
|
+
import { defaultDistDir } from '../kit/launcher.js';
|
|
14
|
+
/** How long one listing of the live generations is reused (each costs a pid-manifest read and a process lookup per generation). */
|
|
15
|
+
export const LIVE_GENERATIONS_TTL_MS = 2000;
|
|
16
|
+
export class TerminalGenerations {
|
|
17
|
+
options;
|
|
18
|
+
/** The current build's client: the only one that launches keeper-terminal. */
|
|
19
|
+
primary;
|
|
20
|
+
bound = new Map();
|
|
21
|
+
currentGenValue;
|
|
22
|
+
manifestValue;
|
|
23
|
+
liveCache;
|
|
24
|
+
now;
|
|
25
|
+
/** Set by the replacement policy; absent = the current build always serves. */
|
|
26
|
+
picker;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.now = options.now ?? Date.now;
|
|
30
|
+
this.primary = options.primary ?? new TerminalKeeperClient({
|
|
31
|
+
launcher: options.launcher, distDir: options.distDir, daemonProtocol: options.daemonProtocol,
|
|
32
|
+
requestTimeoutMs: options.requestTimeoutMs, connectTimeoutMs: options.connectTimeoutMs,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** The current build's manifest (`dist/keepers/terminal/manifest.json`), read once. */
|
|
36
|
+
manifest() {
|
|
37
|
+
this.manifestValue ??= readKeeperManifest(path.join(this.options.distDir ?? defaultDistDir(), 'terminal'))
|
|
38
|
+
.catch((error) => { this.manifestValue = undefined; throw error; });
|
|
39
|
+
return this.manifestValue;
|
|
40
|
+
}
|
|
41
|
+
/** The generation the current build runs as. */
|
|
42
|
+
currentGen() {
|
|
43
|
+
this.currentGenValue ??= this.options.launcher.currentGen('terminal')
|
|
44
|
+
.catch((error) => { this.currentGenValue = undefined; throw error; });
|
|
45
|
+
return this.currentGenValue;
|
|
46
|
+
}
|
|
47
|
+
/** Generations with a live keeper-terminal (pid manifest), the current one included when it runs. Starts nothing. Briefly cached. */
|
|
48
|
+
live() {
|
|
49
|
+
const cached = this.liveCache;
|
|
50
|
+
if (cached && this.now() - cached.at < LIVE_GENERATIONS_TTL_MS)
|
|
51
|
+
return cached.gens;
|
|
52
|
+
const gens = this.options.launcher.liveGenerations('terminal');
|
|
53
|
+
this.liveCache = { at: this.now(), gens };
|
|
54
|
+
gens.catch(() => { if (this.liveCache?.gens === gens)
|
|
55
|
+
this.liveCache = undefined; });
|
|
56
|
+
return gens;
|
|
57
|
+
}
|
|
58
|
+
/** Forget the cached listing (a generation was started or stopped). */
|
|
59
|
+
invalidate() { this.liveCache = undefined; }
|
|
60
|
+
/** The client of `gen`: the current build's own, or one bound to `gen` that only connects. */
|
|
61
|
+
async client(gen) {
|
|
62
|
+
if (gen === await this.currentGen().catch(() => undefined))
|
|
63
|
+
return this.primary;
|
|
64
|
+
let client = this.bound.get(gen);
|
|
65
|
+
if (!client) {
|
|
66
|
+
client = new TerminalKeeperClient({
|
|
67
|
+
launcher: this.options.launcher, distDir: this.options.distDir, daemonProtocol: this.options.daemonProtocol,
|
|
68
|
+
requestTimeoutMs: this.options.requestTimeoutMs, connectTimeoutMs: this.options.connectTimeoutMs, gen,
|
|
69
|
+
});
|
|
70
|
+
this.bound.set(gen, client);
|
|
71
|
+
}
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
/** The serving generation (see the file comment). Never starts anything itself; its client's `ensure()` may (current build only). */
|
|
75
|
+
async servingGeneration() {
|
|
76
|
+
let current;
|
|
77
|
+
try {
|
|
78
|
+
current = await this.currentGen();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { gen: 'current', client: this.primary };
|
|
82
|
+
} // no build: as before
|
|
83
|
+
let older;
|
|
84
|
+
if (this.picker) {
|
|
85
|
+
try {
|
|
86
|
+
const candidates = (await this.live()).filter((gen) => gen !== current);
|
|
87
|
+
if (candidates.length > 0)
|
|
88
|
+
older = await this.picker(current, candidates);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
older = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return older !== undefined && older !== current ? { gen: older, client: await this.client(older) } : { gen: current, client: this.primary };
|
|
95
|
+
}
|
|
96
|
+
/** `ExecKeeperGenerations.serving` / the router's create target. */
|
|
97
|
+
async serving() { return (await this.servingGeneration()).client; }
|
|
98
|
+
/** Every live generation with its client (the current build's only when it runs). Starts nothing. */
|
|
99
|
+
async running() {
|
|
100
|
+
const gens = [...await this.live()];
|
|
101
|
+
// The current build's client launched keeper-terminal since the (cached) listing: it runs.
|
|
102
|
+
const current = await this.currentGen().catch(() => undefined);
|
|
103
|
+
if (current !== undefined && this.primary.hello && !gens.includes(current))
|
|
104
|
+
gens.push(current);
|
|
105
|
+
return Promise.all(gens.map(async (gen) => ({ gen, client: await this.client(gen) })));
|
|
106
|
+
}
|
|
107
|
+
/** `ExecKeeperGenerations.others`: the clients of every live generation except the serving one. */
|
|
108
|
+
async others() {
|
|
109
|
+
const serving = await this.serving();
|
|
110
|
+
return (await this.running()).map((generation) => generation.client).filter((client) => client !== serving);
|
|
111
|
+
}
|
|
112
|
+
/** `tools.info` terminal fields from the serving generation when it already runs, else `fallback`. Starts nothing. */
|
|
113
|
+
async terminalStatus(fallback) {
|
|
114
|
+
return (await this.serving()).terminalStatus(fallback);
|
|
115
|
+
}
|
|
116
|
+
/** Drops the connection to `gen` (it was stopped). */
|
|
117
|
+
forget(gen) {
|
|
118
|
+
this.bound.get(gen)?.disconnect();
|
|
119
|
+
this.bound.delete(gen);
|
|
120
|
+
this.invalidate();
|
|
121
|
+
}
|
|
122
|
+
/** Drops every connection (the keepers and their PTYs keep running). */
|
|
123
|
+
disconnect() {
|
|
124
|
+
this.primary.disconnect();
|
|
125
|
+
for (const client of this.bound.values())
|
|
126
|
+
client.disconnect();
|
|
127
|
+
this.bound.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// keeper-terminal's methods on the kit server: `pty.*` over PtySessions, `exec.*` over ExecHost, plus their activity samplers.
|
|
2
|
+
// Keeper-side module: node builtins and kit siblings only (the import guard enforces it).
|
|
3
|
+
import { constants, promises as fs } from 'node:fs';
|
|
4
|
+
import { absolutePath, badRequest as bad, environment } from './params.js';
|
|
5
|
+
import { CREATE_KEYS, PtySessions } from './pty.js';
|
|
6
|
+
import { KeeperMethodError } from '../kit/protocol.js';
|
|
7
|
+
import { EXEC_CAPABILITY, ExecErrorCode, PTY_CAPABILITY, execStream } from './protocol.js';
|
|
8
|
+
/**
|
|
9
|
+
* `pty.create`'s launch: the daemon's absolute cwd (must be a directory now), absolute shell (an executable file now: a bad path
|
|
10
|
+
* would otherwise spawn, exit at once and still hold one of the 8 session slots) and complete env. Never this process's own env.
|
|
11
|
+
*/
|
|
12
|
+
async function launchOf(p) {
|
|
13
|
+
const cwd = absolutePath(p.cwd, 'cwd');
|
|
14
|
+
const shell = absolutePath(p.shell, 'shell');
|
|
15
|
+
const env = environment(p.env);
|
|
16
|
+
const stat = await fs.stat(cwd).catch(() => null);
|
|
17
|
+
if (!stat?.isDirectory())
|
|
18
|
+
bad('Terminal working directory is not a directory');
|
|
19
|
+
const executable = await fs.stat(shell).then((s) => s.isFile(), () => false) && await fs.access(shell, constants.X_OK).then(() => true, () => false);
|
|
20
|
+
if (!executable)
|
|
21
|
+
bad('Terminal shell is not an executable file');
|
|
22
|
+
return { cwd, shell, env };
|
|
23
|
+
}
|
|
24
|
+
export const KEEPER_CREATE_KEYS = [...CREATE_KEYS, 'shell', 'env'];
|
|
25
|
+
export function createTerminalKeeper(options = {}) {
|
|
26
|
+
const sessions = new PtySessions(options);
|
|
27
|
+
const exec = options.exec;
|
|
28
|
+
return {
|
|
29
|
+
sessions,
|
|
30
|
+
exec,
|
|
31
|
+
capabilities: [...(sessions.available ? [PTY_CAPABILITY] : []), ...(exec ? [EXEC_CAPABILITY] : [])],
|
|
32
|
+
install(server) {
|
|
33
|
+
server.register('pty.create', (params) => sessions.create(params, KEEPER_CREATE_KEYS, () => launchOf(params)), { startsWork: true });
|
|
34
|
+
server.register('pty.list', (params) => sessions.list(params));
|
|
35
|
+
for (const op of ['attach', 'poll', 'write', 'resize', 'takeover', 'detach', 'close'])
|
|
36
|
+
server.register(`pty.${op}`, (params) => sessions.operate(op, params));
|
|
37
|
+
server.addActivitySampler(() => sessions.activity());
|
|
38
|
+
if (!exec)
|
|
39
|
+
return;
|
|
40
|
+
server.register('exec.start', (params) => exec.start(params), { startsWork: true });
|
|
41
|
+
server.register('exec.list', () => exec.list());
|
|
42
|
+
server.register('exec.status', (params) => exec.status(params));
|
|
43
|
+
server.register('exec.read', (params) => exec.read(params));
|
|
44
|
+
server.register('exec.kill', (params) => exec.kill(params));
|
|
45
|
+
server.register('exec.commit', (params) => exec.commit(params));
|
|
46
|
+
server.register('exec.checkpoint', (params) => exec.checkpoint(params));
|
|
47
|
+
server.register('exec.subscribe', (params, context) => {
|
|
48
|
+
const { execId, from = 0, credit = 0 } = params;
|
|
49
|
+
if (typeof execId !== 'string' || !Number.isSafeInteger(from) || from < 0 || !Number.isSafeInteger(credit) || credit < 0)
|
|
50
|
+
bad('exec.subscribe needs execId, from >= 0 and credit >= 0');
|
|
51
|
+
const source = exec.source(execId);
|
|
52
|
+
if (!source)
|
|
53
|
+
throw new KeeperMethodError(ExecErrorCode.notFound, 'Exec not found');
|
|
54
|
+
context.subscribe(execStream(execId), source, from, credit);
|
|
55
|
+
return { stream: execStream(execId), from };
|
|
56
|
+
});
|
|
57
|
+
server.setStreamResolver((stream) => (stream.startsWith('exec/') ? exec.source(stream.slice('exec/'.length)) : undefined));
|
|
58
|
+
server.addActivitySampler(exec.activity);
|
|
59
|
+
},
|
|
60
|
+
shutdown: async () => { await Promise.all([sessions.dispose(), exec?.dispose()]); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// keeper-terminal: hosts persistent PTY sessions (spec §4.1) and durable non-interactive execs (spec §4.2). Started by the daemon's
|
|
2
|
+
// KeeperLauncher as `node <bundleDir>/keeper.mjs <launch config>` and outlives daemon restarts and CLI upgrades.
|
|
3
|
+
// `scripts/bundle-keepers.mjs` reads `protocolVersion` below (the line must keep this exact shape) and
|
|
4
|
+
// `../daemonProtocol.json` for the daemon protocol range. It must equal TERMINAL_PROTOCOL_VERSION (protocol.ts).
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path, { sep } from 'node:path';
|
|
8
|
+
import { runKeeperMain } from '../kit/bootstrap.js';
|
|
9
|
+
import { ExecHost } from './exec.js';
|
|
10
|
+
import { createTerminalKeeper } from './keeper.js';
|
|
11
|
+
export const protocolVersion = 1;
|
|
12
|
+
/**
|
|
13
|
+
* node-pty from the bundle snapshot's own `node_modules` (copied next to keeper.mjs), never one found further up the
|
|
14
|
+
* directory tree: the snapshot is what the build hashed. Missing or foreign → PTYs unavailable (`pty` capability absent).
|
|
15
|
+
*/
|
|
16
|
+
function loadBundledPty(bundleDir) {
|
|
17
|
+
const load = createRequire(import.meta.url);
|
|
18
|
+
const resolved = realpathSync(load.resolve('node-pty'));
|
|
19
|
+
if (!resolved.startsWith(realpathSync(bundleDir) + sep))
|
|
20
|
+
throw new Error(`node-pty resolves outside the keeper bundle (${resolved})`);
|
|
21
|
+
return load('node-pty');
|
|
22
|
+
}
|
|
23
|
+
await runKeeperMain({
|
|
24
|
+
kind: 'terminal',
|
|
25
|
+
protocolVersion,
|
|
26
|
+
create: async (config, { log }) => {
|
|
27
|
+
// Execs live under the kind's data dir (shared by its generations; each exec belongs to the keeper process that spawned it).
|
|
28
|
+
const exec = new ExecHost({ root: path.join(config.dataDir, 'exec'), owner: { pid: process.pid, gen: config.gen, bundleDir: config.bundleDir }, log });
|
|
29
|
+
await exec.open(); // startup sweep: execs whose keeper died get keeper_lost before anyone can ask
|
|
30
|
+
return createTerminalKeeper({
|
|
31
|
+
exec,
|
|
32
|
+
loadPty: () => {
|
|
33
|
+
try {
|
|
34
|
+
return loadBundledPty(config.bundleDir);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
log(`node-pty unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Parameter checks shared by `pty.create` and `exec.start` (the daemon decides cwd/env; the keeper only refuses malformed input).
|
|
2
|
+
// Keeper-side module: node builtins and kit siblings only (the import guard enforces it).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { KeeperMethodError } from '../kit/protocol.js';
|
|
5
|
+
export const MAX_PATH = 4096;
|
|
6
|
+
export const MAX_ENV_ENTRIES = 4096;
|
|
7
|
+
export const badRequest = (message) => { throw new KeeperMethodError('bad_request', message); };
|
|
8
|
+
export function absolutePath(value, name) {
|
|
9
|
+
if (typeof value !== 'string' || !value.length || value.length > MAX_PATH || value.includes('\0') || !path.isAbsolute(value))
|
|
10
|
+
badRequest(`Invalid ${name}`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** The environment exactly as the daemon sent it: string values only, no `=` or NUL in names, no NUL in values. */
|
|
14
|
+
export function environment(value) {
|
|
15
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
16
|
+
return badRequest('Invalid env');
|
|
17
|
+
const entries = Object.entries(value);
|
|
18
|
+
if (entries.length > MAX_ENV_ENTRIES)
|
|
19
|
+
badRequest('Invalid env');
|
|
20
|
+
const env = {};
|
|
21
|
+
for (const [name, entry] of entries) {
|
|
22
|
+
if (!name || name.includes('=') || name.includes('\0') || typeof entry !== 'string' || entry.includes('\0'))
|
|
23
|
+
badRequest('Invalid env');
|
|
24
|
+
env[name] = entry;
|
|
25
|
+
}
|
|
26
|
+
return env;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// keeper-terminal's wire vocabulary, shared by the keeper (`main.ts`, `pty.ts`) and the daemon (`client.ts`).
|
|
2
|
+
// Keeper-side module: node builtins and kit siblings only (the import guard enforces it). Spec §4.1.
|
|
3
|
+
/** The keeper protocol this code speaks. `main.ts` declares the same number for the bundler (a test keeps them equal). */
|
|
4
|
+
export const TERMINAL_PROTOCOL_VERSION = 1;
|
|
5
|
+
/** `keeper.hello` capability: node-pty loaded from the bundle snapshot, so `pty.*` works. Absent = PTYs unavailable. */
|
|
6
|
+
export const PTY_CAPABILITY = 'pty';
|
|
7
|
+
/** The legacy keeper's wording (tools/terminal.ts at the merge-base) when node-pty does not load; every `pty.*` call but `pty.list` answers it. */
|
|
8
|
+
export const PTY_UNAVAILABLE_ERROR = 'Terminal native dependency unavailable; reinstall CLI with optional node-pty support';
|
|
9
|
+
/** Ring of rendered output kept per session, the largest poll delta, the scrollback serialised into a snapshot. */
|
|
10
|
+
export const PTY_HISTORY_BYTES = 1024 * 1024;
|
|
11
|
+
export const PTY_MAX_POLL_BYTES = 64 * 1024;
|
|
12
|
+
export const PTY_SCROLLBACK = 1000;
|
|
13
|
+
/** A snapshot must fit one tool result (2 MiB, the same as keeper-terminal's outbound frame cap). */
|
|
14
|
+
export const PTY_RESULT_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
export const PTY_SESSION_LIMIT = 8;
|
|
16
|
+
export const PTY_INPUT_BYTES = 64 * 1024;
|
|
17
|
+
/** Longest activity label (the foreground process basename; never its arguments). */
|
|
18
|
+
export const PTY_LABEL_CHARS = 64;
|
|
19
|
+
export const PTY_METHODS = ['pty.create', 'pty.attach', 'pty.poll', 'pty.write', 'pty.resize', 'pty.takeover', 'pty.detach', 'pty.close', 'pty.list'];
|
|
20
|
+
/** Typed error codes of `pty.*` (the message keeps today's `terminal.*` wording). */
|
|
21
|
+
export const PtyErrorCode = {
|
|
22
|
+
unavailable: 'pty_unavailable',
|
|
23
|
+
badRequest: 'bad_request',
|
|
24
|
+
notFound: 'pty_not_found',
|
|
25
|
+
notController: 'pty_not_controller',
|
|
26
|
+
limit: 'pty_limit',
|
|
27
|
+
exited: 'pty_exited',
|
|
28
|
+
snapshotTooLarge: 'pty_snapshot_too_large',
|
|
29
|
+
};
|
|
30
|
+
// ---- exec (spec §4.2): non-interactive commands with durable per-exec output logs ----------------------------------
|
|
31
|
+
/** `keeper.hello` capability: `exec.*` is served. */
|
|
32
|
+
export const EXEC_CAPABILITY = 'exec';
|
|
33
|
+
export const EXEC_METHODS = ['exec.start', 'exec.list', 'exec.status', 'exec.read', 'exec.subscribe', 'exec.kill', 'exec.commit', 'exec.checkpoint'];
|
|
34
|
+
/** The kit stream (`keeper.subscribe`) carrying one exec's records. */
|
|
35
|
+
export const execStream = (execId) => `exec/${execId}`;
|
|
36
|
+
/** Spec §14. The daemon passes the per-exec values with `exec.start`; these are the keeper's own bounds and defaults. */
|
|
37
|
+
export const EXEC_RUNNING_LIMIT = 128;
|
|
38
|
+
export const EXEC_TOTAL_BYTES = 512 * 1024 * 1024;
|
|
39
|
+
export const EXEC_TERMINAL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
/** Largest payload of one out/err record (a pipe read is at most 64 KiB; anything bigger is split). */
|
|
41
|
+
export const EXEC_CHUNK_BYTES = 64 * 1024;
|
|
42
|
+
/** Exec streams use a relaxed fsync policy (spawn/exit/commit/checkpoint still fsync at once); lost output on a power cut reads as keeper_lost. */
|
|
43
|
+
export const EXEC_FSYNC_INTERVAL_MS = 1000;
|
|
44
|
+
export const EXEC_FSYNC_BYTES = 4 * 1024 * 1024;
|
|
45
|
+
/** The kit's segment size (spec §14). Sealing a segment fsyncs it, so smaller segments would slow a flood down measurably. */
|
|
46
|
+
export const EXEC_SEGMENT_BYTES = 8 * 1024 * 1024;
|
|
47
|
+
export const EXEC_LABEL_CHARS = 256;
|
|
48
|
+
export const EXEC_SIGNALS = ['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGHUP', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2'];
|
|
49
|
+
export const ExecErrorCode = {
|
|
50
|
+
badRequest: 'bad_request',
|
|
51
|
+
notFound: 'exec_not_found',
|
|
52
|
+
limit: 'exec_limit',
|
|
53
|
+
/** The exec directory belongs to another live keeper-terminal generation; this one never touches it. */
|
|
54
|
+
foreign: 'exec_foreign',
|
|
55
|
+
notExited: 'exec_not_exited',
|
|
56
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Persistent PTY sessions (tmux-like): each session keeps a headless xterm screen and a bounded ring of rendered output;
|
|
2
|
+
// clients `attach` (snapshot) and `poll(after)` (delta, or a snapshot reset when they fell behind). Control is explicit
|
|
3
|
+
// (controller / takeover / detach), at most 8 sessions, exited sessions stay listed until closed. Spec §4.1.
|
|
4
|
+
//
|
|
5
|
+
// This is the one implementation: keeper-terminal serves it as `pty.*` (main.ts); the daemon's `terminal.*` reaches it through
|
|
6
|
+
// tools/terminalRouter.ts.
|
|
7
|
+
//
|
|
8
|
+
// Keeper-side module: node builtins, kit siblings and the bundled @xterm packages only (the import guard enforces it).
|
|
9
|
+
// Sessions are independent: there is no queue across sessions (a slow operation on one never delays another).
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import xtermHeadless from '@xterm/headless';
|
|
14
|
+
import xtermSerialize from '@xterm/addon-serialize';
|
|
15
|
+
import { KeeperMethodError } from '../kit/protocol.js';
|
|
16
|
+
import { PTY_HISTORY_BYTES, PTY_INPUT_BYTES, PTY_LABEL_CHARS, PTY_MAX_POLL_BYTES, PTY_RESULT_BYTES, PTY_SCROLLBACK, PTY_SESSION_LIMIT, PTY_UNAVAILABLE_ERROR, PtyErrorCode, } from './protocol.js';
|
|
17
|
+
// CommonJS packages: their default export is `module.exports` both under Node and once esbuild bundles them.
|
|
18
|
+
const { Terminal } = xtermHeadless;
|
|
19
|
+
const { SerializeAddon } = xtermSerialize;
|
|
20
|
+
const darwinShellName = (pid) => new Promise((resolve) => {
|
|
21
|
+
execFile('ps', ['-o', 'ucomm=', '-p', String(pid)], { timeout: 1000 }, (error, stdout) => resolve(error ? null : stdout.trim() || null));
|
|
22
|
+
});
|
|
23
|
+
const defaultShellProcessName = (pid) => (process.platform === 'darwin' ? darwinShellName(pid) : Promise.resolve(null));
|
|
24
|
+
// Parameter checks (today's tools/validation.ts rules; kept here so the keeper bundle depends on no daemon module).
|
|
25
|
+
const bad = (message) => { throw new KeeperMethodError(PtyErrorCode.badRequest, message); };
|
|
26
|
+
function keys(params, allowed) {
|
|
27
|
+
if (Object.keys(params).some((key) => !allowed.includes(key)))
|
|
28
|
+
bad('Unexpected tool parameter');
|
|
29
|
+
}
|
|
30
|
+
function identifier(params, name) {
|
|
31
|
+
const value = params[name];
|
|
32
|
+
if (typeof value !== 'string' || !value.length || value.includes('\0') || value.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(value))
|
|
33
|
+
bad(`Invalid ${name}`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function integer(params, name, min, max, fallback) {
|
|
37
|
+
const value = params[name] === undefined ? fallback : params[name];
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
39
|
+
bad(`Invalid ${name}`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/** The basename of a foreground process name, without the leading '-' a login shell may carry. */
|
|
43
|
+
export const processBasename = (name) => path.basename(name.trim()).replace(/^-/, '');
|
|
44
|
+
export const CREATE_KEYS = ['id', 'clientId', 'name', 'cwd', 'cols', 'rows'];
|
|
45
|
+
export const PTY_OPERATIONS = new Set(['attach', 'poll', 'write', 'resize', 'takeover', 'detach', 'close']);
|
|
46
|
+
export class PtySessions {
|
|
47
|
+
options;
|
|
48
|
+
sessions = new Map();
|
|
49
|
+
ptyModule;
|
|
50
|
+
error;
|
|
51
|
+
now;
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
this.now = options.now ?? Date.now;
|
|
55
|
+
try {
|
|
56
|
+
this.ptyModule = (options.loadPty ?? (() => createRequire(import.meta.url)('node-pty')))();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
this.error = PTY_UNAVAILABLE_ERROR;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
get available() { return !!this.ptyModule; }
|
|
63
|
+
descriptor(s) {
|
|
64
|
+
return { id: s.id, name: s.name, cwd: s.cwd, cols: s.cols, rows: s.rows, exited: s.exited, exitCode: s.exitCode, controllerId: s.controllerId };
|
|
65
|
+
}
|
|
66
|
+
control(s, clientId) {
|
|
67
|
+
if (s.controllerId !== clientId)
|
|
68
|
+
throw new KeeperMethodError(PtyErrorCode.notController, 'Terminal is controlled by another client; take control explicitly');
|
|
69
|
+
}
|
|
70
|
+
resize(s, p) {
|
|
71
|
+
const cols = integer(p, 'cols', 20, 300, s.cols);
|
|
72
|
+
const rows = integer(p, 'rows', 5, 100, s.rows);
|
|
73
|
+
if (cols !== s.cols || rows !== s.rows) {
|
|
74
|
+
if (!s.exited)
|
|
75
|
+
s.pty.resize(cols, rows);
|
|
76
|
+
s.terminal.resize(cols, rows);
|
|
77
|
+
s.cols = cols;
|
|
78
|
+
s.rows = rows;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
snapshot(s) {
|
|
82
|
+
// Scroll position belongs to the viewer, never the keeper. Serialization includes normal and
|
|
83
|
+
// alternate buffers, SGR attributes, cursor and modes; it starts from a clean emulator.
|
|
84
|
+
for (let scrollback = PTY_SCROLLBACK;; scrollback = Math.floor(scrollback / 2)) {
|
|
85
|
+
const snapshot = '\x1bc' + s.serializer.serialize({ scrollback });
|
|
86
|
+
if (Buffer.byteLength(JSON.stringify({ snapshot })) < PTY_RESULT_BYTES - 8192)
|
|
87
|
+
return snapshot;
|
|
88
|
+
if (!scrollback)
|
|
89
|
+
throw new KeeperMethodError(PtyErrorCode.snapshotTooLarge, 'Terminal screen exceeds snapshot limit; resize to a smaller viewport');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
output(s, data) {
|
|
93
|
+
if (!data || s.closed)
|
|
94
|
+
return;
|
|
95
|
+
const bytes = Buffer.byteLength(data);
|
|
96
|
+
s.pendingBytes += bytes;
|
|
97
|
+
if (s.pendingBytes > 256 * 1024)
|
|
98
|
+
s.pty.pause();
|
|
99
|
+
// Written to xterm at once, not chained behind the previous write's callback: xterm parses writes and runs their
|
|
100
|
+
// callbacks strictly in order, so sequence numbers stay per event and `rendered` (the newest write's promise) still
|
|
101
|
+
// means "everything received so far is rendered". Chaining cost one timer tick per event, and node-pty on macOS
|
|
102
|
+
// delivers line-by-line output as one ~10-byte event per line (~9 KB/s rendered, a paused shell behind it).
|
|
103
|
+
s.rendered = new Promise((resolve) => {
|
|
104
|
+
s.terminal.write(data, () => {
|
|
105
|
+
// Each PTY event is a sequence boundary. A single large event can exceed the history budget;
|
|
106
|
+
// dropping it is safe because poll then returns a fully rendered snapshot.
|
|
107
|
+
s.seq += 1;
|
|
108
|
+
s.ring.push({ seq: s.seq, data, bytes });
|
|
109
|
+
s.ringBytes += bytes;
|
|
110
|
+
const limit = this.options.historyBytes ?? PTY_HISTORY_BYTES;
|
|
111
|
+
while (s.ringBytes > limit && s.ring.length)
|
|
112
|
+
s.ringBytes -= s.ring.shift().bytes;
|
|
113
|
+
s.pendingBytes -= bytes;
|
|
114
|
+
if (s.pendingBytes < 64 * 1024 && !s.exited)
|
|
115
|
+
s.pty.resume();
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
unavailable() { throw new KeeperMethodError(PtyErrorCode.unavailable, this.error ?? 'Terminal unavailable'); }
|
|
121
|
+
list(p) {
|
|
122
|
+
keys(p, []);
|
|
123
|
+
return { sessions: [...this.sessions.values()].map((s) => this.descriptor(s)) };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Creates session `id`, or returns the existing one (idempotent). `allowedKeys` is the caller's parameter set; `launch`
|
|
127
|
+
* supplies cwd, shell and env and runs only when a session is really started (after the duplicate and limit checks,
|
|
128
|
+
* which are re-checked afterwards because `launch` may be asynchronous).
|
|
129
|
+
*/
|
|
130
|
+
async create(p, allowedKeys, launch) {
|
|
131
|
+
if (!this.ptyModule)
|
|
132
|
+
this.unavailable();
|
|
133
|
+
const id = identifier(p, 'id');
|
|
134
|
+
const clientId = identifier(p, 'clientId');
|
|
135
|
+
keys(p, allowedKeys);
|
|
136
|
+
const cols = integer(p, 'cols', 20, 300, 100);
|
|
137
|
+
const rows = integer(p, 'rows', 5, 100, 30);
|
|
138
|
+
if (p.name !== undefined && (typeof p.name !== 'string' || !p.name.length || p.name.length > 128 || /[\x00-\x1f]/.test(p.name)))
|
|
139
|
+
bad('Invalid terminal name');
|
|
140
|
+
const existing = () => { const old = this.sessions.get(id); return old && this.descriptor(old); };
|
|
141
|
+
const limit = () => { if (this.sessions.size >= PTY_SESSION_LIMIT)
|
|
142
|
+
throw new KeeperMethodError(PtyErrorCode.limit, 'Terminal session limit exceeded'); };
|
|
143
|
+
let old = existing();
|
|
144
|
+
if (old)
|
|
145
|
+
return old;
|
|
146
|
+
limit();
|
|
147
|
+
const { cwd, shell, env } = await launch();
|
|
148
|
+
old = existing();
|
|
149
|
+
if (old)
|
|
150
|
+
return old; // a concurrent create of the same id won
|
|
151
|
+
limit();
|
|
152
|
+
const terminal = new Terminal({ cols, rows, scrollback: PTY_SCROLLBACK, allowProposedApi: true });
|
|
153
|
+
const serializer = new SerializeAddon();
|
|
154
|
+
// Headless and browser xterm share the addon contract, but their declaration packages differ.
|
|
155
|
+
terminal.loadAddon(serializer);
|
|
156
|
+
let pty;
|
|
157
|
+
try {
|
|
158
|
+
pty = this.ptyModule.spawn(shell, ['-l'], { name: 'xterm-256color', cwd, cols, rows, env: { ...env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } });
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
terminal.dispose();
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const created = this.now();
|
|
165
|
+
const shellName = processBasename(shell);
|
|
166
|
+
const session = {
|
|
167
|
+
id, name: p.name ?? 'Terminal', cwd, cols, rows, exited: false, exitCode: null, controllerId: clientId,
|
|
168
|
+
pty, closed: false, terminal, serializer, seq: 0, ring: [], ringBytes: 0, pendingBytes: 0, rendered: Promise.resolve(),
|
|
169
|
+
shellName, exitedAt: 0, shellNames: new Set([shellName]), observed: { label: shellName, state: 'idle', since: created },
|
|
170
|
+
};
|
|
171
|
+
pty.onData((data) => this.output(session, data));
|
|
172
|
+
pty.onExit((event) => { session.exited = true; session.exitCode = event.exitCode; session.exitedAt = this.now(); });
|
|
173
|
+
// Answering terminal queries (DA/DSR, etc.) is keeper-owned and does not require a web client.
|
|
174
|
+
terminal.onData((data) => { if (!session.exited)
|
|
175
|
+
pty.write(data); });
|
|
176
|
+
this.sessions.set(id, session);
|
|
177
|
+
const learn = setTimeout(() => { void this.learnShellName(session); }, this.options.shellNameDelayMs ?? 250);
|
|
178
|
+
learn.unref();
|
|
179
|
+
return this.descriptor(session);
|
|
180
|
+
}
|
|
181
|
+
/** attach / poll / write / resize / takeover / detach / close on an existing session. */
|
|
182
|
+
async operate(op, p) {
|
|
183
|
+
if (!this.ptyModule)
|
|
184
|
+
this.unavailable();
|
|
185
|
+
const id = identifier(p, 'id');
|
|
186
|
+
const clientId = identifier(p, 'clientId');
|
|
187
|
+
const s = this.sessions.get(id);
|
|
188
|
+
if (!s)
|
|
189
|
+
throw new KeeperMethodError(PtyErrorCode.notFound, 'Terminal session not found; keeper restart loses live PTYs');
|
|
190
|
+
switch (op) {
|
|
191
|
+
case 'attach': {
|
|
192
|
+
keys(p, ['id', 'clientId', 'cols', 'rows']);
|
|
193
|
+
// Validate dimensions even for a viewer, but only the controller's dimensions affect PTY.
|
|
194
|
+
integer(p, 'cols', 20, 300, s.cols);
|
|
195
|
+
integer(p, 'rows', 5, 100, s.rows);
|
|
196
|
+
await s.rendered;
|
|
197
|
+
if (s.controllerId === null)
|
|
198
|
+
s.controllerId = clientId;
|
|
199
|
+
if (s.controllerId === clientId)
|
|
200
|
+
this.resize(s, p);
|
|
201
|
+
return { session: this.descriptor(s), seq: s.seq, snapshot: this.snapshot(s), control: s.controllerId === clientId };
|
|
202
|
+
}
|
|
203
|
+
case 'poll': {
|
|
204
|
+
keys(p, ['id', 'clientId', 'after']);
|
|
205
|
+
const after = integer(p, 'after', 0, Number.MAX_SAFE_INTEGER);
|
|
206
|
+
await s.rendered;
|
|
207
|
+
const common = { exited: s.exited, exitCode: s.exitCode, control: s.controllerId === clientId };
|
|
208
|
+
if (after > s.seq || after < (s.ring[0]?.seq ?? s.seq + 1) - 1)
|
|
209
|
+
return { ...common, seq: s.seq, data: '', reset: true, snapshot: this.snapshot(s) };
|
|
210
|
+
let data = '';
|
|
211
|
+
let bytes = 0;
|
|
212
|
+
let seq = after;
|
|
213
|
+
for (const output of s.ring) {
|
|
214
|
+
if (output.seq <= after)
|
|
215
|
+
continue;
|
|
216
|
+
if (bytes + output.bytes > PTY_MAX_POLL_BYTES) {
|
|
217
|
+
if (!bytes)
|
|
218
|
+
return { ...common, seq: s.seq, data: '', reset: true, snapshot: this.snapshot(s) };
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
data += output.data;
|
|
222
|
+
bytes += output.bytes;
|
|
223
|
+
seq = output.seq;
|
|
224
|
+
}
|
|
225
|
+
return { ...common, seq, data, reset: false };
|
|
226
|
+
}
|
|
227
|
+
case 'takeover':
|
|
228
|
+
keys(p, ['id', 'clientId']);
|
|
229
|
+
s.controllerId = clientId;
|
|
230
|
+
return { ok: true };
|
|
231
|
+
case 'detach':
|
|
232
|
+
keys(p, ['id', 'clientId']);
|
|
233
|
+
if (s.controllerId === clientId)
|
|
234
|
+
s.controllerId = null;
|
|
235
|
+
return { ok: true };
|
|
236
|
+
case 'write':
|
|
237
|
+
keys(p, ['id', 'clientId', 'data']);
|
|
238
|
+
this.control(s, clientId);
|
|
239
|
+
if (s.exited)
|
|
240
|
+
throw new KeeperMethodError(PtyErrorCode.exited, 'Terminal has exited');
|
|
241
|
+
if (typeof p.data !== 'string' || Buffer.byteLength(p.data) > PTY_INPUT_BYTES)
|
|
242
|
+
bad('Invalid terminal input');
|
|
243
|
+
s.pty.write(p.data);
|
|
244
|
+
return { ok: true };
|
|
245
|
+
case 'resize':
|
|
246
|
+
keys(p, ['id', 'clientId', 'cols', 'rows']);
|
|
247
|
+
this.control(s, clientId);
|
|
248
|
+
integer(p, 'cols', 20, 300);
|
|
249
|
+
integer(p, 'rows', 5, 100);
|
|
250
|
+
await s.rendered;
|
|
251
|
+
this.resize(s, p);
|
|
252
|
+
return { ok: true };
|
|
253
|
+
case 'close':
|
|
254
|
+
keys(p, ['id', 'clientId']);
|
|
255
|
+
this.control(s, clientId);
|
|
256
|
+
this.sessions.delete(id);
|
|
257
|
+
s.closed = true;
|
|
258
|
+
if (!s.exited)
|
|
259
|
+
s.pty.kill();
|
|
260
|
+
await s.rendered;
|
|
261
|
+
s.terminal.dispose();
|
|
262
|
+
return { ok: true };
|
|
263
|
+
default: return bad('Unsupported terminal tool');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** The tty's foreground process basename, '' when it cannot be read (node-pty falls back to the shell path itself). */
|
|
267
|
+
foreground(s) {
|
|
268
|
+
try {
|
|
269
|
+
return processBasename(String(s.pty.process ?? ''));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Learns once per session, in the background shortly after spawn, what the tty calls the shell itself (macOS `p_comm`).
|
|
277
|
+
* Until it resolves (or when it fails) the configured basename is the only shell name: activity never waits on a subprocess.
|
|
278
|
+
*/
|
|
279
|
+
async learnShellName(s) {
|
|
280
|
+
if (s.closed || s.exited)
|
|
281
|
+
return;
|
|
282
|
+
try {
|
|
283
|
+
const name = await (this.options.shellProcessName ?? defaultShellProcessName)(s.pty.pid);
|
|
284
|
+
if (name)
|
|
285
|
+
s.shellNames.add(processBasename(name));
|
|
286
|
+
}
|
|
287
|
+
catch { /* keep the configured basename */ }
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* A live sample for `keeper.activity` (spec §3.5/§4.1): `ptyOpen` live sessions, `ptyActive` live sessions whose foreground
|
|
291
|
+
* process is not their shell, `ptyExited` exited sessions still listed. One item per session, labelled with the foreground
|
|
292
|
+
* process basename (never its arguments), `sinceEpochMs` = when this label/state was first observed.
|
|
293
|
+
*/
|
|
294
|
+
activity() {
|
|
295
|
+
const now = this.now();
|
|
296
|
+
let open = 0;
|
|
297
|
+
let active = 0;
|
|
298
|
+
let exited = 0;
|
|
299
|
+
const items = [];
|
|
300
|
+
for (const s of this.sessions.values()) {
|
|
301
|
+
if (s.exited) {
|
|
302
|
+
exited += 1;
|
|
303
|
+
items.push({ type: 'pty', id: s.id, label: s.shellName.slice(0, PTY_LABEL_CHARS), state: 'exited', sinceEpochMs: s.exitedAt });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
open += 1;
|
|
307
|
+
const fg = this.foreground(s);
|
|
308
|
+
const busy = fg !== '' && !s.shellNames.has(fg);
|
|
309
|
+
const label = (busy ? fg : s.shellName).slice(0, PTY_LABEL_CHARS);
|
|
310
|
+
const state = busy ? 'active' : 'idle';
|
|
311
|
+
if (s.observed.label !== label || s.observed.state !== state)
|
|
312
|
+
s.observed = { label, state, since: now };
|
|
313
|
+
if (busy)
|
|
314
|
+
active += 1;
|
|
315
|
+
items.push({ type: 'pty', id: s.id, label, state, sinceEpochMs: s.observed.since });
|
|
316
|
+
}
|
|
317
|
+
return { metrics: { ptyOpen: open, ptyActive: active, ptyExited: exited }, items };
|
|
318
|
+
}
|
|
319
|
+
/** Kills every session (the keeper is stopping). */
|
|
320
|
+
async dispose() {
|
|
321
|
+
for (const session of this.sessions.values()) {
|
|
322
|
+
session.closed = true;
|
|
323
|
+
if (!session.exited)
|
|
324
|
+
session.pty.kill();
|
|
325
|
+
await session.rendered;
|
|
326
|
+
session.terminal.dispose();
|
|
327
|
+
}
|
|
328
|
+
this.sessions.clear();
|
|
329
|
+
}
|
|
330
|
+
}
|