@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +21 -9
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +16 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/provider-connect.ts +4 -4
  14. package/src/apps/shell.ts +39 -20
  15. package/src/apps/startup.ts +22 -9
  16. package/src/apps/ui/app.ts +196 -26
  17. package/src/apps/ui/control-model.ts +26 -0
  18. package/src/apps/ui/surface.ts +2 -0
  19. package/src/core/completion-action.ts +20 -0
  20. package/src/core/completion-review.ts +26 -26
  21. package/src/core/control-protocol.ts +62 -0
  22. package/src/core/control-view.ts +42 -0
  23. package/src/core/event-validation.ts +1 -1
  24. package/src/core/events.ts +2 -0
  25. package/src/core/harness-diagnostics.ts +206 -0
  26. package/src/core/oauth-fields.ts +44 -0
  27. package/src/core/program-status.ts +31 -0
  28. package/src/core/project.ts +25 -1
  29. package/src/core/request-progress.ts +87 -0
  30. package/src/core/request-state.ts +114 -0
  31. package/src/core/settings.ts +7 -0
  32. package/src/core/tool-input.ts +6 -0
  33. package/src/runtime/auth.ts +10 -3
  34. package/src/runtime/catalog.ts +13 -3
  35. package/src/runtime/control-connection.ts +104 -0
  36. package/src/runtime/harness-eval.ts +3 -1
  37. package/src/runtime/loop.ts +72 -19
  38. package/src/runtime/model.ts +9 -0
  39. package/src/runtime/native-eval.ts +6 -3
  40. package/src/runtime/oauth.ts +80 -3
  41. package/src/runtime/pi-provider.ts +169 -12
  42. package/src/runtime/program-status.ts +38 -0
  43. package/src/runtime/provider-connect.ts +2 -2
  44. package/src/runtime/provider-presets.ts +1 -1
  45. package/src/runtime/provider-request.ts +13 -2
  46. package/src/runtime/provider.ts +23 -9
  47. package/src/runtime/session-control.ts +198 -0
  48. package/src/tui/ui/composer.ts +3 -2
  49. package/src/tui/ui/control-panel.ts +23 -0
  50. package/src/tui/ui/mascot.ts +1 -1
  51. package/src/tui/ui/status.ts +8 -0
@@ -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 /login custom endpoints. */
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 => resolve(value), onCancel: () => resolve(undefined) }));
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
- return new Promise(resolve => this.screen.dialogs.push({ kind: "confirm", title, message, onAnswer: yes => resolve(yes) }));
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 { this.abort?.abort(); this.returnQueueToEditor(); }
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 · /login"].filter(Boolean).join(" · ");
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 { this.derived = handoffStatus(await this.app.session.previewHandoff()); this.derivedKey = target; }
575
- catch { /* preview needs a readable log; the status keeps its last state */ }
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") { composer.acceptCompletion(); this.updateCompletion(); this.paint(); return true; }
611
- if (key.chord === "enter" && /^\/\S*$/.test(composer.text)) {
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(item.value.trim());
615
- if (definition && "argumentHint" in definition && definition.argumentHint?.startsWith("<")) { this.updateCompletion(); return true; }
616
- this.track(() => this.submit(item.value, "steering")); return true;
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 answered = (choice: ApprovalChoice) => { this.screen.messages.setWaiting(false); return choice; };
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
- this.screen.dialogs.push({ kind: "list", title: "Tool approval", search: false, tone: "warning", intro: approvalHeading(request).split("\n"),
964
- items: approvalItems(request), onPick: item => resolve(answered(choiceFromValue(item.value))), onCancel: () => resolve(answered("deny")) });
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 /login to add a credential.",
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 ui = new InteractiveApp(app, hooks, r);
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
+ }
@@ -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 { changedPaths } from "./checkpoints.ts";
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
- if (remainingRequests < 1) return null;
44
- const start = events.findLastIndex((event) => event.t === "user");
45
- if (start < 0) return null;
46
- const current = events.slice(start);
47
- if (current.some((event) => event.t === "guidance" && event.reason === "completion_review")) return null;
48
- const final = current.findLast((event) => event.t === "assistant");
49
- if (final?.t !== "assistant" || final.blocks.some((block) => block.type === "tool_use")) return null;
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
- const text = final.blocks.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n").trim();
52
- const usedTools = current.some((event) => event.t === "tool_result");
53
- const lastEdit = current.findLastIndex((event) => event.t === "tool_result" && event.effect?.kind === "edit" ||
54
- event.t === "checkpoint" && event.phase === "applied" && changedPaths(event.changes).length > 0);
55
- const verifiedAfter = lastEdit >= 0 && current.slice(lastEdit + 1)
56
- .some((event) => event.t === "tool_result" && event.effect?.kind === "command" && event.effect.exit === 0 &&
57
- resolveEvidence(current, { command: event.effect.cmd }).ok &&
58
- (!options?.strictVerification || event.effect.gate !== undefined));
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
- if (usedTools && text.length === 0) gaps.push("the final reply is empty");
62
- if (lastEdit >= 0 && !verifiedAfter && !options?.acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
63
- const acceptance = options?.acceptance;
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 null;
72
- return {
71
+ if (gaps.length === 0) return finish;
72
+ return { action: "continue", guidance: {
73
73
  reason: "completion_review",
74
- sources: [current[0]!.id, final.id],
74
+ sources: [state.request.event, final.source.event],
75
75
  text: `Harness completion check: ${gaps.join("; ")}. ` +
76
- `Re-read the original request (event ${current[0]!.id}) and check each requirement against evidence in this session. ` +
77
- (options?.taskAware && lastEdit < 0
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
+ }