@mingchuno/agent-workflows 0.2.0 → 0.3.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/README.md CHANGED
@@ -38,7 +38,12 @@ agent-workflows init
38
38
 
39
39
  For SDK use, install locally with `npm install @mingchuno/agent-workflows`. Both interfaces ship in the same package.
40
40
 
41
- Edit `agent-workflows.json`: set the checkout, hosting origin/repository, Git identity, agent stages and validation commands. Set the named hosting-token environment variable through your usual secret manager; for GitHub, follow the [PAT creation and required permissions](docs/providers.md#create-a-fine-grained-personal-access-token) guide before running. Commit or ignore the configuration before running. Keep the state directory outside managed checkouts, or explicitly Git-ignore it.
41
+ Edit `agent-workflows.json`: set the checkout, hosting origin/repository, agent
42
+ stages and validation commands. Relative checkout, state and prompt-file paths
43
+ resolve from the configuration file's directory. Commits use native Git identity
44
+ and disclose retained writable agent contributions with default-enabled co-author
45
+ trailers. Set the named hosting-token environment variable through your usual
46
+ secret manager; for GitHub, follow the [PAT creation and required permissions](docs/providers.md#create-a-fine-grained-personal-access-token) guide before running. Commit or ignore the configuration before running. Keep the state directory outside managed checkouts, or explicitly Git-ignore it.
42
47
 
43
48
  ```sh
44
49
  agent-workflows run
@@ -0,0 +1,9 @@
1
+ import type { Project } from "./config.js";
2
+ import type { ContributionCandidate, Publication, Snapshot } from "./domain.js";
3
+ export declare const agentCoAuthors: {
4
+ readonly codex: "Codex <noreply@openai.com>";
5
+ readonly copilot: "Copilot <223556219+Copilot@users.noreply.github.com>";
6
+ };
7
+ export type AttributedProvider = keyof typeof agentCoAuthors;
8
+ export declare function contributingProviders(candidates: ContributionCandidate[], final: Snapshot): AttributedProvider[];
9
+ export declare function finalizeCommitMessage(publication: Publication, project: Project, providers: AttributedProvider[], runId: string): Publication;
@@ -0,0 +1,57 @@
1
+ export const agentCoAuthors = {
2
+ codex: "Codex <noreply@openai.com>",
3
+ copilot: "Copilot <223556219+Copilot@users.noreply.github.com>",
4
+ };
5
+ const trailerLine = /^([A-Za-z0-9][A-Za-z0-9-]*):[ \t]*(\S.*)$/;
6
+ function normalizedTrailer(line) {
7
+ const match = trailerLine.exec(line);
8
+ return match ? `${match[1]}:${match[2]}`.toLowerCase() : undefined;
9
+ }
10
+ function retained(candidate, final) {
11
+ const paths = new Set([
12
+ ...Object.keys(candidate.beforeFiles),
13
+ ...Object.keys(candidate.afterFiles),
14
+ ]);
15
+ return [...paths].some((path) => candidate.beforeFiles[path] !== candidate.afterFiles[path] &&
16
+ candidate.beforeFiles[path] !== final.files[path]);
17
+ }
18
+ export function contributingProviders(candidates, final) {
19
+ const providers = new Set();
20
+ for (const candidate of candidates) {
21
+ if (!(candidate.provider in agentCoAuthors) || !retained(candidate, final))
22
+ continue;
23
+ providers.add(candidate.provider);
24
+ }
25
+ return [...providers];
26
+ }
27
+ export function finalizeCommitMessage(publication, project, providers, runId) {
28
+ const identities = project.includeAgentCoAuthors
29
+ ? providers.map((provider) => agentCoAuthors[provider])
30
+ : [];
31
+ const matching = new Set(identities.map((identity) => `co-authored-by:${identity}`.toLowerCase()));
32
+ const lines = publication.commitMessage
33
+ .split(/\r?\n/)
34
+ .filter((line) => !line.trim().toLowerCase().startsWith("agent-workflows-run:"));
35
+ while (lines.at(-1)?.trim() === "")
36
+ lines.pop();
37
+ const separator = lines.findLastIndex((line) => line.trim() === "");
38
+ const possibleTrailers = lines.slice(separator + 1);
39
+ const hasTrailerBlock = separator >= 0 &&
40
+ possibleTrailers.length > 0 &&
41
+ possibleTrailers.every((line) => trailerLine.test(line) || /^\s+\S/.test(line));
42
+ const body = hasTrailerBlock ? lines.slice(0, separator) : lines;
43
+ while (body.at(-1)?.trim() === "")
44
+ body.pop();
45
+ const existingTrailers = hasTrailerBlock
46
+ ? possibleTrailers.filter((line) => !matching.has(normalizedTrailer(line.trim()) ?? ""))
47
+ : [];
48
+ const finalizedTrailers = [
49
+ ...existingTrailers,
50
+ ...identities.map((identity) => `Co-authored-by: ${identity}`),
51
+ `Agent-Workflows-Run: ${runId}`,
52
+ ];
53
+ return {
54
+ ...publication,
55
+ commitMessage: `${body.join("\n")}\n\n${finalizedTrailers.join("\n")}`,
56
+ };
57
+ }
package/dist/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env -S node --
2
+ import { realpathSync, statSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
- import { basename, dirname, resolve } from "node:path";
4
+ import { basename, dirname, relative, resolve } from "node:path";
4
5
  import { parseEnv } from "node:util";
5
6
  import { Command } from "commander";
6
7
  import { render } from "ink";
@@ -11,12 +12,13 @@ import { configSchema } from "./config.js";
11
12
  import { defaultValidationTimeoutMs } from "./defaults.js";
12
13
  import { Runner } from "./runner.js";
13
14
  import { Store } from "./store.js";
14
- import { Monitor } from "./tui/index.js";
15
+ import { createTerminalNotificationWriter, Monitor } from "./tui/index.js";
15
16
  const launchDirectory = process.cwd();
16
17
  const program = new Command()
17
18
  .name("agent-workflows")
18
19
  .description("Local durable issue-to-review workflows")
19
20
  .option("-c, --config <file>", "configuration path", "agent-workflows.json")
21
+ .option("--config-base-directory <directory>", "base directory for paths contained in configuration")
20
22
  .option("--env-file <path>", "load literal dotenv values; existing environment wins")
21
23
  .hook("preAction", async () => {
22
24
  const path = program.opts().envFile;
@@ -36,7 +38,26 @@ const program = new Command()
36
38
  }
37
39
  });
38
40
  async function configuration() {
39
- return configSchema.parse(JSON.parse(await readFile(resolve(program.opts().config), "utf8")));
41
+ return configSchema.parse(JSON.parse(await readFile(configPath(), "utf8")));
42
+ }
43
+ function configPath() {
44
+ return resolve(launchDirectory, program.opts().config);
45
+ }
46
+ function configBaseDirectory() {
47
+ const selected = program.opts().configBaseDirectory;
48
+ const path = selected
49
+ ? resolve(launchDirectory, selected)
50
+ : dirname(configPath());
51
+ let canonical;
52
+ try {
53
+ canonical = realpathSync(path);
54
+ }
55
+ catch (error) {
56
+ throw new Error(`Configuration path base is not an existing directory: ${path}`, { cause: error });
57
+ }
58
+ if (!statSync(canonical).isDirectory())
59
+ throw new Error(`Configuration path base is not an existing directory: ${path}`);
60
+ return canonical;
40
61
  }
41
62
  function databaseUrl(config) {
42
63
  const value = process.env[config.databaseUrlEnv];
@@ -59,14 +80,18 @@ program
59
80
  .command("init")
60
81
  .description("Write a configuration scaffold without overwriting files")
61
82
  .action(async () => {
83
+ const base = configBaseDirectory();
84
+ const checkout = process.cwd();
85
+ const externalState = resolve(checkout, "..", `${basename(checkout)}.agent-workflows`);
86
+ const portable = (path) => relative(base, path) || ".";
62
87
  const config = {
63
88
  id: "local",
64
89
  databaseUrlEnv: "AGENT_WORKFLOWS_DATABASE_URL",
65
- stateDirectory: resolve("..", `${basename(process.cwd())}.agent-workflows`),
90
+ stateDirectory: portable(externalState),
66
91
  projects: [
67
92
  {
68
93
  id: "example",
69
- checkout: process.cwd(),
94
+ checkout: portable(checkout),
70
95
  hosting: {
71
96
  provider: "github",
72
97
  origin: "https://github.com",
@@ -76,7 +101,7 @@ program
76
101
  labels: ["ready-for-agent"],
77
102
  baseBranch: "main",
78
103
  branchTemplate: "agent/{issue}-{attempt}",
79
- gitIdentity: { name: "YOUR NAME", email: "you@example.com" },
104
+ includeAgentCoAuthors: true,
80
105
  agent: { provider: "codex" },
81
106
  validation: [
82
107
  {
@@ -89,8 +114,11 @@ program
89
114
  },
90
115
  ],
91
116
  };
92
- await writeFile(resolve(program.opts().config), JSON.stringify(config, null, 2) + "\n", { flag: "wx", mode: 0o600 });
93
- console.log("Created configuration. Set repository, checkout, identity and credentials before running.");
117
+ await writeFile(configPath(), JSON.stringify(config, null, 2) + "\n", {
118
+ flag: "wx",
119
+ mode: 0o600,
120
+ });
121
+ console.log("Created configuration. Set repository and credentials before running.");
94
122
  });
95
123
  program
96
124
  .command("run")
@@ -105,7 +133,7 @@ program
105
133
  config.projects = config.projects.filter((project) => wanted.has(project.id));
106
134
  }
107
135
  const runner = new Runner({
108
- promptBaseDirectory: dirname(resolve(program.opts().config)),
136
+ pathBaseDirectory: configBaseDirectory(),
109
137
  config,
110
138
  databaseUrl: databaseUrl(config),
111
139
  hosting: createHosting,
@@ -178,13 +206,19 @@ for (const kind of ["pause", "resume", "stop", "retry", "recover"])
178
206
  status: "pending",
179
207
  }));
180
208
  }));
181
- program.command("monitor").action(async () => {
209
+ program
210
+ .command("monitor")
211
+ .option("--notify", "notify when an observed execution reaches an outcome")
212
+ .action(async (options) => {
182
213
  if (!process.stdin.isTTY || !process.stdout.isTTY)
183
214
  throw new Error("Monitor requires an interactive terminal; use status --json instead");
184
215
  await withStore(async (store) => {
185
- await render(React.createElement(Monitor, { source: store }), {
186
- alternateScreen: true,
187
- }).waitUntilExit();
216
+ await render(React.createElement(Monitor, {
217
+ source: store,
218
+ notificationWriter: options.notify
219
+ ? createTerminalNotificationWriter()
220
+ : undefined,
221
+ }), { alternateScreen: true }).waitUntilExit();
188
222
  });
189
223
  });
190
224
  try {
@@ -51,10 +51,7 @@ export declare const projectSchema: z.ZodObject<{
51
51
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
52
52
  timeoutMs: z.ZodDefault<z.ZodNumber>;
53
53
  }, z.core.$strict>>>;
54
- gitIdentity: z.ZodObject<{
55
- name: z.ZodString;
56
- email: z.ZodEmail;
57
- }, z.core.$strict>;
54
+ includeAgentCoAuthors: z.ZodDefault<z.ZodBoolean>;
58
55
  agent: z.ZodObject<{
59
56
  provider: z.ZodEnum<{
60
57
  codex: "codex";
@@ -147,10 +144,7 @@ export declare const configSchema: z.ZodObject<{
147
144
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
148
145
  timeoutMs: z.ZodDefault<z.ZodNumber>;
149
146
  }, z.core.$strict>>>;
150
- gitIdentity: z.ZodObject<{
151
- name: z.ZodString;
152
- email: z.ZodEmail;
153
- }, z.core.$strict>;
147
+ includeAgentCoAuthors: z.ZodDefault<z.ZodBoolean>;
154
148
  agent: z.ZodObject<{
155
149
  provider: z.ZodEnum<{
156
150
  codex: "codex";
@@ -60,7 +60,7 @@ export const projectSchema = z.strictObject({
60
60
  .default(defaultValidationTimeoutMs),
61
61
  }))
62
62
  .default([]),
63
- gitIdentity: z.strictObject({ name: z.string().min(1), email: z.email() }),
63
+ includeAgentCoAuthors: z.boolean().default(true),
64
64
  agent: profileSchema,
65
65
  stages: z
66
66
  .strictObject({
@@ -47,6 +47,11 @@ export interface Snapshot {
47
47
  paths: string[];
48
48
  files: Record<string, string | null>;
49
49
  }
50
+ export interface ContributionCandidate {
51
+ provider: string;
52
+ beforeFiles: Record<string, string | null>;
53
+ afterFiles: Record<string, string | null>;
54
+ }
50
55
  export interface Workspace {
51
56
  check(project: Project): Promise<void>;
52
57
  prepare(project: Project, branch: string, signal?: AbortSignal): Promise<Snapshot>;
@@ -140,6 +145,8 @@ export interface RunRecord {
140
145
  snapshot?: Snapshot;
141
146
  validation?: ValidationResult[];
142
147
  publication?: Publication;
148
+ contributionCandidates?: ContributionCandidate[];
149
+ contributingProviders?: string[];
143
150
  change?: ChangeRequest;
144
151
  review?: Review;
145
152
  reviewHead?: string;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type Stage } from "./config.js";
3
- import { type RunRecord, type Snapshot } from "./domain.js";
3
+ import { type ContributionCandidate, type RunRecord, type Snapshot } from "./domain.js";
4
4
  import { type ChangeEvidence } from "./evidence.js";
5
5
  import type { OperationDependencies } from "./operations.js";
6
6
  export interface InvocationTask {
@@ -17,8 +17,9 @@ interface StageExecution {
17
17
  task: InvocationTask;
18
18
  stepId: number;
19
19
  dependencies: OperationDependencies;
20
- saveImplementationSnapshot: (runId: string, snapshot: Snapshot) => Promise<void>;
20
+ saveImplementationSnapshot: (runId: string, snapshot: Snapshot, provider: string) => Promise<ContributionCandidate | undefined>;
21
+ acceptContribution: (runId: string, candidate: ContributionCandidate) => Promise<void>;
21
22
  }
22
23
  /** One logical stage; only returned format errors admit a second response attempt. */
23
- export declare function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, }: StageExecution): Promise<string>;
24
+ export declare function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }: StageExecution): Promise<string>;
24
25
  export {};
@@ -3,12 +3,12 @@ import { appendFile, mkdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { resolveProfile } from "./config.js";
6
- import { BlockedError } from "./domain.js";
6
+ import { BlockedError, } from "./domain.js";
7
7
  import { verifyEvidence } from "./evidence.js";
8
8
  import { resolveStagePrompt, sha256 } from "./prompts.js";
9
9
  const maxInvocationAttempts = 2;
10
10
  /** One logical stage; only returned format errors admit a second response attempt. */
11
- export async function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, }) {
11
+ export async function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }) {
12
12
  const { store, project, agents, signal, workspace, redact } = dependencies;
13
13
  const previous = (await store.invocations(run.id)).filter((item) => item.stepId === stepId);
14
14
  if (previous.length) {
@@ -56,6 +56,7 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
56
56
  const directory = join(dependencies.artifacts, run.id);
57
57
  await mkdir(directory, { recursive: true, mode: 0o700 });
58
58
  let correction = "";
59
+ let pendingContribution;
59
60
  for (let attempt = 1; attempt <= maxInvocationAttempts; attempt++) {
60
61
  invocationSignal.throwIfAborted();
61
62
  const expected = (await store.run(run.id)).snapshot;
@@ -118,7 +119,7 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
118
119
  if (readOnly)
119
120
  await workspace.verify(project, expected);
120
121
  else
121
- await saveImplementationSnapshot(run.id, expected);
122
+ pendingContribution = await saveImplementationSnapshot(run.id, expected, profile.provider);
122
123
  if (task.evidence)
123
124
  await verifyEvidence(task.evidence);
124
125
  invocationSignal.throwIfAborted();
@@ -144,6 +145,8 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
144
145
  correction = `\n\nCorrect the prior response format in this fresh inspection-only session. Do not modify files.\nPrior invalid response:\n${output}\nValidation errors:\n${String(error)}`;
145
146
  continue;
146
147
  }
148
+ if (pendingContribution)
149
+ await acceptContribution(run.id, pendingContribution);
147
150
  record.outcome = "completed";
148
151
  return redact(task.outputContract ? JSON.stringify(parsed) : output);
149
152
  }
@@ -26,6 +26,7 @@ export declare class Operations {
26
26
  prepare(): Promise<void>;
27
27
  invoke(name: string, stage: Stage, task: InvocationTask): Promise<string>;
28
28
  private saveImplementationSnapshot;
29
+ private acceptContribution;
29
30
  implement(): Promise<void>;
30
31
  validate(): Promise<boolean>;
31
32
  private prepareEvidence;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { DBOS } from "@dbos-inc/dbos-sdk";
5
+ import { contributingProviders, finalizeCommitMessage } from "./attribution.js";
5
6
  import { BlockedError, isBlockedError, publicationSchema, reviewSchema, } from "./domain.js";
6
7
  import { captureEvidence, evidenceContext, } from "./evidence.js";
7
8
  import { invokeStage } from "./invocation.js";
@@ -103,15 +104,32 @@ export class Operations {
103
104
  task,
104
105
  stepId: DBOS.stepID,
105
106
  dependencies: this.dependencies,
106
- saveImplementationSnapshot: (runId, expected) => this.saveImplementationSnapshot(runId, expected),
107
+ saveImplementationSnapshot: (runId, expected, provider) => this.saveImplementationSnapshot(runId, expected, provider),
108
+ acceptContribution: (runId, candidate) => this.acceptContribution(runId, candidate),
107
109
  }));
108
110
  }
109
- async saveImplementationSnapshot(runId, expected) {
111
+ async saveImplementationSnapshot(runId, expected, provider) {
110
112
  const { workspace, project, store } = this.dependencies;
111
113
  const snapshot = await workspace.inspect(project);
112
114
  if (snapshot.head !== expected.head || snapshot.branch !== expected.branch)
113
115
  throw new BlockedError("Agent changed branch or committed unexpectedly");
114
116
  await store.patchRun(runId, { snapshot });
117
+ return snapshot.fingerprint === expected.fingerprint
118
+ ? undefined
119
+ : {
120
+ provider,
121
+ beforeFiles: expected.files,
122
+ afterFiles: snapshot.files,
123
+ };
124
+ }
125
+ async acceptContribution(runId, candidate) {
126
+ const run = await this.dependencies.store.run(runId);
127
+ await this.dependencies.store.patchRun(runId, {
128
+ contributionCandidates: [
129
+ ...(run.contributionCandidates ?? []),
130
+ candidate,
131
+ ],
132
+ });
115
133
  }
116
134
  async implement() {
117
135
  await this.invoke("implementation", this.dependencies.project.stages.implementation, {
@@ -206,8 +224,12 @@ export class Operations {
206
224
  context: (run) => `${evidenceContext(evidence)}\nIssue: ${JSON.stringify(run.issue)}\nValidation: ${JSON.stringify(run.validation ?? [])}`,
207
225
  });
208
226
  await this.step("publication-content", async (run) => {
227
+ if (!run.snapshot)
228
+ throw new Error("Missing publication snapshot");
229
+ const providers = contributingProviders(run.contributionCandidates ?? [], run.snapshot);
209
230
  await this.dependencies.store.patchRun(run.id, {
210
- publication: publicationSchema.parse(JSON.parse(output)),
231
+ publication: finalizeCommitMessage(publicationSchema.parse(JSON.parse(output)), this.dependencies.project, providers, run.id),
232
+ contributingProviders: providers,
211
233
  });
212
234
  });
213
235
  }
@@ -3,6 +3,7 @@ import { type AgentAdapter, type HostingAdapter, type Workspace } from "./domain
3
3
  import { Operations } from "./operations.js";
4
4
  import { Store } from "./store.js";
5
5
  export interface RunnerOptions {
6
+ pathBaseDirectory?: string;
6
7
  promptBaseDirectory?: string;
7
8
  config: Configuration;
8
9
  databaseUrl: string;
@@ -19,6 +20,7 @@ export declare class Runner {
19
20
  private readonly controllers;
20
21
  private readonly active;
21
22
  private readonly hosting;
23
+ private readonly promptBaseDirectory;
22
24
  private workflow;
23
25
  private readonly ownership;
24
26
  private stopping;
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { realpathSync, statSync } from "node:fs";
2
3
  import { mkdir, realpath } from "node:fs/promises";
3
4
  import { resolve } from "node:path";
4
5
  import { DBOS } from "@dbos-inc/dbos-sdk";
@@ -22,6 +23,7 @@ export class Runner {
22
23
  controllers = new Map();
23
24
  active = new Map();
24
25
  hosting = new Map();
26
+ promptBaseDirectory;
25
27
  workflow;
26
28
  ownership = new CheckoutOwnership();
27
29
  stopping = false;
@@ -33,8 +35,16 @@ export class Runner {
33
35
  constructor(options) {
34
36
  this.options = options;
35
37
  this.config = configSchema.parse(options.config);
38
+ const pathBaseDirectory = canonicalDirectory(options.pathBaseDirectory ?? process.cwd(), "Configuration path base");
39
+ this.config.stateDirectory = resolve(pathBaseDirectory, this.config.stateDirectory);
36
40
  for (const project of this.config.projects)
37
- projectPrompts(project, options.promptBaseDirectory);
41
+ project.checkout = resolve(pathBaseDirectory, project.checkout);
42
+ const promptBaseDirectory = options.promptBaseDirectory
43
+ ? resolve(options.promptBaseDirectory)
44
+ : pathBaseDirectory;
45
+ this.promptBaseDirectory = promptBaseDirectory;
46
+ for (const project of this.config.projects)
47
+ projectPrompts(project, promptBaseDirectory);
38
48
  this.store = new Store(options.databaseUrl, this.config.id, this.redact);
39
49
  }
40
50
  queue(id) {
@@ -49,7 +59,7 @@ export class Runner {
49
59
  const ids = new Set();
50
60
  for (const project of this.config.projects) {
51
61
  project.checkout = await realpath(project.checkout);
52
- await assertEvidenceDirectory(project.checkout, this.config.stateDirectory);
62
+ this.config.stateDirectory = await assertEvidenceDirectory(project.checkout, this.config.stateDirectory);
53
63
  if (canonical.has(project.checkout) || ids.has(project.id))
54
64
  throw new Error("Duplicate project identity or canonical checkout");
55
65
  canonical.add(project.checkout);
@@ -277,7 +287,7 @@ export class Runner {
277
287
  const workspace = this.options.workspace ?? new ExistingCheckout();
278
288
  let recoveryChecked = false;
279
289
  const operations = new Operations(runId, {
280
- promptBaseDirectory: this.options.promptBaseDirectory,
290
+ promptBaseDirectory: this.promptBaseDirectory,
281
291
  store: this.store,
282
292
  project,
283
293
  workspace,
@@ -495,3 +505,18 @@ export class Runner {
495
505
  await this.store.close();
496
506
  }
497
507
  }
508
+ function canonicalDirectory(path, label) {
509
+ const resolved = resolve(path);
510
+ let canonical;
511
+ try {
512
+ canonical = realpathSync(resolved);
513
+ }
514
+ catch (error) {
515
+ throw new Error(`${label} is not an existing directory: ${resolved}`, {
516
+ cause: error,
517
+ });
518
+ }
519
+ if (!statSync(canonical).isDirectory())
520
+ throw new Error(`${label} is not an existing directory: ${resolved}`);
521
+ return canonical;
522
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RunRecord } from "../domain.js";
2
2
  import type { EventRecord, InvocationRecord, ProjectState, Store } from "../store.js";
3
+ import { type ExecutionNotificationWriter } from "./notifications.js";
3
4
  export interface MonitorSource {
4
5
  projects: Store["projects"];
5
6
  runs: Store["runs"];
@@ -12,7 +13,7 @@ export type MonitorAction = "pause" | "resume" | "stop" | "retry" | "recover";
12
13
  export declare function useMonitorData(source: MonitorSource, selection: {
13
14
  projectId?: string;
14
15
  runId?: string;
15
- }): {
16
+ }, notificationWriter?: ExecutionNotificationWriter): {
16
17
  projects: ProjectState[];
17
18
  project: ProjectState | undefined;
18
19
  projectRuns: RunRecord[];
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { tuiRefreshIntervalMs } from "./constants.js";
3
- export function useMonitorData(source, selection) {
3
+ import { ExecutionNotificationObserver, } from "./notifications.js";
4
+ export function useMonitorData(source, selection, notificationWriter) {
4
5
  const [projects, setProjects] = useState([]);
5
6
  const [runs, setRuns] = useState([]);
6
7
  const [detail, setDetail] = useState({ sessions: [], events: [] });
@@ -10,6 +11,9 @@ export function useMonitorData(source, selection) {
10
11
  const [pending, setPending] = useState();
11
12
  const pendingRef = useRef(undefined);
12
13
  const mounted = useRef(true);
14
+ const notificationObserver = useRef(notificationWriter
15
+ ? new ExecutionNotificationObserver(notificationWriter)
16
+ : undefined).current;
13
17
  const project = projects.find((item) => item.id === selection.projectId) ?? projects[0];
14
18
  const projectRuns = runs.filter((item) => item.projectId === project?.id);
15
19
  const run = projectRuns.find((item) => item.id === selection.runId) ?? projectRuns[0];
@@ -38,6 +42,7 @@ export function useMonitorData(source, selection) {
38
42
  return;
39
43
  setProjects(nextProjects);
40
44
  setRuns(nextRuns);
45
+ notificationObserver?.observe(nextRuns);
41
46
  if (selectedRunId) {
42
47
  const [sessions, nextEvents] = await Promise.all([
43
48
  source.invocations(selectedRunId),
@@ -71,7 +76,7 @@ export function useMonitorData(source, selection) {
71
76
  closed = true;
72
77
  clearInterval(timer);
73
78
  };
74
- }, [source, selectedRunId]);
79
+ }, [source, selectedRunId, notificationObserver]);
75
80
  useEffect(() => {
76
81
  if (!pending || pending === "submitting")
77
82
  return;
@@ -1,2 +1,3 @@
1
1
  export type { MonitorSource } from "./data.js";
2
2
  export { Monitor } from "./monitor.js";
3
+ export { createTerminalNotificationWriter, type ExecutionNotification, type ExecutionNotificationWriter, } from "./notifications.js";
@@ -1 +1,2 @@
1
1
  export { Monitor } from "./monitor.js";
2
+ export { createTerminalNotificationWriter, } from "./notifications.js";
@@ -1,5 +1,5 @@
1
1
  /** Pane content sizes shared by rendering and keyboard scrolling. */
2
- export declare function monitorLayout(columns: number, rows: number): {
2
+ export declare function monitorLayout(columns: number, rows: number, compactChrome?: boolean): {
3
3
  wide: boolean;
4
4
  height: number;
5
5
  paneWidth: number;
@@ -2,9 +2,9 @@ import { screenChromeRows } from "./constants.js";
2
2
  const panelHorizontalChrome = 4;
3
3
  const panelVerticalChrome = 3;
4
4
  /** Pane content sizes shared by rendering and keyboard scrolling. */
5
- export function monitorLayout(columns, rows) {
5
+ export function monitorLayout(columns, rows, compactChrome = false) {
6
6
  const wide = columns >= 110;
7
- const height = Math.max(1, rows - screenChromeRows);
7
+ const height = Math.max(1, rows - (compactChrome ? 4 : screenChromeRows));
8
8
  const paneWidth = wide ? Math.floor(columns * 0.43) : columns;
9
9
  const summaryWidth = wide ? columns - paneWidth : columns;
10
10
  const summaryPanelHeight = wide ? height - 6 : height;
@@ -1,8 +1,10 @@
1
1
  import { type MonitorSource } from "./data.js";
2
- export declare function Monitor({ source, size, }: {
2
+ import type { ExecutionNotificationWriter } from "./notifications.js";
3
+ export declare function Monitor({ source, size, notificationWriter, }: {
3
4
  source: MonitorSource;
4
5
  size?: {
5
6
  columns: number;
6
7
  rows: number;
7
8
  };
9
+ notificationWriter?: ExecutionNotificationWriter;
8
10
  }): import("react").JSX.Element;