@visual-z/xeno 0.3.0 → 0.5.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 +19 -0
- package/README.md +55 -16
- package/docs/app-server.md +86 -0
- package/docs/install.md +1 -1
- package/docs/providers.md +20 -6
- package/package.json +3 -2
- package/src/apps/app-server.ts +28 -0
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +10 -6
- package/src/apps/completions.ts +15 -13
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/preferences.ts +20 -0
- package/src/apps/provider-connect.ts +4 -4
- package/src/apps/shell.ts +73 -50
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +52 -19
- package/src/apps/ui/app.ts +210 -56
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +38 -29
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/event-validation.ts +1 -1
- package/src/core/events.ts +2 -0
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/permissions.ts +13 -7
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +25 -1
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/settings.ts +8 -1
- package/src/core/tool-input.ts +6 -0
- package/src/runtime/auth.ts +10 -3
- package/src/runtime/catalog.ts +21 -4
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/harness-eval.ts +3 -1
- package/src/runtime/loop.ts +72 -19
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval.ts +6 -3
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +2 -2
- package/src/runtime/provider-presets.ts +1 -1
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tools.ts +11 -9
- package/src/store/resources.ts +13 -22
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/composer.ts +3 -2
- package/src/tui/ui/control-panel.ts +25 -0
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/mascot.ts +1 -1
- package/src/tui/ui/screen.ts +107 -11
- package/src/tui/ui/status.ts +8 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Q24: bounded local JSONL transport. No TCP listener or additional runtime dependency. */
|
|
2
|
+
import { createServer, type Socket } from "node:net";
|
|
3
|
+
import { chmodSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, rm } from "node:fs/promises";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { CONTROL_SCHEMA } from "../core/control-protocol.ts";
|
|
7
|
+
import { ControlConnection, type RpcOutput } from "../runtime/control-connection.ts";
|
|
8
|
+
import type { SessionControl } from "../runtime/session-control.ts";
|
|
9
|
+
|
|
10
|
+
export class ControlJsonl {
|
|
11
|
+
private buffer = Buffer.alloc(0);
|
|
12
|
+
private pending = 0;
|
|
13
|
+
private tail = Promise.resolve();
|
|
14
|
+
private closed = false;
|
|
15
|
+
readonly connection: ControlConnection;
|
|
16
|
+
private readonly stop: () => void;
|
|
17
|
+
private readonly output: (text: string) => void;
|
|
18
|
+
constructor(control: () => SessionControl, output: (text: string) => void, stop: () => void, denyOnClose = false) {
|
|
19
|
+
this.stop = stop;
|
|
20
|
+
this.output = text => { try { output(text); } catch { this.close(); this.stop(); } };
|
|
21
|
+
this.connection = new ControlConnection(control, message => this.write(message), { denyOnClose });
|
|
22
|
+
}
|
|
23
|
+
private write(message: RpcOutput): void {
|
|
24
|
+
if (this.closed) return;
|
|
25
|
+
const text = JSON.stringify(message) + "\n";
|
|
26
|
+
if (Buffer.byteLength(text) > CONTROL_SCHEMA.limits.frameBytes) {
|
|
27
|
+
if ("id" in message) this.output(JSON.stringify({ id: message.id, error: { code: -32002, message: "response exceeds frame limit; use paginated inspectors" } }) + "\n");
|
|
28
|
+
else this.fail("event exceeds frame limit; reconnect and resynchronize");
|
|
29
|
+
} else this.output(text);
|
|
30
|
+
}
|
|
31
|
+
private fail(message: string): void {
|
|
32
|
+
if (this.closed) return;
|
|
33
|
+
this.output(JSON.stringify({ id: null, error: { code: -32001, message } }) + "\n");
|
|
34
|
+
this.close(); this.stop();
|
|
35
|
+
}
|
|
36
|
+
feed(chunk: Uint8Array): void {
|
|
37
|
+
if (this.closed) return;
|
|
38
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
39
|
+
for (;;) {
|
|
40
|
+
const newline = this.buffer.indexOf(10);
|
|
41
|
+
if (newline < 0) { if (this.buffer.length > CONTROL_SCHEMA.limits.frameBytes) this.fail("input frame exceeds limit"); return; }
|
|
42
|
+
if (newline > CONTROL_SCHEMA.limits.frameBytes) { this.fail("input frame exceeds limit"); return; }
|
|
43
|
+
const line = this.buffer.subarray(0, newline).toString("utf8"); this.buffer = this.buffer.subarray(newline + 1);
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
if (++this.pending > CONTROL_SCHEMA.limits.queuedRequests) { this.fail("request queue is full"); return; }
|
|
46
|
+
this.tail = this.tail.then(async () => {
|
|
47
|
+
if (this.closed) return;
|
|
48
|
+
let value: unknown;
|
|
49
|
+
try { value = JSON.parse(line); }
|
|
50
|
+
catch { this.write({ id: null, error: { code: -32700, message: "invalid JSON" } }); return; }
|
|
51
|
+
await this.connection.receive(value);
|
|
52
|
+
}).finally(() => { this.pending--; });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async drained(): Promise<void> { await this.tail; }
|
|
56
|
+
close(): void { this.closed = true; this.buffer = Buffer.alloc(0); this.connection.close(); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listenControl(path: string, control: () => SessionControl): Promise<() => Promise<void>> {
|
|
60
|
+
const absolute = resolve(path), parent = dirname(absolute);
|
|
61
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
62
|
+
const directory = await lstat(parent);
|
|
63
|
+
if (!directory.isDirectory() || directory.mode & 0o077 || process.getuid && directory.uid !== process.getuid()) throw Error("control socket requires an owned private directory (mode 0700)");
|
|
64
|
+
try { await lstat(absolute); throw Error("control socket path already exists; refusing to replace it"); }
|
|
65
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
66
|
+
const clients = new Map<Socket, ControlJsonl>();
|
|
67
|
+
const server = createServer(socket => {
|
|
68
|
+
// Assumed local adapter capacity; together with per-peer bounds this caps retained transport memory.
|
|
69
|
+
if (clients.size >= 32) { socket.destroy(); return; }
|
|
70
|
+
const peer = new ControlJsonl(control, text => {
|
|
71
|
+
if (socket.writableLength + Buffer.byteLength(text) > CONTROL_SCHEMA.limits.outputBytes) socket.destroy(); else socket.write(text);
|
|
72
|
+
}, () => socket.destroy());
|
|
73
|
+
clients.set(socket, peer);
|
|
74
|
+
socket.on("data", chunk => peer.feed(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
|
|
75
|
+
socket.on("error", () => socket.destroy());
|
|
76
|
+
socket.on("close", () => { peer.close(); clients.delete(socket); });
|
|
77
|
+
});
|
|
78
|
+
await new Promise<void>((resolveReady, reject) => {
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
server.listen(absolute, () => { chmodSync(absolute, 0o600); resolveReady(); });
|
|
81
|
+
});
|
|
82
|
+
const owned = await lstat(absolute);
|
|
83
|
+
return async () => {
|
|
84
|
+
for (const [socket, peer] of clients) { peer.close(); socket.destroy(); }
|
|
85
|
+
await new Promise<void>((resolveClosed, reject) => server.close(error => error ? reject(error) : resolveClosed()));
|
|
86
|
+
try { const current = await lstat(absolute); if (current.ino === owned.ino && current.dev === owned.dev) await rm(absolute); }
|
|
87
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { modelKey } from "../core/session-position.ts";
|
|
2
2
|
import { clampThinkingLevel } from "../core/thinking.ts";
|
|
3
|
-
import { createProvider } from "../runtime/catalog.ts";
|
|
3
|
+
import { createProvider, providerSessionId } from "../runtime/catalog.ts";
|
|
4
4
|
import { storedIsOAuth } from "../runtime/auth.ts";
|
|
5
5
|
import { outputTokenBudget, type ModelSpec } from "../runtime/model.ts";
|
|
6
6
|
import { providerAccess, setProviderAccess } from "../runtime/provider-access.ts";
|
|
@@ -29,6 +29,7 @@ export async function publishModelCatalog(app: App, models: readonly ModelSpec[]
|
|
|
29
29
|
const config = { ...app.providerConfig, spec, maxTokens: outputTokenBudget(spec), effort: clampThinkingLevel(spec, app.providerConfig.effort) };
|
|
30
30
|
const credential = app.credentialOf(spec);
|
|
31
31
|
const next = createProvider(config, credential.ok ? credential.key : "", retryHandler(app.io), {
|
|
32
|
+
sessionId: providerSessionId(app.session.config.logDir),
|
|
32
33
|
effects: app.effects, oauth: !app.args.apiKey && storedIsOAuth(app.auth, spec.provider),
|
|
33
34
|
});
|
|
34
35
|
const needsCut = providerReplayKey(previous) !== providerReplayKey(spec);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** User intent owns personal defaults; CLI overrides and restored/extension state only own a session. */
|
|
2
|
+
import { mergeSettings, type Settings } from "../core/settings.ts";
|
|
3
|
+
import { writeUserSettings } from "../store/resources.ts";
|
|
4
|
+
import { describeError } from "./render.ts";
|
|
5
|
+
import type { App } from "./startup.ts";
|
|
6
|
+
|
|
7
|
+
export type PreferenceOrigin = "user" | "session";
|
|
8
|
+
|
|
9
|
+
export async function commitUserSettings(app: App, patch: Settings): Promise<boolean> {
|
|
10
|
+
const ui = app.ui;
|
|
11
|
+
if (!ui) return false;
|
|
12
|
+
ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
|
|
13
|
+
try { await writeUserSettings(patch); }
|
|
14
|
+
catch (error) {
|
|
15
|
+
app.io.say(app.io.palette.yellow(` The change is active in this session, but could not save settings for the next startup: ${describeError(error)}`));
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom endpoints and connected-provider management, reached from `/
|
|
2
|
+
* Custom endpoints and connected-provider management, reached from `/provider`.
|
|
3
3
|
*
|
|
4
4
|
* Flow (magpie's add form + OpenCode's /connect, see
|
|
5
5
|
* docs/tasks/feat-provider-connect.md): choose a preset or a custom endpoint
|
|
@@ -68,7 +68,7 @@ function connectedName(id: string): string {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Entry points from `/
|
|
71
|
+
* Entry points from `/provider`. API-key and subscription sign-in stay on the
|
|
72
72
|
* login picker itself (their masked editor and OAuth flows are fixed by
|
|
73
73
|
* tests); the wizard owns what login never did: custom endpoints and
|
|
74
74
|
* per-provider model choice.
|
|
@@ -86,7 +86,7 @@ export function startConnect(host: ConnectHost, entry: "custom" | "manage"): voi
|
|
|
86
86
|
}
|
|
87
87
|
const connected = [...new Set(app.catalog.map(model => model.provider))].filter(id => providerReady(app, id).ready > 0).sort();
|
|
88
88
|
if (connected.length === 0) {
|
|
89
|
-
wizard.push({ kind: "message", title: "No provider is connected yet", lines: ["Choose a subscription, an API-key provider or a custom endpoint from /
|
|
89
|
+
wizard.push({ kind: "message", title: "No provider is connected yet", lines: ["Choose a subscription, an API-key provider or a custom endpoint from /provider first."], onDone: () => host.close() });
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
92
|
wizard.push({
|
|
@@ -100,7 +100,7 @@ export function startConnect(host: ConnectHost, entry: "custom" | "manage"): voi
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/** A provider configured by hand (or by an earlier /
|
|
103
|
+
/** A provider configured by hand (or by an earlier /provider) that is not a preset. */
|
|
104
104
|
function customFromCatalog(app: App, id: string): ProviderPreset | undefined {
|
|
105
105
|
const model = app.catalog.find(candidate => candidate.provider === id);
|
|
106
106
|
if (!model) return undefined;
|
package/src/apps/shell.ts
CHANGED
|
@@ -30,14 +30,14 @@ import {
|
|
|
30
30
|
} from "../core/context-policy.ts";
|
|
31
31
|
import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
|
|
32
32
|
import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
|
|
33
|
-
import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
33
|
+
import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
34
34
|
import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
|
|
35
35
|
import { scopedModels } from "../runtime/model-scope.ts";
|
|
36
36
|
import { equivalentCost, reportedCost } from "../runtime/usage.ts";
|
|
37
37
|
import type { ProviderConfig } from "../runtime/provider.ts";
|
|
38
38
|
import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
|
|
39
39
|
import {
|
|
40
|
-
SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS,
|
|
40
|
+
SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
|
|
41
41
|
loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
|
|
42
42
|
startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
|
|
43
43
|
ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
|
|
@@ -58,18 +58,20 @@ import { registerSessionRoot } from "../store/session-browser.ts";
|
|
|
58
58
|
import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
59
59
|
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
60
60
|
import { resolveTuiMode } from "../core/tui-mode.ts";
|
|
61
|
-
import {
|
|
61
|
+
import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
|
|
62
62
|
import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
|
|
63
63
|
import { cycle } from "../core/cycle.ts";
|
|
64
64
|
import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
|
|
65
65
|
import { modelKey } from "../core/session-position.ts";
|
|
66
66
|
import { defaultKeymap, listBindings } from "../core/keymap.ts";
|
|
67
|
-
import { settingPatch
|
|
68
|
-
import {
|
|
67
|
+
import { settingPatch } from "../core/settings.ts";
|
|
68
|
+
import { resolveTheme } from "../store/resources.ts";
|
|
69
|
+
import { commitUserSettings, type PreferenceOrigin } from "./preferences.ts";
|
|
69
70
|
import { colorEnabled } from "./render.ts";
|
|
70
71
|
import { createStyle, IDENTITY } from "../tui/style.ts";
|
|
71
72
|
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
72
73
|
import { toJsonEvent } from "../core/json-event.ts";
|
|
74
|
+
import { sessionControl } from "../runtime/session-control.ts";
|
|
73
75
|
import { COMMANDS, parseCommand, type Command } from "./commands.ts";
|
|
74
76
|
import { expandInput } from "./input.ts";
|
|
75
77
|
import {
|
|
@@ -84,7 +86,7 @@ import type { TurnSurface, Ui } from "./ui/surface.ts";
|
|
|
84
86
|
* One line. Listing all ~40 commands here wrapped into a two-line wall that
|
|
85
87
|
* nobody reads; typing `/` already opens the searchable command menu.
|
|
86
88
|
*/
|
|
87
|
-
const HINT = `type a prompt · / for ${COMMANDS.length} commands · /
|
|
89
|
+
const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
|
|
88
90
|
const OAUTH_PASTE = "\0oauth";
|
|
89
91
|
|
|
90
92
|
const initializedExtensions = new WeakSet<ExtensionRegistry>();
|
|
@@ -143,9 +145,9 @@ async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
|
143
145
|
},
|
|
144
146
|
setModel: async model => {
|
|
145
147
|
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
146
|
-
await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
148
|
+
await switchByPattern(app, String(model.provider) + "/" + String(model.id), "session"); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
147
149
|
},
|
|
148
|
-
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
|
|
150
|
+
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null, "session"); },
|
|
149
151
|
exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
|
|
150
152
|
});
|
|
151
153
|
if (!initializedExtensions.has(registry)) {
|
|
@@ -344,7 +346,7 @@ async function runLines(app: App): Promise<void> {
|
|
|
344
346
|
await bindExtensions(app);
|
|
345
347
|
const C = app.io.palette;
|
|
346
348
|
if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
|
|
347
|
-
/** Set by `/
|
|
349
|
+
/** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
|
|
348
350
|
let awaitingKeyFor: string | null = null;
|
|
349
351
|
for await (const line of console) {
|
|
350
352
|
const text = line.trim();
|
|
@@ -438,7 +440,11 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
|
|
|
438
440
|
printUsage(app);
|
|
439
441
|
return "continue";
|
|
440
442
|
case "state":
|
|
441
|
-
printState(app);
|
|
443
|
+
await printState(app);
|
|
444
|
+
return "continue";
|
|
445
|
+
case "control":
|
|
446
|
+
if (tui) await tui.openControl();
|
|
447
|
+
else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
|
|
442
448
|
return "continue";
|
|
443
449
|
case "context":
|
|
444
450
|
printContext(app);
|
|
@@ -514,8 +520,8 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
|
|
|
514
520
|
if (command.provider === undefined) {
|
|
515
521
|
if (tui) { tui.openLogin(); return "continue"; }
|
|
516
522
|
const C = app.io.palette;
|
|
517
|
-
const providers = [...new Set(app.catalog.map((m) => m.provider))];
|
|
518
|
-
app.io.say(C.bold("\
|
|
523
|
+
const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
|
|
524
|
+
app.io.say(C.bold("\nproviders"));
|
|
519
525
|
app.io.say(C.bold(" API keys"));
|
|
520
526
|
for (const p of providers) {
|
|
521
527
|
app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
|
|
@@ -524,13 +530,15 @@ async function execute(app: App, command: Command, tui: Ui | null): Promise<Outc
|
|
|
524
530
|
for (const s of SUBSCRIPTION_OPTIONS) {
|
|
525
531
|
app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
|
|
526
532
|
}
|
|
527
|
-
app.io.say(C.dim("\n /
|
|
528
|
-
app.io.say(C.dim(
|
|
533
|
+
app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
|
|
534
|
+
app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
|
|
529
535
|
return "continue";
|
|
530
536
|
}
|
|
531
|
-
|
|
532
|
-
|
|
537
|
+
const subscription = subscriptionById(command.provider);
|
|
538
|
+
if (subscription) {
|
|
539
|
+
return startSubscriptionLogin(app, subscription.id, tui);
|
|
533
540
|
}
|
|
541
|
+
if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
|
|
534
542
|
if (tui) {
|
|
535
543
|
const provider = command.provider;
|
|
536
544
|
const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
|
|
@@ -703,12 +711,21 @@ function printUsage(app: App): void {
|
|
|
703
711
|
app.io.say("");
|
|
704
712
|
}
|
|
705
713
|
|
|
706
|
-
function printState(app: App): void {
|
|
714
|
+
async function printState(app: App): Promise<void> {
|
|
707
715
|
const C = app.io.palette;
|
|
708
716
|
const s = app.session.state;
|
|
709
717
|
app.io.say(C.bold("\nstate"));
|
|
710
718
|
app.io.say(` node ${s.node} (depth ${s.depth})`);
|
|
711
719
|
app.io.say(` events in node ${s.events.length}`);
|
|
720
|
+
const request = await app.session.requestState();
|
|
721
|
+
if (request) {
|
|
722
|
+
app.io.say(` request ${request.request.event} · ${request.status}`);
|
|
723
|
+
app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
|
|
724
|
+
app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
|
|
725
|
+
app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
|
|
726
|
+
app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
|
|
727
|
+
if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
|
|
728
|
+
}
|
|
712
729
|
if (s.handoff) {
|
|
713
730
|
const h = s.handoff;
|
|
714
731
|
app.io.say(` completed ${h.state.completed.length}`);
|
|
@@ -806,7 +823,8 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
|
|
|
806
823
|
}
|
|
807
824
|
if (key === "thinking") {
|
|
808
825
|
const effort = asEffort(value);
|
|
809
|
-
if (
|
|
826
|
+
if (effort) await applyThinking(app, effort, tui);
|
|
827
|
+
return;
|
|
810
828
|
}
|
|
811
829
|
const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
|
|
812
830
|
if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
|
|
@@ -817,12 +835,12 @@ async function commitSetting(app: App, field: string, value: string, tui: Ui | n
|
|
|
817
835
|
app.ui.theme = theme;
|
|
818
836
|
app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
819
837
|
}
|
|
820
|
-
await
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
838
|
+
const saved = await commitUserSettings(app, parsed.settings);
|
|
839
|
+
if (saved && key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
840
|
+
if (saved) {
|
|
841
|
+
if (tui) tui.toast(`${field} · ${value}`, "success");
|
|
842
|
+
else app.io.say(C.dim(` ${field} ${value}`));
|
|
843
|
+
}
|
|
826
844
|
tui?.applyConfiguration();
|
|
827
845
|
if (key === "theme") tui?.followTheme();
|
|
828
846
|
tui?.redraw();
|
|
@@ -840,7 +858,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
|
840
858
|
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
841
859
|
* valid because the model did not change.
|
|
842
860
|
*/
|
|
843
|
-
async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
|
|
861
|
+
async function applyThinking(app: App, level: Effort, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<boolean> {
|
|
844
862
|
const C = app.io.palette;
|
|
845
863
|
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
846
864
|
app.io.say(
|
|
@@ -858,19 +876,20 @@ async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<b
|
|
|
858
876
|
if (app.ui !== undefined) {
|
|
859
877
|
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
860
878
|
}
|
|
879
|
+
if (origin === "user") await commitUserSettings(app, thinkingPreferencePatch(app.providerConfig.spec, level));
|
|
861
880
|
app.io.say(C.dim(` thinking ${level}`));
|
|
862
881
|
tui?.redraw();
|
|
863
882
|
return true;
|
|
864
883
|
}
|
|
865
884
|
|
|
866
|
-
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
|
|
885
|
+
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<Outcome> {
|
|
867
886
|
if (level !== undefined) {
|
|
868
887
|
const effort = asEffort(level);
|
|
869
888
|
if (effort === undefined) {
|
|
870
889
|
app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
|
|
871
890
|
return "continue";
|
|
872
891
|
}
|
|
873
|
-
await applyThinking(app, effort, tui);
|
|
892
|
+
await applyThinking(app, effort, tui, origin);
|
|
874
893
|
return "continue";
|
|
875
894
|
}
|
|
876
895
|
if (tui) {
|
|
@@ -1244,7 +1263,7 @@ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefin
|
|
|
1244
1263
|
}
|
|
1245
1264
|
|
|
1246
1265
|
function makeProvider(app: App, config: ProviderConfig, key: string) {
|
|
1247
|
-
return createProvider(config, key, retryHandler(app.io), { ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1266
|
+
return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1248
1267
|
}
|
|
1249
1268
|
|
|
1250
1269
|
interface PendingOAuth {
|
|
@@ -1361,19 +1380,20 @@ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Pro
|
|
|
1361
1380
|
await cancelPendingOAuth(app);
|
|
1362
1381
|
const effects = tui?.oauthEffects ?? loginEffects;
|
|
1363
1382
|
app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
|
|
1364
|
-
if (sub.id === "github-copilot") {
|
|
1383
|
+
if (sub.id === "github-copilot" || sub.id === "grok") {
|
|
1365
1384
|
const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
|
|
1366
1385
|
pendingDevices.set(app, pending);
|
|
1367
1386
|
const work = async () => { try {
|
|
1368
|
-
const credential = await effects.device({
|
|
1387
|
+
const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
|
|
1369
1388
|
notify: (message) => {
|
|
1370
1389
|
if (pendingDevices.get(app) !== pending) return;
|
|
1371
|
-
if (tui) { pending.close?.(); pending.close = tui.waiting(
|
|
1390
|
+
if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
|
|
1372
1391
|
else app.io.say(` ${message}`);
|
|
1373
1392
|
},
|
|
1374
1393
|
prompt: async () => "",
|
|
1375
1394
|
fetch: effects.fetch,
|
|
1376
1395
|
signal: pending.abort.signal,
|
|
1396
|
+
openBrowser: effects.openBrowser,
|
|
1377
1397
|
});
|
|
1378
1398
|
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1379
1399
|
await storeOAuth(app, sub.provider, credential);
|
|
@@ -1387,7 +1407,7 @@ async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Pro
|
|
|
1387
1407
|
pending.close?.();
|
|
1388
1408
|
}
|
|
1389
1409
|
} };
|
|
1390
|
-
if (tui) { pending.close = tui.waiting(
|
|
1410
|
+
if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
|
|
1391
1411
|
await work();
|
|
1392
1412
|
return "continue";
|
|
1393
1413
|
}
|
|
@@ -1469,7 +1489,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1469
1489
|
const got = app.credentialOf(spec);
|
|
1470
1490
|
if (!got.ok) return;
|
|
1471
1491
|
if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
|
|
1472
|
-
await switchToModel(app, spec);
|
|
1492
|
+
await switchToModel(app, spec, undefined, "session");
|
|
1473
1493
|
return;
|
|
1474
1494
|
}
|
|
1475
1495
|
const nextConfig: ProviderConfig = {
|
|
@@ -1494,7 +1514,7 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1494
1514
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1495
1515
|
}
|
|
1496
1516
|
|
|
1497
|
-
async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
1517
|
+
async function switchByPattern(app: App, pattern: string, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1498
1518
|
const C = app.io.palette;
|
|
1499
1519
|
const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
|
|
1500
1520
|
if (!selected) {
|
|
@@ -1502,24 +1522,25 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
|
1502
1522
|
app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
|
|
1503
1523
|
return;
|
|
1504
1524
|
}
|
|
1505
|
-
await switchToModel(app, selected.model, selected.thinking);
|
|
1525
|
+
await switchToModel(app, selected.model, selected.thinking, origin);
|
|
1506
1526
|
}
|
|
1507
1527
|
|
|
1508
|
-
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1528
|
+
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort, origin: PreferenceOrigin = "user"): Promise<void> {
|
|
1509
1529
|
const C = app.io.palette;
|
|
1510
|
-
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1511
|
-
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
|
|
1512
|
-
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
1530
|
const got = app.credentialOf(spec);
|
|
1516
1531
|
if (!got.ok) {
|
|
1517
1532
|
app.io.say(C.red(`\n ${got.reason}\n`));
|
|
1518
|
-
if (!got.reason.includes("/
|
|
1533
|
+
if (!got.reason.includes("/provider")) {
|
|
1519
1534
|
app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
|
|
1520
1535
|
}
|
|
1521
1536
|
return;
|
|
1522
1537
|
}
|
|
1538
|
+
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1539
|
+
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null, "session");
|
|
1540
|
+
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1541
|
+
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1523
1544
|
const nextConfig: ProviderConfig = {
|
|
1524
1545
|
spec,
|
|
1525
1546
|
maxTokens: outputTokenBudget(spec),
|
|
@@ -1537,15 +1558,15 @@ export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort
|
|
|
1537
1558
|
app.providerConfig = nextConfig;
|
|
1538
1559
|
app.credential = got;
|
|
1539
1560
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1540
|
-
await
|
|
1561
|
+
if (origin === "user") await rememberModelSelection(app, spec, thinking);
|
|
1541
1562
|
}
|
|
1542
1563
|
|
|
1543
|
-
/**
|
|
1544
|
-
async function
|
|
1564
|
+
/** N.8 must accept the checkpoint before a user choice can own the next startup. */
|
|
1565
|
+
async function rememberModelSelection(app: App, spec: ModelSpec, explicitThinking: Effort | undefined): Promise<void> {
|
|
1545
1566
|
if (!app.ui) return;
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1567
|
+
const effort = app.providerConfig.effort;
|
|
1568
|
+
const patch = modelPreferencePatch(spec, app.ui.settings.recentModels ?? [], effort);
|
|
1569
|
+
await commitUserSettings(app, explicitThinking === undefined ? patch : { ...patch, ...thinkingPreferencePatch(spec, effort) });
|
|
1549
1570
|
}
|
|
1550
1571
|
|
|
1551
1572
|
function sessionDirOf(app: App): string | undefined {
|
|
@@ -1750,6 +1771,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
|
|
|
1750
1771
|
if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
|
|
1751
1772
|
const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
|
|
1752
1773
|
tui ? chunk => tui.pushBash(chunk) : undefined);
|
|
1774
|
+
tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
|
|
1753
1775
|
if (tui) { await tui.syncTranscript(); tui.commitStream(); }
|
|
1754
1776
|
else app.io.say(`$ ${result.command}\n${result.content}`);
|
|
1755
1777
|
return true;
|
|
@@ -1760,7 +1782,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, ima
|
|
|
1760
1782
|
const C = app.io.palette;
|
|
1761
1783
|
app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
|
|
1762
1784
|
app.io.say(C.dim(` ${app.credential.reason}`));
|
|
1763
|
-
app.io.say(C.dim(" export an API key, or /
|
|
1785
|
+
app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
|
|
1764
1786
|
tui?.restoreDraft(prompt, images);
|
|
1765
1787
|
return false;
|
|
1766
1788
|
}
|
|
@@ -1855,7 +1877,8 @@ export async function runTurn(
|
|
|
1855
1877
|
}
|
|
1856
1878
|
await registry?.emit("agent_start");
|
|
1857
1879
|
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1858
|
-
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : app.session.send(prompt, signal, pull)) {
|
|
1880
|
+
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
|
|
1881
|
+
tui?.observeEvent?.(e);
|
|
1859
1882
|
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1860
1883
|
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
1861
1884
|
if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
type StartupStdin = Pick<NodeJS.ReadStream, "isTTY" | "isRaw" | "setRawMode" | "setEncoding" | "on" | "off" | "resume" | "pause" | "unshift">;
|
|
8
8
|
export interface StartupInput {
|
|
9
9
|
phase(name: string): void;
|
|
10
|
-
/** Pause
|
|
10
|
+
/** Pause and replay held bytes while keeping raw mode for the next input owner. */
|
|
11
11
|
handoff(): void;
|
|
12
12
|
/** Final cleanup discards unsubmitted input instead of leaking it to the shell. */
|
|
13
13
|
restore(): void;
|
|
@@ -45,9 +45,14 @@ export function createStartupInput(opts: {
|
|
|
45
45
|
stdin.off("data", receive);
|
|
46
46
|
signals.off("SIGINT", interrupt); signals.off("SIGTERM", terminate); signals.off("SIGHUP", hangup);
|
|
47
47
|
pasting = false; partial = "";
|
|
48
|
+
};
|
|
49
|
+
const restore = () => {
|
|
50
|
+
if (closed) return;
|
|
51
|
+
closed = true; release(); pending = [];
|
|
52
|
+
// D20: ownership transfer must not expose a cooked-mode gap that turns CR into LF.
|
|
53
|
+
// Final cleanup also restores termios when the next renderer never mounted.
|
|
48
54
|
stdin.setRawMode(wasRaw);
|
|
49
55
|
};
|
|
50
|
-
const restore = () => { closed = true; release(); pending = []; };
|
|
51
56
|
const cancel = (code: number) => {
|
|
52
57
|
if (!active) return;
|
|
53
58
|
restore();
|