@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,262 @@
|
|
|
1
|
+
/** Qoder 1.1.61 drops native background task notifications from both live ACP and session/load.
|
|
2
|
+
* This read-only compatibility boundary repairs only a known root Agent tool, never a session.
|
|
3
|
+
* Lifecycle/removal contract: axom-patch/cli/qoder-background-terminal.md.
|
|
4
|
+
*/
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { open } from 'node:fs/promises';
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { BackgroundProcess } from './backgroundProcess.js';
|
|
9
|
+
const CHUNK = 64 * 1024;
|
|
10
|
+
const MAX_LINE = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_POLL = 4 * 1024 * 1024;
|
|
12
|
+
const MAX_TASKS = 512;
|
|
13
|
+
const MAX_RESULT = 128 * 1024;
|
|
14
|
+
const MAX_RESULTS = 4 * 1024 * 1024;
|
|
15
|
+
const TERMINALS = new Set(['completed', 'failed', 'cancelled']);
|
|
16
|
+
function object(value) {
|
|
17
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
18
|
+
}
|
|
19
|
+
function address(value) {
|
|
20
|
+
return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,160}$/.test(value) ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
export function nativeTerminalHistoryPath(identity, sessionId, cwd, historyHome) {
|
|
23
|
+
if (identity?.name !== 'qoder-cli' || identity.version !== '1.1.61' ||
|
|
24
|
+
!address(sessionId) || !isAbsolute(cwd) || !isAbsolute(historyHome))
|
|
25
|
+
return;
|
|
26
|
+
// Certified ~/.qoder/projects layout. Even a colliding directory name cannot cross session/cwd
|
|
27
|
+
// identity: every accepted row is checked again against the exact native session and directory.
|
|
28
|
+
return join(historyHome, '.qoder', 'projects', resolve(cwd).replace(/[^a-zA-Z0-9]/g, '-'), sessionId + '.jsonl');
|
|
29
|
+
}
|
|
30
|
+
export class BackgroundTerminalRepair {
|
|
31
|
+
options;
|
|
32
|
+
calls = new Map();
|
|
33
|
+
launches = new Map();
|
|
34
|
+
terminals = new Map();
|
|
35
|
+
delivered = new Set();
|
|
36
|
+
processes = new Map();
|
|
37
|
+
pendingProcesses = new Set();
|
|
38
|
+
offset = 0;
|
|
39
|
+
fileIdentity = '';
|
|
40
|
+
pendingLine = Buffer.alloc(0);
|
|
41
|
+
skipLine = false;
|
|
42
|
+
closed = false;
|
|
43
|
+
timer;
|
|
44
|
+
inFlight;
|
|
45
|
+
warned = false;
|
|
46
|
+
resultBytes = 0;
|
|
47
|
+
restoring;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.restoring = options.restoreCalls === true;
|
|
51
|
+
}
|
|
52
|
+
start() {
|
|
53
|
+
if (this.closed || this.timer)
|
|
54
|
+
return;
|
|
55
|
+
this.timer = setInterval(() => { void this.poll(); }, 1000);
|
|
56
|
+
this.timer.unref();
|
|
57
|
+
}
|
|
58
|
+
observe(event) {
|
|
59
|
+
if (this.closed || (event.acpSessionId && event.acpSessionId !== this.options.nativeSessionId) ||
|
|
60
|
+
!['tool_call', 'tool_call_update'].includes(event.eventName))
|
|
61
|
+
return;
|
|
62
|
+
const payload = event.payload;
|
|
63
|
+
const id = address(payload.toolCallId);
|
|
64
|
+
if (!id)
|
|
65
|
+
return;
|
|
66
|
+
let call = this.calls.get(id);
|
|
67
|
+
const input = object(payload.rawInput);
|
|
68
|
+
if (!call && input.run_in_background === true && typeof input.prompt === 'string' &&
|
|
69
|
+
(input.subagent_type == null || typeof input.subagent_type === 'string') && payload.kind === 'think' &&
|
|
70
|
+
object(object(payload._meta).qoder).toolName === 'Agent' && this.calls.size < MAX_TASKS) {
|
|
71
|
+
const meta = object(object(payload._meta).qoder);
|
|
72
|
+
call = { input, invocationComplete: false, nativeTerminal: false,
|
|
73
|
+
process: this.options.includeProcess === true && meta.version === 1 && meta.source === 'native-acp-patch' };
|
|
74
|
+
this.calls.set(id, call);
|
|
75
|
+
}
|
|
76
|
+
if (!call)
|
|
77
|
+
return;
|
|
78
|
+
if (payload.status === 'completed')
|
|
79
|
+
call.invocationComplete = true;
|
|
80
|
+
const output = object(payload.rawOutput);
|
|
81
|
+
if (output.kind === 'agent-result' && (TERMINALS.has(String(output.state)) || output.state === 'error')) {
|
|
82
|
+
call.nativeTerminal = true; // Upstream's structured evidence always wins.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
poll() {
|
|
86
|
+
if (this.closed || (!this.restoring && ![...this.calls].some(([id, call]) => !this.delivered.has(id) && !call.nativeTerminal)))
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
if (!this.inFlight) {
|
|
89
|
+
this.inFlight = this.readIncrement().catch(error => {
|
|
90
|
+
const code = object(error).code;
|
|
91
|
+
if (!['ENOENT', 'ELOOP', 'ENOTDIR', 'EACCES'].includes(String(code)) && !this.warned) {
|
|
92
|
+
this.warned = true;
|
|
93
|
+
console.warn('[acp:qoder] background terminal repair unavailable; retaining native ACP state');
|
|
94
|
+
}
|
|
95
|
+
}).finally(() => { this.inFlight = undefined; });
|
|
96
|
+
}
|
|
97
|
+
return this.inFlight;
|
|
98
|
+
}
|
|
99
|
+
close() {
|
|
100
|
+
this.closed = true;
|
|
101
|
+
if (this.timer)
|
|
102
|
+
clearInterval(this.timer);
|
|
103
|
+
this.timer = undefined;
|
|
104
|
+
this.calls.clear();
|
|
105
|
+
this.launches.clear();
|
|
106
|
+
this.terminals.clear();
|
|
107
|
+
this.delivered.clear();
|
|
108
|
+
this.processes.clear();
|
|
109
|
+
this.pendingProcesses.clear();
|
|
110
|
+
this.pendingLine = Buffer.alloc(0);
|
|
111
|
+
}
|
|
112
|
+
async readIncrement() {
|
|
113
|
+
// O_NOFOLLOW avoids importing a transcript through a replaced symlink. File is never written.
|
|
114
|
+
const file = await open(this.options.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
115
|
+
try {
|
|
116
|
+
const stat = await file.stat();
|
|
117
|
+
if (!stat.isFile() || this.closed)
|
|
118
|
+
return;
|
|
119
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
120
|
+
if (identity !== this.fileIdentity || stat.size < this.offset) {
|
|
121
|
+
this.offset = 0;
|
|
122
|
+
this.pendingLine = Buffer.alloc(0);
|
|
123
|
+
this.skipLine = false;
|
|
124
|
+
this.fileIdentity = identity;
|
|
125
|
+
}
|
|
126
|
+
const end = Math.min(stat.size, this.offset + MAX_POLL);
|
|
127
|
+
const chunk = Buffer.alloc(CHUNK);
|
|
128
|
+
while (!this.closed && this.offset < end) {
|
|
129
|
+
const { bytesRead } = await file.read(chunk, 0, Math.min(CHUNK, end - this.offset), this.offset);
|
|
130
|
+
if (!bytesRead)
|
|
131
|
+
break;
|
|
132
|
+
this.offset += bytesRead;
|
|
133
|
+
this.lines(chunk.subarray(0, bytesRead));
|
|
134
|
+
}
|
|
135
|
+
if (this.offset >= stat.size)
|
|
136
|
+
this.restoring = false;
|
|
137
|
+
if (!this.closed) {
|
|
138
|
+
await this.pollProcesses();
|
|
139
|
+
if (!this.closed)
|
|
140
|
+
this.publish();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
await file.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
lines(chunk) {
|
|
148
|
+
let start = 0;
|
|
149
|
+
for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) {
|
|
150
|
+
if (!this.skipLine && this.pendingLine.length + end - start <= MAX_LINE) {
|
|
151
|
+
const line = Buffer.concat([this.pendingLine, chunk.subarray(start, end)]).toString('utf8');
|
|
152
|
+
try {
|
|
153
|
+
this.consume(object(JSON.parse(line)));
|
|
154
|
+
}
|
|
155
|
+
catch { /* partial/corrupt non-evidence */ }
|
|
156
|
+
}
|
|
157
|
+
this.pendingLine = Buffer.alloc(0);
|
|
158
|
+
this.skipLine = false;
|
|
159
|
+
start = end + 1;
|
|
160
|
+
}
|
|
161
|
+
const tail = chunk.subarray(start);
|
|
162
|
+
if (!this.skipLine && this.pendingLine.length + tail.length <= MAX_LINE) {
|
|
163
|
+
this.pendingLine = Buffer.concat([this.pendingLine, tail]);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
this.pendingLine = Buffer.alloc(0);
|
|
167
|
+
this.skipLine = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
consume(row) {
|
|
171
|
+
if (row.sessionId !== this.options.nativeSessionId || row.cwd !== this.options.cwd ||
|
|
172
|
+
row.isSidechain !== false)
|
|
173
|
+
return;
|
|
174
|
+
const content = object(row.message).content;
|
|
175
|
+
if (!Array.isArray(content))
|
|
176
|
+
return;
|
|
177
|
+
// resume deliberately sends no historical ACP openings. Recover only certified root Agent
|
|
178
|
+
// background inputs; no opening is emitted and the existing exact-ID notification rules apply.
|
|
179
|
+
if (this.options.restoreCalls && row.type === 'assistant') {
|
|
180
|
+
for (const block of content.map(object)) {
|
|
181
|
+
const input = object(block.input), id = address(block.id);
|
|
182
|
+
if (block.type === 'tool_use' && block.name === 'Agent' && id && input.run_in_background === true &&
|
|
183
|
+
typeof input.prompt === 'string' && (input.subagent_type == null || typeof input.subagent_type === 'string') &&
|
|
184
|
+
!this.calls.has(id) && this.calls.size < MAX_TASKS) {
|
|
185
|
+
this.calls.set(id, { input, invocationComplete: false, nativeTerminal: false, process: false });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (row.type !== 'user')
|
|
191
|
+
return;
|
|
192
|
+
const output = object(row.toolUseResult);
|
|
193
|
+
if (output.isAsync === true && output.status === 'async_launched') {
|
|
194
|
+
const results = content.map(object).filter(c => c.type === 'tool_result');
|
|
195
|
+
const id = results.length === 1 ? address(results[0].tool_use_id) : undefined;
|
|
196
|
+
const agent = address(output.agentId);
|
|
197
|
+
if (this.options.restoreCalls && id && this.calls.has(id))
|
|
198
|
+
this.calls.get(id).invocationComplete = true;
|
|
199
|
+
if (id && agent && this.launches.size < MAX_TASKS && !this.launches.has(id))
|
|
200
|
+
this.launches.set(id, agent);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (row.isMeta !== true || object(row.origin).kind !== 'task-notification')
|
|
204
|
+
return;
|
|
205
|
+
for (const block of content.map(object)) {
|
|
206
|
+
if (block.type !== 'text' || typeof block.text !== 'string' ||
|
|
207
|
+
!block.text.startsWith('[SYSTEM NOTIFICATION - NOT USER INPUT]\n'))
|
|
208
|
+
continue;
|
|
209
|
+
// Match the authoritative envelope before result text; tags inside a result cannot change IDs.
|
|
210
|
+
const match = block.text.match(/<task-notification>\n<task-id>([a-zA-Z0-9_-]{1,160})<\/task-id>\n<tool-use-id>([a-zA-Z0-9_-]{1,160})<\/tool-use-id>\n(?:<output-file>[^\n]*<\/output-file>\n)?<status>(completed|failed|cancelled)<\/status>\n<summary>[^\n]*<\/summary>\n<result>([\s\S]*)<\/result>\n(?:<usage>[^\n]*<\/usage>\n)?<\/task-notification>\s*$/);
|
|
211
|
+
const bytes = match ? Buffer.byteLength(match[4]) : 0;
|
|
212
|
+
if (match && bytes <= MAX_RESULT && this.resultBytes + bytes <= MAX_RESULTS &&
|
|
213
|
+
this.terminals.size < MAX_TASKS && !this.terminals.has(match[2])) {
|
|
214
|
+
this.resultBytes += bytes;
|
|
215
|
+
this.terminals.set(match[2], { taskId: match[1], state: match[3], result: match[4] });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
publish() {
|
|
220
|
+
for (const [id, terminal] of this.terminals) {
|
|
221
|
+
const call = this.calls.get(id), agentId = this.launches.get(id);
|
|
222
|
+
if (this.delivered.has(id) || this.pendingProcesses.has(id) || !call?.invocationComplete || call.nativeTerminal || !agentId)
|
|
223
|
+
continue;
|
|
224
|
+
this.delivered.add(id);
|
|
225
|
+
this.options.emit({ eventName: 'tool_call_update', payload: {
|
|
226
|
+
name: 'Agent', kind: 'think', toolCallId: id, rawInput: call.input, status: 'completed',
|
|
227
|
+
rawOutput: { kind: 'agent-result', state: terminal.state, agentId, result: terminal.result },
|
|
228
|
+
content: [{ type: 'content', content: { type: 'text', text: terminal.result } }],
|
|
229
|
+
_meta: { qoder: { toolName: 'Agent' }, axomNativeTerminalRepair: {
|
|
230
|
+
patch: 'qoder-background-terminal', nativeSessionId: this.options.nativeSessionId,
|
|
231
|
+
taskId: terminal.taskId, source: 'native-task-notification',
|
|
232
|
+
} },
|
|
233
|
+
} });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async pollProcesses() {
|
|
237
|
+
for (const [id, agentId] of this.launches) {
|
|
238
|
+
const call = this.calls.get(id);
|
|
239
|
+
if (this.closed || this.delivered.has(id) || !call?.process || call.nativeTerminal)
|
|
240
|
+
continue;
|
|
241
|
+
let process = this.processes.get(id);
|
|
242
|
+
if (!process) {
|
|
243
|
+
process = new BackgroundProcess({
|
|
244
|
+
path: join(dirname(this.options.path), this.options.nativeSessionId, 'subagents', `agent-${agentId}.jsonl`),
|
|
245
|
+
sessionId: this.options.nativeSessionId, cwd: this.options.cwd, agentId, parentToolUseId: id,
|
|
246
|
+
emit: event => { if (!this.closed)
|
|
247
|
+
this.options.emit(event); },
|
|
248
|
+
});
|
|
249
|
+
this.processes.set(id, process);
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
if (await process.poll())
|
|
253
|
+
this.pendingProcesses.delete(id);
|
|
254
|
+
else
|
|
255
|
+
this.pendingProcesses.add(id);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
this.pendingProcesses.delete(id); /* missing/corrupt transcript is not execution evidence */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { capabilitiesFor, QODER_CERTIFICATIONS } from './profile.js';
|
|
2
|
+
const MAX_ID_BYTES = 256;
|
|
3
|
+
const MAX_MESSAGE_BYTES = 131072;
|
|
4
|
+
const MAX_RECORDS = 4096;
|
|
5
|
+
const states = new Set(['pending', 'paused', 'running', 'completed', 'stopped', 'failed', 'unknown']);
|
|
6
|
+
const id = (value) => typeof value === 'string' && value.trim().length > 0 &&
|
|
7
|
+
Buffer.byteLength(value, 'utf8') <= MAX_ID_BYTES && !/[\x00-\x1f\x7f]/.test(value);
|
|
8
|
+
const message = (value) => typeof value === 'string' &&
|
|
9
|
+
Buffer.byteLength(value, 'utf8') <= MAX_MESSAGE_BYTES;
|
|
10
|
+
function exactIdentity(value) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
12
|
+
return false;
|
|
13
|
+
const input = value;
|
|
14
|
+
return ['rootSessionId', 'runtimeGeneration', 'sourceTaskId', 'agentId', 'requestId', 'toolUseId']
|
|
15
|
+
.every((key) => id(input[key]));
|
|
16
|
+
}
|
|
17
|
+
function hasVendorResumeClaim(value) {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19
|
+
return false;
|
|
20
|
+
if (Object.prototype.hasOwnProperty.call(value, 'resumedAgentId'))
|
|
21
|
+
return true;
|
|
22
|
+
return Object.values(value).some((entry) => hasVendorResumeClaim(entry));
|
|
23
|
+
}
|
|
24
|
+
export function normalizeContinuationEvidence(value) {
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || hasVendorResumeClaim(value))
|
|
26
|
+
return undefined;
|
|
27
|
+
const input = value;
|
|
28
|
+
if (!exactIdentity(input.request) || !exactIdentity(input.sendMessage))
|
|
29
|
+
return undefined;
|
|
30
|
+
if (typeof input.sendMessage.success !== 'boolean')
|
|
31
|
+
return undefined;
|
|
32
|
+
const joined = input.joinedTask;
|
|
33
|
+
if (joined === undefined)
|
|
34
|
+
return {
|
|
35
|
+
request: { ...input.request },
|
|
36
|
+
sendMessage: { ...input.sendMessage,
|
|
37
|
+
success: input.sendMessage.success },
|
|
38
|
+
};
|
|
39
|
+
if (!joined || typeof joined !== 'object' || Array.isArray(joined))
|
|
40
|
+
return undefined;
|
|
41
|
+
const task = joined;
|
|
42
|
+
const joinedTask = task;
|
|
43
|
+
if (!exactIdentity(task) || !id(joinedTask.taskId) || joinedTask.taskId === joinedTask.sourceTaskId ||
|
|
44
|
+
!states.has(joinedTask.state) || !['pending', 'running', 'completed'].includes(joinedTask.state) ||
|
|
45
|
+
typeof joinedTask.live !== 'boolean' || typeof joinedTask.stale !== 'boolean')
|
|
46
|
+
return undefined;
|
|
47
|
+
return {
|
|
48
|
+
request: { ...input.request },
|
|
49
|
+
sendMessage: { ...input.sendMessage,
|
|
50
|
+
success: input.sendMessage.success },
|
|
51
|
+
joinedTask: { ...joinedTask },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function sameIdentity(a, b) {
|
|
55
|
+
return a.rootSessionId === b.rootSessionId && a.runtimeGeneration === b.runtimeGeneration &&
|
|
56
|
+
a.sourceTaskId === b.sourceTaskId && a.agentId === b.agentId && a.requestId === b.requestId &&
|
|
57
|
+
a.toolUseId === b.toolUseId;
|
|
58
|
+
}
|
|
59
|
+
/** Confirm only an observed, exact new execution. Natural-language claims are intentionally ignored. */
|
|
60
|
+
export function confirmContinuation(expectedAgentId, value) {
|
|
61
|
+
if (!id(expectedAgentId))
|
|
62
|
+
return 'rejected';
|
|
63
|
+
const evidence = normalizeContinuationEvidence(value);
|
|
64
|
+
if (!evidence || !evidence.joinedTask || !evidence.sendMessage.success ||
|
|
65
|
+
evidence.request.agentId !== expectedAgentId || !sameIdentity(evidence.request, evidence.sendMessage) ||
|
|
66
|
+
!sameIdentity(evidence.request, evidence.joinedTask) || evidence.joinedTask.agentId !== expectedAgentId ||
|
|
67
|
+
evidence.joinedTask.stale || !evidence.joinedTask.taskId ||
|
|
68
|
+
evidence.joinedTask.taskId === evidence.request.sourceTaskId)
|
|
69
|
+
return 'rejected';
|
|
70
|
+
return 'confirmed';
|
|
71
|
+
}
|
|
72
|
+
function key(identity) {
|
|
73
|
+
return [identity.rootSessionId, identity.runtimeGeneration, identity.requestId].join('\u0000');
|
|
74
|
+
}
|
|
75
|
+
/** A bounded, request-scoped ledger prevents duplicate confirmations and keeps late proof unknown. */
|
|
76
|
+
export class ContinuationLedger {
|
|
77
|
+
requests = new Map();
|
|
78
|
+
statuses = new Map();
|
|
79
|
+
joinedTaskIds = new Set();
|
|
80
|
+
evidence = new Map();
|
|
81
|
+
request(identity) {
|
|
82
|
+
if (!exactIdentity(identity) || this.requests.size >= MAX_RECORDS)
|
|
83
|
+
return 'rejected';
|
|
84
|
+
const requestKey = key(identity);
|
|
85
|
+
if (this.requests.has(requestKey))
|
|
86
|
+
return 'rejected';
|
|
87
|
+
this.requests.set(requestKey, { ...identity });
|
|
88
|
+
this.statuses.set(requestKey, 'requested');
|
|
89
|
+
return 'requested';
|
|
90
|
+
}
|
|
91
|
+
confirm(expectedAgentId, value) {
|
|
92
|
+
const evidence = normalizeContinuationEvidence(value);
|
|
93
|
+
if (!evidence)
|
|
94
|
+
return 'rejected';
|
|
95
|
+
const requestKey = key(evidence.request);
|
|
96
|
+
const original = this.requests.get(requestKey);
|
|
97
|
+
if (!original || !sameIdentity(original, evidence.request))
|
|
98
|
+
return 'unknown';
|
|
99
|
+
if (!evidence.joinedTask)
|
|
100
|
+
return 'unknown';
|
|
101
|
+
if (this.statuses.get(requestKey) === 'confirmed' || this.joinedTaskIds.has(evidence.joinedTask.taskId)) {
|
|
102
|
+
return 'rejected';
|
|
103
|
+
}
|
|
104
|
+
const result = confirmContinuation(expectedAgentId, evidence);
|
|
105
|
+
if (result === 'confirmed') {
|
|
106
|
+
this.joinedTaskIds.add(evidence.joinedTask.taskId);
|
|
107
|
+
this.statuses.set(requestKey, 'confirmed');
|
|
108
|
+
this.evidence.set(requestKey, evidence);
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
this.statuses.set(requestKey, 'rejected');
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
snapshot() {
|
|
115
|
+
return [...this.evidence.values()].map((entry) => ({
|
|
116
|
+
request: { ...entry.request }, sendMessage: { ...entry.sendMessage },
|
|
117
|
+
...(entry.joinedTask ? { joinedTask: { ...entry.joinedTask } } : {}),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function requestCompletedContinuation(rootSessionId, agentId, text, requestId, options) {
|
|
122
|
+
const rejected = () => ({ status: 'rejected', rootSessionId, agentId, requestId });
|
|
123
|
+
if (!options)
|
|
124
|
+
return rejected();
|
|
125
|
+
const profile = options.profile, task = options.task;
|
|
126
|
+
if (!id(rootSessionId) || !id(agentId) || !id(requestId) || !message(text) || options.enabled !== true ||
|
|
127
|
+
options.busy === true || !profile || !task ||
|
|
128
|
+
task.origin.rootSessionId !== rootSessionId || task.origin.agentId !== agentId ||
|
|
129
|
+
task.origin.runtimeGeneration !== profile.runtimeGeneration ||
|
|
130
|
+
!capabilitiesFor(profile, task, options.certifications ?? QODER_CERTIFICATIONS, options.binding).continueCompleted)
|
|
131
|
+
return rejected();
|
|
132
|
+
try {
|
|
133
|
+
await options.prompt(text);
|
|
134
|
+
return { status: 'requested', rootSessionId, agentId, requestId };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return rejected();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { parseQoderProfile } from './profile.js';
|
|
3
|
+
/**
|
|
4
|
+
* Native SDK/Hooks capabilities are opt-in until one real process has produced
|
|
5
|
+
* a complete, attributable contract record. The fake fixture must never add a
|
|
6
|
+
* profile to QODER_CERTIFICATIONS by itself.
|
|
7
|
+
*/
|
|
8
|
+
export const QODER_NATIVE_FALLBACKS = Object.freeze({
|
|
9
|
+
model: 'Model changes are negotiated by the base ACP session only.',
|
|
10
|
+
mode: 'Mode changes are negotiated by the base ACP session only.',
|
|
11
|
+
media: 'Prompt media and resources are unsupported by the SDK adapter.',
|
|
12
|
+
forms: 'Form elicitation is unsupported until an equivalent public SDK callback is certified.',
|
|
13
|
+
load: 'Load requires an exact native session proof; it never creates a replacement session.',
|
|
14
|
+
fork: 'Fork is unsupported until an exact new native execution can be proved.',
|
|
15
|
+
});
|
|
16
|
+
export function nativeFallback(feature) {
|
|
17
|
+
return { supported: false, reason: QODER_NATIVE_FALLBACKS[feature] };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parses the deliberately small output contract of an opt-in native runner.
|
|
21
|
+
* This is validation only: callers still decide whether to persist evidence or
|
|
22
|
+
* open a capability, and the shipping certification allowlist remains closed.
|
|
23
|
+
*/
|
|
24
|
+
export function parseNativeContractEvidence(value) {
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
26
|
+
return undefined;
|
|
27
|
+
const input = value;
|
|
28
|
+
const profile = parseQoderProfile(input.profile);
|
|
29
|
+
if (input.certified !== true || !profile || input.tasks !== true || input.hooks !== true ||
|
|
30
|
+
input.approvals !== true || input.terminalControl !== true || input.replayFree !== true ||
|
|
31
|
+
profile.cliVersion !== '1.1.64' || profile.sdkVersion !== '1.0.50' || profile.mode !== 'sdk-adapter' ||
|
|
32
|
+
profile.transport !== 'process' || profile.initialized !== true || profile.rootConfirmed !== true ||
|
|
33
|
+
profile.observerHealthy !== true || !profile.capabilities.includes('background_tasks_v1'))
|
|
34
|
+
return undefined;
|
|
35
|
+
return { certified: true, profile, tasks: true, hooks: true, approvals: true,
|
|
36
|
+
terminalControl: true, replayFree: true };
|
|
37
|
+
}
|
|
38
|
+
const MAX_OUTPUT_BYTES = 128 * 1024;
|
|
39
|
+
const TIMEOUT_MS = 120_000;
|
|
40
|
+
/**
|
|
41
|
+
* Runs an external certification command only when explicitly configured. The
|
|
42
|
+
* command is an executable path (no shell), receives no credentials from this
|
|
43
|
+
* helper, and must print NativeContractEvidence JSON to stdout.
|
|
44
|
+
*/
|
|
45
|
+
export async function runOptInNativeContract(command = process.env.AXOM_QODER_SDK_CONTRACT_COMMAND) {
|
|
46
|
+
if (!command)
|
|
47
|
+
return { status: 'not-run', stdout: '', stderr: '' };
|
|
48
|
+
if (command.includes('\0') || command.trim() !== command || command.length > 4096)
|
|
49
|
+
throw new Error('Invalid native contract command');
|
|
50
|
+
return await new Promise((resolve) => {
|
|
51
|
+
const child = spawn(command, [], { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
52
|
+
let stdout = '', stderr = '', oversized = false, settled = false;
|
|
53
|
+
const append = (target, chunk) => {
|
|
54
|
+
if (Buffer.byteLength(target === 'stdout' ? stdout : stderr, 'utf8') + chunk.byteLength > MAX_OUTPUT_BYTES) {
|
|
55
|
+
oversized = true;
|
|
56
|
+
child.kill('SIGTERM');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (target === 'stdout')
|
|
60
|
+
stdout += chunk.toString('utf8');
|
|
61
|
+
else
|
|
62
|
+
stderr += chunk.toString('utf8');
|
|
63
|
+
};
|
|
64
|
+
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
65
|
+
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
66
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), TIMEOUT_MS);
|
|
67
|
+
const finish = (result) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve(result);
|
|
73
|
+
};
|
|
74
|
+
child.once('error', (error) => finish({ status: 'failed', command, stdout, stderr: `${stderr}${error.message}` }));
|
|
75
|
+
child.once('close', (code) => finish({ status: oversized ? 'failed' : code === 0 ? 'completed' : 'failed',
|
|
76
|
+
command, exitCode: code, stdout, stderr }));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function certifiedRuntime(identity) {
|
|
2
|
+
return identity?.name === 'qoder-cli' && identity.version === '1.1.61';
|
|
3
|
+
}
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
export class ForegroundOutputBoundary {
|
|
8
|
+
nativeSessionId;
|
|
9
|
+
pending = new Set();
|
|
10
|
+
constructor(nativeSessionId) {
|
|
11
|
+
this.nativeSessionId = nativeSessionId;
|
|
12
|
+
}
|
|
13
|
+
accept(event) {
|
|
14
|
+
if (event.acpSessionId && event.acpSessionId !== this.nativeSessionId)
|
|
15
|
+
return true;
|
|
16
|
+
const p = event.payload, input = record(p.rawInput);
|
|
17
|
+
if (event.eventName === 'tool_call' && p.kind === 'think' && record(record(p._meta).qoder).toolName === 'Agent' &&
|
|
18
|
+
typeof p.toolCallId === 'string' && typeof input.prompt === 'string' &&
|
|
19
|
+
(input.run_in_background === undefined || input.run_in_background === false))
|
|
20
|
+
this.pending.add(p.toolCallId);
|
|
21
|
+
if (event.eventName === 'tool_call_update' && typeof p.toolCallId === 'string' &&
|
|
22
|
+
['completed', 'failed', 'cancelled'].includes(String(p.status)))
|
|
23
|
+
this.pending.delete(p.toolCallId);
|
|
24
|
+
// Identified/draft child streams and controls keep their existing handling. Never assign an
|
|
25
|
+
// unaddressed chunk to whichever child happens to finish first.
|
|
26
|
+
return !this.pending.size || !['agent_message_chunk', 'agent_thought_chunk'].includes(event.eventName) ||
|
|
27
|
+
typeof p.messageId === 'string' || p._meta != null;
|
|
28
|
+
}
|
|
29
|
+
endTurn() { this.pending.clear(); }
|
|
30
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getSubagentMessages, listSubagents } from '@qoder-ai/qoder-agent-sdk';
|
|
2
|
+
const DEFAULT_LIMIT = 50;
|
|
3
|
+
const MAX_LIMIT = 200;
|
|
4
|
+
const MAX_OFFSET = 100_000;
|
|
5
|
+
const MAX_IDS = 4096;
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
8
|
+
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const sdkReader = { listSubagents, getSubagentMessages };
|
|
11
|
+
const safeIdentity = (value) => typeof value === 'string' &&
|
|
12
|
+
value.length > 0 && value.length <= 256 && !/[\x00-\x1f\x7f\\/]/.test(value) &&
|
|
13
|
+
/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
|
|
14
|
+
function validDirectory(value) {
|
|
15
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 4096 &&
|
|
16
|
+
value.startsWith('/') && !/[\x00-\x1f\x7f]/.test(value) &&
|
|
17
|
+
!value.replaceAll('\\', '/').split('/').includes('..');
|
|
18
|
+
}
|
|
19
|
+
function unknownResult(rootSessionId, agentId, offset = 0, limit = DEFAULT_LIMIT, reason = 'sdk-error') {
|
|
20
|
+
return { status: 'unknown', entries: [], rootSessionId, agentId, offset, limit,
|
|
21
|
+
hasMore: false, snapshotStable: false, reason };
|
|
22
|
+
}
|
|
23
|
+
async function bounded(operation, timeoutMs) {
|
|
24
|
+
return await new Promise((resolve, reject) => {
|
|
25
|
+
const timer = setTimeout(() => reject(new Error('history read timed out')), timeoutMs);
|
|
26
|
+
operation.then(resolve, reject).finally(() => clearTimeout(timer));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function normalizeMessage(rootSessionId, agentId, message) {
|
|
30
|
+
const kind = message.type === 'user' || message.type === 'assistant' || message.type === 'system'
|
|
31
|
+
? message.type : 'unknown';
|
|
32
|
+
const parent = safeIdentity(message.parent_agent_id) ? message.parent_agent_id : undefined;
|
|
33
|
+
return {
|
|
34
|
+
kind, id: safeIdentity(message.uuid) ? message.uuid : 'unknown', rootSessionId, agentId,
|
|
35
|
+
source: 'qoder-sdk', readOnly: true, actions: [], message: message.message,
|
|
36
|
+
...(typeof message.timestamp === 'string' ? { timestamp: message.timestamp } : {}),
|
|
37
|
+
...(parent ? { parentAgentId: parent } : {}),
|
|
38
|
+
parentAgentIdStatus: parent ? 'verified' : 'unknown',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reads a bounded, read-only child transcript using only Qoder's public SDK APIs.
|
|
43
|
+
* `offset` is a page offset, never a durable tail cursor. The SDK may read the full
|
|
44
|
+
* transcript before applying it; maxBytes bounds returned data, not bytes read on disk.
|
|
45
|
+
*/
|
|
46
|
+
export async function readChildHistory(rootSessionId, agentId, page, reader = sdkReader) {
|
|
47
|
+
const offset = page?.offset ?? 0;
|
|
48
|
+
const limit = page?.limit ?? DEFAULT_LIMIT;
|
|
49
|
+
const timeoutMs = page?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
50
|
+
const maxBytes = page?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
51
|
+
if (!safeIdentity(rootSessionId) || !safeIdentity(agentId))
|
|
52
|
+
return unknownResult(rootSessionId, agentId, 0, DEFAULT_LIMIT, 'invalid-identity');
|
|
53
|
+
if (!validDirectory(page?.dir) || !Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET ||
|
|
54
|
+
!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT ||
|
|
55
|
+
!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS ||
|
|
56
|
+
!Number.isSafeInteger(maxBytes) || maxBytes < 1024 || maxBytes > MAX_BYTES)
|
|
57
|
+
return unknownResult(rootSessionId, agentId, Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, Number.isSafeInteger(limit) && limit > 0 ? limit : DEFAULT_LIMIT, 'invalid-page');
|
|
58
|
+
const rosterOptions = { dir: page.dir };
|
|
59
|
+
const messageOptions = { dir: page.dir, offset, limit };
|
|
60
|
+
try {
|
|
61
|
+
const listed = await bounded(reader.listSubagents(rootSessionId, rosterOptions), timeoutMs);
|
|
62
|
+
if (!Array.isArray(listed) || listed.length > MAX_IDS ||
|
|
63
|
+
listed.some((id) => !safeIdentity(id)))
|
|
64
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'malformed-result');
|
|
65
|
+
if (!listed.includes(agentId))
|
|
66
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'child-not-listed');
|
|
67
|
+
const messages = await bounded(reader.getSubagentMessages(rootSessionId, agentId, messageOptions), timeoutMs);
|
|
68
|
+
if (!Array.isArray(messages) || messages.length > limit || messages.length > MAX_IDS)
|
|
69
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'malformed-result');
|
|
70
|
+
if (messages.some((message) => !message || typeof message !== 'object' ||
|
|
71
|
+
message.session_id !== rootSessionId || !safeIdentity(message.uuid)))
|
|
72
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'malformed-result');
|
|
73
|
+
const entries = messages.map((message) => normalizeMessage(rootSessionId, agentId, message));
|
|
74
|
+
const serialized = JSON.stringify(entries);
|
|
75
|
+
if (serialized === undefined || Buffer.byteLength(serialized, 'utf8') > maxBytes)
|
|
76
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'oversized-result');
|
|
77
|
+
// Recheck membership after the read so a changing child roster cannot be presented as stable.
|
|
78
|
+
const after = await bounded(reader.listSubagents(rootSessionId, rosterOptions), timeoutMs);
|
|
79
|
+
if (!Array.isArray(after) || !after.includes(agentId))
|
|
80
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'snapshot-changed');
|
|
81
|
+
return { status: 'verified', entries, rootSessionId, agentId, offset, limit,
|
|
82
|
+
hasMore: messages.length === limit, snapshotStable: true };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return unknownResult(rootSessionId, agentId, offset, limit, 'sdk-error');
|
|
86
|
+
}
|
|
87
|
+
}
|