@platless/axom-cli 0.1.0 → 0.3.13
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 +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +37 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/cli.js +9 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { identifier, integer, keys, TOOL_RESULT_BYTES } from './validation.js';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const { Terminal } = require('@xterm/headless');
|
|
6
|
+
const { SerializeAddon } = require('@xterm/addon-serialize');
|
|
7
|
+
const HISTORY_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_POLL_BYTES = 64 * 1024;
|
|
9
|
+
const SCROLLBACK = 1000;
|
|
10
|
+
export class PersistentTerminals {
|
|
11
|
+
files;
|
|
12
|
+
options;
|
|
13
|
+
sessions = new Map();
|
|
14
|
+
ptyModule;
|
|
15
|
+
error;
|
|
16
|
+
constructor(files, options) {
|
|
17
|
+
this.files = files;
|
|
18
|
+
this.options = options;
|
|
19
|
+
try {
|
|
20
|
+
this.ptyModule = (options.loadPty ?? (() => require('node-pty')))();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
this.error = 'Terminal native dependency unavailable; reinstall CLI with optional node-pty support';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
get available() { return !!this.ptyModule; }
|
|
27
|
+
descriptor(s) {
|
|
28
|
+
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 };
|
|
29
|
+
}
|
|
30
|
+
control(s, clientId) {
|
|
31
|
+
if (s.controllerId !== clientId)
|
|
32
|
+
throw new Error('Terminal is controlled by another client; take control explicitly');
|
|
33
|
+
}
|
|
34
|
+
resize(s, p) {
|
|
35
|
+
const cols = integer(p, 'cols', 20, 300, s.cols);
|
|
36
|
+
const rows = integer(p, 'rows', 5, 100, s.rows);
|
|
37
|
+
if (cols !== s.cols || rows !== s.rows) {
|
|
38
|
+
if (!s.exited)
|
|
39
|
+
s.pty.resize(cols, rows);
|
|
40
|
+
s.terminal.resize(cols, rows);
|
|
41
|
+
s.cols = cols;
|
|
42
|
+
s.rows = rows;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
snapshot(s) {
|
|
46
|
+
// Scroll position belongs to the viewer, never the keeper. Serialization includes normal and
|
|
47
|
+
// alternate buffers, SGR attributes, cursor and modes; it starts from a clean emulator.
|
|
48
|
+
for (let scrollback = SCROLLBACK;; scrollback = Math.floor(scrollback / 2)) {
|
|
49
|
+
const snapshot = '\x1bc' + s.serializer.serialize({ scrollback });
|
|
50
|
+
if (Buffer.byteLength(JSON.stringify({ snapshot })) < TOOL_RESULT_BYTES - 8192)
|
|
51
|
+
return snapshot;
|
|
52
|
+
if (!scrollback)
|
|
53
|
+
throw new Error('Terminal screen exceeds snapshot limit; resize to a smaller viewport');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
output(s, data) {
|
|
57
|
+
if (!data || s.closed)
|
|
58
|
+
return;
|
|
59
|
+
const bytes = Buffer.byteLength(data);
|
|
60
|
+
s.pendingBytes += bytes;
|
|
61
|
+
if (s.pendingBytes > 256 * 1024)
|
|
62
|
+
s.pty.pause();
|
|
63
|
+
const previous = s.rendered;
|
|
64
|
+
s.rendered = previous.then(() => new Promise(resolve => {
|
|
65
|
+
s.terminal.write(data, () => {
|
|
66
|
+
// Each PTY event is a sequence boundary. A single large event can exceed the history budget;
|
|
67
|
+
// dropping it is safe because poll then returns a fully rendered snapshot.
|
|
68
|
+
s.seq += 1;
|
|
69
|
+
s.ring.push({ seq: s.seq, data, bytes });
|
|
70
|
+
s.ringBytes += bytes;
|
|
71
|
+
const limit = this.options.historyBytes ?? HISTORY_BYTES;
|
|
72
|
+
while (s.ringBytes > limit && s.ring.length)
|
|
73
|
+
s.ringBytes -= s.ring.shift().bytes;
|
|
74
|
+
s.pendingBytes -= bytes;
|
|
75
|
+
if (s.pendingBytes < 64 * 1024 && !s.exited)
|
|
76
|
+
s.pty.resume();
|
|
77
|
+
resolve();
|
|
78
|
+
});
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
async dispatch(method, p) {
|
|
82
|
+
if (method === 'terminal.list') {
|
|
83
|
+
keys(p, []);
|
|
84
|
+
return { sessions: [...this.sessions.values()].map(s => this.descriptor(s)) };
|
|
85
|
+
}
|
|
86
|
+
if (!this.ptyModule)
|
|
87
|
+
throw new Error(this.error ?? 'Terminal unavailable');
|
|
88
|
+
const id = identifier(p, 'id');
|
|
89
|
+
const clientId = identifier(p, 'clientId');
|
|
90
|
+
if (method === 'terminal.create') {
|
|
91
|
+
keys(p, ['id', 'clientId', 'name', 'cwd', 'cols', 'rows']);
|
|
92
|
+
const cols = integer(p, 'cols', 20, 300, 100);
|
|
93
|
+
const rows = integer(p, 'rows', 5, 100, 30);
|
|
94
|
+
if (p.name !== undefined && (typeof p.name !== 'string' || !p.name.length || p.name.length > 128 || /[\x00-\x1f]/.test(p.name)))
|
|
95
|
+
throw new Error('Invalid terminal name');
|
|
96
|
+
const old = this.sessions.get(id);
|
|
97
|
+
if (old)
|
|
98
|
+
return this.descriptor(old);
|
|
99
|
+
if (this.sessions.size >= 8)
|
|
100
|
+
throw new Error('Terminal session limit exceeded');
|
|
101
|
+
if (p.cwd !== undefined && typeof p.cwd !== 'string')
|
|
102
|
+
throw new Error('Invalid cwd');
|
|
103
|
+
const cwd = await this.files.resolve(p.cwd ?? this.files.home);
|
|
104
|
+
const terminal = new Terminal({ cols, rows, scrollback: SCROLLBACK, allowProposedApi: true });
|
|
105
|
+
const serializer = new SerializeAddon();
|
|
106
|
+
// Headless and browser xterm share the addon contract, but their declaration packages differ.
|
|
107
|
+
terminal.loadAddon(serializer);
|
|
108
|
+
let pty;
|
|
109
|
+
try {
|
|
110
|
+
pty = this.ptyModule.spawn(this.options.shell ?? process.env.SHELL ?? os.userInfo().shell ?? '/bin/sh', ['-l'], { name: 'xterm-256color', cwd, cols, rows, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
terminal.dispose();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const session = { id, name: p.name ?? 'Terminal', cwd, cols, rows, exited: false, exitCode: null, controllerId: clientId, pty, closed: false, terminal, serializer, seq: 0, ring: [], ringBytes: 0, pendingBytes: 0, rendered: Promise.resolve() };
|
|
117
|
+
pty.onData(data => this.output(session, data));
|
|
118
|
+
pty.onExit(event => { session.exited = true; session.exitCode = event.exitCode; });
|
|
119
|
+
// Answering terminal queries (DA/DSR, etc.) is keeper-owned and does not require a web client.
|
|
120
|
+
terminal.onData(data => { if (!session.exited)
|
|
121
|
+
pty.write(data); });
|
|
122
|
+
this.sessions.set(id, session);
|
|
123
|
+
return this.descriptor(session);
|
|
124
|
+
}
|
|
125
|
+
const s = this.sessions.get(id);
|
|
126
|
+
if (!s)
|
|
127
|
+
throw new Error('Terminal session not found; keeper restart loses live PTYs');
|
|
128
|
+
switch (method) {
|
|
129
|
+
case 'terminal.attach': {
|
|
130
|
+
keys(p, ['id', 'clientId', 'cols', 'rows']);
|
|
131
|
+
// Validate dimensions even for a viewer, but only the controller's dimensions affect PTY.
|
|
132
|
+
integer(p, 'cols', 20, 300, s.cols);
|
|
133
|
+
integer(p, 'rows', 5, 100, s.rows);
|
|
134
|
+
await s.rendered;
|
|
135
|
+
if (s.controllerId === null)
|
|
136
|
+
s.controllerId = clientId;
|
|
137
|
+
if (s.controllerId === clientId)
|
|
138
|
+
this.resize(s, p);
|
|
139
|
+
return { session: this.descriptor(s), seq: s.seq, snapshot: this.snapshot(s), control: s.controllerId === clientId };
|
|
140
|
+
}
|
|
141
|
+
case 'terminal.poll': {
|
|
142
|
+
keys(p, ['id', 'clientId', 'after']);
|
|
143
|
+
const after = integer(p, 'after', 0, Number.MAX_SAFE_INTEGER);
|
|
144
|
+
await s.rendered;
|
|
145
|
+
const common = { exited: s.exited, exitCode: s.exitCode, control: s.controllerId === clientId };
|
|
146
|
+
if (after > s.seq || after < (s.ring[0]?.seq ?? s.seq + 1) - 1)
|
|
147
|
+
return { ...common, seq: s.seq, data: '', reset: true, snapshot: this.snapshot(s) };
|
|
148
|
+
let data = '';
|
|
149
|
+
let bytes = 0;
|
|
150
|
+
let seq = after;
|
|
151
|
+
for (const output of s.ring) {
|
|
152
|
+
if (output.seq <= after)
|
|
153
|
+
continue;
|
|
154
|
+
if (bytes + output.bytes > MAX_POLL_BYTES) {
|
|
155
|
+
if (!bytes)
|
|
156
|
+
return { ...common, seq: s.seq, data: '', reset: true, snapshot: this.snapshot(s) };
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
data += output.data;
|
|
160
|
+
bytes += output.bytes;
|
|
161
|
+
seq = output.seq;
|
|
162
|
+
}
|
|
163
|
+
return { ...common, seq, data, reset: false };
|
|
164
|
+
}
|
|
165
|
+
case 'terminal.takeover':
|
|
166
|
+
keys(p, ['id', 'clientId']);
|
|
167
|
+
s.controllerId = clientId;
|
|
168
|
+
return { ok: true };
|
|
169
|
+
case 'terminal.detach':
|
|
170
|
+
keys(p, ['id', 'clientId']);
|
|
171
|
+
if (s.controllerId === clientId)
|
|
172
|
+
s.controllerId = null;
|
|
173
|
+
return { ok: true };
|
|
174
|
+
case 'terminal.write':
|
|
175
|
+
keys(p, ['id', 'clientId', 'data']);
|
|
176
|
+
this.control(s, clientId);
|
|
177
|
+
if (s.exited)
|
|
178
|
+
throw new Error('Terminal has exited');
|
|
179
|
+
if (typeof p.data !== 'string' || Buffer.byteLength(p.data) > 64 * 1024)
|
|
180
|
+
throw new Error('Invalid terminal input');
|
|
181
|
+
s.pty.write(p.data);
|
|
182
|
+
return { ok: true };
|
|
183
|
+
case 'terminal.resize':
|
|
184
|
+
keys(p, ['id', 'clientId', 'cols', 'rows']);
|
|
185
|
+
this.control(s, clientId);
|
|
186
|
+
integer(p, 'cols', 20, 300);
|
|
187
|
+
integer(p, 'rows', 5, 100);
|
|
188
|
+
await s.rendered;
|
|
189
|
+
this.resize(s, p);
|
|
190
|
+
return { ok: true };
|
|
191
|
+
case 'terminal.close':
|
|
192
|
+
keys(p, ['id', 'clientId']);
|
|
193
|
+
this.control(s, clientId);
|
|
194
|
+
this.sessions.delete(id);
|
|
195
|
+
s.closed = true;
|
|
196
|
+
if (!s.exited)
|
|
197
|
+
s.pty.kill();
|
|
198
|
+
await s.rendered;
|
|
199
|
+
s.terminal.dispose();
|
|
200
|
+
return { ok: true };
|
|
201
|
+
default: throw new Error('Unsupported terminal tool');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async dispose() {
|
|
205
|
+
for (const session of this.sessions.values()) {
|
|
206
|
+
session.closed = true;
|
|
207
|
+
if (!session.exited)
|
|
208
|
+
session.pty.kill();
|
|
209
|
+
await session.rendered;
|
|
210
|
+
session.terminal.dispose();
|
|
211
|
+
}
|
|
212
|
+
this.sessions.clear();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Bounds apply on both the encrypted channel and private keeper IPC. */
|
|
2
|
+
export const TOOL_REQUEST_BYTES = 128 * 1024;
|
|
3
|
+
export const TOOL_RESULT_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
export const CHUNK_BYTES = 64 * 1024;
|
|
5
|
+
export const TOOL_METHODS = new Set([
|
|
6
|
+
'browser.list', 'browser.create', 'browser.attach', 'browser.frame', 'browser.navigate', 'browser.input', 'browser.takeover', 'browser.detach', 'browser.close',
|
|
7
|
+
'tools.info', 'terminal.list', 'terminal.create', 'terminal.attach', 'terminal.poll',
|
|
8
|
+
'terminal.write', 'terminal.resize', 'terminal.takeover', 'terminal.detach', 'terminal.close',
|
|
9
|
+
'files.list', 'files.read', 'files.mkdir', 'files.rename', 'files.delete',
|
|
10
|
+
'files.upload.begin', 'files.upload.chunk', 'files.upload.finish', 'files.upload.cancel',
|
|
11
|
+
]);
|
|
12
|
+
export function object(value) {
|
|
13
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
export function keys(params, allowed) {
|
|
16
|
+
if (Object.keys(params).some(key => !allowed.includes(key)))
|
|
17
|
+
throw new Error('Unexpected tool parameter');
|
|
18
|
+
}
|
|
19
|
+
export function string(params, name, max = 4096, fallback) {
|
|
20
|
+
const value = params[name] === undefined ? fallback : params[name];
|
|
21
|
+
if (typeof value !== 'string' || !value.length || value.includes('\0') || value.length > max)
|
|
22
|
+
throw new Error(`Invalid ${name}`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function identifier(params, name) {
|
|
26
|
+
const value = string(params, name, 128);
|
|
27
|
+
if (!/^[A-Za-z0-9_.:-]+$/.test(value))
|
|
28
|
+
throw new Error(`Invalid ${name}`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function integer(params, name, min, max, fallback) {
|
|
32
|
+
const value = params[name] === undefined ? fallback : params[name];
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
34
|
+
throw new Error(`Invalid ${name}`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
export function base64(value) {
|
|
38
|
+
if (typeof value !== 'string' || value.length > Math.ceil(CHUNK_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
|
|
39
|
+
throw new Error('Invalid upload chunk');
|
|
40
|
+
const bytes = Buffer.from(value, 'base64');
|
|
41
|
+
if (bytes.length > CHUNK_BYTES || bytes.toString('base64') !== value)
|
|
42
|
+
throw new Error('Invalid upload chunk');
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
export function validateRequest(method, params) {
|
|
46
|
+
if (typeof method !== 'string' || !TOOL_METHODS.has(method))
|
|
47
|
+
throw new Error('Unsupported workstation tool');
|
|
48
|
+
if (!object(params) || Buffer.byteLength(JSON.stringify(params)) > TOOL_REQUEST_BYTES)
|
|
49
|
+
throw new Error('Invalid or oversized tool parameters');
|
|
50
|
+
}
|
|
@@ -93,6 +93,14 @@ export class UpdateCoordinator {
|
|
|
93
93
|
await this.transition({ ...this.status, phase: 'SUCCEEDED' });
|
|
94
94
|
}
|
|
95
95
|
async ensureInitialized() {
|
|
96
|
+
// Concurrent callers must share one in-flight attempt (assigned synchronously below, before
|
|
97
|
+
// any `await`), but a *rejected* attempt must not stay memoized: a transient failure here --
|
|
98
|
+
// the state dir being unwritable while the daemon died mid-update, say -- would otherwise wedge
|
|
99
|
+
// this coordinator forever, since every future `handle`/`snapshotAsync`/`managementReady` call
|
|
100
|
+
// awaits the same rejected promise and never gets a chance to retry. Clearing `this.initialized`
|
|
101
|
+
// only happens inside this same promise's own `.catch`, i.e. strictly after it has settled, so
|
|
102
|
+
// callers that started awaiting it while it was still in flight all observe the same outcome;
|
|
103
|
+
// only a call that arrives *after* it has rejected gets to start a fresh attempt.
|
|
96
104
|
if (!this.initialized)
|
|
97
105
|
this.initialized = this.options.state.load().then(async (saved) => {
|
|
98
106
|
if (!saved)
|
|
@@ -113,7 +121,28 @@ export class UpdateCoordinator {
|
|
|
113
121
|
catch { /* a broken logger must not fail a boot */ }
|
|
114
122
|
return;
|
|
115
123
|
}
|
|
124
|
+
// Any other non-terminal phase was written by a process that no longer exists: the execute()
|
|
125
|
+
// that owned it died with the previous daemon, so nothing will ever advance it. Adopting it
|
|
126
|
+
// verbatim makes handle() answer every later command with UPDATE_IN_PROGRESS while the
|
|
127
|
+
// heartbeat repeats the stale phase to the server forever -- the same permanent wedge the
|
|
128
|
+
// RESTARTING branch above exists to prevent, reachable by killing the daemon at any earlier
|
|
129
|
+
// point (a laptop shut mid-download, an operator's restart mid-drain). RESTARTING is the one
|
|
130
|
+
// deliberate exception in every lifecycle: the successor is *expected* to adopt it and let
|
|
131
|
+
// management_ready promote it (or the armed watchdog fail it).
|
|
132
|
+
if (!isTerminalPhase(saved.phase) && saved.phase !== 'RESTARTING') {
|
|
133
|
+
this.status = { ...saved, phase: 'FAILED', error: `process restarted during '${saved.phase}'; the update never completed` };
|
|
134
|
+
await this.options.state.save(this.status);
|
|
135
|
+
// Reported like any other transition, for the same reason the container branch is.
|
|
136
|
+
try {
|
|
137
|
+
this.options.onPhase?.(this.status);
|
|
138
|
+
}
|
|
139
|
+
catch { /* a broken logger must not fail a boot */ }
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
116
142
|
this.status = saved;
|
|
143
|
+
}).catch((error) => {
|
|
144
|
+
this.initialized = null;
|
|
145
|
+
throw error;
|
|
117
146
|
});
|
|
118
147
|
await this.initialized;
|
|
119
148
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
2
|
import { redactPaths, redactSecrets } from './redact.js';
|
|
3
|
+
import { verifyOptionalPty } from './watchdog.js';
|
|
3
4
|
const STDERR_LIMIT = 4096;
|
|
4
5
|
function absolute(value, field) {
|
|
5
6
|
if (!value.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value))
|
|
@@ -47,6 +48,7 @@ export class NpmInstaller {
|
|
|
47
48
|
reject(new Error(`npm install failed: ${sanitizeNpmError(stderr) || `exit ${code ?? signal ?? 'unknown'}`}`));
|
|
48
49
|
});
|
|
49
50
|
});
|
|
51
|
+
await verifyOptionalPty(this.options.npmPrefix, this.spawn);
|
|
50
52
|
}
|
|
51
53
|
/** Packs the installed package locally; `--json` gives npm's own name/version validation input. */
|
|
52
54
|
async pack(packageRoot, destination) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
/**
|
|
5
|
+
* Says once, at daemon start, when the installed CLI's optional `node-pty` does not load. There is
|
|
6
|
+
* no repair to attempt: the pinned node-pty ships prebuilds for every desktop target, so a load
|
|
7
|
+
* failure means an unsupported platform (musl, an older glibc), not a missing build step.
|
|
8
|
+
*/
|
|
9
|
+
export async function preflightOptionalPty(context, deps = {}) {
|
|
10
|
+
const installedRoot = path.join(context.npmPrefix, process.platform === 'win32' ? 'node_modules' : 'lib/node_modules', '@platless/axom-cli');
|
|
11
|
+
// Source checkouts and custom unpacked installations load their own node-pty; nothing to report.
|
|
12
|
+
if (path.resolve(installedRoot) !== path.resolve(context.packageRoot))
|
|
13
|
+
return;
|
|
14
|
+
const addon = path.join(installedRoot, 'node_modules/node-pty');
|
|
15
|
+
try {
|
|
16
|
+
(deps.load ?? require)(addon);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
// An explicitly omitted optional dependency is not a problem worth a warning.
|
|
20
|
+
if (error.code === 'MODULE_NOT_FOUND' && String(error.message).includes(addon))
|
|
21
|
+
return;
|
|
22
|
+
(deps.warn ?? console.warn)('[daemon] Optional node-pty does not load on this platform; the terminal tool stays unavailable.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -3,16 +3,39 @@
|
|
|
3
3
|
* forced update closes it permanently because this daemon is about to be replaced.
|
|
4
4
|
*/
|
|
5
5
|
export class TaskRegistry {
|
|
6
|
-
/** Every running
|
|
6
|
+
/** Every running computer task's abort handle plus the promise that says its finally cleanup has run. */
|
|
7
7
|
shellTasks = new Map();
|
|
8
|
-
agentTasks = new Set();
|
|
9
8
|
agentOpenReservations = new Map();
|
|
10
9
|
agentSessions = new Map();
|
|
11
10
|
idleWaiters = new Set();
|
|
12
11
|
stopping = null;
|
|
13
12
|
accepting = true;
|
|
13
|
+
/**
|
|
14
|
+
* 忙碌是**现在采出来的**,不是攒出来的。
|
|
15
|
+
*
|
|
16
|
+
* 上一版把每个 `tool_call` / `permission_request` 记进一个 `Set`,指望 `turn_done` 来删。
|
|
17
|
+
* 任何一条没有配套 `turn_done` 的开始事件都会把那个 ref 永久钉住,`isIdle()` 于是永远假,
|
|
18
|
+
* 优雅更新白等满 drain 超时——2026-09-21 一次重装就这样卡在 BUSY。集合删掉之后这类缺陷
|
|
19
|
+
* **按构造**不存在:没有可泄漏的东西,而不是"记得要删"。
|
|
20
|
+
*
|
|
21
|
+
* `busy()` 由会话自己提供,两个来源取或(见 agentSessions.ts 的注册点):本进程发出的
|
|
22
|
+
* 提示词是否在途,以及 agent 自报的状态。两个都是层级量。
|
|
23
|
+
*/
|
|
14
24
|
snapshot() {
|
|
15
|
-
const activeAgentRefs = new Set(
|
|
25
|
+
const activeAgentRefs = new Set();
|
|
26
|
+
for (const [sessionRef, session] of this.agentSessions) {
|
|
27
|
+
// 一个抛异常的采样器不得让心跳和更新准入一起垮掉;读不出来就当它不主张忙碌,
|
|
28
|
+
// 与未知 vendor 值的处置同一条推理。
|
|
29
|
+
let busy = false;
|
|
30
|
+
try {
|
|
31
|
+
busy = session.busy();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
busy = false;
|
|
35
|
+
}
|
|
36
|
+
if (busy)
|
|
37
|
+
activeAgentRefs.add(sessionRef);
|
|
38
|
+
}
|
|
16
39
|
for (const sessionRef of this.agentOpenReservations.keys())
|
|
17
40
|
activeAgentRefs.add(sessionRef);
|
|
18
41
|
return { activeShellTasks: this.shellTasks.size, activeAgentTasks: activeAgentRefs.size };
|
|
@@ -56,7 +79,7 @@ export class TaskRegistry {
|
|
|
56
79
|
for (const waiter of [...this.idleWaiters])
|
|
57
80
|
waiter();
|
|
58
81
|
}
|
|
59
|
-
/** Run one
|
|
82
|
+
/** Run one computer task under an abort signal which force-update owns. The method name preserves old callers. */
|
|
60
83
|
async withShellTask(run) {
|
|
61
84
|
if (!this.accepting)
|
|
62
85
|
throw new Error('Workstation is not accepting new work');
|
|
@@ -73,9 +96,16 @@ export class TaskRegistry {
|
|
|
73
96
|
this.notifyIfIdle();
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
|
-
/** Register a live native agent for force-stop
|
|
77
|
-
registerAgentSession(sessionRef, close) {
|
|
78
|
-
this.agentSessions.set(sessionRef, close);
|
|
99
|
+
/** Register a live native agent for force-stop, plus how to ask whether it is busy right now. */
|
|
100
|
+
registerAgentSession(sessionRef, close, busy) {
|
|
101
|
+
this.agentSessions.set(sessionRef, { close, busy });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 采样式判据没有"变空闲"这个事件可以挂钩,所以持有者在可能变空闲时喊一声。
|
|
105
|
+
* 多喊无害(`notifyIfIdle` 自己会再判一次),漏喊的代价只是等到下一次调用或超时。
|
|
106
|
+
*/
|
|
107
|
+
sessionBusyChanged() {
|
|
108
|
+
this.notifyIfIdle();
|
|
79
109
|
}
|
|
80
110
|
/** Atomically admits and counts an asynchronous open before any journal or subprocess await. */
|
|
81
111
|
tryReserveAgentOpen(sessionRef) {
|
|
@@ -98,19 +128,14 @@ export class TaskRegistry {
|
|
|
98
128
|
}
|
|
99
129
|
unregisterAgentSession(sessionRef) {
|
|
100
130
|
this.agentSessions.delete(sessionRef);
|
|
101
|
-
this.agentTasks.delete(sessionRef);
|
|
102
131
|
this.notifyIfIdle();
|
|
103
132
|
}
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
agentTurnFinished(sessionRef) {
|
|
112
|
-
this.agentTasks.delete(sessionRef);
|
|
113
|
-
this.notifyIfIdle();
|
|
133
|
+
/**
|
|
134
|
+
* 排干期间拒收新回合。**只管准入**——它不再登记任何东西,忙碌由 [snapshot] 现场采样。
|
|
135
|
+
* 这两件事以前挤在一个方法里,删计数时极易把准入一起删掉,那会让 drain 期间照收新活。
|
|
136
|
+
*/
|
|
137
|
+
tryAdmitTurn(_sessionRef) {
|
|
138
|
+
return this.accepting;
|
|
114
139
|
}
|
|
115
140
|
/**
|
|
116
141
|
* Atomically refuse future work, signal every shell process group through its owning executor,
|
|
@@ -126,11 +151,10 @@ export class TaskRegistry {
|
|
|
126
151
|
const shells = [...this.shellTasks.entries()];
|
|
127
152
|
for (const [task] of shells)
|
|
128
153
|
task.abort();
|
|
129
|
-
this.agentTasks.clear();
|
|
130
154
|
this.agentOpenReservations.clear();
|
|
131
155
|
this.notifyIfIdle();
|
|
132
156
|
const [outcomes] = await Promise.all([
|
|
133
|
-
Promise.allSettled([...this.agentSessions.values()].map(async (
|
|
157
|
+
Promise.allSettled([...this.agentSessions.values()].map(async (session) => session.close())),
|
|
134
158
|
Promise.all(shells.map(([, cleanedUp]) => cleanedUp)),
|
|
135
159
|
]);
|
|
136
160
|
const failures = outcomes.filter((outcome) => outcome.status === 'rejected');
|
|
@@ -242,6 +242,72 @@ async function npmInstall(npmPath, npmPrefix, archive) {
|
|
|
242
242
|
child.once('error', reject);
|
|
243
243
|
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`npm rollback failed with exit ${code ?? 'unknown'}`)));
|
|
244
244
|
});
|
|
245
|
+
await verifyOptionalPty(npmPrefix);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Whether the installed CLI's optional `node-pty` loads in this runtime. Kept in this standalone file
|
|
249
|
+
* because the rollback watchdog is copied out of the package.
|
|
250
|
+
*
|
|
251
|
+
* There is nothing to build: the pinned node-pty ships N-API prebuilds for all six desktop targets
|
|
252
|
+
* (linux included since 1.2.0-beta; 1.1.0 had none for linux, which is why this used to run
|
|
253
|
+
* `npm rebuild` and needed Python/make/a C++ compiler on the workstation). The check runs in a
|
|
254
|
+
* child process so a crashing addon cannot take the installer with it.
|
|
255
|
+
*
|
|
256
|
+
* Never fails the install. Terminal support is optional and the daemon already degrades only the
|
|
257
|
+
* terminal tool when the addon is missing; an update that rolled back over it left the whole
|
|
258
|
+
* workstation on the old client instead.
|
|
259
|
+
*/
|
|
260
|
+
export async function verifyOptionalPty(npmPrefix, spawn = nodeSpawn, timeoutMs = 60_000, warn = console.warn) {
|
|
261
|
+
const root = path.join(npmPrefix, process.platform === 'win32' ? 'node_modules' : 'lib/node_modules', PACKAGE_NAME);
|
|
262
|
+
const addon = path.join(root, 'node_modules/node-pty');
|
|
263
|
+
try {
|
|
264
|
+
await fs.access(path.join(addon, 'package.json'));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error.code === 'ENOENT')
|
|
268
|
+
return false; // Explicitly omitted optional dependency.
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await new Promise((resolve, reject) => {
|
|
273
|
+
// A detached POSIX group lets a timeout stop the check and anything the addon started.
|
|
274
|
+
// It must finish well before the previous release's 120-second rollback deadline.
|
|
275
|
+
const child = spawn(process.execPath, ['-e', 'require(process.argv[1])', addon], { shell: false, stdio: 'ignore', windowsHide: true, detached: process.platform !== 'win32' });
|
|
276
|
+
let done = false;
|
|
277
|
+
const finish = (error) => {
|
|
278
|
+
if (done)
|
|
279
|
+
return;
|
|
280
|
+
done = true;
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
if (error)
|
|
283
|
+
reject(error);
|
|
284
|
+
else
|
|
285
|
+
resolve();
|
|
286
|
+
};
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
try {
|
|
289
|
+
if (process.platform !== 'win32' && child.pid)
|
|
290
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
291
|
+
else
|
|
292
|
+
child.kill('SIGKILL');
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
try {
|
|
296
|
+
child.kill('SIGKILL');
|
|
297
|
+
}
|
|
298
|
+
catch { /* Already exited. */ }
|
|
299
|
+
}
|
|
300
|
+
finish(new Error('timed out'));
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
child.once('error', () => finish(new Error('could not start the check')));
|
|
303
|
+
child.once('close', code => finish(code === 0 ? undefined : new Error(`exit ${code ?? 'unknown'}`)));
|
|
304
|
+
});
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
warn(`[update] Optional node-pty does not load on this platform (${error.message}); the terminal tool stays unavailable.`);
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
245
311
|
}
|
|
246
312
|
/** How much of the child's own complaint is worth carrying into a 512-character status field. */
|
|
247
313
|
const CAPTURED_STDERR_LIMIT = 300;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platless/axom-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Axomwork CLI - manage Axomwork structures, records and executions over MCP",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,14 +14,19 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"build": "rm -rf dist && tsc",
|
|
17
|
+
"build": "rm -rf dist && tsc && node scripts/copy-patches.mjs",
|
|
18
|
+
"pretest": "npm run build",
|
|
18
19
|
"test": "vitest run",
|
|
19
|
-
"typecheck": "tsc --noEmit"
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"prepack": "node scripts/copy-patches.mjs --check"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@agentclientprotocol/sdk": "^1.4.0",
|
|
23
25
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
24
26
|
"@napi-rs/keyring": "^1.1.3",
|
|
27
|
+
"@qoder-ai/qoder-agent-sdk": "1.0.50",
|
|
28
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
29
|
+
"@xterm/headless": "^6.0.0",
|
|
25
30
|
"commander": "^15.0.0",
|
|
26
31
|
"js-yaml": "^4.1.0",
|
|
27
32
|
"semver": "^7.8.5",
|
|
@@ -35,5 +40,10 @@
|
|
|
35
40
|
"@types/ws": "^8.5.13",
|
|
36
41
|
"typescript": "^5.2.2",
|
|
37
42
|
"vitest": "^2.1.9"
|
|
38
|
-
}
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"node-pty": "1.2.0-beta.15",
|
|
46
|
+
"playwright-core": "1.63.0"
|
|
47
|
+
},
|
|
48
|
+
"axomPatchBundle": true
|
|
39
49
|
}
|