@visual-z/xeno 0.8.0 → 0.8.2
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 +41 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/startup.ts +27 -2
- package/src/apps/ui/app.ts +69 -9
- package/src/core/activity-status.ts +10 -1
- package/src/core/command-contract.ts +79 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/environment.ts +7 -0
- package/src/core/goal-view.ts +64 -0
- package/src/core/history.ts +7 -1
- package/src/core/permissions.ts +4 -3
- package/src/core/prompt-policy.ts +9 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/response-recovery.ts +13 -1
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +21 -1
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +17 -4
- package/src/core/update-check.ts +2 -1
- package/src/runtime/exec-sessions.ts +3 -1
- package/src/runtime/loop.ts +98 -12
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/tools.ts +46 -8
- package/src/runtime/usage.ts +28 -0
- package/src/store/command.ts +3 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/search.ts +9 -5
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +18 -9
- package/src/tui/ui/status.ts +115 -21
package/src/runtime/provider.ts
CHANGED
|
@@ -35,10 +35,12 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
|
|
|
35
35
|
import { imageData } from "./images.ts";
|
|
36
36
|
import type { AssistantBlock } from "../core/events.ts";
|
|
37
37
|
import { assertNever } from "../core/exhaustive.ts";
|
|
38
|
+
import { isToolStopWithoutCall } from "../core/response-recovery.ts";
|
|
38
39
|
import type { UsageSample } from "./usage.ts";
|
|
39
40
|
import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
|
|
40
41
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
41
42
|
import { anthropicOAuthHeaders } from "./oauth.ts";
|
|
43
|
+
import { XENO_VERSION } from "./version.ts";
|
|
42
44
|
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
43
45
|
|
|
44
46
|
export interface ToolSchema {
|
|
@@ -159,6 +161,27 @@ export class FailedResponseError extends Error {
|
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A 200 response that contradicts itself, such as a tool stop with no tool
|
|
166
|
+
* call. retry.ts treats it like a transient stream failure: a fresh sample is
|
|
167
|
+
* the remedy, and exhausting retries must surface as a provider error rather
|
|
168
|
+
* than reach the loop as a silent final answer. The output token count tells
|
|
169
|
+
* "generated but lost in transit" apart from "nothing generated".
|
|
170
|
+
*/
|
|
171
|
+
export class MalformedResponseError extends FailedResponseError {
|
|
172
|
+
constructor(detail: string, usage?: UsageSample) {
|
|
173
|
+
super(`malformed provider response: ${detail}${usage ? ` (${usage.output_tokens} output tokens)` : ""}`, usage);
|
|
174
|
+
this.name = "MalformedResponseError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every adapter returns through this, so no dialect can hand the loop a contradictory turn. */
|
|
179
|
+
export function coherentTurn(turn: Turn, usageKnown = true): Turn {
|
|
180
|
+
if (isToolStopWithoutCall(turn.stopReason, turn.blocks))
|
|
181
|
+
throw new MalformedResponseError(`stop_reason ${JSON.stringify(turn.stopReason)} without a tool call`, usageKnown ? turn.usage : undefined);
|
|
182
|
+
return turn;
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
/** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
|
|
163
186
|
export class RefusalError extends FailedResponseError {
|
|
164
187
|
readonly category: string | null;
|
|
@@ -176,6 +199,12 @@ function buildAnthropicClient(
|
|
|
176
199
|
apiKey: string | undefined,
|
|
177
200
|
options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
|
|
178
201
|
): AnthropicTransport {
|
|
202
|
+
// D18: the User-Agent, not SDK headers, identifies xeno on the wire. Left alone
|
|
203
|
+
// the SDK sends `Anthropic/JS <version>`, which model gateways fold into "custom
|
|
204
|
+
// client" attribution; provider-request.ts already stamps xeno's UA on the Pi
|
|
205
|
+
// path. Constructor defaultHeaders merge after the SDK's built-in UA, so this
|
|
206
|
+
// entry wins without touching per-request headers.
|
|
207
|
+
const identity = { "User-Agent": `xeno/${XENO_VERSION}` };
|
|
179
208
|
if (options.oauth === true && apiKey) {
|
|
180
209
|
// authToken is Bearer. Passing apiKey here would send x-api-key, which
|
|
181
210
|
// an OAuth token is not accepted as.
|
|
@@ -183,7 +212,7 @@ function buildAnthropicClient(
|
|
|
183
212
|
maxRetries: 0,
|
|
184
213
|
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
185
214
|
authToken: apiKey,
|
|
186
|
-
defaultHeaders: anthropicOAuthHeaders(),
|
|
215
|
+
defaultHeaders: { ...anthropicOAuthHeaders(), ...identity },
|
|
187
216
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
188
217
|
});
|
|
189
218
|
}
|
|
@@ -191,6 +220,7 @@ function buildAnthropicClient(
|
|
|
191
220
|
maxRetries: 0,
|
|
192
221
|
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
193
222
|
...(apiKey ? { apiKey } : {}),
|
|
223
|
+
defaultHeaders: identity,
|
|
194
224
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
195
225
|
});
|
|
196
226
|
}
|
|
@@ -319,11 +349,11 @@ export class AnthropicProvider implements Provider {
|
|
|
319
349
|
throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
|
|
320
350
|
}
|
|
321
351
|
|
|
322
|
-
return {
|
|
352
|
+
return coherentTurn({
|
|
323
353
|
blocks: assembled.blocks,
|
|
324
354
|
stopReason: assembled.stopReason,
|
|
325
355
|
usage,
|
|
326
|
-
};
|
|
356
|
+
}, assembled.usageKnown);
|
|
327
357
|
}
|
|
328
358
|
}
|
|
329
359
|
|
package/src/runtime/retry.ts
CHANGED
|
@@ -33,6 +33,11 @@ function isStreamFailure(error: unknown): error is Error & { code?: string } {
|
|
|
33
33
|
return error instanceof Error && error.name === "StreamError";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** provider.ts MalformedResponseError: a self-contradictory 200, structural for the same reason. */
|
|
37
|
+
function isMalformedResponse(error: unknown): boolean {
|
|
38
|
+
return error instanceof Error && error.name === "MalformedResponseError";
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
export interface RetryPolicy {
|
|
37
42
|
/**
|
|
38
43
|
* Attempts after the first. Four means a request can take about 15s of
|
|
@@ -140,8 +145,10 @@ export function retryDecision(
|
|
|
140
145
|
// A failure reported inside a 200 stream carries no status. Relays and
|
|
141
146
|
// Anthropic's own `overloaded_error` use it for exactly the conditions a
|
|
142
147
|
// 5xx would signal; anything else in a stream is a real rejection.
|
|
143
|
-
if (status === undefined && isStreamFailure(error)) {
|
|
144
|
-
|
|
148
|
+
if (status === undefined && (isStreamFailure(error) || isMalformedResponse(error))) {
|
|
149
|
+
// A contradictory response is a bad sample, not a rejection of the request:
|
|
150
|
+
// resend it like a dropped stream, and let the bound turn it into an error.
|
|
151
|
+
if (isStreamFailure(error) && !TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
|
|
145
152
|
return { retry: false, reason: "the stream reported a non-transient error" };
|
|
146
153
|
}
|
|
147
154
|
const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
|
package/src/runtime/tools.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
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
|
-
import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
|
|
36
|
+
import { commandCompletion, commandTimeout, negativeAnswer, outputBytesForTokens } from "../core/command-contract.ts";
|
|
37
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";
|
|
@@ -571,6 +571,34 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
571
571
|
required: ["node", "resolution"],
|
|
572
572
|
},
|
|
573
573
|
},
|
|
574
|
+
{
|
|
575
|
+
/**
|
|
576
|
+
* The model's way into an isolated checkout of this repository.
|
|
577
|
+
*
|
|
578
|
+
* Edits are confined to the session root, so isolation the model builds
|
|
579
|
+
* itself with `git worktree add` is unusable: outside the root is refused,
|
|
580
|
+
* and inside `.xeno` is session state. This creates the checkout where
|
|
581
|
+
* xeno keeps them and moves the root after the tool round, the way an
|
|
582
|
+
* adopted branch does; the cursor records the root, so resume stays there.
|
|
583
|
+
*/
|
|
584
|
+
name: "worktree",
|
|
585
|
+
access: "state", execution: "exclusive", branch: "none",
|
|
586
|
+
example: { action: "enter", name: "fix-parser" },
|
|
587
|
+
description:
|
|
588
|
+
"enter: create a git worktree of this repository on a new branch xeno/<name> from HEAD, kept under xeno's config directory, " +
|
|
589
|
+
"and move this session's root there after the current tool round, so later edits and commands run in the isolated checkout. " +
|
|
590
|
+
"Use it when the shared working tree has changes by others or must stay untouched. include_changes copies the current uncommitted files. " +
|
|
591
|
+
"exit: move the root back to the repository's main checkout. The worktree and its branch are kept; nothing is merged or deleted.",
|
|
592
|
+
input_schema: {
|
|
593
|
+
type: "object",
|
|
594
|
+
properties: {
|
|
595
|
+
action: { type: "string", enum: ["enter", "exit"] },
|
|
596
|
+
name: { type: "string", description: "enter: letters, digits, dot, dash or underscore; becomes the branch xeno/<name>" },
|
|
597
|
+
include_changes: { type: "boolean", description: "enter: start from the current uncommitted files instead of HEAD; default false" },
|
|
598
|
+
},
|
|
599
|
+
required: ["action"],
|
|
600
|
+
},
|
|
601
|
+
},
|
|
574
602
|
{
|
|
575
603
|
name: "bash",
|
|
576
604
|
example: { command: "bun test", workdir: ".", timeout: 300 },
|
|
@@ -631,6 +659,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
631
659
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
632
660
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
633
661
|
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
662
|
+
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
634
663
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
635
664
|
},
|
|
636
665
|
required: ["cmd"],
|
|
@@ -651,6 +680,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
651
680
|
session_id: { type: "integer", minimum: 1 },
|
|
652
681
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
653
682
|
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" },
|
|
683
|
+
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
654
684
|
},
|
|
655
685
|
required: ["session_id"],
|
|
656
686
|
},
|
|
@@ -663,7 +693,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
663
693
|
description:
|
|
664
694
|
"Search file contents for a regex. Stays inside the workspace, does " +
|
|
665
695
|
"not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
|
|
666
|
-
"ripgrep when it is
|
|
696
|
+
"ripgrep when it is on PATH or in xeno's bin directory, otherwise a TypeScript walk. " +
|
|
667
697
|
"Reports scan completeness separately from preview truncation. Long matches and omitted results remain in an immutable history artifact; use its sha256 and byte offset to recover them. Unknown or partial coverage cannot prove absence.",
|
|
668
698
|
input_schema: {
|
|
669
699
|
type: "object",
|
|
@@ -1040,7 +1070,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1040
1070
|
const observation = await commandFingerprint(ctx);
|
|
1041
1071
|
const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
|
|
1042
1072
|
...(requestedMs !== undefined ? { requestedMs } : {}),
|
|
1043
|
-
...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
1073
|
+
...(captured.stopped ? { stopped: captured.stopped } : {}) }) + answerLabel(command, exit, captured.stopped);
|
|
1044
1074
|
const artifactSummary = commandArtifactSummary(artifacts);
|
|
1045
1075
|
|
|
1046
1076
|
return {
|
|
@@ -1049,7 +1079,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1049
1079
|
content: header + artifactSummary + "\n" + observationView(captured,
|
|
1050
1080
|
ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx)),
|
|
1051
1081
|
content_ref: captured.ref,
|
|
1052
|
-
...commandCompletion(exit, captured.stopped, captured.cleanup),
|
|
1082
|
+
...commandCompletion(exit, captured.stopped, captured.cleanup, command),
|
|
1053
1083
|
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
1054
1084
|
effect: {
|
|
1055
1085
|
kind: "command",
|
|
@@ -1090,7 +1120,8 @@ async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1090
1120
|
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1091
1121
|
if ("error" in session) return failure("execution_failed", session.error);
|
|
1092
1122
|
const wait = yieldWait(input["yield_time_ms"]);
|
|
1093
|
-
|
|
1123
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1124
|
+
return sessionResult(view, command, timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1094
1125
|
}
|
|
1095
1126
|
|
|
1096
1127
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
@@ -1109,7 +1140,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1109
1140
|
}
|
|
1110
1141
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1111
1142
|
const wait = yieldWait(input["yield_time_ms"]);
|
|
1112
|
-
|
|
1143
|
+
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1144
|
+
return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1113
1145
|
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined), wait.note);
|
|
1114
1146
|
}
|
|
1115
1147
|
|
|
@@ -1139,7 +1171,7 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1139
1171
|
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1140
1172
|
? `poll or send input with write_stdin(session_id=${id})`
|
|
1141
1173
|
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1142
|
-
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed
|
|
1174
|
+
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
|
|
1143
1175
|
const header = shown + waitNote;
|
|
1144
1176
|
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1145
1177
|
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
@@ -1150,7 +1182,7 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1150
1182
|
const gate = matchGate(ctx, command);
|
|
1151
1183
|
const observation = await commandFingerprint(ctx);
|
|
1152
1184
|
return {
|
|
1153
|
-
...commandCompletion(exit, taken.stopped),
|
|
1185
|
+
...commandCompletion(exit, taken.stopped, undefined, command),
|
|
1154
1186
|
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1155
1187
|
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
1156
1188
|
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
@@ -1158,6 +1190,12 @@ export async function sessionResult(ctx: ToolContext, command: string, timeoutMs
|
|
|
1158
1190
|
};
|
|
1159
1191
|
}
|
|
1160
1192
|
|
|
1193
|
+
/** The program's own meaning of exit 1 (grep: no match), appended to the header; the exit itself stays. */
|
|
1194
|
+
function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
|
|
1195
|
+
const meaning = stopped === undefined ? negativeAnswer(command, exit) : null;
|
|
1196
|
+
return meaning === null ? "" : ` · ${meaning}`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1161
1199
|
const utf8Length = (text: string) => new TextEncoder().encode(text).length;
|
|
1162
1200
|
|
|
1163
1201
|
/**
|
package/src/runtime/usage.ts
CHANGED
|
@@ -88,6 +88,34 @@ export function totalUsage(samples: readonly UsageSample[]): UsageTotals {
|
|
|
88
88
|
return t;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Below this a request's speed is not shown: a response that returns at once
|
|
93
|
+
* (a test double, a local cache) would divide into thousands of tokens per
|
|
94
|
+
* second. Assumed, not measured.
|
|
95
|
+
*/
|
|
96
|
+
export const SPEED_MIN_MS = 250;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Output tokens per second over a set of model requests, or undefined when
|
|
100
|
+
* none can be measured (feat-sidebar-goal-and-speed). OpenCode's built-in
|
|
101
|
+
* turnTokensPerSecond uses the same definition: output over the time the
|
|
102
|
+
* requests took, each request counted once. Not output over the time since
|
|
103
|
+
* the first visible output -- output counts include reasoning that providers
|
|
104
|
+
* do not always stream (OpenAI summaries arrive late, Anthropic can omit
|
|
105
|
+
* them), so that would keep reasoning tokens and drop reasoning time, and
|
|
106
|
+
* overstate. Whole request time never overstates; an overloaded upstream
|
|
107
|
+
* reads slow, which is what the user is waiting on.
|
|
108
|
+
*/
|
|
109
|
+
export function generationSpeed(samples: readonly UsageSample[]): number | undefined {
|
|
110
|
+
let tokens = 0, ms = 0;
|
|
111
|
+
for (const sample of samples) {
|
|
112
|
+
if (sample.output_tokens <= 0 || !(sample.ms >= SPEED_MIN_MS)) continue;
|
|
113
|
+
tokens += sample.output_tokens;
|
|
114
|
+
ms += sample.ms;
|
|
115
|
+
}
|
|
116
|
+
return ms > 0 ? tokens / (ms / 1000) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
/**
|
|
92
120
|
* Cost in equivalent base-input tokens, the unit appendix A's model uses.
|
|
93
121
|
*
|
package/src/store/command.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** O.1: own the process group, preserve partial observations, and drain before returning. */
|
|
2
2
|
import { captureCommandObservation } from "./observations.ts";
|
|
3
|
+
import { configDir } from "./config.ts";
|
|
4
|
+
import { xenoBinDir } from "./tool-binaries.ts";
|
|
3
5
|
import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
|
|
4
6
|
import { ToolFault, NOT_STARTED, failureDiagnostic } from "../core/tool-failure.ts";
|
|
5
7
|
|
|
@@ -44,7 +46,7 @@ async function executeCaptured(input: CommandCaptureInput, argv: string[], nativ
|
|
|
44
46
|
cwd: input.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true,
|
|
45
47
|
// Preserve the trusted native API's inherited environment; shell tools
|
|
46
48
|
// retain their explicit noninteractive policy and login-shell startup.
|
|
47
|
-
env: native ? process.env : commandEnvironment(process.env),
|
|
49
|
+
env: native ? process.env : commandEnvironment(process.env, xenoBinDir(configDir())),
|
|
48
50
|
}); } catch (error) {
|
|
49
51
|
const os = (error as { code?: string }).code;
|
|
50
52
|
throw new ToolFault(os === "ENOENT" || os === "ENOTDIR" ? "environment_missing" : os === "EACCES" || os === "EPERM" ? "permission_denied" : "execution_failed", String(error), NOT_STARTED);
|
|
@@ -4,12 +4,15 @@ import { resolve, relative, dirname, join, sep } from "node:path";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { blobAddress, putBlob, getBlobBytes, verifyBlob } from "./blobs.ts";
|
|
6
6
|
import { sameVersion, changedPaths, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
|
|
7
|
-
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, failureFromError } from "../core/tool-failure.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED, STATE_PATH_GUIDANCE, failureFromError } from "../core/tool-failure.ts";
|
|
8
8
|
|
|
9
9
|
export async function editablePath(root: string, path: string): Promise<string> {
|
|
10
10
|
const base = resolve(root), absolute = resolve(base, path), rel = relative(base, absolute);
|
|
11
|
-
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel
|
|
12
|
-
|
|
11
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
|
|
12
|
+
// Session logs and blobs are the evidence; an edit there could rewrite it.
|
|
13
|
+
if (rel.split(sep).some(part => part === ".git" || part === ".xeno")) {
|
|
14
|
+
throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path} is inside .git or .xeno, which edit tools never change`, NOT_STARTED, { next: STATE_PATH_GUIDANCE });
|
|
15
|
+
}
|
|
13
16
|
let current = base;
|
|
14
17
|
for (const part of rel.split(sep)) {
|
|
15
18
|
current = join(current, part);
|
package/src/store/search.ts
CHANGED
|
@@ -18,9 +18,12 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
|
|
18
18
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
19
19
|
import { Worker } from "node:worker_threads";
|
|
20
20
|
import { assertNever } from "../core/exhaustive.ts";
|
|
21
|
+
import { jsPatternFromRipgrep } from "../core/regex-dialect.ts";
|
|
21
22
|
import { buildFileIndex, gitIgnored } from "./file-index.ts";
|
|
22
23
|
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
23
24
|
import { blobAddress } from "./blobs.ts";
|
|
25
|
+
import { configDir } from "./config.ts";
|
|
26
|
+
import { findRipgrep } from "./tool-binaries.ts";
|
|
24
27
|
import { previewSearchObservation, type SearchObservation } from "../core/search-observation.ts";
|
|
25
28
|
import { failureDiagnostic, failureFromError, READ_ATTEMPTED, NOT_STARTED } from "../core/tool-failure.ts";
|
|
26
29
|
import type { ToolDiagnostic } from "../core/tool-contract.ts";
|
|
@@ -459,7 +462,7 @@ function rgText(value: Record<string, unknown>): string {
|
|
|
459
462
|
}
|
|
460
463
|
|
|
461
464
|
async function grepRipgrep(opts: GrepOpts, abs: string, limit: number): Promise<SearchResult | null> {
|
|
462
|
-
const rg =
|
|
465
|
+
const rg = findRipgrep(configDir());
|
|
463
466
|
if (rg === null) return null;
|
|
464
467
|
const args = [
|
|
465
468
|
"--json",
|
|
@@ -491,8 +494,9 @@ function compareMatch(a: MatchLine, b: MatchLine): number {
|
|
|
491
494
|
}
|
|
492
495
|
|
|
493
496
|
async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
|
|
497
|
+
const pattern = jsPatternFromRipgrep(opts.pattern);
|
|
494
498
|
try {
|
|
495
|
-
new RegExp(
|
|
499
|
+
new RegExp(pattern);
|
|
496
500
|
} catch (err) {
|
|
497
501
|
return searchFailure("invalid_input", `invalid regex: ${(err as Error).message}`);
|
|
498
502
|
}
|
|
@@ -538,7 +542,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
538
542
|
// Reserve the worst-case formatted hits before matching, never discard already observed hits.
|
|
539
543
|
const reservation = Buffer.byteLength(text) + lines.length * (Buffer.byteLength(fileRel) + String(MAX_FILE_BYTES).length + 3);
|
|
540
544
|
if (capturedBytes + reservation > budget) { reasons.push({ code: "capture_budget", path: fileRel }); break; }
|
|
541
|
-
const matched = await matcher.match(
|
|
545
|
+
const matched = await matcher.match(pattern, "", lines, deadline - performance.now(), opts.signal);
|
|
542
546
|
if (matched === "cancelled") { reasons.push({ code: "cancelled", path: fileRel }); break; }
|
|
543
547
|
if (matched === "timeout") {
|
|
544
548
|
reasons.push({ code: "match_timeout", path: fileRel });
|
|
@@ -580,11 +584,11 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
|
|
|
580
584
|
return grepWalk(opts, opts.path, target.kind, target.rel, limit);
|
|
581
585
|
case "ripgrep": {
|
|
582
586
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
583
|
-
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH");
|
|
587
|
+
if (got === null) return searchFailure("environment_missing", "ripgrep is not on PATH or in xeno's bin directory");
|
|
584
588
|
return got;
|
|
585
589
|
}
|
|
586
590
|
case "auto": {
|
|
587
|
-
if (
|
|
591
|
+
if (findRipgrep(configDir()) !== null) {
|
|
588
592
|
const got = await grepRipgrep(opts, opts.path, limit);
|
|
589
593
|
if (got !== null) return got;
|
|
590
594
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xeno's own copy of ripgrep, for machines that have none.
|
|
3
|
+
*
|
|
4
|
+
* Where it lives: `<configDir>/bin`, next to settings and auth, so one
|
|
5
|
+
* install serves every workspace. Who sees it: the grep tool (after PATH)
|
|
6
|
+
* and shell commands, whose PATH gets the directory appended -- appended so a
|
|
7
|
+
* user who later installs rg keeps theirs (core/command-contract.ts).
|
|
8
|
+
*
|
|
9
|
+
* The network fetch is injected: store owns the filesystem, the caller owns
|
|
10
|
+
* the request (as runtime/update-check.ts does for the registry).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { ripgrepAsset, type RipgrepAsset } from "../core/ripgrep-release.ts";
|
|
17
|
+
|
|
18
|
+
export function xenoBinDir(configDir: string): string {
|
|
19
|
+
return join(configDir, "bin");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function onPath(name: string, pathEnv: string): string | null {
|
|
23
|
+
for (const dir of pathEnv.split(process.platform === "win32" ? ";" : ":")) {
|
|
24
|
+
if (dir.length === 0) continue;
|
|
25
|
+
const candidate = join(dir, name);
|
|
26
|
+
if (existsSync(candidate)) return candidate;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The user's rg first, then xeno's; null when neither exists. */
|
|
32
|
+
export function findRipgrep(configDir: string, pathEnv = process.env["PATH"] ?? ""): string | null {
|
|
33
|
+
const own = join(xenoBinDir(configDir), "rg");
|
|
34
|
+
return onPath("rg", pathEnv) ?? (existsSync(own) ? own : null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProvisionResult =
|
|
38
|
+
| { ok: true; path: string; installed: boolean }
|
|
39
|
+
| { ok: false; error: string };
|
|
40
|
+
|
|
41
|
+
export interface ProvisionOptions {
|
|
42
|
+
configDir: string;
|
|
43
|
+
fetchBytes: (url: string, signal?: AbortSignal) => Promise<Uint8Array>;
|
|
44
|
+
/** Defaults to the user's PATH; injected so tests are not decided by the machine. */
|
|
45
|
+
path?: string;
|
|
46
|
+
/** Defaults to the pinned build for this platform. */
|
|
47
|
+
asset?: RipgrepAsset | null;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Installs the pinned ripgrep unless one is already reachable.
|
|
53
|
+
*
|
|
54
|
+
* Nothing becomes visible until it is proven: the archive is hashed before
|
|
55
|
+
* extraction, only the one member is extracted, and the executable reaches
|
|
56
|
+
* `bin/rg` by rename, so a crash or a mismatch never leaves a partial or
|
|
57
|
+
* unverified binary where the shell would find it.
|
|
58
|
+
*/
|
|
59
|
+
export async function provisionRipgrep(options: ProvisionOptions): Promise<ProvisionResult> {
|
|
60
|
+
const existing = findRipgrep(options.configDir, options.path);
|
|
61
|
+
if (existing !== null) return { ok: true, path: existing, installed: false };
|
|
62
|
+
const asset = options.asset === undefined ? ripgrepAsset(process.platform, process.arch) : options.asset;
|
|
63
|
+
if (asset === null) return { ok: false, error: `no pinned ripgrep build for ${process.platform} ${process.arch}` };
|
|
64
|
+
|
|
65
|
+
const bin = xenoBinDir(options.configDir);
|
|
66
|
+
await mkdir(bin, { recursive: true });
|
|
67
|
+
// Inside bin, so the final rename stays on one filesystem and is atomic.
|
|
68
|
+
const stage = await mkdtemp(join(bin, ".rg-install-"));
|
|
69
|
+
try {
|
|
70
|
+
const bytes = await options.fetchBytes(asset.url, options.signal);
|
|
71
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
72
|
+
if (digest !== asset.sha256) return { ok: false, error: `ripgrep archive sha256 ${digest} does not match pinned ${asset.sha256}` };
|
|
73
|
+
const archive = join(stage, "rg.tar.gz");
|
|
74
|
+
await writeFile(archive, bytes);
|
|
75
|
+
const extracted = Bun.spawnSync(["tar", "-xzf", archive, "-C", stage, asset.member], { stdout: "ignore", stderr: "pipe" });
|
|
76
|
+
if (extracted.exitCode !== 0) return { ok: false, error: `cannot extract ripgrep: ${extracted.stderr.toString().trim()}` };
|
|
77
|
+
const executable = join(stage, asset.member);
|
|
78
|
+
// The digest pins the archive, but chmod and rename follow what tar
|
|
79
|
+
// wrote; a member that is not a plain file is refused before either.
|
|
80
|
+
if (!(await lstat(executable)).isFile()) return { ok: false, error: "ripgrep archive member is not a regular file" };
|
|
81
|
+
await chmod(executable, 0o755);
|
|
82
|
+
const target = join(bin, "rg");
|
|
83
|
+
await rename(executable, target);
|
|
84
|
+
return { ok: true, path: target, installed: true };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, error: `cannot install ripgrep: ${error instanceof Error ? error.message : String(error)}` };
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(stage, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/store/worktree.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* is for cooperative branches, not adversarial ones.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
-
import {
|
|
23
|
+
import { mkdir, mkdtemp, rm, realpath } from "node:fs/promises";
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
25
27
|
import { tmpdir } from "node:os";
|
|
26
28
|
|
|
27
29
|
/** Branch prefix, so a stale worktree is identifiable months later. */
|
|
@@ -37,6 +39,37 @@ export type CreateResult =
|
|
|
37
39
|
| { ok: true; path: string; branch: string; snapshot: { head: string; tree: string } }
|
|
38
40
|
| { ok: false; error: string };
|
|
39
41
|
|
|
42
|
+
export interface CreateOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Where the checkout goes. Default: a fresh temp directory, which suits a
|
|
45
|
+
* writing branch that lives for one subtask. A model-entered worktree is
|
|
46
|
+
* kept across sessions, so it names a stable parent and its own leaf.
|
|
47
|
+
*/
|
|
48
|
+
location?: { parent: string; leaf: string };
|
|
49
|
+
/** Carry the parent's uncommitted files (default, as a branch needs). */
|
|
50
|
+
includeChanges?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The stable home of model-entered worktrees for one repository.
|
|
55
|
+
*
|
|
56
|
+
* Under xeno's config directory rather than the project: inside `.xeno` it
|
|
57
|
+
* would collide with the rule that session state is never edited, and a
|
|
58
|
+
* sibling directory leaves xeno's checkouts in the user's project tree (Codex
|
|
59
|
+
* keeps its worktrees under its own home for the same reason). The digest of
|
|
60
|
+
* the repository path keeps two clones with the same name apart.
|
|
61
|
+
*/
|
|
62
|
+
export function persistentWorktreeParent(configDir: string, repo: string): string {
|
|
63
|
+
const digest = createHash("sha256").update(repo).digest("hex").slice(0, 8);
|
|
64
|
+
return join(configDir, "worktrees", `${basename(repo)}-${digest}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The repository's main checkout: `git worktree list` always lists it first. */
|
|
68
|
+
export async function mainWorktree(dir: string): Promise<string | null> {
|
|
69
|
+
const listed = await listWorktrees(dir).catch(() => []);
|
|
70
|
+
return listed[0]?.path ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
export type RemoveResult =
|
|
41
74
|
| { ok: true }
|
|
42
75
|
| { ok: false; error: string; dirty: boolean };
|
|
@@ -61,7 +94,7 @@ export async function isGitRepo(dir: string): Promise<boolean> {
|
|
|
61
94
|
return git(dir, ["rev-parse", "--git-dir"]).code === 0;
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
export async function createWorktree(repo: string, name: string): Promise<CreateResult> {
|
|
97
|
+
export async function createWorktree(repo: string, name: string, options: CreateOptions = {}): Promise<CreateResult> {
|
|
65
98
|
// Refused rather than degraded. Falling back to the shared directory is the
|
|
66
99
|
// exact failure isolation exists to prevent: parallel writing branches
|
|
67
100
|
// would overwrite each other with nothing to indicate isolation was never
|
|
@@ -107,13 +140,24 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
107
140
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
108
141
|
|
|
109
142
|
const branch = `${WORKTREE_BRANCH_PREFIX}${name}`;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
if (git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).code === 0) {
|
|
144
|
+
return { ok: false, error: `branch ${branch} already exists; choose another name or work in its existing checkout` };
|
|
145
|
+
}
|
|
146
|
+
let path: string;
|
|
147
|
+
if (options.location) {
|
|
148
|
+
await mkdir(options.location.parent, { recursive: true });
|
|
149
|
+
// Resolved for the same reason as the temp path below.
|
|
150
|
+
path = join(await realpath(options.location.parent), options.location.leaf);
|
|
151
|
+
if (existsSync(path)) return { ok: false, error: `${path} already exists; choose another name` };
|
|
152
|
+
} else {
|
|
153
|
+
const created = await mkdtemp(join(tmpdir(), `xeno-wt-${name}-`));
|
|
154
|
+
// Resolved through symlinks, because git reports the real path and a
|
|
155
|
+
// caller comparing the two would never match. On macOS /var is a symlink
|
|
156
|
+
// to /private/var, so every temp path differs by default.
|
|
157
|
+
path = await realpath(created);
|
|
158
|
+
// mkdtemp created it; git worktree add needs the path not to exist.
|
|
159
|
+
await rm(path, { recursive: true, force: true });
|
|
160
|
+
}
|
|
117
161
|
|
|
118
162
|
// --lock in the same call: locking afterwards leaves a window in which a
|
|
119
163
|
// concurrent prune can remove the worktree that was just created.
|
|
@@ -121,6 +165,7 @@ export async function createWorktree(repo: string, name: string): Promise<Create
|
|
|
121
165
|
if (add.code !== 0) {
|
|
122
166
|
return { ok: false, error: `git worktree add failed: ${add.stderr.trim()}` };
|
|
123
167
|
}
|
|
168
|
+
if (options.includeChanges === false) return { ok: true, path, branch, snapshot: { head: head.stdout.trim(), tree: git(path, ["rev-parse", "HEAD^{tree}"]).stdout.trim() } };
|
|
124
169
|
// Only this freshly created disposable checkout is reset. The child index
|
|
125
170
|
// carries the parent's uncommitted input, so normal diff/inspection still sees it.
|
|
126
171
|
const hydrate = git(path, ["read-tree", "--reset", "-u", tree]);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elapsed time as people read it (feat-sidebar-goal-and-speed): `59s`, then
|
|
3
|
+
* `1m 00s`, then `1h 01m 01s`. The steps are Codex's fmt_elapsed_compact; a
|
|
4
|
+
* running turn used to read `3725s`.
|
|
5
|
+
*
|
|
6
|
+
* Every field after the first is two digits, so a running clock keeps its
|
|
7
|
+
* width within a unit instead of jittering the labels beside it. Parts of a
|
|
8
|
+
* second never round up: the clock shows seconds that have fully passed.
|
|
9
|
+
*/
|
|
10
|
+
export function formatElapsed(ms: number): string {
|
|
11
|
+
const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
|
|
12
|
+
if (total < 60) return `${total}s`;
|
|
13
|
+
const seconds = String(total % 60).padStart(2, "0");
|
|
14
|
+
if (total < 3600) return `${Math.floor(total / 60)}m ${seconds}s`;
|
|
15
|
+
return `${Math.floor(total / 3600)}h ${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}m ${seconds}s`;
|
|
16
|
+
}
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
|
|
|
48
48
|
return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
51
|
+
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
52
52
|
export type StatusField = (typeof STATUS_FIELDS)[number];
|
|
53
53
|
/** Screen surfaces only; the terminal title is outside the frame and listed separately. */
|
|
54
54
|
export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
|
61
61
|
export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
|
|
62
62
|
switch (tier) {
|
|
63
63
|
case "sidebar":
|
|
64
|
-
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
64
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", goal: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
65
65
|
usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
|
|
66
66
|
case "topbar":
|
|
67
|
-
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
67
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
68
68
|
usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
|
|
69
69
|
case "bare":
|
|
70
|
-
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
70
|
+
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
71
71
|
usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
|
|
72
72
|
default: return assertNever(tier, "placement");
|
|
73
73
|
}
|