@yaag/extension 0.1.4 → 0.2.1
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/package.json +5 -4
- package/src/background-status.ts +94 -0
- package/src/cli-child.ts +3 -0
- package/src/fake-extension-ui.ts +6 -3
- package/src/fake-tui.ts +1 -1
- package/src/index.ts +10 -7
- package/src/process-liveness.ts +51 -0
- package/src/run-details.ts +4 -2
- package/src/run-foreground.ts +107 -60
- package/src/run-id.ts +24 -0
- package/src/run-record.ts +91 -0
- package/src/run-registry.ts +153 -16
- package/src/run-restore.ts +43 -0
- package/src/run-settlement.ts +1 -1
- package/src/run-store.ts +103 -0
- package/src/run-tool-test-support.ts +4 -4
- package/src/run-tool.ts +38 -30
- package/src/run-tree-component.ts +2 -1
- package/src/run-tree-host.ts +4 -5
- package/src/run-trees.ts +4 -4
- package/src/runs-dir.ts +18 -0
- package/src/spawn-run.ts +3 -1
- package/src/status-tool.ts +39 -4
- package/src/stop-tool.ts +41 -3
- package/src/test-tui-context.ts +7 -1
- package/src/yaag-command.ts +12 -19
- package/src/background-widget.ts +0 -107
- package/src/run-foreground-view.ts +0 -95
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/extension",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
27
|
-
"@yaag/
|
|
28
|
-
"@yaag/
|
|
29
|
-
"@yaag/
|
|
27
|
+
"@yaag/cli": "0.2.1",
|
|
28
|
+
"@yaag/runtime": "0.2.1",
|
|
29
|
+
"@yaag/tui": "0.2.1",
|
|
30
|
+
"nanoid": "^6.0.1"
|
|
30
31
|
},
|
|
31
32
|
"peerDependencies": {
|
|
32
33
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The footer segment for every live background Run (architecture §9).
|
|
3
|
+
*
|
|
4
|
+
* A background Run costs no scrollback and owns no widget: it reports itself in
|
|
5
|
+
* one cooperative footer segment, `yaag: 2 live · $0.42`, which pi lays out
|
|
6
|
+
* beside the segments of every other extension. The segment is cleared as soon
|
|
7
|
+
* as the last background Run ends.
|
|
8
|
+
*/
|
|
9
|
+
import { costText } from "@yaag/tui";
|
|
10
|
+
import type { RunRegistry } from "./run-registry.ts";
|
|
11
|
+
import type { RunTreeStore } from "./run-trees.ts";
|
|
12
|
+
|
|
13
|
+
/** The one footer key this extension owns. */
|
|
14
|
+
export const STATUS_KEY = "yaag";
|
|
15
|
+
|
|
16
|
+
/** The only pi capability the footer segment needs. */
|
|
17
|
+
export interface StatusSurface {
|
|
18
|
+
setStatus(key: string, text: string | undefined): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the footer segment reads: the Run registry and the projection store. */
|
|
22
|
+
export interface BackgroundStatusOptions {
|
|
23
|
+
readonly registry: RunRegistry;
|
|
24
|
+
readonly store: RunTreeStore;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The footer controller: bind it once, then refresh it on every occurrence. */
|
|
28
|
+
export interface BackgroundStatus {
|
|
29
|
+
/**
|
|
30
|
+
* Binds the pi UI surface, once the Host Session has one, and writes the
|
|
31
|
+
* current text. A `StatusSurface.setStatus()` that throws propagates here.
|
|
32
|
+
*/
|
|
33
|
+
bind(surface: StatusSurface): void;
|
|
34
|
+
/**
|
|
35
|
+
* Rewrites the segment; drops the write before `bind`, and skips unchanged
|
|
36
|
+
* text. A bound `StatusSurface.setStatus()` that throws propagates here.
|
|
37
|
+
*/
|
|
38
|
+
refresh(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds the footer segment over this session's Run registry and projections.
|
|
43
|
+
*
|
|
44
|
+
* An unbound controller drops every refresh, and a Run without a projection is
|
|
45
|
+
* skipped. The controller lives for the whole Host Session, so it never
|
|
46
|
+
* unsubscribes. Construction itself never throws, but `bind()` and `refresh()`
|
|
47
|
+
* call the caller's `StatusSurface.setStatus()` and propagate whatever it
|
|
48
|
+
* throws; the store subscription also propagates it into the `ingest()` that
|
|
49
|
+
* caused the rewrite.
|
|
50
|
+
*/
|
|
51
|
+
export function createBackgroundStatus(options: BackgroundStatusOptions): BackgroundStatus {
|
|
52
|
+
let surface: StatusSurface | undefined;
|
|
53
|
+
let written: string | undefined;
|
|
54
|
+
|
|
55
|
+
const refresh = (): void => {
|
|
56
|
+
if (surface === undefined) return;
|
|
57
|
+
const text = statusText(options);
|
|
58
|
+
if (written === text) return;
|
|
59
|
+
written = text;
|
|
60
|
+
surface.setStatus(STATUS_KEY, text);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
options.store.subscribe(() => refresh());
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
bind(bound: StatusSurface): void {
|
|
67
|
+
surface = bound;
|
|
68
|
+
written = undefined;
|
|
69
|
+
refresh();
|
|
70
|
+
},
|
|
71
|
+
refresh,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The segment text, or undefined when no background Run is live.
|
|
77
|
+
*
|
|
78
|
+
* The cost is the sum over every live background Run, and it carries
|
|
79
|
+
* `+incomplete` when any of those Runs reports its cost as a floor (ADR-0012).
|
|
80
|
+
*/
|
|
81
|
+
function statusText(options: BackgroundStatusOptions): string | undefined {
|
|
82
|
+
let count = 0;
|
|
83
|
+
let cost: number | null = null;
|
|
84
|
+
let incomplete = false;
|
|
85
|
+
for (const run of options.registry.live) {
|
|
86
|
+
if (options.store.kind(run.id) !== "background") continue;
|
|
87
|
+
count += 1;
|
|
88
|
+
const summary = run.summary;
|
|
89
|
+
if (summary.cost !== null) cost = (cost ?? 0) + summary.cost;
|
|
90
|
+
if (summary.incomplete) incomplete = true;
|
|
91
|
+
}
|
|
92
|
+
if (count === 0) return undefined;
|
|
93
|
+
return `${STATUS_KEY}: ${count} live · ${costText(cost, incomplete)}`;
|
|
94
|
+
}
|
package/src/cli-child.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface CliChildOptions {
|
|
|
22
22
|
export interface CliChild {
|
|
23
23
|
readonly outcome: Promise<CliChildOutcome>;
|
|
24
24
|
readonly events: Readable | undefined;
|
|
25
|
+
/** The child's pid, and the group id its reap ladder signals. */
|
|
26
|
+
readonly pid?: number | undefined;
|
|
25
27
|
stop(): void;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -53,6 +55,7 @@ export function startCliChild(options: CliChildOptions): CliChild {
|
|
|
53
55
|
const eventStream = options.events === true ? child.stdio[3] : undefined;
|
|
54
56
|
return {
|
|
55
57
|
outcome,
|
|
58
|
+
pid: child.pid,
|
|
56
59
|
events: isReadable(eventStream) ? eventStream : undefined,
|
|
57
60
|
stop: () => void reap(target),
|
|
58
61
|
};
|
package/src/fake-extension-ui.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A fully typed `ExtensionUIContext` for the tui-mode test context.
|
|
3
3
|
*
|
|
4
|
-
* `custom`, `notify`, `editor`, `setWidget`, and a scripted
|
|
4
|
+
* `custom`, `notify`, `editor`, `setWidget`, `setStatus`, and a scripted
|
|
5
|
+
* `select` work;
|
|
5
6
|
* every other capability throws, so a dependency a view is not supposed to
|
|
6
7
|
* have fails loudly. The
|
|
7
8
|
* class implements the interface, so pi's UI surface is checked at compile
|
|
@@ -54,6 +55,8 @@ export class FakeExtensionUi implements ExtensionUIContext {
|
|
|
54
55
|
readonly edits: OpenedEditor[] = [];
|
|
55
56
|
/** The latest frame per widget key; `undefined` means the key was cleared. */
|
|
56
57
|
readonly widgets = new Map<string, string[] | undefined>();
|
|
58
|
+
/** The latest text per footer key; `undefined` means the key was cleared. */
|
|
59
|
+
readonly statuses = new Map<string, string | undefined>();
|
|
57
60
|
/** Answers `select` in order; an empty queue answers `undefined`, i.e. esc. */
|
|
58
61
|
readonly selections: string[] = [];
|
|
59
62
|
/** The component `custom` opened, once a Run opened one. */
|
|
@@ -116,8 +119,8 @@ export class FakeExtensionUi implements ExtensionUIContext {
|
|
|
116
119
|
onTerminalInput(): () => void {
|
|
117
120
|
return unavailable("ui.onTerminalInput");
|
|
118
121
|
}
|
|
119
|
-
setStatus(): void {
|
|
120
|
-
|
|
122
|
+
setStatus(key: string, text: string | undefined): void {
|
|
123
|
+
this.statuses.set(key, text);
|
|
121
124
|
}
|
|
122
125
|
setWorkingMessage(): void {
|
|
123
126
|
unavailable("ui.setWorkingMessage");
|
package/src/fake-tui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fully typed `TUI` and `Terminal` doubles for the tui-mode test context.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The Run view uses three capabilities only: `terminal.rows`,
|
|
5
5
|
* `terminal.columns`, and `requestRender()`. Every other member of `Terminal`
|
|
6
6
|
* and `TUI` — fields and getters included — throws on access, so a component
|
|
7
7
|
* that starts to depend on the terminal fails loudly in the test instead of
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { createBackgroundStatus } from "./background-status.ts";
|
|
3
3
|
import { createDescribeTool } from "./describe-tool.ts";
|
|
4
4
|
import { findProgramDirectories } from "./program-directories.ts";
|
|
5
5
|
import { resolveBun } from "./resolve-bun.ts";
|
|
6
6
|
import { resolveCliEntry } from "./resolve-cli.ts";
|
|
7
7
|
import { createRunCompleteRenderer } from "./run-complete-renderer.ts";
|
|
8
8
|
import { RunRegistry } from "./run-registry.ts";
|
|
9
|
+
import { RunStore } from "./run-store.ts";
|
|
9
10
|
import { createRunTool } from "./run-tool.ts";
|
|
10
11
|
import { RunTreeStore } from "./run-trees.ts";
|
|
11
12
|
import { createSetupWorkspaceExecutor } from "./setup-workspace.ts";
|
|
@@ -31,14 +32,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
31
32
|
const cli = resolveCliEntry();
|
|
32
33
|
const report = statusReport(bun, cli);
|
|
33
34
|
|
|
34
|
-
// One registry per session
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// One registry per session, mirrored to a durable store so a restart of this
|
|
36
|
+
// Host Session keeps every Run id addressable (ticket 02).
|
|
37
|
+
const registry = new RunRegistry({ store: new RunStore() });
|
|
38
|
+
await registry.restore();
|
|
39
|
+
// The renderer projections and the background footer segment both live for
|
|
37
40
|
// the session, beside the registry, so a background Run keeps a tree.
|
|
38
41
|
const store = new RunTreeStore();
|
|
39
|
-
const
|
|
42
|
+
const status = createBackgroundStatus({ registry, store });
|
|
40
43
|
pi.registerTool(
|
|
41
|
-
createRunTool({ bun, cli, registry, store,
|
|
44
|
+
createRunTool({ bun, cli, registry, store, status, sendMessage: pi.sendMessage.bind(pi) }),
|
|
42
45
|
);
|
|
43
46
|
pi.registerTool(createStatusTool(registry));
|
|
44
47
|
pi.registerMessageRenderer("yaag-run-complete", createRunCompleteRenderer());
|
|
@@ -55,7 +58,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
55
58
|
|
|
56
59
|
pi.on("session_start", (_event, ctx) => {
|
|
57
60
|
if (bun === null) ctx.ui.notify(report, "error");
|
|
58
|
-
if (ctx.hasUI)
|
|
61
|
+
if (ctx.hasUI) status.bind(ctx.ui);
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
// Commands and tools have separate namespaces: this CLI-resolution command
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** What a Run record remembers about the process that carried it. */
|
|
4
|
+
export interface ProcessIdentity {
|
|
5
|
+
readonly pid: number;
|
|
6
|
+
/** Kernel start time of that pid, when the platform exposes it. */
|
|
7
|
+
readonly startedAt: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the start time of a live pid, so a later session can tell the original
|
|
12
|
+
* process from an unrelated one that inherited the pid. Linux exposes it as
|
|
13
|
+
* field 22 of `/proc/<pid>/stat`; every other platform returns `null` and the
|
|
14
|
+
* liveness probe falls back to the pid alone.
|
|
15
|
+
*/
|
|
16
|
+
export function readProcessStart(pid: number): string | null {
|
|
17
|
+
if (process.platform !== "linux") return null;
|
|
18
|
+
try {
|
|
19
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
20
|
+
// The second field is the executable name in parentheses and may itself
|
|
21
|
+
// contain spaces and parentheses, so fields are counted after the last ")".
|
|
22
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
23
|
+
return fields[19] ?? null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reports whether the exact process a record names is still running. A pid that
|
|
31
|
+
* exists but reports a different start time is a reused pid, and is treated as
|
|
32
|
+
* dead: killing it would end an unrelated process.
|
|
33
|
+
*/
|
|
34
|
+
export function isProcessAlive(identity: ProcessIdentity): boolean {
|
|
35
|
+
if (!Number.isInteger(identity.pid) || identity.pid <= 0) return false;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(identity.pid, 0);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// EPERM means the process exists but belongs to another user.
|
|
40
|
+
return isPermissionError(error);
|
|
41
|
+
}
|
|
42
|
+
if (identity.startedAt === null) return true;
|
|
43
|
+
const current = readProcessStart(identity.pid);
|
|
44
|
+
return current === null || current === identity.startedAt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPermissionError(error: unknown): boolean {
|
|
48
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
49
|
+
const { code } = error;
|
|
50
|
+
return code === "EPERM";
|
|
51
|
+
}
|
package/src/run-details.ts
CHANGED
|
@@ -43,7 +43,8 @@ function isRunSummary(value: unknown): value is RunSummary {
|
|
|
43
43
|
value.runState === "ended" &&
|
|
44
44
|
isOutcome(value.outcome) &&
|
|
45
45
|
typeof value.ok === "boolean" &&
|
|
46
|
-
nullableNumber(value.endedAt)
|
|
46
|
+
nullableNumber(value.endedAt) &&
|
|
47
|
+
optionalString(value.checkpointLost)
|
|
47
48
|
);
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -207,7 +208,8 @@ function isEvent(value: unknown): value is LifecycleEvent {
|
|
|
207
208
|
number(value.cost) &&
|
|
208
209
|
nullableTokens(value.tokens) &&
|
|
209
210
|
typeof value.incomplete === "boolean" &&
|
|
210
|
-
natural(value.worstFrameGapMs)
|
|
211
|
+
natural(value.worstFrameGapMs) &&
|
|
212
|
+
optionalString(value.checkpointLost)
|
|
211
213
|
);
|
|
212
214
|
default:
|
|
213
215
|
return false;
|
package/src/run-foreground.ts
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The two blocking `yaag_run` paths (
|
|
3
|
-
* client gets, and the
|
|
2
|
+
* The two blocking `yaag_run` paths (architecture §9): the details-only stream every
|
|
3
|
+
* client gets, and the inline frame a tui-mode Host Session also draws.
|
|
4
4
|
*
|
|
5
|
-
* Both return the same model-visible contract, so
|
|
6
|
-
*
|
|
5
|
+
* Both return the same model-visible contract, so drawing the frame changes
|
|
6
|
+
* nothing the model can see, and neither path opens a modal: the Host Session
|
|
7
|
+
* keeps its input loop while the tool call blocks.
|
|
7
8
|
*/
|
|
8
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type
|
|
10
|
+
import { renderInlineRun, type TreeState } from "@yaag/tui";
|
|
10
11
|
import type { RunDetails } from "./run-details.ts";
|
|
11
|
-
import { openRunTreeView } from "./run-foreground-view.ts";
|
|
12
12
|
import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
|
|
13
|
-
import { failure, observedSettlement, toError
|
|
13
|
+
import { failure, observedSettlement, toError } from "./run-settlement.ts";
|
|
14
14
|
import type { RunTreeStore } from "./run-trees.ts";
|
|
15
15
|
import { toUsage } from "./usage.ts";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The pi widget key one blocking Run draws under.
|
|
19
|
+
*
|
|
20
|
+
* The key carries the Run id, because pi executes sibling tool calls
|
|
21
|
+
* concurrently: two blocking Runs keep two frames, and each one clears only
|
|
22
|
+
* its own.
|
|
23
|
+
*/
|
|
24
|
+
export function foregroundWidgetKey(id: string): string {
|
|
25
|
+
return `yaag-run:${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Line budget for the inline frame; pi truncates a widget past 10 lines. */
|
|
29
|
+
const WIDGET_MAX_LINES = 10;
|
|
30
|
+
|
|
31
|
+
/** Width the inline frame is clamped to; pi wraps nothing for a widget. */
|
|
32
|
+
const WIDGET_WIDTH = 100;
|
|
33
|
+
|
|
17
34
|
/** What `execute` returns for a blocking Run. */
|
|
18
35
|
export interface ForegroundResult {
|
|
19
36
|
content: { type: "text"; text: string }[];
|
|
@@ -34,25 +51,23 @@ export interface ForegroundOptions {
|
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
/**
|
|
37
|
-
* `ForegroundOptions` plus what
|
|
54
|
+
* `ForegroundOptions` plus what drawing the inline frame needs.
|
|
38
55
|
*
|
|
39
|
-
* Only valid when `
|
|
40
|
-
*
|
|
56
|
+
* Only valid when `inlineAvailable()` is true; `ctx.ui` is touched on no other
|
|
57
|
+
* path.
|
|
41
58
|
*/
|
|
42
|
-
export interface
|
|
59
|
+
export interface InlineOptions extends ForegroundOptions {
|
|
43
60
|
readonly ctx: ExtensionContext;
|
|
44
|
-
/** Ingested from Run start, so the
|
|
61
|
+
/** Ingested from Run start by the caller, so the frame misses no early Ask. */
|
|
45
62
|
readonly state: TreeState;
|
|
46
|
-
/** Detaching hands the Run to the
|
|
63
|
+
/** Detaching hands the Run to the footer segment. */
|
|
47
64
|
readonly store: RunTreeStore;
|
|
48
65
|
/** Registers the `yaag-run-complete` follow-up; called only on detach. */
|
|
49
66
|
announce(): void;
|
|
50
|
-
/** Receives the redraw handle, so a pushed fd 3 update can reach the view. */
|
|
51
|
-
onOpen?(view: { touch(): void }): void;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
/** Whether this call may
|
|
55
|
-
export function
|
|
69
|
+
/** Whether this call may draw the inline frame; tui mode only (architecture §9). */
|
|
70
|
+
export function inlineAvailable(ctx: ExtensionContext, background: boolean): boolean {
|
|
56
71
|
return !background && ctx.mode === "tui" && ctx.hasUI;
|
|
57
72
|
}
|
|
58
73
|
|
|
@@ -77,63 +92,95 @@ export async function foregroundResult(options: ForegroundOptions): Promise<Fore
|
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
/**
|
|
80
|
-
* The
|
|
81
|
-
*
|
|
95
|
+
* The inline path: a read-only frame of the Run tree is drawn beside the chat
|
|
96
|
+
* while the tool call blocks, and the frame is cleared when the call returns.
|
|
82
97
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
98
|
+
* `esc` aborts the tool call, and an abort detaches rather than stops: the Run
|
|
99
|
+
* becomes a background Run, joins the footer segment, and its completion
|
|
100
|
+
* arrives through the `yaag-run-complete` follow-up. Stopping a Run is `/yaag`
|
|
101
|
+
* → `ctrl+q` → Stop Run (ADR-0008).
|
|
87
102
|
*/
|
|
88
|
-
export async function
|
|
89
|
-
options: InteractiveOptions,
|
|
90
|
-
): Promise<ForegroundResult> {
|
|
103
|
+
export async function foregroundInline(options: InlineOptions): Promise<ForegroundResult> {
|
|
91
104
|
const { run, registry, signal, ctx } = options;
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const frame = createFrame(ctx, run.id, options.state);
|
|
106
|
+
let detached = false;
|
|
107
|
+
// Resolved by the detach itself, so the abort needs no second listener and
|
|
108
|
+
// leaves nothing registered on a signal the tool call outlives.
|
|
109
|
+
let onDetached: () => void = () => {};
|
|
110
|
+
const detaching = new Promise<undefined>((resolve) => {
|
|
111
|
+
onDetached = () => resolve(undefined);
|
|
98
112
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
const detach = (): void => {
|
|
114
|
+
if (detached) return;
|
|
115
|
+
detached = true;
|
|
116
|
+
options.store.adopt(run.id);
|
|
117
|
+
options.announce();
|
|
118
|
+
onDetached();
|
|
103
119
|
};
|
|
104
|
-
signal?.addEventListener("abort",
|
|
105
|
-
|
|
120
|
+
signal?.addEventListener("abort", detach, { once: true });
|
|
121
|
+
const unsubscribe = options.store.subscribe((id) => {
|
|
122
|
+
if (id === run.id && !detached) frame.draw();
|
|
123
|
+
});
|
|
124
|
+
frame.draw();
|
|
106
125
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
if (signal?.aborted === true) detach();
|
|
127
|
+
if (detached) return detachedResult(run);
|
|
109
128
|
const settled = observedSettlement(run.id, run.outcome, registry).then(
|
|
110
|
-
(settlement):
|
|
111
|
-
(reason):
|
|
129
|
+
(settlement): RunSettlement => settlement,
|
|
130
|
+
(reason): RunSettlement => ({ kind: "rejected", reason }),
|
|
112
131
|
);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
// The detach resolves this race, so the tool call returns while the Run
|
|
133
|
+
// keeps executing; `settled` stays a handled promise either way.
|
|
134
|
+
const settlement = await Promise.race([settled, detaching]);
|
|
135
|
+
// `detached` is checked beside the settlement: an abort that lands in the
|
|
136
|
+
// same synchronous stretch as the settlement already announced the Run, and
|
|
137
|
+
// returning its value here would report the same Run to the model twice.
|
|
138
|
+
if (settlement === undefined || detached) {
|
|
139
|
+
detach();
|
|
119
140
|
return detachedResult(run);
|
|
120
141
|
}
|
|
121
|
-
|
|
122
|
-
const exit = await view.exit;
|
|
123
|
-
if (exit === "detached") {
|
|
124
|
-
options.announce();
|
|
125
|
-
return detachedResult(run);
|
|
126
|
-
}
|
|
127
|
-
if (signal?.aborted === true) throw new Error("yaag_run: cancelled");
|
|
128
|
-
return finalResult(run.id, await settled.then(({ settlement }) => settlement));
|
|
142
|
+
return finalResult(run.id, settlement);
|
|
129
143
|
} finally {
|
|
130
|
-
signal?.removeEventListener("abort",
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
signal?.removeEventListener("abort", detach);
|
|
145
|
+
unsubscribe();
|
|
146
|
+
frame.clear();
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
|
|
136
|
-
|
|
150
|
+
/** One inline frame: a redraw and a clear, both idempotent after the clear. */
|
|
151
|
+
interface InlineFrame {
|
|
152
|
+
draw(): void;
|
|
153
|
+
clear(): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createFrame(ctx: ExtensionContext, id: string, state: TreeState): InlineFrame {
|
|
157
|
+
const key = foregroundWidgetKey(id);
|
|
158
|
+
let cleared = false;
|
|
159
|
+
let drawn: readonly string[] | undefined;
|
|
160
|
+
return {
|
|
161
|
+
draw(): void {
|
|
162
|
+
if (cleared) return;
|
|
163
|
+
const lines = renderInlineRun(state, {
|
|
164
|
+
now: Date.now(),
|
|
165
|
+
width: WIDGET_WIDTH,
|
|
166
|
+
label: id,
|
|
167
|
+
maxLines: WIDGET_MAX_LINES,
|
|
168
|
+
});
|
|
169
|
+
if (drawn !== undefined && sameLines(drawn, lines)) return;
|
|
170
|
+
drawn = lines;
|
|
171
|
+
ctx.ui.setWidget(key, [...lines]);
|
|
172
|
+
},
|
|
173
|
+
clear(): void {
|
|
174
|
+
if (cleared) return;
|
|
175
|
+
cleared = true;
|
|
176
|
+
ctx.ui.setWidget(key, undefined);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sameLines(left: readonly string[], right: readonly string[]): boolean {
|
|
182
|
+
return left.length === right.length && left.every((line, index) => line === right[index]);
|
|
183
|
+
}
|
|
137
184
|
|
|
138
185
|
function finalResult(id: string, settlement: RunSettlement): ForegroundResult {
|
|
139
186
|
if (settlement.kind === "rejected") throw toError(settlement.reason);
|
package/src/run-id.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { customAlphabet } from "nanoid";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lowercase letters and digits only: a Run id is typed back by a human into
|
|
5
|
+
* `yaag_status` and `yaag_stop`, so the alphabet holds no case distinction and
|
|
6
|
+
* no punctuation.
|
|
7
|
+
*/
|
|
8
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
9
|
+
|
|
10
|
+
/** Nine random characters over 36 symbols; the `r` prefix is not random. */
|
|
11
|
+
const ID_LENGTH = 9;
|
|
12
|
+
|
|
13
|
+
const nanoid = customAlphabet(ALPHABET, ID_LENGTH);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mints one globally unique Run id.
|
|
17
|
+
*
|
|
18
|
+
* The id must stay unique across Host Sessions, because Run records outlive the
|
|
19
|
+
* session that started them (ticket 02); a per-session counter would collide
|
|
20
|
+
* with every earlier session's `r1`.
|
|
21
|
+
*/
|
|
22
|
+
export function mintRunId(): string {
|
|
23
|
+
return `r${nanoid()}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { initialSummary, type RunSummary } from "@yaag/runtime";
|
|
2
|
+
import type { ProcessIdentity } from "./process-liveness.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The durable state of a Run.
|
|
6
|
+
*
|
|
7
|
+
* `live` and `finished` are written by the session that owns the Run.
|
|
8
|
+
* `interrupted` and `orphaned` are written by a later session that found a
|
|
9
|
+
* `live` record its own memory knows nothing about: the child is dead, or the
|
|
10
|
+
* child is still running without an owner (ticket 02).
|
|
11
|
+
*/
|
|
12
|
+
export type PersistedRunState = "live" | "finished" | "interrupted" | "orphaned";
|
|
13
|
+
|
|
14
|
+
/** How a Run ended, reduced to values that survive JSON. */
|
|
15
|
+
export type PersistedOutcome =
|
|
16
|
+
| { readonly kind: "fulfilled"; readonly code: number | null; readonly result: string }
|
|
17
|
+
| { readonly kind: "rejected"; readonly reason: string };
|
|
18
|
+
|
|
19
|
+
/** What starting a Run said about it; enough to describe it in a later session. */
|
|
20
|
+
export interface RunLaunch {
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly args?: string;
|
|
23
|
+
readonly record?: string;
|
|
24
|
+
readonly resume?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One Run as stored on disk, at `<runs dir>/<id>.json`. */
|
|
28
|
+
export interface RunRecord {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly launch: RunLaunch;
|
|
31
|
+
readonly process: ProcessIdentity | null;
|
|
32
|
+
readonly startedAt: string;
|
|
33
|
+
readonly endedAt: string | null;
|
|
34
|
+
readonly state: PersistedRunState;
|
|
35
|
+
readonly summary: RunSummary;
|
|
36
|
+
readonly outcome: PersistedOutcome | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Narrows an untrusted parsed JSON value to a {@link RunRecord}.
|
|
41
|
+
*
|
|
42
|
+
* A record may have been written by an older yaag, or truncated by a crash, so
|
|
43
|
+
* every field the extension reads is checked here and a record that fails is
|
|
44
|
+
* dropped rather than repaired.
|
|
45
|
+
*/
|
|
46
|
+
export function isRunRecord(value: unknown): value is RunRecord {
|
|
47
|
+
if (typeof value !== "object" || value === null) return false;
|
|
48
|
+
const candidate: Record<string, unknown> = { ...value };
|
|
49
|
+
return (
|
|
50
|
+
typeof candidate["id"] === "string" &&
|
|
51
|
+
typeof candidate["startedAt"] === "string" &&
|
|
52
|
+
isState(candidate["state"]) &&
|
|
53
|
+
isLaunch(candidate["launch"]) &&
|
|
54
|
+
isSummary(candidate["summary"])
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A Run record for a Run that has just started. */
|
|
59
|
+
export function startedRecord(options: {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly launch: RunLaunch;
|
|
62
|
+
readonly process: ProcessIdentity | null;
|
|
63
|
+
readonly now: string;
|
|
64
|
+
}): RunRecord {
|
|
65
|
+
return {
|
|
66
|
+
id: options.id,
|
|
67
|
+
launch: options.launch,
|
|
68
|
+
process: options.process,
|
|
69
|
+
startedAt: options.now,
|
|
70
|
+
endedAt: null,
|
|
71
|
+
state: "live",
|
|
72
|
+
summary: initialSummary(),
|
|
73
|
+
outcome: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isState(value: unknown): value is PersistedRunState {
|
|
78
|
+
return (
|
|
79
|
+
value === "live" || value === "finished" || value === "interrupted" || value === "orphaned"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isLaunch(value: unknown): value is RunLaunch {
|
|
84
|
+
return (
|
|
85
|
+
typeof value === "object" && value !== null && "file" in value && typeof value.file === "string"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isSummary(value: unknown): value is RunSummary {
|
|
90
|
+
return typeof value === "object" && value !== null && "runState" in value;
|
|
91
|
+
}
|