@visual-z/xeno 0.7.2 → 0.8.0
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/CHANGELOG.md +43 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +38 -6
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +2 -1
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/status.ts +6 -2
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
package/src/runtime/oauth.ts
CHANGED
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
import { createServer, type Server } from "node:http";
|
|
17
17
|
import { pollDeviceCode } from "./oauth-device.ts";
|
|
18
18
|
import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
|
|
19
|
-
|
|
20
|
-
/** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
|
|
21
|
-
export const XENO_VERSION = "0.7.2";
|
|
19
|
+
import { XENO_VERSION } from "./version.ts";
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* Minutes subtracted from server expiry so a request issued at the edge
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
|
|
2
2
|
import { providerAccess } from "./provider-access.ts";
|
|
3
3
|
import type { ModelSpec } from "./model.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
|
|
5
|
+
import { XENO_VERSION } from "./version.ts";
|
|
5
6
|
|
|
6
7
|
const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
|
|
7
8
|
|
|
@@ -13,7 +13,7 @@ import { executionOutcome } from "../core/outcome.ts";
|
|
|
13
13
|
import { archiveCommandArtifacts, artifactExclusions, observeArtifactFiles, observeArtifactInputs } from "../store/command-artifacts.ts";
|
|
14
14
|
import { readObservation } from "../store/observations.ts";
|
|
15
15
|
import { targetDirectory, targetProgram, observeRuntimeIdentity } from "../store/runtime-target.ts";
|
|
16
|
-
import { findSession, startSession, pollSession } from "./exec-sessions.ts";
|
|
16
|
+
import { findSession, startSession, pollSession, yieldWait } from "./exec-sessions.ts";
|
|
17
17
|
import { sessionResult, type ToolContext, type ToolResult } from "./tools.ts";
|
|
18
18
|
|
|
19
19
|
type History = readonly { node: number; events: readonly LogEvent[] }[];
|
|
@@ -63,7 +63,7 @@ export class RuntimeTargets {
|
|
|
63
63
|
const live: LiveTarget = { request: state.request, contract: state.contract_event, build: receipt.build!.event,
|
|
64
64
|
instance: { id: randomUUID(), session: session.id, pid: session.pid, source, entry: { path: entryPath, sha256: entry.sha256 }, program } };
|
|
65
65
|
this.live.set(target.id, live);
|
|
66
|
-
const taken = await pollSession(session, archive, ctx.maxOutputBytes, input.yield_time_ms ?? 1000, "", ctx.signal);
|
|
66
|
+
const taken = await pollSession(session, archive, ctx.maxOutputBytes, yieldWait(input.yield_time_ms ?? 1000).ms, "", ctx.signal);
|
|
67
67
|
raw.push(taken.stored.ref);
|
|
68
68
|
processResult = await sessionResult(ctx, session.command, session.timeoutMs, taken);
|
|
69
69
|
}
|
|
@@ -9,7 +9,7 @@ import type { PermissionRequest } from "../core/permissions.ts";
|
|
|
9
9
|
import type { ExecutionOutcome } from "../core/outcome.ts";
|
|
10
10
|
import type { LogEvent } from "../core/events.ts";
|
|
11
11
|
import { readNodeHistory } from "../store/restore.ts";
|
|
12
|
-
import type { Session, LoopEvent } from "./loop.ts";
|
|
12
|
+
import type { Session, LoopEvent, RequestOptions } from "./loop.ts";
|
|
13
13
|
import type { ApprovalChoice } from "./tools.ts";
|
|
14
14
|
import { validateQuestionReply, type QuestionRequest, type QuestionReply } from "../core/question.ts";
|
|
15
15
|
import type { QuestionSlot } from "./question-tool.ts";
|
|
@@ -87,7 +87,7 @@ export class SessionControl {
|
|
|
87
87
|
// Ancestors are sealed. The live segment must share the cursor's publication
|
|
88
88
|
// boundary: a disk read can finish between append and in-memory publication.
|
|
89
89
|
const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
|
|
90
|
-
const projectedGoal = goalView(parts);
|
|
90
|
+
const projectedGoal = goalView(parts, { strict: s.config.strictVerification === true, blind: s.config.blindChecks !== undefined });
|
|
91
91
|
const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
|
|
92
92
|
return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
|
|
93
93
|
activeTurnId: this.activeTurnId, goal, waiting: this.waiting, workflowMode: s.config.tools.workflowMode ?? "build",
|
|
@@ -107,7 +107,7 @@ export class SessionControl {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** Both local and remote turns pass through this iterator; Session remains the execution owner. */
|
|
110
|
-
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null
|
|
110
|
+
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>, options?: RequestOptions): AsyncGenerator<LoopEvent> {
|
|
111
111
|
if (this.busy) throw Error("a turn is already running");
|
|
112
112
|
const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
|
|
113
113
|
this.active = active;
|
|
@@ -123,7 +123,7 @@ export class SessionControl {
|
|
|
123
123
|
const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
|
|
124
124
|
return images.length ? { text, images } : text || null;
|
|
125
125
|
};
|
|
126
|
-
for await (const event of this.session.send(input, signals, steering, active.id)) {
|
|
126
|
+
for await (const event of this.session.send(input, signals, steering, active.id, options)) {
|
|
127
127
|
this.emit("turn/event", { event });
|
|
128
128
|
if (event.kind === "done") outcome = event.outcome;
|
|
129
129
|
yield event;
|
package/src/runtime/tools.ts
CHANGED
|
@@ -28,20 +28,20 @@ import { archiveObservation, readObservation, type ArchivedObservation } from ".
|
|
|
28
28
|
import { blobAddress } from "../store/blobs.ts";
|
|
29
29
|
import { commandHeader, headTailView } from "../core/observation-view.ts";
|
|
30
30
|
import {
|
|
31
|
-
|
|
31
|
+
yieldWait, findSession, closedSession, runningSessions, startSession, pollSession,
|
|
32
32
|
} from "./exec-sessions.ts";
|
|
33
33
|
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
34
|
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
35
35
|
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
36
|
import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
|
|
37
|
-
import { previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
37
|
+
import { linesWithin, previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
38
38
|
import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
|
|
39
39
|
import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
|
|
40
40
|
import { waitForAdmission } from "./execution-control.ts";
|
|
41
41
|
import { assertNever } from "../core/exhaustive.ts";
|
|
42
42
|
import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
|
|
43
43
|
import { classifyGate } from "../core/evidence.ts";
|
|
44
|
-
import { readBounds } from "../core/read-bounds.ts";
|
|
44
|
+
import { readBounds, readNotice } from "../core/read-bounds.ts";
|
|
45
45
|
import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
|
|
46
46
|
import { failureDiagnostic, failureFromError, scopeFailureGuidance, NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, type FailureFacts } from "../core/tool-failure.ts";
|
|
47
47
|
import { planEdits, type EditSpec } from "../core/edit-plan.ts";
|
|
@@ -66,7 +66,7 @@ import { imageDescription } from "../core/images.ts";
|
|
|
66
66
|
import { attachImageBytes } from "./images.ts";
|
|
67
67
|
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
68
68
|
import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
|
|
69
|
-
import {
|
|
69
|
+
import { DEFAULT_IGNORE } from "../store/workspace.ts";
|
|
70
70
|
import { observeWorkspace } from "../store/workspace-observation.ts";
|
|
71
71
|
import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
|
|
72
72
|
import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
@@ -98,14 +98,26 @@ export interface ToolResult {
|
|
|
98
98
|
data?: JsonValue;
|
|
99
99
|
diagnostic?: ToolDiagnostic;
|
|
100
100
|
warnings?: readonly ToolDiagnostic[];
|
|
101
|
+
/** Scoped instructions attached to a read-only call that ran (S.6 item 3). */
|
|
102
|
+
instructions?: { keys: readonly string[]; text: string };
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* What target-scoped instruction discovery decided for one call: hold a change
|
|
107
|
+
* back until the rules are read, or let a read-only call run and carry them.
|
|
108
|
+
*/
|
|
109
|
+
export type InstructionCheck =
|
|
110
|
+
| { kind: "block"; result: ToolResult }
|
|
111
|
+
| { kind: "attach"; instructions: { keys: readonly string[]; text: string } };
|
|
112
|
+
|
|
103
113
|
export interface ToolContext {
|
|
104
114
|
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
105
115
|
checkpointBlobs?: string;
|
|
106
116
|
frozenSystem?: () => string;
|
|
107
|
-
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<
|
|
117
|
+
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<InstructionCheck | null>;
|
|
108
118
|
workflowMode?: "build" | "plan";
|
|
119
|
+
/** What a Plan-mode refusal tells the model instead of the default "select Build". */
|
|
120
|
+
planHint?: string;
|
|
109
121
|
/** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
|
|
110
122
|
appOwnsPermissions?: true;
|
|
111
123
|
hostPermissionRules?: PermissionRule[];
|
|
@@ -121,8 +133,6 @@ export interface ToolContext {
|
|
|
121
133
|
/** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
|
|
122
134
|
workspace?: WorkspaceLifecycle;
|
|
123
135
|
ownedStatePaths?: readonly string[];
|
|
124
|
-
/** Historical fingerprint compatibility, never enabled by the normal CLI. */
|
|
125
|
-
legacyWorkspace?: boolean;
|
|
126
136
|
/** O.15: optional disposable view; the reader still validates the original log. */
|
|
127
137
|
historyCache?: HistoryCache;
|
|
128
138
|
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
@@ -349,7 +359,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
349
359
|
input_schema: { type: "object", required: ["action", "target"], properties: {
|
|
350
360
|
action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
|
|
351
361
|
session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
|
|
352
|
-
yield_time_ms: { type: "integer",
|
|
362
|
+
yield_time_ms: { type: "integer", description: "Milliseconds to wait for launch; default 1000, distinct from the hard timeout; effective range 100-30000, values outside are clamped" },
|
|
353
363
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
|
|
354
364
|
} },
|
|
355
365
|
},
|
|
@@ -620,7 +630,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
620
630
|
artifacts: commandArtifactsSchema,
|
|
621
631
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
622
632
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
623
|
-
yield_time_ms: { type: "integer",
|
|
633
|
+
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
624
634
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
625
635
|
},
|
|
626
636
|
required: ["cmd"],
|
|
@@ -640,7 +650,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
640
650
|
properties: {
|
|
641
651
|
session_id: { type: "integer", minimum: 1 },
|
|
642
652
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
643
|
-
yield_time_ms: { type: "integer",
|
|
653
|
+
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling; effective range 100-30000, values outside are clamped; agent empty waits are owned until an observation changes" },
|
|
644
654
|
},
|
|
645
655
|
required: ["session_id"],
|
|
646
656
|
},
|
|
@@ -831,11 +841,16 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
831
841
|
// cannot take the whole observation budget; the notice names the next start.
|
|
832
842
|
const first = input["start"] ?? 1;
|
|
833
843
|
const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
|
|
834
|
-
const
|
|
835
|
-
if (!
|
|
836
|
-
const { start } =
|
|
837
|
-
const slice = lines.slice(start - 1, selected.end);
|
|
844
|
+
const bounds = readBounds(lines.length, input["start"], end);
|
|
845
|
+
if (!bounds.ok) return failure("invalid_input", bounds.message, READ_ATTEMPTED);
|
|
846
|
+
const { start } = bounds;
|
|
838
847
|
const version = sha256(bytes);
|
|
848
|
+
// The selection is what fits whole, so the receipt's range is the range shown.
|
|
849
|
+
const sized = (count: number, handle: string) =>
|
|
850
|
+
`${readNotice(start, start + count - 1, lines.length, count < bounds.end - start + 1 ? ctx.maxOutputBytes : undefined)}\nsha256: ${version}\nread_handle: ${handle}\n`;
|
|
851
|
+
const shown = linesWithin(count => sized(count, "0".repeat(64)), lines.slice(start - 1, bounds.end), start, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
852
|
+
const selected = { end: start + shown - 1 };
|
|
853
|
+
const slice = lines.slice(start - 1, selected.end);
|
|
839
854
|
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
840
855
|
async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
|
|
841
856
|
// Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
|
|
@@ -844,7 +859,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
844
859
|
const selection = { start, end: selected.end, total_lines: lines.length };
|
|
845
860
|
const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
|
|
846
861
|
const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
|
|
847
|
-
const header =
|
|
862
|
+
const header = sized(shown, handle.ref.sha256);
|
|
848
863
|
const full = new TextEncoder().encode(readObservationText(header, slice, start));
|
|
849
864
|
const observation = await archiveObservation(store, one(full), 0);
|
|
850
865
|
const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
@@ -1048,19 +1063,6 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1048
1063
|
};
|
|
1049
1064
|
}
|
|
1050
1065
|
|
|
1051
|
-
/**
|
|
1052
|
-
* Derived output a command's fingerprint leaves out.
|
|
1053
|
-
*
|
|
1054
|
-
* The fingerprint keys verification evidence to the source state it ran
|
|
1055
|
-
* against; build products and caches change on every build without the
|
|
1056
|
-
* sources changing, and walking them (a Rust `target/` or a virtualenv) costs
|
|
1057
|
-
* seconds after every command. Snapshots and rewind keep DEFAULT_IGNORE --
|
|
1058
|
-
* whether to restore derived files is their decision, not this one.
|
|
1059
|
-
*/
|
|
1060
|
-
const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
|
|
1061
|
-
"target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
|
|
1062
|
-
".gradle", ".next", ".turbo", ".cache"];
|
|
1063
|
-
|
|
1064
1066
|
async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
|
|
1065
1067
|
const path = relative(ctx.root, absolute);
|
|
1066
1068
|
if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
|
|
@@ -1068,16 +1070,10 @@ async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<Re
|
|
|
1068
1070
|
}
|
|
1069
1071
|
|
|
1070
1072
|
async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
|
|
1071
|
-
if (ctx.legacyWorkspace) {
|
|
1072
|
-
const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
|
|
1073
|
-
return observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {};
|
|
1074
|
-
}
|
|
1075
1073
|
const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
|
|
1076
1074
|
return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
|
|
1077
1075
|
}
|
|
1078
1076
|
|
|
1079
|
-
const clampYield = (value: unknown) =>
|
|
1080
|
-
Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
|
|
1081
1077
|
|
|
1082
1078
|
async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1083
1079
|
const command = String(input["cmd"] ?? "");
|
|
@@ -1093,7 +1089,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1093
1089
|
...(artifactObserver ? { artifactObserver } : {}),
|
|
1094
1090
|
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1095
1091
|
if ("error" in session) return failure("execution_failed", session.error);
|
|
1096
|
-
|
|
1092
|
+
const wait = yieldWait(input["yield_time_ms"]);
|
|
1093
|
+
return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1097
1094
|
}
|
|
1098
1095
|
|
|
1099
1096
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -1111,8 +1108,9 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1111
1108
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1112
1109
|
}
|
|
1113
1110
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1114
|
-
|
|
1115
|
-
|
|
1111
|
+
const wait = yieldWait(input["yield_time_ms"]);
|
|
1112
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1113
|
+
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
|
|
1116
1114
|
}
|
|
1117
1115
|
|
|
1118
1116
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
@@ -1133,15 +1131,16 @@ function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string)
|
|
|
1133
1131
|
* carries no effect; only an observed exit does, exactly as bash's would (D11).
|
|
1134
1132
|
*/
|
|
1135
1133
|
export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
|
|
1136
|
-
taken: Awaited<ReturnType<typeof pollSession
|
|
1134
|
+
taken: Awaited<ReturnType<typeof pollSession>>, waitNote = ""): Promise<ToolResult> {
|
|
1137
1135
|
const { stored, exit } = taken;
|
|
1138
1136
|
const id = taken.receipt.session_id;
|
|
1139
1137
|
if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
|
|
1140
|
-
const
|
|
1138
|
+
const shown = exit === null
|
|
1141
1139
|
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1142
1140
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1143
1141
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1144
1142
|
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
|
|
1143
|
+
const header = shown + waitNote;
|
|
1145
1144
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1146
1145
|
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
1147
1146
|
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
@@ -1338,7 +1337,7 @@ export async function gateTool(
|
|
|
1338
1337
|
const definition = toolDefinition(name, ctx);
|
|
1339
1338
|
if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
|
|
1340
1339
|
!(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
|
|
1341
|
-
return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint
|
|
1340
|
+
return failure("permission_denied", `${name} is unavailable in Plan mode; ${ctx.planHint ?? "select Build at an idle checkpoint."}`);
|
|
1342
1341
|
input = repairToolInput(name, input);
|
|
1343
1342
|
let invalid: string | null = null;
|
|
1344
1343
|
if (definition?.validateInput) {
|
|
@@ -1434,11 +1433,13 @@ function observerValue<T>(value: T): T {
|
|
|
1434
1433
|
export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
|
|
1435
1434
|
prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1436
1435
|
const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
|
|
1436
|
+
let attached: ToolResult["instructions"];
|
|
1437
1437
|
try {
|
|
1438
1438
|
const gated = await gateTool(ctx, name, input);
|
|
1439
1439
|
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1440
1440
|
const instructions = await ctx.instructionCheck?.(ctx, name, input);
|
|
1441
|
-
if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
|
|
1441
|
+
if (instructions?.kind === "block") return scopeToolAdvice(ctx, finishToolResult(instructions.result));
|
|
1442
|
+
if (instructions?.kind === "attach") attached = instructions.instructions;
|
|
1442
1443
|
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1443
1444
|
await ctx.drainNested?.();
|
|
1444
1445
|
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
@@ -1488,7 +1489,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1488
1489
|
else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
|
|
1489
1490
|
}
|
|
1490
1491
|
await Promise.all(updates);
|
|
1491
|
-
|
|
1492
|
+
const result = warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed;
|
|
1493
|
+
return scopeToolAdvice(ctx, attached ? { ...result, instructions: attached } : result);
|
|
1492
1494
|
};
|
|
1493
1495
|
}
|
|
1494
1496
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place xeno's version is known (S.4: a release edits package.json and
|
|
3
|
+
* nothing else). It is sent as the User-Agent (D18: xeno names itself
|
|
4
|
+
* honestly) and compared against npm for update notices.
|
|
5
|
+
*
|
|
6
|
+
* Read at load rather than imported: `bun run check` requires every import to
|
|
7
|
+
* be a literal relative `.ts` path. package.json ships in every install, so
|
|
8
|
+
* this resolves the same way from a checkout and from a global install.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const XENO_VERSION: string = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
@@ -100,9 +100,40 @@ export class WorkspaceLifecycle {
|
|
|
100
100
|
source: "git", phase: "unavailable", changes: {}, reason: "process ended without an observed checkpoint boundary" });
|
|
101
101
|
this.pending.clear();
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* A full source snapshot through the private Git checkpoint store, or null
|
|
105
|
+
* where Git checkpoints cannot run (not a Git working directory, over
|
|
106
|
+
* budget). Never touches the user's HEAD, index or stash (P.3).
|
|
107
|
+
*/
|
|
108
|
+
async snapshot(signal?: AbortSignal): Promise<GitCheckpoint | null> {
|
|
109
|
+
try { return await this.git.capture(signal); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What changed between two snapshots, with both versions' bytes in the blob store. */
|
|
113
|
+
async snapshotChanges(before: GitCheckpoint, after: GitCheckpoint, signal?: AbortSignal): Promise<FileChanges> {
|
|
114
|
+
return this.git.changes(before, after, this.blobs, signal);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Records the workspace as a goal request begins, always: the state a
|
|
119
|
+
* counter-check must rebuild exactly before it may run (feat-counter-check).
|
|
120
|
+
*/
|
|
121
|
+
async baseline(signal?: AbortSignal): Promise<void> {
|
|
122
|
+
const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
|
|
123
|
+
const fingerprint = observation.ok ? observation.fingerprint : undefined;
|
|
124
|
+
await this.record({ t: "workspace_observation", scope: "source-v1", baseline: true,
|
|
125
|
+
changed: !observation.ok || this.fingerprint !== undefined && fingerprint !== this.fingerprint,
|
|
126
|
+
...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
|
|
127
|
+
this.fingerprint = fingerprint;
|
|
128
|
+
}
|
|
129
|
+
|
|
103
130
|
async observe(previous?: string, signal?: AbortSignal): Promise<void> {
|
|
104
131
|
const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
|
|
105
132
|
const fingerprint = observation.ok ? observation.fingerprint : undefined;
|
|
133
|
+
// S.1: an observation identical to the last known fingerprint adds no fact.
|
|
134
|
+
// Failures, a first look without a baseline, and changes are still recorded.
|
|
135
|
+
const known = previous ?? this.fingerprint;
|
|
136
|
+
if (fingerprint !== undefined && known !== undefined && fingerprint === known) { this.fingerprint = fingerprint; return; }
|
|
106
137
|
await this.record({ t: "workspace_observation", scope: "source-v1", changed: !observation.ok || (previous ?? this.fingerprint) !== undefined && fingerprint !== (previous ?? this.fingerprint),
|
|
107
138
|
...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
|
|
108
139
|
this.fingerprint = fingerprint;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a blind check directory (feat-blind-checks). It must sit outside
|
|
3
|
+
* the workspace -- a check inside it is a file the model can open -- and is
|
|
4
|
+
* frozen into the session with every file's digest, so a resumed session
|
|
5
|
+
* runs exactly the checks it started with.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { BLIND_BYTES_LIMIT, BLIND_FILE_LIMIT, parseBlindManifest, type BlindChecks } from "../core/blind-checks.ts";
|
|
10
|
+
import { blobAddress } from "./blobs.ts";
|
|
11
|
+
|
|
12
|
+
export async function loadBlindChecks(dir: string, workspace: string): Promise<BlindChecks> {
|
|
13
|
+
const given = resolve(workspace, dir), root = await realpath(given), work = await realpath(workspace);
|
|
14
|
+
const inside = relative(work, root);
|
|
15
|
+
if (inside === "" || !inside.startsWith("..") && !isAbsolute(inside)) throw Error("--blind-checks must be outside the workspace, where the model cannot read it");
|
|
16
|
+
const files: Record<string, string> = {};
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
const walk = async (path: string): Promise<void> => {
|
|
19
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
20
|
+
const full = join(path, entry.name), rel = relative(root, full);
|
|
21
|
+
if (entry.isDirectory()) { await walk(full); continue; }
|
|
22
|
+
if (!entry.isFile()) throw Error(`blind check ${rel} is not a regular file`);
|
|
23
|
+
if (Object.keys(files).length >= BLIND_FILE_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_FILE_LIMIT} files`);
|
|
24
|
+
bytes += (await stat(full)).size;
|
|
25
|
+
if (bytes > BLIND_BYTES_LIMIT) throw Error(`--blind-checks holds more than ${BLIND_BYTES_LIMIT} bytes`);
|
|
26
|
+
files[rel] = blobAddress(await readFile(full));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
await walk(root);
|
|
30
|
+
const manifest = parseBlindManifest(JSON.parse(await readFile(join(root, "blind.json"), "utf8")));
|
|
31
|
+
return { dir: root, aliases: given === root ? [] : [given], manifest, files };
|
|
32
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** File ancestry is independent of model-context ancestry (P.3). */
|
|
2
2
|
import { readNodeHistory, type RestoreTarget } from "./restore.ts";
|
|
3
|
-
import { planFileTransition, type CheckpointEvent, type
|
|
3
|
+
import { planFileTransition, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
4
4
|
|
|
5
5
|
export async function checkpointHistory(dir: string, target: RestoreTarget, visited = new Set<string>()): Promise<CheckpointEvent[]> {
|
|
6
6
|
const position = typeof target === "number" ? { node: target } : target;
|
|
@@ -10,7 +10,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
|
|
|
10
10
|
const history = await readNodeHistory(dir, position.node);
|
|
11
11
|
if (!history.ok) throw new Error(history.error);
|
|
12
12
|
let checkpoints: CheckpointEvent[] = [];
|
|
13
|
-
const legacy = new Map<string, FileVersion>();
|
|
14
13
|
for (const node of history.nodes) {
|
|
15
14
|
let events = node.events;
|
|
16
15
|
if (node.node === position.node && position.atEvent !== undefined) {
|
|
@@ -23,16 +22,6 @@ export async function checkpointHistory(dir: string, target: RestoreTarget, visi
|
|
|
23
22
|
checkpoints = await checkpointHistory(dir, { node: event.files_from.node,
|
|
24
23
|
...(event.files_from.at_event ? { atEvent: event.files_from.at_event } : {}) }, next);
|
|
25
24
|
} else if (event.t === "checkpoint") checkpoints.push(event);
|
|
26
|
-
else if (event.t === "snapshot") {
|
|
27
|
-
// Interpret historical full snapshots without rewriting them or scanning today's disk.
|
|
28
|
-
const changes: FileChanges = Object.create(null) as FileChanges;
|
|
29
|
-
for (const [path, sha256] of Object.entries({ ...event.added, ...event.changed })) {
|
|
30
|
-
changes[path] = { before: legacy.get(path) ?? null, after: { sha256, mode: 0o644 } };
|
|
31
|
-
legacy.set(path, changes[path]!.after);
|
|
32
|
-
}
|
|
33
|
-
for (const path of event.removed) { changes[path] = { before: legacy.get(path) ?? null, after: null }; legacy.delete(path); }
|
|
34
|
-
checkpoints.push({ t: "checkpoint", id: event.id, at: event.at, operation: event.id, source: "git", phase: "applied", changes });
|
|
35
|
-
}
|
|
36
25
|
}
|
|
37
26
|
}
|
|
38
27
|
return checkpoints;
|
package/src/store/file-index.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Candidate paths for @-file expansion.
|
|
2
|
+
* Candidate paths for @-file expansion and the walk search engine.
|
|
3
3
|
*
|
|
4
4
|
* A glob on every keystroke is a stall in a large repo (the card's rejected
|
|
5
5
|
* alternative). Scan once, cache for the session. The snapshot scanner is a
|
|
6
6
|
* different job -- it hashes contents and refuses to truncate -- so this walk
|
|
7
7
|
* is its own function rather than a mode flag on scanWorkspace.
|
|
8
|
+
*
|
|
9
|
+
* Inside a Git work tree the candidates are Git's own listing: tracked plus
|
|
10
|
+
* untracked files that are not ignored (S.6 item 4). Claude Code's Grep,
|
|
11
|
+
* OpenCode (`rg --files`) and Pi (`fd`) follow .gitignore the same way; a
|
|
12
|
+
* walk that ignores it let an ignored worktree's build output fill the whole
|
|
13
|
+
* budget before the product's directories were reached
|
|
14
|
+
* (research-file-index-truncation).
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import { readdir } from "node:fs/promises";
|
|
17
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
11
18
|
import type { Dirent } from "node:fs";
|
|
12
|
-
import { join, relative, sep } from "node:path";
|
|
19
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
13
20
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
21
|
+
import { gitBytes } from "./workspace-observation.ts";
|
|
14
22
|
|
|
15
23
|
/**
|
|
16
|
-
* Cap on
|
|
17
|
-
* large
|
|
18
|
-
* one. Hitting it truncates and says so -- a
|
|
19
|
-
* files that exist.
|
|
24
|
+
* Cap on paths from an unfiltered directory walk, outside Git. Assumed, not
|
|
25
|
+
* measured: enough to fuzzy-search a large tree; past this the walk is hiding
|
|
26
|
+
* a missing filter, not serving one. Hitting it truncates and says so -- a
|
|
27
|
+
* silent cut would make @ miss files that exist. A Git listing is already the
|
|
28
|
+
* filtered candidate set and is not capped unless the caller asks.
|
|
20
29
|
*/
|
|
21
30
|
export const MAX_INDEX_ENTRIES = 20_000;
|
|
22
31
|
|
|
@@ -38,7 +47,79 @@ function isIgnored(rel: string, ignore: readonly string[]): boolean {
|
|
|
38
47
|
return ignore.some((i) => parts.includes(i));
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
/** Git judges its own ignore rules; an error or a path outside any work tree is not ignored. */
|
|
51
|
+
export async function gitIgnored(path: string, signal?: AbortSignal): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
const info = await lstat(path);
|
|
54
|
+
await gitBytes(info.isDirectory() ? path : dirname(path), ["check-ignore", "-q", "--", path], undefined, signal);
|
|
55
|
+
return true;
|
|
56
|
+
} catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Git's listing under `dir`, relative to it, or null when `dir` is not a work tree that Git lists. */
|
|
60
|
+
async function gitListing(dir: string, signal?: AbortSignal): Promise<string[] | null> {
|
|
61
|
+
try {
|
|
62
|
+
if (new TextDecoder().decode(await gitBytes(dir, ["rev-parse", "--is-inside-work-tree"], undefined, signal)).trim() !== "true") return null;
|
|
63
|
+
// Naming an ignored directory is an explicit request: walk it instead.
|
|
64
|
+
if (await gitIgnored(dir, signal)) return null;
|
|
65
|
+
const listed = new TextDecoder().decode(await gitBytes(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], undefined, signal));
|
|
66
|
+
// An untracked nested repository is listed once, as "name/".
|
|
67
|
+
return [...new Set(listed.split("\0").filter(Boolean).map(path => path.replace(/\/$/, "")))].sort();
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Assumed: enough concurrent lstat calls to hide latency without exhausting descriptors. */
|
|
72
|
+
const LSTAT_BATCH = 256;
|
|
73
|
+
|
|
74
|
+
async function indexGitListing(root: string, listed: readonly string[], opts: IndexOptions): Promise<FileIndex> {
|
|
75
|
+
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
76
|
+
const max = opts.maxEntries ?? Infinity;
|
|
77
|
+
const paths: string[] = [], issues: { code: string; path?: string }[] = [];
|
|
78
|
+
// The index can name a path under a directory since replaced by a symlink;
|
|
79
|
+
// following it would read outside the tree, so every ancestor is checked once.
|
|
80
|
+
const directories = new Map<string, Promise<boolean>>([["", Promise.resolve(true)]]);
|
|
81
|
+
const plainDirectory = (rel: string): Promise<boolean> => {
|
|
82
|
+
let known = directories.get(rel);
|
|
83
|
+
if (known === undefined) {
|
|
84
|
+
const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
85
|
+
known = plainDirectory(parent).then(async ok => ok && await lstat(join(root, rel)).then(info => info.isDirectory() && !info.isSymbolicLink(), () => false));
|
|
86
|
+
directories.set(rel, known);
|
|
87
|
+
}
|
|
88
|
+
return known;
|
|
89
|
+
};
|
|
90
|
+
for (let offset = 0; offset < listed.length; offset += LSTAT_BATCH) {
|
|
91
|
+
if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
|
|
92
|
+
issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return { paths, truncated: true, issues };
|
|
93
|
+
}
|
|
94
|
+
const batch = listed.slice(offset, offset + LSTAT_BATCH).filter(rel => !isIgnored(rel.split("/").join(sep), ignore));
|
|
95
|
+
const kinds = await Promise.all(batch.map(async rel => {
|
|
96
|
+
const parent = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
97
|
+
if (!await plainDirectory(parent)) return "skip";
|
|
98
|
+
// A tracked path deleted from the work tree is not a candidate.
|
|
99
|
+
const info = await lstat(join(root, rel)).catch(() => null);
|
|
100
|
+
return info === null || info.isSymbolicLink() ? "skip" : info.isDirectory() ? "nested" : info.isFile() ? "file" : "skip";
|
|
101
|
+
}));
|
|
102
|
+
for (let i = 0; i < batch.length; i++) {
|
|
103
|
+
const rel = batch[i]!;
|
|
104
|
+
if (kinds[i] === "nested") {
|
|
105
|
+
// A submodule or nested repository: its own listing, or a walk if it has none.
|
|
106
|
+
const nested = await buildFileIndex(join(root, rel), { ...opts, ...(max === Infinity ? {} : { maxEntries: max - paths.length }) });
|
|
107
|
+
paths.push(...nested.paths.map(path => `${rel}/${path}`));
|
|
108
|
+
issues.push(...(nested.issues ?? []).map(issue => issue.path === undefined ? issue : { ...issue, path: `${rel}/${issue.path}` }));
|
|
109
|
+
if (nested.truncated) return { paths, truncated: true, issues };
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (kinds[i] !== "file") continue;
|
|
113
|
+
if (paths.length >= max) { issues.push({ code: "file_index_limit" }); return { paths, truncated: true, issues }; }
|
|
114
|
+
paths.push(rel);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { paths, truncated: false, ...(issues.length ? { issues } : {}) };
|
|
118
|
+
}
|
|
119
|
+
|
|
41
120
|
export async function buildFileIndex(root: string, opts: IndexOptions = {}): Promise<FileIndex> {
|
|
121
|
+
const listed = await gitListing(root, opts.signal);
|
|
122
|
+
if (listed !== null) return indexGitListing(root, listed, opts);
|
|
42
123
|
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
43
124
|
const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
|
|
44
125
|
const paths: string[] = [];
|