@adaptic/maestro 1.10.0 → 1.10.2

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
@@ -76,6 +76,15 @@ SLACK_BOT_TOKEN=xoxb-...
76
76
  # Found at: https://api.slack.com/apps → Basic Information → Signing Secret
77
77
  SLACK_SIGNING_SECRET=
78
78
 
79
+ # OPTIONAL — App-level token (starts with xapp-) for Slack Socket Mode.
80
+ # Required when the slack-socket-mode feature is enabled. Generate it at:
81
+ # https://api.slack.com/apps → your app → "Basic Information" → "App-Level Tokens"
82
+ # Add the scope: connections:write
83
+ # Then also enable Socket Mode and Event Subscriptions (message.channels,
84
+ # message.groups, message.im, message.mpim, app_mention) on the same app.
85
+ # Run `maestro init slack-socket-mode --apply` to walk through the full setup.
86
+ SLACK_APP_LEVEL_TOKEN=
87
+
79
88
 
80
89
  # ─── GMAIL ───────────────────────────────────────────────────────────────────
81
90
  #
package/bin/maestro.mjs CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  readdirSync,
21
21
  statSync,
22
22
  lstatSync,
23
+ openSync,
24
+ readSync,
25
+ closeSync,
23
26
  } from "node:fs";
24
27
  import { execFileSync, spawnSync } from "node:child_process";
25
28
  import { createHash } from "node:crypto";
@@ -1499,6 +1502,95 @@ function doctor() {
1499
1502
  } else if (preferSubs) {
1500
1503
  ok("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 — using Claude Code subscription (Keychain OAuth)");
1501
1504
  }
1505
+
1506
+ // ── Slack Socket Mode ────────────────────────────────────────────────
1507
+ // When SLACK_APP_LEVEL_TOKEN is set, verify the launchd job is loaded
1508
+ // and the listener has connected recently. The check is fully optional:
1509
+ // the framework still works with the legacy 60s poller if the operator
1510
+ // hasn't enabled Socket Mode yet.
1511
+ const socketTokenMatch = env.match(/^SLACK_APP_LEVEL_TOKEN=(.+)$/m);
1512
+ const socketToken = socketTokenMatch ? socketTokenMatch[1].trim().replace(/^["']|["']$/g, "") : "";
1513
+ if (socketToken && socketToken.startsWith("xapp-")) {
1514
+ ok("SLACK_APP_LEVEL_TOKEN configured (Socket Mode candidate)");
1515
+
1516
+ // Resolve the agent's first name the same way generate-plists.sh does
1517
+ // so the label matches whatever is installed in ~/Library/LaunchAgents/.
1518
+ let firstName = "";
1519
+ try {
1520
+ const aj = JSON.parse(readFileSync(join(cwd, "config/agent.json"), "utf-8"));
1521
+ if (aj.firstName) firstName = String(aj.firstName).toLowerCase();
1522
+ } catch { /* fall back */ }
1523
+ if (!firstName) firstName = cwd.split("/").pop().replace(/-ai$/, "").toLowerCase();
1524
+ const socketLabel = `ai.adaptic.${firstName}-slack-socket`;
1525
+
1526
+ // Is the plist installed at ~/Library/LaunchAgents/?
1527
+ const installedDir = join(process.env.HOME || "/", "Library/LaunchAgents");
1528
+ const installedPath = join(installedDir, `${socketLabel}.plist`);
1529
+ if (existsSync(installedPath)) {
1530
+ // Is it actually loaded? launchctl list returns 0 when the label exists.
1531
+ const listRes = spawnSync("launchctl", ["list", socketLabel], { encoding: "utf-8" });
1532
+ if (listRes.status === 0) ok(`launchd job loaded: ${socketLabel}`);
1533
+ else {
1534
+ warn(`Plist installed but launchd job not loaded: ${socketLabel}`);
1535
+ warn(` Fix: launchctl load ${installedPath}`);
1536
+ issues++;
1537
+ }
1538
+ } else {
1539
+ warn(`SLACK_APP_LEVEL_TOKEN set but ${installedPath} missing.`);
1540
+ warn(" Run: node scripts/setup/init-slack-socket-mode.mjs");
1541
+ issues++;
1542
+ }
1543
+
1544
+ // Did the listener log a `hello` envelope recently? The log lives at
1545
+ // logs/polling/<today>-slack-socket.jsonl — scan the last few lines
1546
+ // for a fresh "hello" or "inbox-item-written" entry.
1547
+ const today = new Date().toISOString().slice(0, 10);
1548
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
1549
+ let foundRecent = false;
1550
+ let latestTs = null;
1551
+ for (const day of [today, yesterday]) {
1552
+ const logPath = join(cwd, "logs/polling", `${day}-slack-socket.jsonl`);
1553
+ if (!existsSync(logPath)) continue;
1554
+ // Read the tail (last 50KB) to avoid loading huge files.
1555
+ try {
1556
+ const stat = statSync(logPath);
1557
+ const buf = Buffer.alloc(Math.min(stat.size, 50_000));
1558
+ const fd = openSync(logPath, "r");
1559
+ try {
1560
+ const start = Math.max(0, stat.size - buf.length);
1561
+ readSync(fd, buf, 0, buf.length, start);
1562
+ } finally { closeSync(fd); }
1563
+ const tail = buf.toString("utf-8");
1564
+ for (const line of tail.split("\n").reverse()) {
1565
+ if (!line) continue;
1566
+ try {
1567
+ const entry = JSON.parse(line);
1568
+ if (entry.message === "hello — connected to Slack Socket Mode" ||
1569
+ entry.message === "inbox-item-written" ||
1570
+ entry.message === "starting Socket Mode listener") {
1571
+ latestTs = entry.ts;
1572
+ const ageMs = Date.now() - new Date(entry.ts).getTime();
1573
+ if (ageMs >= 0 && ageMs < 60 * 60_000) foundRecent = true;
1574
+ break;
1575
+ }
1576
+ } catch { /* malformed line — skip */ }
1577
+ }
1578
+ if (latestTs) break;
1579
+ } catch { /* unreadable log — skip */ }
1580
+ }
1581
+ if (foundRecent) {
1582
+ const ageMin = Math.round((Date.now() - new Date(latestTs).getTime()) / 60_000);
1583
+ ok(`Socket Mode listener was active ${ageMin}m ago`);
1584
+ } else if (latestTs) {
1585
+ const ageMin = Math.round((Date.now() - new Date(latestTs).getTime()) / 60_000);
1586
+ warn(`Socket Mode last connect ${ageMin}m ago — may be stalled`);
1587
+ issues++;
1588
+ } else {
1589
+ warn("Socket Mode listener has not produced a connect log yet.");
1590
+ warn(" Check: tail -f logs/polling/$(date +%Y-%m-%d)-slack-socket.jsonl");
1591
+ issues++;
1592
+ }
1593
+ }
1502
1594
  } else {
1503
1595
  fail(".env file not found — copy from .env.example");
1504
1596
  issues++;
@@ -102,6 +102,16 @@
102
102
  "command": "node scripts/setup/init-backup.mjs",
103
103
  "description": "Configure off-machine backup of state/, knowledge/, outputs/, and rotated logs to GCS or S3. Requires bucket name + credentials. Doctor flags if last successful backup is >24h old once configured."
104
104
  }
105
+ },
106
+ "slack-socket-mode": {
107
+ "version": "1",
108
+ "since": "1.10.0",
109
+ "title": "Slack Socket Mode (realtime DM + @mention ingestion)",
110
+ "init": {
111
+ "auto": false,
112
+ "command": "node scripts/setup/init-slack-socket-mode.mjs",
113
+ "description": "Replace the 60s slack-poller cycle with a persistent WebSocket to Slack. Three steps in the Slack admin UI: (1) open the agent's Slack app at api.slack.com/apps; (2) enable Socket Mode and generate an app-level token (xapp-…) with the connections:write scope; (3) enable Event Subscriptions and subscribe to message.channels, message.groups, message.im, message.mpim, and app_mention. The init wizard prompts for the token, appends it to .env, regenerates the launchd plist, and launchctl-loads it. Doctor verifies the token is present, the plist is loaded, and the listener recently connected."
114
+ }
105
115
  }
106
116
  }
107
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,95 @@
1
+ #!/bin/bash
2
+ # launchd-socket-mode-wrapper.sh — Bootstraps env for slack-socket-mode.mjs
3
+ # under launchd.
4
+ #
5
+ # Mirrors launchd-wrapper.sh exactly: launchd's bare env doesn't include
6
+ # HOME, PATH, or AGENT_ROOT, so we hydrate them before exec'ing the
7
+ # Socket Mode listener. Logs land on the external SSD when available
8
+ # (same fallback semantics as the main daemon wrapper).
9
+ #
10
+ # This wrapper is exec'd by ai.adaptic.{firstname}-slack-socket.plist.
11
+
12
+ set -e
13
+
14
+ AGENT_ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)"
15
+ export AGENT_ROOT
16
+ export HOME="${HOME:-/Users/$(whoami)}"
17
+ export USER="${USER:-$(whoami)}"
18
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
19
+
20
+ # ── SSD redirect ────────────────────────────────────────────────────────────
21
+ # If an external SSD is mounted at /Volumes/{name}, redirect:
22
+ # - Claude Code per-cwd temp (CLAUDE_CODE_TMPDIR)
23
+ # - Listener stdout/stderr (via shell redirection at exec time)
24
+ #
25
+ # Detection mirrors launchd-wrapper.sh — first volume under /Volumes that's
26
+ # not a system mount; MAESTRO_SSD_VOLUME env var overrides if multiple SSDs.
27
+
28
+ SSD_VOLUME="${MAESTRO_SSD_VOLUME:-}"
29
+ if [ -z "$SSD_VOLUME" ]; then
30
+ for v in /Volumes/*-SSD /Volumes/*SSD* /Volumes/maestro-data; do
31
+ if [ -d "$v" ] && [ "$v" != "/Volumes/Macintosh HD" ]; then
32
+ SSD_VOLUME="$v"
33
+ break
34
+ fi
35
+ done
36
+ fi
37
+
38
+ AGENT_NAME="$(basename "$AGENT_ROOT" | sed 's/-ai$//')"
39
+ SSD_AGENT_ROOT=""
40
+ SSD_WRITABLE=0
41
+ if [ -n "$SSD_VOLUME" ] && [ -d "$SSD_VOLUME" ]; then
42
+ SSD_AGENT_ROOT="$SSD_VOLUME/maestro/$AGENT_NAME"
43
+ if mkdir -p "$SSD_AGENT_ROOT/claude-tmp" "$SSD_AGENT_ROOT/logs/slack-socket" 2>/dev/null && \
44
+ touch "$SSD_AGENT_ROOT/.write-test-$$" 2>/dev/null; then
45
+ rm -f "$SSD_AGENT_ROOT/.write-test-$$"
46
+ SSD_WRITABLE=1
47
+ export CLAUDE_CODE_TMPDIR="$SSD_AGENT_ROOT/claude-tmp"
48
+ fi
49
+ fi
50
+
51
+ cd "$AGENT_ROOT"
52
+
53
+ # Resolve node binary — prefer nvm, fall back to homebrew, then system.
54
+ NODE_BIN=""
55
+ for candidate in \
56
+ "$HOME/.nvm/versions/node/v24.11.1/bin/node" \
57
+ "$HOME/.nvm/versions/node/v24/bin/node" \
58
+ "$HOME/.nvm/versions/node/v22/bin/node" \
59
+ "$HOME/.nvm/versions/node/v20/bin/node" \
60
+ /opt/homebrew/bin/node \
61
+ /usr/local/bin/node \
62
+ /usr/bin/node; do
63
+ if [ -x "$candidate" ]; then
64
+ NODE_BIN="$candidate"
65
+ break
66
+ fi
67
+ done
68
+ if [ -z "$NODE_BIN" ] && [ -d "$HOME/.nvm/versions/node" ]; then
69
+ NODE_BIN=$(ls -1d "$HOME/.nvm/versions/node"/v*/bin/node 2>/dev/null | sort -V | tail -1)
70
+ fi
71
+ if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
72
+ echo "[slack-socket-wrapper] FATAL: could not find node binary" >&2
73
+ exit 127
74
+ fi
75
+
76
+ # Node 22.4+ is required for the global WebSocket. Warn (don't fail) on
77
+ # older versions — the user might have polyfilled via `--experimental-websocket`
78
+ # or installed the `ws` package as a fallback.
79
+ NODE_VERSION="$("$NODE_BIN" --version 2>/dev/null || echo 'v0.0.0')"
80
+ NODE_MAJOR="$(echo "$NODE_VERSION" | sed -E 's/^v([0-9]+).*/\1/')"
81
+ if [ "$NODE_MAJOR" -lt 22 ] 2>/dev/null; then
82
+ echo "[slack-socket-wrapper] WARNING: Node $NODE_VERSION is older than v22 — global WebSocket may be missing." >&2
83
+ fi
84
+
85
+ # Exec the listener. Prefer SSD log path if writable, otherwise fall back
86
+ # to internal disk so the listener stays up even when macOS denies launchd
87
+ # write access to /Volumes/{name}.
88
+ if [ "$SSD_WRITABLE" = "1" ]; then
89
+ LISTENER_LOG="$SSD_AGENT_ROOT/logs/slack-socket/listener-$(date +%Y-%m-%d).log"
90
+ exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-socket-mode.mjs" >> "$LISTENER_LOG" 2>&1
91
+ else
92
+ LISTENER_LOG="$AGENT_ROOT/logs/polling/slack-socket-$(date +%Y-%m-%d).log"
93
+ mkdir -p "$(dirname "$LISTENER_LOG")" 2>/dev/null || true
94
+ exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-socket-mode.mjs" >> "$LISTENER_LOG" 2>&1
95
+ fi
@@ -23,15 +23,21 @@ else
23
23
  echo "[OK] No emergency stop flag"
24
24
  fi
25
25
 
26
- # Check 2: Required environment variables
27
- for var in ANTHROPIC_API_KEY; do
28
- if [ -z "${!var}" ]; then
29
- echo "[FAIL] Missing environment variable: $var"
30
- ERRORS=$((ERRORS + 1))
31
- else
32
- echo "[OK] Environment variable: $var"
33
- fi
34
- done
26
+ # Check 2: Anthropic auth — either ANTHROPIC_API_KEY set OR subscription
27
+ # auth enabled. Daemons launched under launchd don't inherit the agent's
28
+ # shell env, so a "missing" ANTHROPIC_API_KEY is normal when the operator
29
+ # opted into Claude Code subscription auth (MAESTRO_PREFER_SUBSCRIPTION_AUTH=1
30
+ # in .env strips the var and falls back to Keychain OAuth).
31
+ if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
32
+ echo "[OK] Environment variable: ANTHROPIC_API_KEY"
33
+ elif [ -f "$SOPHIE_AI_DIR/.env" ] && grep -qE "^MAESTRO_PREFER_SUBSCRIPTION_AUTH=(1|true|yes)$" "$SOPHIE_AI_DIR/.env"; then
34
+ echo "[OK] Anthropic auth: subscription mode (.env: MAESTRO_PREFER_SUBSCRIPTION_AUTH=1)"
35
+ elif [ -f "$SOPHIE_AI_DIR/.env" ] && grep -qE "^ANTHROPIC_API_KEY=." "$SOPHIE_AI_DIR/.env"; then
36
+ echo "[OK] Anthropic auth: ANTHROPIC_API_KEY in .env"
37
+ else
38
+ echo "[FAIL] Anthropic auth not configured (.env needs either ANTHROPIC_API_KEY or MAESTRO_PREFER_SUBSCRIPTION_AUTH=1)"
39
+ ERRORS=$((ERRORS + 1))
40
+ fi
35
41
 
36
42
  # Check 3: Key directories exist and are writable
37
43
  for dir in logs outputs knowledge/memory config; do
@@ -261,6 +261,21 @@ generate_plist "ai.adaptic.${AGENT_FIRST}-poll-relay" \
261
261
  "${AGENT_DIR}/scripts/daemon/launchd-wrapper-generic.sh|${AGENT_DIR}/scripts/poll-slack-events.sh" \
262
262
  "" "5" ""
263
263
 
264
+ # 2b. Slack Socket Mode listener (persistent WebSocket — KeepAlive).
265
+ # Replaces the 60s slack-poller cycle for DMs + @mentions with a
266
+ # realtime WSS connection to Slack. Requires SLACK_APP_LEVEL_TOKEN
267
+ # in .env (the operator generates this via api.slack.com → App-Level
268
+ # Tokens with connections:write scope). The wrapper at
269
+ # scripts/cadence/launchd-socket-mode-wrapper.sh bootstraps env +
270
+ # execs scripts/poller/slack-socket-mode.mjs.
271
+ #
272
+ # KeepAlive uses the same SuccessfulExit:false / Crashed:true semantics
273
+ # as the daemon plist so .emergency-stop (clean exit 0) refuses to
274
+ # restart, but unexpected crashes do trigger a restart treadmill.
275
+ generate_plist "ai.adaptic.${AGENT_FIRST}-slack-socket" \
276
+ "${AGENT_DIR}/scripts/cadence/launchd-socket-mode-wrapper.sh" \
277
+ "" "" "true"
278
+
264
279
  # 3. Inbox processor (every 5 minutes)
265
280
  generate_trigger_plist "inbox-processor" "" "300"
266
281
 
@@ -56,6 +56,10 @@ async function makeAgent(firstName) {
56
56
  join(MAESTRO_ROOT, "scripts/cadence/launchd-cadence-wrapper.sh"),
57
57
  join(root, "scripts/cadence/launchd-cadence-wrapper.sh"),
58
58
  );
59
+ copyFileSync(
60
+ join(MAESTRO_ROOT, "scripts/cadence/launchd-socket-mode-wrapper.sh"),
61
+ join(root, "scripts/cadence/launchd-socket-mode-wrapper.sh"),
62
+ );
59
63
  copyFileSync(
60
64
  join(MAESTRO_ROOT, "scripts/cadence/enqueue-cadence-tick.mjs"),
61
65
  join(root, "scripts/cadence/enqueue-cadence-tick.mjs"),
@@ -68,6 +72,7 @@ async function makeAgent(firstName) {
68
72
  "scripts/daemon/launchd-wrapper.sh",
69
73
  "scripts/daemon/launchd-wrapper-generic.sh",
70
74
  "scripts/cadence/launchd-cadence-wrapper.sh",
75
+ "scripts/cadence/launchd-socket-mode-wrapper.sh",
71
76
  ]) {
72
77
  await fsp.chmod(join(root, p), 0o755);
73
78
  }
@@ -95,9 +100,9 @@ function listPlists(agentRoot) {
95
100
  // Tests
96
101
  // ---------------------------------------------------------------------------
97
102
 
98
- test("generator emits 15 plists with the agent's first name", async () => {
99
- // Inventory (as of cadence-bus v1):
100
- // daemon, poll-relay,
103
+ test("generator emits 16 plists with the agent's first name", async () => {
104
+ // Inventory (as of cadence-bus v1 + slack-socket-mode v1):
105
+ // daemon, poll-relay, slack-socket,
101
106
  // inbox-processor, backlog-executor, meeting-prep, meeting-action-capture,
102
107
  // daily-morning-brief, daily-midday-sweep, daily-evening-wrap,
103
108
  // weekly-hiring, weekly-priorities, weekly-execution,
@@ -108,7 +113,7 @@ test("generator emits 15 plists with the agent's first name", async () => {
108
113
  const r = runGenerator(root);
109
114
  assert.equal(r.status, 0, r.stderr);
110
115
  const plists = listPlists(root);
111
- assert.equal(plists.length, 15);
116
+ assert.equal(plists.length, 16);
112
117
  for (const p of plists) {
113
118
  assert.match(p, /^ai\.adaptic\.alice-/);
114
119
  }
@@ -134,9 +139,12 @@ test("NO trigger plist invokes run-trigger.sh", async () => {
134
139
  const dir = join(root, "scripts/local-triggers/plists");
135
140
  for (const name of listPlists(root)) {
136
141
  const body = readFileSync(join(dir, name), "utf-8");
137
- // Exception: the daemon/poll-relay plists don't run triggers at all.
138
- // The trigger plists must invoke enqueue-cadence-tick.mjs.
139
- if (name.endsWith("-daemon.plist") || name.endsWith("-poll-relay.plist")) {
142
+ // Exception: the daemon/poll-relay/slack-socket plists don't run cadence
143
+ // triggers at all (the first two are KeepAlive workers; slack-socket is
144
+ // a KeepAlive WSS listener). The trigger plists must invoke
145
+ // enqueue-cadence-tick.mjs.
146
+ if (name.endsWith("-daemon.plist") || name.endsWith("-poll-relay.plist") ||
147
+ name.endsWith("-slack-socket.plist")) {
140
148
  continue;
141
149
  }
142
150
  assert.ok(!body.includes("run-trigger.sh"),
@@ -229,6 +237,10 @@ export interface AgentConfig { firstName: string; }
229
237
  join(MAESTRO_ROOT, "scripts/cadence/launchd-cadence-wrapper.sh"),
230
238
  join(root, "scripts/cadence/launchd-cadence-wrapper.sh"),
231
239
  );
240
+ copyFileSync(
241
+ join(MAESTRO_ROOT, "scripts/cadence/launchd-socket-mode-wrapper.sh"),
242
+ join(root, "scripts/cadence/launchd-socket-mode-wrapper.sh"),
243
+ );
232
244
  copyFileSync(
233
245
  join(MAESTRO_ROOT, "scripts/cadence/enqueue-cadence-tick.mjs"),
234
246
  join(root, "scripts/cadence/enqueue-cadence-tick.mjs"),