@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.
@@ -1,6 +1,12 @@
1
1
  import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
2
+ import type { ProcessIdentity } from "./process-liveness.ts";
3
+ import { type RunLaunch, type RunRecord, startedRecord } from "./run-record.ts";
4
+ import { restoreRecords } from "./run-restore.ts";
5
+ import type { RunStore } from "./run-store.ts";
2
6
  import type { RunOutcome } from "./spawn-run.ts";
3
7
 
8
+ export type { RunLaunch } from "./run-record.ts";
9
+
4
10
  /** A Run in flight, including its stop capability and latest Summary fold. */
5
11
  export interface LiveRun {
6
12
  readonly id: string;
@@ -21,30 +27,51 @@ export interface FinishedRun {
21
27
  readonly outcome: RunSettlement;
22
28
  }
23
29
 
30
+ /** A Run read back from disk, started by an earlier Host Session. */
31
+ export interface RestoredRun {
32
+ readonly id: string;
33
+ readonly summary: RunSummary;
34
+ readonly record: RunRecord;
35
+ }
36
+
24
37
  /** The retained state of a registered Run. */
25
38
  export type RegisteredRun =
26
39
  | { readonly state: "live"; readonly run: LiveRun }
27
- | { readonly state: "finished"; readonly run: FinishedRun };
40
+ | { readonly state: "finished"; readonly run: FinishedRun }
41
+ | { readonly state: "restored"; readonly run: RestoredRun };
28
42
 
29
43
  /** What the registry knows about an id. */
30
44
  export type RunStatus = RegisteredRun | { readonly state: "unknown" };
31
45
 
46
+ /** How often a Run's latest Summary is persisted while it runs. */
47
+ export const SUMMARY_WRITE_INTERVAL_MS = 2_000;
48
+
49
+ export interface RunRegistryOptions {
50
+ /** Durable mirror; omit it and the registry stays memory-only. */
51
+ readonly store?: RunStore;
52
+ readonly now?: () => Date;
53
+ }
54
+
32
55
  /**
33
- * Per-session source of truth for every Run started by the Host Session.
56
+ * Source of truth for every Run the Host Session can address.
34
57
  *
35
- * It mints monotonically increasing ids, retains live stop capabilities, and
36
- * preserves final outcomes for the rest of the session. Concurrent Runs remain
37
- * independently controllable (ADR-0008).
58
+ * It retains live stop capabilities and final outcomes in memory, and mirrors
59
+ * each Run to a {@link RunStore} so a restart of the Host Session does not lose
60
+ * the id (ticket 02). Concurrent Runs remain independently controllable
61
+ * (ADR-0008).
38
62
  */
39
63
  export class RunRegistry {
40
- #counter = 0;
41
64
  readonly #live = new Map<string, LiveRun>();
42
65
  readonly #runs = new Map<string, RegisteredRun>();
43
-
44
- /** The next Run id for this session; it is never reused. */
45
- mint(): string {
46
- this.#counter += 1;
47
- return `r${this.#counter}`;
66
+ readonly #records = new Map<string, RunRecord>();
67
+ readonly #lastWrite = new Map<string, number>();
68
+ readonly #trailing = new Map<string, ReturnType<typeof setTimeout>>();
69
+ readonly #store: RunStore | undefined;
70
+ readonly #now: () => Date;
71
+
72
+ constructor(options: RunRegistryOptions = {}) {
73
+ this.#store = options.store;
74
+ this.#now = options.now ?? (() => new Date());
48
75
  }
49
76
 
50
77
  /** Every currently-live Run, in registration order. */
@@ -52,31 +79,72 @@ export class RunRegistry {
52
79
  return [...this.#live.values()];
53
80
  }
54
81
 
55
- /** Every registered Run, live and finished, in registration order. */
82
+ /** Every addressable Run: this session's Runs, plus unaccounted earlier ones. */
56
83
  get runs(): readonly RegisteredRun[] {
57
84
  return [...this.#runs.values()];
58
85
  }
59
86
 
60
- /** Every registered Run id, in registration order. */
87
+ /** Every addressable Run id, in registration order. */
61
88
  get knownIds(): readonly string[] {
62
89
  return [...this.#runs.keys()];
63
90
  }
64
91
 
92
+ /**
93
+ * Loads persisted Runs, settles the ones an earlier session left `live`, and
94
+ * seeds the still-unaccounted ones. Finished Runs of earlier sessions stay on
95
+ * disk and are reachable through {@link recall} by id.
96
+ */
97
+ async restore(): Promise<void> {
98
+ if (this.#store === undefined) return;
99
+ const records = await restoreRecords({ store: this.#store, now: this.#now });
100
+ for (const record of records) {
101
+ if (record.state !== "orphaned" && record.state !== "interrupted") continue;
102
+ this.#runs.set(record.id, { state: "restored", run: restored(record) });
103
+ }
104
+ }
105
+
65
106
  /** Registers a newly-started Run and synchronously observes its settlement. */
66
- add(run: LiveRun): void {
107
+ add(run: LiveRun, launch?: RunLaunch, identity?: ProcessIdentity | null): void {
67
108
  this.#live.set(run.id, run);
68
109
  this.#runs.set(run.id, { state: "live", run });
110
+ if (launch !== undefined) this.#persistStart(run.id, launch, identity ?? null);
69
111
  void run.outcome.then(
70
112
  (outcome) => this.finish(run.id, outcome),
71
113
  (reason: unknown) => this.reject(run.id, reason),
72
114
  );
73
115
  }
74
116
 
117
+ /** Records the latest fold for a live Run and persists it at a bounded rate. */
118
+ progress(id: string, summary: RunSummary): void {
119
+ const run = this.#live.get(id);
120
+ if (run === undefined) return;
121
+ run.summary = summary;
122
+ const record = this.#records.get(id);
123
+ if (record === undefined) return;
124
+ this.#records.set(id, { ...record, summary });
125
+ const waited = this.#now().getTime() - (this.#lastWrite.get(id) ?? 0);
126
+ if (waited < SUMMARY_WRITE_INTERVAL_MS) {
127
+ this.#scheduleTrailing(id, waited);
128
+ return;
129
+ }
130
+ this.#persist(id);
131
+ }
132
+
75
133
  /** Looks up a live capability or the retained completed Run. */
76
134
  lookup(id: string): RunStatus {
77
135
  return this.#runs.get(id) ?? { state: "unknown" };
78
136
  }
79
137
 
138
+ /** Reads a Run this session never started back from the store, by id. */
139
+ async recall(id: string): Promise<RunStatus> {
140
+ const known = this.lookup(id);
141
+ if (known.state !== "unknown" || this.#store === undefined) return known;
142
+ const record = (await this.#store.load()).find((entry) => entry.id === id);
143
+ return record === undefined
144
+ ? { state: "unknown" }
145
+ : { state: "restored", run: restored(record) };
146
+ }
147
+
80
148
  /**
81
149
  * Retains a Run's first observed final outcome and removes only its live
82
150
  * capability. Repeated observers are harmless and cannot overwrite history.
@@ -88,16 +156,85 @@ export class RunRegistry {
88
156
  /** Retains a rejected child outcome with its latest terminal accounting projection. */
89
157
  reject(id: string, reason: unknown): void {
90
158
  const record = this.#runs.get(id);
91
- if (record === undefined || record.state === "finished") return;
159
+ if (record === undefined || record.state !== "live") return;
92
160
  this.#settle(id, failedSummary(record.run.summary), { kind: "rejected", reason });
93
161
  }
94
162
 
163
+ /**
164
+ * Persists the latest Summary when the interval ends. Without it, everything
165
+ * folded inside one interval would be lost if the Host Session died before
166
+ * the next write (ticket 02).
167
+ */
168
+ #scheduleTrailing(id: string, waited: number): void {
169
+ if (this.#trailing.has(id)) return;
170
+ const timer = setTimeout(() => {
171
+ this.#trailing.delete(id);
172
+ if (this.#live.has(id)) this.#persist(id);
173
+ }, SUMMARY_WRITE_INTERVAL_MS - waited);
174
+ // Housekeeping must never keep the Host Session alive.
175
+ timer.unref();
176
+ this.#trailing.set(id, timer);
177
+ }
178
+
95
179
  #settle(id: string, summary: RunSummary, outcome: RunSettlement): void {
96
180
  const record = this.#runs.get(id);
97
- if (record === undefined || record.state === "finished") return;
181
+ if (record === undefined || record.state !== "live") return;
98
182
  this.#live.delete(id);
183
+ const trailing = this.#trailing.get(id);
184
+ if (trailing !== undefined) clearTimeout(trailing);
185
+ this.#trailing.delete(id);
99
186
  this.#runs.set(id, { state: "finished", run: { id, summary, outcome } });
187
+ this.#persistEnd(id, summary, outcome);
188
+ }
189
+
190
+ #persistStart(id: string, launch: RunLaunch, identity: ProcessIdentity | null): void {
191
+ if (this.#store === undefined) return;
192
+ const store = this.#store;
193
+ this.#records.set(
194
+ id,
195
+ startedRecord({ id, launch, process: identity, now: this.#now().toISOString() }),
196
+ );
197
+ this.#persist(id);
198
+ void store.prune();
199
+ }
200
+
201
+ #persistEnd(id: string, summary: RunSummary, outcome: RunSettlement): void {
202
+ const record = this.#records.get(id);
203
+ if (record === undefined) return;
204
+ this.#records.set(id, {
205
+ ...record,
206
+ state: "finished",
207
+ endedAt: this.#now().toISOString(),
208
+ summary,
209
+ outcome: persistedOutcome(outcome),
210
+ });
211
+ this.#persist(id);
100
212
  }
213
+
214
+ #persist(id: string): void {
215
+ const record = this.#records.get(id);
216
+ if (record === undefined || this.#store === undefined) return;
217
+ this.#lastWrite.set(id, this.#now().getTime());
218
+ void this.#store.save(record);
219
+ }
220
+ }
221
+
222
+ function restored(record: RunRecord): RestoredRun {
223
+ return { id: record.id, summary: record.summary, record };
224
+ }
225
+
226
+ function persistedOutcome(outcome: RunSettlement): RunRecord["outcome"] {
227
+ if (outcome.kind === "rejected")
228
+ return { kind: "rejected", reason: errorMessage(outcome.reason) };
229
+ return {
230
+ kind: "fulfilled",
231
+ code: outcome.outcome.code,
232
+ result: outcome.outcome.stdout.trimEnd(),
233
+ };
234
+ }
235
+
236
+ function errorMessage(reason: unknown): string {
237
+ return reason instanceof Error ? reason.message : String(reason);
101
238
  }
102
239
 
103
240
  function failedSummary(summary: RunSummary): EndedRunSummary {
@@ -0,0 +1,43 @@
1
+ import { isProcessAlive, type ProcessIdentity } from "./process-liveness.ts";
2
+ import type { RunRecord } from "./run-record.ts";
3
+ import type { RunStore } from "./run-store.ts";
4
+
5
+ /** Decides whether the process a record names is still running. */
6
+ export type LivenessProbe = (identity: ProcessIdentity) => boolean;
7
+
8
+ export interface RestoreOptions {
9
+ readonly store: RunStore;
10
+ readonly isAlive?: LivenessProbe;
11
+ readonly now?: () => Date;
12
+ }
13
+
14
+ /**
15
+ * Reads every persisted Run and settles the ones a previous Host Session left
16
+ * behind.
17
+ *
18
+ * A `live` record can only be stale here, because the session that owned it is
19
+ * gone: its child is dead and the Run is `interrupted`, or its child still runs
20
+ * without an owner and the Run is `orphaned`. Both re-writes are persisted, so
21
+ * the verdict survives this session too (ticket 02).
22
+ */
23
+ export async function restoreRecords(options: RestoreOptions): Promise<readonly RunRecord[]> {
24
+ const isAlive = options.isAlive ?? isProcessAlive;
25
+ const now = options.now ?? (() => new Date());
26
+ const records = await options.store.load();
27
+ return Promise.all(records.map((record) => settleStale(record, isAlive, now, options.store)));
28
+ }
29
+
30
+ async function settleStale(
31
+ record: RunRecord,
32
+ isAlive: LivenessProbe,
33
+ now: () => Date,
34
+ store: RunStore,
35
+ ): Promise<RunRecord> {
36
+ if (record.state !== "live") return record;
37
+ const alive = record.process !== null && isAlive(record.process);
38
+ const settled: RunRecord = alive
39
+ ? { ...record, state: "orphaned" }
40
+ : { ...record, state: "interrupted", endedAt: now().toISOString() };
41
+ await store.save(settled);
42
+ return settled;
43
+ }
@@ -26,7 +26,7 @@ export async function observedSettlement(
26
26
  }
27
27
 
28
28
  /**
29
- * The Result region's content for a settled Run, shared by the foreground view
29
+ * The Result region's content for a settled Run, shared by the `/yaag` view
30
30
  * and `/yaag`'s settled background view.
31
31
  */
32
32
  export function viewResult(settlement: RunSettlement): RunViewResult {
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isRunRecord, type RunRecord } from "./run-record.ts";
5
+ import { resolveRunsDirectory } from "./runs-dir.ts";
6
+
7
+ /** How many finished records are kept before the oldest ones are pruned. */
8
+ export const FINISHED_RECORD_CAP = 200;
9
+
10
+ export interface RunStoreOptions {
11
+ readonly directory?: string;
12
+ readonly cap?: number;
13
+ }
14
+
15
+ /**
16
+ * The durable side of the Run registry: one JSON file per Run.
17
+ *
18
+ * Every method is best-effort and never throws — a Host Session must keep
19
+ * working when the state directory is read-only or full. Writes go to a temp
20
+ * file and are renamed into place, so a reader never sees a half-written record
21
+ * (ADR-0021).
22
+ */
23
+ export class RunStore {
24
+ readonly #directory: string;
25
+ readonly #cap: number;
26
+ #chain: Promise<void> = Promise.resolve();
27
+
28
+ constructor(options: RunStoreOptions = {}) {
29
+ this.#directory = options.directory ?? resolveRunsDirectory();
30
+ this.#cap = options.cap ?? FINISHED_RECORD_CAP;
31
+ }
32
+
33
+ get directory(): string {
34
+ return this.#directory;
35
+ }
36
+
37
+ /** Queues one record write; later writes for any Run never overtake earlier ones. */
38
+ save(record: RunRecord): Promise<void> {
39
+ this.#chain = this.#chain.then(() => this.#write(record));
40
+ return this.#chain;
41
+ }
42
+
43
+ /** Resolves once every queued write has been attempted. */
44
+ async settled(): Promise<void> {
45
+ await this.#chain;
46
+ }
47
+
48
+ /** Every readable record, oldest start first. Unreadable records are skipped. */
49
+ async load(): Promise<readonly RunRecord[]> {
50
+ let entries: readonly string[];
51
+ try {
52
+ entries = await readdir(this.#directory);
53
+ } catch {
54
+ return [];
55
+ }
56
+ const records = await Promise.all(
57
+ entries.filter((entry) => entry.endsWith(".json")).map((entry) => this.#read(entry)),
58
+ );
59
+ return records
60
+ .filter((record): record is RunRecord => record !== null)
61
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
62
+ }
63
+
64
+ /**
65
+ * Removes the oldest finished records above the cap. `live`, `orphaned`, and
66
+ * `interrupted` records are never pruned: they are the only trace of a Run
67
+ * nobody accounted for.
68
+ */
69
+ async prune(): Promise<void> {
70
+ const finished = (await this.load()).filter((record) => record.state === "finished");
71
+ const excess = finished.slice(0, Math.max(0, finished.length - this.#cap));
72
+ await Promise.all(excess.map((record) => this.remove(record.id)));
73
+ }
74
+
75
+ async remove(id: string): Promise<void> {
76
+ try {
77
+ await unlink(join(this.#directory, `${id}.json`));
78
+ } catch {
79
+ // Already gone, or never written; nothing to report.
80
+ }
81
+ }
82
+
83
+ async #write(record: RunRecord): Promise<void> {
84
+ const target = join(this.#directory, `${record.id}.json`);
85
+ const temp = `${target}.${randomUUID()}.tmp`;
86
+ try {
87
+ await mkdir(this.#directory, { recursive: true });
88
+ await writeFile(temp, JSON.stringify(record), "utf8");
89
+ await rename(temp, target);
90
+ } catch {
91
+ await unlink(temp).catch(() => {});
92
+ }
93
+ }
94
+
95
+ async #read(entry: string): Promise<RunRecord | null> {
96
+ try {
97
+ const value: unknown = JSON.parse(await readFile(join(this.#directory, entry), "utf8"));
98
+ return isRunRecord(value) ? value : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+ }
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { BackgroundWidget } from "./background-widget.ts";
3
+ import type { BackgroundStatus } from "./background-status.ts";
4
4
  import { resolveBun } from "./resolve-bun.ts";
5
5
  import { resolveCliEntry } from "./resolve-cli.ts";
6
6
  import type { RunDetails } from "./run-details.ts";
@@ -40,10 +40,10 @@ export async function run(
40
40
  readonly registry?: RunRegistry;
41
41
  readonly sent?: Sent[];
42
42
  readonly start?: (options: import("./spawn-run.ts").StartRunOptions) => RunHandle;
43
- /** A tui-mode context, for the interactive foreground path. */
43
+ /** A tui-mode context, for the inline foreground path. */
44
44
  readonly ctx?: ExtensionContext;
45
45
  readonly store?: RunTreeStore;
46
- readonly widget?: BackgroundWidget;
46
+ readonly status?: BackgroundStatus;
47
47
  } = {},
48
48
  ): Promise<{
49
49
  readonly text: string;
@@ -56,7 +56,7 @@ export async function run(
56
56
  registry: opts.registry ?? new RunRegistry(),
57
57
  ...(opts.start === undefined ? {} : { start: opts.start }),
58
58
  ...(opts.store === undefined ? {} : { store: opts.store }),
59
- ...(opts.widget === undefined ? {} : { widget: opts.widget }),
59
+ ...(opts.status === undefined ? {} : { status: opts.status }),
60
60
  sendMessage: (message, options) =>
61
61
  void opts.sent?.push({
62
62
  content: message.content,
package/src/run-tool.ts CHANGED
@@ -4,14 +4,16 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { initialSummary } from "@yaag/runtime";
6
6
  import { Type } from "typebox";
7
- import { type BackgroundWidget, createBackgroundWidget } from "./background-widget.ts";
7
+ import { type BackgroundStatus, createBackgroundStatus } from "./background-status.ts";
8
8
  import type { RunDetails } from "./run-details.ts";
9
- import { foregroundInteractive, foregroundResult, interactiveAvailable } from "./run-foreground.ts";
9
+ import { foregroundInline, foregroundResult, inlineAvailable } from "./run-foreground.ts";
10
10
  import { RunTreeComponent } from "./run-tree-component.ts";
11
11
  import { RunTreeStore } from "./run-trees.ts";
12
12
 
13
13
  export type { RunDetails } from "./run-details.ts";
14
14
 
15
+ import { type ProcessIdentity, readProcessStart } from "./process-liveness.ts";
16
+ import { mintRunId } from "./run-id.ts";
15
17
  import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
16
18
  import { errorText, failure, observedSettlement } from "./run-settlement.ts";
17
19
  import { type RunHandle, type RunOutcome, type StartRunOptions, startRun } from "./spawn-run.ts";
@@ -48,8 +50,8 @@ export interface RunToolOptions {
48
50
  readonly start?: (options: StartRunOptions) => RunHandle;
49
51
  /** Per-session renderer projections; defaulted so a test may omit it. */
50
52
  readonly store?: RunTreeStore;
51
- /** The combined inline widget; defaulted so a test may omit it. */
52
- readonly widget?: BackgroundWidget;
53
+ /** The background footer segment; defaulted so a test may omit it. */
54
+ readonly status?: BackgroundStatus;
53
55
  }
54
56
 
55
57
  const DESCRIPTION = [
@@ -88,7 +90,7 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
88
90
  const { bun, cli, registry, sendMessage } = deps;
89
91
  const start = deps.start ?? startRun;
90
92
  const store = deps.store ?? new RunTreeStore();
91
- const widget = deps.widget ?? createBackgroundWidget({ registry, store });
93
+ const status = deps.status ?? createBackgroundStatus({ registry, store });
92
94
  return {
93
95
  name: "yaag_run",
94
96
  label: "Run",
@@ -122,13 +124,12 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
122
124
  throw new Error(`yaag_run: no such Orchestration Program: ${file}`);
123
125
  }
124
126
 
125
- const id = registry.mint();
127
+ const id = mintRunId();
126
128
  const background = params.background === true;
127
- const interactive = interactiveAvailable(ctx, background);
129
+ const inline = inlineAvailable(ctx, background);
128
130
  // Attached before start(): the Ask ledger folds from events, so a state
129
- // created when a view opens would miss every early Ask.
131
+ // created when the frame is first drawn would miss every early Ask.
130
132
  const state = store.attach(id, background ? "background" : "foreground");
131
- const tree = interactive ? state : undefined;
132
133
  const run = registeredRun({
133
134
  bun,
134
135
  cli,
@@ -140,33 +141,28 @@ export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof param
140
141
  start,
141
142
  onUpdate,
142
143
  store,
143
- onIngest: () => view?.touch(),
144
144
  });
145
- let view: { touch(): void } | undefined;
146
145
 
147
146
  if (background) {
148
147
  // Attached before returning, so a Run that ends immediately still
149
148
  // announces itself (ADR-0005).
150
- void announce(id, run.outcome, registry, sendMessage, widget);
151
- widget.refresh();
149
+ void announce(id, run.outcome, registry, sendMessage, status);
150
+ status.refresh();
152
151
  return {
153
152
  content: [{ type: "text", text: `Run ${id} started in the background.` }],
154
153
  details: { summary: run.summary, id },
155
154
  };
156
155
  }
157
156
 
158
- if (tree === undefined) return await foregroundResult({ run, registry, signal });
159
- return await foregroundInteractive({
157
+ if (!inline) return await foregroundResult({ run, registry, signal });
158
+ return await foregroundInline({
160
159
  run,
161
160
  registry,
162
161
  signal,
163
162
  ctx,
164
- state: tree,
163
+ state,
165
164
  store,
166
- announce: () => void announce(id, run.outcome, registry, sendMessage, widget),
167
- onOpen: (opened) => {
168
- view = opened;
169
- },
165
+ announce: () => void announce(id, run.outcome, registry, sendMessage, status),
170
166
  });
171
167
  },
172
168
  };
@@ -195,11 +191,11 @@ async function announce(
195
191
  outcome: Promise<RunOutcome>,
196
192
  registry: RunRegistry,
197
193
  sendMessage: SendMessage,
198
- widget: BackgroundWidget,
194
+ status: BackgroundStatus,
199
195
  ): Promise<void> {
200
196
  const settlement = await observedSettlement(id, outcome, registry);
201
- // The Run left `registry.live`, so its widget entry must go with it.
202
- widget.refresh();
197
+ // The Run left `registry.live`, so the footer segment must drop it.
198
+ status.refresh();
203
199
  const content = completionContent(id, settlement);
204
200
  const details = completionDetails(id, settlement, registry);
205
201
  await sendMessage<RunDetails>(
@@ -221,10 +217,7 @@ function registeredRun(options: {
221
217
  readonly onUpdate?: (partial: { readonly content: []; readonly details: RunDetails }) => void;
222
218
  /** The projection store; it ingests each occurrence exactly once. */
223
219
  readonly store: RunTreeStore;
224
- /** Redraw request for the pushed path: fd 3 fold → ingest → requestRender. */
225
- readonly onIngest?: () => void;
226
220
  }): LiveRun {
227
- let run: LiveRun;
228
221
  const handle = options.start({
229
222
  bun: options.bun,
230
223
  cli: options.cli,
@@ -233,17 +226,32 @@ function registeredRun(options: {
233
226
  record: options.record,
234
227
  resume: options.resume,
235
228
  onProgress: (summary, event, sequence) => {
236
- run.summary = summary;
229
+ options.registry.progress(options.id, summary);
237
230
  options.store.ingest(options.id, { summary, event, sequence, id: options.id });
238
231
  options.onUpdate?.({ content: [], details: { summary, event, sequence, id: options.id } });
239
- options.onIngest?.();
240
232
  },
241
233
  });
242
- run = { id: options.id, stop: handle.stop, outcome: handle.outcome, summary: initialSummary() };
243
- options.registry.add(run);
234
+ const run: LiveRun = {
235
+ id: options.id,
236
+ stop: handle.stop,
237
+ outcome: handle.outcome,
238
+ summary: initialSummary(),
239
+ };
240
+ const launch = {
241
+ file: options.file,
242
+ ...(options.args === undefined ? {} : { args: options.args }),
243
+ ...(options.record === undefined ? {} : { record: options.record }),
244
+ ...(options.resume === undefined ? {} : { resume: options.resume }),
245
+ };
246
+ options.registry.add(run, launch, processIdentity(handle.pid));
244
247
  return run;
245
248
  }
246
249
 
250
+ /** A pid alone cannot be trusted after a restart, so its start time goes with it. */
251
+ function processIdentity(pid: number | undefined): ProcessIdentity | null {
252
+ return pid === undefined ? null : { pid, startedAt: readProcessStart(pid) };
253
+ }
254
+
247
255
  function completionContent(id: string, settlement: RunSettlement): string {
248
256
  if (settlement.kind === "rejected") return `Run ${id} failed: ${errorText(settlement.reason)}`;
249
257
  const { outcome } = settlement;
@@ -3,7 +3,8 @@
3
3
  * `yaag_run` tool result or its details-only progress frames.
4
4
  *
5
5
  * It is not interactive — the rpc-mode and details-only path has no input focus
6
- * (spec §4). The interactive foreground view is `run-foreground-view.ts`.
6
+ * (architecture §9). The inline frame a blocking Run draws is `run-foreground.ts`, and
7
+ * the interactive view is `/yaag` (`yaag-command.ts`).
7
8
  */
8
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
10
  import { renderTree, TreeState } from "@yaag/tui";
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * The read-only half of `RunTreeViewHost`, adapted from a pi context.
3
3
  *
4
- * Both Run surfaces the blocking foreground view and `/yaag` compose it,
5
- * so the property that a Peek observes and never sends is asserted in one
6
- * place: nothing here can prompt an Agent. The caller adds `stop`, `detach`,
7
- * and `done`.
4
+ * `/yaag` composes it, so the property that a Peek observes and never sends is
5
+ * asserted in one place: nothing here can prompt an Agent. The caller adds
6
+ * `stop` and `done`.
8
7
  */
9
8
  import { readFile } from "node:fs/promises";
10
9
  import { copyToClipboard, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
@@ -22,7 +21,7 @@ export interface RunTreeHostOptions {
22
21
  }
23
22
 
24
23
  /** The read-only capabilities every Run view shares. */
25
- export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "detach" | "done">;
24
+ export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "done">;
26
25
 
27
26
  /** Builds the read-only host; it never writes to an Agent or to the Run. */
28
27
  export function createRunTreeHost(options: RunTreeHostOptions): ReadOnlyRunTreeHost {
package/src/run-trees.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * `RunRegistry` stays the domain record — ids, stop capabilities, outcomes —
5
5
  * and this store holds the bounded renderer-only fold beside it, so the inline
6
- * widget and `/yaag` read the same state the foreground view reads. A
6
+ * frame of a blocking Run and `/yaag` read the same state. A
7
7
  * `TreeState` is bounded on every axis (node cap, Ask-ledger pruning, 2-line
8
8
  * output tails), so keeping one per Run for the session is bounded memory.
9
9
  */
10
10
  import { TreeState, type TreeUpdate } from "@yaag/tui";
11
11
 
12
- /** Which surface owns a Run: the blocking tool call, or the widget. */
12
+ /** Which surface owns a Run: the blocking tool call, or the background. */
13
13
  export type RunKind = "foreground" | "background";
14
14
 
15
15
  interface Entry {
@@ -49,8 +49,8 @@ export class RunTreeStore {
49
49
 
50
50
  /**
51
51
  * Converts a foreground Run to a background Run after a detach, then
52
- * notifies subscribers so the combined inline widget picks the Run up at
53
- * once, without waiting for a later Lifecycle Event.
52
+ * notifies subscribers so the footer segment counts the Run at once, without
53
+ * waiting for a later Lifecycle Event.
54
54
  */
55
55
  adopt(id: string): void {
56
56
  const found = this.#entries.get(id);
@@ -0,0 +1,18 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ const RUNS_SUFFIX = join("yaag", "runs");
5
+
6
+ /**
7
+ * Resolves where Run records are stored: `YAAG_RUNS_DIR`, then an absolute
8
+ * `XDG_STATE_HOME`, then `$HOME/.local/state`. A relative `XDG_STATE_HOME` is
9
+ * ignored (XDG spec). Same conventions as the checkpoint directory (ADR-0021).
10
+ */
11
+ export function resolveRunsDirectory(env: NodeJS.ProcessEnv = process.env): string {
12
+ const override = env["YAAG_RUNS_DIR"];
13
+ if (override !== undefined && override !== "") return override;
14
+ const xdg = env["XDG_STATE_HOME"];
15
+ if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, RUNS_SUFFIX);
16
+ const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
17
+ return join(home, ".local", "state", RUNS_SUFFIX);
18
+ }