@rubytech/create-maxy-code 0.1.389 → 0.1.392

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 (62) hide show
  1. package/dist/__tests__/claude-bin.test.js +51 -0
  2. package/dist/__tests__/claude-upgrade-privilege.test.js +17 -0
  3. package/dist/__tests__/launchd-plist.test.js +36 -1
  4. package/dist/__tests__/macos-darwin-branch.test.js +18 -0
  5. package/dist/__tests__/neo4j-datadir.test.js +14 -0
  6. package/dist/claude-bin.js +78 -0
  7. package/dist/claude-upgrade-privilege.js +21 -0
  8. package/dist/index.js +241 -34
  9. package/dist/launchd-plist.js +25 -9
  10. package/dist/neo4j-datadir.js +15 -0
  11. package/package.json +1 -1
  12. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -4
  13. package/payload/platform/scripts/smoke-boot-services.sh +35 -10
  14. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts +11 -0
  15. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts.map +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js +37 -0
  17. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js.map +1 -0
  18. package/payload/platform/services/claude-session-manager/dist/index.js +8 -8
  19. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +7 -0
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +5 -1
  26. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +28 -0
  28. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +54 -0
  30. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  31. package/payload/server/public/assets/{AdminLoginScreens-CerrEc_m.js → AdminLoginScreens-CL27ZV86.js} +1 -1
  32. package/payload/server/public/assets/{AdminShell-cRTvNRbo.js → AdminShell-CBcSh9Yd.js} +1 -1
  33. package/payload/server/public/assets/{Checkbox-Dh2pLMFN.js → Checkbox-DRIcYN9r.js} +1 -1
  34. package/payload/server/public/assets/{Transcript-B_GVJujB.js → Transcript-CRc72hkG.js} +1 -1
  35. package/payload/server/public/assets/{admin-BXaYelnR.js → admin-iKYYnv3w.js} +1 -1
  36. package/payload/server/public/assets/{browser-fhjGE7fH.js → browser--8gzAe-E.js} +1 -1
  37. package/payload/server/public/assets/{calendar-PnZudAtc.js → calendar-7xAT81N_.js} +1 -1
  38. package/payload/server/public/assets/chat-B8Z9W42Z.js +1 -0
  39. package/payload/server/public/assets/chevron-left-DDselYau.js +1 -0
  40. package/payload/server/public/assets/data-Ece320dZ.js +1 -0
  41. package/payload/server/public/assets/{graph-CycO3tkW.js → graph-B3haz2DN.js} +1 -1
  42. package/payload/server/public/assets/{graph-labels-DlSsSpBV.js → graph-labels-Ba_fyw6I.js} +1 -1
  43. package/payload/server/public/assets/{operator-L4kBC-zS.js → operator-BYL1IFz9.js} +1 -1
  44. package/payload/server/public/assets/page-Bj6lB07z.js +32 -0
  45. package/payload/server/public/assets/{page-D5E-Ng4D.js → page-CLKVCquw.js} +1 -1
  46. package/payload/server/public/assets/{public-BmnajF3K.js → public-BKJUKC76.js} +1 -1
  47. package/payload/server/public/assets/{rotate-ccw-W5HhvAbo.js → rotate-ccw-BypBbSI4.js} +1 -1
  48. package/payload/server/public/assets/{useSubAccountSwitcher-D1TI1xvd.css → useSubAccountSwitcher-4qilMyPX.css} +1 -1
  49. package/payload/server/public/browser.html +4 -4
  50. package/payload/server/public/calendar.html +5 -5
  51. package/payload/server/public/chat.html +8 -8
  52. package/payload/server/public/data.html +7 -7
  53. package/payload/server/public/graph.html +8 -8
  54. package/payload/server/public/index.html +10 -10
  55. package/payload/server/public/operator.html +10 -10
  56. package/payload/server/public/public.html +8 -8
  57. package/payload/server/server.js +157 -12
  58. package/payload/server/public/assets/chat-Dy_zrjKS.js +0 -1
  59. package/payload/server/public/assets/chevron-left-IB6TmMZ_.js +0 -1
  60. package/payload/server/public/assets/data-B2IcjAj6.js +0 -1
  61. package/payload/server/public/assets/page-M8sD9LOi.js +0 -32
  62. /package/payload/server/public/assets/{useSubAccountSwitcher-UqZbmzYy.js → useSubAccountSwitcher-CsRv6MEd.js} +0 -0
@@ -0,0 +1,51 @@
1
+ // acceptance grid for claude-bin.ts (Task 1395 gap 4).
2
+ //
3
+ // Pure resolver: version parse/compare, canonical pick (highest version),
4
+ // and PATH enumeration with injected fs/spawn so no real binary is probed.
5
+ // Runs under node --test alongside the rest of the package.
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { pickCanonicalClaude, compareVersions, parseClaudeVersion, enumerateClaudeCandidates, } from "../claude-bin.js";
9
+ test("parseClaudeVersion extracts leading version", () => {
10
+ assert.equal(parseClaudeVersion("2.1.201 (Claude Code)"), "2.1.201");
11
+ assert.equal(parseClaudeVersion(" 2.1.153 (Claude Code)\n"), "2.1.153");
12
+ assert.equal(parseClaudeVersion("not installed"), null);
13
+ });
14
+ test("compareVersions orders by numeric parts", () => {
15
+ assert.ok(compareVersions("2.1.201", "2.1.153") > 0);
16
+ assert.ok(compareVersions("2.1.153", "2.1.201") < 0);
17
+ assert.equal(compareVersions("2.1.1", "2.1.1"), 0);
18
+ assert.ok(compareVersions("2.2.0", "2.1.999") > 0);
19
+ });
20
+ test("pickCanonicalClaude picks highest version (nvm over stale Cask)", () => {
21
+ const pick = pickCanonicalClaude([
22
+ { path: "/usr/local/bin/claude", version: "2.1.153" },
23
+ { path: "/Users/x/.nvm/versions/node/v22/bin/claude", version: "2.1.201" },
24
+ ]);
25
+ assert.equal(pick, "/Users/x/.nvm/versions/node/v22/bin/claude");
26
+ });
27
+ test("pickCanonicalClaude: empty -> null, ties -> first", () => {
28
+ assert.equal(pickCanonicalClaude([]), null);
29
+ assert.equal(pickCanonicalClaude([
30
+ { path: "/a/claude", version: "2.1.1" },
31
+ { path: "/b/claude", version: "2.1.1" },
32
+ ]), "/a/claude");
33
+ });
34
+ test("enumerateClaudeCandidates dedupes by realpath, skips non-exec/unversioned", () => {
35
+ const versions = {
36
+ "/opt/homebrew/bin/claude": "2.1.201 (Claude Code)",
37
+ "/usr/local/bin/claude": "2.1.153 (Claude Code)",
38
+ };
39
+ const deps = {
40
+ isExecutable: (p) => p === "/opt/homebrew/bin/claude" || p === "/usr/local/bin/claude" || p === "/dup/claude",
41
+ // /dup/claude is a symlink to the homebrew one -> deduped away
42
+ realpath: (p) => (p === "/dup/claude" ? "/opt/homebrew/bin/claude" : p),
43
+ runVersion: (p) => versions[p] ?? null,
44
+ };
45
+ const cands = enumerateClaudeCandidates("/opt/homebrew/bin:/usr/local/bin:/dup:/empty", deps);
46
+ assert.deepEqual(cands, [
47
+ { path: "/opt/homebrew/bin/claude", version: "2.1.201" },
48
+ { path: "/usr/local/bin/claude", version: "2.1.153" },
49
+ ]);
50
+ assert.equal(pickCanonicalClaude(cands), "/opt/homebrew/bin/claude");
51
+ });
@@ -0,0 +1,17 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { decideClaudeUpgradePrivilege } from "../claude-upgrade-privilege.js";
4
+ test("darwin: writable user-owned prefix runs the upgrade without sudo", () => {
5
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: true, canSudo: false }), "run-no-sudo");
6
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: true, canSudo: true }), "run-no-sudo");
7
+ });
8
+ test("darwin: non-writable prefix falls back to sudo when available, else skips", () => {
9
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: false, canSudo: true }), "run-sudo");
10
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "darwin", prefixWritable: false, canSudo: false }), "skip");
11
+ });
12
+ test("linux: unchanged — sudo when available, skip when not; prefixWritable is ignored", () => {
13
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: false, canSudo: true }), "run-sudo");
14
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: false, canSudo: false }), "skip");
15
+ // prefixWritable must not change the Linux decision (root-owned global prefix assumed).
16
+ assert.equal(decideClaudeUpgradePrivilege({ platform: "linux", prefixWritable: true, canSudo: false }), "skip");
17
+ });
@@ -10,7 +10,7 @@
10
10
  // package so `node --test dist/__tests__/*.test.js` picks it up after build.
11
11
  import test from "node:test";
12
12
  import assert from "node:assert/strict";
13
- import { renderPlist } from "../launchd-plist.js";
13
+ import { renderPlist, buildSessionManagerWrapper } from "../launchd-plist.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Standard render — every required field present, no escaping needed.
16
16
  // ---------------------------------------------------------------------------
@@ -193,3 +193,38 @@ test("renderPlist omits WorkingDirectory key when workingDirectory is absent", (
193
193
  assert.match(xml, /<key>KeepAlive<\/key>/);
194
194
  assert.match(xml, /<key>RunAtLoad<\/key>/);
195
195
  });
196
+ // ---------------------------------------------------------------------------
197
+ // buildSessionManagerWrapper — Task 1395 gap 2
198
+ // ---------------------------------------------------------------------------
199
+ test("buildSessionManagerWrapper sources .env and execs the manager", () => {
200
+ const w = buildSessionManagerWrapper({
201
+ envPath: "/Users/x/.maxy/.env",
202
+ installDir: "/Users/x/maxy-code",
203
+ nodeBin: "/usr/local/bin/node",
204
+ persistDir: "/Users/x/.maxy",
205
+ brandPort: 19200,
206
+ });
207
+ assert.match(w, /^#!\/bin\/bash\n/);
208
+ assert.match(w, /set -a; \[ -f "\/Users\/x\/\.maxy\/\.env" \] && \. "\/Users\/x\/\.maxy\/\.env"; set \+a/);
209
+ assert.match(w, /export CLAUDE_SESSION_MANAGER_PERSIST_DIR="\/Users\/x\/\.maxy"/);
210
+ assert.match(w, /export PLATFORM_ROOT="\/Users\/x\/maxy-code\/platform"/);
211
+ assert.match(w, /export MAXY_BRAND_PORT="19200"/);
212
+ assert.match(w, /export PLATFORM_PORT="19200"/);
213
+ assert.match(w, /cd "\/Users\/x\/maxy-code\/platform\/services\/claude-session-manager"/);
214
+ assert.match(w, /exec \/usr\/local\/bin\/node dist\/index\.js\n$/);
215
+ });
216
+ test("session-manager plist renders bash wrapper as sole ProgramArguments", () => {
217
+ const xml = renderPlist({
218
+ label: "com.rubytech.maxy-code-claude-session-manager",
219
+ programArguments: ["/bin/bash", "/Users/x/.maxy/claude-session-manager-wrapper.sh"],
220
+ stdoutPath: "/Users/x/.maxy/logs/server.log",
221
+ stderrPath: "/Users/x/.maxy/logs/server.log",
222
+ keepAlive: true,
223
+ runAtLoad: true,
224
+ workingDirectory: "/Users/x/maxy-code/platform/services/claude-session-manager",
225
+ });
226
+ assert.match(xml, /<string>com\.rubytech\.maxy-code-claude-session-manager<\/string>/);
227
+ assert.match(xml, /<string>\/bin\/bash<\/string>\s*<string>\/Users\/x\/\.maxy\/claude-session-manager-wrapper\.sh<\/string>/);
228
+ assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
229
+ assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
230
+ });
@@ -81,3 +81,21 @@ test("uninstall darwin path bootsout the LaunchAgent and removes the plist", ()
81
81
  // brand-keyed — install + uninstall both depend on the same label shape.
82
82
  assert.match(SRC, /function launchdLabel\(\):\s*string\s*\{\s*return\s*`com\.rubytech\.\$\{BRAND\.hostname\}`/);
83
83
  });
84
+ test("resetNeo4jAuth has a darwin branch that uses brew services and no sudo", () => {
85
+ // On darwin Neo4j runs under a user launchd agent (brew services) and its
86
+ // datadir is user-owned — no systemctl, no sudo. Task 1394. Static-grep, not
87
+ // behaviour: resetNeo4jAuth spawns brew/neo4j-admin and can't run headless.
88
+ const fn = SRC.slice(SRC.indexOf("function resetNeo4jAuth"), SRC.indexOf("function redactInstallLogs"));
89
+ assert.match(fn, /platform === "darwin"/, "resetNeo4jAuth must branch on darwin");
90
+ assert.match(fn, /brew["']?,\s*\[["']services["']\s*,\s*["']stop["']/, "darwin branch stops neo4j via brew services");
91
+ assert.match(fn, /brew["']?,\s*\[["']services["']\s*,\s*["']start["']/, "darwin branch starts neo4j via brew services");
92
+ assert.match(fn, /neo4j-darwin-auth-reset/, "darwin branch emits the reset observability marker");
93
+ // The darwin branch must NOT shell out to systemctl or sudo (that path is
94
+ // Linux-only). Match invocation shapes — quoted command literals and the
95
+ // `sudo: true` option — not prose, so the branch's own explanatory comment
96
+ // ("no systemctl, no sudo") does not defeat the assertion.
97
+ const darwinBranch = fn.slice(fn.indexOf('platform === "darwin"'), fn.indexOf("} else {"));
98
+ assert.ok(!/["']systemctl["']/.test(darwinBranch), "darwin branch must not invoke systemctl");
99
+ assert.ok(!/["']sudo["']/.test(darwinBranch), "darwin branch must not spawn sudo");
100
+ assert.ok(!/sudo:\s*true/.test(darwinBranch), "darwin branch must not pass { sudo: true } on the user-owned datadir");
101
+ });
@@ -0,0 +1,14 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveNeo4jDataDir } from "../neo4j-datadir.js";
4
+ test("darwin resolves the Homebrew datadir under the brew prefix (Intel + Apple Silicon)", () => {
5
+ assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/usr/local", dedicated: false, hostname: "maxy-code" }), "/usr/local/var/neo4j");
6
+ assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/opt/homebrew", dedicated: false, hostname: "maxy-code" }), "/opt/homebrew/var/neo4j");
7
+ });
8
+ test("darwin ignores dedicated — only the shared Homebrew instance exists on darwin", () => {
9
+ assert.equal(resolveNeo4jDataDir({ platform: "darwin", brewPrefix: "/usr/local", dedicated: true, hostname: "realagent-code" }), "/usr/local/var/neo4j");
10
+ });
11
+ test("linux shared and dedicated datadirs are unchanged", () => {
12
+ assert.equal(resolveNeo4jDataDir({ platform: "linux", brewPrefix: "", dedicated: false, hostname: "maxy-code" }), "/var/lib/neo4j");
13
+ assert.equal(resolveNeo4jDataDir({ platform: "linux", brewPrefix: "", dedicated: true, hostname: "realagent" }), "/var/lib/neo4j-realagent");
14
+ });
@@ -0,0 +1,78 @@
1
+ // Task 1395 gap 4 — resolve one canonical `claude` binary.
2
+ //
3
+ // A Mac can carry more than one `claude` on PATH at different versions — e.g.
4
+ // an npm-global (nvm/Homebrew node) claude AND a Homebrew Cask `claude-code` at
5
+ // /usr/local/bin/claude. Whichever the runtime PATH hits first wins, which
6
+ // during the Task 1393 migration was the STALE Cask (2.1.153) rather than the
7
+ // logged-in nvm binary (2.1.201). The installer resolves a single canonical
8
+ // path and persists it as CLAUDE_BIN in `.env`; every runtime spawn then uses
9
+ // that exact binary instead of a PATH lottery.
10
+ //
11
+ // Canonical = highest version. This is deterministic and picks the newest
12
+ // claude, which is the defensible choice when two are present. All fs/spawn I/O
13
+ // is injected so the resolver stays pure and unit-testable.
14
+ import { join } from "node:path";
15
+ /** Extract the leading dotted-numeric version from `claude --version` output,
16
+ * e.g. "2.1.201 (Claude Code)" -> "2.1.201". Returns null when absent. */
17
+ export function parseClaudeVersion(raw) {
18
+ const m = raw.trim().match(/(\d+(?:\.\d+)+)/);
19
+ return m ? m[1] : null;
20
+ }
21
+ function toParts(v) {
22
+ const m = v.trim().match(/^(\d+(?:\.\d+)*)/);
23
+ return m ? m[1].split(".").map((n) => parseInt(n, 10)) : [];
24
+ }
25
+ /** Numeric dotted-version compare (a<b -> <0, a>b -> >0). Missing parts = 0. */
26
+ export function compareVersions(a, b) {
27
+ const pa = toParts(a);
28
+ const pb = toParts(b);
29
+ const len = Math.max(pa.length, pb.length);
30
+ for (let i = 0; i < len; i++) {
31
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
32
+ if (d !== 0)
33
+ return d;
34
+ }
35
+ return 0;
36
+ }
37
+ /** Pick the highest-version candidate's path; first-wins on ties. Null when the
38
+ * list is empty. */
39
+ export function pickCanonicalClaude(candidates) {
40
+ if (candidates.length === 0)
41
+ return null;
42
+ let best = candidates[0];
43
+ for (let i = 1; i < candidates.length; i++) {
44
+ if (compareVersions(candidates[i].version, best.version) > 0)
45
+ best = candidates[i];
46
+ }
47
+ return best.path;
48
+ }
49
+ /** Enumerate every distinct `claude` reachable from `pathEnv` (colon-separated
50
+ * PATH), deduped by realpath, each probed for its version. Non-executable
51
+ * entries, symlink duplicates, and version-probe failures are skipped. */
52
+ export function enumerateClaudeCandidates(pathEnv, deps) {
53
+ const seen = new Set();
54
+ const out = [];
55
+ for (const dir of pathEnv.split(":").filter(Boolean)) {
56
+ const cand = join(dir, "claude");
57
+ if (!deps.isExecutable(cand))
58
+ continue;
59
+ let real;
60
+ try {
61
+ real = deps.realpath(cand);
62
+ }
63
+ catch {
64
+ real = cand;
65
+ }
66
+ if (seen.has(real))
67
+ continue;
68
+ seen.add(real);
69
+ const raw = deps.runVersion(cand);
70
+ if (!raw)
71
+ continue;
72
+ const version = parseClaudeVersion(raw);
73
+ if (!version)
74
+ continue;
75
+ out.push({ path: cand, version });
76
+ }
77
+ return out;
78
+ }
@@ -0,0 +1,21 @@
1
+ // Pure decision for whether the phase-3 `npm install -g @anthropic-ai/claude-code`
2
+ // upgrade runs under sudo, runs unprivileged, or is skipped. Peer of
3
+ // apt-resolve.ts / brew-resolve.ts: no spawnSync, no fs — the caller injects the
4
+ // probed inputs and applies the result.
5
+ //
6
+ // Root cause this encodes: on darwin the npm global prefix (nvm or Homebrew) is
7
+ // user-owned, so `npm install -g` needs no sudo; wrapping it in sudo forces a
8
+ // non-interactive password prompt that hard-fails. On Linux the global prefix is
9
+ // root-owned by default, so the existing sudo-or-skip behaviour is preserved
10
+ // unchanged (prefixWritable is not consulted on Linux).
11
+ export function decideClaudeUpgradePrivilege(input) {
12
+ if (input.platform === "darwin") {
13
+ if (input.prefixWritable)
14
+ return "run-no-sudo";
15
+ if (input.canSudo)
16
+ return "run-sudo";
17
+ return "skip";
18
+ }
19
+ // linux: unchanged from the historical `isLinux() && !canSudo()` skip.
20
+ return input.canSudo ? "run-sudo" : "skip";
21
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync } from "node:fs";
3
+ import { existsSync, mkdirSync, writeFileSync, cpSync, readFileSync, rmSync, readdirSync, appendFileSync, openSync, closeSync, chmodSync, statSync, realpathSync, accessSync, constants as fsConstants } from "node:fs";
4
4
  import { registerSpecialistAgentsAt, SpecialistSymlinkCollision } from "./specialist-registration.js";
5
5
  import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./permissions-seed.js";
6
6
  import { resolve, join, dirname } from "node:path";
@@ -13,8 +13,11 @@ import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
13
13
  import { resolveNeo4jPidfileScrub } from "./neo4j-pidfile-scrub.js";
14
14
  import { runInitLogging } from "./init-logging.js";
15
15
  import { requireSupportedPlatform, detectPlatform } from "./platform-detect.js";
16
+ import { decideClaudeUpgradePrivilege } from "./claude-upgrade-privilege.js";
17
+ import { resolveNeo4jDataDir } from "./neo4j-datadir.js";
16
18
  import { protectCommands, unprotectCommands, lsattrShowsImmutable } from "./install-immutability.js";
17
- import { renderPlist } from "./launchd-plist.js";
19
+ import { renderPlist, buildSessionManagerWrapper } from "./launchd-plist.js";
20
+ import { enumerateClaudeCandidates, pickCanonicalClaude } from "./claude-bin.js";
18
21
  import { installAllBrewPackages } from "./brew-install.js";
19
22
  import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
20
23
  import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
@@ -278,6 +281,29 @@ function canSudo() {
278
281
  const result = spawnSync("sudo", ["-n", "true"], { stdio: "pipe", timeout: 5_000 });
279
282
  return result.status === 0;
280
283
  }
284
+ /** True when the npm global prefix's node_modules dir is writable by the current
285
+ * user (nvm/Homebrew installs) — meaning `npm install -g` needs no sudo. */
286
+ function npmGlobalPrefixWritable() {
287
+ try {
288
+ const prefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf-8", timeout: 10_000 }).trim();
289
+ accessSync(join(prefix, "lib", "node_modules"), fsConstants.W_OK);
290
+ return true;
291
+ }
292
+ catch {
293
+ return false;
294
+ }
295
+ }
296
+ /** Resolve the Homebrew prefix (`/usr/local` on Intel, `/opt/homebrew` on Apple
297
+ * Silicon). Falls back to the arch default if `brew --prefix` is unavailable. */
298
+ function resolveBrewPrefix() {
299
+ try {
300
+ const out = execFileSync("brew", ["--prefix"], { encoding: "utf-8", timeout: 10_000 }).trim();
301
+ if (out)
302
+ return out;
303
+ }
304
+ catch { /* fall through to arch default */ }
305
+ return isArm64() ? "/opt/homebrew" : "/usr/local";
306
+ }
281
307
  // verified-not-asserted apt-dep reconciliation.
282
308
  // resolve package-name aliases before probing dpkg, so the
283
309
  // post-install check no longer false-negatives when apt resolves a virtual
@@ -1023,13 +1049,44 @@ function installNodejs() {
1023
1049
  console.log(" [privileged] apt-get install");
1024
1050
  shell("apt-get", ["install", "-y", "nodejs"], { sudo: true });
1025
1051
  }
1052
+ // Task 1395 gap 4 — the single canonical `claude` path resolved for this
1053
+ // install, pinned into `.env` as CLAUDE_BIN by installServiceDarwin. Null on
1054
+ // Linux (and darwin when unresolved) — callers fall back to bare `claude`.
1055
+ let RESOLVED_CLAUDE_BIN = null;
1056
+ /** Resolve the highest-version `claude` reachable from the runtime PATH the
1057
+ * launchd wrapper will use (`/opt/homebrew/bin:/usr/local/bin:$PATH`). Darwin
1058
+ * only — a Mac can carry two claude binaries at different versions; the
1059
+ * logged-in one must win, not whichever the bare PATH hits first. */
1060
+ function resolveCanonicalClaudeBin() {
1061
+ if (process.platform !== "darwin")
1062
+ return null;
1063
+ const runtimePath = `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? ""}`;
1064
+ const candidates = enumerateClaudeCandidates(runtimePath, {
1065
+ isExecutable: (p) => { try {
1066
+ accessSync(p, fsConstants.X_OK);
1067
+ return true;
1068
+ }
1069
+ catch {
1070
+ return false;
1071
+ } },
1072
+ realpath: (p) => realpathSync(p),
1073
+ runVersion: (p) => {
1074
+ const r = spawnSync(p, ["--version"], { encoding: "utf-8", timeout: 5_000 });
1075
+ return r.status === 0 ? (r.stdout ?? "").toString() : null;
1076
+ },
1077
+ });
1078
+ return pickCanonicalClaude(candidates);
1079
+ }
1026
1080
  function installClaudeCode() {
1027
1081
  let needsInstall = true;
1082
+ // Darwin: pin the canonical claude before the version check so the check (and
1083
+ // any upgrade decision) targets the logged-in binary, not the stale one.
1084
+ const claudeForCheck = (RESOLVED_CLAUDE_BIN = resolveCanonicalClaudeBin()) ?? "claude";
1028
1085
  if (commandExists("claude")) {
1029
1086
  try {
1030
1087
  // `claude --version` prints "2.1.114 (Claude Code)" — extract the semver so
1031
1088
  // the equality check against `npm view` (which returns bare "2.1.114") works.
1032
- const rawVersion = execFileSync("claude", ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
1089
+ const rawVersion = execFileSync(claudeForCheck, ["--version"], { encoding: "utf-8", timeout: 10_000 }).trim();
1033
1090
  const installed = rawVersion.match(/^(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/)?.[1] ?? rawVersion;
1034
1091
  let latest = null;
1035
1092
  try {
@@ -1054,21 +1111,38 @@ function installClaudeCode() {
1054
1111
  log("3", TOTAL, "Installing Claude Code...");
1055
1112
  }
1056
1113
  if (needsInstall) {
1057
- // `npm install -g` needs write access to the global prefix, which on Linux is
1058
- // root-owned by default so we run it under sudo. When sudo requires a password
1059
- // and the installer is running non-interactively (e.g. systemd-run --scope on
1060
- // upgrade), sudo fails instantly. Skip the upgrade in that case; the running
1061
- // installation is assumed adequate. Matches the apt-get skip in step 1.
1062
- if (isLinux() && !canSudo()) {
1114
+ // `npm install -g` needs write access to the global prefix. On Linux that
1115
+ // prefix is root-owned by default, so we run under sudo and skip when sudo
1116
+ // is non-interactive (e.g. systemd-run --scope on upgrade), keeping the
1117
+ // running version. On darwin the nvm/Homebrew prefix is user-owned, so the
1118
+ // upgrade runs unprivileged; sudo there would force a password prompt that
1119
+ // hard-fails non-interactively.
1120
+ const platform = requireSupportedPlatform(process.platform);
1121
+ const decision = decideClaudeUpgradePrivilege({
1122
+ platform,
1123
+ prefixWritable: platform === "darwin" ? npmGlobalPrefixWritable() : false,
1124
+ canSudo: canSudo(),
1125
+ });
1126
+ if (decision === "skip") {
1127
+ logFile(`[create-maxy] claude-upgrade skipped reason=no-noninteractive-sudo platform=${platform}`);
1063
1128
  console.log(" Skipping Claude Code upgrade (sudo unavailable non-interactively — keeping installed version)");
1064
1129
  }
1065
1130
  else {
1131
+ const useSudo = decision === "run-sudo";
1132
+ logFile(`[create-maxy] claude-upgrade platform=${platform} sudo=${useSudo}`);
1066
1133
  console.log(" This may take 15–30 minutes on Raspberry Pi...");
1067
- console.log(" [privileged] npm install -g @anthropic-ai/claude-code@latest");
1068
- shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: true, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
1134
+ console.log(` ${useSudo ? "[privileged] " : ""}npm install -g @anthropic-ai/claude-code@latest`);
1135
+ shellRetry("npm", ["install", "-g", ...NPM_NET_FLAGS, "--loglevel", "verbose", "@anthropic-ai/claude-code@latest"], { sudo: useSudo, timeout: 2_400_000 }, // 40 min — Pi downloads can take 25+ min
1069
1136
  3, 30);
1070
1137
  }
1071
1138
  }
1139
+ // Task 1395 gap 4 — a darwin upgrade may have just installed a newer
1140
+ // npm-global claude; re-resolve so the pinned CLAUDE_BIN reflects post-upgrade
1141
+ // state. Keep the pre-upgrade pick if re-resolution finds nothing.
1142
+ RESOLVED_CLAUDE_BIN = resolveCanonicalClaudeBin() ?? RESOLVED_CLAUDE_BIN;
1143
+ if (process.platform === "darwin") {
1144
+ logFile(` [create-maxy] claude-bin=${RESOLVED_CLAUDE_BIN ?? "unresolved"}`);
1145
+ }
1072
1146
  console.log(" Registering Claude plugin marketplaces...");
1073
1147
  // pre-create the per-brand CLAUDE_CONFIG_DIR so the first
1074
1148
  // `claude plugin marketplace add` has a directory to write into.
@@ -1176,34 +1250,54 @@ function installClaudeCode() {
1176
1250
  console.log(` Warning: Playwright MCP cache may be incomplete (browser automation may not work).${npxResult.stderr ? ` npx stderr: ${npxResult.stderr.slice(0, 200)}` : ""}`);
1177
1251
  }
1178
1252
  }
1179
- function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j") {
1253
+ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j", platform = "linux") {
1180
1254
  const password = randomBytes(24).toString("base64url");
1181
1255
  const dedicated = port !== DEFAULT_NEO4J_PORT;
1182
- const serviceName = dedicated ? `neo4j-${BRAND.hostname}` : "neo4j";
1183
- console.log(` Resetting Neo4j auth with fresh password (${serviceName})...`);
1184
- spawnSync("sudo", ["systemctl", "stop", serviceName], { stdio: "inherit" });
1185
- // Clear the system database (stores auth/roles) and dbms auth config.
1186
- // The neo4j user database (graph data) is preserved.
1187
- // set-initial-password only works before the system DB's first start,
1188
- // so we must delete it to make Neo4j treat the next start as initial.
1189
- spawnSync("sudo", ["rm", "-rf",
1256
+ // Clear the system database (stores auth/roles) and dbms auth config so the
1257
+ // next start is treated as initial and set-initial-password takes. The neo4j
1258
+ // user database (graph data) is preserved.
1259
+ const clearPaths = [
1190
1260
  `${dataDir}/data/dbms`,
1191
1261
  `${dataDir}/data/databases/system`,
1192
1262
  `${dataDir}/data/transactions/system`,
1193
- ], { stdio: "inherit" });
1194
- if (dedicated) {
1195
- const confDir = `/etc/neo4j-${BRAND.hostname}`;
1196
- // sudo env VAR=val passes the variable through sudo's env_reset
1197
- spawnSync("sudo", ["env", `NEO4J_CONF=${confDir}`, "neo4j-admin", "dbms", "set-initial-password", "--", password], {
1198
- stdio: "inherit",
1199
- });
1263
+ ];
1264
+ if (platform === "darwin") {
1265
+ // Homebrew Neo4j runs under a user launchd agent (brew services) and its
1266
+ // datadir is user-owned no systemctl, no sudo. This branch handles the
1267
+ // shared (default-brand) instance. Dedicated brands (realagent-code etc.)
1268
+ // set NEO4J_DEDICATED, but their dedicated provisioning (setupDedicatedNeo4j)
1269
+ // is Linux-only and aborts a dedicated-brand darwin install upstream, so a
1270
+ // non-default port never reaches here — see Task 1396 for darwin dedicated
1271
+ // support.
1272
+ console.log(" Resetting Neo4j auth with fresh password (brew services)...");
1273
+ spawnSync("brew", ["services", "stop", "neo4j"], { stdio: "inherit" });
1274
+ spawnSync("rm", ["-rf", ...clearPaths], { stdio: "inherit" });
1275
+ shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { redact: true });
1276
+ const brewStart = spawnSync("brew", ["services", "start", "neo4j"], { stdio: "inherit" });
1277
+ if (brewStart.status !== 0) {
1278
+ throw new Error(`brew services start neo4j failed (exit ${brewStart.status}) during auth reset — Neo4j must be running before the brand server starts`);
1279
+ }
1280
+ logFile(` [neo4j-darwin-auth-reset] datadir=${dataDir} service=brew-services sudo=false`);
1200
1281
  }
1201
1282
  else {
1202
- console.log(" [privileged] neo4j-admin dbms");
1203
- shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true, redact: true });
1283
+ const serviceName = dedicated ? `neo4j-${BRAND.hostname}` : "neo4j";
1284
+ console.log(` Resetting Neo4j auth with fresh password (${serviceName})...`);
1285
+ spawnSync("sudo", ["systemctl", "stop", serviceName], { stdio: "inherit" });
1286
+ spawnSync("sudo", ["rm", "-rf", ...clearPaths], { stdio: "inherit" });
1287
+ if (dedicated) {
1288
+ const confDir = `/etc/neo4j-${BRAND.hostname}`;
1289
+ // sudo env VAR=val passes the variable through sudo's env_reset
1290
+ spawnSync("sudo", ["env", `NEO4J_CONF=${confDir}`, "neo4j-admin", "dbms", "set-initial-password", "--", password], {
1291
+ stdio: "inherit",
1292
+ });
1293
+ }
1294
+ else {
1295
+ console.log(" [privileged] neo4j-admin dbms");
1296
+ shell("neo4j-admin", ["dbms", "set-initial-password", "--", password], { sudo: true, redact: true });
1297
+ }
1298
+ console.log(" [privileged] systemctl start");
1299
+ shell("systemctl", ["start", serviceName], { sudo: true });
1204
1300
  }
1205
- console.log(" [privileged] systemctl start");
1206
- shell("systemctl", ["start", serviceName], { sudo: true });
1207
1301
  console.log(" Waiting for Neo4j to start...");
1208
1302
  for (let i = 0; i < 15; i++) {
1209
1303
  const check = spawnSync("cypher-shell", [
@@ -1249,7 +1343,15 @@ function ensureNeo4jPassword() {
1249
1343
  const persistentPasswordFile = join(persistDir, ".neo4j-password");
1250
1344
  // Dedicated instances have their own auth database — password checks and resets
1251
1345
  // must target the dedicated port and data directory, not the shared instance.
1252
- const dataDir = NEO4J_DEDICATED ? `/var/lib/neo4j-${BRAND.hostname}` : "/var/lib/neo4j";
1346
+ // On darwin the datadir lives under the Homebrew prefix and is user-owned.
1347
+ const platform = requireSupportedPlatform(process.platform);
1348
+ const dataDir = resolveNeo4jDataDir({
1349
+ platform,
1350
+ brewPrefix: platform === "darwin" ? resolveBrewPrefix() : "",
1351
+ dedicated: NEO4J_DEDICATED,
1352
+ hostname: BRAND.hostname,
1353
+ });
1354
+ logFile(`[create-maxy] neo4j-datadir=${dataDir} platform=${platform}`);
1253
1355
  // 1. Same-brand check: if our own password file exists and works, done (upgrade path).
1254
1356
  if (existsSync(passwordFile)) {
1255
1357
  const existingPassword = readFileSync(passwordFile, "utf-8").trim();
@@ -1276,7 +1378,7 @@ function ensureNeo4jPassword() {
1276
1378
  console.log(" Neo4j has existing data. Resetting auth only (data preserved)...");
1277
1379
  logFile(` Neo4j auth reset — clearing dbms auth in ${dataDir}, preserving databases + transactions`);
1278
1380
  }
1279
- const password = resetNeo4jAuth(NEO4J_PORT, dataDir);
1381
+ const password = resetNeo4jAuth(NEO4J_PORT, dataDir, platform);
1280
1382
  writeFileSync(passwordFile, password, { mode: 0o600 });
1281
1383
  mkdirSync(persistDir, { recursive: true });
1282
1384
  writeFileSync(persistentPasswordFile, password, { mode: 0o600 });
@@ -2542,6 +2644,39 @@ function buildPlatform() {
2542
2644
  }
2543
2645
  console.log(` Installing claude-session-manager dependencies (${csmDir})...`);
2544
2646
  shellRetry("npm", ["install", "--omit=dev", ...NPM_NET_FLAGS], { cwd: csmDir }, 3, 15);
2647
+ // Task 1395 gap 3 — on darwin node-pty ships uncompiled. Its install script
2648
+ // is `node scripts/prebuild.js || node-gyp rebuild`; on this platform
2649
+ // prebuild.js exits 0 without placing a binary, so `node-gyp rebuild` never
2650
+ // runs and every PTY spawn fails `posix_spawnp failed`. `npm rebuild` just
2651
+ // re-runs the same broken script — proven inert. The fix is to invoke
2652
+ // `node-gyp rebuild` DIRECTLY in node-pty's resolved directory, bypassing
2653
+ // prebuild.js. node-pty hoists to the platform workspace root, so resolve
2654
+ // its real location rather than assuming csmDir. Xcode Command Line Tools
2655
+ // are the prerequisite. Blocker, not warning: no addon means no session
2656
+ // ever spawns.
2657
+ if (process.platform === "darwin") {
2658
+ const nodePtyCandidates = [
2659
+ join(INSTALL_DIR, "platform", "node_modules", "node-pty"),
2660
+ join(csmDir, "node_modules", "node-pty"),
2661
+ ];
2662
+ const nodePtyDir = nodePtyCandidates.find((d) => existsSync(d));
2663
+ if (!nodePtyDir) {
2664
+ throw new Error(`node-pty not found after install (looked in ${nodePtyCandidates.join(", ")}). ` +
2665
+ `The platform / claude-session-manager npm install did not place it.`);
2666
+ }
2667
+ console.log(` Rebuilding node-pty native addon (darwin, ${nodePtyDir})...`);
2668
+ const rebuilt = spawnSync("npx", ["--yes", "node-gyp", "rebuild"], { cwd: nodePtyDir, stdio: "inherit", timeout: 300_000 });
2669
+ const nodePtyRelease = join(nodePtyDir, "build", "Release");
2670
+ const ptyNode = join(nodePtyRelease, "pty.node");
2671
+ const spawnHelper = join(nodePtyRelease, "spawn-helper");
2672
+ const built = existsSync(ptyNode) && existsSync(spawnHelper);
2673
+ logFile(` [create-maxy] node-pty build=${built ? "rebuilt" : "failed"} exit=${rebuilt.status ?? "null"} dir=${nodePtyDir} pty.node=${existsSync(ptyNode)} spawn-helper=${existsSync(spawnHelper)}`);
2674
+ if (!built) {
2675
+ throw new Error(`node-pty native addon missing after node-gyp rebuild in ${nodePtyDir} ` +
2676
+ `(pty.node=${existsSync(ptyNode)} spawn-helper=${existsSync(spawnHelper)}). ` +
2677
+ `Install Xcode Command Line Tools (xcode-select --install) and re-run.`);
2678
+ }
2679
+ }
2545
2680
  }
2546
2681
  // (maxy-code, Task 677 P2) — whatsapp-channel service has its own
2547
2682
  // package.json declaring @modelcontextprotocol/sdk. It ships dist/ +
@@ -3203,8 +3338,20 @@ function installServiceDarwin() {
3203
3338
  envContent = envContent.trimEnd() + (envContent.length > 0 ? "\n" : "") + `${key}=${value}\n`;
3204
3339
  }
3205
3340
  }
3341
+ // Task 1395 gap 4 — pin the canonical claude path (resolved in phase 3) so
3342
+ // every runtime spawn under launchd uses the logged-in binary, not whichever
3343
+ // the bare launchd PATH resolves first. Written only when resolved; an unset
3344
+ // CLAUDE_BIN makes runtime callers fall back to bare `claude` on PATH.
3345
+ const canonicalClaude = RESOLVED_CLAUDE_BIN ?? resolveCanonicalClaudeBin();
3346
+ if (canonicalClaude) {
3347
+ const re = /^CLAUDE_BIN=.*$/m;
3348
+ if (re.test(envContent))
3349
+ envContent = envContent.replace(re, `CLAUDE_BIN=${canonicalClaude}`);
3350
+ else
3351
+ envContent = envContent.trimEnd() + (envContent.length > 0 ? "\n" : "") + `CLAUDE_BIN=${canonicalClaude}\n`;
3352
+ }
3206
3353
  writeFileSync(envPath, envContent);
3207
- logFile(` .env: DISPLAY_MODE=${DISPLAY_MODE}, EMBED_MODEL=${EMBED_MODEL}, EMBED_DIMENSIONS=${EMBED_DIMS}, NEO4J_URI=bolt://localhost:${NEO4J_PORT}, PORT=${PORT}, MAXY_UI_INTERNAL_PORT=${PORT} (darwin-collapsed), CLAUDE_SESSION_MANAGER_PORT=${BRAND.claudeSessionManagerPort}, HOSTNAME=0.0.0.0, ACCOUNT_ID=${installAccountId.slice(0, 8)}…, CLAUDE_CONFIG_DIR=${persistDir}/.claude`);
3354
+ logFile(` .env: DISPLAY_MODE=${DISPLAY_MODE}, EMBED_MODEL=${EMBED_MODEL}, EMBED_DIMENSIONS=${EMBED_DIMS}, NEO4J_URI=bolt://localhost:${NEO4J_PORT}, PORT=${PORT}, MAXY_UI_INTERNAL_PORT=${PORT} (darwin-collapsed), CLAUDE_SESSION_MANAGER_PORT=${BRAND.claudeSessionManagerPort}, HOSTNAME=0.0.0.0, ACCOUNT_ID=${installAccountId.slice(0, 8)}…, CLAUDE_CONFIG_DIR=${persistDir}/.claude, CLAUDE_BIN=${canonicalClaude ?? "unresolved(PATH)"}`);
3208
3355
  }
3209
3356
  catch (err) {
3210
3357
  console.error(` WARNING: failed to write .env to ${envPath}: ${err instanceof Error ? err.message : String(err)}`);
@@ -3266,6 +3413,66 @@ function installServiceDarwin() {
3266
3413
  logFile(` [create-maxy] launchd-plist=${path} loaded=false exit=${bootstrap.status}`);
3267
3414
  throw new Error(`launchctl bootstrap ${gui()} ${path} failed (exit ${bootstrap.status}): ${stderr}`);
3268
3415
  }
3416
+ // Task 1395 gap 2 — the claude-session-manager. On Linux it is a separate
3417
+ // systemd user unit (buildClaudeSessionManagerUnitFile) the brand unit
3418
+ // Requires=; darwin authored no such unit, so nothing listened on the manager
3419
+ // port and every session spawn returned `manager-unreachable`. Author a second
3420
+ // LaunchAgent mirroring the Linux unit: a wrapper sources .env + the
3421
+ // manager-only vars, then execs `node dist/index.js`.
3422
+ const smLabel = `${launchdLabel()}-claude-session-manager`;
3423
+ const smPlistPath = join(launchAgentsDir(), `${smLabel}.plist`);
3424
+ const smWrapperPath = join(persistDir, "claude-session-manager-wrapper.sh");
3425
+ writeFileSync(smWrapperPath, buildSessionManagerWrapper({
3426
+ envPath,
3427
+ installDir: INSTALL_DIR,
3428
+ nodeBin,
3429
+ persistDir,
3430
+ brandPort: PORT,
3431
+ }));
3432
+ chmodSync(smWrapperPath, 0o755);
3433
+ const smPlist = renderPlist({
3434
+ label: smLabel,
3435
+ programArguments: ["/bin/bash", smWrapperPath],
3436
+ stdoutPath: join(logsDir, "server.log"),
3437
+ stderrPath: join(logsDir, "server.log"),
3438
+ keepAlive: true,
3439
+ runAtLoad: true,
3440
+ workingDirectory: `${INSTALL_DIR}/platform/services/claude-session-manager`,
3441
+ });
3442
+ writeFileSync(smPlistPath, smPlist);
3443
+ logFile(` ${smPlistPath} written (${smPlist.length} bytes)`);
3444
+ spawnSync("launchctl", ["bootout", `${gui()}/${smLabel}`], { stdio: "pipe" });
3445
+ const smBootstrap = spawnSync("launchctl", ["bootstrap", gui(), smPlistPath], {
3446
+ stdio: "pipe",
3447
+ encoding: "utf-8",
3448
+ timeout: 15_000,
3449
+ });
3450
+ if (smBootstrap.status === 0) {
3451
+ console.log(` [launchd] bootstrap ${gui()}/${smLabel} ok`);
3452
+ logFile(` [create-maxy] launchd-plist=${smLabel} loaded=true`);
3453
+ }
3454
+ else {
3455
+ const smStderr = (smBootstrap.stderr ?? "").trim();
3456
+ console.error(` [launchd] bootstrap ${smLabel} returned ${smBootstrap.status}: ${smStderr}`);
3457
+ logFile(` [create-maxy] launchd-plist=${smLabel} loaded=false exit=${smBootstrap.status}`);
3458
+ throw new Error(`launchctl bootstrap ${smLabel} failed (exit ${smBootstrap.status}): ${smStderr}`);
3459
+ }
3460
+ // Health-probe the manager (/healthz) so a silent boot failure surfaces at
3461
+ // install time, not at first session spawn as `manager-unreachable`.
3462
+ console.log(" Waiting for session manager...");
3463
+ let smUp = false;
3464
+ for (let i = 0; i < 15; i++) {
3465
+ const probe = spawnSync("curl", ["-sf", `http://127.0.0.1:${BRAND.claudeSessionManagerPort}/healthz`, "-o", "/dev/null"], { timeout: 3000 });
3466
+ if (probe.status === 0) {
3467
+ smUp = true;
3468
+ break;
3469
+ }
3470
+ spawnSync("sleep", ["2"]);
3471
+ }
3472
+ logFile(` [create-maxy] session-manager-health=${smUp ? "ok" : "timeout"} port=${BRAND.claudeSessionManagerPort}`);
3473
+ if (!smUp) {
3474
+ console.log(` Session manager not yet healthy on ${BRAND.claudeSessionManagerPort} — check ${join(logsDir, "server.log")}.`);
3475
+ }
3269
3476
  // Wait for the server to come up.
3270
3477
  console.log(" Waiting for web server...");
3271
3478
  let webServerUp = false;