@visual-z/xeno 0.3.0 → 0.4.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 +9 -0
- package/README.md +21 -9
- package/docs/app-server.md +84 -0
- package/docs/install.md +1 -1
- package/docs/providers.md +16 -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/provider-connect.ts +4 -4
- package/src/apps/shell.ts +39 -20
- package/src/apps/startup.ts +22 -9
- package/src/apps/ui/app.ts +196 -26
- 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 +26 -26
- 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/oauth-fields.ts +44 -0
- 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 +7 -0
- package/src/core/tool-input.ts +6 -0
- package/src/runtime/auth.ts +10 -3
- package/src/runtime/catalog.ts +13 -3
- 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/tui/ui/composer.ts +3 -2
- package/src/tui/ui/control-panel.ts +23 -0
- package/src/tui/ui/mascot.ts +1 -1
- package/src/tui/ui/status.ts +8 -0
package/src/apps/ui/app.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { attachImageBytes, attachImageFile } from "../../runtime/images.ts";
|
|
|
36
36
|
import { readClipboard, writeClipboard, type ClipboardContent } from "../../runtime/clipboard.ts";
|
|
37
37
|
import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runtime/rich-text.ts";
|
|
38
38
|
import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
|
|
39
|
-
import { SUBSCRIPTION_OPTIONS, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
|
|
39
|
+
import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
|
|
40
40
|
import { reportedCost } from "../../runtime/usage.ts";
|
|
41
41
|
import { fetchLatestVersion } from "../../runtime/update-check.ts";
|
|
42
42
|
import { installKind, updateNotice, updatePlan } from "../../core/update-check.ts";
|
|
@@ -64,6 +64,7 @@ import { displayPath } from "../../tui/ui/paths.ts";
|
|
|
64
64
|
import { approvalHeading, approvalItems, choiceFromValue } from "../approve.ts";
|
|
65
65
|
import { completionAt, completionSources, commandEntries } from "../completions.ts";
|
|
66
66
|
import { commandDefinition } from "../commands.ts";
|
|
67
|
+
import { completionAction } from "../../core/completion-action.ts";
|
|
67
68
|
import { expandInput } from "../input.ts";
|
|
68
69
|
import type { TrustChoice } from "../project-trust.ts";
|
|
69
70
|
import { describeError, firstLine } from "../render.ts";
|
|
@@ -79,6 +80,11 @@ import type { OAuthEffects, Ui } from "./surface.ts";
|
|
|
79
80
|
import { styleTokens, terminalPalette, uiColors } from "./theme.ts";
|
|
80
81
|
import { ImageCache } from "./image-cache.ts";
|
|
81
82
|
import { ImageDraft } from "../image-draft.ts";
|
|
83
|
+
import { sessionControl, type SessionControl, type ControlSnapshot } from "../../runtime/session-control.ts";
|
|
84
|
+
import { controlModel } from "./control-model.ts";
|
|
85
|
+
import { controlPanelLines, type ControlTab, type ControlFocus } from "../../tui/ui/control-panel.ts";
|
|
86
|
+
import { programStatus } from "../../core/program-status.ts";
|
|
87
|
+
import { ProgramStatusReporter } from "../../runtime/program-status.ts";
|
|
82
88
|
import type { PictureSource } from "../../tui/ui/pictures.ts";
|
|
83
89
|
import { DEFAULT_CELL } from "../../tui/ui/image-size.ts";
|
|
84
90
|
import type { TerminalPalette } from "../../tui/ui/ink.ts";
|
|
@@ -110,7 +116,7 @@ export class InteractiveApp implements Ui {
|
|
|
110
116
|
oauthEffects: OAuthEffects = { ...loginEffects };
|
|
111
117
|
clipboardWriter: (text: string) => boolean | Promise<boolean> = writeClipboard;
|
|
112
118
|
clipboardReader: () => Promise<ClipboardContent> = readClipboard;
|
|
113
|
-
/** Test seam for /
|
|
119
|
+
/** Test seam for /provider custom endpoints. */
|
|
114
120
|
connectFetch: ((input: string, init?: RequestInit) => Promise<Response>) | undefined;
|
|
115
121
|
sessionName: string | null = null;
|
|
116
122
|
queue: QueuedMessage[] = [];
|
|
@@ -138,6 +144,12 @@ export class InteractiveApp implements Ui {
|
|
|
138
144
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
139
145
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
140
146
|
private derived: ReturnType<typeof handoffStatus> | undefined;
|
|
147
|
+
private control: SessionControl | undefined;
|
|
148
|
+
private stopControl: (() => void) | undefined;
|
|
149
|
+
private controlSnapshot: ControlSnapshot | undefined;
|
|
150
|
+
private readonly program: ProgramStatusReporter | undefined;
|
|
151
|
+
private readonly stopOsc: (() => void) | undefined;
|
|
152
|
+
private programResult = false;
|
|
141
153
|
private derivedKey = "";
|
|
142
154
|
private derivedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
143
155
|
private derivedRun: Promise<void> | null = null;
|
|
@@ -191,10 +203,12 @@ export class InteractiveApp implements Ui {
|
|
|
191
203
|
/** Set once the renderer is gone; late async work (timers, warmed grammars) must not touch it. */
|
|
192
204
|
private disposed = false;
|
|
193
205
|
|
|
194
|
-
constructor(app: App, hooks: Hooks, renderer: CliRenderer) {
|
|
206
|
+
constructor(app: App, hooks: Hooks, renderer: CliRenderer, program?: ProgramStatusReporter) {
|
|
195
207
|
this.app = app;
|
|
196
208
|
this.hooks = hooks;
|
|
197
209
|
this.renderer = renderer;
|
|
210
|
+
this.program = program;
|
|
211
|
+
this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
|
|
198
212
|
this.closed = new Promise(resolve => { this.closedResolve = resolve; });
|
|
199
213
|
this.branch = currentBranch(app.workspace);
|
|
200
214
|
this.sessionName = app.args.sessionName || null;
|
|
@@ -274,22 +288,28 @@ export class InteractiveApp implements Ui {
|
|
|
274
288
|
redraw(): void { this.paint(); }
|
|
275
289
|
|
|
276
290
|
askSecret(title: string, label: string, hint: string): Promise<string | undefined> {
|
|
291
|
+
const control = sessionControl(this.app.session); control.setWaiting("auth");
|
|
277
292
|
return new Promise(resolve => this.screen.dialogs.push({ kind: "form", title, label, secret: true, hint,
|
|
278
|
-
onSubmit: value =>
|
|
293
|
+
onSubmit: value => { control.setWaiting(null); resolve(value); },
|
|
294
|
+
onCancel: () => { control.setWaiting(null); resolve(undefined); } }));
|
|
279
295
|
}
|
|
280
296
|
confirm(title: string, message: string): Promise<boolean> {
|
|
281
|
-
|
|
297
|
+
const control = sessionControl(this.app.session); control.setWaiting("question");
|
|
298
|
+
return new Promise(resolve => this.screen.dialogs.push({ kind: "confirm", title, message, onAnswer: yes => { control.setWaiting(null); resolve(yes); } }));
|
|
282
299
|
}
|
|
283
300
|
waiting(title: string, lines: readonly string[], onCancel: () => void, pasteLabel?: string, onPaste?: (value: string) => void): () => void {
|
|
301
|
+
const control = sessionControl(this.app.session); control.setWaiting("auth");
|
|
302
|
+
const cancel = () => { control.setWaiting(null); onCancel(); };
|
|
284
303
|
const spec: DialogSpec = pasteLabel && onPaste
|
|
285
|
-
? { kind: "form", title, label: pasteLabel, secret: true, intro: lines, hint: "waiting for the browser · paste the redirect URL + enter · esc cancel", onSubmit: onPaste, onCancel }
|
|
286
|
-
: { kind: "busy", title, message: lines.join("\n"), onCancel };
|
|
304
|
+
? { kind: "form", title, label: pasteLabel, secret: true, intro: lines, hint: "waiting for the browser · paste the redirect URL + enter · esc cancel", onSubmit: onPaste, onCancel: cancel }
|
|
305
|
+
: { kind: "busy", title, message: lines.join("\n"), onCancel: cancel };
|
|
287
306
|
this.screen.dialogs.push(spec);
|
|
288
|
-
return () => this.screen.dialogs.remove(spec);
|
|
307
|
+
return () => { this.screen.dialogs.remove(spec); control.setWaiting(null); };
|
|
289
308
|
}
|
|
290
309
|
|
|
291
310
|
// ----------------------------------------------------------------- lifecycle
|
|
292
311
|
async start(): Promise<void> {
|
|
312
|
+
this.program?.start();
|
|
293
313
|
this.applyConfiguration();
|
|
294
314
|
this.followTheme();
|
|
295
315
|
void this.detectPalette();
|
|
@@ -341,7 +361,12 @@ export class InteractiveApp implements Ui {
|
|
|
341
361
|
this.abort?.abort();
|
|
342
362
|
this.closedResolve();
|
|
343
363
|
}
|
|
344
|
-
interrupt(): void {
|
|
364
|
+
interrupt(): void {
|
|
365
|
+
this.abort?.abort();
|
|
366
|
+
const control = sessionControl(this.app.session);
|
|
367
|
+
if (control.activeTurnId) control.interrupt(control.activeTurnId);
|
|
368
|
+
this.returnQueueToEditor();
|
|
369
|
+
}
|
|
345
370
|
|
|
346
371
|
async settle(): Promise<void> {
|
|
347
372
|
await this.hooks.cancelPendingOAuth(this.app);
|
|
@@ -356,6 +381,8 @@ export class InteractiveApp implements Ui {
|
|
|
356
381
|
|
|
357
382
|
dispose(): void {
|
|
358
383
|
this.disposed = true;
|
|
384
|
+
this.stopControl?.();
|
|
385
|
+
this.stopOsc?.(); this.program?.close();
|
|
359
386
|
clearTimeout(this.paintTimer); clearInterval(this.retryTimer); clearTimeout(this.derivedTimer); this.derivedTimer = undefined;
|
|
360
387
|
this.stopThemeWatch?.();
|
|
361
388
|
this.renderer.off("theme_mode", this.terminalThemeChanged);
|
|
@@ -376,6 +403,7 @@ export class InteractiveApp implements Ui {
|
|
|
376
403
|
|
|
377
404
|
// --------------------------------------------------------------- transcript
|
|
378
405
|
async syncTranscript(): Promise<void> {
|
|
406
|
+
this.bindControl();
|
|
379
407
|
const session = this.app.session, dir = this.app.logDir, node = session.state.node;
|
|
380
408
|
if (dir !== this.historyDir || node !== this.historyNode) {
|
|
381
409
|
const history = await readNodeHistory(dir, node);
|
|
@@ -509,7 +537,7 @@ export class InteractiveApp implements Ui {
|
|
|
509
537
|
const app = this.app, spec = app.providerConfig.spec, budget = this.budget();
|
|
510
538
|
const t = app.session.totals, cost = reportedCost(t, spec.price);
|
|
511
539
|
const tools = items.filter(item => item.kind === "tool");
|
|
512
|
-
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /
|
|
540
|
+
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
|
|
513
541
|
return {
|
|
514
542
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
515
543
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
@@ -519,6 +547,11 @@ export class InteractiveApp implements Ui {
|
|
|
519
547
|
cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null } } : {}),
|
|
520
548
|
tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
|
|
521
549
|
...(this.derived ?? {}),
|
|
550
|
+
...(this.controlSnapshot?.goal ? { goal: { text: this.controlSnapshot.goal.text,
|
|
551
|
+
status: this.controlSnapshot.goal.state.status,
|
|
552
|
+
execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
|
|
553
|
+
observations: this.controlSnapshot.goal.state.progress.observations.length,
|
|
554
|
+
retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
|
|
522
555
|
files: this.files,
|
|
523
556
|
resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
|
|
524
557
|
...(extension ? { extension } : {}),
|
|
@@ -571,8 +604,24 @@ export class InteractiveApp implements Ui {
|
|
|
571
604
|
this.derivedLast = Date.now();
|
|
572
605
|
const target = this.derivedTarget();
|
|
573
606
|
this.derivedRun = (async () => {
|
|
574
|
-
try {
|
|
575
|
-
|
|
607
|
+
try {
|
|
608
|
+
const session = this.app.session;
|
|
609
|
+
const derived = handoffStatus(await session.previewHandoff());
|
|
610
|
+
const snapshot = await sessionControl(session).snapshot(false);
|
|
611
|
+
if (this.app.session !== session) return;
|
|
612
|
+
this.derived = derived; this.controlSnapshot = snapshot; this.derivedKey = target;
|
|
613
|
+
// Idle acceptance changes have no model delta. Reconcile terminal attention
|
|
614
|
+
// with the same evidence projection that refreshes the visible goal.
|
|
615
|
+
if (!this.inflight) this.program?.update(programStatus({ running: false, waiting: snapshot.waiting,
|
|
616
|
+
...(snapshot.goal?.state.execution ? { outcome: snapshot.goal.state.execution } : {}),
|
|
617
|
+
...(snapshot.goal ? { goal: snapshot.goal.state.status } : {}) }));
|
|
618
|
+
if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// Unavailable evidence cannot leave a stale verified goal visible.
|
|
622
|
+
this.controlSnapshot = undefined;
|
|
623
|
+
if (this.screen.dialogs.top?.title === "Control panel") this.screen.dialogs.refresh();
|
|
624
|
+
}
|
|
576
625
|
})();
|
|
577
626
|
await this.derivedRun;
|
|
578
627
|
this.derivedRun = null;
|
|
@@ -607,17 +656,19 @@ export class InteractiveApp implements Ui {
|
|
|
607
656
|
if (key.chord === "up") { composer.moveCompletion(-1); return true; }
|
|
608
657
|
if (key.chord === "down") { composer.moveCompletion(1); return true; }
|
|
609
658
|
if (key.chord === "escape") { composer.setCompletion(undefined); return true; }
|
|
610
|
-
if (key.chord === "tab"
|
|
611
|
-
|
|
659
|
+
if (key.chord === "tab" || key.chord === "enter") {
|
|
660
|
+
const kind = composer.completionView!.kind;
|
|
612
661
|
const item = composer.acceptCompletion();
|
|
613
662
|
if (item) {
|
|
614
|
-
const definition = commandDefinition(
|
|
615
|
-
|
|
616
|
-
|
|
663
|
+
const definition = commandDefinition(composer.text);
|
|
664
|
+
const required = !!definition && "argumentHint" in definition && definition.argumentHint.startsWith("<");
|
|
665
|
+
if (completionAction(kind, key.chord, required) === "submit") {
|
|
666
|
+
const text = composer.text;
|
|
667
|
+
this.track(() => this.submit(text, "steering"));
|
|
668
|
+
} else { this.updateCompletion(); this.paint(); }
|
|
617
669
|
}
|
|
670
|
+
return true;
|
|
618
671
|
}
|
|
619
|
-
if (key.chord === "enter" && !composer.text.trimEnd().match(/^\/\S+\s*$/)) { composer.acceptCompletion(); this.updateCompletion(); return true; }
|
|
620
|
-
if (key.chord === "enter") { composer.setCompletion(undefined); }
|
|
621
672
|
}
|
|
622
673
|
if (bound(b, "app.palette", key)) { this.openPalette(); return true; }
|
|
623
674
|
if (bound(b, "app.clear", key)) {
|
|
@@ -694,7 +745,7 @@ export class InteractiveApp implements Ui {
|
|
|
694
745
|
async whenIdle(): Promise<void> {
|
|
695
746
|
for (let round = 0; round < 50; round++) {
|
|
696
747
|
const turn = this.turn, work = this.work.size;
|
|
697
|
-
await turn; await this.idle(); await this.app.session.whenIdle(); await this.app.extensions?.settle();
|
|
748
|
+
await turn; await this.control?.whenIdle().catch(() => {}); await this.idle(); await this.app.session.whenIdle(); await this.app.extensions?.settle();
|
|
698
749
|
if (turn === this.turn && !this.inflight && work === this.work.size && !this.work.size) return;
|
|
699
750
|
}
|
|
700
751
|
}
|
|
@@ -743,12 +794,22 @@ export class InteractiveApp implements Ui {
|
|
|
743
794
|
private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
|
|
744
795
|
this.abort = new AbortController();
|
|
745
796
|
this.inflight = true;
|
|
797
|
+
this.programResult = false;
|
|
798
|
+
this.program?.update(programStatus({ running: true }));
|
|
746
799
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
747
800
|
this.paint();
|
|
748
801
|
try {
|
|
749
802
|
const sent = await this.hooks.run(this.app, text, this.abort.signal, this, images, skill);
|
|
803
|
+
if (!sent && !this.app.credential.ok) {
|
|
804
|
+
this.programResult = true; this.program?.update(programStatus({ running: false, waiting: "auth" }));
|
|
805
|
+
}
|
|
750
806
|
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
807
|
+
} catch (error) {
|
|
808
|
+
this.programResult = true;
|
|
809
|
+
this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
810
|
+
throw error;
|
|
751
811
|
} finally {
|
|
812
|
+
if (!this.programResult) this.program?.update(programStatus({ running: false }));
|
|
752
813
|
await this.syncTranscript();
|
|
753
814
|
this.abort = null;
|
|
754
815
|
this.hooks.dropFileIndex(this.app.workspace);
|
|
@@ -956,13 +1017,117 @@ export class InteractiveApp implements Ui {
|
|
|
956
1017
|
|
|
957
1018
|
// ---------------------------------------------------------------- approval
|
|
958
1019
|
ask(request: PermissionRequest): Promise<ApprovalChoice> {
|
|
1020
|
+
this.program?.update(programStatus({ running: this.inflight, waiting: "permission" }));
|
|
959
1021
|
// While the dialog is open the pending call shows ◌: it is waiting on the person, not running.
|
|
960
1022
|
this.screen.messages.setWaiting(true);
|
|
961
|
-
const
|
|
1023
|
+
const present = (_id: string, resolve: (choice: ApprovalChoice) => void) => {
|
|
1024
|
+
const spec: DialogSpec = { kind: "list", title: "Tool approval", search: false, tone: "warning", intro: approvalHeading(request).split("\n"),
|
|
1025
|
+
items: approvalItems(request), onPick: item => resolve(choiceFromValue(item.value)), onCancel: () => resolve("deny") };
|
|
1026
|
+
this.screen.dialogs.push(spec);
|
|
1027
|
+
return () => { this.screen.messages.setWaiting(false); this.screen.dialogs.remove(spec); this.program?.update(programStatus({ running: this.inflight })); };
|
|
1028
|
+
};
|
|
1029
|
+
// Isolated skills and user shell commands retain their existing local approval lifecycle.
|
|
1030
|
+
const control = sessionControl(this.app.session);
|
|
1031
|
+
if (control.activeTurnId) return control.ask(request, present);
|
|
962
1032
|
return new Promise(resolve => {
|
|
963
|
-
|
|
964
|
-
|
|
1033
|
+
let close: () => void;
|
|
1034
|
+
close = present("local", choice => { close(); resolve(choice); });
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private bindControl(): void {
|
|
1039
|
+
const control = sessionControl(this.app.session);
|
|
1040
|
+
if (this.control === control) return;
|
|
1041
|
+
this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
|
|
1042
|
+
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1043
|
+
const unsubscribe = control.subscribe(notice => {
|
|
1044
|
+
if (this.disposed) return;
|
|
1045
|
+
if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
|
|
1046
|
+
if (notice.method === "turn/error") {
|
|
1047
|
+
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
1048
|
+
this.track(() => this.syncTranscript());
|
|
1049
|
+
}
|
|
1050
|
+
if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
|
|
1051
|
+
if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
|
|
1052
|
+
this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
|
|
1053
|
+
// A remote client drives the same Session; local turns already have the shell's stream adapter.
|
|
1054
|
+
if (!this.abort) {
|
|
1055
|
+
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
1056
|
+
if (notice.method === "turn/completed") {
|
|
1057
|
+
this.inflight = false; this.executionKind = "idle"; this.clearStream();
|
|
1058
|
+
const outcome = notice.params["outcome"] as import("../../core/outcome.ts").ExecutionOutcome | undefined;
|
|
1059
|
+
this.turn = this.continueRemoteTurn(control, outcome).catch(error => this.notice(describeError(error), "error"));
|
|
1060
|
+
}
|
|
1061
|
+
if (notice.method === "turn/event") {
|
|
1062
|
+
const event = notice.params["event"] as import("../../runtime/loop.ts").LoopEvent;
|
|
1063
|
+
this.observeEvent(event);
|
|
1064
|
+
if (event.kind === "text_delta" || event.kind === "thinking_delta") this.pushDelta(event.kind === "text_delta" ? "text" : "thinking", event.text ?? "");
|
|
1065
|
+
else if (event.kind === "discard") this.discardStream();
|
|
1066
|
+
else if (event.kind === "progress") { if (event.progress) this.pushProgress(event.progress); }
|
|
1067
|
+
else this.track(() => this.syncTranscript());
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (notice.method !== "turn/event") { this.derivedKey = ""; this.requestDerived(); this.paint(); }
|
|
965
1071
|
});
|
|
1072
|
+
this.stopControl = () => { detachInput(); unsubscribe(); };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private async continueRemoteTurn(control: SessionControl, outcome?: import("../../core/outcome.ts").ExecutionOutcome): Promise<void> {
|
|
1076
|
+
// Completion is announced before the iterator releases execution. Follow-ups
|
|
1077
|
+
// remain editor-owned until that release, preserving the existing queue contract.
|
|
1078
|
+
await control.whenIdle().catch(() => {});
|
|
1079
|
+
if (this.disposed || this.shutdownRequested || this.control !== control || this.inflight) return;
|
|
1080
|
+
if (outcome?.status !== "completed") { this.returnQueueToEditor(); return; }
|
|
1081
|
+
const batch = this.nextBatch("turn_end");
|
|
1082
|
+
if (!batch.length) return;
|
|
1083
|
+
this.queue = this.queue.filter(item => !batch.includes(item));
|
|
1084
|
+
await this.runPromptTurn(batch.map(item => item.text).join("\n\n"), batch.flatMap(item => item.images ?? []));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
|
|
1088
|
+
if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
|
|
1089
|
+
if (event.kind === "done") this.program?.update(programStatus({ running: false,
|
|
1090
|
+
...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
|
|
1091
|
+
if (event.kind === "stop_and_ask") this.program?.update(programStatus({ running: false, waiting: "question" }));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async openControl(tab: ControlTab = "goal"): Promise<void> {
|
|
1095
|
+
this.bindControl();
|
|
1096
|
+
this.controlSnapshot = await this.control!.snapshot();
|
|
1097
|
+
const tabs: ControlTab[] = ["goal", "progress", "context"];
|
|
1098
|
+
const lenses: ControlFocus[] = ["messages", "omitted", "history"];
|
|
1099
|
+
let focus: ControlFocus = "messages", scroll = 0, total = 0;
|
|
1100
|
+
this.screen.dialogs.push({ kind: "lines", title: "Control panel",
|
|
1101
|
+
hint: "tab sections · ←→ lenses · ↑↓ scroll · r refresh · i interrupt · a accept",
|
|
1102
|
+
render: width => {
|
|
1103
|
+
if (!this.controlSnapshot) return ["Current evidence unavailable. Press r to refresh."];
|
|
1104
|
+
const lines = controlPanelLines(controlModel(this.controlSnapshot), tab, width, focus);
|
|
1105
|
+
const rows = Math.max(4, this.renderer.height - 14); total = lines.length;
|
|
1106
|
+
scroll = Math.min(scroll, Math.max(0, total - rows));
|
|
1107
|
+
return [...lines.slice(scroll, scroll + rows), ...(total > rows ? [`${scroll + 1}–${Math.min(total, scroll + rows)} / ${total} · ↑↓ scroll`] : [])];
|
|
1108
|
+
},
|
|
1109
|
+
handleKey: async key => {
|
|
1110
|
+
if (key.name === "tab") { tab = tabs[(tabs.indexOf(tab) + 1) % tabs.length]!; scroll = 0; this.screen.dialogs.refresh(); return true; }
|
|
1111
|
+
if (key.name === "up" || key.name === "down") { scroll = Math.max(0, Math.min(total - 1, scroll + (key.name === "down" ? 1 : -1))); this.screen.dialogs.refresh(); return true; }
|
|
1112
|
+
if (tab === "context" && (key.name === "left" || key.name === "right")) {
|
|
1113
|
+
focus = lenses[(lenses.indexOf(focus) + (key.name === "right" ? 1 : 2)) % 3]!; scroll = 0; this.screen.dialogs.refresh(); return true;
|
|
1114
|
+
}
|
|
1115
|
+
if (key.name === "r") { this.controlSnapshot = await sessionControl(this.app.session).snapshot(); this.screen.dialogs.refresh(); return true; }
|
|
1116
|
+
if (key.name === "i") { this.interrupt(); return true; }
|
|
1117
|
+
if (key.name === "a") {
|
|
1118
|
+
const goal = this.controlSnapshot?.goal;
|
|
1119
|
+
const criteria = goal?.state.acceptance?.criteria.filter(item => item.kind === "human" && item.status !== "pass" && item.status !== "accepted") ?? [];
|
|
1120
|
+
if (this.inflight || !goal || !criteria.length) { this.toast("No human criterion ready for acceptance", "warning"); return true; }
|
|
1121
|
+
this.screen.dialogs.push({ kind: "list", title: "Accept a human criterion", search: false,
|
|
1122
|
+
items: criteria.map(item => ({ value: item.id, label: item.description, description: item.status })),
|
|
1123
|
+
onPick: async item => {
|
|
1124
|
+
await sessionControl(this.app.session).accept(goal.state.request.event, item.value);
|
|
1125
|
+
this.controlSnapshot = await sessionControl(this.app.session).snapshot(); this.screen.dialogs.refresh();
|
|
1126
|
+
} });
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
return false;
|
|
1130
|
+
} });
|
|
966
1131
|
}
|
|
967
1132
|
|
|
968
1133
|
// ----------------------------------------------------------------- palette
|
|
@@ -1062,7 +1227,7 @@ export class InteractiveApp implements Ui {
|
|
|
1062
1227
|
}
|
|
1063
1228
|
|
|
1064
1229
|
openLogin(method?: "oauth" | "api_key"): void {
|
|
1065
|
-
const providers = [...new Set(this.app.catalog.map(model => model.provider))];
|
|
1230
|
+
const providers = [...new Set(this.app.catalog.map(model => model.provider))].filter(provider => !subscriptionById(provider));
|
|
1066
1231
|
const status = (provider: string): string => {
|
|
1067
1232
|
const spec = this.app.catalog.find(model => model.provider === provider);
|
|
1068
1233
|
const resolved = spec ? this.app.credentialOf(spec) : undefined;
|
|
@@ -1105,7 +1270,7 @@ export class InteractiveApp implements Ui {
|
|
|
1105
1270
|
const ready = this.app.catalog.filter(spec => this.app.credentialOf(spec).ok);
|
|
1106
1271
|
const configured = this.app.ui?.settings.scopedModels;
|
|
1107
1272
|
const checked = configured ? ready.filter(spec => configured.some(pattern => pattern === modelKey(spec) || pattern === spec.id)).map(modelKey) : ready.map(modelKey);
|
|
1108
|
-
this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /
|
|
1273
|
+
this.push({ kind: "list", title: "Models to cycle with alt+m", multi: true, checked, empty: "No available models. Use /provider to add a credential.",
|
|
1109
1274
|
items: ready.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider, section: SOURCE_PROVIDERS[spec.provider]?.name ?? spec.provider })),
|
|
1110
1275
|
onSubmitChecked: async values => {
|
|
1111
1276
|
await writeUserSettings({ scopedModels: values });
|
|
@@ -1318,10 +1483,15 @@ function noticeOf(id: string, after: number, lines: string[], tone: Tone): Notic
|
|
|
1318
1483
|
export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: () => void, renderer?: CliRenderer): Promise<void> {
|
|
1319
1484
|
const fullscreen = (app.ui?.settings.tuiMode ?? "fullscreen") !== "regular";
|
|
1320
1485
|
releaseEarlyTty?.();
|
|
1486
|
+
// Capture the real writer before OpenTUI owns stdout, so OSC never becomes transcript text.
|
|
1487
|
+
const terminalWrite = process.stdout.write.bind(process.stdout);
|
|
1321
1488
|
const r = renderer ?? await createCliRenderer({ screenMode: fullscreen ? "alternate-screen" : "main-screen", exitOnCtrlC: false, exitSignals: [],
|
|
1322
1489
|
// Performance (refactor-tui-dock-layout DoD 9): no console capture, no stats, no hover tracking; bursts coalesce at 60 fps.
|
|
1323
1490
|
consoleMode: "disabled", openConsoleOnError: false, useMouse: true, enableMouseMovement: false, gatherStats: false, memorySnapshotInterval: 0, targetFps: 60, maxFps: 60 });
|
|
1324
|
-
const
|
|
1491
|
+
const program = new ProgramStatusReporter({ write: text => { terminalWrite(text); }, id: `xeno-${process.pid}`,
|
|
1492
|
+
tty: !renderer && process.stdout.isTTY === true, mode: process.env["XENO_PROGRAM_STATUS"] ?? "auto",
|
|
1493
|
+
multiplexed: Boolean(process.env["TMUX"] || process.env["STY"]) });
|
|
1494
|
+
const ui = new InteractiveApp(app, hooks, r, program);
|
|
1325
1495
|
const previousSay = app.io.say;
|
|
1326
1496
|
app.io.say = line => ui.notice(line);
|
|
1327
1497
|
const stop = (code: number) => { process.exitCode = code; ui.requestShutdown(); };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ControlSnapshot } from "../../runtime/session-control.ts";
|
|
2
|
+
import type { ControlPanelModel } from "../../tui/ui/control-panel.ts";
|
|
3
|
+
|
|
4
|
+
export function controlModel(snapshot: ControlSnapshot): ControlPanelModel {
|
|
5
|
+
const goal = snapshot.goal, state = goal?.state, context = snapshot.context;
|
|
6
|
+
const progress = (label: string, sources: readonly { node: number; event: string }[]) => ({ label, count: sources.length, sources: sources.map(source => `${source.node}/${source.event}`) });
|
|
7
|
+
return { thread: snapshot.threadId, turn: snapshot.activeTurnId ?? state?.request.event ?? null,
|
|
8
|
+
goal: goal?.text ?? "Send a request to establish a goal.", status: state?.status ?? "idle",
|
|
9
|
+
execution: snapshot.waiting ? `waiting for ${snapshot.waiting}` : snapshot.activeTurnId ? "running" : state?.execution?.reason ?? "idle",
|
|
10
|
+
gaps: goal?.gaps ?? [], checks: state?.commands.map(command => `${command.status} · exit ${command.exit} · ${command.command} · ${command.source.node}/${command.source.event}`) ?? [],
|
|
11
|
+
progress: state ? [
|
|
12
|
+
progress("New observations", state.progress.observations),
|
|
13
|
+
progress("Artifact changes", state.progress.artifact_changes),
|
|
14
|
+
progress("Acceptance advances", state.progress.acceptance_advances),
|
|
15
|
+
progress("New diagnostics", state.progress.new_diagnostics),
|
|
16
|
+
progress("Unchanged retries", state.progress.unchanged_retries),
|
|
17
|
+
progress("Unknown diagnostics", state.progress.unknown_diagnostics),
|
|
18
|
+
] : [],
|
|
19
|
+
context: [`${context.used} tokens (${context.countKind}); ${context.messages.length} messages; ${context.policy}`,
|
|
20
|
+
...Object.entries(context.components).map(([name, count]) => `${name}: ~${count}`),
|
|
21
|
+
...context.messages.map(message => `#${message.index} ${message.role}: ${message.sources.join(", ")}`)],
|
|
22
|
+
omitted: context.omitted.map(event => `${event.id}: ${event.type} (original stays in log)`),
|
|
23
|
+
history: context.sections.filter(section => section.total > 0).map(section => `${section.section}: ${section.retained}/${section.total}` +
|
|
24
|
+
(section.recovery ? ` · history(${JSON.stringify(section.recovery)})` : "")),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface TurnSurface {
|
|
|
19
19
|
syncTranscript(): Promise<void>;
|
|
20
20
|
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
21
21
|
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
22
|
+
observeEvent?(event: LoopEvent): void;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface Ui extends TurnSurface {
|
|
@@ -52,6 +53,7 @@ export interface Ui extends TurnSurface {
|
|
|
52
53
|
openFork(): Promise<void>;
|
|
53
54
|
openTree(): Promise<void>;
|
|
54
55
|
openResources(): Promise<void>;
|
|
56
|
+
openControl(tab?: "goal" | "progress" | "context"): Promise<void>;
|
|
55
57
|
openTrust(): Promise<void>;
|
|
56
58
|
openAttachments(): void;
|
|
57
59
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { assertNever } from "./exhaustive.ts";
|
|
2
|
+
|
|
3
|
+
/** D19: confirmation follows the selected completion, never the unfinished draft. */
|
|
4
|
+
export function completionAction(kind: "command" | "argument" | "path", key: "enter" | "tab", requiresArgument: boolean): "insert" | "submit" {
|
|
5
|
+
if (key === "tab") return "insert";
|
|
6
|
+
switch (kind) {
|
|
7
|
+
case "command": return requiresArgument ? "insert" : "submit";
|
|
8
|
+
case "argument": return "submit";
|
|
9
|
+
case "path": return "insert";
|
|
10
|
+
default: return assertNever(kind, "completion kind");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Match the parser's exact identity before discoverable prefixes and substrings. */
|
|
15
|
+
export function commandCompletionRank(name: string, query: string): number {
|
|
16
|
+
const canonical = name.toLowerCase(), typed = query.toLowerCase();
|
|
17
|
+
if (canonical === typed) return 0;
|
|
18
|
+
if (canonical.startsWith(typed)) return 1;
|
|
19
|
+
return typed.length > 1 && canonical.includes(typed) ? 2 : Number.POSITIVE_INFINITY;
|
|
20
|
+
}
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { GuidanceEvent, LogEvent } from "./events.ts";
|
|
14
14
|
import type { AcceptanceState } from "./acceptance.ts";
|
|
15
|
-
import {
|
|
16
|
-
import { resolveEvidence } from "./evidence.ts";
|
|
15
|
+
import { deriveRequestState, type RequestState } from "./request-state.ts";
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Wording that hands the task back instead of finishing it. Ported from
|
|
@@ -40,27 +39,28 @@ export function completionReview(
|
|
|
40
39
|
remainingRequests: number,
|
|
41
40
|
options?: { strictVerification?: boolean; acceptance?: AcceptanceState; taskAware?: boolean },
|
|
42
41
|
): Pick<GuidanceEvent, "reason" | "text" | "sources"> | null {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
const state = deriveRequestState([{ node: 0, events }]);
|
|
43
|
+
const decision = state ? requestCompletion(state, remainingRequests, options) : { action: "finish" as const };
|
|
44
|
+
return decision.action === "continue" ? decision.guidance : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RequestCompletion = { action: "finish" } |
|
|
48
|
+
{ action: "continue"; guidance: Pick<GuidanceEvent, "reason" | "text" | "sources"> };
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
/** Q23: loop termination and the public request view consume the same evidence. */
|
|
51
|
+
export function requestCompletion(state: RequestState, remainingRequests: number,
|
|
52
|
+
options?: { strictVerification?: boolean; acceptance?: AcceptanceState; taskAware?: boolean }): RequestCompletion {
|
|
53
|
+
const finish = { action: "finish" as const };
|
|
54
|
+
if (remainingRequests < 1 || state.reviewed || state.status === "blocked") return finish;
|
|
55
|
+
const final = state.final;
|
|
56
|
+
if (!final || final.has_tools || state.execution?.reason !== "model_end" && state.execution?.reason !== "unknown") return finish;
|
|
57
|
+
const text = final.text;
|
|
58
|
+
const verifiedAfter = state.commands.some(command => command.status === "pass" && (!options?.strictVerification || command.gate !== undefined));
|
|
59
59
|
|
|
60
60
|
const gaps: string[] = [];
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
61
|
+
const acceptance = options?.acceptance ?? state.acceptance;
|
|
62
|
+
if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
|
|
63
|
+
if (state.changed && !verifiedAfter && !acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
|
|
64
64
|
if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
|
|
65
65
|
...acceptance.criteria.filter(item => item.kind === "command" && item.status !== "pass").map(item => `${item.id}=${item.status}`),
|
|
66
66
|
...(acceptance.targets ?? []).filter(target => target.status !== "ready").map(target => `target ${target.id}=${target.status}`),
|
|
@@ -68,15 +68,15 @@ export function completionReview(
|
|
|
68
68
|
if (text.length > 0 && looksLikeHandBack(text) && acceptance?.status !== "awaiting_human") gaps.push(options?.taskAware
|
|
69
69
|
? "the reply deferred the request; check whether authorized work remains or a specific blocker requires the user"
|
|
70
70
|
: "the reply handed the task back instead of finishing it; no one will answer");
|
|
71
|
-
if (gaps.length === 0) return
|
|
72
|
-
return {
|
|
71
|
+
if (gaps.length === 0) return finish;
|
|
72
|
+
return { action: "continue", guidance: {
|
|
73
73
|
reason: "completion_review",
|
|
74
|
-
sources: [
|
|
74
|
+
sources: [state.request.event, final.source.event],
|
|
75
75
|
text: `Harness completion check: ${gaps.join("; ")}. ` +
|
|
76
|
-
`Re-read the original request (event ${
|
|
77
|
-
(options?.taskAware &&
|
|
76
|
+
`Re-read the original request (event ${state.request.event}) and check each requirement against evidence in this session. ` +
|
|
77
|
+
(options?.taskAware && !state.changed
|
|
78
78
|
? "Use original sources for research and full inputs for calculations; do not invent command evidence. Required human acceptance remains pending. Finish with the result or the specific blocker. "
|
|
79
79
|
: "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ") +
|
|
80
80
|
"This check runs once per request.",
|
|
81
|
-
};
|
|
81
|
+
} };
|
|
82
82
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Q24: the experimental wire contract and its validator share one schema. No SDK dependency (D13). */
|
|
2
|
+
const text = { type: "string", minLength: 1, maxLength: 65536 } as const;
|
|
3
|
+
const identity = { type: "string", minLength: 1, maxLength: 128 } as const;
|
|
4
|
+
const object = (properties: Record<string, unknown>, required: readonly string[] = Object.keys(properties)) =>
|
|
5
|
+
({ type: "object", properties, required, additionalProperties: false });
|
|
6
|
+
const thread = { threadId: identity };
|
|
7
|
+
const turn = { ...thread, turnId: identity };
|
|
8
|
+
|
|
9
|
+
export const CONTROL_METHODS = {
|
|
10
|
+
initialize: object({ clientInfo: object({ name: identity, version: identity }) }),
|
|
11
|
+
initialized: object({}),
|
|
12
|
+
"thread/read": object(thread),
|
|
13
|
+
"thread/subscribe": object({ ...thread, after: object({ streamId: identity, sequence: { type: "integer", minimum: 0 } }) }, ["threadId"]),
|
|
14
|
+
"turn/start": object({ ...thread, input: text }),
|
|
15
|
+
"turn/steer": object({ ...turn, input: text }),
|
|
16
|
+
"turn/interrupt": object(turn),
|
|
17
|
+
"approval/respond": object({ ...turn, approvalId: identity, choice: { enum: ["once", "command", "tool", "deny"] } }),
|
|
18
|
+
"acceptance/accept": object({ ...thread, requestId: identity, criterionId: identity }),
|
|
19
|
+
"context/read": object({ ...thread, focus: { enum: ["messages", "omitted", "history"] }, offset: { type: "integer", minimum: 0 },
|
|
20
|
+
limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId", "focus"]),
|
|
21
|
+
"item/list": object({ ...thread, after: object({ node: { type: "integer", minimum: 0 }, event: identity }),
|
|
22
|
+
limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId"]),
|
|
23
|
+
"item/read": object({ ...thread, node: { type: "integer", minimum: 0 }, event: identity }),
|
|
24
|
+
} as const;
|
|
25
|
+
export const CONTROL_SCHEMA = { protocol: "xeno-control/0.1", experimental: true,
|
|
26
|
+
transport: "JSONL; JSON-RPC envelopes with jsonrpc omitted", methods: CONTROL_METHODS,
|
|
27
|
+
notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "thread/statusChanged"],
|
|
28
|
+
recovery: "subscribe first, then use the returned snapshot cursor; gaps or a new streamId require a snapshot and paginated item/list, never replaying commands",
|
|
29
|
+
identity: "threadId hashes the resolved session directory path; turnId is the original user event ID; item identity is node plus event ID",
|
|
30
|
+
limits: { frameBytes: 1048576, queuedRequests: 32, outputBytes: 2097152 } } as const;
|
|
31
|
+
|
|
32
|
+
export type RpcId = string | number;
|
|
33
|
+
export type ControlRequest = { id?: RpcId; method: keyof typeof CONTROL_METHODS; params: Record<string, unknown> };
|
|
34
|
+
export class ControlProtocolError extends Error {
|
|
35
|
+
readonly code: number;
|
|
36
|
+
constructor(code: number, message: string) { super(message); this.code = code; }
|
|
37
|
+
}
|
|
38
|
+
function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
39
|
+
function matches(value: unknown, schemaValue: unknown): boolean {
|
|
40
|
+
const schema = schemaValue as Record<string, unknown>;
|
|
41
|
+
if (Array.isArray(schema["enum"])) return schema["enum"].includes(value);
|
|
42
|
+
if (schema["type"] === "string") return typeof value === "string" && value.length >= Number(schema["minLength"]) && value.length <= Number(schema["maxLength"]);
|
|
43
|
+
if (schema["type"] === "integer") return typeof value === "number" && Number.isSafeInteger(value) && value >= Number(schema["minimum"]) && (schema["maximum"] === undefined || value <= Number(schema["maximum"]));
|
|
44
|
+
if (schema["type"] === "object") {
|
|
45
|
+
if (!record(value)) return false;
|
|
46
|
+
const properties = schema["properties"] as Record<string, unknown>, required = schema["required"] as string[];
|
|
47
|
+
return required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => Object.hasOwn(properties, key) && matches(value[key], properties[key]));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
export function parseControlRequest(value: unknown): ControlRequest {
|
|
52
|
+
if (!record(value) || typeof value["method"] !== "string" || Object.keys(value).some(key => !["id", "method", "params"].includes(key)))
|
|
53
|
+
throw new ControlProtocolError(-32600, "invalid request envelope");
|
|
54
|
+
const id = value["id"], method = value["method"];
|
|
55
|
+
if (id !== undefined && !(typeof id === "string" && id.length <= 128 || typeof id === "number" && Number.isSafeInteger(id)))
|
|
56
|
+
throw new ControlProtocolError(-32600, "invalid request id");
|
|
57
|
+
if (!Object.hasOwn(CONTROL_METHODS, method)) throw new ControlProtocolError(-32601, "unknown method");
|
|
58
|
+
const name = method as ControlRequest["method"], params = value["params"] ?? {};
|
|
59
|
+
if ((name === "initialized") !== (id === undefined)) throw new ControlProtocolError(-32600, "only initialized is a client notification");
|
|
60
|
+
if (!matches(params, CONTROL_METHODS[name])) throw new ControlProtocolError(-32602, `invalid parameters for ${name}`);
|
|
61
|
+
return { ...(id === undefined ? {} : { id }), method: name, params: params as Record<string, unknown> };
|
|
62
|
+
}
|