@vimhead.dev/norn-cli 0.1.0-tip.36679386408.1 → 0.1.0-tip.37307659560.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.
Files changed (48) hide show
  1. package/assets/README.md +3 -2
  2. package/assets/docs/agents.md +7 -2
  3. package/assets/docs/cli.md +25 -0
  4. package/assets/docs/providers.md +3 -3
  5. package/assets/docs/recovery.md +4 -0
  6. package/assets/examples/agent-then-analysis/README.md +1 -1
  7. package/assets/examples/agent-then-analysis/plugin.ts +2 -0
  8. package/assets/examples/coordinating-multiple-agents/README.md +2 -1
  9. package/assets/examples/coordinating-multiple-agents/plugin.ts +1 -0
  10. package/assets/examples/getting-started/README.md +3 -3
  11. package/assets/examples/getting-started/plugin.ts +1 -0
  12. package/assets/examples/shared-state/README.md +1 -1
  13. package/assets/examples/shared-state/plugin.ts +1 -0
  14. package/assets/examples/worktree-development-loop/README.md +1 -1
  15. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +1 -0
  16. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -0
  17. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +1 -0
  18. package/assets/package.json +1 -1
  19. package/assets/packages/cli/src/cli.ts +37 -0
  20. package/assets/packages/cli/src/client.ts +16 -0
  21. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  22. package/assets/packages/cli/src/internal/agents.ts +42 -2
  23. package/assets/packages/cli/src/internal/execution-context.ts +0 -2
  24. package/assets/packages/cli/src/internal/run-lease.ts +24 -2
  25. package/assets/packages/cli/src/internal/run-pruning.ts +169 -0
  26. package/assets/packages/cli/src/internal/run-store.ts +76 -4
  27. package/assets/packages/core/src/atomic-files.ts +8 -1
  28. package/assets/packages/sdk/src/api.ts +29 -1
  29. package/dist/cli.js +34 -0
  30. package/dist/client.d.ts +2 -1
  31. package/dist/client.js +8 -0
  32. package/dist/generated-build-info.d.ts +2 -2
  33. package/dist/generated-build-info.js +2 -2
  34. package/dist/internal/agents.d.ts +1 -1
  35. package/dist/internal/agents.js +33 -2
  36. package/dist/internal/execution-context.d.ts +0 -1
  37. package/dist/internal/execution-context.js +0 -1
  38. package/dist/internal/file-coordinator.js +4 -1
  39. package/dist/internal/launch-request.js +4 -1
  40. package/dist/internal/run-lease.d.ts +1 -0
  41. package/dist/internal/run-lease.js +27 -3
  42. package/dist/internal/run-log.js +4 -1
  43. package/dist/internal/run-pruning.d.ts +14 -0
  44. package/dist/internal/run-pruning.js +141 -0
  45. package/dist/internal/run-state.js +4 -1
  46. package/dist/internal/run-store.d.ts +5 -0
  47. package/dist/internal/run-store.js +66 -5
  48. package/package.json +2 -2
@@ -0,0 +1,169 @@
1
+ import type {
2
+ NornRunInfo,
3
+ NornRunPruneOptions,
4
+ NornRunPruneResult,
5
+ } from "@vimhead.dev/norn";
6
+ import { errorMessage } from "./errors.ts";
7
+ import { getRunLeaseOwner, NornRunLease } from "./run-lease.ts";
8
+ import { getRunInfo, listRuns } from "./run-state.ts";
9
+ import { NornRunStore } from "./run-store.ts";
10
+
11
+ const DURATION_UNITS: Readonly<Record<string, number>> = {
12
+ ms: 1,
13
+ s: 1000,
14
+ m: 60_000,
15
+ h: 3_600_000,
16
+ d: 86_400_000,
17
+ w: 604_800_000,
18
+ };
19
+
20
+ export function parseRunPruneOptions(
21
+ args: readonly string[],
22
+ ): NornRunPruneOptions {
23
+ const flags = new Set<string>();
24
+ let olderThan: string | null = null;
25
+ for (let index = 0; index < args.length; index++) {
26
+ const flag = args[index];
27
+ if (!["--delete-runs", "--all", "--dry-run", "--older-than"].includes(flag))
28
+ throw new Error(`runs prune: unknown argument ${flag}`);
29
+ if (flags.has(flag))
30
+ throw new Error(`runs prune: duplicate option ${flag}`);
31
+ flags.add(flag);
32
+ if (flag === "--older-than") {
33
+ const value = args[++index];
34
+ if (value === undefined || value.startsWith("--"))
35
+ throw new Error(
36
+ "runs prune: --older-than requires a duration such as 24h or 7d",
37
+ );
38
+ olderThan = value;
39
+ }
40
+ }
41
+ return {
42
+ deleteRuns: flags.has("--delete-runs"),
43
+ all: flags.has("--all"),
44
+ olderThan,
45
+ dryRun: flags.has("--dry-run"),
46
+ };
47
+ }
48
+
49
+ function parsePruneDuration(value: string): number {
50
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/.exec(value);
51
+ const duration = match ? Number(match[1]) * DURATION_UNITS[match[2]] : NaN;
52
+ if (
53
+ !Number.isFinite(duration) ||
54
+ duration < 0 ||
55
+ duration > Number.MAX_SAFE_INTEGER
56
+ )
57
+ throw new Error(
58
+ `runs prune: invalid duration ${value}; use a non-negative number followed by ms, s, m, h, d, or w`,
59
+ );
60
+ return duration;
61
+ }
62
+
63
+ export class NornRunPruner {
64
+ private readonly olderThan: string | null;
65
+ private readonly cutoff: number | null;
66
+
67
+ constructor(
68
+ private readonly input: {
69
+ readonly projectRoot: string;
70
+ readonly options: NornRunPruneOptions;
71
+ readonly now: Date;
72
+ },
73
+ ) {
74
+ if (input.options.all && !input.options.deleteRuns)
75
+ throw new Error("runs prune: --all requires --delete-runs");
76
+ if (input.options.all && input.options.olderThan !== null)
77
+ throw new Error(
78
+ "runs prune: --all and --older-than are mutually exclusive",
79
+ );
80
+ if (!Number.isFinite(input.now.getTime()))
81
+ throw new Error("Invalid pruning time");
82
+ this.olderThan = input.options.all
83
+ ? null
84
+ : (input.options.olderThan ?? "24h");
85
+ this.cutoff =
86
+ this.olderThan === null
87
+ ? null
88
+ : input.now.getTime() - parsePruneDuration(this.olderThan);
89
+ }
90
+
91
+ async prune(): Promise<NornRunPruneResult> {
92
+ const results: NornRunPruneResult["runs"][number][] = [];
93
+ for (const run of await listRuns(this.input.projectRoot)) {
94
+ const identity = { id: run.id, name: run.name, path: run.path };
95
+ let lease: NornRunLease | undefined;
96
+ try {
97
+ let reason = this.findIneligibilityReason(run);
98
+ if (!reason && (await getRunLeaseOwner(run.path)))
99
+ reason = "Run is owned by an executor";
100
+ if (reason) {
101
+ results.push({ ...identity, status: "skipped", reason });
102
+ continue;
103
+ }
104
+ if (this.input.options.dryRun) {
105
+ results.push({ ...identity, status: "planned" });
106
+ continue;
107
+ }
108
+ try {
109
+ lease = await NornRunLease.acquire(run.path);
110
+ } catch (error) {
111
+ if (
112
+ error instanceof Error &&
113
+ error.message.startsWith("Run is already active:")
114
+ ) {
115
+ results.push({
116
+ ...identity,
117
+ status: "skipped",
118
+ reason: "Run is owned by an executor",
119
+ });
120
+ continue;
121
+ }
122
+ throw error;
123
+ }
124
+ reason = this.findIneligibilityReason(await getRunInfo(run.path));
125
+ if (reason) {
126
+ results.push({ ...identity, status: "skipped", reason });
127
+ continue;
128
+ }
129
+ await lease.assertOwned();
130
+ if (this.input.options.deleteRuns) {
131
+ await lease.deleteRunDirectory();
132
+ } else {
133
+ const store = await NornRunStore.open(run.path);
134
+ await store.pruneCheckpointHistory(this.input.now.toISOString());
135
+ }
136
+ results.push({ ...identity, status: "pruned" });
137
+ } catch (error) {
138
+ results.push({
139
+ ...identity,
140
+ status: "failed",
141
+ reason: errorMessage(error),
142
+ });
143
+ } finally {
144
+ await lease?.release();
145
+ }
146
+ }
147
+ return {
148
+ mode: this.input.options.deleteRuns ? "runs" : "checkpoints",
149
+ dryRun: this.input.options.dryRun,
150
+ olderThan: this.olderThan,
151
+ runs: results,
152
+ };
153
+ }
154
+
155
+ private findIneligibilityReason(run: NornRunInfo): string | undefined {
156
+ if (run.status !== "completed" && run.status !== "failed")
157
+ return `Run status is ${run.status}; only completed or failed runs are eligible`;
158
+ const finishedAt =
159
+ run.status === "failed"
160
+ ? (run.failed?.failedAt ?? run.outcome?.completedAt ?? run.updatedAt)
161
+ : (run.outcome?.completedAt ?? run.updatedAt);
162
+ const finishedTime = Date.parse(finishedAt);
163
+ if (!Number.isFinite(finishedTime))
164
+ return "Run completion/failure time is invalid";
165
+ if (this.cutoff !== null && finishedTime >= this.cutoff)
166
+ return `Run finished less than or exactly ${this.olderThan} ago`;
167
+ return undefined;
168
+ }
169
+ }
@@ -21,6 +21,7 @@ import { gunzip, gzip } from "node:zlib";
21
21
  import type { NornRunCheckpoint } from "@vimhead.dev/norn";
22
22
  import { isNodeError } from "@vimhead.dev/norn-core/errors";
23
23
  import {
24
+ writeBytesAtomically,
24
25
  writeJsonAtomically,
25
26
  writeTextAtomically,
26
27
  } from "@vimhead.dev/norn-core/atomic-files";
@@ -31,6 +32,7 @@ const SNAPSHOT_VERSION = 1;
31
32
  const CURRENT_DIR_NAME = "current";
32
33
  const STORE_DIR_NAME = "store";
33
34
  const CHECKPOINTS_FILE_NAME = "checkpoints.json";
35
+ const PRUNED_HISTORY_FILE_NAME = "checkpoint-history-pruned.json";
34
36
 
35
37
  export function runCurrentRoot(runRoot: string): string {
36
38
  return join(runRoot, CURRENT_DIR_NAME);
@@ -57,15 +59,18 @@ export class NornRunStore {
57
59
  static async open(root: string): Promise<NornRunStore> {
58
60
  const runStore = new NornRunStore(root);
59
61
  await access(runStore.currentRoot, constants.R_OK | constants.W_OK);
60
- await access(runStore.storeRoot, constants.R_OK | constants.W_OK);
62
+ if (!(await runStore.isCheckpointHistoryPruned()))
63
+ await access(runStore.storeRoot, constants.R_OK | constants.W_OK);
61
64
  return runStore;
62
65
  }
63
66
 
64
67
  async currentSnapshotRef(): Promise<string> {
68
+ await this.assertCheckpointHistoryAvailable();
65
69
  return readFile(this.currentRefPath, "utf8").then((value) => value.trim());
66
70
  }
67
71
 
68
72
  async snapshotCurrent(message: string): Promise<NornRunCheckpoint> {
73
+ await this.assertCheckpointHistoryAvailable();
69
74
  const previous = await this.readCheckpointHistory();
70
75
  const checkpoint = this.createCheckpoint(
71
76
  message,
@@ -89,6 +94,7 @@ export class NornRunStore {
89
94
  ref: string,
90
95
  prepare: ((stagedRunRoot: string) => Promise<void>) | undefined,
91
96
  ): Promise<void> {
97
+ await this.assertCheckpointHistoryAvailable();
92
98
  const checkpoint = (await this.listCheckpoints()).find(
93
99
  (entry) => entry.id === ref,
94
100
  );
@@ -107,9 +113,55 @@ export class NornRunStore {
107
113
  }
108
114
 
109
115
  async listCheckpoints(): Promise<NornRunCheckpoint[]> {
116
+ if (await this.isCheckpointHistoryPruned()) return [];
110
117
  return (await this.readCheckpointHistory()).checkpoints;
111
118
  }
112
119
 
120
+ async pruneCheckpointHistory(prunedAt: string): Promise<void> {
121
+ if (!(await this.isCheckpointHistoryPruned()))
122
+ await writeJsonAtomically(this.prunedHistoryPath, {
123
+ version: 1,
124
+ prunedAt,
125
+ });
126
+ await writeJsonAtomically(this.checkpointsPath, []);
127
+ await rm(this.storeRoot, { recursive: true, force: true });
128
+ }
129
+
130
+ private get prunedHistoryPath(): string {
131
+ return join(this.runRoot, PRUNED_HISTORY_FILE_NAME);
132
+ }
133
+
134
+ private async isCheckpointHistoryPruned(): Promise<boolean> {
135
+ let content: string;
136
+ try {
137
+ content = await readFile(this.prunedHistoryPath, "utf8");
138
+ } catch (error) {
139
+ if (isNodeError(error) && error.code === "ENOENT") return false;
140
+ throw error;
141
+ }
142
+ const marker: unknown = JSON.parse(content);
143
+ if (
144
+ !marker ||
145
+ typeof marker !== "object" ||
146
+ !("version" in marker) ||
147
+ marker.version !== 1 ||
148
+ !("prunedAt" in marker) ||
149
+ typeof marker.prunedAt !== "string" ||
150
+ !Number.isFinite(Date.parse(marker.prunedAt))
151
+ )
152
+ throw new Error(
153
+ `Invalid pruned checkpoint history marker: ${this.prunedHistoryPath}`,
154
+ );
155
+ return true;
156
+ }
157
+
158
+ private async assertCheckpointHistoryAvailable(): Promise<void> {
159
+ if (await this.isCheckpointHistoryPruned())
160
+ throw new Error(
161
+ `Checkpoint history was pruned for this run: ${this.runRoot}`,
162
+ );
163
+ }
164
+
113
165
  private async readCheckpointHistory(): Promise<{
114
166
  readonly checkpoints: NornRunCheckpoint[];
115
167
  readonly content?: string;
@@ -191,7 +243,10 @@ export class NornRunStore {
191
243
  createdAt: checkpoint.createdAt,
192
244
  entries,
193
245
  };
194
- await writeJsonAtomically(this.snapshotPath(snapshot.id), snapshot);
246
+ await writeBytesAtomically(
247
+ this.snapshotPath(snapshot.id),
248
+ await gzipBuffer(Buffer.from(JSON.stringify(snapshot))),
249
+ );
195
250
  return snapshot;
196
251
  }
197
252
 
@@ -363,12 +418,25 @@ export class NornRunStore {
363
418
  }
364
419
 
365
420
  private async readSnapshot(id: string): Promise<RunSnapshotManifest> {
421
+ let compressed: Buffer;
422
+ try {
423
+ compressed = await readFile(this.snapshotPath(id));
424
+ } catch (error) {
425
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
426
+ return parseRunSnapshotManifest(
427
+ JSON.parse(await readFile(this.getLegacySnapshotPath(id), "utf8")),
428
+ );
429
+ }
366
430
  return parseRunSnapshotManifest(
367
- JSON.parse(await readFile(this.snapshotPath(id), "utf8")),
431
+ JSON.parse((await gunzipBuffer(compressed)).toString("utf8")),
368
432
  );
369
433
  }
370
434
 
371
435
  private snapshotPath(id: string): string {
436
+ return join(this.snapshotsRoot, `${id}.json.gz`);
437
+ }
438
+
439
+ private getLegacySnapshotPath(id: string): string {
372
440
  return join(this.snapshotsRoot, `${id}.json`);
373
441
  }
374
442
 
@@ -378,7 +446,11 @@ export class NornRunStore {
378
446
 
379
447
  private assertCheckpointPathMatchesId(checkpoint: NornRunCheckpoint): void {
380
448
  const expectedPath = this.snapshotRelativePath(checkpoint.id);
381
- if (checkpoint.path !== expectedPath)
449
+ const legacyPath = relative(
450
+ this.runRoot,
451
+ this.getLegacySnapshotPath(checkpoint.id),
452
+ );
453
+ if (checkpoint.path !== expectedPath && checkpoint.path !== legacyPath)
382
454
  throw new Error(
383
455
  `Run checkpoint path does not match id: ${checkpoint.id}`,
384
456
  );
@@ -13,6 +13,13 @@ export async function writeJsonAtomically(
13
13
  export async function writeTextAtomically(
14
14
  path: string,
15
15
  content: string,
16
+ ): Promise<void> {
17
+ await writeBytesAtomically(path, Buffer.from(content, "utf8"));
18
+ }
19
+
20
+ export async function writeBytesAtomically(
21
+ path: string,
22
+ content: Uint8Array,
16
23
  ): Promise<void> {
17
24
  await mkdir(dirname(path), { recursive: true });
18
25
  let existingMode: number | undefined;
@@ -23,7 +30,7 @@ export async function writeTextAtomically(
23
30
  }
24
31
  const tmpPath = `${path}.${randomUUID()}.tmp`;
25
32
  try {
26
- await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
33
+ await writeFile(tmpPath, content, { mode: existingMode });
27
34
  if (existingMode !== undefined) await chmod(tmpPath, existingMode);
28
35
  await rename(tmpPath, path);
29
36
  } catch (error) {
@@ -383,6 +383,26 @@ export type DeletedNornRunInfo = {
383
383
  readonly path: string;
384
384
  };
385
385
 
386
+ export type NornRunPruneOptions = {
387
+ readonly deleteRuns: boolean;
388
+ readonly all: boolean;
389
+ readonly olderThan: string | null;
390
+ readonly dryRun: boolean;
391
+ };
392
+
393
+ export type NornRunPruneResult = {
394
+ readonly mode: "checkpoints" | "runs";
395
+ readonly dryRun: boolean;
396
+ readonly olderThan: string | null;
397
+ readonly runs: readonly {
398
+ readonly id: string;
399
+ readonly name: string;
400
+ readonly path: string;
401
+ readonly status: "planned" | "pruned" | "skipped" | "failed";
402
+ readonly reason?: string;
403
+ }[];
404
+ };
405
+
386
406
  export type NornRunCheckpoint = {
387
407
  readonly id: string;
388
408
  readonly path: string;
@@ -458,6 +478,11 @@ export type NornAgentBeforeSessionStartContext = {
458
478
  readonly events: EventBus;
459
479
  };
460
480
 
481
+ export type NornAgentModelReference = {
482
+ readonly provider: string;
483
+ readonly id: string;
484
+ };
485
+
461
486
  export type NornAgentCreateSessionInput = {
462
487
  readonly customTools?: readonly ToolDefinition[];
463
488
  readonly label: string;
@@ -466,7 +491,10 @@ export type NornAgentCreateSessionInput = {
466
491
  readonly beforeSessionStart?: (
467
492
  context: NornAgentBeforeSessionStartContext,
468
493
  ) => MaybePromise<void>;
469
- readonly model?: CreateAgentSessionOptions["model"];
494
+ readonly models: readonly [
495
+ NornAgentModelReference,
496
+ ...NornAgentModelReference[],
497
+ ];
470
498
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
471
499
  readonly systemPrompt?: string;
472
500
  readonly appendSystemPrompt?: readonly string[];
package/dist/cli.js CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  import { readRunMetrics } from "./internal/metrics.js";
49
49
  import { getRunLeaseOwner, NornRunLease } from "./internal/run-lease.js";
50
50
  import { generateRunName } from "./internal/run-names.js";
51
+ import { NornRunPruner, parseRunPruneOptions } from "./internal/run-pruning.js";
51
52
  import {
52
53
  assertRunVersion,
53
54
  getRunInfo,
@@ -394,6 +395,37 @@ var COMMANDS = [
394
395
  await signalRun(run, "SIGKILL");
395
396
  }
396
397
  },
398
+ {
399
+ id: "runs.prune",
400
+ path: ["runs", "prune"],
401
+ description: "Discard checkpoint history or entire failed/completed runs, preserving recent and nonterminal runs.",
402
+ usage: "norn runs prune [--delete-runs] [--older-than <duration>|--all] [--dry-run]",
403
+ options: [
404
+ "--delete-runs: delete entire eligible runs instead of only checkpoint storage",
405
+ "--older-than <duration>: time since completion/failure; default 24h; units ms, s, m, h, d, w",
406
+ "--all: ignore age; requires --delete-runs; incompatible with --older-than",
407
+ "--dry-run: report eligible runs without changing files"
408
+ ],
409
+ output: "JSON object under prune with mode, dryRun, olderThan, and per-run planned/pruned/skipped/failed results. Any failed result sets a nonzero exit status.",
410
+ examples: [
411
+ "norn runs prune --dry-run",
412
+ "norn runs prune --older-than 7d",
413
+ "norn runs prune --delete-runs --all"
414
+ ],
415
+ execute: async (args) => {
416
+ const options = parseRunPruneOptions(args);
417
+ const project = await findNornProject(process.cwd());
418
+ const pruner = new NornRunPruner({
419
+ projectRoot: project.projectRoot,
420
+ options,
421
+ now: /* @__PURE__ */ new Date()
422
+ });
423
+ const result = await pruner.prune();
424
+ writeJson({ prune: result });
425
+ if (result.runs.some((run) => run.status === "failed"))
426
+ process.exitCode = 1;
427
+ }
428
+ },
397
429
  {
398
430
  id: "runs.delete",
399
431
  path: ["runs", "delete"],
@@ -468,6 +500,7 @@ var HELP_COMMAND_ORDER = [
468
500
  "runs.stop",
469
501
  "runs.kill",
470
502
  "runs.delete",
503
+ "runs.prune",
471
504
  "project.init",
472
505
  "project.inspect",
473
506
  "pi",
@@ -495,6 +528,7 @@ var HUMAN_COMMAND_SUMMARIES = {
495
528
  "runs.stop": "Stop a running run.",
496
529
  "runs.kill": "Force-stop a running run.",
497
530
  "runs.delete": "Delete an inactive run.",
531
+ "runs.prune": "Prune checkpoint storage or old failed/completed runs.",
498
532
  "project.init": "Create a Norn project in the current directory.",
499
533
  "project.inspect": "Inspect the Norn project.",
500
534
  pi: "Run bundled Pi for authentication, providers, and model setup.",
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DeletedNornRunInfo, NornProjectInspection, NornWorkflowCatalogInfo, NornWorkflowInspection, NornRunCheckpoint, NornRunInfo, NornRunMetrics } from "@vimhead.dev/norn";
1
+ import type { DeletedNornRunInfo, NornProjectInspection, NornWorkflowCatalogInfo, NornWorkflowInspection, NornRunCheckpoint, NornRunInfo, NornRunPruneOptions, NornRunPruneResult, NornRunMetrics } from "@vimhead.dev/norn";
2
2
  import type { NornRegisteredWorkflow } from "./internal/workflow-registry.ts";
3
3
  export { NornProjectLoadError } from "./internal/errors.ts";
4
4
  export type NornClientInput = {
@@ -35,6 +35,7 @@ export type NornClient = {
35
35
  stop(run: string): Promise<NornRunInfo>;
36
36
  kill(run: string): Promise<NornRunInfo>;
37
37
  delete(run: string): Promise<DeletedNornRunInfo>;
38
+ prune(options: NornRunPruneOptions): Promise<NornRunPruneResult>;
38
39
  logs(run: string, options?: {
39
40
  readonly follow?: boolean;
40
41
  }): AsyncIterable<Record<string, unknown>>;
package/dist/client.js CHANGED
@@ -79,6 +79,14 @@ function createNornClient(input = {}) {
79
79
  "delete",
80
80
  run
81
81
  ])).deleted,
82
+ prune: async (options) => (await processRunner.readJson([
83
+ "runs",
84
+ "prune",
85
+ ...options.deleteRuns ? ["--delete-runs"] : [],
86
+ ...options.all ? ["--all"] : [],
87
+ ...options.olderThan === null ? [] : ["--older-than", options.olderThan],
88
+ ...options.dryRun ? ["--dry-run"] : []
89
+ ])).prune,
82
90
  logs: (run, options) => processRunner.readJsonLines([
83
91
  "runs",
84
92
  "logs",
@@ -1,7 +1,7 @@
1
1
  export declare const NORN_GENERATED_BUILD_INFO: {
2
2
  readonly kind: "npm-registry";
3
- readonly version: "0.1.0-tip.36679386408.1";
4
- readonly commit: "3cb3189ec70f3b4407456061207294d685297b69";
3
+ readonly version: "0.1.0-tip.37307659560.1";
4
+ readonly commit: "31ff51915f9cd5da805d67b7a4b23c2f10a6de59";
5
5
  readonly packageSpec: "@vimhead.dev/norn-cli@tip";
6
6
  readonly upgrade: {
7
7
  readonly supported: false;
@@ -1,8 +1,8 @@
1
1
  // src/generated-build-info.ts
2
2
  var NORN_GENERATED_BUILD_INFO = {
3
3
  "kind": "npm-registry",
4
- "version": "0.1.0-tip.36679386408.1",
5
- "commit": "3cb3189ec70f3b4407456061207294d685297b69",
4
+ "version": "0.1.0-tip.37307659560.1",
5
+ "commit": "31ff51915f9cd5da805d67b7a4b23c2f10a6de59",
6
6
  "packageSpec": "@vimhead.dev/norn-cli@tip",
7
7
  "upgrade": {
8
8
  "supported": false,
@@ -8,7 +8,6 @@ type NornAgentRunnerInput = {
8
8
  readonly id: string;
9
9
  readonly runRoot: string;
10
10
  readonly signal?: AbortSignal;
11
- readonly model?: CreateAgentSessionOptions["model"];
12
11
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
13
12
  readonly agentDir?: string;
14
13
  readonly logs: NornRunLogs;
@@ -20,6 +19,7 @@ export declare class NornAgentRunner {
20
19
  private readonly responseToolFactory;
21
20
  constructor(input: NornAgentRunnerInput);
22
21
  createSession(agentInput: NornAgentCreateSessionInput): Promise<NornAgentSession>;
22
+ private selectModel;
23
23
  prompt<ResponseSchema extends TSchema>(agentInput: NornAgentSinglePromptInput<ResponseSchema>): Promise<StaticDecode<ResponseSchema>>;
24
24
  }
25
25
  export {};
@@ -76,6 +76,7 @@ var NornAgentRunner = class {
76
76
  ...loader.getExtensions().extensions.flatMap((extension) => [...extension.tools.keys()])
77
77
  ]
78
78
  });
79
+ const model = this.selectModel({ models: agentInput.models, services });
79
80
  let session;
80
81
  try {
81
82
  ({ session } = await createAgentSessionFromServices({
@@ -83,7 +84,7 @@ var NornAgentRunner = class {
83
84
  sessionManager: SessionManager.create(cwd, sessionDir),
84
85
  tools: withAgentResponseTool(agentInput.tools),
85
86
  customTools: [this.responseToolFactory.create(), ...customTools],
86
- model: agentInput.model ?? this.input.model,
87
+ model,
87
88
  thinkingLevel: agentInput.thinkingLevel ?? this.input.thinkingLevel
88
89
  }));
89
90
  await agentInput.beforeSessionStart?.({ events: eventBus });
@@ -91,7 +92,8 @@ var NornAgentRunner = class {
91
92
  await this.input.logger.record({
92
93
  type: "agent.spawned",
93
94
  label: agentInput.label,
94
- cwd
95
+ cwd,
96
+ model: { provider: model.provider, id: model.id }
95
97
  });
96
98
  return new CreatedNornAgentSession({
97
99
  ...this.input,
@@ -112,6 +114,35 @@ var NornAgentRunner = class {
112
114
  throw error;
113
115
  }
114
116
  }
117
+ selectModel(input) {
118
+ if (!Array.isArray(input.models) || input.models.length === 0)
119
+ throw new Error("Agent models must be a non-empty ordered list");
120
+ for (const [index, reference] of input.models.entries()) {
121
+ if (!reference || typeof reference.provider !== "string" || reference.provider.trim().length === 0 || typeof reference.id !== "string" || reference.id.trim().length === 0)
122
+ throw new Error(
123
+ `Agent models[${index}] requires non-empty provider and id strings`
124
+ );
125
+ }
126
+ const reasons = [];
127
+ for (const reference of input.models) {
128
+ const name = `${reference.provider}/${reference.id}`;
129
+ const model = input.services.modelRuntime.getModel(
130
+ reference.provider,
131
+ reference.id
132
+ );
133
+ if (!model) {
134
+ reasons.push(`${name}: not registered`);
135
+ continue;
136
+ }
137
+ if (!input.services.modelRuntime.hasConfiguredAuth(reference.provider)) {
138
+ reasons.push(`${name}: no configured credentials`);
139
+ continue;
140
+ }
141
+ return model;
142
+ }
143
+ throw new Error(`No usable agent models:
144
+ ${reasons.join("\n")}`);
145
+ }
115
146
  async prompt(agentInput) {
116
147
  const agent = await this.createSession(agentInput);
117
148
  try {
@@ -7,7 +7,6 @@ type NornExecutionContextInput = {
7
7
  readonly id: string;
8
8
  readonly runRoot: string;
9
9
  readonly signal?: AbortSignal;
10
- readonly model?: CreateAgentSessionOptions["model"];
11
10
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
12
11
  readonly agentDir?: string;
13
12
  readonly responseCollector: NornAgentResponseCollector;
@@ -42,7 +42,6 @@ var NornExecutionContext = class {
42
42
  id: this.input.id,
43
43
  runRoot: this.input.runRoot,
44
44
  signal: this.input.signal,
45
- model: this.input.model,
46
45
  thinkingLevel: this.input.thinkingLevel,
47
46
  agentDir: this.input.agentDir,
48
47
  logs: this.input.logs,
@@ -10,6 +10,9 @@ function isNodeError(error) {
10
10
 
11
11
  // ../core/src/atomic-files.ts
12
12
  async function writeTextAtomically(path, content) {
13
+ await writeBytesAtomically(path, Buffer.from(content, "utf8"));
14
+ }
15
+ async function writeBytesAtomically(path, content) {
13
16
  await mkdir(dirname(path), { recursive: true });
14
17
  let existingMode;
15
18
  try {
@@ -19,7 +22,7 @@ async function writeTextAtomically(path, content) {
19
22
  }
20
23
  const tmpPath = `${path}.${randomUUID()}.tmp`;
21
24
  try {
22
- await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
25
+ await writeFile(tmpPath, content, { mode: existingMode });
23
26
  if (existingMode !== void 0) await chmod(tmpPath, existingMode);
24
27
  await rename(tmpPath, path);
25
28
  } catch (error) {
@@ -19,6 +19,9 @@ async function writeJsonAtomically(path, value) {
19
19
  `);
20
20
  }
21
21
  async function writeTextAtomically(path, content) {
22
+ await writeBytesAtomically(path, Buffer.from(content, "utf8"));
23
+ }
24
+ async function writeBytesAtomically(path, content) {
22
25
  await mkdir(dirname(path), { recursive: true });
23
26
  let existingMode;
24
27
  try {
@@ -28,7 +31,7 @@ async function writeTextAtomically(path, content) {
28
31
  }
29
32
  const tmpPath = `${path}.${randomUUID()}.tmp`;
30
33
  try {
31
- await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
34
+ await writeFile(tmpPath, content, { mode: existingMode });
32
35
  if (existingMode !== void 0) await chmod(tmpPath, existingMode);
33
36
  await rename(tmpPath, path);
34
37
  } catch (error) {
@@ -17,6 +17,7 @@ export declare class NornRunLease {
17
17
  private constructor();
18
18
  static acquire(runRoot: string, processOwner?: NornRunProcessOwner): Promise<NornRunLease>;
19
19
  assertOwned(): Promise<void>;
20
+ deleteRunDirectory(): Promise<void>;
20
21
  release(): Promise<void>;
21
22
  private get ownerPath();
22
23
  private startHeartbeat;