@yaag/extension 0.5.2 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@earendil-works/pi-tui": "^0.84.0",
27
- "@yaag/cli": "0.5.2",
28
- "@yaag/runtime": "0.5.2",
29
- "@yaag/tui": "0.5.2",
27
+ "@yaag/cli": "0.6.0",
28
+ "@yaag/runtime": "0.6.0",
29
+ "@yaag/tui": "0.6.0",
30
30
  "nanoid": "^6.0.1"
31
31
  },
32
32
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -35,7 +35,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
35
35
  // One registry per session, mirrored to a durable store so a restart of this
36
36
  // Host Session keeps every Run id addressable (ticket 02).
37
37
  const registry = new RunRegistry({ store: new RunStore() });
38
- await registry.restore();
39
38
  // The renderer projections and the background footer segment both live for
40
39
  // the session, beside the registry, so a background Run keeps a tree.
41
40
  const store = new RunTreeStore();
@@ -56,9 +55,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
56
55
  const appendYaagPrompt = createSystemPromptAppender(() => findProgramDirectories(process.cwd()));
57
56
  pi.on("before_agent_start", (event) => appendYaagPrompt(event));
58
57
 
59
- pi.on("session_start", (_event, ctx) => {
58
+ pi.on("session_start", async (_event, ctx) => {
60
59
  if (bun === null) ctx.ui.notify(report, "error");
61
60
  if (ctx.hasUI) status.bind(ctx.ui);
61
+ // The pi session id is knowable only here, and `/resume` rebinds this
62
+ // extension and fires the same id, which is what re-adopts this session's
63
+ // Runs. A fork arrives with a new id and adopts nothing (ADR-0036).
64
+ await registry.restore(ctx.sessionManager.getSessionId());
62
65
  });
63
66
 
64
67
  // Commands and tools have separate namespaces: this CLI-resolution command
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The words a Run read back from its record gets, shared by `yaag_status` and
3
+ * the `/yaag` snapshot view (ADR-0036). A resumed session owns such a Run again
4
+ * when the record names it, but no session is attached to it: there is no event
5
+ * stream and no renderer projection, only the persisted Summary. Pure text, no
6
+ * I/O.
7
+ */
8
+ import type { RunViewResult } from "@yaag/tui";
9
+ import type { RunRecord } from "./run-record.ts";
10
+
11
+ /**
12
+ * States the persisted verdict of a Run that no process is attached to.
13
+ *
14
+ * The folded Summary alone cannot say that the Run stopped without an owner
15
+ * process, or that a Checkpoint of it can be resumed (ticket 02), so the state,
16
+ * the surviving process, and the resume call are stated here.
17
+ */
18
+ export function restoredStateText(record: RunRecord): string {
19
+ const state = `Run ${record.id} is ${record.state} (started ${record.startedAt}).`;
20
+ const alive =
21
+ record.state === "orphaned" && record.process !== null
22
+ ? ` Its process ${record.process.pid} is still running; stop it with yaag_stop.`
23
+ : "";
24
+ return `${state}${alive}${resumeHint(record)}`;
25
+ }
26
+
27
+ /**
28
+ * The frozen Result region of a restored Run, for the read-only `/yaag` view.
29
+ *
30
+ * Every restored Run states its persisted verdict first, whatever its outcome:
31
+ * the reader opened a snapshot of a record, which no live Run feeds any more,
32
+ * and the value or the error alone does not say so (ADR-0036).
33
+ */
34
+ export function restoredViewResult(record: RunRecord): RunViewResult {
35
+ const state = restoredStateText(record);
36
+ const { outcome } = record;
37
+ if (outcome?.kind === "fulfilled")
38
+ return { kind: "fulfilled", result: joinText(state, outcome.result) };
39
+ if (outcome?.kind === "rejected")
40
+ return { kind: "failed", error: joinText(state, outcome.reason) };
41
+ return { kind: "failed", error: state };
42
+ }
43
+
44
+ function joinText(state: string, detail: string): string {
45
+ return detail.trim() === "" ? state : `${state}\n${detail}`;
46
+ }
47
+
48
+ /**
49
+ * Names the Checkpoint an unowned Run left behind (ADR-0031), and the call that
50
+ * resumes it. The Run republishes the Checkpoint at each Ask boundary, so it
51
+ * exists whenever the Run reached one, for a dead Run and for a still-running
52
+ * orphan alike.
53
+ *
54
+ * The hint names the Checkpoint path and never the stored program source: the
55
+ * path is enough, because yaag reads the source back from the Run record
56
+ * (ADR-0033). A whole source here would flood the transcript and repeat a
57
+ * private text the record already holds.
58
+ */
59
+ function resumeHint(record: RunRecord): string {
60
+ const artifact = record.summary.artifact;
61
+ if (artifact === null || artifact === undefined) return "";
62
+ if (record.state !== "interrupted" && record.state !== "orphaned") return "";
63
+ // Each path becomes a JSON string literal: a path can hold a quote, a
64
+ // backslash, or a newline, and the hint must stay a call the model can copy.
65
+ const resume = JSON.stringify(artifact);
66
+ const { launch } = record;
67
+ switch (launch.kind) {
68
+ case "inline":
69
+ return ` Resume it with yaag_run({ resume: ${resume} }) and no file and no script; yaag reuses the program source it stored.`;
70
+ case "file":
71
+ return ` Resume it with yaag_run({ file: ${JSON.stringify(launch.file)}, resume: ${resume} }).`;
72
+ default: {
73
+ const never: never = launch;
74
+ return never;
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one bound both Run listings share: the `/yaag` picker and the
3
+ * `yaag_status` overview must agree on which Runs exist (ADR-0036).
4
+ */
5
+ import type { DatedRun, RegisteredRun } from "./run-registry.ts";
6
+
7
+ /** How many Runs the `/yaag` picker and the `yaag_status` overview list. */
8
+ export const RUN_LISTING_CAP = 5;
9
+
10
+ /**
11
+ * The newest `cap` Runs, newest first, plus every live Run whatever its age.
12
+ *
13
+ * A live Run is exempt from the cap: the session must always see and stop what
14
+ * it runs (ADR-0036). Runs that start at the same instant keep registration
15
+ * order.
16
+ */
17
+ export function boundedRuns(
18
+ runs: readonly DatedRun[],
19
+ cap: number = RUN_LISTING_CAP,
20
+ ): readonly RegisteredRun[] {
21
+ const newestFirst = [...runs.entries()].sort(compareNewestFirst).map(([, run]) => run);
22
+ return newestFirst
23
+ .filter((run, rank) => rank < cap || run.entry.state === "live")
24
+ .map((run) => run.entry);
25
+ }
26
+
27
+ function compareNewestFirst(
28
+ left: readonly [number, DatedRun],
29
+ right: readonly [number, DatedRun],
30
+ ): number {
31
+ if (left[1].startedAt === right[1].startedAt) return left[0] - right[0];
32
+ return left[1].startedAt < right[1].startedAt ? 1 : -1;
33
+ }
package/src/run-picker.ts CHANGED
@@ -3,21 +3,28 @@
3
3
  * the list can be tested without a live UI.
4
4
  */
5
5
  import type { RunSummary } from "@yaag/runtime";
6
- import type { RegisteredRun } from "./run-registry.ts";
6
+ import { boundedRuns } from "./run-listing.ts";
7
+ import type { DatedRun } from "./run-registry.ts";
7
8
 
8
9
  /** One Run the picker can open: its id, latest Summary fold, and liveness. */
9
10
  export interface PickableRun {
10
11
  readonly id: string;
11
12
  readonly summary: RunSummary;
12
13
  readonly live: boolean;
14
+ /** A Run an earlier session started; the view of it is a frozen snapshot. */
15
+ readonly restored: boolean;
13
16
  }
14
17
 
15
- /** Every registered Run, live and finished, in registration order. */
16
- export function pickableRuns(runs: readonly RegisteredRun[]): readonly PickableRun[] {
17
- return runs.map((run) => ({
18
+ /**
19
+ * The Runs the picker lists: the bounded listing the `yaag_status` overview
20
+ * uses, so both surfaces agree on which Runs exist (ADR-0036).
21
+ */
22
+ export function pickableRuns(runs: readonly DatedRun[]): readonly PickableRun[] {
23
+ return boundedRuns(runs).map((run) => ({
18
24
  id: run.run.id,
19
25
  summary: run.run.summary,
20
26
  live: run.state === "live",
27
+ restored: run.state === "restored",
21
28
  }));
22
29
  }
23
30
 
package/src/run-record.ts CHANGED
@@ -40,6 +40,12 @@ export type RunLaunch =
40
40
  /** One Run as stored on disk, at `<runs dir>/<id>.json`. */
41
41
  export interface RunRecord {
42
42
  readonly id: string;
43
+ /**
44
+ * The pi session that started the Run. `null` for a record an older yaag
45
+ * wrote, or an unbound registry; such a record belongs to no session
46
+ * (ADR-0036).
47
+ */
48
+ readonly sessionId: string | null;
43
49
  readonly launch: RunLaunch;
44
50
  readonly process: ProcessIdentity | null;
45
51
  readonly startedAt: string;
@@ -81,6 +87,7 @@ export function parseRunRecord(value: unknown): RunRecord | null {
81
87
  // field a consumer reads has the type this parser proved (rule 0001 §3).
82
88
  return {
83
89
  id: candidate["id"],
90
+ sessionId: typeof candidate["sessionId"] === "string" ? candidate["sessionId"] : null,
84
91
  launch,
85
92
  process: identity,
86
93
  startedAt: candidate["startedAt"],
@@ -97,9 +104,11 @@ export function startedRecord(options: {
97
104
  readonly launch: RunLaunch;
98
105
  readonly process: ProcessIdentity | null;
99
106
  readonly now: string;
107
+ readonly sessionId?: string | null;
100
108
  }): RunRecord {
101
109
  return {
102
110
  id: options.id,
111
+ sessionId: options.sessionId ?? null,
103
112
  launch: options.launch,
104
113
  process: options.process,
105
114
  startedAt: options.now,
@@ -44,6 +44,12 @@ export type RegisteredRun =
44
44
  /** What the registry knows about an id. */
45
45
  export type RunStatus = RegisteredRun | { readonly state: "unknown" };
46
46
 
47
+ /** A registered Run plus the start instant that orders a bounded listing. */
48
+ export interface DatedRun {
49
+ readonly startedAt: string;
50
+ readonly entry: RegisteredRun;
51
+ }
52
+
47
53
  /** How often a Run's latest Summary is persisted while it runs. */
48
54
  export const SUMMARY_WRITE_INTERVAL_MS = 2_000;
49
55
 
@@ -67,8 +73,11 @@ export class RunRegistry {
67
73
  readonly #records = new Map<string, RunRecord>();
68
74
  readonly #lastWrite = new Map<string, number>();
69
75
  readonly #trailing = new Map<string, ReturnType<typeof setTimeout>>();
76
+ readonly #startedAt = new Map<string, string>();
70
77
  readonly #store: RunStore | undefined;
71
78
  readonly #now: () => Date;
79
+ #sessionId: string | null = null;
80
+ #restoredFor: string | null | undefined = undefined;
72
81
 
73
82
  constructor(options: RunRegistryOptions = {}) {
74
83
  this.#store = options.store;
@@ -90,25 +99,72 @@ export class RunRegistry {
90
99
  return [...this.#runs.keys()];
91
100
  }
92
101
 
102
+ /** Every addressable Run with its start instant, in registration order. */
103
+ get datedRuns(): readonly DatedRun[] {
104
+ return [...this.#runs.entries()].map(([id, entry]) => ({
105
+ startedAt: this.#startedAt.get(id) ?? "",
106
+ entry,
107
+ }));
108
+ }
109
+
93
110
  /**
94
- * Loads persisted Runs, settles the ones an earlier session left `live`, and
95
- * seeds the still-unaccounted ones. Finished Runs of earlier sessions stay on
96
- * disk and are reachable through {@link recall} by id.
111
+ * Binds this Host Session's pi session id, settles the Runs an earlier
112
+ * session left `live`, and adopts the ones this session owns (ADR-0036).
113
+ *
114
+ * Idempotent per session id: a second `session_start` with the same id (a
115
+ * `/reload`) adopts nothing again. A fork carries a new id and therefore
116
+ * adopts nothing; the parent's Runs stay reachable through {@link recall}.
97
117
  */
98
- async restore(): Promise<void> {
118
+ async restore(sessionId?: string): Promise<void> {
119
+ const bound = sessionId ?? null;
120
+ if (this.#restoredFor === bound) return;
121
+ this.#sessionId = bound;
122
+ this.#restoredFor = bound;
99
123
  if (this.#store === undefined) return;
100
124
  const records = await restoreRecords({ store: this.#store, now: this.#now });
101
125
  for (const record of records) {
102
- if (record.state !== "orphaned" && record.state !== "interrupted") continue;
126
+ // A Run of this process is never shadowed by its own persisted record.
127
+ if (this.#runs.has(record.id)) continue;
128
+ if (!this.#adoptable(record)) continue;
103
129
  this.#runs.set(record.id, { state: "restored", run: restored(record) });
130
+ this.#startedAt.set(record.id, record.startedAt);
104
131
  }
105
132
  }
106
133
 
134
+ /** Persisted Runs this overview does not list; addressable by id only. */
135
+ async unlistedRunCount(listed: readonly string[]): Promise<number> {
136
+ if (this.#store === undefined) return 0;
137
+ const shown = new Set(listed);
138
+ return (await this.#store.load()).filter((record) => !shown.has(record.id)).length;
139
+ }
140
+
141
+ /** Persisted finished Runs that another pi session owns. */
142
+ async finishedElsewhereCount(): Promise<number> {
143
+ if (this.#store === undefined) return 0;
144
+ return (await this.#store.load()).filter(
145
+ (record) =>
146
+ record.state === "finished" &&
147
+ (this.#sessionId === null || record.sessionId !== this.#sessionId),
148
+ ).length;
149
+ }
150
+
151
+ /**
152
+ * `restoreRecords` already settled every stale `live` record, so this check
153
+ * can never adopt a Run as live (ADR-0036).
154
+ */
155
+ #adoptable(record: RunRecord): boolean {
156
+ if (record.state === "orphaned" || record.state === "interrupted") return true;
157
+ return this.#sessionId !== null && record.sessionId === this.#sessionId;
158
+ }
159
+
107
160
  /** Registers a newly-started Run and synchronously observes its settlement. */
108
161
  add(run: LiveRun, launch?: RunLaunch, identity?: ProcessIdentity | null): void {
162
+ const startedAt = this.#now().toISOString();
163
+ this.#startedAt.set(run.id, startedAt);
109
164
  this.#live.set(run.id, run);
110
165
  this.#runs.set(run.id, { state: "live", run });
111
- if (launch !== undefined) this.#persistStart(run.id, launch, identity ?? null);
166
+ if (launch !== undefined)
167
+ this.#persistStart({ id: run.id, launch, process: identity ?? null, now: startedAt });
112
168
  void run.outcome.then(
113
169
  (outcome) => this.finish(run.id, outcome),
114
170
  (reason: unknown) => this.reject(run.id, reason),
@@ -203,13 +259,16 @@ export class RunRegistry {
203
259
  this.#persistEnd(id, summary, outcome);
204
260
  }
205
261
 
206
- #persistStart(id: string, launch: RunLaunch, identity: ProcessIdentity | null): void {
262
+ #persistStart(options: {
263
+ readonly id: string;
264
+ readonly launch: RunLaunch;
265
+ readonly process: ProcessIdentity | null;
266
+ readonly now: string;
267
+ }): void {
207
268
  if (this.#store === undefined) return;
208
269
  const store = this.#store;
209
- this.#records.set(
210
- id,
211
- startedRecord({ id, launch, process: identity, now: this.#now().toISOString() }),
212
- );
270
+ const id = options.id;
271
+ this.#records.set(id, startedRecord({ ...options, sessionId: this.#sessionId }));
213
272
  this.#persist(id);
214
273
  void store.prune();
215
274
  }
@@ -2,7 +2,8 @@ import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-
2
2
  import type { RunSummary } from "@yaag/runtime";
3
3
  import { renderSnapshot, TreeState } from "@yaag/tui";
4
4
  import { Type } from "typebox";
5
- import type { RunRecord } from "./run-record.ts";
5
+ import { restoredStateText } from "./restored-run-text.ts";
6
+ import { boundedRuns, RUN_LISTING_CAP } from "./run-listing.ts";
6
7
  import type { RegisteredRun, RestoredRun, RunRegistry } from "./run-registry.ts";
7
8
  import { toUsage } from "./usage.ts";
8
9
 
@@ -33,18 +34,22 @@ const DESCRIPTION = [
33
34
  "resolves: a Run whose process died is reported as interrupted, and a Run whose",
34
35
  "process is still alive is reported as orphaned.",
35
36
  "",
36
- "Without an id, lists this session's Runs plus every orphaned or interrupted",
37
- "Run left by an earlier session.",
37
+ "Without an id, this tool lists the Runs of this session, and the orphaned or",
38
+ "interrupted Runs another session left. The list is bounded: it holds every",
39
+ `live Run, and then the newest Runs up to a total of ${RUN_LISTING_CAP}. An older Run stays`,
40
+ "on disk, and the last line of the list counts the Runs on disk. Give an id to",
41
+ "see one of them.",
38
42
  "",
39
43
  "An interrupted inline Run resumes with `resume` alone: yaag reads the program",
40
44
  "source from its own Run record.",
41
45
  ].join("\n");
42
46
 
43
47
  /**
44
- * The `yaag_status` tool renders session-local Run snapshots for the model.
48
+ * The `yaag_status` tool renders Run snapshots for the model.
45
49
  *
46
- * Throws if an id was not started in this session; finished Runs remain
47
- * queryable and are never restarted or controlled by this tool.
50
+ * An id resolves through {@link RunRegistry.recall}, so a Run of any session is
51
+ * queryable while its record is on disk; only an id no record holds throws.
52
+ * The tool never restarts or controls a Run.
48
53
  */
49
54
  export function createStatusTool(
50
55
  registry: RunRegistry,
@@ -58,7 +63,7 @@ export function createStatusTool(
58
63
  description: DESCRIPTION,
59
64
  parameters,
60
65
  async execute(_id, params) {
61
- if (params.id === undefined) return overview(registry.runs, now, width);
66
+ if (params.id === undefined) return await overview(registry, now, width);
62
67
  const found = await registry.recall(params.id);
63
68
  if (found.state === "unknown") throw unknownId(params.id, registry.knownIds);
64
69
  const details = detailsFor(found);
@@ -71,25 +76,40 @@ export function createStatusTool(
71
76
  };
72
77
  }
73
78
 
74
- function overview(
75
- runs: readonly RegisteredRun[],
79
+ /**
80
+ * Lists the Runs of this session, bounded by {@link RUN_LISTING_CAP}.
81
+ *
82
+ * The disk counts are read on each call: the store holds dozens of small files
83
+ * and a stale count would name Runs that no longer exist (ADR-0036).
84
+ */
85
+ async function overview(
86
+ registry: RunRegistry,
76
87
  now: () => number,
77
88
  width: number,
78
- ): AgentToolResult<StatusDetails> {
79
- if (runs.length === 0)
80
- return {
81
- content: [{ type: "text", text: "No Runs started this session." }],
82
- details: { summary: undefined },
83
- };
84
- return {
85
- content: [
86
- {
87
- type: "text",
88
- text: runs.map((run) => snapshot(detailsFor(run), now, width, true)).join("\n"),
89
- },
90
- ],
91
- details: { summary: undefined },
92
- };
89
+ ): Promise<AgentToolResult<StatusDetails>> {
90
+ const shown = boundedRuns(registry.datedRuns);
91
+ if (shown.length === 0) return text(emptyOverview(await registry.finishedElsewhereCount()));
92
+ const lines = shown.map((run) => snapshot(detailsFor(run), now, width, true));
93
+ const unlisted = await registry.unlistedRunCount(shown.map((run) => run.run.id));
94
+ if (unlisted > 0) lines.push(overflowLine(shown.length, unlisted));
95
+ return text(lines.join("\n"));
96
+ }
97
+
98
+ function text(value: string): AgentToolResult<StatusDetails> {
99
+ return { content: [{ type: "text", text: value }], details: { summary: undefined } };
100
+ }
101
+
102
+ /** The confirmed empty-overview copy of the specification, with its count. */
103
+ function emptyOverview(elsewhere: number): string {
104
+ if (elsewhere === 0) return "No Runs in this session.";
105
+ const runs = elsewhere === 1 ? "Run" : "Runs";
106
+ const verb = elsewhere === 1 ? "is" : "are";
107
+ return `No Runs in this session. ${elsewhere} finished ${runs} from other sessions ${verb} on disk; query one with yaag_status({ id }).`;
108
+ }
109
+
110
+ /** The confirmed overflow copy of the specification, with its two counts. */
111
+ function overflowLine(shown: number, unlisted: number): string {
112
+ return `${shown} shown; ${unlisted} more on disk \u2014 query one with yaag_status({ id })`;
93
113
  }
94
114
 
95
115
  function detailsFor(run: RegisteredRun): SnapshotDetails {
@@ -106,51 +126,19 @@ function detailsFor(run: RegisteredRun): SnapshotDetails {
106
126
  }
107
127
 
108
128
  /**
109
- * A Run of an earlier Host Session. Its persisted state is stated in the
110
- * Result region, because the folded Summary alone cannot say that nobody owns
111
- * the Run any more (ticket 02).
129
+ * A Run read back from its record: this session may own it again, but nothing
130
+ * is attached to it. Its persisted state is stated in the Result region,
131
+ * because the folded Summary alone cannot say that the snapshot is final and
132
+ * that no process feeds it (ticket 02, ADR-0036).
112
133
  */
113
134
  function restoredDetails(run: RestoredRun): SnapshotDetails {
114
135
  const { record } = run;
115
- const state = `Run ${record.id} is ${record.state} (started ${record.startedAt}).`;
116
- const alive =
117
- record.state === "orphaned" && record.process !== null
118
- ? ` Its process ${record.process.pid} is still running; stop it with yaag_stop.`
119
- : "";
120
- const detail = `${alive}${resumeHint(record)}`;
121
136
  const result = record.outcome?.kind === "fulfilled" ? `\n${record.outcome.result}` : "";
122
- return { id: record.id, summary: run.summary, result: `${state}${detail}${result}` };
123
- }
124
-
125
- /**
126
- * Names the Checkpoint an unowned Run left behind (ADR-0031), and the call that
127
- * resumes it. The Run republishes the Checkpoint at each Ask boundary, so it
128
- * exists whenever the Run reached one, for a dead Run and for a still-running
129
- * orphan alike.
130
- *
131
- * The hint names the Checkpoint path and never the stored program source: the
132
- * path is enough, because yaag reads the source back from the Run record
133
- * (ADR-0033). A whole source here would flood the transcript and repeat a
134
- * private text the record already holds.
135
- */
136
- function resumeHint(record: RunRecord): string {
137
- const artifact = record.summary.artifact;
138
- if (artifact === null || artifact === undefined) return "";
139
- if (record.state !== "interrupted" && record.state !== "orphaned") return "";
140
- // Each path becomes a JSON string literal: a path can hold a quote, a
141
- // backslash, or a newline, and the hint must stay a call the model can copy.
142
- const resume = JSON.stringify(artifact);
143
- const { launch } = record;
144
- switch (launch.kind) {
145
- case "inline":
146
- return ` Resume it with yaag_run({ resume: ${resume} }) and no file and no script; yaag reuses the program source it stored.`;
147
- case "file":
148
- return ` Resume it with yaag_run({ file: ${JSON.stringify(launch.file)}, resume: ${resume} }).`;
149
- default: {
150
- const never: never = launch;
151
- return never;
152
- }
153
- }
137
+ return {
138
+ id: record.id,
139
+ summary: run.summary,
140
+ result: `${restoredStateText(record)}${result}`,
141
+ };
154
142
  }
155
143
 
156
144
  function snapshot(
package/src/stop-tool.ts CHANGED
@@ -67,8 +67,8 @@ function agentStateReport(agent: RunSummary["agents"][string]): string {
67
67
  }
68
68
 
69
69
  /**
70
- * Ends a Run nobody owns any more. There is no reap ladder to run — its pipes
71
- * belong to a dead Host Session — so the process group is killed outright
70
+ * Ends a Run no process is attached to. There is no reap ladder to run — its
71
+ * pipes belong to a dead Host Session — so the process group is killed outright
72
72
  * (ADR-0008). The pid is re-probed first, because a record can name a pid that
73
73
  * a different process has since inherited (ticket 02).
74
74
  */
@@ -14,6 +14,7 @@ import type {
14
14
  RegisteredCommand,
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { createRunTreeView, type RunTreeViewHost, type RunViewResult, TreeState } from "@yaag/tui";
17
+ import { restoredViewResult } from "./restored-run-text.ts";
17
18
  import { type PickableRun, pickableRuns, runFromLabel, runPickerLabel } from "./run-picker.ts";
18
19
  import type { RunRegistry, RunSettlement } from "./run-registry.ts";
19
20
  import { observedSettlement, viewResult } from "./run-settlement.ts";
@@ -53,9 +54,9 @@ export function createYaagCommand(options: YaagCommandOptions): CommandOptions {
53
54
  * not the TUI, or when the reader cancels the picker.
54
55
  */
55
56
  export async function runYaagCommand(ctx: YaagContext, options: YaagCommandOptions): Promise<void> {
56
- const runs = pickableRuns(options.registry.runs);
57
+ const runs = pickableRuns(options.registry.datedRuns);
57
58
  if (runs.length === 0) {
58
- ctx.ui.notify("yaag: no Runs started this session.", "info");
59
+ ctx.ui.notify("yaag: no Runs in this session.", "info");
59
60
  return;
60
61
  }
61
62
  if (ctx.mode !== "tui") {
@@ -87,8 +88,13 @@ async function openRun(
87
88
  run: PickableRun,
88
89
  options: YaagCommandOptions,
89
90
  ): Promise<void> {
90
- const state = options.store.get(run.id) ?? TreeState.fromSummary(run.summary);
91
91
  const found = options.registry.lookup(run.id);
92
+ // A Run of an earlier session has no projection here, so its view is built
93
+ // from the persisted Summary alone (ADR-0036).
94
+ const state =
95
+ found.state === "restored"
96
+ ? TreeState.fromSummary(run.summary)
97
+ : (options.store.get(run.id) ?? TreeState.fromSummary(run.summary));
92
98
  let unsubscribe: (() => void) | undefined;
93
99
  // The observer below outlives no more than the `ctx.ui.custom()` call: once
94
100
  // the view exits or the UI call rejects, a later settlement must touch
@@ -98,6 +104,8 @@ async function openRun(
98
104
  await ctx.ui.custom<void>((tui, _theme, keybindings, done) => {
99
105
  const host: RunTreeViewHost = {
100
106
  ...createRunTreeHost({ ui: ctx.ui, tui, keybindings, state }),
107
+ // A restored orphan is never re-attached, so there is nothing to stop
108
+ // from here; `yaag_stop` ends it (ADR-0036).
101
109
  stop: () => {
102
110
  if (found.state === "live") found.run.stop();
103
111
  },
@@ -105,8 +113,13 @@ async function openRun(
105
113
  // hand over, because a Run of this session is already addressable.
106
114
  done: () => done(),
107
115
  };
108
- const view = createRunTreeView({ state, host, label: run.id });
109
- if (found.state === "finished")
116
+ const view = createRunTreeView({
117
+ state,
118
+ host,
119
+ label: found.state === "restored" ? `${run.id} (restored)` : run.id,
120
+ });
121
+ if (found.state === "restored") view.settle(restoredViewResult(found.run.record));
122
+ else if (found.state === "finished")
110
123
  settle(state, view, found.run.outcome, options.registry, run.id);
111
124
  else if (found.state === "live") {
112
125
  unsubscribe = options.store.subscribe((id) => {