pi-supernova 0.9.1 → 0.10.1
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 +201 -15
- package/docs/CHANGELOG.md +10 -0
- package/index.js +30 -9
- package/package.json +1 -1
- package/src/adapters/bash.js +40 -0
- package/src/adapters/read.js +24 -3
- package/src/bridge/host-bridge.js +50 -7
- package/src/bridge/invoke.js +6 -1
- package/src/bridge/trace.js +4 -1
- package/src/context/evidence.js +10 -1
- package/src/context/snap.js +11 -2
- package/src/contract/bash.js +43 -11
- package/src/contract/read.js +24 -8
- package/src/fs/background.js +304 -0
- package/src/fs/commit.js +6 -0
- package/src/fs/file-io.js +17 -2
- package/src/fs/json-read.js +42 -0
- package/src/fs/process-tree.js +92 -0
- package/src/fs/read-window.js +18 -1
- package/src/fs/vfs.js +17 -4
- package/src/fs/workspace.js +51 -46
- package/src/output/bottleneck.js +14 -1
- package/src/runtime/guest-api.js +36 -20
- package/src/runtime/reference.js +11 -14
- package/src/shared/syntax-context.js +28 -7
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createBackgroundTerminals } from "../fs/background.js";
|
|
1
2
|
import {createToolRegistry} from './tool-registry.js';
|
|
2
3
|
import {traceArgs,finishRecord} from './trace.js';
|
|
3
4
|
import * as fs from "node:fs/promises";
|
|
@@ -16,20 +17,23 @@ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from
|
|
|
16
17
|
import { createNativeAdapters } from "../adapters/index.js";
|
|
17
18
|
import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
|
|
18
19
|
|
|
19
|
-
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
|
|
20
|
+
export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget, terminalIdentity }) {
|
|
20
21
|
const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
|
|
21
22
|
const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
|
|
22
23
|
|
|
23
24
|
const vfs = new CausalVfs(paths => {
|
|
24
25
|
index.invalidate();
|
|
25
26
|
notifyWorkspaceChanged(paths);
|
|
26
|
-
}, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
|
|
27
|
+
}, target => resolveWorkspacePath(getCwd(), target, "commit", false, true), assertSession);
|
|
27
28
|
|
|
28
29
|
const executors = registry?.executors ?? new Map();
|
|
29
30
|
const definitions = registry?.definitions ?? new Map();
|
|
30
31
|
const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
|
|
32
|
+
sharedRegistry.terminals ??= createBackgroundTerminals();
|
|
31
33
|
let closed = false;
|
|
32
|
-
const hooks = {};
|
|
34
|
+
const hooks = { terminals: sharedRegistry.terminals, terminalGeneration: terminalIdentity?.generation ?? sharedRegistry.terminals.getGeneration() };
|
|
35
|
+
const ownerOf = ctx => JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(getCwd())]);
|
|
36
|
+
let terminalOwner = terminalIdentity?.owner ?? ownerOf(null);
|
|
33
37
|
const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
|
|
34
38
|
let callCount = 0;
|
|
35
39
|
let activeCtx = null;
|
|
@@ -51,9 +55,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
51
55
|
try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
hooks.terminalOwner = () => terminalOwner;
|
|
54
59
|
hooks.workspaceChanged = notifyWorkspaceChanged;
|
|
55
|
-
hooks.artifactsDir = () =>
|
|
60
|
+
hooks.artifactsDir = () => {
|
|
61
|
+
assertSession();
|
|
62
|
+
|
|
63
|
+
return activeCtx?.sessionManager?.getArtifactsDir?.();
|
|
64
|
+
};
|
|
65
|
+
|
|
56
66
|
hooks.commandEnv = () => {
|
|
67
|
+
assertSession();
|
|
57
68
|
const env = { ...process.env };
|
|
58
69
|
|
|
59
70
|
const current = {
|
|
@@ -77,6 +88,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
77
88
|
|
|
78
89
|
function bindCallContext(ctx, signal) {
|
|
79
90
|
activeCtx = ctx || null;
|
|
91
|
+
terminalOwner = terminalIdentity?.owner ?? ownerOf(ctx);
|
|
80
92
|
tools.bindSession(ctx);
|
|
81
93
|
activeSignal = signal;
|
|
82
94
|
vfs.signal = signal;
|
|
@@ -100,8 +112,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
100
112
|
} catch {}
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
function assertSession() {
|
|
116
|
+
if (hooks.terminalGeneration !== sharedRegistry.terminals.getGeneration() || terminalOwner !== ownerOf(activeCtx)) {
|
|
117
|
+
throw new Error("host session changed; start a new call");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
function assertRunOpen(name) {
|
|
104
122
|
if (closed) throw new Error("program is already complete");
|
|
123
|
+
assertSession();
|
|
105
124
|
|
|
106
125
|
if (activeSignal?.aborted) throw new Error("aborted");
|
|
107
126
|
|
|
@@ -149,7 +168,10 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
function assertOwnedOverride(name, args) {
|
|
171
|
+
if (name === "bash" && (args?.background === true || args?.action !== undefined)) throw new Error("background terminals require the Supernova-owned bash adapter, not an external override");
|
|
172
|
+
|
|
152
173
|
if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
|
|
174
|
+
|
|
153
175
|
if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
|
|
154
176
|
}
|
|
155
177
|
|
|
@@ -159,8 +181,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
159
181
|
const mutating = isMutatingTool(name, config, args, definitions.get(name));
|
|
160
182
|
|
|
161
183
|
if (mutating) await vfs.prepareExternalMutation(name);
|
|
184
|
+
|
|
162
185
|
if (activeSignal?.aborted || closed) throw new Error("aborted");
|
|
186
|
+
|
|
163
187
|
if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
|
|
188
|
+
assertSession();
|
|
164
189
|
|
|
165
190
|
try {
|
|
166
191
|
const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
|
|
@@ -176,6 +201,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
176
201
|
}
|
|
177
202
|
|
|
178
203
|
async function invokeNative(target, args, record, onItem) {
|
|
204
|
+
assertSession();
|
|
179
205
|
const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal, onItem);
|
|
180
206
|
completeRecord(record, res);
|
|
181
207
|
|
|
@@ -207,6 +233,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
207
233
|
const target = resolveInvokeTarget(name, args, { hostTool, hostSession: tools.session, executors, natives });
|
|
208
234
|
|
|
209
235
|
if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
|
|
236
|
+
|
|
210
237
|
if (target.kind === "native") return await invokeNative(target, args, record, onItem);
|
|
211
238
|
|
|
212
239
|
throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
|
|
@@ -218,11 +245,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
218
245
|
|
|
219
246
|
function fileMutationKey(name, args) {
|
|
220
247
|
if (!["edit", "write", "apply_patch"].includes(name) || !isString(args?.path)) return;
|
|
248
|
+
|
|
221
249
|
// Overrides can mutate more than their declared path: keep them global.
|
|
222
250
|
if (hostTool(name) || executors.has(name)) return;
|
|
223
251
|
|
|
224
252
|
return async () => {
|
|
225
253
|
const target = await resolveWorkspacePath(getCwd(), args.path, name, false, true);
|
|
254
|
+
|
|
226
255
|
// Share the commit identity, including symlinks and not-yet-created files.
|
|
227
256
|
return (await resolveCommitTarget(target)).target;
|
|
228
257
|
};
|
|
@@ -296,8 +325,14 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
296
325
|
fork(options) {
|
|
297
326
|
const runConfig = options.timeoutMs === undefined ? config : { ...config, timeoutMs: Number(options.timeoutMs) };
|
|
298
327
|
|
|
299
|
-
return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
|
|
328
|
+
return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget, terminalIdentity:options.terminalIdentity });
|
|
300
329
|
},
|
|
330
|
+
captureTerminalIdentity: (ctx, runCwd) => ({
|
|
331
|
+
generation:sharedRegistry.terminals.getGeneration(),
|
|
332
|
+
owner:JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(runCwd)]),
|
|
333
|
+
}),
|
|
334
|
+
shutdownTerminals: () => sharedRegistry.terminals.shutdown(),
|
|
335
|
+
reopenTerminals: () => sharedRegistry.terminals.reopen(),
|
|
301
336
|
close() { closed = true; vfs.closed = true; },
|
|
302
337
|
bindCallContext,
|
|
303
338
|
resetCallBudget,
|
|
@@ -305,8 +340,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
|
|
|
305
340
|
getMutations: () => ({ ...vfs.mutations }),
|
|
306
341
|
setCallListener: fn => { callListener = isFunction(fn) ? fn : null; },
|
|
307
342
|
barrier: run => scheduler.schedule("write", run, activeSignal),
|
|
308
|
-
beginSpeculation
|
|
309
|
-
|
|
343
|
+
beginSpeculation() {
|
|
344
|
+
assertSession();
|
|
345
|
+
|
|
346
|
+
return vfs.begin();
|
|
347
|
+
},
|
|
348
|
+
async commitSpeculation() {
|
|
349
|
+
assertSession();
|
|
350
|
+
|
|
351
|
+
return await vfs.commit();
|
|
352
|
+
},
|
|
310
353
|
rollbackSpeculation: () => vfs.rollback(),
|
|
311
354
|
getOverlayDepth: () => vfs.getOverlayDepth(),
|
|
312
355
|
call,
|
package/src/bridge/invoke.js
CHANGED
|
@@ -3,15 +3,18 @@ import { isString } from "../shared/decode.js";
|
|
|
3
3
|
/** Ordered permission rules. First decisive answer wins. */
|
|
4
4
|
export function toolIsCallable(name, env) {
|
|
5
5
|
if (name === "supernova" || env.excluded.has(name)) return false;
|
|
6
|
+
|
|
6
7
|
if (env.sessionInvalid()) return false;
|
|
8
|
+
|
|
7
9
|
if (env.nativeOwned(name)) return true;
|
|
10
|
+
|
|
8
11
|
if (env.hostSession) return env.evalAllows(name);
|
|
9
12
|
|
|
10
13
|
return env.listed(name);
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
function isArgvOwned(name, args) {
|
|
14
|
-
return name === "bash" && Array.isArray(args?.args)
|
|
17
|
+
return name === "bash" && args?.background !== true && args?.action === undefined && Array.isArray(args?.args)
|
|
15
18
|
&& args.args.length === Object.keys(args.args).length && args.args.every(isString);
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -19,6 +22,7 @@ function hostExecutor(name, env) {
|
|
|
19
22
|
const delegated = env.hostTool(name);
|
|
20
23
|
|
|
21
24
|
if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
|
|
25
|
+
|
|
22
26
|
if (env.hostSession) return { exec: undefined, delegated };
|
|
23
27
|
|
|
24
28
|
return { exec: env.executors.get(name), delegated };
|
|
@@ -29,6 +33,7 @@ export function resolveInvokeTarget(name, args, env) {
|
|
|
29
33
|
const { exec, delegated } = hostExecutor(name, env);
|
|
30
34
|
|
|
31
35
|
if (exec && !argvOwned) return { kind: "override", exec, delegated, argvOwned: false };
|
|
36
|
+
|
|
32
37
|
if (env.natives[name]) return { kind: "native", native: env.natives[name], argvOwned };
|
|
33
38
|
|
|
34
39
|
return { kind: "unknown" };
|
package/src/bridge/trace.js
CHANGED
|
@@ -7,7 +7,7 @@ function traceArgs(args) {
|
|
|
7
7
|
if (!isObject(args)) return {};
|
|
8
8
|
const out = {};
|
|
9
9
|
|
|
10
|
-
for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
|
|
10
|
+
for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "sessionId", "op"]) {
|
|
11
11
|
const value = args[key];
|
|
12
12
|
|
|
13
13
|
if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
|
|
@@ -16,7 +16,9 @@ function traceArgs(args) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (isString(args.content)) out.content = args.content.length + " chars";
|
|
19
|
+
|
|
19
20
|
if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
|
|
21
|
+
|
|
20
22
|
if (Array.isArray(args.args)) out.args = args.args.length + " argv";
|
|
21
23
|
|
|
22
24
|
return out;
|
|
@@ -38,4 +40,5 @@ function finishRecord(record, res) {
|
|
|
38
40
|
|
|
39
41
|
if (text) record.resultText = truncateChars(text, 4096, "trace").text;
|
|
40
42
|
}
|
|
43
|
+
|
|
41
44
|
export { traceArgs, finishRecord };
|
package/src/context/evidence.js
CHANGED
|
@@ -3,6 +3,7 @@ import {pendingInScope,overlaySearchEntry} from './search-files.js';
|
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { WorkspaceIndex } from "./repo-index.js";
|
|
6
|
+
import { relativeSlash } from "../fs/workspace.js";
|
|
6
7
|
import { tokenizeQuery, scorePathTopology, stem } from "./query.js";
|
|
7
8
|
|
|
8
9
|
// Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
|
|
@@ -148,15 +149,18 @@ function render(spans, picks, fused, opts, root) {
|
|
|
148
149
|
const lastLine = span.start + text.split("\n").length - 1;
|
|
149
150
|
const truncated = lastLine < span.sourceEnd;
|
|
150
151
|
budget -= text.length;
|
|
152
|
+
|
|
151
153
|
const rendered = {
|
|
152
|
-
path:
|
|
154
|
+
path: relativeSlash(root, span.path),
|
|
153
155
|
lines: [span.start, lastLine],
|
|
154
156
|
name: span.name,
|
|
155
157
|
kind: span.kind,
|
|
156
158
|
why,
|
|
157
159
|
text,
|
|
158
160
|
};
|
|
161
|
+
|
|
159
162
|
if (truncated) { rendered.truncated = true; rendered.nextOffset = lastLine+1; }
|
|
163
|
+
|
|
160
164
|
out.push(rendered);
|
|
161
165
|
|
|
162
166
|
if (budget <= 0) break;
|
|
@@ -206,15 +210,20 @@ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
|
|
|
206
210
|
// its declaration. Keep the matching line in the bounded window, even in long bodies.
|
|
207
211
|
function usageSpans(spans, profile, maxSpanLines) {
|
|
208
212
|
if (profile.answerType !== "usage" || !profile.subjects.length) return spans;
|
|
213
|
+
|
|
209
214
|
return spans.flatMap(span => {
|
|
210
215
|
for (let i = span.start - 1; i < span.sourceEnd; i++) {
|
|
211
216
|
const words = span.lines.idents[i];
|
|
217
|
+
|
|
212
218
|
const matched = profile.subjects.some(subject =>
|
|
213
219
|
words.filter(word => word === subject).length > Number(span.lines.defNames[i] === subject.toLowerCase()));
|
|
220
|
+
|
|
214
221
|
if (!matched) continue;
|
|
215
222
|
const start = Math.max(span.start, i - 1);
|
|
223
|
+
|
|
216
224
|
return [{ ...span, start, end: Math.min(span.sourceEnd, start + maxSpanLines - 1) }];
|
|
217
225
|
}
|
|
226
|
+
|
|
218
227
|
return [];
|
|
219
228
|
});
|
|
220
229
|
}
|
package/src/context/snap.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {inScope,makeCandidate,contentCandidates,MAX_SEARCH_CHARS} from './snap-search.js';
|
|
2
2
|
import { tokenizeQuery, stem } from "./query.js";
|
|
3
|
+
|
|
3
4
|
export {tokenizeQuery,scorePathTopology,stem} from './query.js';
|
|
5
|
+
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
|
|
6
8
|
import * as fs from "node:fs/promises";
|
|
@@ -22,13 +24,13 @@ function rankScore(candidate, tokenCount) {
|
|
|
22
24
|
function location(candidate, root) {
|
|
23
25
|
const context = candidate.context;
|
|
24
26
|
|
|
25
|
-
return { path:
|
|
27
|
+
return { path: relativeSlash(root, candidate.path), line: candidate.line, signature: candidate.signature,
|
|
26
28
|
context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
async function spanCandidates(filePath, lines, root, overlayText, signal) {
|
|
30
32
|
const staged = overlayText(filePath);
|
|
31
|
-
const rel =
|
|
33
|
+
const rel = relativeSlash(root, filePath);
|
|
32
34
|
let text = staged;
|
|
33
35
|
|
|
34
36
|
if (text === undefined) {
|
|
@@ -38,10 +40,12 @@ async function spanCandidates(filePath, lines, root, overlayText, signal) {
|
|
|
38
40
|
const stat = await file.stat();
|
|
39
41
|
|
|
40
42
|
if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
|
|
43
|
+
|
|
41
44
|
if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
|
|
42
45
|
text = await file.readFile({ encoding: "utf8", signal });
|
|
43
46
|
} finally { await file.close(); }
|
|
44
47
|
}
|
|
48
|
+
|
|
45
49
|
const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
|
|
46
50
|
|
|
47
51
|
return lines.map(line => {
|
|
@@ -68,6 +72,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
|
|
|
68
72
|
try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
|
|
69
73
|
catch { signal?.throwIfAborted(); out.push(location(candidate, root)); }
|
|
70
74
|
}
|
|
75
|
+
|
|
71
76
|
if (out.length >= MAX_ALTERNATIVES) break;
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -104,7 +109,9 @@ function listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths) {
|
|
|
104
109
|
|
|
105
110
|
function filenameEligible(search, filePath, relative, tokens, exact, queryLower) {
|
|
106
111
|
if (search.candidates.has(filePath)) return false;
|
|
112
|
+
|
|
107
113
|
if (!tokens.some(token => relative.includes(token))) return false;
|
|
114
|
+
|
|
108
115
|
if (exact && tokens.length > 1 && !relative.includes(queryLower)) return false;
|
|
109
116
|
|
|
110
117
|
return true;
|
|
@@ -174,6 +181,7 @@ async function decideSnapResult(ranked, tokens, empty, candidates, relativeRoot,
|
|
|
174
181
|
function fuzzySnapMiss(exact, query, paths, pathContext, relativeRoot, empty) {
|
|
175
182
|
const eligible = exact && query.length >= 4 && query.length <= 64;
|
|
176
183
|
const limited = eligible && paths.length > 1024;
|
|
184
|
+
|
|
177
185
|
const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
|
|
178
186
|
{ ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
|
|
179
187
|
|
|
@@ -209,6 +217,7 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
|
|
|
209
217
|
signal?.throwIfAborted();
|
|
210
218
|
|
|
211
219
|
const empty = emptySnap();
|
|
220
|
+
|
|
212
221
|
const dirStat = await fs.stat(dir).catch(error => {
|
|
213
222
|
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
|
|
214
223
|
|
package/src/contract/bash.js
CHANGED
|
@@ -2,13 +2,10 @@ import { isString, isObject } from "../shared/decode.js";
|
|
|
2
2
|
|
|
3
3
|
const ARGV_ERROR = "bash argv requires a command string and an array of string args";
|
|
4
4
|
|
|
5
|
-
function quoteShellArg(value) {
|
|
6
|
-
return "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
5
|
/** Normalize guest bash(command, opts) / bash({command, args}) into host args. */
|
|
10
6
|
function normalizeArgv(args) {
|
|
11
7
|
if (args.args === undefined) return;
|
|
8
|
+
|
|
12
9
|
if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
|
|
13
10
|
|
|
14
11
|
for (let i = 0; i < args.args.length; i++) {
|
|
@@ -17,30 +14,39 @@ function normalizeArgv(args) {
|
|
|
17
14
|
throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
|
|
18
15
|
}
|
|
19
16
|
}
|
|
17
|
+
|
|
20
18
|
args.args = args.args.map((arg, i) => {
|
|
21
19
|
if (arg.includes("\0")) throw new Error(`bash args[${i}] must not contain null bytes`);
|
|
20
|
+
|
|
22
21
|
return String(arg);
|
|
23
22
|
});
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
delete args._directArgv;
|
|
27
|
-
args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
|
|
28
|
-
delete args.args;
|
|
29
|
-
} else args._directArgv = true;
|
|
24
|
+
args._directArgv = true;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
|
|
27
|
+
const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv", "background", "pty"];
|
|
33
28
|
|
|
34
29
|
/** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
|
|
35
30
|
function assertBashOptions(args) {
|
|
36
31
|
const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
|
|
37
32
|
|
|
38
|
-
if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs");
|
|
33
|
+
if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs, background, pty");
|
|
39
34
|
}
|
|
35
|
+
|
|
40
36
|
export function normalizeBash(command, opts) {
|
|
41
37
|
const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
|
|
38
|
+
|
|
39
|
+
if (args.action !== undefined || args.sessionId !== undefined) return normalizeTerminalControl(args);
|
|
42
40
|
assertBashOptions(args);
|
|
41
|
+
|
|
42
|
+
for (const key of ["background", "pty"]) {
|
|
43
|
+
if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error(`bash ${key} must be boolean`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (args.pty !== undefined && args.background !== true) throw new Error("bash pty requires background:true");
|
|
47
|
+
|
|
43
48
|
if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
|
|
49
|
+
|
|
44
50
|
if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
|
|
45
51
|
normalizeArgv(args);
|
|
46
52
|
|
|
@@ -49,11 +55,37 @@ export function normalizeBash(command, opts) {
|
|
|
49
55
|
return args;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
function normalizeTerminalControl(args) {
|
|
59
|
+
const fields = {
|
|
60
|
+
list: ["action"],
|
|
61
|
+
poll: ["action", "sessionId", "cursor", "waitMs"],
|
|
62
|
+
write: ["action", "sessionId", "input"],
|
|
63
|
+
stop: ["action", "sessionId"],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!Object.hasOwn(fields, args.action)) throw new Error("bash terminal action must be list, poll, write or stop");
|
|
67
|
+
const unknown = Object.keys(args).filter(key => !fields[args.action].includes(key));
|
|
68
|
+
|
|
69
|
+
if (unknown.length) throw new Error(`bash ${args.action} does not accept option ${unknown.join(", ")}`);
|
|
70
|
+
|
|
71
|
+
if (args.action !== "list" && (!isString(args.sessionId) || !args.sessionId.trim())) throw new Error("bash terminal action requires sessionId");
|
|
72
|
+
|
|
73
|
+
if (args.action === "write" && (!isString(args.input) || args.input.length > 16384)) throw new Error("bash terminal input must be a string of at most 16384 characters");
|
|
74
|
+
|
|
75
|
+
if (args.cursor !== undefined && (!Number.isSafeInteger(args.cursor) || args.cursor < 0)) throw new Error("bash terminal cursor must be a non-negative safe integer");
|
|
76
|
+
|
|
77
|
+
if (args.waitMs !== undefined && (!Number.isInteger(args.waitMs) || args.waitMs < 0 || args.waitMs > 30000)) throw new Error("bash terminal waitMs must be an integer from 0 to 30000");
|
|
78
|
+
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
function normalizeTimeout(args) {
|
|
53
83
|
if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
|
|
84
|
+
|
|
54
85
|
// Reject before the host's external-mutation barrier can flush staged files.
|
|
55
86
|
if (args.timeoutMs !== undefined) {
|
|
56
87
|
const timeout = Number(args.timeoutMs);
|
|
88
|
+
|
|
57
89
|
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
|
|
58
90
|
args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
|
|
59
91
|
}
|
package/src/contract/read.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorMessage, isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
|
|
2
|
-
import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
2
|
+
import { foldJsonSelectorAlias, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
|
|
3
3
|
|
|
4
4
|
export const SESSION_URI = /^(?:agent|artifact):\/\//i;
|
|
5
5
|
|
|
@@ -12,6 +12,7 @@ function assertReadOptions(args) {
|
|
|
12
12
|
const unknown = Object.keys(args).filter(key => !READ_OPTION_KEYS.includes(key));
|
|
13
13
|
|
|
14
14
|
if (unknown.length === 0) return;
|
|
15
|
+
|
|
15
16
|
const windowHint = unknown.some(key => key === "start" || key === "end")
|
|
16
17
|
? " For a line window use read(path, {offset:1, limit:80}): offset is the first line and limit is the line count."
|
|
17
18
|
: "";
|
|
@@ -33,11 +34,14 @@ export function gatherReadArgs(p, a, b) {
|
|
|
33
34
|
return args;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Only the string shorthand guesses between a path and a symbol. Explicit
|
|
38
|
+
// path/target objects and path arrays must not turn missing files into search.
|
|
39
|
+
return autoResolve(isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export function assertReadPaths(targetParam) {
|
|
40
43
|
if (!Array.isArray(targetParam)) return;
|
|
44
|
+
|
|
41
45
|
if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
|
|
42
46
|
|
|
43
47
|
for (const item of targetParam) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
|
|
@@ -49,6 +53,7 @@ function assertReadFlags(args) {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
|
|
56
|
+
|
|
52
57
|
if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -56,32 +61,38 @@ function assertExclusiveRead(args) {
|
|
|
56
61
|
const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
|
|
57
62
|
|
|
58
63
|
if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
|
|
64
|
+
|
|
59
65
|
if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
|
|
66
|
+
|
|
60
67
|
if ((focusModes === 1 || args.evidence === true) && args.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
function autoResolve(args) {
|
|
64
71
|
if (!isString(args.path) || args.resolve !== undefined || args.complete === true || args.json !== undefined) return args;
|
|
65
|
-
|
|
72
|
+
|
|
73
|
+
if (args.about !== undefined || args.query !== undefined || args.offset !== undefined || args.limit !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
|
|
66
74
|
|
|
67
75
|
return { ...args, resolve: true };
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
/**
|
|
78
|
+
/** Preserve explicit intent across guest, host, and coalesced batch normalization. */
|
|
71
79
|
export function normalizeRead(params) {
|
|
72
80
|
if (!isObject(params)) throw new Error("read requires an options object");
|
|
73
|
-
|
|
81
|
+
|
|
82
|
+
const folded = foldJsonSelectorAlias(params);
|
|
83
|
+
|
|
84
|
+
if (folded.path !== undefined && folded.target !== undefined && folded.path !== folded.target) {
|
|
74
85
|
throw new Error("read accepts either path or target, not both");
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
const args = sessionJsonArgs({ ...
|
|
88
|
+
const args = sessionJsonArgs({ ...folded, path: folded.path ?? folded.target });
|
|
78
89
|
validateJsonRead(args);
|
|
79
90
|
assertReadOptions(args);
|
|
80
91
|
assertReadFlags(args);
|
|
81
92
|
assertExclusiveRead(args);
|
|
82
93
|
assertReadPaths(args.path);
|
|
83
94
|
|
|
84
|
-
return
|
|
95
|
+
return args;
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
export function needsProbe(params) {
|
|
@@ -126,7 +137,7 @@ function classifyEvidence(params, target) {
|
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
function classifyBarePath(params, target) {
|
|
129
|
-
if (params.json === undefined && !looksLikePath(target)) {
|
|
140
|
+
if (params.resolve === true && params.json === undefined && !looksLikePath(target)) {
|
|
130
141
|
return { kind: "snap", query: isString(params.about) ? params.about : target, scoped: isString(params.about) };
|
|
131
142
|
}
|
|
132
143
|
|
|
@@ -139,9 +150,13 @@ export function classifyRead(params, existing) {
|
|
|
139
150
|
const target = params.path;
|
|
140
151
|
|
|
141
152
|
if (isSessionUri(target)) return classifySession(params);
|
|
153
|
+
|
|
142
154
|
if (params.evidence === true) return classifyEvidence(params, target);
|
|
155
|
+
|
|
143
156
|
if (isString(params.query)) return { kind: "snap", query: params.query, scoped: Boolean(target && target !== params.query) };
|
|
157
|
+
|
|
144
158
|
if (params.outline === true) return { kind: "outline" };
|
|
159
|
+
|
|
145
160
|
if (existing) return classifyExisting(params, existing);
|
|
146
161
|
|
|
147
162
|
return classifyBarePath(params, target);
|
|
@@ -186,5 +201,6 @@ export function decodeReadValue(args, value) {
|
|
|
186
201
|
|
|
187
202
|
function jsonReadError(args, error) {
|
|
188
203
|
const target = String(args.path ?? args.target ?? "resource");
|
|
204
|
+
|
|
189
205
|
return new Error("JSON read failed for " + target + jsonSelectorNote(args) + ": " + errorMessage(error));
|
|
190
206
|
}
|