@parcha/agentrun-pi 0.1.0-beta.2 → 0.1.0-beta.21

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 (114) hide show
  1. package/README.md +123 -32
  2. package/dist/cli.js +15 -4
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/demo.js +1 -0
  6. package/dist/durable/decision-recovery.d.ts +23 -0
  7. package/dist/durable/decision-recovery.js +57 -0
  8. package/dist/durable/decisions.d.ts +113 -0
  9. package/dist/durable/decisions.js +71 -0
  10. package/dist/durable/index.d.ts +20 -0
  11. package/dist/durable/index.js +14 -0
  12. package/dist/durable/node-runner.d.ts +106 -0
  13. package/dist/durable/node-runner.js +253 -0
  14. package/dist/durable/node.d.ts +88 -0
  15. package/dist/durable/node.js +98 -0
  16. package/dist/durable/pi-versions.d.ts +34 -0
  17. package/dist/durable/pi-versions.js +66 -0
  18. package/dist/durable/readers.d.ts +28 -0
  19. package/dist/durable/readers.js +103 -0
  20. package/dist/durable/receipts.d.ts +54 -0
  21. package/dist/durable/receipts.js +92 -0
  22. package/dist/durable/record-file.d.ts +10 -0
  23. package/dist/durable/record-file.js +56 -0
  24. package/dist/durable/record-tool.d.ts +76 -0
  25. package/dist/durable/record-tool.js +123 -0
  26. package/dist/durable/record.d.ts +157 -0
  27. package/dist/durable/record.js +402 -0
  28. package/dist/durable/recording.d.ts +90 -0
  29. package/dist/durable/recording.js +292 -0
  30. package/dist/durable/store.d.ts +23 -0
  31. package/dist/durable/store.js +242 -0
  32. package/dist/durable/task.d.ts +133 -0
  33. package/dist/durable/task.js +411 -0
  34. package/dist/durable/workflow-tool.d.ts +32 -0
  35. package/dist/durable/workflow-tool.js +188 -0
  36. package/dist/extension-service.js +63 -22
  37. package/dist/extension.d.ts +2 -0
  38. package/dist/extension.js +463 -80
  39. package/dist/host-addendum.d.ts +4 -0
  40. package/dist/host-addendum.js +20 -0
  41. package/dist/host-session.js +5 -2
  42. package/dist/index.d.ts +1 -2
  43. package/dist/index.js +1 -2
  44. package/dist/pi-host.d.ts +7 -0
  45. package/dist/pi-host.js +22 -0
  46. package/dist/presentation.js +2 -2
  47. package/dist/run-error.d.ts +6 -0
  48. package/dist/run-error.js +13 -0
  49. package/dist/runner.d.ts +1 -6
  50. package/dist/runner.js +7 -13
  51. package/dist/signin/index.d.ts +9 -0
  52. package/dist/signin/index.js +7 -0
  53. package/dist/signin/limit.d.ts +10 -0
  54. package/dist/signin/limit.js +23 -0
  55. package/dist/signin/resolve.d.ts +47 -0
  56. package/dist/signin/resolve.js +163 -0
  57. package/dist/signin/signin.d.ts +44 -0
  58. package/dist/signin/signin.js +126 -0
  59. package/dist/signin/spec.d.ts +116 -0
  60. package/dist/signin/spec.js +181 -0
  61. package/dist/signin/store.d.ts +40 -0
  62. package/dist/signin/store.js +206 -0
  63. package/dist/skills/author/SKILL.md +138 -0
  64. package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
  65. package/dist/skills/author/references/language.md +134 -0
  66. package/dist/skills/author/references/sdk-authoring.md +11 -0
  67. package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
  68. package/dist/sop.d.ts +12 -0
  69. package/dist/sop.js +29 -0
  70. package/dist/thinking.d.ts +3 -0
  71. package/dist/thinking.js +7 -0
  72. package/dist/tool-context.d.ts +21 -0
  73. package/dist/tool-context.js +13 -0
  74. package/dist/triage-demo.d.ts +62 -0
  75. package/dist/triage-demo.js +153 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/workflow-inspector.d.ts +47 -0
  78. package/dist/workflow-inspector.js +235 -0
  79. package/dist/workflow-session.d.ts +23 -0
  80. package/dist/workflow-session.js +168 -0
  81. package/dist/workflow-store.d.ts +36 -0
  82. package/dist/workflow-store.js +255 -0
  83. package/dist/workflow-tool.d.ts +81 -0
  84. package/dist/workflow-tool.js +94 -0
  85. package/dist/workflow-view.d.ts +64 -0
  86. package/dist/workflow-view.js +273 -0
  87. package/dist/workflows.d.ts +1 -0
  88. package/dist/workflows.js +3 -0
  89. package/package.json +57 -11
  90. package/dist/admission.d.ts +0 -6
  91. package/dist/admission.js +0 -51
  92. package/dist/author.d.ts +0 -21
  93. package/dist/author.js +0 -72
  94. package/dist/benchmark-contract.d.ts +0 -88
  95. package/dist/benchmark-contract.js +0 -50
  96. package/dist/benchmark-freeze.d.ts +0 -9
  97. package/dist/benchmark-freeze.js +0 -9
  98. package/dist/benchmark.d.ts +0 -127
  99. package/dist/benchmark.js +0 -260
  100. package/dist/comparison.d.ts +0 -69
  101. package/dist/comparison.js +0 -229
  102. package/dist/skill-bundle.d.ts +0 -5
  103. package/dist/skill-bundle.js +0 -15
  104. package/skills/author/SKILL.md +0 -102
  105. package/skills/author/references/sdk-authoring.md +0 -16
  106. /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
  107. /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
  108. /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
  109. /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
  110. /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
  111. /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
  112. /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
  113. /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
  114. /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
@@ -0,0 +1,126 @@
1
+ // Sign in with ChatGPT through pi-ai's own flow (`openaiChatGPTOAuth.login`), unchanged. We pass the two options it
2
+ // takes: `agentName`, this app's name rather than pi's, and `getDeviceId`, the host id kept in auth.json. The credential
3
+ // it returns goes straight into the file store through pi-ai's `Models.login`; nothing here sees or prints a token.
4
+ import { spawn } from "node:child_process";
5
+ import { createInterface } from "node:readline";
6
+ import { createModels } from "@earendil-works/pi-ai/models";
7
+ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
8
+ import { agentrunHome, fileCredentialStore } from "./store.js";
9
+ export const AGENT_NAME = "AgentRun";
10
+ /** pi-ai's provider id for OpenAI, whose OAuth method is Sign in with ChatGPT. */
11
+ export const CHATGPT_PROVIDER = "openai";
12
+ export class SignInError extends Error {
13
+ code;
14
+ constructor(code, message, options) {
15
+ super(message, options);
16
+ this.code = code;
17
+ this.name = "SignInError";
18
+ }
19
+ }
20
+ /** A pi-ai collection with only OpenAI, whose credentials live in `store`. */
21
+ export function chatgptModels(store) {
22
+ const models = createModels({ credentials: store });
23
+ models.setProvider(openaiProvider());
24
+ return models;
25
+ }
26
+ /**
27
+ * Sign in once per install; the token refreshes on its own afterwards. The sign-in page must name this app: pi-ai
28
+ * before 1.1 ignores `agentName` and names pi, so a page that names anything else is never shown, and the sign-in fails
29
+ * with `SignInError` ("APP_NAME_IGNORED") before the browser opens.
30
+ */
31
+ export async function signIn(options = {}) {
32
+ const home = agentrunHome(options.home, options.env);
33
+ const agentName = options.agentName ?? AGENT_NAME;
34
+ const store = fileCredentialStore(home);
35
+ const hostId = await store.hostId();
36
+ const inner = options.interaction ?? consoleInteraction();
37
+ const stop = new AbortController();
38
+ let named;
39
+ const interaction = {
40
+ signal: inner.signal ? AbortSignal.any([inner.signal, stop.signal]) : stop.signal,
41
+ notify(event) {
42
+ if (event.type === "auth_url" && (named = new URL(event.url).searchParams.get("agent_name_hint")) !== agentName)
43
+ return stop.abort();
44
+ inner.notify(event);
45
+ },
46
+ // pi-ai's login ends only when the browser's callback comes or this prompt rejects, and its callback server holds
47
+ // port 1455 until then: a cancel rejects the prompt even when the interaction's own prompt does not watch it.
48
+ prompt: (prompt) => {
49
+ if (stop.signal.aborted)
50
+ return Promise.reject(stop.signal.reason);
51
+ const cancel = prompt.signal;
52
+ if (!cancel)
53
+ return inner.prompt(prompt);
54
+ if (cancel.aborted)
55
+ return Promise.reject(cancel.reason);
56
+ return new Promise((resolve, reject) => {
57
+ const onAbort = () => reject(cancel.reason);
58
+ cancel.addEventListener("abort", onAbort, { once: true });
59
+ inner.prompt(prompt).then(resolve, reject).finally(() => cancel.removeEventListener("abort", onAbort));
60
+ });
61
+ },
62
+ };
63
+ // A variable, not a literal: pi-ai before 1.1 has no `agentName` in its `LoginOptions` type, and the page check above
64
+ // refuses those versions at run time.
65
+ const loginOptions = { agentName, getDeviceId: () => hostId };
66
+ let credential;
67
+ try {
68
+ credential = await chatgptModels(store).login(CHATGPT_PROVIDER, "oauth", interaction, loginOptions);
69
+ }
70
+ catch (error) {
71
+ if (stop.signal.aborted) {
72
+ throw new SignInError("APP_NAME_IGNORED", `The installed pi-ai names ${named ? `"${named}"` : "no app"} on the sign-in page instead of "${agentName}": it ignores the app name. Sign in with ChatGPT needs a pi-ai that passes it on (1.1.0 or later).`, { cause: error });
73
+ }
74
+ // pi-ai's callback port is fixed at 1455, and its message for a taken port names the likely holder.
75
+ if (error instanceof Error && /\bPort 1455 is in use\b/.test(error.message)) {
76
+ throw new SignInError("PORT_IN_USE", `${error.message}\nFinish or cancel the other login, then run again.`, { cause: error });
77
+ }
78
+ throw error;
79
+ }
80
+ return { home, provider: CHATGPT_PROVIDER, hostId, expires: credential.type === "oauth" ? credential.expires : 0 };
81
+ }
82
+ /** The terminal: prints the sign-in page's address and opens it, then waits for the browser's callback or, on a
83
+ * terminal, a pasted redirect address. The page address carries no token. */
84
+ export function consoleInteraction(io = {}) {
85
+ const input = io.input ?? process.stdin;
86
+ const output = io.output ?? process.stderr;
87
+ const open = io.open ?? (process.env.AGENTRUN_NO_BROWSER ? undefined : openBrowser);
88
+ return {
89
+ notify(event) {
90
+ if (event.type === "auth_url") {
91
+ output.write(`Continue with ChatGPT: sign in at\n ${event.url}\n${event.instructions ? `${event.instructions}\n` : ""}`);
92
+ open?.(event.url);
93
+ }
94
+ else if (event.type === "progress" || event.type === "info") {
95
+ output.write(`${event.message}\n`);
96
+ }
97
+ },
98
+ prompt(prompt) {
99
+ return new Promise((done, fail) => {
100
+ const signal = prompt.signal;
101
+ if (signal?.aborted)
102
+ return fail(signal.reason);
103
+ // Off a terminal only the browser's callback can finish the sign-in; the prompt waits to be cancelled.
104
+ const lines = input.isTTY ? createInterface({ input, output }) : undefined;
105
+ const onAbort = () => { lines?.close(); fail(signal?.reason); };
106
+ signal?.addEventListener("abort", onAbort, { once: true });
107
+ lines?.question(`${prompt.message} `, (answer) => {
108
+ signal?.removeEventListener("abort", onAbort);
109
+ lines.close();
110
+ done(answer);
111
+ });
112
+ });
113
+ },
114
+ };
115
+ }
116
+ function openBrowser(url) {
117
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
118
+ : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
119
+ : ["xdg-open", [url]];
120
+ try {
121
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
122
+ child.on("error", () => { });
123
+ child.unref();
124
+ }
125
+ catch { /* no opener: the address is printed */ }
126
+ }
@@ -0,0 +1,116 @@
1
+ /** A run mode an item is needed for: `live` (paid calls) or `kill` (the kill on a real box). Without `for`, every run. */
2
+ export type RunMode = "live" | "kill";
3
+ export type Platform = "linux" | "darwin" | "win32";
4
+ export type Key = {
5
+ env: string;
6
+ why: string;
7
+ where: string;
8
+ required: boolean;
9
+ for?: RunMode;
10
+ };
11
+ /** `min` is an x.y.z version, compared with the first x.y.z that `<cmd> --version` prints. */
12
+ export type Program = {
13
+ cmd: string;
14
+ why: string;
15
+ required: boolean;
16
+ platforms?: Platform[];
17
+ min?: string;
18
+ for?: RunMode;
19
+ };
20
+ /** An npm package the demo loads only when used, installed with `--no-save` so it is never a dependency. */
21
+ export type Package = {
22
+ name: string;
23
+ version: string;
24
+ why: string;
25
+ required: boolean;
26
+ for?: RunMode;
27
+ };
28
+ /** One of its members; an array member means all of its items (a key pair, or a key with its package). */
29
+ export type Choice = {
30
+ oneOf: Array<Key | Program | Package | Array<Key | Program | Package>>;
31
+ why: string;
32
+ for?: RunMode;
33
+ };
34
+ export type ModelNeed = "text" | "tools" | "images";
35
+ export type EyesNeed = "images" | "video";
36
+ export type DemoSpec = {
37
+ name: string;
38
+ models: {
39
+ lead: {
40
+ needs: ModelNeed[];
41
+ default?: string;
42
+ };
43
+ /** A second model the run calls itself. */
44
+ eyes?: {
45
+ needs: EyesNeed[];
46
+ default?: string;
47
+ };
48
+ judge?: {
49
+ jev: true;
50
+ };
51
+ };
52
+ keys: Array<Key | Choice>;
53
+ programs: Array<Program | Choice>;
54
+ packages?: Array<Package | Choice>;
55
+ expectedCalls: number;
56
+ /** USD for one `--live` run. */
57
+ expectedCost: number;
58
+ };
59
+ /** Jev in a stranger's run: its own key, or Jev through OpenRouter. Nothing is substituted silently. */
60
+ export declare const JEV_KEYS: Choice;
61
+ export type CheckOptions = {
62
+ env?: NodeJS.ProcessEnv;
63
+ platform?: NodeJS.Platform;
64
+ /** The demo's directory, where its packages resolve from. Default: the current directory. */
65
+ dir?: string;
66
+ live?: boolean;
67
+ kill?: boolean;
68
+ /** An explicit eyes model (`--eyes-model`); its key is checked when the model is resolved. */
69
+ eyes?: {
70
+ provider: string;
71
+ model: string;
72
+ };
73
+ };
74
+ /** One requirement of this run and whether this machine meets it. `fix` says what to do, never a value. */
75
+ export type ReportItem = {
76
+ kind: "key" | "program" | "package" | "choice" | "model";
77
+ name: string;
78
+ why: string;
79
+ required: boolean;
80
+ met: boolean;
81
+ fix?: string;
82
+ };
83
+ export type KeyReport = {
84
+ ok: boolean;
85
+ /** 2 when a required item is missing: nothing may start. */
86
+ exitCode: 0 | 2;
87
+ items: ReportItem[];
88
+ missing: ReportItem[];
89
+ expectedCalls: number;
90
+ expectedCost: number;
91
+ /** The report as printed: every requirement, what is missing and how to fix it. */
92
+ text: string;
93
+ };
94
+ /**
95
+ * What this demo needs for this run, and what is missing. Items whose `for` is not this run's mode are left out, choice
96
+ * members included; `kill` implies `live`. A choice is met by any member, an array member only when all of its items
97
+ * are. A relative `dir` is taken from the current directory. A required program
98
+ * outside its platforms is missing, before anything starts, and so is one below its `min` or whose version cannot be
99
+ * read. It sends no request and prompts for nothing, so a run that checks first sends nothing while a required item is
100
+ * missing (`exitCode` 2).
101
+ */
102
+ export declare function checkKeys(spec: DemoSpec, options?: CheckOptions): KeyReport;
103
+ export type SetupPromptReport = {
104
+ ok: boolean;
105
+ /** Spec names the prompt never mentions: key env names, program commands and package names, choice members included. */
106
+ missing: string[];
107
+ /** Credential env names the prompt mentions that the spec does not list. */
108
+ extra: string[];
109
+ };
110
+ /**
111
+ * Whether a demo's SETUP-PROMPT.md matches its spec. Every key env name, program command and package name in the spec,
112
+ * choice members included, appears in the prompt. "Nothing more" covers credential env names only: an UPPER_SNAKE name
113
+ * that reads as a credential and is not in the spec fails. A prompt's own command words (sudo, an install step) are
114
+ * never read as programs.
115
+ */
116
+ export declare function checkSetupPrompt(spec: DemoSpec, text: string): SetupPromptReport;
@@ -0,0 +1,181 @@
1
+ // What a demo needs, and the key check that runs before anything else. Neither check sends a request or prompts. They
2
+ // read only the environment they are given, the PATH in it and the demo's own node_modules, and the key check runs one
3
+ // process: `<cmd> --version`, for a program with a minimum version. A key's value is never read past "is it set", and
4
+ // never printed.
5
+ import { spawnSync } from "node:child_process";
6
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { delimiter, join, resolve } from "node:path";
9
+ /** Jev in a stranger's run: its own key, or Jev through OpenRouter. Nothing is substituted silently. */
10
+ export const JEV_KEYS = {
11
+ oneOf: [
12
+ { env: "TYPESAFE_API_KEY", why: "Jev's typed judgments", where: "https://console.typesafe.ai/keys", required: true },
13
+ { env: "OPENROUTER_API_KEY", why: "Jev through OpenRouter", where: "https://openrouter.ai/keys", required: true },
14
+ ],
15
+ why: "the judge is Jev",
16
+ for: "live",
17
+ };
18
+ const isChoice = (item) => "oneOf" in item;
19
+ const isKey = (item) => "env" in item;
20
+ const isProgram = (item) => "cmd" in item;
21
+ const nameOf = (item) => isKey(item) ? item.env : isProgram(item) ? item.cmd : `${item.name}@${item.version}`;
22
+ /** Every requirement of `spec`, the implied ones included: Jev's keys when the judge is Jev. */
23
+ function requirements(spec) {
24
+ return [...spec.keys, ...(spec.models.judge?.jev ? [JEV_KEYS] : []), ...spec.programs, ...(spec.packages ?? [])];
25
+ }
26
+ /** The file `cmd` runs from `env`'s PATH, or undefined when it is not there. */
27
+ function onPath(cmd, ctx) {
28
+ const exts = ctx.platform === "win32" ? ["", ...(ctx.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
29
+ for (const dir of (ctx.env.PATH ?? ctx.env.Path ?? "").split(delimiter)) {
30
+ if (!dir)
31
+ continue;
32
+ for (const ext of exts) {
33
+ const file = join(dir, cmd + ext);
34
+ try {
35
+ if (!statSync(file).isFile())
36
+ continue;
37
+ accessSync(file, constants.X_OK);
38
+ return file;
39
+ }
40
+ catch { /* not this one */ }
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ const XYZ = /(\d+)\.(\d+)\.(\d+)/;
46
+ const versionParts = (version) => XYZ.exec(version)?.slice(1, 4).map(Number);
47
+ /** The first x.y.z that `file --version` prints, stdout before stderr; undefined when it prints none, fails or hangs. */
48
+ function programVersion(file, ctx) {
49
+ const shell = ctx.platform === "win32" && /\.(cmd|bat)$/i.test(file);
50
+ const run = spawnSync(file, ["--version"], { env: ctx.env, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"], shell });
51
+ return run.status === 0 ? XYZ.exec(`${run.stdout}\n${run.stderr}`)?.[0] : undefined;
52
+ }
53
+ /** Whether x.y.z `found` is at least `min`. */
54
+ function atLeast(found, min) {
55
+ const [a, b] = [versionParts(found), versionParts(min)];
56
+ for (let i = 0; i < 3; i += 1)
57
+ if (a[i] !== b[i])
58
+ return a[i] > b[i];
59
+ return true;
60
+ }
61
+ /** The version of `name` that Node would load from `dir`, or undefined when it resolves nowhere. */
62
+ function installedVersion(name, dir) {
63
+ for (const base of createRequire(join(dir, "noop.js")).resolve.paths(name) ?? []) {
64
+ const manifest = join(base, name, "package.json");
65
+ if (!existsSync(manifest))
66
+ continue;
67
+ try {
68
+ return String(JSON.parse(readFileSync(manifest, "utf8")).version);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ /** Whether `item` is met here, and the fix when it is not. */
77
+ function checkMember(item, ctx) {
78
+ if (isKey(item))
79
+ return ctx.env[item.env] ? { met: true } : { met: false, fix: `set ${item.env} (create it: ${item.where})` };
80
+ if (isProgram(item)) {
81
+ if (item.platforms && !item.platforms.includes(ctx.platform)) {
82
+ return { met: false, fix: `${item.cmd} runs on ${item.platforms.join(" or ")} only; this machine is ${ctx.platform}` };
83
+ }
84
+ const file = onPath(item.cmd, ctx);
85
+ if (!file)
86
+ return { met: false, fix: `install ${item.cmd}${item.min ? ` ${item.min} or later` : ""}; it is not on PATH` };
87
+ if (!item.min)
88
+ return { met: true };
89
+ if (!versionParts(item.min))
90
+ return { met: false, fix: `the spec's minimum for ${item.cmd} (${item.min}) is not an x.y.z version` };
91
+ const found = programVersion(file, ctx);
92
+ if (!found)
93
+ return { met: false, fix: `${item.cmd} --version printed no x.y.z version, so ${item.min} or later cannot be confirmed` };
94
+ return atLeast(found, item.min) ? { met: true } : { met: false, fix: `${item.cmd} is ${found}; this run needs ${item.min} or later` };
95
+ }
96
+ const found = installedVersion(item.name, ctx.dir);
97
+ if (found === item.version)
98
+ return { met: true };
99
+ const install = `npm install --no-save ${item.name}@${item.version}`;
100
+ return { met: false, fix: found === undefined ? install : `${install} (found ${found})` };
101
+ }
102
+ const applies = (item, options) => !item.for || (item.for === "live" ? options.live || options.kill : options.kill) === true;
103
+ /**
104
+ * What this demo needs for this run, and what is missing. Items whose `for` is not this run's mode are left out, choice
105
+ * members included; `kill` implies `live`. A choice is met by any member, an array member only when all of its items
106
+ * are. A relative `dir` is taken from the current directory. A required program
107
+ * outside its platforms is missing, before anything starts, and so is one below its `min` or whose version cannot be
108
+ * read. It sends no request and prompts for nothing, so a run that checks first sends nothing while a required item is
109
+ * missing (`exitCode` 2).
110
+ */
111
+ export function checkKeys(spec, options = {}) {
112
+ const ctx = { env: options.env ?? process.env, platform: options.platform ?? process.platform, dir: resolve(options.dir ?? ".") };
113
+ const items = [];
114
+ for (const item of requirements(spec)) {
115
+ if (!applies(item, options))
116
+ continue;
117
+ if (!isChoice(item)) {
118
+ const kind = isKey(item) ? "key" : isProgram(item) ? "program" : "package";
119
+ items.push({ kind, name: nameOf(item), why: item.why, required: item.required, ...checkMember(item, ctx) });
120
+ continue;
121
+ }
122
+ // A member's own `for` applies too: an item this mode does not need drops out of its member, and a member left with
123
+ // nothing drops out of the choice, which this mode then does not need at all.
124
+ const members = item.oneOf.map((member) => (Array.isArray(member) ? member : [member]).filter((one) => applies(one, options))).filter((all) => all.length);
125
+ if (!members.length)
126
+ continue;
127
+ const options_ = members.map((all) => {
128
+ const checked = all.map((one) => ({ one, ...checkMember(one, ctx) }));
129
+ return { name: all.map(nameOf).join(" + "), met: checked.every((one) => one.met), fixes: checked.filter((one) => !one.met).map((one) => one.fix) };
130
+ });
131
+ const met = options_.some((option) => option.met);
132
+ items.push({
133
+ kind: "choice", name: options_.map((option) => option.name).join(" or "), why: item.why, required: true, met,
134
+ ...(met ? {} : { fix: `one of: ${options_.map((option) => option.fixes.join(", ")).join("; or ")}` }),
135
+ });
136
+ }
137
+ const eyes = spec.models.eyes;
138
+ if (eyes && (options.live || options.kill) && eyes.needs.includes("video") && !options.eyes) {
139
+ items.push({
140
+ kind: "model", name: "eyes", why: "the run's eyes read video", required: true, met: false,
141
+ fix: "the ChatGPT plan takes no video input, and the lead model's catalog lists none; name a model that reads video natively with --eyes-model and set its provider's key, or list images and let frames be extracted locally",
142
+ });
143
+ }
144
+ const missing = items.filter((item) => item.required && !item.met);
145
+ const mode = options.kill ? "--live --kill" : options.live ? "--live" : "on fakes";
146
+ const lines = [`${spec.name} (${mode}) needs:`];
147
+ for (const item of items) {
148
+ const state = item.met ? "ok " : item.required ? "MISSING " : "optional";
149
+ lines.push(` ${state} ${item.kind} ${item.name}: ${item.why}`);
150
+ if (!item.met)
151
+ lines.push(` ${item.fix}`);
152
+ }
153
+ if (options.live || options.kill) {
154
+ lines.push(" model: Sign in with ChatGPT (your plan pays), or --provider and --model with that provider's key");
155
+ lines.push(`Expected: about ${spec.expectedCalls} model calls, about $${spec.expectedCost.toFixed(2)} at list prices.`);
156
+ }
157
+ lines.push(missing.length
158
+ ? `${missing.length} required ${missing.length === 1 ? "item is" : "items are"} missing, so nothing started. SETUP-PROMPT.md beside this demo walks through each one.`
159
+ : "Everything this run needs is here.");
160
+ return { ok: missing.length === 0, exitCode: missing.length ? 2 : 0, items, missing, expectedCalls: spec.expectedCalls, expectedCost: spec.expectedCost, text: lines.join("\n") };
161
+ }
162
+ /** An UPPER_SNAKE name that reads as a credential: `*_API_KEY`, `*_KEY`, `*_TOKEN*`, `*_SECRET*`, `*_PROJECT_ID`. */
163
+ const CREDENTIAL = /_(API_)?KEY$|_TOKEN|_SECRET|_PROJECT_ID$/;
164
+ const ENV_NAME = /(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![A-Za-z0-9_])/g;
165
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ /** `name` as a whole word: not part of a longer name, a path, or a hyphenated command. */
167
+ const mentions = (text, name) => new RegExp(`(?<![A-Za-z0-9_@/.-])${escape(name)}(?![A-Za-z0-9_/-])`).test(text);
168
+ /**
169
+ * Whether a demo's SETUP-PROMPT.md matches its spec. Every key env name, program command and package name in the spec,
170
+ * choice members included, appears in the prompt. "Nothing more" covers credential env names only: an UPPER_SNAKE name
171
+ * that reads as a credential and is not in the spec fails. A prompt's own command words (sudo, an install step) are
172
+ * never read as programs.
173
+ */
174
+ export function checkSetupPrompt(spec, text) {
175
+ const members = requirements(spec).flatMap((item) => isChoice(item) ? item.oneOf.flat() : [item]);
176
+ const names = members.map((item) => isKey(item) ? item.env : isProgram(item) ? item.cmd : item.name);
177
+ const envs = new Set(members.filter(isKey).map((item) => item.env));
178
+ const missing = [...new Set(names)].filter((name) => !mentions(text, name));
179
+ const extra = [...new Set(text.match(ENV_NAME) ?? [])].filter((name) => CREDENTIAL.test(name) && !envs.has(name));
180
+ return { ok: missing.length === 0 && extra.length === 0, missing, extra };
181
+ }
@@ -0,0 +1,40 @@
1
+ import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
2
+ /** Where the sign-in lives: `home`, else $AGENTRUN_HOME, else ~/.config/agentrun. */
3
+ export declare function agentrunHome(home?: string, env?: NodeJS.ProcessEnv): string;
4
+ export declare class AuthStoreError extends Error {
5
+ readonly code: "HOME_MODE" | "LOCK_TIMEOUT" | "CORRUPT";
6
+ constructor(code: "HOME_MODE" | "LOCK_TIMEOUT" | "CORRUPT", message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+ export type FileCredentialStoreOptions = {
11
+ /** How long a writer waits for another process's lock before failing. Default 60 s; pi-ai bounds a refresh at 15 s. */
12
+ lockTimeoutMs?: number;
13
+ };
14
+ export declare class FileCredentialStore implements CredentialStore {
15
+ readonly home: string;
16
+ readonly file: string;
17
+ private readonly lockFile;
18
+ private readonly lockTimeoutMs;
19
+ constructor(home: string, options?: FileCredentialStoreOptions);
20
+ read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined>;
21
+ list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]>;
22
+ modify(providerId: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>, options?: AuthOperationOptions): Promise<Credential | undefined>;
23
+ delete(providerId: string, options?: AuthOperationOptions): Promise<void>;
24
+ /** This install's host id, a UUID made once and kept in auth.json: the `getDeviceId` of every sign-in from here. */
25
+ hostId(options?: AuthOperationOptions): Promise<string>;
26
+ /** Run `fn` on the file as it is under the lock. `signal` cancels only the wait: once `fn` starts, a refresh may have
27
+ * rotated the token at the server, so it and its write run to the end. */
28
+ private exclusive;
29
+ private load;
30
+ /** The whole file, written to a new file in the same directory, flushed, then renamed over auth.json. */
31
+ private save;
32
+ /** The directory, made 0700 when missing. An existing one that others can reach is refused, not changed. */
33
+ private ensureHome;
34
+ /** The cross-process lock: an exclusive transaction on the lock database, taken without blocking the event loop (a
35
+ * busy database answers at once and the wait is a timer). Closing the connection ends the transaction and frees it.
36
+ * The lock file is created with O_EXCL and never opened or closed by hand afterwards: closing any descriptor of a file
37
+ * drops every lock this process holds on it. */
38
+ private lock;
39
+ }
40
+ export declare function fileCredentialStore(home: string, options?: FileCredentialStoreOptions): FileCredentialStore;
@@ -0,0 +1,206 @@
1
+ // The sign-in's token store: $AGENTRUN_HOME/auth.json, as pi-ai's `CredentialStore`.
2
+ //
3
+ // The directory is 0700 and the file 0600. Every write is the whole file, written beside it and renamed into place, so a
4
+ // reader without the lock always sees one complete version. Writes are serialized inside a process by one chain per file,
5
+ // and across processes by an operating-system lock: an exclusive SQLite transaction on auth.json.lock, an empty
6
+ // database. The system drops that lock when its holder exits or is killed, so there is no abandoned lock to detect or
7
+ // break, and a lock another writer holds is never moved or removed. `modify` re-reads the file under the lock, so
8
+ // pi-ai's refresh, which runs inside `modify` and re-checks expiry there, refreshes once: a process that waited sees the
9
+ // rotated token and leaves it. A token is never written anywhere else, logged or printed.
10
+ import { randomUUID } from "node:crypto";
11
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { homedir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ /** Where the sign-in lives: `home`, else $AGENTRUN_HOME, else ~/.config/agentrun. */
16
+ export function agentrunHome(home, env = process.env) {
17
+ return resolve(home ?? env.AGENTRUN_HOME ?? join(homedir(), ".config", "agentrun"));
18
+ }
19
+ export class AuthStoreError extends Error {
20
+ code;
21
+ constructor(code, message, options) {
22
+ super(message, options);
23
+ this.code = code;
24
+ this.name = "AuthStoreError";
25
+ }
26
+ }
27
+ /** One chain per auth file in this process: every instance on the same file queues on it, so the lock file only ever
28
+ * arbitrates between processes. */
29
+ const chains = new Map();
30
+ const sleep = (ms, signal) => new Promise((done, fail) => {
31
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); done(); }, ms);
32
+ const onAbort = () => { clearTimeout(timer); fail(signal?.reason); };
33
+ signal?.addEventListener("abort", onAbort, { once: true });
34
+ });
35
+ /** node:sqlite, loaded on the first write: Node 22 marks it experimental and warns once when it loads. */
36
+ let sqlite;
37
+ const SQLITE_BUSY = 5;
38
+ /** Every lock connection this process holds, until its writer releases it. A connection nothing references is closed
39
+ * by the garbage collector, and closing it frees the lock: a writer whose own code no longer references its wait would
40
+ * otherwise lose the lock while still inside. */
41
+ const held = new Set();
42
+ export class FileCredentialStore {
43
+ home;
44
+ file;
45
+ lockFile;
46
+ lockTimeoutMs;
47
+ constructor(home, options = {}) {
48
+ this.home = home;
49
+ this.file = join(home, "auth.json");
50
+ this.lockFile = `${this.file}.lock`;
51
+ this.lockTimeoutMs = options.lockTimeoutMs ?? 60_000;
52
+ }
53
+ async read(providerId, options) {
54
+ options?.signal?.throwIfAborted();
55
+ return this.load().credentials[providerId];
56
+ }
57
+ async list(options) {
58
+ options?.signal?.throwIfAborted();
59
+ return Object.entries(this.load().credentials).map(([providerId, credential]) => ({ providerId, type: credential.type }));
60
+ }
61
+ modify(providerId, fn, options) {
62
+ return this.exclusive(async (file) => {
63
+ const current = file.credentials[providerId];
64
+ const next = await fn(current);
65
+ if (next === undefined)
66
+ return current;
67
+ file.credentials[providerId] = next;
68
+ this.save(file);
69
+ return next;
70
+ }, options?.signal);
71
+ }
72
+ delete(providerId, options) {
73
+ return this.exclusive(async (file) => {
74
+ if (!(providerId in file.credentials))
75
+ return;
76
+ delete file.credentials[providerId];
77
+ this.save(file);
78
+ }, options?.signal);
79
+ }
80
+ /** This install's host id, a UUID made once and kept in auth.json: the `getDeviceId` of every sign-in from here. */
81
+ hostId(options) {
82
+ return this.exclusive(async (file) => {
83
+ if (file.hostId)
84
+ return file.hostId;
85
+ file.hostId = randomUUID();
86
+ this.save(file);
87
+ return file.hostId;
88
+ }, options?.signal);
89
+ }
90
+ /** Run `fn` on the file as it is under the lock. `signal` cancels only the wait: once `fn` starts, a refresh may have
91
+ * rotated the token at the server, so it and its write run to the end. */
92
+ exclusive(fn, signal) {
93
+ const previous = chains.get(this.file) ?? Promise.resolve();
94
+ const run = previous.catch(() => { }).then(async () => {
95
+ signal?.throwIfAborted();
96
+ const lock = await this.lock(signal);
97
+ try {
98
+ return await fn(this.load());
99
+ }
100
+ finally {
101
+ held.delete(lock);
102
+ lock.close();
103
+ }
104
+ });
105
+ const tail = run.catch(() => { });
106
+ chains.set(this.file, tail);
107
+ void tail.then(() => { if (chains.get(this.file) === tail)
108
+ chains.delete(this.file); });
109
+ return run;
110
+ }
111
+ load() {
112
+ let text;
113
+ try {
114
+ text = readFileSync(this.file, "utf8");
115
+ }
116
+ catch (error) {
117
+ if (error.code === "ENOENT")
118
+ return { version: 1, credentials: {} };
119
+ throw error;
120
+ }
121
+ try {
122
+ const parsed = JSON.parse(text);
123
+ if (parsed?.version !== 1 || typeof parsed.credentials !== "object" || parsed.credentials === null)
124
+ throw new Error("not an auth file");
125
+ return parsed;
126
+ }
127
+ catch (error) {
128
+ throw new AuthStoreError("CORRUPT", `${this.file} is not a sign-in file this version reads; move it aside and sign in again`, { cause: error });
129
+ }
130
+ }
131
+ /** The whole file, written to a new file in the same directory, flushed, then renamed over auth.json. */
132
+ save(file) {
133
+ this.ensureHome();
134
+ const temp = join(this.home, `.auth.json.${process.pid}.${randomUUID()}.tmp`);
135
+ const fd = openSync(temp, "wx", 0o600);
136
+ try {
137
+ try {
138
+ writeSync(fd, `${JSON.stringify(file, null, 2)}\n`);
139
+ fsyncSync(fd);
140
+ }
141
+ finally {
142
+ closeSync(fd);
143
+ }
144
+ renameSync(temp, this.file);
145
+ }
146
+ catch (error) {
147
+ try {
148
+ unlinkSync(temp);
149
+ }
150
+ catch { /* already renamed or never written */ }
151
+ throw error;
152
+ }
153
+ }
154
+ /** The directory, made 0700 when missing. An existing one that others can reach is refused, not changed. */
155
+ ensureHome() {
156
+ mkdirSync(this.home, { recursive: true, mode: 0o700 });
157
+ if (process.platform === "win32")
158
+ return;
159
+ const stat = statSync(this.home);
160
+ if ((stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid())) {
161
+ throw new AuthStoreError("HOME_MODE", `${this.home} must be a directory only you can read (mode 0700, owned by you); it is ${(stat.mode & 0o777).toString(8)}. Run chmod 700 on it, or set AGENTRUN_HOME to a new directory.`);
162
+ }
163
+ }
164
+ /** The cross-process lock: an exclusive transaction on the lock database, taken without blocking the event loop (a
165
+ * busy database answers at once and the wait is a timer). Closing the connection ends the transaction and frees it.
166
+ * The lock file is created with O_EXCL and never opened or closed by hand afterwards: closing any descriptor of a file
167
+ * drops every lock this process holds on it. */
168
+ async lock(signal) {
169
+ this.ensureHome();
170
+ try {
171
+ closeSync(openSync(this.lockFile, "wx", 0o600));
172
+ }
173
+ catch (error) {
174
+ if (error.code !== "EEXIST")
175
+ throw error;
176
+ }
177
+ sqlite ??= createRequire(import.meta.url)("node:sqlite");
178
+ const db = new sqlite.DatabaseSync(this.lockFile);
179
+ const deadline = Date.now() + this.lockTimeoutMs;
180
+ try {
181
+ for (let delay = 5;; delay = Math.min(delay * 2, 100)) {
182
+ signal?.throwIfAborted();
183
+ try {
184
+ db.exec("BEGIN EXCLUSIVE");
185
+ held.add(db);
186
+ return db;
187
+ }
188
+ catch (error) {
189
+ if (error.errcode !== SQLITE_BUSY)
190
+ throw error;
191
+ }
192
+ if (Date.now() > deadline) {
193
+ throw new AuthStoreError("LOCK_TIMEOUT", `${this.lockFile} is held by another process; it was not released within ${Math.round(this.lockTimeoutMs / 1000)} s`);
194
+ }
195
+ await sleep(delay + Math.random() * delay, signal);
196
+ }
197
+ }
198
+ catch (error) {
199
+ db.close();
200
+ throw error;
201
+ }
202
+ }
203
+ }
204
+ export function fileCredentialStore(home, options) {
205
+ return new FileCredentialStore(home, options);
206
+ }