@visual-z/xeno 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
package/src/apps/ui/app.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { DISPLAY_SETTINGS, settingValue, settingValues } from "../../core/settin
|
|
|
29
29
|
import { THINKING_LEVELS, asThinkingLevel, supportedThinkingLevels } from "../../core/thinking.ts";
|
|
30
30
|
import { builtinTheme } from "../../core/theme.ts";
|
|
31
31
|
import { projectTranscript, type TranscriptItem } from "../../core/transcript.ts";
|
|
32
|
+
import { DEFAULT_FULLSCREEN_EXIT_OUTPUT, sessionExitStatus } from "../../core/session-exit.ts";
|
|
32
33
|
import { lastAssistantText } from "../../core/export.ts";
|
|
33
34
|
import type { PermissionRequest } from "../../core/permissions.ts";
|
|
34
35
|
import type { ApprovalChoice } from "../../runtime/tools.ts";
|
|
@@ -38,14 +39,16 @@ import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../../runt
|
|
|
38
39
|
import { SOURCE_PROVIDERS } from "../../runtime/model-data.ts";
|
|
39
40
|
import { SUBSCRIPTION_OPTIONS, subscriptionById, loginGitHubCopilot, startCallbackServer, XENO_VERSION } from "../../runtime/oauth.ts";
|
|
40
41
|
import { reportedCost } from "../../runtime/usage.ts";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
42
|
+
import { UpdateChecks } from "../update-check.ts";
|
|
43
|
+
import { updateNotice } from "../../core/update-check.ts";
|
|
43
44
|
import { isUpgrade } from "../../core/version.ts";
|
|
44
|
-
import { packageLocation, readUpdateCache, writeUpdateCache } from "../../store/update-cache.ts";
|
|
45
45
|
import type { Effort } from "../../runtime/model.ts";
|
|
46
46
|
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
47
47
|
import { readNodeHistory } from "../../store/restore.ts";
|
|
48
|
-
import {
|
|
48
|
+
import { readGitStatus } from "../../store/git-status.ts";
|
|
49
|
+
import type { GitStatus } from "../../core/git-status.ts";
|
|
50
|
+
import { activityStatus } from "../../core/activity-status.ts";
|
|
51
|
+
import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
|
|
49
52
|
import { projectTrust } from "../../store/trust.ts";
|
|
50
53
|
import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
|
|
51
54
|
import { nextResourceOverride } from "../../core/resource-selection.ts";
|
|
@@ -108,6 +111,11 @@ function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
|
|
|
108
111
|
return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
/** Only the endpoint host is chrome; credentials, query and path stay out of it. */
|
|
115
|
+
function endpointHost(baseUrl: string | undefined): string | null {
|
|
116
|
+
try { return baseUrl ? new URL(baseUrl).host : null; } catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
export class InteractiveApp implements Ui {
|
|
112
120
|
readonly app: App;
|
|
113
121
|
readonly screen: Screen;
|
|
@@ -142,6 +150,11 @@ export class InteractiveApp implements Ui {
|
|
|
142
150
|
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
143
151
|
private bashDecoder = new TextDecoder();
|
|
144
152
|
private branch: string | null;
|
|
153
|
+
private git: GitStatus | null = null;
|
|
154
|
+
private gitRun: Promise<void> | undefined;
|
|
155
|
+
private gitAgain = false;
|
|
156
|
+
private attention: ControlSnapshot["waiting"] = null;
|
|
157
|
+
private lastOutcome: ExecutionOutcome | undefined;
|
|
145
158
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
146
159
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
147
160
|
private derived: ReturnType<typeof handoffStatus> | undefined;
|
|
@@ -178,8 +191,9 @@ export class InteractiveApp implements Ui {
|
|
|
178
191
|
};
|
|
179
192
|
/** Decoded pictures shared by every node that shows them (feat-tui-inline-images). */
|
|
180
193
|
private readonly images = new ImageCache(() => this.app.blobDir);
|
|
181
|
-
/**
|
|
182
|
-
private readonly
|
|
194
|
+
/** Shared update requests are aborted on close so quitting never waits on the registry. */
|
|
195
|
+
private readonly updateChecks = new UpdateChecks();
|
|
196
|
+
private manualUpdateRequested = false;
|
|
183
197
|
/** The terminal finished answering capability queries: picture support may have changed. */
|
|
184
198
|
private readonly capabilitiesChanged = (): void => this.applyPictures();
|
|
185
199
|
/** What the terminal reported (OSC 4/10/11); null until it answers, or forever if it never does. */
|
|
@@ -211,7 +225,7 @@ export class InteractiveApp implements Ui {
|
|
|
211
225
|
this.program = program;
|
|
212
226
|
this.stopOsc = program ? renderer.subscribeOsc(sequence => program.receive(sequence)) : undefined;
|
|
213
227
|
this.closed = new Promise(resolve => { this.closedResolve = resolve; });
|
|
214
|
-
this.branch =
|
|
228
|
+
this.branch = null;
|
|
215
229
|
this.sessionName = app.args.sessionName || null;
|
|
216
230
|
this.thinkingExpanded = app.ui?.settings.hideThinkingBlock === false;
|
|
217
231
|
this.screen = new Screen(renderer, this.colors(), () => this.keymap().byAction, {
|
|
@@ -325,7 +339,7 @@ export class InteractiveApp implements Ui {
|
|
|
325
339
|
this.requestDerived();
|
|
326
340
|
this.paint(true);
|
|
327
341
|
// Never awaited: startup and exit do not wait on the network (feat-update-check DoD 3).
|
|
328
|
-
void this.
|
|
342
|
+
void this.checkForUpdateOnStartup();
|
|
329
343
|
}
|
|
330
344
|
|
|
331
345
|
/**
|
|
@@ -334,25 +348,24 @@ export class InteractiveApp implements Ui {
|
|
|
334
348
|
* and is shown now only while the session is still empty, so it never
|
|
335
349
|
* interrupts work. xeno names the update command and never runs it.
|
|
336
350
|
*/
|
|
337
|
-
private async
|
|
351
|
+
private async checkForUpdateOnStartup(): Promise<void> {
|
|
338
352
|
try {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
if (plan.notice && !this.disposed && !this.inflight) this.notice(plan.notice);
|
|
345
|
-
if (!plan.check) return;
|
|
346
|
-
const latest = await fetchLatestVersion(process.env["npm_config_registry"], { signal: this.updateRequest.signal });
|
|
347
|
-
// Quitting cut the request short: that is no answer, so the next start asks again.
|
|
348
|
-
if (this.disposed) return;
|
|
349
|
-
// A failed attempt is recorded too, so a closed network is not retried on every start.
|
|
350
|
-
const known = latest ?? cache?.latest;
|
|
351
|
-
await writeUpdateCache({ checkedAt: Date.now(), ...(known ? { latest: known } : {}) });
|
|
352
|
-
if (!plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, install));
|
|
353
|
+
const { input, plan } = await this.updateChecks.prepare({ offline: this.app.args.offline === true, enabled: this.app.ui?.settings.updateCheck });
|
|
354
|
+
if (plan.notice && !this.manualUpdateRequested && !this.disposed && !this.inflight) this.notice(plan.notice);
|
|
355
|
+
if (!plan.check || this.manualUpdateRequested) return;
|
|
356
|
+
const latest = await this.updateChecks.refresh(input);
|
|
357
|
+
if (!this.manualUpdateRequested && !plan.notice && latest && isUpgrade(latest, XENO_VERSION) && this.emptySession()) this.notice(updateNotice(latest, XENO_VERSION, input.install));
|
|
353
358
|
} catch { /* an update hint is never worth an error */ }
|
|
354
359
|
}
|
|
355
360
|
|
|
361
|
+
checkForUpdate(): void {
|
|
362
|
+
// The explicit action owns its result even when it joins a startup request.
|
|
363
|
+
this.manualUpdateRequested = true;
|
|
364
|
+
void this.updateChecks.manual({ offline: this.app.args.offline === true }, text => {
|
|
365
|
+
if (!this.disposed) this.notice(text);
|
|
366
|
+
}).catch(() => { if (!this.disposed) this.notice("Could not check for xeno updates. Try /update again.", "error"); });
|
|
367
|
+
}
|
|
368
|
+
|
|
356
369
|
private emptySession(): boolean {
|
|
357
370
|
return !this.disposed && !this.inflight && !this.transcriptItems.some(item => item.kind === "user" || item.kind === "assistant" || item.kind === "tool" || item.kind === "shell");
|
|
358
371
|
}
|
|
@@ -394,7 +407,7 @@ export class InteractiveApp implements Ui {
|
|
|
394
407
|
this.renderer.off("palette", this.paletteChanged);
|
|
395
408
|
this.renderer.off("selection", this.selectionFinished);
|
|
396
409
|
this.renderer.off("capabilities", this.capabilitiesChanged);
|
|
397
|
-
this.
|
|
410
|
+
this.updateChecks.cancel();
|
|
398
411
|
this.images.dispose();
|
|
399
412
|
this.screen.dispose();
|
|
400
413
|
delete this.app.askSlot.current;
|
|
@@ -418,7 +431,7 @@ export class InteractiveApp implements Ui {
|
|
|
418
431
|
// A different session: its notices, queue and draft attachments do not carry over.
|
|
419
432
|
if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
|
|
420
433
|
this.clearStream();
|
|
421
|
-
this.
|
|
434
|
+
void this.refreshGit();
|
|
422
435
|
this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
|
|
423
436
|
this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
|
|
424
437
|
void this.refreshFileIndex();
|
|
@@ -459,7 +472,8 @@ export class InteractiveApp implements Ui {
|
|
|
459
472
|
} else {
|
|
460
473
|
this.stopRetry();
|
|
461
474
|
if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
|
|
462
|
-
else this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
|
|
475
|
+
else if (progress.kind === "output") this.live.output = `${this.live.output}${progress.text}`.slice(-64 * 1024);
|
|
476
|
+
else this.live.output = progress.active ? `Waiting for process ${progress.session} · Esc to interrupt` : "";
|
|
463
477
|
}
|
|
464
478
|
this.paint();
|
|
465
479
|
}
|
|
@@ -543,9 +557,19 @@ export class InteractiveApp implements Ui {
|
|
|
543
557
|
const t = app.session.totals, cost = reportedCost(t, spec.price);
|
|
544
558
|
const tools = items.filter(item => item.kind === "tool");
|
|
545
559
|
const extension = [this.extensions.status, app.credential.ok ? "" : "no provider · /provider"].filter(Boolean).join(" · ");
|
|
560
|
+
const pending = tools.find(tool => tool.status === "pending");
|
|
561
|
+
const phase = this.executionKind === "bash" || pending ? "tool" : this.live.text ? "streaming" : this.live.thinking ? "thinking" : "connecting";
|
|
562
|
+
const activity = activityStatus({ running: this.inflight, waiting: this.attention ?? (!app.credential.ok ? "auth" : null), queued: this.queue.length, phase,
|
|
563
|
+
...(pending ? { tool: pending.name } : this.executionKind === "bash" ? { tool: "bash" } : {}),
|
|
564
|
+
...(this.lastOutcome ? { outcome: this.lastOutcome } : this.controlSnapshot?.goal?.state.execution ? { outcome: this.controlSnapshot.goal.state.execution } : {}),
|
|
565
|
+
...(this.controlSnapshot?.goal ? { goal: this.controlSnapshot.goal.state.status } : {}),
|
|
566
|
+
...(this.live.retryUntil ? { retry: { attempt: this.live.retryAttempt, seconds: (this.live.retryUntil - Date.now()) / 1000 } } : {}) });
|
|
546
567
|
return {
|
|
547
568
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
548
569
|
workspaceShort: shortPath(displayPath(app.workspace, homedir())), branch: this.branch,
|
|
570
|
+
mode: { yolo: app.args.yolo, workflow: app.session.config.tools.workflowMode ?? "build" },
|
|
571
|
+
provider: { name: spec.provider, api: spec.api, host: endpointHost(spec.baseUrl), role: app.args.role?.profile.name ?? app.args.agent ?? null, ready: app.credential.ok },
|
|
572
|
+
activity, git: this.git,
|
|
549
573
|
...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
|
|
550
574
|
...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
|
|
551
575
|
// Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
|
|
@@ -569,6 +593,18 @@ export class InteractiveApp implements Ui {
|
|
|
569
593
|
this.renderer.setTerminalTitle(title);
|
|
570
594
|
}
|
|
571
595
|
|
|
596
|
+
private async refreshGit(): Promise<void> {
|
|
597
|
+
if (this.disposed) return;
|
|
598
|
+
if (this.gitRun) { this.gitAgain = true; return; }
|
|
599
|
+
const workspace = this.app.workspace;
|
|
600
|
+
this.gitRun = (async () => {
|
|
601
|
+
const status = await readGitStatus(workspace);
|
|
602
|
+
if (!this.disposed && this.app.workspace === workspace) { this.git = status; this.branch = status?.branch ?? null; this.paint(); }
|
|
603
|
+
})();
|
|
604
|
+
try { await this.gitRun; }
|
|
605
|
+
finally { this.gitRun = undefined; if (this.gitAgain) { this.gitAgain = false; void this.refreshGit(); } }
|
|
606
|
+
}
|
|
607
|
+
|
|
572
608
|
private liveStream(): LiveStream | undefined {
|
|
573
609
|
const l = this.live;
|
|
574
610
|
if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
|
|
@@ -794,7 +830,8 @@ export class InteractiveApp implements Ui {
|
|
|
794
830
|
const shell = command.text.startsWith("!");
|
|
795
831
|
const images = shell ? [] : input.images;
|
|
796
832
|
if (shell) this.imageDraft.insertImages(input.images);
|
|
797
|
-
if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
|
|
833
|
+
if (this.inflight) { this.queue.push({ kind: shell ? "followup" : kind, text: command.text, ...(images.length ? { images } : {}) });
|
|
834
|
+
if (!shell && kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); return; }
|
|
798
835
|
this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
|
|
799
836
|
this.paint();
|
|
800
837
|
}
|
|
@@ -802,6 +839,7 @@ export class InteractiveApp implements Ui {
|
|
|
802
839
|
private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
|
|
803
840
|
this.abort = new AbortController();
|
|
804
841
|
this.inflight = true;
|
|
842
|
+
this.lastOutcome = undefined;
|
|
805
843
|
this.programResult = false;
|
|
806
844
|
this.program?.update(programStatus({ running: true }));
|
|
807
845
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
@@ -814,16 +852,18 @@ export class InteractiveApp implements Ui {
|
|
|
814
852
|
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
815
853
|
} catch (error) {
|
|
816
854
|
this.programResult = true;
|
|
855
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
817
856
|
this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
818
857
|
throw error;
|
|
819
858
|
} finally {
|
|
859
|
+
if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
|
|
820
860
|
if (!this.programResult) this.program?.update(programStatus({ running: false }));
|
|
821
861
|
await this.syncTranscript();
|
|
822
862
|
this.abort = null;
|
|
823
863
|
this.hooks.dropFileIndex(this.app.workspace);
|
|
824
864
|
this.inflight = false;
|
|
825
865
|
this.executionKind = "idle";
|
|
826
|
-
this.
|
|
866
|
+
void this.refreshGit();
|
|
827
867
|
this.clearStream();
|
|
828
868
|
this.requestDerived();
|
|
829
869
|
this.paint();
|
|
@@ -955,7 +995,7 @@ export class InteractiveApp implements Ui {
|
|
|
955
995
|
this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
|
|
956
996
|
}
|
|
957
997
|
deliverExtension(text: string, kind: QueueKind): void {
|
|
958
|
-
if (this.inflight) { this.queue.push({ kind, text }); this.paint(); }
|
|
998
|
+
if (this.inflight) { this.queue.push({ kind, text }); if (kind === "steering") this.app.session.wakeProcessWaits(); this.paint(); }
|
|
959
999
|
else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
|
|
960
1000
|
}
|
|
961
1001
|
|
|
@@ -994,10 +1034,12 @@ export class InteractiveApp implements Ui {
|
|
|
994
1034
|
const requested = terminal?.imageProtocol ?? "auto";
|
|
995
1035
|
const resolution = this.renderer.resolution;
|
|
996
1036
|
const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
|
|
997
|
-
const show = terminal?.showImages !== false &&
|
|
1037
|
+
const show = terminal?.showImages !== false && effective !== "blocks";
|
|
1038
|
+
const reason = terminal?.showImages === false ? "preview off" : requested === "sixel" && resolution === null ? "waiting for terminal resolution" : "terminal graphics unavailable";
|
|
998
1039
|
const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
|
|
999
1040
|
? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
|
|
1000
|
-
return { mode: show ? "picture" : "chip", protocol:
|
|
1041
|
+
return { mode: show ? "picture" : "chip", protocol: effective, cell, widthCells: terminal?.imageWidthCells ?? 60,
|
|
1042
|
+
...(!show ? { reason } : {}), load: sha => this.images.loadResult(sha) };
|
|
1001
1043
|
}
|
|
1002
1044
|
private applyPictures(): void {
|
|
1003
1045
|
if (this.disposed) return;
|
|
@@ -1047,18 +1089,23 @@ export class InteractiveApp implements Ui {
|
|
|
1047
1089
|
const control = sessionControl(this.app.session);
|
|
1048
1090
|
if (this.control === control) return;
|
|
1049
1091
|
this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
|
|
1092
|
+
this.attention = null; this.lastOutcome = undefined;
|
|
1050
1093
|
this.screen.setControlModel(undefined);
|
|
1051
1094
|
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1052
1095
|
const unsubscribe = control.subscribe(notice => {
|
|
1053
1096
|
if (this.disposed) return;
|
|
1054
|
-
if (notice.method === "turn/started") this.program?.update(programStatus({ running: true }));
|
|
1097
|
+
if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
|
|
1055
1098
|
if (notice.method === "turn/error") {
|
|
1099
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
1056
1100
|
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
1057
1101
|
this.track(() => this.syncTranscript());
|
|
1058
1102
|
}
|
|
1059
1103
|
if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
|
|
1060
|
-
if (notice.method === "thread/statusChanged" && "waiting" in notice.params)
|
|
1104
|
+
if (notice.method === "thread/statusChanged" && "waiting" in notice.params) {
|
|
1105
|
+
this.attention = notice.params["waiting"] as ControlSnapshot["waiting"];
|
|
1061
1106
|
this.program?.update(programStatus({ running: control.busy, waiting: notice.params["waiting"] as ControlSnapshot["waiting"] }));
|
|
1107
|
+
}
|
|
1108
|
+
if (notice.method === "turn/completed") this.lastOutcome = notice.params["outcome"] as ExecutionOutcome | undefined;
|
|
1062
1109
|
// A remote client drives the same Session; local turns already have the shell's stream adapter.
|
|
1063
1110
|
if (!this.abort) {
|
|
1064
1111
|
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
@@ -1094,6 +1141,7 @@ export class InteractiveApp implements Ui {
|
|
|
1094
1141
|
}
|
|
1095
1142
|
|
|
1096
1143
|
observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
|
|
1144
|
+
if (event.outcome) this.lastOutcome = event.outcome;
|
|
1097
1145
|
if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
|
|
1098
1146
|
if (event.kind === "done") this.program?.update(programStatus({ running: false,
|
|
1099
1147
|
...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
|
|
@@ -1502,20 +1550,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
|
|
|
1502
1550
|
app.io.say = previousSay;
|
|
1503
1551
|
process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
|
|
1504
1552
|
}
|
|
1505
|
-
if (!renderer && fullscreen) printExit(app);
|
|
1553
|
+
if (!renderer && fullscreen) await printExit(app);
|
|
1506
1554
|
}
|
|
1507
1555
|
}
|
|
1508
1556
|
|
|
1509
|
-
/**
|
|
1510
|
-
function printExit(app: App): void {
|
|
1511
|
-
const
|
|
1557
|
+
/** Restore the shell with a bounded status and resume entry; full replay is explicit. */
|
|
1558
|
+
async function printExit(app: App): Promise<void> {
|
|
1559
|
+
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
1560
|
+
const events = history.ok ? history.nodes.flatMap(part => part.events) : app.session.state.events;
|
|
1561
|
+
const mode = app.ui?.settings.fullscreenExitOutput ?? DEFAULT_FULLSCREEN_EXIT_OUTPUT;
|
|
1512
1562
|
if (mode === "transcript") {
|
|
1513
|
-
for (const item of projectTranscript(
|
|
1563
|
+
for (const item of projectTranscript(events)) {
|
|
1514
1564
|
if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
|
|
1515
1565
|
else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
|
|
1516
1566
|
else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
|
|
1517
1567
|
}
|
|
1518
1568
|
}
|
|
1569
|
+
process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
|
|
1519
1570
|
process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
|
|
1520
1571
|
}
|
|
1521
1572
|
|
|
@@ -8,20 +8,28 @@
|
|
|
8
8
|
* its pixels, and memory is returned once the last node goes.
|
|
9
9
|
*
|
|
10
10
|
* Bytes come from the blob store, which checks the hash; a missing or
|
|
11
|
-
* corrupt blob, or one that will not decode,
|
|
12
|
-
*
|
|
11
|
+
* corrupt blob, or one that will not decode, has a typed failure so the view
|
|
12
|
+
* retains the caption and shows the actual preparation error.
|
|
13
13
|
*/
|
|
14
|
-
import { NativeImage } from "@opentui/core";
|
|
14
|
+
import { NativeImage, imageInfo } from "@opentui/core";
|
|
15
15
|
import { getBlobBytes } from "../../store/blobs.ts";
|
|
16
|
+
import { modelImageDimensions, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../../core/image-limits.ts";
|
|
17
|
+
import type { PictureLoad } from "../../tui/ui/pictures.ts";
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/** Two full previews fit; mounted ImageRenderables retain their own references. */
|
|
18
20
|
export const IMAGE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
/** Bounds failed entries and queued requests as well as successful pixels. */
|
|
22
|
+
export const IMAGE_CACHE_ENTRIES = 128;
|
|
23
|
+
/** Limit simultaneous native allocations; remaining admitted loads wait in the bounded cache. */
|
|
24
|
+
const IMAGE_DECODE_CONCURRENCY = 2;
|
|
25
|
+
interface Entry { readonly result: Promise<PictureLoad>; bytes: number; settled: boolean }
|
|
21
26
|
|
|
22
27
|
export class ImageCache {
|
|
23
28
|
private readonly entries = new Map<string, Entry>();
|
|
24
29
|
private used = 0;
|
|
30
|
+
private active = 0;
|
|
31
|
+
private closed = false;
|
|
32
|
+
private readonly waiting: (() => void)[] = [];
|
|
25
33
|
private readonly blobDir: () => string;
|
|
26
34
|
private readonly budget: number;
|
|
27
35
|
|
|
@@ -34,45 +42,80 @@ export class ImageCache {
|
|
|
34
42
|
get size(): number { return this.used; }
|
|
35
43
|
get count(): number { return this.entries.size; }
|
|
36
44
|
|
|
37
|
-
load(sha256: string): Promise<NativeImage | null> {
|
|
45
|
+
async load(sha256: string): Promise<NativeImage | null> {
|
|
46
|
+
const result = await this.loadResult(sha256);
|
|
47
|
+
return result.kind === "ready" ? result.image : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadResult(sha256: string): Promise<PictureLoad> {
|
|
51
|
+
if (this.closed) return Promise.resolve({ kind: "error", reason: "closed" });
|
|
38
52
|
const hit = this.entries.get(sha256);
|
|
39
53
|
if (hit) {
|
|
40
54
|
// Most recently used goes last; eviction starts from the front.
|
|
41
55
|
this.entries.delete(sha256); this.entries.set(sha256, hit);
|
|
42
|
-
return hit.
|
|
56
|
+
return hit.result;
|
|
43
57
|
}
|
|
44
|
-
|
|
58
|
+
if (this.entries.size >= IMAGE_CACHE_ENTRIES) {
|
|
59
|
+
const oldest = [...this.entries].find(([, entry]) => entry.settled);
|
|
60
|
+
if (oldest) this.drop(oldest[0], oldest[1]);
|
|
61
|
+
else return Promise.resolve({ kind: "error", reason: "busy" });
|
|
62
|
+
}
|
|
63
|
+
// Capture the blob owner before awaiting: session switches cannot redirect an in-flight read.
|
|
64
|
+
const entry: Entry = { result: this.decode(this.blobDir(), sha256), bytes: 0, settled: false };
|
|
45
65
|
this.entries.set(sha256, entry);
|
|
46
|
-
void entry.
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
void entry.result.then(result => {
|
|
67
|
+
entry.settled = true;
|
|
68
|
+
if (this.entries.get(sha256) !== entry) { if (result.kind === "ready") result.image.dispose(); return; }
|
|
69
|
+
entry.bytes = result.kind === "ready" ? result.image.width * result.image.height * 4 : 0;
|
|
49
70
|
this.used += entry.bytes;
|
|
50
|
-
this.
|
|
71
|
+
for (const [sha, old] of this.entries) {
|
|
72
|
+
if (this.used <= this.budget) break;
|
|
73
|
+
if (sha !== sha256 && old.settled) this.drop(sha, old);
|
|
74
|
+
}
|
|
51
75
|
});
|
|
52
|
-
return entry.
|
|
76
|
+
return entry.result;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
|
-
private async decode(
|
|
79
|
+
private async decode(dir: string, sha: string): Promise<PictureLoad> {
|
|
80
|
+
if (this.active >= IMAGE_DECODE_CONCURRENCY) await new Promise<void>(resolve => this.waiting.push(resolve));
|
|
81
|
+
else this.active++;
|
|
82
|
+
if (this.closed) return { kind: "error", reason: "closed" };
|
|
83
|
+
let native: NativeImage | undefined;
|
|
56
84
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
let bytes: Uint8Array | null;
|
|
86
|
+
try { bytes = await getBlobBytes(dir, sha, MAX_IMAGE_SOURCE_BYTES); }
|
|
87
|
+
catch (error) { return { kind: "error", reason: error instanceof RangeError ? "limit" : "corrupt" }; }
|
|
88
|
+
if (!bytes) return { kind: "error", reason: "missing" };
|
|
89
|
+
if (bytes.length > MAX_IMAGE_SOURCE_BYTES) return { kind: "error", reason: "limit" };
|
|
90
|
+
const info = imageInfo(bytes);
|
|
91
|
+
if (info.width * info.height > MAX_IMAGE_SOURCE_PIXELS) return { kind: "error", reason: "limit" };
|
|
92
|
+
native = await NativeImage.load(bytes);
|
|
93
|
+
const target = modelImageDimensions(native);
|
|
94
|
+
if (target.width !== native.width || target.height !== native.height) {
|
|
95
|
+
const resized = native.resize(target); native.dispose(); native = resized;
|
|
96
|
+
}
|
|
97
|
+
if (this.closed) return { kind: "error", reason: "closed" };
|
|
98
|
+
if (native.width * native.height * 4 > this.budget) return { kind: "error", reason: "limit" };
|
|
99
|
+
const image = native; native = undefined;
|
|
100
|
+
return { kind: "ready", image };
|
|
101
|
+
} catch { return { kind: "error", reason: "decode" }; }
|
|
102
|
+
finally {
|
|
103
|
+
native?.dispose();
|
|
104
|
+
const next = this.waiting.shift();
|
|
105
|
+
if (next) next(); else this.active--;
|
|
106
|
+
}
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (this.used <= this.budget) break;
|
|
66
|
-
if (sha === keep) continue;
|
|
67
|
-
this.entries.delete(sha);
|
|
68
|
-
this.used -= entry.bytes;
|
|
69
|
-
void entry.image.then(native => native?.dispose());
|
|
70
|
-
}
|
|
109
|
+
private drop(sha: string, entry: Entry): void {
|
|
110
|
+
this.entries.delete(sha); this.used -= entry.bytes;
|
|
111
|
+
void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
dispose(): void {
|
|
74
|
-
|
|
115
|
+
this.closed = true;
|
|
116
|
+
for (const entry of this.entries.values()) if (entry.settled) void entry.result.then(result => { if (result.kind === "ready") result.image.dispose(); });
|
|
75
117
|
this.entries.clear();
|
|
76
118
|
this.used = 0;
|
|
119
|
+
this.waiting.splice(0).forEach(resolve => resolve());
|
|
77
120
|
}
|
|
78
121
|
}
|
package/src/apps/ui/surface.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface Ui extends TurnSurface {
|
|
|
34
34
|
/** Notices and documents in the message stream. */
|
|
35
35
|
notice(text: string, tone?: "info" | "success" | "warning" | "error"): void;
|
|
36
36
|
showDocument(title: string, text: string): void;
|
|
37
|
+
checkForUpdate(): void;
|
|
37
38
|
toast(text: string, tone?: "info" | "success" | "warning" | "error"): void;
|
|
38
39
|
redraw(): void;
|
|
39
40
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Shared lifecycle for automatic hints and explicit checks; policy remains in core (D16). */
|
|
2
|
+
import { installKind, manualUpdateResult, updateDisabled, updatePlan, type UpdateInput } from "../core/update-check.ts";
|
|
3
|
+
import { packageLocation, readUpdateCache, writeUpdateCache } from "../store/update-cache.ts";
|
|
4
|
+
import { fetchLatestVersion } from "../runtime/update-check.ts";
|
|
5
|
+
import { XENO_VERSION } from "../runtime/oauth.ts";
|
|
6
|
+
|
|
7
|
+
export interface UpdateOptions {
|
|
8
|
+
readonly offline?: boolean;
|
|
9
|
+
readonly enabled?: boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class UpdateChecks {
|
|
13
|
+
private readonly controller = new AbortController();
|
|
14
|
+
private pending: Promise<string | null> | undefined;
|
|
15
|
+
private manualTask: Promise<number> | undefined;
|
|
16
|
+
|
|
17
|
+
async prepare(options: UpdateOptions, manual = false) {
|
|
18
|
+
const location = packageLocation();
|
|
19
|
+
const input: UpdateInput = { current: XENO_VERSION, now: Date.now(), cache: await readUpdateCache(), env: process.env,
|
|
20
|
+
offline: options.offline === true, enabled: options.enabled, install: installKind(location.root, location.git), manual };
|
|
21
|
+
return { input, plan: updatePlan(input), disabled: updateDisabled(input) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One request and one cache write even when a manual check joins startup's request. */
|
|
25
|
+
refresh(input: UpdateInput): Promise<string | null> {
|
|
26
|
+
if (this.pending) return this.pending;
|
|
27
|
+
const pending = (async () => {
|
|
28
|
+
const latest = await fetchLatestVersion(input.env["npm_config_registry"], { signal: this.controller.signal });
|
|
29
|
+
if (!this.controller.signal.aborted) {
|
|
30
|
+
const known = latest ?? input.cache?.latest;
|
|
31
|
+
await writeUpdateCache({ checkedAt: Date.now(), ...(known ? { latest: known } : {}) });
|
|
32
|
+
}
|
|
33
|
+
return latest;
|
|
34
|
+
})().finally(() => { if (this.pending === pending) this.pending = undefined; });
|
|
35
|
+
this.pending = pending;
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
manual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
|
|
40
|
+
if (this.manualTask) return this.manualTask;
|
|
41
|
+
const task = this.runManual(options, say).finally(() => { if (this.manualTask === task) this.manualTask = undefined; });
|
|
42
|
+
this.manualTask = task;
|
|
43
|
+
return task;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async runManual(options: UpdateOptions, say: (text: string) => void): Promise<number> {
|
|
47
|
+
const { input, plan, disabled } = await this.prepare(options, true);
|
|
48
|
+
if (this.controller.signal.aborted) return 130;
|
|
49
|
+
if (!plan.check) {
|
|
50
|
+
say(input.install === "checkout" ? `Source checkout: xeno ${input.current}. Use git pull to update this checkout.`
|
|
51
|
+
: `Update check skipped: ${disabled ?? "check unavailable"} (xeno ${input.current}).`);
|
|
52
|
+
return input.install === "checkout" ? 0 : 1;
|
|
53
|
+
}
|
|
54
|
+
say(`Checking for xeno updates (current ${input.current})...`);
|
|
55
|
+
const latest = await this.refresh(input);
|
|
56
|
+
if (this.controller.signal.aborted) return 130;
|
|
57
|
+
const result = manualUpdateResult(latest, input.current, input.install);
|
|
58
|
+
say(result.text);
|
|
59
|
+
return result.status === "failed" ? 1 : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
cancel(): void { this.controller.abort(); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A standalone command has no UI lifecycle to own cancellation. */
|
|
66
|
+
export async function runManualUpdate(options: UpdateOptions, say: (text: string) => void): Promise<number> {
|
|
67
|
+
const checks = new UpdateChecks(), cancel = () => checks.cancel();
|
|
68
|
+
process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
|
|
69
|
+
try { return await checks.manual(options, say); }
|
|
70
|
+
finally { checks.cancel(); process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** D5/D6: the app changes capabilities only through the live session's checkpoint owner. */
|
|
2
|
+
import { isWorkflowMode, workflowInstructions, type WorkflowMode } from "../core/workflow-mode.ts";
|
|
3
|
+
import { sessionControl } from "../runtime/session-control.ts";
|
|
4
|
+
import { sessionToolCatalog, type App } from "./startup.ts";
|
|
5
|
+
|
|
6
|
+
export type WorkflowApp = Pick<App, "args" | "session" | "extensions" | "system">;
|
|
7
|
+
|
|
8
|
+
export async function setAppWorkflow(app: WorkflowApp, mode: WorkflowMode): Promise<boolean> {
|
|
9
|
+
if (!isWorkflowMode(mode)) throw Error("workflow mode must be build or plan");
|
|
10
|
+
const session = app.session, previousMode = session.config.tools.workflowMode;
|
|
11
|
+
const current = previousMode ?? app.args.workflow ?? "build";
|
|
12
|
+
return sessionControl(session).withIdleChange(async () => {
|
|
13
|
+
const previousProfile = session.config.agentProfile, name = previousProfile?.name ?? app.args.agent;
|
|
14
|
+
const nextProfile = name ? { name, workflowExplicit: true } : undefined;
|
|
15
|
+
const args = structuredClone(app.args), previousScope = session.config.workflowToolProfile;
|
|
16
|
+
const promote = mode === "plan" && args.tools === undefined && !args.noTools && args.toolProfile === undefined;
|
|
17
|
+
if (promote) args.toolProfile = "foundation";
|
|
18
|
+
const nextScope = args.toolProfile === "foundation" ? "foundation" as const : undefined;
|
|
19
|
+
const metadataChanged = JSON.stringify(previousProfile) !== JSON.stringify(nextProfile) || previousScope !== nextScope;
|
|
20
|
+
if (mode === current && !metadataChanged && !promote) {
|
|
21
|
+
app.args.workflow = mode; app.args.cliFlags = [...new Set([...app.args.cliFlags ?? [], "--workflow"])];
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const oldInstruction = workflowInstructions(current), nextInstruction = workflowInstructions(mode);
|
|
25
|
+
// Replace only a complete harness paragraph: user context and all other frozen bytes survive.
|
|
26
|
+
const paragraphs = session.config.frozen.system.split("\n\n");
|
|
27
|
+
const index = paragraphs.indexOf(oldInstruction);
|
|
28
|
+
if (index >= 0) paragraphs[index] = nextInstruction; else paragraphs.push(nextInstruction);
|
|
29
|
+
const system = mode === current ? session.config.frozen.system : paragraphs.join("\n\n");
|
|
30
|
+
const catalog = sessionToolCatalog({ ...args, workflow: mode }, app.extensions, session.config.frozen.tools);
|
|
31
|
+
// The persisted checkpoint owns the admission mode as well as declarations, including cold restore.
|
|
32
|
+
session.config.tools.workflowMode = mode;
|
|
33
|
+
if (nextProfile) session.config.agentProfile = nextProfile;
|
|
34
|
+
if (nextScope) session.config.workflowToolProfile = nextScope; else delete session.config.workflowToolProfile;
|
|
35
|
+
try {
|
|
36
|
+
const changed = await session.reloadResources(mode === current && !promote ? session.config.frozen : { ...session.config.frozen, system, tools: catalog.schemas }, undefined, undefined, metadataChanged);
|
|
37
|
+
if (mode !== current || promote) session.config.tools.deferredTools = catalog.deferred;
|
|
38
|
+
args.workflow = mode; args.cliFlags = [...new Set([...args.cliFlags ?? [], "--workflow"])]; app.args = args; app.system = system;
|
|
39
|
+
return changed;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (previousMode === undefined) delete session.config.tools.workflowMode;
|
|
42
|
+
else session.config.tools.workflowMode = previousMode;
|
|
43
|
+
if (previousProfile === undefined) delete session.config.agentProfile;
|
|
44
|
+
else session.config.agentProfile = previousProfile;
|
|
45
|
+
if (previousScope === undefined) delete session.config.workflowToolProfile;
|
|
46
|
+
else session.config.workflowToolProfile = previousScope;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function bindAppWorkflow(app: WorkflowApp): void {
|
|
53
|
+
const session = app.session;
|
|
54
|
+
sessionControl(session).configureWorkflow = async mode => {
|
|
55
|
+
if (app.session !== session) throw Error("this session is no longer attached to the app");
|
|
56
|
+
await setAppWorkflow(app, mode);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A finer view of OSC status, derived from the same runtime facts (D11). */
|
|
2
|
+
import { programStatus, type ProgramStatus } from "./program-status.ts";
|
|
3
|
+
import type { ExecutionOutcome } from "./outcome.ts";
|
|
4
|
+
|
|
5
|
+
export interface ActivityFacts {
|
|
6
|
+
readonly running: boolean;
|
|
7
|
+
readonly waiting?: "permission" | "question" | "auth" | null;
|
|
8
|
+
readonly outcome?: ExecutionOutcome;
|
|
9
|
+
readonly goal?: string;
|
|
10
|
+
readonly phase?: "connecting" | "thinking" | "streaming" | "tool";
|
|
11
|
+
readonly tool?: string;
|
|
12
|
+
readonly retry?: { readonly attempt: number; readonly seconds: number };
|
|
13
|
+
readonly queued: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
|
|
16
|
+
|
|
17
|
+
export function activityStatus(facts: ActivityFacts): ActivityStatus {
|
|
18
|
+
const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
|
|
19
|
+
const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
|
|
20
|
+
if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval" : "Awaiting input");
|
|
21
|
+
if (status.state === "error") return view("Failed");
|
|
22
|
+
if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
|
|
23
|
+
if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
|
|
24
|
+
if (facts.phase === "tool") return view("Running tool", facts.tool ?? "Tool execution");
|
|
25
|
+
if (facts.phase === "thinking") return view("Thinking");
|
|
26
|
+
if (facts.phase === "streaming") return view("Responding");
|
|
27
|
+
return view("Connecting", "Waiting for the first response");
|
|
28
|
+
}
|