@platless/axom-cli 0.6.36 → 0.6.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/axom-patch/cli/README.md +31 -2
- package/dist/axom-patch/cli/account-limits/generate.py +2 -2
- package/dist/axom-patch/cli/claude-replay-lifecycle.json +16 -0
- package/dist/axom-patch/cli/codex-terminal-output.json +2 -2
- package/dist/axom-patch/cli/probes/claude-replay-lifecycle.mjs +31 -0
- package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +1 -1
- package/dist/axom-patch/cli/probes/zcode-env-account.mjs +81 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +7 -0
- package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +137 -41
- package/dist/axom-patch/cli/qoder/releases/1.1.75.json +5 -88
- package/dist/axom-patch/cli/qoder-native-acp.json +23 -23
- package/dist/axom-patch/cli/runtime.json +3 -1
- package/dist/axom-patch/cli/zcode/env-account.mjs +13 -0
- package/dist/axom-patch/cli/zcode/generate-env-account.py +52 -0
- package/dist/axom-patch/cli/zcode-account-limits.json +7 -7
- package/dist/axom-patch/cli/zcode-env-account.json +58 -0
- package/dist/axom-patch/cli/zcode-models.json +9 -9
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +10 -10
- package/dist/cli.js +9 -11
- package/dist/commands/auth.js +4 -1
- package/dist/commands/update.js +68 -7
- package/dist/commands/workstation.js +94 -223
- package/dist/commands/workstationRegistrations.js +116 -0
- package/dist/credentialBroker.js +13 -2
- package/dist/credentials.js +16 -0
- package/dist/install/claude/commands.js +2 -2
- package/dist/keepers/terminal/keeper.mjs +1089 -673
- package/dist/keepers/terminal/manifest.json +4 -4
- package/dist/native/darwin-x64/axom-quic +0 -0
- package/dist/workstation/acp/acpAgents.js +11 -4
- package/dist/workstation/acp/outbox.js +12 -5
- package/dist/workstation/acp/permission.js +2 -2
- package/dist/workstation/acp/processor/checkpoint.js +4 -3
- package/dist/workstation/acp/processor/control.js +2 -2
- package/dist/workstation/acp/processor/jsonrpc.js +8 -0
- package/dist/workstation/acp/processor/session.js +3 -2
- package/dist/workstation/acp/processor/state.js +1 -0
- package/dist/workstation/acp/processor/transport/local.js +19 -3
- package/dist/workstation/acp/processor/updates.js +25 -0
- package/dist/workstation/acp/processorSession.js +12 -1
- package/dist/workstation/acp/registry/catalog.js +6 -6
- package/dist/workstation/acp/test/keeperWorld.js +4 -3
- package/dist/workstation/agentSessions.js +76 -23
- package/dist/workstation/channels/agent.js +9 -1
- package/dist/workstation/channels/computer.js +27 -4
- package/dist/workstation/channels/management.js +36 -5
- package/dist/workstation/config.js +18 -6
- package/dist/workstation/control/bindingStore.js +4 -0
- package/dist/workstation/control/commands.js +1 -0
- package/dist/workstation/control/daemonIdentity.js +90 -0
- package/dist/workstation/control/deviceCommands.js +191 -0
- package/dist/workstation/control/deviceProtocol.js +68 -0
- package/dist/workstation/control/locks.js +51 -4
- package/dist/workstation/control/rootFormat.js +16 -0
- package/dist/workstation/control/runtimeClient.js +60 -5
- package/dist/workstation/control/runtimeHost.js +96 -9
- package/dist/workstation/control/serverIdentity.js +2 -2
- package/dist/workstation/control/updateTransition.js +91 -0
- package/dist/workstation/daemon.js +262 -360
- package/dist/workstation/daemonctl.js +150 -12
- package/dist/workstation/desktopControl/automationDevice.js +219 -0
- package/dist/workstation/desktopControl/automationJson.js +74 -0
- package/dist/workstation/desktopControl/automationLocalState.js +23 -0
- package/dist/workstation/desktopControl/automationPreparation.js +1 -0
- package/dist/workstation/desktopControl/automationProtocol.js +253 -0
- package/dist/workstation/desktopControl/automationSchema.generated.js +1 -0
- package/dist/workstation/desktopControl/automationSession.js +81 -0
- package/dist/workstation/desktopControl/automationTransport.js +138 -0
- package/dist/workstation/desktopControl/capabilities.js +2 -1
- package/dist/workstation/desktopControl/deviceAdmission.js +229 -0
- package/dist/workstation/desktopControl/deviceOwnership.js +130 -0
- package/dist/workstation/desktopControl/deviceSockets.js +2 -2
- package/dist/workstation/desktopControl/helperProcess.js +250 -1
- package/dist/workstation/desktopControl/hostGrant.js +65 -0
- package/dist/workstation/desktopControl/lobby.js +4 -2
- package/dist/workstation/desktopControl/macProvider.js +41 -40
- package/dist/workstation/desktopControl/nativeDeviceAdmission.js +207 -0
- package/dist/workstation/desktopControl/nativeProvider.js +31 -5
- package/dist/workstation/desktopControl/ownershipProtocol.js +31 -0
- package/dist/workstation/desktopControl/protocol.js +15 -6
- package/dist/workstation/desktopControl/supervisor.js +110 -17
- package/dist/workstation/desktopControl/wallpaper.js +26 -6
- package/dist/workstation/exec/policy.js +5 -0
- package/dist/workstation/files/client.js +10 -1
- package/dist/workstation/files/host.js +9 -2
- package/dist/workstation/journal.js +5 -2
- package/dist/workstation/keepers/acp/client.js +38 -2
- package/dist/workstation/keepers/kit/buildVersion.js +35 -11
- package/dist/workstation/keepers/kit/client.js +2 -2
- package/dist/workstation/keepers/kit/launcher.js +80 -4
- package/dist/workstation/keepers/legacy.js +16 -2
- package/dist/workstation/keepers/policy.js +5 -34
- package/dist/workstation/keepers/terminal/client.js +115 -23
- package/dist/workstation/keepers/terminal/exec.js +6 -3
- package/dist/workstation/keepers/terminal/frames.js +73 -0
- package/dist/workstation/keepers/terminal/generations.js +5 -8
- package/dist/workstation/keepers/terminal/gridProtocol.js +16 -0
- package/dist/workstation/keepers/terminal/input.js +146 -0
- package/dist/workstation/keepers/terminal/keeper.js +22 -8
- package/dist/workstation/keepers/terminal/main.js +1 -1
- package/dist/workstation/keepers/terminal/metadata.js +138 -0
- package/dist/workstation/keepers/terminal/protocol.js +4 -28
- package/dist/workstation/keepers/terminal/pty.js +1 -330
- package/dist/workstation/keepers/terminal/screen.js +246 -0
- package/dist/workstation/keepers/terminal/sessions.js +526 -0
- package/dist/workstation/oauth/deviceAuthorization.js +31 -17
- package/dist/workstation/protocol/management.js +23 -4
- package/dist/workstation/rd/artifact.js +4 -3
- package/dist/workstation/rd/control.js +3 -5
- package/dist/workstation/rd/host.js +213 -0
- package/dist/workstation/rd/migrationReadiness.js +46 -0
- package/dist/workstation/rd/provider.js +44 -10
- package/dist/workstation/rd/state.js +14 -2
- package/dist/workstation/rd/update.js +5 -8
- package/dist/workstation/rd/upkeep.js +44 -2
- package/dist/workstation/registrations/enrollment.js +250 -0
- package/dist/workstation/registrations/enrollmentPending.js +361 -0
- package/dist/workstation/registrations/migration.js +356 -0
- package/dist/workstation/registrations/model.js +34 -0
- package/dist/workstation/registrations/removal.js +222 -0
- package/dist/workstation/registrations/store.js +737 -0
- package/dist/workstation/runtime/desktopBinding.js +20 -0
- package/dist/workstation/runtime/deviceDesktop.js +193 -0
- package/dist/workstation/runtime/deviceRuntime.js +568 -0
- package/dist/workstation/runtime/deviceServices.js +117 -0
- package/dist/workstation/runtime/events.js +20 -0
- package/dist/workstation/runtime/ownership.js +100 -0
- package/dist/workstation/runtime/registrationRuntime.js +575 -0
- package/dist/workstation/runtime/remoteDesktopAdapter.js +186 -0
- package/dist/workstation/runtime/removalActivity.js +184 -0
- package/dist/workstation/service/installer.js +93 -1
- package/dist/workstation/service/launchd.js +1 -0
- package/dist/workstation/service/systemd.js +1 -0
- package/dist/workstation/systemMetrics.js +3 -0
- package/dist/workstation/tools/client.js +8 -27
- package/dist/workstation/tools/info.js +6 -13
- package/dist/workstation/tools/terminalRouter.js +17 -172
- package/dist/workstation/tools/validation.js +55 -1
- package/dist/workstation/update/archiveEvidence.js +206 -0
- package/dist/workstation/update/coordinator.js +34 -13
- package/dist/workstation/update/deviceCompatibility.js +119 -0
- package/dist/workstation/update/deviceCoordinator.js +369 -0
- package/dist/workstation/update/deviceHealth.js +104 -0
- package/dist/workstation/update/deviceHealthProtocol.js +26 -0
- package/dist/workstation/update/deviceRecoveryLifecycle.js +46 -0
- package/dist/workstation/update/deviceRollback.js +78 -0
- package/dist/workstation/update/globalInstall.js +80 -10
- package/dist/workstation/update/legacyBridge.js +414 -0
- package/dist/workstation/update/legacyOwner.js +39 -0
- package/dist/workstation/update/npmInstaller.js +2 -2
- package/dist/workstation/update/privateDurability.js +61 -0
- package/dist/workstation/update/recoveryPlan.js +124 -0
- package/dist/workstation/update/runUpdate.js +7 -1
- package/dist/workstation/update/taskRegistry.js +161 -3
- package/dist/workstation/update/watchdog.js +27427 -14410
- package/dist/workstation/update/watchdogProcess.js +72 -0
- package/package.json +7 -3
- package/dist/native/darwin-arm64/axom-quic +0 -0
|
@@ -1,330 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import xtermHeadless from '@xterm/headless';
|
|
2
|
+
import { TERMINAL_GRID_LIMITS, TERMINAL_RGB_COLOR_BASE, TERMINAL_STYLE_FLAGS, } from './gridProtocol.js';
|
|
3
|
+
const { Terminal } = xtermHeadless;
|
|
4
|
+
const mouseEncodings = { 1005: 'utf8', 1006: 'sgr', 1015: 'urxvt', 1016: 'sgr-pixels' };
|
|
5
|
+
/** Keeper-owned emulator and its single shared, native xterm viewport. */
|
|
6
|
+
export class TerminalScreen {
|
|
7
|
+
terminal;
|
|
8
|
+
settled = Promise.resolve();
|
|
9
|
+
resizeBarrier;
|
|
10
|
+
pending = new Set();
|
|
11
|
+
closed = false;
|
|
12
|
+
normalRestoreTop;
|
|
13
|
+
alternateScroll = false;
|
|
14
|
+
mouseEncoding = 'default';
|
|
15
|
+
constructor(cols, rows) {
|
|
16
|
+
this.terminal = new Terminal({ cols, rows, scrollback: TERMINAL_GRID_LIMITS.scrollbackLines, allowProposedApi: true });
|
|
17
|
+
// Observe only the input modes absent from xterm's public API. Native parsing
|
|
18
|
+
// still owns all escape processing; these handlers fall through to xterm.
|
|
19
|
+
for (const final of ['h', 'l'])
|
|
20
|
+
this.terminal.parser.registerCsiHandler({ prefix: '?', final }, params => {
|
|
21
|
+
for (const param of params) {
|
|
22
|
+
if (param === 1007)
|
|
23
|
+
this.alternateScroll = final === 'h';
|
|
24
|
+
const encoding = typeof param === 'number' ? mouseEncodings[param] : undefined;
|
|
25
|
+
if (encoding)
|
|
26
|
+
this.mouseEncoding = final === 'h' ? encoding : 'default';
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
});
|
|
30
|
+
this.terminal.parser.registerEscHandler({ final: 'c' }, () => {
|
|
31
|
+
this.alternateScroll = false;
|
|
32
|
+
this.mouseEncoding = 'default';
|
|
33
|
+
return false;
|
|
34
|
+
});
|
|
35
|
+
this.terminal.buffer.onBufferChange(buffer => {
|
|
36
|
+
// xterm's isUserScrolling flag is shared by both buffers. Alternate-buffer
|
|
37
|
+
// scrolling must not change whether normal-buffer output follows the cursor.
|
|
38
|
+
const top = buffer.type === 'normal' ? this.normalRestoreTop ?? buffer.viewportY : buffer.viewportY;
|
|
39
|
+
if (buffer.type === 'normal')
|
|
40
|
+
this.normalRestoreTop = undefined;
|
|
41
|
+
this.terminal.scrollToBottom();
|
|
42
|
+
this.terminal.scrollToLine(top);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
write(data) {
|
|
46
|
+
if (this.closed)
|
|
47
|
+
return Promise.reject(new Error('Terminal screen is disposed'));
|
|
48
|
+
const barrier = this.resizeBarrier;
|
|
49
|
+
const operation = new Promise((resolve, reject) => {
|
|
50
|
+
this.pending.add(reject);
|
|
51
|
+
const write = () => {
|
|
52
|
+
if (this.closed)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
this.terminal.write(data, () => { this.pending.delete(reject); resolve(); });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.pending.delete(reject);
|
|
59
|
+
reject(error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
// Batch ordinary PTY writes directly into xterm; do not add a timer per chunk.
|
|
63
|
+
if (barrier)
|
|
64
|
+
void barrier.then(write, error => { this.pending.delete(reject); reject(error); });
|
|
65
|
+
else
|
|
66
|
+
write();
|
|
67
|
+
});
|
|
68
|
+
this.settled = operation;
|
|
69
|
+
return operation;
|
|
70
|
+
}
|
|
71
|
+
resize(cols, rows) {
|
|
72
|
+
if (this.closed)
|
|
73
|
+
return Promise.reject(new Error('Terminal screen is disposed'));
|
|
74
|
+
const previous = this.settled;
|
|
75
|
+
const operation = previous.then(() => {
|
|
76
|
+
if (this.closed)
|
|
77
|
+
throw new Error('Terminal screen is disposed');
|
|
78
|
+
const normal = this.terminal.buffer.normal;
|
|
79
|
+
const anchor = this.anchor(normal);
|
|
80
|
+
const live = (this.normalRestoreTop ?? normal.viewportY) === normal.baseY;
|
|
81
|
+
this.terminal.resize(cols, rows);
|
|
82
|
+
// The native resize reflows both buffers. Restore a content anchor, never
|
|
83
|
+
// the old numerical line index, which now refers to different content.
|
|
84
|
+
const top = live ? normal.baseY : anchor ? this.anchorLine(anchor, normal) : 0;
|
|
85
|
+
if (this.terminal.buffer.active.type === 'normal') {
|
|
86
|
+
this.terminal.scrollToBottom();
|
|
87
|
+
this.terminal.scrollToLine(top);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
this.normalRestoreTop = Math.min(top, normal.baseY);
|
|
91
|
+
}
|
|
92
|
+
anchor?.marker.dispose();
|
|
93
|
+
});
|
|
94
|
+
this.resizeBarrier = operation;
|
|
95
|
+
this.settled = operation;
|
|
96
|
+
void operation.then(() => { if (this.resizeBarrier === operation)
|
|
97
|
+
this.resizeBarrier = undefined; }, () => {
|
|
98
|
+
if (this.resizeBarrier === operation)
|
|
99
|
+
this.resizeBarrier = undefined;
|
|
100
|
+
});
|
|
101
|
+
return operation;
|
|
102
|
+
}
|
|
103
|
+
scroll(lines) {
|
|
104
|
+
this.assertOpen();
|
|
105
|
+
this.terminal.scrollLines(lines);
|
|
106
|
+
}
|
|
107
|
+
/** Only settled state may be published; callers await write/resize before capture. */
|
|
108
|
+
capture() {
|
|
109
|
+
this.assertOpen();
|
|
110
|
+
if (this.pending.size || this.resizeBarrier)
|
|
111
|
+
throw new Error('Terminal screen has pending writes or resize');
|
|
112
|
+
const terminal = this.terminal;
|
|
113
|
+
const buffer = terminal.buffer.active;
|
|
114
|
+
const core = terminal._core;
|
|
115
|
+
const styles = [];
|
|
116
|
+
const styleIndices = new Map();
|
|
117
|
+
const changedRows = Array.from({ length: terminal.rows }, (_, index) => {
|
|
118
|
+
const line = buffer.getLine(buffer.viewportY + index);
|
|
119
|
+
const runs = [];
|
|
120
|
+
for (let x = 0; x < terminal.cols; x++) {
|
|
121
|
+
const cell = line.getCell(x);
|
|
122
|
+
const style = cellStyle(cell);
|
|
123
|
+
const key = `${style.fg},${style.bg},${style.flags}`;
|
|
124
|
+
let styleIndex = styleIndices.get(key);
|
|
125
|
+
if (styleIndex === undefined) {
|
|
126
|
+
styleIndex = styles.length;
|
|
127
|
+
styles.push(style);
|
|
128
|
+
styleIndices.set(key, styleIndex);
|
|
129
|
+
}
|
|
130
|
+
let run = runs[runs.length - 1];
|
|
131
|
+
if (!run || run.style !== styleIndex) {
|
|
132
|
+
run = { style: styleIndex, cells: [] };
|
|
133
|
+
runs.push(run);
|
|
134
|
+
}
|
|
135
|
+
// A width-zero continuation still occupies one grid column. Never drop it.
|
|
136
|
+
run.cells.push({ text: cell.getChars(), width: cell.getWidth() });
|
|
137
|
+
}
|
|
138
|
+
return { index, runs, wrapped: line.isWrapped };
|
|
139
|
+
});
|
|
140
|
+
const y = buffer.baseY + buffer.cursorY - buffer.viewportY;
|
|
141
|
+
return {
|
|
142
|
+
cols: terminal.cols, rows: terminal.rows, buffer: buffer.type,
|
|
143
|
+
viewport: { top: buffer.viewportY, totalLines: buffer.length, live: buffer.viewportY === buffer.baseY },
|
|
144
|
+
cursor: {
|
|
145
|
+
x: Math.min(buffer.cursorX, terminal.cols - 1), y,
|
|
146
|
+
visible: !core.coreService.isCursorHidden && y >= 0 && y < terminal.rows,
|
|
147
|
+
shape: core.coreService.decPrivateModes.cursorStyle ?? terminal.options.cursorStyle ?? 'block',
|
|
148
|
+
blink: core.coreService.decPrivateModes.cursorBlink ?? terminal.options.cursorBlink ?? false,
|
|
149
|
+
},
|
|
150
|
+
modes: this.state().modes,
|
|
151
|
+
styles, changedRows,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Cheap parsed state for input routing; never extracts viewport cells. */
|
|
155
|
+
state() {
|
|
156
|
+
this.assertOpen();
|
|
157
|
+
if (this.pending.size || this.resizeBarrier)
|
|
158
|
+
throw new Error('Terminal screen has pending writes or resize');
|
|
159
|
+
const terminal = this.terminal;
|
|
160
|
+
const modes = terminal.modes;
|
|
161
|
+
return {
|
|
162
|
+
buffer: terminal.buffer.active.type,
|
|
163
|
+
modes: {
|
|
164
|
+
applicationCursor: modes.applicationCursorKeysMode, applicationKeypad: modes.applicationKeypadMode,
|
|
165
|
+
bracketedPaste: modes.bracketedPasteMode, alternateScroll: this.alternateScroll, focusReporting: modes.sendFocusMode,
|
|
166
|
+
mouseTracking: modes.mouseTrackingMode,
|
|
167
|
+
mouseEncoding: this.mouseEncoding,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
onTitleChange(listener) { return this.terminal.onTitleChange(listener); }
|
|
172
|
+
onCwdChange(listener) {
|
|
173
|
+
return this.terminal.parser.registerOscHandler(7, uri => { listener(uri); return true; });
|
|
174
|
+
}
|
|
175
|
+
onReply(listener) { return this.terminal.onData(listener); }
|
|
176
|
+
dispose() {
|
|
177
|
+
if (this.closed)
|
|
178
|
+
return;
|
|
179
|
+
this.closed = true;
|
|
180
|
+
for (const reject of this.pending)
|
|
181
|
+
reject(new Error('Terminal screen is disposed'));
|
|
182
|
+
this.pending.clear();
|
|
183
|
+
this.terminal.dispose();
|
|
184
|
+
}
|
|
185
|
+
assertOpen() { if (this.closed)
|
|
186
|
+
throw new Error('Terminal screen is disposed'); }
|
|
187
|
+
anchor(buffer) {
|
|
188
|
+
const top = this.normalRestoreTop ?? buffer.viewportY;
|
|
189
|
+
if (top === buffer.baseY)
|
|
190
|
+
return undefined;
|
|
191
|
+
let end = top + 1;
|
|
192
|
+
while (buffer.getLine(end)?.isWrapped)
|
|
193
|
+
end++;
|
|
194
|
+
let cellsFromEnd = this.trimmedLength(buffer, end - 1);
|
|
195
|
+
for (let line = top; line < end - 1; line++)
|
|
196
|
+
cellsFromEnd += this.wrappedLength(buffer, line);
|
|
197
|
+
// Anchor AFTER the logical line. Its beginning can be evicted during reflow
|
|
198
|
+
// while the segment being read survives. A marker at that beginning would
|
|
199
|
+
// incorrectly lose the surviving segment. The end marker follows native
|
|
200
|
+
// insert/delete/trim events, then we walk backwards over the reflowed cells.
|
|
201
|
+
// Public registerMarker cannot target inactive normal history.
|
|
202
|
+
const marker = this.terminal.buffer.active.type === 'normal'
|
|
203
|
+
? this.terminal.registerMarker(end - buffer.baseY - buffer.cursorY)
|
|
204
|
+
: this.terminal._core.buffers.normal.addMarker(end);
|
|
205
|
+
return marker ? { marker, cellsFromEnd } : undefined;
|
|
206
|
+
}
|
|
207
|
+
anchorLine(anchor, buffer) {
|
|
208
|
+
if (anchor.marker.isDisposed)
|
|
209
|
+
return 0;
|
|
210
|
+
let line = Math.min(anchor.marker.line, buffer.length) - 1;
|
|
211
|
+
if (line < 0)
|
|
212
|
+
return 0;
|
|
213
|
+
let remaining = anchor.cellsFromEnd - this.trimmedLength(buffer, line);
|
|
214
|
+
while (remaining > 0 && line > 0 && buffer.getLine(line)?.isWrapped) {
|
|
215
|
+
line--;
|
|
216
|
+
remaining -= this.wrappedLength(buffer, line);
|
|
217
|
+
}
|
|
218
|
+
return line;
|
|
219
|
+
}
|
|
220
|
+
trimmedLength(buffer, line) {
|
|
221
|
+
const row = buffer.getLine(line);
|
|
222
|
+
for (let x = this.terminal.cols - 1; x >= 0; x--) {
|
|
223
|
+
const cell = row.getCell(x);
|
|
224
|
+
if (cell.getChars())
|
|
225
|
+
return x + cell.getWidth();
|
|
226
|
+
}
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
wrappedLength(buffer, line) {
|
|
230
|
+
const last = buffer.getLine(line).getCell(this.terminal.cols - 1);
|
|
231
|
+
// xterm leaves one empty padding cell when a wide glyph wraps at the edge.
|
|
232
|
+
return this.terminal.cols - (last.getChars() === '' && last.getWidth() === 1
|
|
233
|
+
&& buffer.getLine(line + 1)?.getCell(0)?.getWidth() === 2 ? 1 : 0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function cellStyle(cell) {
|
|
237
|
+
const flags = TERMINAL_STYLE_FLAGS;
|
|
238
|
+
return {
|
|
239
|
+
fg: cell.isFgDefault() ? -1 : cell.getFgColor() + (cell.isFgRGB() ? TERMINAL_RGB_COLOR_BASE : 0),
|
|
240
|
+
bg: cell.isBgDefault() ? -1 : cell.getBgColor() + (cell.isBgRGB() ? TERMINAL_RGB_COLOR_BASE : 0),
|
|
241
|
+
flags: (cell.isBold() ? flags.bold : 0) | (cell.isDim() ? flags.dim : 0)
|
|
242
|
+
| (cell.isItalic() ? flags.italic : 0) | (cell.isUnderline() ? flags.underline : 0)
|
|
243
|
+
| (cell.isBlink() ? flags.blink : 0) | (cell.isInverse() ? flags.inverse : 0)
|
|
244
|
+
| (cell.isInvisible() ? flags.invisible : 0) | (cell.isStrikethrough() ? flags.strike : 0),
|
|
245
|
+
};
|
|
246
|
+
}
|