@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 +21 -3
- package/bin/maestro.mjs +37 -0
- package/package.json +1 -1
- package/scripts/daemon/cadence-consumer.mjs +14 -0
- package/scripts/daemon/maestro-daemon.mjs +43 -17
- package/scripts/local-triggers/generate-plists.sh +7 -2
- package/scripts/local-triggers/generate-plists.test.mjs +6 -1
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
@@ -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.
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
-
//
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
|
|
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");
|