@adaptic/maestro 1.9.1 → 1.9.3

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/.env.example CHANGED
@@ -16,11 +16,29 @@
16
16
  # The agent's reasoning engines. At minimum you need Anthropic (Claude).
17
17
  #
18
18
 
19
- # REQUIRED — Primary reasoning engine (Claude Code uses this)
20
- # Get your key: https://console.anthropic.com/settings/keys
21
- # Subscription: Anthropic API plan (pay-per-token) or Max subscription
19
+ # REQUIRED — Primary reasoning engine. Two ways to authenticate:
20
+ #
21
+ # Option A API key (pay-per-token)
22
+ # Set ANTHROPIC_API_KEY below to a valid sk-ant-api03-... key.
23
+ # Get one: https://console.anthropic.com/settings/keys
24
+ #
25
+ # Option B — Claude Code subscription (Pro/Max, OAuth via Keychain)
26
+ # LEAVE ANTHROPIC_API_KEY EMPTY *and* set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1.
27
+ # This tells the cadence consumer to strip ANTHROPIC_API_KEY from every
28
+ # sub-session spawn so claude --print falls back to the keychain OAuth
29
+ # token. Most agents on a Mac mini with a Claude Code subscription
30
+ # should use this option — routine cadence ticks cost zero API credits.
31
+ #
32
+ # Doctor validates the key against api.anthropic.com on every run; an
33
+ # invalid key here will cascade 401s through every sub-session spawn.
22
34
  ANTHROPIC_API_KEY=
23
35
 
36
+ # OPTIONAL — When set to 1, the cadence consumer strips ANTHROPIC_API_KEY
37
+ # from every claude --print sub-session env so claude falls back to
38
+ # Claude Code subscription auth (Keychain OAuth). Use this when the
39
+ # agent's Mac has a Claude Code Pro/Max subscription.
40
+ MAESTRO_PREFER_SUBSCRIPTION_AUTH=
41
+
24
42
  # OPTIONAL — Supplemental model access (GPT-4, embeddings)
25
43
  # Get your key: https://platform.openai.com/api-keys
26
44
  # Subscription: OpenAI API plan (pay-per-token)
package/bin/maestro.mjs CHANGED
@@ -1462,6 +1462,43 @@ function doctor() {
1462
1462
  check("ANTHROPIC_API_KEY", true);
1463
1463
  check("SLACK_USER_TOKEN", false);
1464
1464
  check("GMAIL_APP_PASSWORD", false);
1465
+
1466
+ // Auth validity: if ANTHROPIC_API_KEY is set, ping the API to
1467
+ // verify it works. An invalid key in .env will silently be sent
1468
+ // to every `claude --print` sub-session and cause cascading 401s
1469
+ // (exactly the ravi-ai inbox-processor runaway). Better to catch
1470
+ // it here. Skips the check if the user opted out via
1471
+ // MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 (subscription wins).
1472
+ const keyMatch = env.match(/^ANTHROPIC_API_KEY=(.+)$/m);
1473
+ const preferSubsMatch = env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=(.+)$/m);
1474
+ const preferSubs = preferSubsMatch && /^1|true|yes$/i.test(preferSubsMatch[1].trim());
1475
+ if (keyMatch && !preferSubs) {
1476
+ const key = keyMatch[1].trim().replace(/^"|"$/g, "");
1477
+ try {
1478
+ const result = spawnSync("curl", [
1479
+ "-s", "-o", "/dev/null", "-w", "%{http_code}",
1480
+ "-X", "POST",
1481
+ "-H", `x-api-key: ${key}`,
1482
+ "-H", "anthropic-version: 2023-06-01",
1483
+ "-H", "content-type: application/json",
1484
+ "--max-time", "8",
1485
+ "https://api.anthropic.com/v1/messages",
1486
+ "-d", JSON.stringify({ model: "claude-haiku-4-5", max_tokens: 5, messages: [{ role: "user", content: "ping" }] }),
1487
+ ], { encoding: "utf-8" });
1488
+ const code = (result.stdout || "").trim();
1489
+ if (code === "200") ok("ANTHROPIC_API_KEY validated against api.anthropic.com");
1490
+ else if (code === "401") {
1491
+ warn(`ANTHROPIC_API_KEY is INVALID (HTTP 401 from api.anthropic.com).`);
1492
+ warn(` This will cause every sub-session spawn to fail. Either:`);
1493
+ warn(` 1. Replace the key in .env with a valid one, OR`);
1494
+ warn(` 2. Set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 in .env to use Claude Code subscription auth.`);
1495
+ issues++;
1496
+ } else if (code) warn(`ANTHROPIC_API_KEY check returned HTTP ${code} (expected 200)`);
1497
+ else warn(`ANTHROPIC_API_KEY check skipped (no network / curl missing)`);
1498
+ } catch { warn("ANTHROPIC_API_KEY check failed (curl error)"); }
1499
+ } else if (preferSubs) {
1500
+ ok("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 — using Claude Code subscription (Keychain OAuth)");
1501
+ }
1465
1502
  } else {
1466
1503
  fail(".env file not found — copy from .env.example");
1467
1504
  issues++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -183,6 +183,20 @@ function realSpawnSession({ agentRoot, cadence, promptPath, timeoutMs, log }) {
183
183
  AGENT_DIR: agentRoot,
184
184
  PATH: augmentedPath,
185
185
  };
186
+ // Auth handling. Claude Code authenticates via macOS Keychain
187
+ // (OAuth from the user's Pro/Max subscription) when no API key is
188
+ // set, OR via the ANTHROPIC_API_KEY env var when one is present.
189
+ // If the env key is present BUT looks like a placeholder / empty
190
+ // string, we strip it so claude can fall back to Keychain OAuth.
191
+ // Set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 in .env to always strip
192
+ // the API key (force subscription auth) — useful when the agent
193
+ // owns a Claude Code Pro/Max subscription and shouldn't burn API
194
+ // credits for routine ticks.
195
+ const preferSubscription = process.env.MAESTRO_PREFER_SUBSCRIPTION_AUTH === "1";
196
+ const apiKey = env.ANTHROPIC_API_KEY || "";
197
+ if (preferSubscription || !apiKey.trim() || /^(your-api-key|placeholder|xxx+|sk-ant-xxx)/i.test(apiKey)) {
198
+ delete env.ANTHROPIC_API_KEY;
199
+ }
186
200
  const started = Date.now();
187
201
 
188
202
  // Per-run log file. Pattern is short enough to be tail-friendly.
@@ -3,18 +3,22 @@
3
3
  // Maestro Daemon — Reactive event-driven message processor
4
4
  // =============================================================================
5
5
  //
6
- // Generic entry point that loads agent identity from config/agent.ts and
6
+ // Generic entry point that loads agent identity from config/agent.json and
7
7
  // delegates to the core daemon logic. This file is agent-name-agnostic.
8
8
  //
9
+ // Lifecycle:
10
+ // 1. Honour .emergency-stop BEFORE doing anything (don't acquire singleton
11
+ // lock, don't start consumer, don't import sophie-daemon). Stops the
12
+ // launchd restart treadmill cold.
13
+ // 2. Acquire the daemon singleton lock so only one instance runs.
14
+ // 3. Start the cadence consumer (state/cadence-bus/ drain loop).
15
+ // 4. Import the core daemon (sophie-daemon.mjs or legacy <firstName>-daemon.mjs).
16
+ //
9
17
  // Run: node scripts/daemon/maestro-daemon.mjs
10
- // Install: launchd plist with KeepAlive: true
18
+ // Install: launchd plist with KeepAlive.SuccessfulExit: false (clean exits
19
+ // during emergency-stop should NOT trigger restart; non-zero exits should).
11
20
  // =============================================================================
12
21
 
13
- // The core daemon implementation is in sophie-daemon.mjs by default, but
14
- // older agent repos may have a renamed copy at <firstname>-daemon.mjs (a
15
- // pre-1.7 convention). This entry-point auto-detects the correct file so
16
- // the launchd plist stays stable regardless of agent identity history.
17
-
18
22
  import { resolve, dirname, join } from "node:path";
19
23
  import { fileURLToPath } from "node:url";
20
24
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -22,25 +26,44 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
22
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
27
  const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
24
28
 
25
- // Set AGENT_DIR as env var so all modules can use it
26
29
  process.env.AGENT_DIR = AGENT_DIR;
27
30
  process.env.AGENT_ROOT = AGENT_DIR;
28
31
 
29
- // Singleton guard — prevent duplicate daemon instances
32
+ // ---------------------------------------------------------------------------
33
+ // 1. Emergency-stop gate
34
+ // ---------------------------------------------------------------------------
35
+ // If the operator dropped .emergency-stop, we exit cleanly (code 0). With
36
+ // KeepAlive.SuccessfulExit:false this means launchd will NOT restart us
37
+ // until the flag is lifted + the plist is reloaded by resume-operations.sh.
38
+ // Older plists with plain KeepAlive:true will still restart, so we hold
39
+ // the process for 30s before exiting to give launchd's ThrottleInterval
40
+ // something to throttle.
41
+ if (existsSync(join(AGENT_DIR, ".emergency-stop"))) {
42
+ console.error("[DAEMON] .emergency-stop flag present — refusing to start.");
43
+ console.error("[DAEMON] Lift with: scripts/resume-operations.sh");
44
+ await new Promise((r) => setTimeout(r, 30_000));
45
+ process.exit(0);
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 2. Singleton guard
50
+ // ---------------------------------------------------------------------------
30
51
  try {
31
52
  const { acquireLock } = await import(resolve(process.env.HOME, "maestro/lib/singleton.js"));
32
53
  if (!acquireLock("daemon")) {
33
54
  console.log("[DAEMON] Already running — exiting");
34
55
  process.exit(0);
35
56
  }
36
- } catch { /* maestro singleton not available */ }
57
+ } catch { /* maestro singleton not available — proceed without lock */ }
37
58
 
38
- // Start the cadence consumer alongside the reactive event loop. This is the
39
- // single persistent owner of cadence housekeeping launchd plists enqueue
40
- // cadence ticks onto state/cadence-bus/ and the consumer drains them here,
41
- // either handling them inline or escalating to a sub-session when warranted.
42
- // Failure to start the consumer must NOT take the reactive daemon down, so
43
- // we isolate startup errors.
59
+ // ---------------------------------------------------------------------------
60
+ // 3. Cadence consumer (cadence bus drain loop)
61
+ // ---------------------------------------------------------------------------
62
+ // Start alongside the reactive event loop. This is the single persistent
63
+ // owner of cadence housekeeping launchd plists enqueue cadence ticks
64
+ // onto state/cadence-bus/ and the consumer drains them here, either
65
+ // handling them inline or escalating to a sub-session when warranted.
66
+ // Failure to start the consumer must NOT take the reactive daemon down.
44
67
  try {
45
68
  const { startConsumer } = await import("./cadence-consumer.mjs");
46
69
  const consumer = startConsumer({ agentRoot: AGENT_DIR });
@@ -55,7 +78,10 @@ try {
55
78
  // Reactive daemon continues. Doctor / healthcheck will surface this.
56
79
  }
57
80
 
58
- // Locate the core daemon module. Try, in order:
81
+ // ---------------------------------------------------------------------------
82
+ // 4. Core daemon import
83
+ // ---------------------------------------------------------------------------
84
+ // Resolve the core daemon module. Try, in order:
59
85
  // 1. ./sophie-daemon.mjs — canonical filename (post-Phase-2.5 SOT)
60
86
  // 2. ./<firstName>-daemon.mjs — legacy rename from init-maestro Phase 1
61
87
  // 3. The first scripts/daemon/*-daemon.mjs that isn't this file
@@ -129,13 +129,18 @@ PLIST_MID
129
129
  cat >> "$FILE" << 'PLIST_KEEPALIVE'
130
130
 
131
131
  <key>KeepAlive</key>
132
- <true/>
132
+ <dict>
133
+ <key>SuccessfulExit</key>
134
+ <false/>
135
+ <key>Crashed</key>
136
+ <true/>
137
+ </dict>
133
138
 
134
139
  <key>RunAtLoad</key>
135
140
  <true/>
136
141
 
137
142
  <key>ThrottleInterval</key>
138
- <integer>5</integer>
143
+ <integer>30</integer>
139
144
  PLIST_KEEPALIVE
140
145
  fi
141
146
 
@@ -167,7 +167,12 @@ test("daemon plist remains a KeepAlive job (not a cadence enqueue)", async () =>
167
167
  runGenerator(root);
168
168
  const path = join(root, "scripts/local-triggers/plists/ai.adaptic.erin-daemon.plist");
169
169
  const body = readFileSync(path, "utf-8");
170
- assert.match(body, /<key>KeepAlive<\/key>\s*<true\/>/);
170
+ // KeepAlive switched 1.9.2 from plain `<true/>` to a dict with
171
+ // SuccessfulExit:false / Crashed:true so emergency-stop doesn't trigger
172
+ // a restart treadmill but real crashes still do.
173
+ assert.match(body, /<key>KeepAlive<\/key>/);
174
+ assert.match(body, /<key>SuccessfulExit<\/key>\s*<false\/>/);
175
+ assert.match(body, /<key>Crashed<\/key>\s*<true\/>/);
171
176
  assert.match(body, /launchd-wrapper\.sh/);
172
177
  assert.ok(!body.includes("enqueue-cadence-tick.mjs"),
173
178
  "daemon plist should not enqueue ticks — it IS the consumer");