@visual-z/xeno 0.6.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 +9 -0
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/apps/session-hub.ts +12 -1
- package/src/apps/shell.ts +1 -1
- package/src/apps/ui/app.ts +68 -16
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/core/activity-status.ts +28 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/progress.ts +9 -0
- package/src/core/request-budget.ts +13 -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/runtime/exec-sessions.ts +40 -4
- package/src/runtime/images.ts +6 -2
- package/src/runtime/loop.ts +56 -11
- 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/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +6 -1
- package/src/runtime/session-control.ts +1 -0
- package/src/runtime/tools.ts +7 -3
- package/src/store/blobs.ts +2 -1
- package/src/store/git-status.ts +15 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# xeno changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- Keep fullscreen exit output bounded to the execution state and resume command. Preserve full transcript export as an explicit choice.
|
|
6
|
+
- Wait for actual process output, exit or the configured timeout when the agent owns an empty-input process wait. Preserve steering, cancellation and process receipts without spending model requests on empty polling.
|
|
7
|
+
- Reserve the last available model request for a response with tools disabled, and report unfinished work when the budget is exhausted. Track cumulative unproductive activity separately from consecutive failures.
|
|
8
|
+
- Share in-flight session discovery so concurrent cold resumes resolve aliases to one execution owner.
|
|
9
|
+
- Show provider, approval/YOLO, Build/Plan, running/waiting/queued state, model budgets and Git status from their current owners. Label model request time explicitly.
|
|
10
|
+
- Preserve screenshot detail with a 4096-pixel long edge and an 8 Mi-pixel default limit, preflight decoding, bounded image caching and visible load errors.
|
|
11
|
+
|
|
3
12
|
## 0.6.0 — 2026-10-09
|
|
4
13
|
|
|
5
14
|
- Add a foundation tool profile and Build/Plan agent roles, structured questions, resource and literal command-prefix permissions. Keep the ten-tool coding default and explicit tool selections intact across cold resumes.
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ is ever summarized by a model, and nothing is ever rewritten in place.
|
|
|
17
17
|
messages[] frozen system prefix + handoff + this node's events
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Version **0.
|
|
20
|
+
Version **0.7.0**. The on-disk session format will still change; `0.x` is that
|
|
21
21
|
statement. Published as `@visual-z/xeno`; the license is `UNLICENSED` (readable and installable, no reuse granted).
|
|
22
22
|
|
|
23
23
|
## Install
|
|
@@ -497,9 +497,20 @@ PgUp/PgDn scroll, and Ctrl+Home/End jump to the start or latest messages.
|
|
|
497
497
|
Ctrl+O expands tools, Ctrl+T expands thinking, and Ctrl+B toggles the sidebar.
|
|
498
498
|
Mouse selection copies plain text when `fullscreenCopyOnSelect` is enabled;
|
|
499
499
|
Ctrl+X copies a selection or the last reply. Exiting restores the terminal and
|
|
500
|
-
prints
|
|
500
|
+
prints a bounded execution status and a resume command. Set
|
|
501
|
+
`fullscreenExitOutput` to `transcript` in `/settings` for full replay on exit.
|
|
502
|
+
The composer stays at the bottom
|
|
501
503
|
and grows with the draft; pickers and forms use the same input area.
|
|
502
504
|
|
|
505
|
+
An empty `write_stdin` in an agent session waits for new output, command exit,
|
|
506
|
+
hard timeout, cancellation or user steering. The process owner holds that wait;
|
|
507
|
+
quiet commands do not consume model requests through periodic polling.
|
|
508
|
+
Sending input and low-level process polling keep their bounded yield contract.
|
|
509
|
+
For `--max-turns N`, budgets above one reserve the last request for a text answer.
|
|
510
|
+
Tools stay frozen and are disabled for that request, with host enforcement.
|
|
511
|
+
Unsupported protocols or a failed final response use an evidence-based stop
|
|
512
|
+
report inside the same budget. `/usage` labels provider durations as model time.
|
|
513
|
+
|
|
503
514
|
`/settings theme` previews built-in and custom themes from the config directory's
|
|
504
515
|
`themes/`. Escape restores the prior theme. Custom files reload after valid edits;
|
|
505
516
|
`auto` follows terminal light/dark changes. Built-in themes keep the terminal's
|
package/package.json
CHANGED
package/src/apps/session-hub.ts
CHANGED
|
@@ -15,6 +15,17 @@ export function createAppSessionHub(initial: App): SessionHub {
|
|
|
15
15
|
// Aliases resolve to one durable log before construction: two cold resumes
|
|
16
16
|
// must never append through independent Session owners (D5/D6).
|
|
17
17
|
const opening = new Map<string, { result: Promise<SessionControl>; configuration: string }>();
|
|
18
|
+
let lookup: ReturnType<typeof listSessions> | undefined;
|
|
19
|
+
// Q.26: cold aliases await one scan in ingress order before claiming their
|
|
20
|
+
// canonical owner. Keep only in-flight I/O; construction and live reloads stay independent.
|
|
21
|
+
const lookupSessions = (): ReturnType<typeof listSessions> => {
|
|
22
|
+
if (lookup) return lookup;
|
|
23
|
+
const current = listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace));
|
|
24
|
+
lookup = current;
|
|
25
|
+
const release = () => { if (lookup === current) lookup = undefined; };
|
|
26
|
+
void current.then(release, release);
|
|
27
|
+
return current;
|
|
28
|
+
};
|
|
18
29
|
return new SessionHub(sessionControl(initial.session), {
|
|
19
30
|
list: async () => ({ ...await listSessions(initial.workspace, sessionDirFor(initial.args, initial.workspace)), live: [...apps.keys()] }),
|
|
20
31
|
open: async (kind, params) => {
|
|
@@ -31,7 +42,7 @@ export function createAppSessionHub(initial: App): SessionHub {
|
|
|
31
42
|
});
|
|
32
43
|
return sessionControl(live.session);
|
|
33
44
|
}
|
|
34
|
-
const found = (await
|
|
45
|
+
const found = (await lookupSessions()).sessions
|
|
35
46
|
.find(item => item.ok && (createHash("sha256").update(item.path).digest("hex") === requested || item.path === requested || item.id === requested));
|
|
36
47
|
if (!live && !found) throw Error("session must belong to this app-server workspace");
|
|
37
48
|
source = live?.logDir ?? found!.path;
|
package/src/apps/shell.ts
CHANGED
|
@@ -739,7 +739,7 @@ function printUsage(app: App): void {
|
|
|
739
739
|
} else {
|
|
740
740
|
return assertNever(dollars, "printUsage");
|
|
741
741
|
}
|
|
742
|
-
app.io.say(`
|
|
742
|
+
app.io.say(` model time ${(t.ms / 1000).toFixed(1)}s`);
|
|
743
743
|
app.io.say("");
|
|
744
744
|
}
|
|
745
745
|
|
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";
|
|
@@ -44,7 +45,10 @@ import { isUpgrade } from "../../core/version.ts";
|
|
|
44
45
|
import type { Effort } from "../../runtime/model.ts";
|
|
45
46
|
import { readNode, readCursor, setSessionLabel } from "../../store/log.ts";
|
|
46
47
|
import { readNodeHistory } from "../../store/restore.ts";
|
|
47
|
-
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";
|
|
48
52
|
import { projectTrust } from "../../store/trust.ts";
|
|
49
53
|
import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
|
|
50
54
|
import { nextResourceOverride } from "../../core/resource-selection.ts";
|
|
@@ -107,6 +111,11 @@ function samePalette(a: TerminalPalette, b: TerminalPalette | null): boolean {
|
|
|
107
111
|
return b !== null && a.foreground === b.foreground && a.background === b.background && a.slots.every((slot, i) => slot === b.slots[i]);
|
|
108
112
|
}
|
|
109
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
|
+
|
|
110
119
|
export class InteractiveApp implements Ui {
|
|
111
120
|
readonly app: App;
|
|
112
121
|
readonly screen: Screen;
|
|
@@ -141,6 +150,11 @@ export class InteractiveApp implements Ui {
|
|
|
141
150
|
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
142
151
|
private bashDecoder = new TextDecoder();
|
|
143
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;
|
|
144
158
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
145
159
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
146
160
|
private derived: ReturnType<typeof handoffStatus> | undefined;
|
|
@@ -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, {
|
|
@@ -417,7 +431,7 @@ export class InteractiveApp implements Ui {
|
|
|
417
431
|
// A different session: its notices, queue and draft attachments do not carry over.
|
|
418
432
|
if (this.historyDir) { this.notices = []; this.queue = []; this.imageDraft.reset(); this.screen.dialogs.clear(); }
|
|
419
433
|
this.clearStream();
|
|
420
|
-
this.
|
|
434
|
+
void this.refreshGit();
|
|
421
435
|
this.sessionName = (await readCursor(dir))?.name ?? this.sessionName;
|
|
422
436
|
this.thinkingExpanded = this.app.ui?.settings.hideThinkingBlock === false;
|
|
423
437
|
void this.refreshFileIndex();
|
|
@@ -458,7 +472,8 @@ export class InteractiveApp implements Ui {
|
|
|
458
472
|
} else {
|
|
459
473
|
this.stopRetry();
|
|
460
474
|
if (progress.kind === "tool") this.live.tools.set(progress.id, { name: progress.name, arguments: progress.arguments });
|
|
461
|
-
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` : "";
|
|
462
477
|
}
|
|
463
478
|
this.paint();
|
|
464
479
|
}
|
|
@@ -542,9 +557,19 @@ export class InteractiveApp implements Ui {
|
|
|
542
557
|
const t = app.session.totals, cost = reportedCost(t, spec.price);
|
|
543
558
|
const tools = items.filter(item => item.kind === "tool");
|
|
544
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 } } : {}) });
|
|
545
567
|
return {
|
|
546
568
|
version: XENO_VERSION, title: this.sessionName ?? this.prompt, workspace: displayPath(app.workspace, homedir()),
|
|
547
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,
|
|
548
573
|
...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
|
|
549
574
|
...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
|
|
550
575
|
// Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
|
|
@@ -568,6 +593,18 @@ export class InteractiveApp implements Ui {
|
|
|
568
593
|
this.renderer.setTerminalTitle(title);
|
|
569
594
|
}
|
|
570
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
|
+
|
|
571
608
|
private liveStream(): LiveStream | undefined {
|
|
572
609
|
const l = this.live;
|
|
573
610
|
if (!l.thinking && !l.text && !l.tools.size && !l.output && l.bash === null && !l.retryUntil) return undefined;
|
|
@@ -793,7 +830,8 @@ export class InteractiveApp implements Ui {
|
|
|
793
830
|
const shell = command.text.startsWith("!");
|
|
794
831
|
const images = shell ? [] : input.images;
|
|
795
832
|
if (shell) this.imageDraft.insertImages(input.images);
|
|
796
|
-
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; }
|
|
797
835
|
this.turn = this.runPromptTurn(command.text, images, command.skill).catch(error => this.notice(describeError(error), "error"));
|
|
798
836
|
this.paint();
|
|
799
837
|
}
|
|
@@ -801,6 +839,7 @@ export class InteractiveApp implements Ui {
|
|
|
801
839
|
private async runPromptTurn(text: string, images: readonly ImageRef[] = [], skill?: import("../../core/skills.ts").Skill): Promise<void> {
|
|
802
840
|
this.abort = new AbortController();
|
|
803
841
|
this.inflight = true;
|
|
842
|
+
this.lastOutcome = undefined;
|
|
804
843
|
this.programResult = false;
|
|
805
844
|
this.program?.update(programStatus({ running: true }));
|
|
806
845
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
@@ -813,16 +852,18 @@ export class InteractiveApp implements Ui {
|
|
|
813
852
|
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
814
853
|
} catch (error) {
|
|
815
854
|
this.programResult = true;
|
|
855
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
816
856
|
this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
817
857
|
throw error;
|
|
818
858
|
} finally {
|
|
859
|
+
if (this.abort?.signal.aborted && !this.lastOutcome) this.lastOutcome = executionOutcome("cancelled");
|
|
819
860
|
if (!this.programResult) this.program?.update(programStatus({ running: false }));
|
|
820
861
|
await this.syncTranscript();
|
|
821
862
|
this.abort = null;
|
|
822
863
|
this.hooks.dropFileIndex(this.app.workspace);
|
|
823
864
|
this.inflight = false;
|
|
824
865
|
this.executionKind = "idle";
|
|
825
|
-
this.
|
|
866
|
+
void this.refreshGit();
|
|
826
867
|
this.clearStream();
|
|
827
868
|
this.requestDerived();
|
|
828
869
|
this.paint();
|
|
@@ -954,7 +995,7 @@ export class InteractiveApp implements Ui {
|
|
|
954
995
|
this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
|
|
955
996
|
}
|
|
956
997
|
deliverExtension(text: string, kind: QueueKind): void {
|
|
957
|
-
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(); }
|
|
958
999
|
else this.turn = this.runPromptTurn(text).catch(error => this.notice(describeError(error), "error"));
|
|
959
1000
|
}
|
|
960
1001
|
|
|
@@ -993,10 +1034,12 @@ export class InteractiveApp implements Ui {
|
|
|
993
1034
|
const requested = terminal?.imageProtocol ?? "auto";
|
|
994
1035
|
const resolution = this.renderer.resolution;
|
|
995
1036
|
const effective = resolveImageRenderProtocol(requested, this.renderer.capabilities, resolution !== null);
|
|
996
|
-
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";
|
|
997
1039
|
const cell = resolution && this.renderer.terminalWidth > 0 && this.renderer.terminalHeight > 0
|
|
998
1040
|
? { width: resolution.width / this.renderer.terminalWidth, height: resolution.height / this.renderer.terminalHeight } : DEFAULT_CELL;
|
|
999
|
-
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) };
|
|
1000
1043
|
}
|
|
1001
1044
|
private applyPictures(): void {
|
|
1002
1045
|
if (this.disposed) return;
|
|
@@ -1046,18 +1089,23 @@ export class InteractiveApp implements Ui {
|
|
|
1046
1089
|
const control = sessionControl(this.app.session);
|
|
1047
1090
|
if (this.control === control) return;
|
|
1048
1091
|
this.stopControl?.(); this.control = control; this.controlSnapshot = undefined;
|
|
1092
|
+
this.attention = null; this.lastOutcome = undefined;
|
|
1049
1093
|
this.screen.setControlModel(undefined);
|
|
1050
1094
|
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1051
1095
|
const unsubscribe = control.subscribe(notice => {
|
|
1052
1096
|
if (this.disposed) return;
|
|
1053
|
-
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 })); }
|
|
1054
1098
|
if (notice.method === "turn/error") {
|
|
1099
|
+
this.lastOutcome = executionOutcome("execution_error");
|
|
1055
1100
|
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
1056
1101
|
this.track(() => this.syncTranscript());
|
|
1057
1102
|
}
|
|
1058
1103
|
if (notice.method === "approval/request") this.program?.update(programStatus({ running: true, waiting: "permission" }));
|
|
1059
|
-
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"];
|
|
1060
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;
|
|
1061
1109
|
// A remote client drives the same Session; local turns already have the shell's stream adapter.
|
|
1062
1110
|
if (!this.abort) {
|
|
1063
1111
|
if (notice.method === "turn/started") { this.inflight = true; this.executionKind = "model"; }
|
|
@@ -1093,6 +1141,7 @@ export class InteractiveApp implements Ui {
|
|
|
1093
1141
|
}
|
|
1094
1142
|
|
|
1095
1143
|
observeEvent(event: import("../../runtime/loop.ts").LoopEvent): void {
|
|
1144
|
+
if (event.outcome) this.lastOutcome = event.outcome;
|
|
1096
1145
|
if (event.kind === "done" || event.kind === "stop_and_ask") this.programResult = true;
|
|
1097
1146
|
if (event.kind === "done") this.program?.update(programStatus({ running: false,
|
|
1098
1147
|
...(event.outcome ? { outcome: event.outcome } : {}), ...(event.request ? { goal: event.request.status } : {}) }));
|
|
@@ -1501,20 +1550,23 @@ export async function runInteractive(app: App, hooks: Hooks, releaseEarlyTty?: (
|
|
|
1501
1550
|
app.io.say = previousSay;
|
|
1502
1551
|
process.off("SIGTERM", terminate); process.off("SIGHUP", hangup); process.off("uncaughtException", crash); process.off("unhandledRejection", crash); process.off("exit", lastResort);
|
|
1503
1552
|
}
|
|
1504
|
-
if (!renderer && fullscreen) printExit(app);
|
|
1553
|
+
if (!renderer && fullscreen) await printExit(app);
|
|
1505
1554
|
}
|
|
1506
1555
|
}
|
|
1507
1556
|
|
|
1508
|
-
/**
|
|
1509
|
-
function printExit(app: App): void {
|
|
1510
|
-
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;
|
|
1511
1562
|
if (mode === "transcript") {
|
|
1512
|
-
for (const item of projectTranscript(
|
|
1563
|
+
for (const item of projectTranscript(events)) {
|
|
1513
1564
|
if (item.kind === "user") process.stdout.write(`\n❯ ${item.text}\n`);
|
|
1514
1565
|
else if (item.kind === "assistant") process.stdout.write(`\n${item.text}\n`);
|
|
1515
1566
|
else if (item.kind === "tool") process.stdout.write(` ⏺ ${item.name}${item.result ? item.result.ok ? "" : " (failed)" : " (pending)"}\n`);
|
|
1516
1567
|
}
|
|
1517
1568
|
}
|
|
1569
|
+
process.stdout.write(`\n ${history.ok ? sessionExitStatus(events) : "session saved · history unavailable; inspect before continuing"}\n`);
|
|
1518
1570
|
process.stdout.write(`\n resume with: xeno --session ${JSON.stringify(app.logDir)}\n`);
|
|
1519
1571
|
}
|
|
1520
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Counts from Git porcelain v2, distinct from agent tool effects. No path decoding needed. */
|
|
2
|
+
export interface GitStatus {
|
|
3
|
+
readonly branch: string | null;
|
|
4
|
+
readonly staged: number;
|
|
5
|
+
readonly changed: number;
|
|
6
|
+
readonly untracked: number;
|
|
7
|
+
readonly conflicted: number;
|
|
8
|
+
readonly ahead: number;
|
|
9
|
+
readonly behind: number;
|
|
10
|
+
}
|
|
11
|
+
export function parseGitStatus(output: string): GitStatus {
|
|
12
|
+
let branch: string | null = null, staged = 0, changed = 0, untracked = 0, conflicted = 0, ahead = 0, behind = 0;
|
|
13
|
+
const records = output.split("\0");
|
|
14
|
+
for (let index = 0; index < records.length; index++) {
|
|
15
|
+
const line = records[index]!;
|
|
16
|
+
if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
|
|
17
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
18
|
+
const match = /^# branch.ab \+(\d+) -(\d+)$/.exec(line.trim());
|
|
19
|
+
if (match) { ahead = Number(match[1]); behind = Number(match[2]); }
|
|
20
|
+
} else if (line.startsWith("? ")) untracked++;
|
|
21
|
+
else if (/^[12u] [A-Z?.]{2} /.test(line)) {
|
|
22
|
+
if (line[2] !== ".") staged++;
|
|
23
|
+
if (line[3] !== ".") changed++;
|
|
24
|
+
if (line[0] === "u") conflicted++;
|
|
25
|
+
if (line[0] === "2") index++; // Rename's next NUL field is a path, never a status record.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { branch, staged, changed, untracked, conflicted, ahead, behind };
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Admission, model detail and preview memory are separate budgets (D8). */
|
|
2
|
+
export const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
3
|
+
/** Matches the native decoder's area cap; inspect headers before WASM allocates. */
|
|
4
|
+
export const MAX_IMAGE_SOURCE_PIXELS = 25_000_000;
|
|
5
|
+
/** More readable screenshots than 2000px, with at most 32 MiB RGBA per preview. */
|
|
6
|
+
export const MAX_MODEL_IMAGE_SIDE = 4096;
|
|
7
|
+
export const MAX_MODEL_IMAGE_PIXELS = 8 * 1024 * 1024;
|
|
8
|
+
export interface ImageDimensions { readonly width: number; readonly height: number }
|
|
9
|
+
|
|
10
|
+
/** Header-only probe. Decoders still validate the complete image afterward. */
|
|
11
|
+
export function imageDimensions(bytes: Uint8Array, mime: string): ImageDimensions | undefined {
|
|
12
|
+
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
13
|
+
const result = (width: number, height: number): ImageDimensions | undefined =>
|
|
14
|
+
Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 ? { width, height } : undefined;
|
|
15
|
+
if (mime === "image/png" && bytes.length >= 24) return result(v.getUint32(16), v.getUint32(20));
|
|
16
|
+
if (mime === "image/gif" && bytes.length >= 10) return result(v.getUint16(6, true), v.getUint16(8, true));
|
|
17
|
+
if (mime === "image/bmp" && bytes.length >= 26) {
|
|
18
|
+
const header = v.getUint32(14, true);
|
|
19
|
+
if (header === 12) return result(v.getUint16(18, true), v.getUint16(20, true));
|
|
20
|
+
if (header >= 40) return result(v.getInt32(18, true), Math.abs(v.getInt32(22, true)));
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (mime === "image/jpeg") {
|
|
24
|
+
let offset = 2;
|
|
25
|
+
while (offset + 3 < bytes.length) {
|
|
26
|
+
if (bytes[offset++] !== 255) return undefined;
|
|
27
|
+
while (bytes[offset] === 255) offset++;
|
|
28
|
+
const marker = bytes[offset++];
|
|
29
|
+
if (marker === undefined || marker === 0xda || marker === 0xd9) return undefined;
|
|
30
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
|
|
31
|
+
if (offset + 2 > bytes.length) return undefined;
|
|
32
|
+
const size = v.getUint16(offset);
|
|
33
|
+
if (size < 2 || offset + size > bytes.length) return undefined;
|
|
34
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
35
|
+
return size >= 8 ? result(v.getUint16(offset + 5), v.getUint16(offset + 3)) : undefined;
|
|
36
|
+
}
|
|
37
|
+
offset += size;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (mime === "image/webp") {
|
|
41
|
+
for (let offset = 12; offset + 8 <= bytes.length;) {
|
|
42
|
+
const size = v.getUint32(offset + 4, true), start = offset + 8;
|
|
43
|
+
if (start + size > bytes.length) return undefined;
|
|
44
|
+
const kind = String.fromCharCode(...bytes.subarray(offset, offset + 4));
|
|
45
|
+
const u24 = (at: number) => v.getUint8(at) | v.getUint8(at + 1) << 8 | v.getUint8(at + 2) << 16;
|
|
46
|
+
if (kind === "VP8X" && size >= 10) return result(u24(start + 4) + 1, u24(start + 7) + 1);
|
|
47
|
+
if (kind === "VP8L" && size >= 5 && bytes[start] === 0x2f) {
|
|
48
|
+
const packed = v.getUint32(start + 1, true);
|
|
49
|
+
return result((packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1);
|
|
50
|
+
}
|
|
51
|
+
if (kind === "VP8 " && size >= 10 && bytes[start + 3] === 0x9d && bytes[start + 4] === 1 && bytes[start + 5] === 0x2a)
|
|
52
|
+
return result(v.getUint16(start + 6, true) & 0x3fff, v.getUint16(start + 8, true) & 0x3fff);
|
|
53
|
+
offset = start + size + size % 2;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Floors keep the area bound exact; small inputs are never upscaled. */
|
|
60
|
+
export function modelImageDimensions(image: ImageDimensions): ImageDimensions {
|
|
61
|
+
const scale = Math.min(1, MAX_MODEL_IMAGE_SIDE / image.width, MAX_MODEL_IMAGE_SIDE / image.height,
|
|
62
|
+
Math.sqrt(MAX_MODEL_IMAGE_PIXELS / image.width / image.height));
|
|
63
|
+
return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pi rotates EXIF before resizing and rounds the short side; symmetric bounds preserve both orientations. */
|
|
67
|
+
export function modelImageSide(image: ImageDimensions): number {
|
|
68
|
+
const long = Math.max(image.width, image.height), short = Math.min(image.width, image.height);
|
|
69
|
+
let side = Math.max(...Object.values(modelImageDimensions(image)));
|
|
70
|
+
while (side * Math.max(1, Math.round(short * side / long)) > MAX_MODEL_IMAGE_PIXELS) side--;
|
|
71
|
+
return side;
|
|
72
|
+
}
|
package/src/core/progress.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* its carried handoff view may be paged like the other structural state (D20).
|
|
6
6
|
*/
|
|
7
7
|
import type { LineRange, LogEvent } from "./events.ts";
|
|
8
|
+
import { isProcessReceipt } from "./process-receipt.ts";
|
|
8
9
|
|
|
9
10
|
export type ProgressEvidence = (
|
|
10
11
|
| { kind: "read"; sha256: string; ranges?: readonly LineRange[]; observation_sha256?: string }
|
|
@@ -123,6 +124,14 @@ export function deriveProgress(
|
|
|
123
124
|
evidence.push({ kind: "observation", sha256: key, source });
|
|
124
125
|
if (isAfter) progressed = true;
|
|
125
126
|
}
|
|
127
|
+
} else if (isProcessReceipt(event.data) && !event.data.replayed && event.data.output !== undefined && event.data.output.bytes > 0 &&
|
|
128
|
+
!observations.has(event.data.output.sha256)) {
|
|
129
|
+
// A managed command can produce useful evidence before it exits. Credit
|
|
130
|
+
// raw captured bytes, never its elapsed header, changing ID or poll count.
|
|
131
|
+
const sha256 = event.data.output.sha256;
|
|
132
|
+
observations.add(sha256);
|
|
133
|
+
evidence.push({ kind: "observation", sha256, source });
|
|
134
|
+
progressed = true;
|
|
126
135
|
} else if ((name === "grep" || name === "find" || name === "ls" || name === "query") &&
|
|
127
136
|
observation !== undefined && (event.content_ref?.bytes ?? event.content.length) > 0 &&
|
|
128
137
|
!observations.has(observation)) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** D6 / Q.26: finalization spends the existing budget and cannot admit new work. */
|
|
2
|
+
export function requestPhase(index: number, limit: number): "work" | "finalize" | "exhausted" {
|
|
3
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(index) || index < 0)
|
|
4
|
+
throw new RangeError("request budget requires positive integer limit and nonnegative index");
|
|
5
|
+
if (index >= limit) return "exhausted";
|
|
6
|
+
// One request cannot both investigate and close. Preserve that work opportunity;
|
|
7
|
+
// its deterministic outcome is the exit, without a second paid request.
|
|
8
|
+
return limit > 1 && index === limit - 1 ? "finalize" : "work";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function finalizationNotice(): string {
|
|
12
|
+
return "Harness: this is the final request in the existing budget. Tools are disabled by the host. Give the user a concise answer from the observations already recorded: what is established, what remains unresolved, and the next useful action. Do not claim unobserved execution or verified completion.";
|
|
13
|
+
}
|