@alexkroman1/aai-cli 5.9.0 → 5.10.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.
Files changed (43) hide show
  1. package/bin.mjs +32 -0
  2. package/dist/{_agent-BPEXSdWX.mjs → _agent-DS2PUJcl.mjs} +1 -1
  3. package/dist/{_bundler-Cjaxa2wi.mjs → _bundler-DC17suWN.mjs} +2 -2
  4. package/dist/{_config-BJm2o795.mjs → _config-DMyolIk9.mjs} +92 -10
  5. package/dist/_config.d.ts +12 -0
  6. package/dist/_dev-env.d.ts +38 -0
  7. package/dist/{_dev-server-Cnf4jNzE.mjs → _dev-server-B3ivqyEd.mjs} +72 -54
  8. package/dist/_dev-server.d.ts +2 -2
  9. package/dist/{_init-DVNeyGSl.mjs → _init-CYLvYU-B.mjs} +3 -3
  10. package/dist/{_server-common-CnaP_Urf.mjs → _server-common-DX8Bfrf5.mjs} +1 -1
  11. package/dist/{_slug-api-CQw1YGR5.mjs → _slug-api-fRNNR8tz.mjs} +1 -1
  12. package/dist/{_templates-DDx08LIG.mjs → _templates-BWJOiWOO.mjs} +2 -2
  13. package/dist/{_typecheck-gate-BT4iPz7A.mjs → _typecheck-gate-DB-PY0A3.mjs} +1 -1
  14. package/dist/{_ui-RZmPgrF6.mjs → _ui-DfwfDbT-.mjs} +26 -1
  15. package/dist/_ui.d.ts +19 -0
  16. package/dist/{_utils-Ch0J4s6a.mjs → _utils-8KKw-bzi.mjs} +9 -2
  17. package/dist/_utils.d.ts +9 -2
  18. package/dist/{build-WdGtFsSx.mjs → build-CACFbdQ4.mjs} +4 -4
  19. package/dist/cli.mjs +20 -20
  20. package/dist/{client-bundler-yiWoXrgb.mjs → client-bundler-DONm-khu.mjs} +1 -1
  21. package/dist/client-bundler.mjs +1 -1
  22. package/dist/{delete-Dr1Jn65f.mjs → delete-DEZ7u3u4.mjs} +5 -4
  23. package/dist/delete.d.ts +5 -0
  24. package/dist/{deploy-Bij1jES6.mjs → deploy-Ch0d_jje.mjs} +7 -7
  25. package/dist/{dev-HBCgChsA.mjs → dev-CdeRcYiQ.mjs} +6 -6
  26. package/dist/{init-BYGp3Usr.mjs → init-Nc6fB774.mjs} +5 -5
  27. package/dist/{login-CXazkkcR.mjs → login-BeFUiU6M.mjs} +6 -7
  28. package/dist/scaffold/CLAUDE.md +35 -12
  29. package/dist/scaffold/package.json +3 -3
  30. package/dist/{secret-vhGe-r2a.mjs → secret-4_dYyrpA.mjs} +2 -2
  31. package/dist/{storage-CMPsAmch.mjs → storage-BTfErOOW.mjs} +2 -2
  32. package/dist/{studio-B6zDNPPr.mjs → studio-LNvXtWak.mjs} +6 -6
  33. package/dist/templates/dispatch-center/agent.ts +4 -6
  34. package/dist/templates/retail/agent.ts +4 -6
  35. package/dist/templates/retail/registry.test.ts +5 -3
  36. package/dist/templates/retail/resolve.test.ts +6 -4
  37. package/dist/templates/retail/seed.test.ts +21 -13
  38. package/dist/templates/retail/shared.test.ts +9 -5
  39. package/dist/templates/solo-rpg/agent.test.ts +7 -5
  40. package/dist/templates/solo-rpg/shared.ts +2 -2
  41. package/dist/{test-CkXvfcpq.mjs → test-C-V98oC-.mjs} +3 -4
  42. package/dist/typecheck.mjs +65 -14
  43. package/package.json +5 -4
package/bin.mjs ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // The `aai` bin in BOTH layouts — the source checkout (`pnpm link --global`,
3
+ // where it loads `cli.ts` directly) and the published tarball (where only
4
+ // `dist/` ships, so it loads `dist/cli.mjs`). It used to be the dev bin only,
5
+ // with `publishConfig.bin` pointing straight at `dist/cli.mjs`.
6
+ //
7
+ // One bin for both is what makes the compile cache reachable. The cache only
8
+ // covers modules compiled AFTER the call, and every dependency of the CLI is
9
+ // external (`deps.neverBundle` in tsdown.config.ts), so `dist/cli.mjs` carries
10
+ // hoisted `import` statements for citty, execa and the rest — all evaluated
11
+ // before any statement in that file could run. A banner or a first-line call
12
+ // inside `cli.ts` would therefore cache nothing that costs anything. Loading
13
+ // the entry through a DYNAMIC import from a wrapper is the only ordering that
14
+ // puts the enable genuinely first.
15
+ import { existsSync } from "node:fs";
16
+ import { enableCompileCache } from "node:module";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ // Caches V8 bytecode per (Node version, file content) under the user's cache
20
+ // dir, so `aai` pays parse+compile once per version rather than per
21
+ // invocation. Deliberately unguarded: this returns a `{ status }` result and
22
+ // does not throw, so an unwritable cache dir degrades to today's behaviour.
23
+ enableCompileCache();
24
+
25
+ // Source wins when present: a checkout that has also been built must keep
26
+ // running `cli.ts`, or `pnpm link --global` would silently serve a stale
27
+ // `dist/` instead of the working tree.
28
+ const source = new URL("./cli.ts", import.meta.url);
29
+ const entry = existsSync(fileURLToPath(source))
30
+ ? source
31
+ : new URL("./dist/cli.mjs", import.meta.url);
32
+ await import(entry.href);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as readProjectConfig, i as readGlobalConfig, n as ensureApiKey, o as serverOrigin, t as approveServer } from "./_config-BJm2o795.mjs";
2
+ import { a as serverOrigin, i as readProjectConfig, n as ensureApiKey, r as readGlobalConfig, t as approveServer } from "./_config-DMyolIk9.mjs";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { u as validateAgentExport } from "./_utils-Ch0J4s6a.mjs";
3
- import { t as buildClient } from "./client-bundler-yiWoXrgb.mjs";
2
+ import { u as validateAgentExport } from "./_utils-8KKw-bzi.mjs";
3
+ import { t as buildClient } from "./client-bundler-DONm-khu.mjs";
4
4
  import { buildWorker } from "./worker-bundler.mjs";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as CliError } from "./_output-CC300DzW.mjs";
3
- import { a as errorMessage, c as readJson, d as writeJson } from "./_utils-Ch0J4s6a.mjs";
3
+ import { a as errorMessage, c as readJson, d as writeJson } from "./_utils-8KKw-bzi.mjs";
4
4
  import { mkdtempSync } from "node:fs";
5
5
  import path from "node:path";
6
+ import fs from "node:fs/promises";
7
+ import { setTimeout } from "node:timers/promises";
6
8
  import { tmpdir } from "node:os";
7
9
  import envPaths from "env-paths";
8
10
  import { z } from "zod";
@@ -107,6 +109,87 @@ function serverOrigin(url) {
107
109
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
108
110
  return parsed.origin;
109
111
  }
112
+ /** Bounded so a stuck lock degrades to the old racy write, never a hang. */
113
+ const CONFIG_LOCK_TIMEOUT_MS = 2e3;
114
+ const CONFIG_LOCK_RETRY_MS = 20;
115
+ /** Older than this and the holder is assumed dead (crashed mid-update). */
116
+ const CONFIG_LOCK_STALE_MS = 1e4;
117
+ /**
118
+ * Serialize a read-modify-write of the global config ACROSS PROCESSES.
119
+ *
120
+ * `writeJson` makes each individual write atomic, so no reader ever sees a
121
+ * torn file — but the read→modify→write SPAN is not atomic, and every writer
122
+ * here replaces the whole document. Two concurrent CLI invocations therefore
123
+ * lose each other's updates: measured on this repo, 8 parallel commands each
124
+ * approving a distinct origin recorded only 5 of them, and — the case that
125
+ * matters — a concurrent `approveServer` straddling the final write of
126
+ * `aai login` DISCARDS THE API KEY the login just reported saving, leaving
127
+ * the next command with `not_logged_in`. That window is wide open in practice:
128
+ * `aai login` polls for up to five minutes while the user approves in the
129
+ * browser, so any other command run in that time can be mid-update when the
130
+ * key lands.
131
+ *
132
+ * The lock is a `wx` (exclusive-create) lockfile — atomic on every platform
133
+ * we target, and the only primitive available across processes without a
134
+ * daemon. Three deliberate properties:
135
+ *
136
+ * - **Acquisition is bounded** (`CONFIG_LOCK_TIMEOUT_MS`). On timeout the
137
+ * update proceeds UNLOCKED rather than throwing: these are small
138
+ * convenience files, and failing `aai login` because a lockfile is stuck
139
+ * would be strictly worse than the lost update the lock exists to prevent.
140
+ * - **A stale lock is broken** (`CONFIG_LOCK_STALE_MS`). A process killed
141
+ * mid-update leaves the file behind; without this, one crash would make
142
+ * every later config write take the unlocked path forever.
143
+ * - **Never nest.** Re-entering from inside `fn` would self-deadlock until the
144
+ * timeout. `executeLogin` calls `approveServer` and the key update in
145
+ * sequence, not nested — keep it that way.
146
+ */
147
+ async function withGlobalConfigLock(dir, fn) {
148
+ const lockPath = path.join(dir, "config.lock");
149
+ const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS;
150
+ let held = false;
151
+ for (;;) try {
152
+ await fs.mkdir(dir, {
153
+ recursive: true,
154
+ mode: 448
155
+ });
156
+ await (await fs.open(lockPath, "wx", 384)).close();
157
+ held = true;
158
+ break;
159
+ } catch (err) {
160
+ if (err.code !== "EEXIST") break;
161
+ if (await fs.stat(lockPath).then((s) => Date.now() - s.mtimeMs).catch(() => 0) > CONFIG_LOCK_STALE_MS) {
162
+ await fs.rm(lockPath, { force: true }).catch(() => void 0);
163
+ continue;
164
+ }
165
+ if (Date.now() >= deadline) break;
166
+ await setTimeout(CONFIG_LOCK_RETRY_MS);
167
+ }
168
+ try {
169
+ return await fn();
170
+ } finally {
171
+ if (held) await fs.rm(lockPath, { force: true }).catch(() => void 0);
172
+ }
173
+ }
174
+ /**
175
+ * Apply `update` to the global config under the cross-process lock, re-reading
176
+ * inside it so the merge is against current contents rather than a snapshot
177
+ * taken before the lock was held. Every read-modify-write of the global config
178
+ * must go through this — a direct `readGlobalConfig`/`writeGlobalConfig` pair
179
+ * is the bug this exists to prevent.
180
+ *
181
+ * Returning the argument unchanged skips the write, so the common no-op case
182
+ * (an origin already approved — i.e. most `--server` invocations) costs a read
183
+ * rather than a rewrite plus the lock contention that comes with it.
184
+ */
185
+ async function updateGlobalConfig(update, configDir) {
186
+ const dir = configDir ?? getConfigDir();
187
+ await withGlobalConfigLock(dir, async () => {
188
+ const current = await readGlobalConfig(dir);
189
+ const next = update(current);
190
+ if (next !== current) await writeGlobalConfig(dir, next);
191
+ });
192
+ }
110
193
  /**
111
194
  * Record `url`'s origin as user-approved, so later commands in this project
112
195
  * may send credentials there without re-passing `--server`.
@@ -114,14 +197,13 @@ function serverOrigin(url) {
114
197
  async function approveServer(url, configDir) {
115
198
  const origin = serverOrigin(url);
116
199
  if (!origin) return;
117
- const dir = configDir ?? getConfigDir();
118
- const config = await readGlobalConfig(dir);
119
- const approved = config.approvedServers ?? [];
120
- if (approved.includes(origin)) return;
121
- await writeGlobalConfig(dir, {
122
- ...config,
123
- approvedServers: [...approved, origin]
124
- });
200
+ await updateGlobalConfig((config) => {
201
+ const approved = config.approvedServers ?? [];
202
+ return approved.includes(origin) ? config : {
203
+ ...config,
204
+ approvedServers: [...approved, origin]
205
+ };
206
+ }, configDir);
125
207
  }
126
208
  async function readGlobalConfig(configDir) {
127
209
  const dir = configDir ?? getConfigDir();
@@ -157,4 +239,4 @@ async function ensureApiKey(configDir) {
157
239
  throw new CliError("not_logged_in", "You're not logged in.", "Run `aai login` to link your account. Non-interactive setups can point AAI_CONFIG_DIR at a config dir holding a logged-in key.");
158
240
  }
159
241
  //#endregion
160
- export { readProjectConfig as a, writeGlobalConfig as c, readGlobalConfig as i, writeProjectConfig as l, ensureApiKey as n, serverOrigin as o, getConfigDir as r, updateProjectConfig as s, approveServer as t };
242
+ export { serverOrigin as a, writeProjectConfig as c, readProjectConfig as i, ensureApiKey as n, updateGlobalConfig as o, readGlobalConfig as r, updateProjectConfig as s, approveServer as t };
package/dist/_config.d.ts CHANGED
@@ -54,6 +54,18 @@ export type GlobalConfig = {
54
54
  * were a real origin.
55
55
  */
56
56
  export declare function serverOrigin(url: string): string | null;
57
+ /**
58
+ * Apply `update` to the global config under the cross-process lock, re-reading
59
+ * inside it so the merge is against current contents rather than a snapshot
60
+ * taken before the lock was held. Every read-modify-write of the global config
61
+ * must go through this — a direct `readGlobalConfig`/`writeGlobalConfig` pair
62
+ * is the bug this exists to prevent.
63
+ *
64
+ * Returning the argument unchanged skips the write, so the common no-op case
65
+ * (an origin already approved — i.e. most `--server` invocations) costs a read
66
+ * rather than a rewrite plus the lock contention that comes with it.
67
+ */
68
+ export declare function updateGlobalConfig(update: (current: GlobalConfig) => GlobalConfig, configDir?: string): Promise<void>;
57
69
  /**
58
70
  * Record `url`'s origin as user-approved, so later commands in this project
59
71
  * may send credentials there without re-passing `--server`.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The dev server's CONTROL variables — the three things read straight from the
3
+ * shell rather than from the agent's `.env`.
4
+ *
5
+ * That distinction is the reason these live together. `resolveServerEnv` builds
6
+ * `ctx.env` from `.env`-declared keys only, deliberately, so an agent cannot
7
+ * come to depend on a shell variable that will not exist after deploy. These
8
+ * three are not agent config at all — they configure the *dev server process*
9
+ * (who may connect, where it binds, whether it restarts), so they are read from
10
+ * the environment on purpose and must never leak into `ctx.env`.
11
+ *
12
+ * Split out of `_dev-server.ts` purely to keep that module under the
13
+ * file-length cap; it is the only consumer.
14
+ */
15
+ /**
16
+ * The env handed to `createServer` for host-mode connections: provider
17
+ * credentials plus the `AAI_ALLOW_HOST` gate read straight from the shell
18
+ * (it is a control variable, not something an agent declares in `.env`).
19
+ */
20
+ export declare function hostModeEnv(providerEnv: Record<string, string>): Record<string, string>;
21
+ /**
22
+ * Explicit bind host for the dev server, or `undefined` to take the
23
+ * loopback default. An empty `AAI_DEV_HOST` means "unset", not "every
24
+ * interface" — Node treats `listen(port, "")` as 0.0.0.0, which would quietly
25
+ * undo the loopback default this exists to guard.
26
+ */
27
+ export declare function devBindHost(): string | undefined;
28
+ /**
29
+ * File watching is OPT-IN — `AAI_DEV_WATCH=1` turns it on.
30
+ *
31
+ * A restart rebuilds the bundle and replaces the server, which drops nothing
32
+ * mid-request but does end in-flight voice sessions. That is the right default
33
+ * while editing an agent and the wrong one while a benchmark is driving the
34
+ * host for twenty minutes: a stray formatter save, a `.env` touch, or a git
35
+ * operation restarts the server underneath the run, and the harness reports it
36
+ * as a provider failure several records deep.
37
+ */
38
+ export declare function devWatchEnabled(): boolean;
@@ -1,26 +1,77 @@
1
1
  #!/usr/bin/env node
2
- import { n as log } from "./_ui-RZmPgrF6.mjs";
3
- import { a as errorMessage, r as errorCode } from "./_utils-Ch0J4s6a.mjs";
4
- import { n as fallbackHtmlPlugin } from "./client-bundler-yiWoXrgb.mjs";
2
+ import { n as log, r as notify } from "./_ui-DfwfDbT-.mjs";
3
+ import { a as errorMessage, r as errorCode } from "./_utils-8KKw-bzi.mjs";
4
+ import { n as fallbackHtmlPlugin } from "./client-bundler-DONm-khu.mjs";
5
5
  import { buildWorker } from "./worker-bundler.mjs";
6
- import { n as createWorkerEvaluator } from "./_bundler-Cjaxa2wi.mjs";
7
- import { n as ensureApiKey } from "./_config-BJm2o795.mjs";
8
- import { t as resolveServerEnv } from "./_server-common-CnaP_Urf.mjs";
9
- import { createRequire } from "node:module";
6
+ import { n as createWorkerEvaluator } from "./_bundler-DC17suWN.mjs";
7
+ import { n as ensureApiKey } from "./_config-DMyolIk9.mjs";
8
+ import { t as resolveServerEnv } from "./_server-common-DX8Bfrf5.mjs";
10
9
  import { existsSync } from "node:fs";
11
10
  import path from "node:path";
12
11
  import { setTimeout } from "node:timers/promises";
13
12
  import { createRuntime, createServer, requiredProviderEnvVars, withHostCredentialFallback } from "@alexkroman1/aai/runtime";
13
+ import { defaultClientDir } from "@alexkroman1/aai-ui/client-dir";
14
14
  import { watch } from "chokidar";
15
15
  import getPort, { portNumbers } from "get-port";
16
16
  import pDebounce from "p-debounce";
17
+ //#region _dev-env.ts
18
+ /**
19
+ * The dev server's CONTROL variables — the three things read straight from the
20
+ * shell rather than from the agent's `.env`.
21
+ *
22
+ * That distinction is the reason these live together. `resolveServerEnv` builds
23
+ * `ctx.env` from `.env`-declared keys only, deliberately, so an agent cannot
24
+ * come to depend on a shell variable that will not exist after deploy. These
25
+ * three are not agent config at all — they configure the *dev server process*
26
+ * (who may connect, where it binds, whether it restarts), so they are read from
27
+ * the environment on purpose and must never leak into `ctx.env`.
28
+ *
29
+ * Split out of `_dev-server.ts` purely to keep that module under the
30
+ * file-length cap; it is the only consumer.
31
+ */
32
+ /**
33
+ * The env handed to `createServer` for host-mode connections: provider
34
+ * credentials plus the `AAI_ALLOW_HOST` gate read straight from the shell
35
+ * (it is a control variable, not something an agent declares in `.env`).
36
+ */
37
+ function hostModeEnv(providerEnv) {
38
+ const gate = process.env.AAI_ALLOW_HOST;
39
+ return gate === void 0 ? providerEnv : {
40
+ ...providerEnv,
41
+ AAI_ALLOW_HOST: gate
42
+ };
43
+ }
44
+ /**
45
+ * Explicit bind host for the dev server, or `undefined` to take the
46
+ * loopback default. An empty `AAI_DEV_HOST` means "unset", not "every
47
+ * interface" — Node treats `listen(port, "")` as 0.0.0.0, which would quietly
48
+ * undo the loopback default this exists to guard.
49
+ */
50
+ function devBindHost() {
51
+ const host = process.env.AAI_DEV_HOST?.trim();
52
+ return host ? host : void 0;
53
+ }
54
+ /**
55
+ * File watching is OPT-IN — `AAI_DEV_WATCH=1` turns it on.
56
+ *
57
+ * A restart rebuilds the bundle and replaces the server, which drops nothing
58
+ * mid-request but does end in-flight voice sessions. That is the right default
59
+ * while editing an agent and the wrong one while a benchmark is driving the
60
+ * host for twenty minutes: a stray formatter save, a `.env` touch, or a git
61
+ * operation restarts the server underneath the run, and the harness reports it
62
+ * as a provider failure several records deep.
63
+ */
64
+ function devWatchEnabled() {
65
+ return /^(1|true|yes|on)$/i.test(process.env.AAI_DEV_WATCH?.trim() ?? "");
66
+ }
67
+ //#endregion
17
68
  //#region _dev-server.ts
18
69
  /**
19
70
  * Dev server for directory-based agents.
20
71
  *
21
72
  * Imports agent.ts directly for the full agent definition,
22
- * builds a runtime, and starts an HTTP+WebSocket server. Watches for
23
- * file changes and restarts automatically. Optionally runs Vite for
73
+ * builds a runtime, and starts an HTTP+WebSocket server. File watching is
74
+ * opt-in via `AAI_DEV_WATCH=1` (see devWatchEnabled). Optionally runs Vite for
24
75
  * client SPA HMR.
25
76
  */
26
77
  /**
@@ -58,28 +109,6 @@ async function resolveAgentEnv(root, agentDef) {
58
109
  return env;
59
110
  }
60
111
  /**
61
- * The env handed to `createServer` for host-mode connections: provider
62
- * credentials plus the `AAI_ALLOW_HOST` gate read straight from the shell
63
- * (it is a control variable, not something an agent declares in `.env`).
64
- */
65
- function hostModeEnv(providerEnv) {
66
- const gate = process.env.AAI_ALLOW_HOST;
67
- return gate === void 0 ? providerEnv : {
68
- ...providerEnv,
69
- AAI_ALLOW_HOST: gate
70
- };
71
- }
72
- /**
73
- * Explicit bind host for the dev server, or `undefined` to take the
74
- * loopback default. An empty `AAI_DEV_HOST` means "unset", not "every
75
- * interface" — Node treats `listen(port, "")` as 0.0.0.0, which would quietly
76
- * undo the loopback default this exists to guard.
77
- */
78
- function devBindHost() {
79
- const host = process.env.AAI_DEV_HOST?.trim();
80
- return host ? host : void 0;
81
- }
82
- /**
83
112
  * Load the agent definition by bundling agent.ts (and all its local imports)
84
113
  * into a single ESM file, then importing that. A raw `import(agent.ts?t=...)`
85
114
  * only cache-busts agent.ts itself — transitive imports (./tools.ts, etc.)
@@ -120,7 +149,7 @@ function isIgnoredPath(dir, filePath) {
120
149
  */
121
150
  function watchDirectory(dir, onChange) {
122
151
  const debouncedChange = pDebounce(() => {
123
- log.info("File change detected, restarting...");
152
+ notify("info", "File change detected, restarting...");
124
153
  onChange();
125
154
  }, 300);
126
155
  const watcher = watch(dir, {
@@ -130,26 +159,15 @@ function watchDirectory(dir, onChange) {
130
159
  });
131
160
  watcher.on("error", (err) => {
132
161
  const hint = errorCode(err) === "ENOSPC" ? " The inotify watch limit was reached — raise the fs.inotify max_user_watches sysctl." : "";
133
- log.error(`File watcher error: ${errorMessage(err)}.${hint} Auto-restart on file changes may have stopped; restart \`aai dev\` after fixing.`);
162
+ notify("error", `File watcher error: ${errorMessage(err)}.${hint} Auto-restart on file changes may have stopped; restart \`aai dev\` after fixing.`);
134
163
  });
135
164
  watcher.on("all", () => {
136
165
  debouncedChange().catch((err) => {
137
- log.error(`Watch handler failed: ${errorMessage(err)}`);
166
+ notify("error", `Watch handler failed: ${errorMessage(err)}`);
138
167
  });
139
168
  });
140
169
  return watcher;
141
170
  }
142
- /** Locate the pre-built default aai-ui client (served when no custom client.tsx). */
143
- function resolveDefaultClientDir() {
144
- const require = createRequire(import.meta.url);
145
- let pkgPath;
146
- try {
147
- pkgPath = require.resolve("@alexkroman1/aai-ui/package.json");
148
- } catch (err) {
149
- throw new Error(`Could not locate the default client UI (${errorMessage(err)}) — is @alexkroman1/aai-ui installed? Try reinstalling dependencies (pnpm install).`, { cause: err });
150
- }
151
- return path.join(path.dirname(pkgPath), "dist", "default-client");
152
- }
153
171
  /**
154
172
  * Vite dev-server config for the client SPA. Extracted so the proxy wiring
155
173
  * is unit-testable: `/websocket` MUST proxy with `ws: true` or `aai dev`
@@ -188,7 +206,7 @@ async function startDevServer(opts) {
188
206
  const hasClient = existsSync(path.join(cwd, "client.tsx"));
189
207
  const backendPort = hasClient ? await getPort({ port: portNumbers(port + 1, port + 100) }) : port;
190
208
  const vitePort = port;
191
- const clientDirOpt = hasClient ? {} : { clientDir: resolveDefaultClientDir() };
209
+ const clientDirOpt = hasClient ? {} : { clientDir: defaultClientDir() };
192
210
  const evaluateWorker = createWorkerEvaluator();
193
211
  /** Full build sequence, shared by initial startup and every restart. */
194
212
  async function buildServer() {
@@ -219,12 +237,12 @@ async function startDevServer(opts) {
219
237
  }
220
238
  restarting = true;
221
239
  restart().catch((err) => {
222
- log.error(`Restart failed: ${errorMessage(err)}`);
240
+ notify("error", `Restart failed: ${errorMessage(err)}`);
223
241
  }).finally(() => {
224
242
  restarting = false;
225
243
  });
226
244
  }
227
- const watcher = watchDirectory(cwd, kickRestart);
245
+ const watcher = devWatchEnabled() ? watchDirectory(cwd, kickRestart) : void 0;
228
246
  let viteServer;
229
247
  try {
230
248
  currentServer = await buildServer();
@@ -234,11 +252,11 @@ async function startDevServer(opts) {
234
252
  viteServer = await createViteServer(viteDevConfig(cwd, vitePort, backendPort));
235
253
  await viteServer.listen();
236
254
  viteServer.httpServer?.on("error", (err) => {
237
- log.error(`Vite dev server error: ${errorMessage(err)}`);
255
+ notify("error", `Vite dev server error: ${errorMessage(err)}`);
238
256
  });
239
257
  }
240
258
  } catch (err) {
241
- await watcher.close().catch(() => void 0);
259
+ await watcher?.close().catch(() => void 0);
242
260
  await viteServer?.close().catch(() => void 0);
243
261
  throw err;
244
262
  }
@@ -255,7 +273,7 @@ async function startDevServer(opts) {
255
273
  try {
256
274
  newServer = await buildServer();
257
275
  } catch (err) {
258
- log.error(`Restart failed: ${errorMessage(err)} (previous server still running)`);
276
+ notify("error", `Restart failed: ${errorMessage(err)} (previous server still running)`);
259
277
  return;
260
278
  }
261
279
  if (closed) {
@@ -272,9 +290,9 @@ async function startDevServer(opts) {
272
290
  await newServer.close().catch(() => void 0);
273
291
  return;
274
292
  }
275
- log.success("Restarted");
293
+ notify("success", "Restarted");
276
294
  } catch (err) {
277
- log.error(`Restart failed: ${errorMessage(err)} — dev server is down; save a file to retry.`);
295
+ notify("error", `Restart failed: ${errorMessage(err)} — dev server is down; save a file to retry.`);
278
296
  await newServer.close().catch(() => void 0);
279
297
  }
280
298
  }
@@ -299,7 +317,7 @@ async function startDevServer(opts) {
299
317
  return () => {
300
318
  cleanupPromise ??= (async () => {
301
319
  closed = true;
302
- await watcher.close().catch(() => void 0);
320
+ await watcher?.close().catch(() => void 0);
303
321
  await viteServer?.close().catch(() => void 0);
304
322
  await currentServer.close().catch(() => void 0);
305
323
  })();
@@ -2,8 +2,8 @@
2
2
  * Dev server for directory-based agents.
3
3
  *
4
4
  * Imports agent.ts directly for the full agent definition,
5
- * builds a runtime, and starts an HTTP+WebSocket server. Watches for
6
- * file changes and restarts automatically. Optionally runs Vite for
5
+ * builds a runtime, and starts an HTTP+WebSocket server. File watching is
6
+ * opt-in via `AAI_DEV_WATCH=1` (see devWatchEnabled). Optionally runs Vite for
7
7
  * client SPA HMR.
8
8
  */
9
9
  import type { AgentDef } from "@alexkroman1/aai";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { c as readJson, d as writeJson, s as isEexist } from "./_utils-Ch0J4s6a.mjs";
3
- import { r as isDevMode, t as getMonorepoRoot } from "./_agent-BPEXSdWX.mjs";
4
- import { REPO_URL, downloadAndMergeTemplate } from "./_templates-DDx08LIG.mjs";
2
+ import { c as readJson, d as writeJson, s as isEexist } from "./_utils-8KKw-bzi.mjs";
3
+ import { r as isDevMode, t as getMonorepoRoot } from "./_agent-DS2PUJcl.mjs";
4
+ import { REPO_URL, downloadAndMergeTemplate } from "./_templates-BWJOiWOO.mjs";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs/promises";
7
7
  //#region _init.ts
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as errorCode } from "./_utils-Ch0J4s6a.mjs";
2
+ import { r as errorCode } from "./_utils-8KKw-bzi.mjs";
3
3
  import path from "node:path";
4
4
  import { parseEnv } from "node:util";
5
5
  import fs from "node:fs/promises";
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as getServerInfo } from "./_agent-BPEXSdWX.mjs";
2
+ import { n as getServerInfo } from "./_agent-DS2PUJcl.mjs";
3
3
  import { n as apiRequest, t as HINT_NOT_DEPLOYED } from "./_api-client-B-upMGkc.mjs";
4
4
  //#region _slug-api.ts
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { a as errorMessage } from "./_utils-Ch0J4s6a.mjs";
3
- import { t as getMonorepoRoot } from "./_agent-BPEXSdWX.mjs";
2
+ import { a as errorMessage } from "./_utils-8KKw-bzi.mjs";
3
+ import { t as getMonorepoRoot } from "./_agent-DS2PUJcl.mjs";
4
4
  import { existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs/promises";
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as CliError } from "./_output-CC300DzW.mjs";
3
- import { n as log } from "./_ui-RZmPgrF6.mjs";
3
+ import { n as log } from "./_ui-DfwfDbT-.mjs";
4
4
  import { typecheckProject } from "./typecheck.mjs";
5
5
  //#region _typecheck-gate.ts
6
6
  /**
@@ -13,6 +13,31 @@ function silenceOutput() {
13
13
  silenced = true;
14
14
  }
15
15
  /**
16
+ * Emit a message that must survive JSON mode — human mode gets the normal
17
+ * clack styling, JSON mode gets a plain line on STDERR.
18
+ *
19
+ * `silenceOutput` no-ops every `log` method so that JSON mode's contract
20
+ * ("exactly one result line on stdout") holds. That is right for
21
+ * request/response commands, and wrong for LONG-RUNNING ones: `aai dev`
22
+ * writes its single JSON line at startup and then keeps running, so every
23
+ * later message — a failed rebuild, an unhandled rejection, "the dev server
24
+ * is down; save a file to retry" — was silenced for the rest of the process.
25
+ * And JSON mode is AUTO-DETECTED on a pipe, so that is the normal case:
26
+ * `aai dev > dev.log`, a process supervisor, or a container all hid every
27
+ * build failure, leaving the old agent served with nothing to say why edits
28
+ * had stopped taking effect.
29
+ *
30
+ * stderr keeps the stdout contract intact — a script still parses one JSON
31
+ * line — while a human tailing the log sees what happened.
32
+ */
33
+ function notify(level, message) {
34
+ if (!silenced) {
35
+ log[level](message);
36
+ return;
37
+ }
38
+ process.stderr.write(`${message}\n`);
39
+ }
40
+ /**
16
41
  * Unwrap a clack prompt result, exiting cleanly if the user cancelled.
17
42
  * `message` lets the caller name what was cancelled (e.g. "Setup cancelled").
18
43
  */
@@ -40,4 +65,4 @@ function parsePort(raw) {
40
65
  return port;
41
66
  }
42
67
  //#endregion
43
- export { unwrapCancel as a, silenceOutput as i, log as n, parsePort as r, fmtUrl as t };
68
+ export { silenceOutput as a, parsePort as i, log as n, unwrapCancel as o, notify as r, fmtUrl as t };
package/dist/_ui.d.ts CHANGED
@@ -4,6 +4,25 @@ type Log = typeof p.log;
4
4
  export declare const log: Log;
5
5
  /** Replace all log methods with no-ops. Call once in JSON mode. */
6
6
  export declare function silenceOutput(): void;
7
+ /**
8
+ * Emit a message that must survive JSON mode — human mode gets the normal
9
+ * clack styling, JSON mode gets a plain line on STDERR.
10
+ *
11
+ * `silenceOutput` no-ops every `log` method so that JSON mode's contract
12
+ * ("exactly one result line on stdout") holds. That is right for
13
+ * request/response commands, and wrong for LONG-RUNNING ones: `aai dev`
14
+ * writes its single JSON line at startup and then keeps running, so every
15
+ * later message — a failed rebuild, an unhandled rejection, "the dev server
16
+ * is down; save a file to retry" — was silenced for the rest of the process.
17
+ * And JSON mode is AUTO-DETECTED on a pipe, so that is the normal case:
18
+ * `aai dev > dev.log`, a process supervisor, or a container all hid every
19
+ * build failure, leaving the old agent served with nothing to say why edits
20
+ * had stopped taking effect.
21
+ *
22
+ * stderr keeps the stdout contract intact — a script still parses one JSON
23
+ * line — while a human tailing the log sees what happened.
24
+ */
25
+ export declare function notify(level: "error" | "warn" | "info" | "success", message: string): void;
7
26
  /**
8
27
  * Unwrap a clack prompt result, exiting cleanly if the user cancelled.
9
28
  * `message` lets the caller name what was cancelled (e.g. "Setup cancelled").
@@ -72,8 +72,15 @@ async function readJson(filePath) {
72
72
  * torn config.json fails `JSON.parse`, reads back as `{}`, and the next
73
73
  * read-modify-write silently wipes fields like `approvedServers`. Rename on
74
74
  * the same filesystem is atomic, so readers only ever see a complete file.
75
- * Two concurrent CLI processes can still lose each other's *updates*
76
- * (last rename wins) acceptable for these small user-config files.
75
+ *
76
+ * Atomic per WRITE is not atomic per read-modify-write: two concurrent CLI
77
+ * processes still lose each other's *updates* (last rename wins). That was
78
+ * long documented here as acceptable "for these small user-config files",
79
+ * and it is — for `.aai/project.json`, which is per-directory. It is NOT
80
+ * acceptable for the GLOBAL config, where it dropped the API key a
81
+ * successful `aai login` had just reported saving. Every global-config
82
+ * update therefore goes through `updateGlobalConfig` in `_config.ts`, which
83
+ * holds a cross-process lock around the read and the write.
77
84
  *
78
85
  * `mode` restricts the file's permissions (the rename carries the temp
79
86
  * file's mode to the destination, so an existing world-readable file is
package/dist/_utils.d.ts CHANGED
@@ -35,8 +35,15 @@ export declare function readJson(filePath: string): Promise<unknown>;
35
35
  * torn config.json fails `JSON.parse`, reads back as `{}`, and the next
36
36
  * read-modify-write silently wipes fields like `approvedServers`. Rename on
37
37
  * the same filesystem is atomic, so readers only ever see a complete file.
38
- * Two concurrent CLI processes can still lose each other's *updates*
39
- * (last rename wins) acceptable for these small user-config files.
38
+ *
39
+ * Atomic per WRITE is not atomic per read-modify-write: two concurrent CLI
40
+ * processes still lose each other's *updates* (last rename wins). That was
41
+ * long documented here as acceptable "for these small user-config files",
42
+ * and it is — for `.aai/project.json`, which is per-directory. It is NOT
43
+ * acceptable for the GLOBAL config, where it dropped the API key a
44
+ * successful `aai login` had just reported saving. Every global-config
45
+ * update therefore goes through `updateGlobalConfig` in `_config.ts`, which
46
+ * holds a cross-process lock around the read and the write.
40
47
  *
41
48
  * `mode` restricts the file's permissions (the rename carries the temp
42
49
  * file's mode to the destination, so an existing world-readable file is
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { a as ok, t as CliError } from "./_output-CC300DzW.mjs";
3
- import { n as log } from "./_ui-RZmPgrF6.mjs";
4
- import { r as evalWorkerBundle, t as buildAgentBundle } from "./_bundler-Cjaxa2wi.mjs";
5
- import { assertTypechecks } from "./_typecheck-gate-BT4iPz7A.mjs";
6
- import { classifyVitestError, runVitest } from "./test-CkXvfcpq.mjs";
3
+ import { n as log } from "./_ui-DfwfDbT-.mjs";
4
+ import { r as evalWorkerBundle, t as buildAgentBundle } from "./_bundler-DC17suWN.mjs";
5
+ import { assertTypechecks } from "./_typecheck-gate-DB-PY0A3.mjs";
6
+ import { classifyVitestError, runVitest } from "./test-C-V98oC-.mjs";
7
7
  //#region build.ts
8
8
  /**
9
9
  * `aai build` — bundle the agent without deploying, behind the same gates