@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Q24: one controller per live Session, shared by the TUI and structured adapters. */
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { goalView, contextLens, type GoalView, type ContextLens } from "../core/control-view.ts";
|
|
5
|
+
import { DEFAULT_CONTEXT_POLICY } from "../core/context-policy.ts";
|
|
6
|
+
import { userInputData, type UserInput } from "../core/images.ts";
|
|
7
|
+
import type { Phase } from "../core/queue.ts";
|
|
8
|
+
import type { PermissionRequest } from "../core/permissions.ts";
|
|
9
|
+
import type { ExecutionOutcome } from "../core/outcome.ts";
|
|
10
|
+
import type { LogEvent } from "../core/events.ts";
|
|
11
|
+
import { readNodeHistory } from "../store/restore.ts";
|
|
12
|
+
import type { Session, LoopEvent } from "./loop.ts";
|
|
13
|
+
import type { ApprovalChoice } from "./tools.ts";
|
|
14
|
+
|
|
15
|
+
export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
|
|
16
|
+
export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
|
|
17
|
+
export type ControlSnapshot = {
|
|
18
|
+
version: "xeno-control/0.1"; threadId: string; streamId: string; sequence: number; node: number;
|
|
19
|
+
activeTurnId: string | null; goal: GoalView | null; context: ContextLens;
|
|
20
|
+
waiting: "permission" | "question" | "auth" | null; approvals: PendingApproval[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SessionControl {
|
|
24
|
+
readonly session: Session;
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly streamId = randomUUID();
|
|
27
|
+
private sequence = 0;
|
|
28
|
+
private listeners = new Set<(event: ControlNotice) => void>();
|
|
29
|
+
private active: { id: string; abort: AbortController; queue: UserInput[] } | null = null;
|
|
30
|
+
private task: Promise<void> | null = null;
|
|
31
|
+
private approvals = new Map<string, { data: PendingApproval; resolve: (choice: ApprovalChoice) => void }>();
|
|
32
|
+
private waiting: ControlSnapshot["waiting"] = null;
|
|
33
|
+
private changing = false;
|
|
34
|
+
private inputSource: ((phase: Phase) => Promise<UserInput | null>) | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(session: Session) {
|
|
37
|
+
this.session = session;
|
|
38
|
+
// Same identity rule as provider affinity; no new durable identity store (D8).
|
|
39
|
+
this.threadId = createHash("sha256").update(resolve(session.config.logDir)).digest("hex");
|
|
40
|
+
session.subscribeRecords(item => this.emit("item/completed", { item }));
|
|
41
|
+
}
|
|
42
|
+
get activeTurnId(): string | null { return this.active?.id ?? null; }
|
|
43
|
+
get busy(): boolean { return this.changing || this.active !== null || this.session.running; }
|
|
44
|
+
|
|
45
|
+
subscribe(listener: (event: ControlNotice) => void): () => void {
|
|
46
|
+
this.listeners.add(listener); return () => { this.listeners.delete(listener); };
|
|
47
|
+
}
|
|
48
|
+
/** The attached editor owns retractable drafts even when an adapter starts the turn. */
|
|
49
|
+
attachInput(pull: (phase: Phase) => Promise<UserInput | null>): () => void {
|
|
50
|
+
if (this.inputSource) throw Error("the session already has an attached input owner");
|
|
51
|
+
this.inputSource = pull;
|
|
52
|
+
return () => { if (this.inputSource === pull) this.inputSource = undefined; };
|
|
53
|
+
}
|
|
54
|
+
private emit(method: string, data: Record<string, unknown> = {}): void {
|
|
55
|
+
const event: ControlNotice = { method, params: { ...data, threadId: this.threadId, streamId: this.streamId,
|
|
56
|
+
sequence: ++this.sequence, ...(this.active ? { turnId: this.active.id } : {}) } };
|
|
57
|
+
for (const listener of this.listeners) {
|
|
58
|
+
// Observers are outside the evidence owner: mutations and failures cannot alter execution.
|
|
59
|
+
try { listener(structuredClone(event)); } catch { /* the transport owns delivery errors */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async snapshot(observe = true): Promise<ControlSnapshot> {
|
|
63
|
+
if (observe && !this.busy) await this.session.acceptanceState();
|
|
64
|
+
const node = this.session.state.node;
|
|
65
|
+
const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
|
|
66
|
+
if (!history.ok) throw Error(history.error);
|
|
67
|
+
if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
|
|
68
|
+
const s = this.session;
|
|
69
|
+
// Ancestors are sealed. The live segment must share the cursor's publication
|
|
70
|
+
// boundary: a disk read can finish between append and in-memory publication.
|
|
71
|
+
const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
|
|
72
|
+
const projectedGoal = goalView(parts);
|
|
73
|
+
const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
|
|
74
|
+
return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
|
|
75
|
+
activeTurnId: this.activeTurnId, goal, waiting: this.waiting,
|
|
76
|
+
approvals: structuredClone([...this.approvals.values()].map(item => item.data)),
|
|
77
|
+
context: contextLens({ ...s.contextInput(), anchor: s.state.anchor, policy: s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
78
|
+
contextWindow: s.provider.config.spec.contextWindow, outputReserve: s.provider.config.maxTokens,
|
|
79
|
+
...(s.config.counter ? { counter: s.config.counter } : {}), ...(s.config.boundary ? { boundary: s.config.boundary } : {}) }) };
|
|
80
|
+
}
|
|
81
|
+
async items(): Promise<{ node: number; event: LogEvent }[]> {
|
|
82
|
+
const node = this.session.state.node;
|
|
83
|
+
const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
|
|
84
|
+
if (!history.ok) throw Error(history.error);
|
|
85
|
+
if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
|
|
86
|
+
return history.nodes.flatMap(part => (part.node === node ? structuredClone(this.session.state.events) : part.events)
|
|
87
|
+
.map(event => ({ node: part.node, event })));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Both local and remote turns pass through this iterator; Session remains the execution owner. */
|
|
91
|
+
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>): AsyncGenerator<LoopEvent> {
|
|
92
|
+
if (this.busy) throw Error("a turn is already running");
|
|
93
|
+
const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
|
|
94
|
+
this.active = active;
|
|
95
|
+
const signals = signal ? AbortSignal.any([signal, active.abort.signal]) : active.abort.signal;
|
|
96
|
+
let outcome: ExecutionOutcome | undefined;
|
|
97
|
+
try {
|
|
98
|
+
this.emit("turn/started");
|
|
99
|
+
const steering = async (phase: Phase): Promise<UserInput | null> => {
|
|
100
|
+
const external = active.queue.splice(0), local = await (pull ?? this.inputSource)?.(phase);
|
|
101
|
+
if (local) external.push(local);
|
|
102
|
+
const data = external.map(value => userInputData(value));
|
|
103
|
+
const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
|
|
104
|
+
return images.length ? { text, images } : text || null;
|
|
105
|
+
};
|
|
106
|
+
for await (const event of this.session.send(input, signals, steering, active.id)) {
|
|
107
|
+
this.emit("turn/event", { event });
|
|
108
|
+
if (event.kind === "done") outcome = event.outcome;
|
|
109
|
+
yield event;
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Raw provider errors may contain credentials. The owner can report a sanitized error separately.
|
|
113
|
+
this.emit("turn/error", { message: "turn failed; inspect the session outcome" });
|
|
114
|
+
throw error;
|
|
115
|
+
} finally {
|
|
116
|
+
for (const approval of [...this.approvals.values()]) approval.resolve("deny");
|
|
117
|
+
this.waiting = null;
|
|
118
|
+
// A thrown provider error also appends an outcome. Clients must be able to discover it.
|
|
119
|
+
try {
|
|
120
|
+
if (!outcome) {
|
|
121
|
+
const state = (await this.snapshot(false)).goal?.state;
|
|
122
|
+
if (state?.request.event === active.id) outcome = state.execution;
|
|
123
|
+
}
|
|
124
|
+
} catch { /* unreadable storage stays unknown, never an invented completion */ }
|
|
125
|
+
this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length });
|
|
126
|
+
this.active = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
start(input: UserInput): string {
|
|
130
|
+
if (this.busy) throw Error("a turn is already running");
|
|
131
|
+
this.task = (async () => { for await (const _event of this.send(input)) { /* notices are the client stream */ } })();
|
|
132
|
+
void this.task.catch(() => {});
|
|
133
|
+
return this.active!.id;
|
|
134
|
+
}
|
|
135
|
+
async whenIdle(): Promise<void> { await this.task; await this.session.whenIdle(); }
|
|
136
|
+
private current(turnId: string): NonNullable<SessionControl["active"]> {
|
|
137
|
+
if (!this.active || this.active.id !== turnId) throw Error("stale or unknown turnId");
|
|
138
|
+
return this.active;
|
|
139
|
+
}
|
|
140
|
+
steer(turnId: string, input: UserInput): void {
|
|
141
|
+
const turn = this.current(turnId);
|
|
142
|
+
// Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
|
|
143
|
+
if (turn.queue.length >= 32) throw Error("steering queue is full");
|
|
144
|
+
turn.queue.push(structuredClone(input));
|
|
145
|
+
this.emit("turn/steeringQueued", { count: turn.queue.length });
|
|
146
|
+
}
|
|
147
|
+
interrupt(turnId: string): void {
|
|
148
|
+
this.current(turnId).abort.abort();
|
|
149
|
+
for (const approval of [...this.approvals.values()]) approval.resolve("deny");
|
|
150
|
+
this.session.cancel();
|
|
151
|
+
}
|
|
152
|
+
setWaiting(kind: ControlSnapshot["waiting"]): void {
|
|
153
|
+
this.waiting = this.approvals.size ? "permission" : kind;
|
|
154
|
+
this.emit("thread/statusChanged", { waiting: this.waiting });
|
|
155
|
+
}
|
|
156
|
+
ask(request: PermissionRequest, present?: (id: string, resolve: (choice: ApprovalChoice) => void) => (() => void)): Promise<ApprovalChoice> {
|
|
157
|
+
if (!this.active) return Promise.resolve("deny");
|
|
158
|
+
const data = { id: randomUUID(), turnId: this.active.id, request: structuredClone(request) };
|
|
159
|
+
return new Promise(resolveChoice => {
|
|
160
|
+
let close: (() => void) | undefined;
|
|
161
|
+
const resolveOnce = (choice: ApprovalChoice) => {
|
|
162
|
+
if (!this.approvals.delete(data.id)) return;
|
|
163
|
+
close?.(); this.waiting = this.approvals.size ? "permission" : null;
|
|
164
|
+
this.emit("approval/resolved", { approvalId: data.id }); resolveChoice(choice);
|
|
165
|
+
};
|
|
166
|
+
this.approvals.set(data.id, { data, resolve: resolveOnce }); this.waiting = "permission";
|
|
167
|
+
this.emit("approval/request", { approval: data });
|
|
168
|
+
if (this.approvals.has(data.id)) {
|
|
169
|
+
try { close = present?.(data.id, resolveOnce); }
|
|
170
|
+
catch { resolveOnce("deny"); }
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
respond(turnId: string, id: string, choice: ApprovalChoice): void {
|
|
175
|
+
this.current(turnId);
|
|
176
|
+
const approval = this.approvals.get(id);
|
|
177
|
+
if (!approval || approval.data.turnId !== turnId) throw Error("stale or unknown approvalId");
|
|
178
|
+
approval.resolve(choice);
|
|
179
|
+
}
|
|
180
|
+
denyApprovals(): void { for (const approval of [...this.approvals.values()]) approval.resolve("deny"); }
|
|
181
|
+
async accept(requestId: string, criterion: string): Promise<void> {
|
|
182
|
+
if (this.busy) throw Error("acceptance requires an idle session");
|
|
183
|
+
this.changing = true;
|
|
184
|
+
try {
|
|
185
|
+
const state = await this.session.requestState();
|
|
186
|
+
if (state?.request.event !== requestId) throw Error("stale or unknown requestId");
|
|
187
|
+
await this.session.acceptCriterion(criterion);
|
|
188
|
+
this.emit("thread/statusChanged");
|
|
189
|
+
} finally { this.changing = false; }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const controls = new WeakMap<Session, SessionControl>();
|
|
194
|
+
export function sessionControl(session: Session): SessionControl {
|
|
195
|
+
let control = controls.get(session);
|
|
196
|
+
if (!control) { control = new SessionControl(session); controls.set(session, control); }
|
|
197
|
+
return control;
|
|
198
|
+
}
|
package/src/tui/ui/composer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { draftRows, type DraftRows } from "./draft.ts";
|
|
|
16
16
|
import type { ListItem } from "./list-model.ts";
|
|
17
17
|
|
|
18
18
|
export interface Completion {
|
|
19
|
+
readonly kind: "command" | "argument" | "path";
|
|
19
20
|
/** Replace text[start, end) with an item's value. */
|
|
20
21
|
readonly start: number;
|
|
21
22
|
readonly end: number;
|
|
@@ -89,8 +90,8 @@ export class Composer {
|
|
|
89
90
|
setAtoms(spans: (text: string) => readonly DraftAtom[]): void { this.area.atoms = spans; }
|
|
90
91
|
get completing(): boolean { return this.completion !== undefined && this.completion.items.length > 0; }
|
|
91
92
|
/** What the completion list shows (screen draws it). */
|
|
92
|
-
get completionView(): { readonly items: readonly ListItem[]; readonly selected: number } | undefined {
|
|
93
|
-
return this.completion ? { items: this.completion.items, selected: this.completionIndex } : undefined;
|
|
93
|
+
get completionView(): { readonly kind: Completion["kind"]; readonly items: readonly ListItem[]; readonly selected: number } | undefined {
|
|
94
|
+
return this.completion ? { kind: this.completion.kind, items: this.completion.items, selected: this.completionIndex } : undefined;
|
|
94
95
|
}
|
|
95
96
|
get historyEntries(): readonly string[] { return this.history; }
|
|
96
97
|
get rows(): number { return Math.max(1, this.area.height); }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** View-only data: apps owns the projection and actions, as required by D20. */
|
|
2
|
+
import { clipText } from "./edge.ts";
|
|
3
|
+
|
|
4
|
+
export type ControlPanelModel = {
|
|
5
|
+
thread: string; turn: string | null; goal: string; status: string; execution: string; gaps: readonly string[];
|
|
6
|
+
progress: readonly { label: string; count: number; sources: readonly string[] }[]; checks: readonly string[];
|
|
7
|
+
context: readonly string[]; omitted: readonly string[]; history: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
export type ControlTab = "goal" | "progress" | "context";
|
|
10
|
+
export type ControlFocus = "messages" | "omitted" | "history";
|
|
11
|
+
|
|
12
|
+
export function controlPanelLines(model: ControlPanelModel, tab: ControlTab, width: number, focus: ControlFocus = "messages"): string[] {
|
|
13
|
+
const lines = [`${tab === "goal" ? "●" : "○"} Goal ${tab === "progress" ? "●" : "○"} Progress ${tab === "context" ? "●" : "○"} Context Lens`, "",
|
|
14
|
+
...(tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
|
|
15
|
+
...model.checks, "", `Thread ${model.thread}`, `Turn ${model.turn ?? "none"}`]
|
|
16
|
+
: tab === "progress" ? ["Evidence for this request", ...model.progress.map(item => `${item.label}: ${item.count}`), "",
|
|
17
|
+
"Counts describe observations, not a completion percentage.", ...model.checks,
|
|
18
|
+
...model.progress.flatMap(item => item.sources.map(source => `${item.label}: ${source}`))]
|
|
19
|
+
: focus === "messages" ? ["Actual model context · ←/→ change lens", ...model.context]
|
|
20
|
+
: focus === "omitted" ? ["Not replayed as messages · ←/→ change lens", ...(model.omitted.length ? model.omitted : ["No omitted events in this segment."])]
|
|
21
|
+
: ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])])];
|
|
22
|
+
return lines.map(line => clipText(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(1, width)));
|
|
23
|
+
}
|
package/src/tui/ui/mascot.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function greetingLines(greeting: Greeting, frame: readonly string[] = MAS
|
|
|
61
61
|
[art(0), gap, { text: "xeno", role: "text", bold: true }, { text: ` ${greeting.version}`, role: "muted" }],
|
|
62
62
|
[art(1), gap, ...(greeting.model
|
|
63
63
|
? [{ text: greeting.model, role: "text" as const }, { text: ` · ${greeting.thinking}`, role: "muted" as const }]
|
|
64
|
-
: [{ text: "No provider connected yet · /
|
|
64
|
+
: [{ text: "No provider connected yet · /provider", role: "warning" as const }])],
|
|
65
65
|
[art(2), gap, { text: greeting.workspace, role: "muted" }, ...(greeting.branch ? [{ text: ` ⎇ ${greeting.branch}`, role: "muted" as const }] : [])],
|
|
66
66
|
];
|
|
67
67
|
}
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface StatusModel {
|
|
|
30
30
|
readonly version: string;
|
|
31
31
|
/** `/name`, else the first prompt; null for a session with neither. */
|
|
32
32
|
readonly title: string | null;
|
|
33
|
+
readonly goal?: { readonly text: string; readonly status: string; readonly execution: string; readonly observations: number; readonly retries: number };
|
|
33
34
|
readonly workspace: string;
|
|
34
35
|
readonly workspaceShort: string;
|
|
35
36
|
readonly branch: string | null;
|
|
@@ -176,6 +177,13 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
|
|
|
176
177
|
...(model.branch ? [[{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }] as Line] : []),
|
|
177
178
|
] });
|
|
178
179
|
const context = model.context;
|
|
180
|
+
if (model.goal) blocks.push({ list: false, items: [], head: [
|
|
181
|
+
[{ text: "Goal", role: "text", bold: true }, { text: ` · ${model.goal.status}`, role: model.goal.status === "verified" ? "success" : "warning" }],
|
|
182
|
+
...wrap(firstLine(model.goal.text), w, 2).map(text => [{ text, role: "text" }] as Line),
|
|
183
|
+
[{ text: model.goal.execution, role: "muted" }],
|
|
184
|
+
[{ text: `${model.goal.observations} observations · ${model.goal.retries} retries`, role: "muted" }],
|
|
185
|
+
[{ text: "/control · goal, evidence, context", role: "muted" }],
|
|
186
|
+
] });
|
|
179
187
|
if (context) {
|
|
180
188
|
const share = contextShare(context), est = context.estimated ? "~" : "";
|
|
181
189
|
const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));
|