@rubytech/create-maxy-code 0.1.320 → 0.1.322

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 (71) hide show
  1. package/dist/index.js +82 -1
  2. package/package.json +1 -1
  3. package/payload/platform/lib/embed-client/dist/index.d.ts +2 -0
  4. package/payload/platform/lib/embed-client/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/embed-client/dist/index.js +50 -10
  6. package/payload/platform/lib/embed-client/dist/index.js.map +1 -1
  7. package/payload/platform/lib/embed-client/src/index.ts +57 -9
  8. package/payload/platform/lib/models/dist/index.d.ts +6 -0
  9. package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
  10. package/payload/platform/lib/models/dist/index.js +9 -0
  11. package/payload/platform/lib/models/dist/index.js.map +1 -1
  12. package/payload/platform/lib/models/src/index.ts +9 -0
  13. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -7
  14. package/payload/platform/plugins/docs/references/admin-ui.md +5 -4
  15. package/payload/platform/plugins/docs/references/deployment.md +1 -0
  16. package/payload/platform/plugins/docs/references/internals.md +2 -0
  17. package/payload/platform/plugins/docs/references/platform.md +1 -1
  18. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js +41 -2
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-body-server-sliced.test.js.map +1 -1
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  22. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +34 -4
  23. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/http-server.js +99 -8
  26. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +6 -0
  28. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +17 -3
  30. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +13 -0
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +24 -2
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +1 -25
  36. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +23 -95
  38. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/settings-permission-mode.d.ts +9 -0
  40. package/payload/platform/services/claude-session-manager/dist/settings-permission-mode.d.ts.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/settings-permission-mode.js +29 -0
  42. package/payload/platform/services/claude-session-manager/dist/settings-permission-mode.js.map +1 -1
  43. package/payload/server/public/assets/{AdminLoginScreens-D_e5PYXv.js → AdminLoginScreens-D1X4M9wX.js} +1 -1
  44. package/payload/server/public/assets/AdminShell-BGZ3d3gY.js +1 -0
  45. package/payload/server/public/assets/{Checkbox-BHxSr_cA.js → Checkbox-DjANdKoK.js} +1 -1
  46. package/payload/server/public/assets/OperatorConversations-BkitSIiT.js +9 -0
  47. package/payload/server/public/assets/{OperatorConversations-pu50BU11.css → OperatorConversations-C-eFXT7w.css} +1 -1
  48. package/payload/server/public/assets/admin--FlkQOMu.js +1 -0
  49. package/payload/server/public/assets/{browser-C1Dp1UxB.js → browser-vowQc0TJ.js} +1 -1
  50. package/payload/server/public/assets/chat-Cw6rUWiC.js +1 -0
  51. package/payload/server/public/assets/data-9ArhwX1q.js +1 -0
  52. package/payload/server/public/assets/{graph-ticN9QbK.js → graph-B-64Cq-y.js} +3 -3
  53. package/payload/server/public/assets/{graph-labels-C2r7zl8O.js → graph-labels-Dtd2ckXH.js} +1 -1
  54. package/payload/server/public/assets/operator-Cqn1Ne60.js +1 -0
  55. package/payload/server/public/assets/page-DQ5D6Dg9.js +30 -0
  56. package/payload/server/public/assets/{public-CjT_WNkz.js → public-CrFepd06.js} +1 -1
  57. package/payload/server/public/browser.html +4 -4
  58. package/payload/server/public/chat.html +5 -5
  59. package/payload/server/public/data.html +4 -4
  60. package/payload/server/public/graph.html +6 -6
  61. package/payload/server/public/index.html +6 -6
  62. package/payload/server/public/operator.html +7 -7
  63. package/payload/server/public/public.html +5 -5
  64. package/payload/server/server.js +209 -14
  65. package/payload/server/public/assets/AdminShell-Dc23yALw.js +0 -1
  66. package/payload/server/public/assets/OperatorConversations-BqNOXEu8.js +0 -9
  67. package/payload/server/public/assets/admin-DE55chV9.js +0 -1
  68. package/payload/server/public/assets/chat-BLQDFyDL.js +0 -1
  69. package/payload/server/public/assets/data-D_L6g-L_.js +0 -1
  70. package/payload/server/public/assets/operator-COIYJFG7.js +0 -1
  71. package/payload/server/public/assets/page-CsOGSSSb.js +0 -30
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
20
20
  import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, } from "./lib/plugin-install.js";
21
21
  import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
22
22
  import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
23
- import { networkInterfaces, userInfo } from "node:os";
23
+ import { networkInterfaces, userInfo, cpus } from "node:os";
24
24
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
25
25
  // Brand manifest — read from payload to derive all brand-specific installation values.
26
26
  // The bundler stamps brand.json into the payload at build time.
@@ -1557,6 +1557,84 @@ function ensureOllamaServing() {
1557
1557
  logFile(` Ollama serve timed out after 30s. Log tail:\n${serveLog}`);
1558
1558
  throw new Error(`Ollama server did not respond within 30s. Log: ${ollamaLog}\n${serveLog}`);
1559
1559
  }
1560
+ // Cap ollama's CPU so an embedding burst cannot thermally hard-reset a
1561
+ // passively-cooled Pi. The platform embeds a document's summary plus every
1562
+ // section body via one `/api/embed` POST; ollama's llama-server then runs
1563
+ // flat-out across every core for the whole batch with no idle gaps, and on a
1564
+ // passively-cooled Pi 5 the SoC climbs past its soft-temp limit until the board
1565
+ // reaches its thermal ceiling and hard-resets. A systemd `CPUQuota` drop-in on
1566
+ // `ollama.service` bounds llama-server to a fraction of the cores so peak
1567
+ // temperature plateaus below the limit regardless of batch shape or which
1568
+ // platform path issues the embeds — the caller-agnostic fix, inherited by every
1569
+ // install. Quota is half the cores (rounded down, min 1) so at least half stay
1570
+ // idle for the thermal budget: 200% on a 4-core Pi. Applied only when the ollama
1571
+ // system service exists (the normal Linux path, where ollama's install.sh
1572
+ // registers `ollama.service`); a detached `ollama serve` has no unit to cap.
1573
+ // ollama is one shared host service across co-resident brands, so the drop-in is
1574
+ // host-wide with a neutral filename — idempotent across brands and reinstalls,
1575
+ // and ollama restarts only when the drop-in content actually changes. Best-
1576
+ // effort: a failure warns, never aborts the install.
1577
+ function capOllamaServiceCpu() {
1578
+ try {
1579
+ const svcCheck = spawnSync("systemctl", ["cat", "ollama"], { stdio: "pipe", timeout: 5_000 });
1580
+ if (svcCheck.status !== 0) {
1581
+ logFile(" ollama CPU cap: skipped (no ollama system service to cap)");
1582
+ return;
1583
+ }
1584
+ const coreCount = Math.max(1, cpus().length);
1585
+ const quotaCores = Math.max(1, Math.floor(coreCount / 2));
1586
+ const quotaPct = quotaCores * 100;
1587
+ const desired = `[Service]\nCPUQuota=${quotaPct}%\n`;
1588
+ const dropinDir = "/etc/systemd/system/ollama.service.d";
1589
+ const dropinPath = `${dropinDir}/10-maxy-cpuquota.conf`;
1590
+ let current = "";
1591
+ if (existsSync(dropinPath)) {
1592
+ try {
1593
+ current = readFileSync(dropinPath, "utf-8");
1594
+ }
1595
+ catch {
1596
+ // Unreadable existing drop-in — treat as changed so we rewrite + restart.
1597
+ current = "";
1598
+ }
1599
+ }
1600
+ if (current === desired) {
1601
+ logFile(` ollama CPU cap: already active (${dropinPath} matches CPUQuota=${quotaPct}%)`);
1602
+ return;
1603
+ }
1604
+ const tmpPath = "/tmp/10-maxy-ollama-cpuquota.conf";
1605
+ writeFileSync(tmpPath, desired);
1606
+ console.log(` [privileged] install ollama CPUQuota=${quotaPct}% drop-in (${quotaCores}/${coreCount} cores)`);
1607
+ shell("mkdir", ["-p", dropinDir], { sudo: true });
1608
+ shell("cp", [tmpPath, dropinPath], { sudo: true });
1609
+ spawnSync("rm", ["-f", tmpPath]);
1610
+ spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "inherit" });
1611
+ console.log(" [privileged] systemctl restart ollama");
1612
+ spawnSync("sudo", ["systemctl", "restart", "ollama"], { stdio: "inherit", timeout: 30_000 });
1613
+ // The restart briefly drops the API. Wait for ollama to serve again before
1614
+ // returning so the immediately-following ensureOllamaServing() probe finds
1615
+ // it up and does not race the restart by spawning a second detached
1616
+ // `ollama serve` that would contend for port 11434. Bounded poll; the drop-in
1617
+ // is already on disk either way, so a timeout still applies the cap on the
1618
+ // next ollama start.
1619
+ let served = false;
1620
+ for (let elapsed = 0; elapsed < 15; elapsed += 3) {
1621
+ if (spawnSync("curl", ["-sf", "http://localhost:11434/api/tags"], { stdio: "pipe", timeout: 5_000 }).status === 0) {
1622
+ served = true;
1623
+ break;
1624
+ }
1625
+ spawnSync("sleep", ["3"]);
1626
+ }
1627
+ if (served) {
1628
+ logFile(` ollama CPU cap: enabled (wrote ${dropinPath} CPUQuota=${quotaPct}%, ollama serving again under the cap)`);
1629
+ }
1630
+ else {
1631
+ logFile(` ollama CPU cap: drop-in written (${dropinPath} CPUQuota=${quotaPct}%); ollama not serving yet after restart — cap applies on its next start`);
1632
+ }
1633
+ }
1634
+ catch (err) {
1635
+ console.error(` WARNING: failed to cap ollama CPU: ${err instanceof Error ? err.message : String(err)}`);
1636
+ }
1637
+ }
1560
1638
  function installOllama(embedModel) {
1561
1639
  if (!commandExists("ollama")) {
1562
1640
  log("5", TOTAL, "Installing Ollama...");
@@ -1565,6 +1643,9 @@ function installOllama(embedModel) {
1565
1643
  else {
1566
1644
  log("5", TOTAL, "Ollama already installed.");
1567
1645
  }
1646
+ // Bound ollama's CPU before bringing the service up so the capped unit is what
1647
+ // serves embeds (and the restart below lands the new quota).
1648
+ capOllamaServiceCpu();
1568
1649
  ensureOllamaServing();
1569
1650
  console.log(` Pulling ${embedModel} embedding model...`);
1570
1651
  logFile(` Pulling embedding model: ${embedModel}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.320",
3
+ "version": "0.1.322",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,4 +1,6 @@
1
1
  export declare const EMBED_INPUT_MAX_CHARS = 8000;
2
+ export declare const EMBED_SUBBATCH_SIZE: number;
3
+ export declare const EMBED_SUBBATCH_COOLDOWN_MS: number;
2
4
  export declare function embed(text: string): Promise<number[]>;
3
5
  export declare function embedBatch(texts: string[]): Promise<number[][]>;
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAU1C,wBAAsB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAa3D;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAarE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAmB1C,eAAO,MAAM,mBAAmB,QAA6D,CAAC;AAC9F,eAAO,MAAM,0BAA0B,QAGtC,CAAC;AA4BF,wBAAsB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAa3D;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAoBrE"}
@@ -6,7 +6,7 @@
6
6
  // and one POST shape across the platform (Task 636). fetch-only — no node:fs —
7
7
  // so it is safe to bundle into the ESM payload.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.EMBED_INPUT_MAX_CHARS = void 0;
9
+ exports.EMBED_SUBBATCH_COOLDOWN_MS = exports.EMBED_SUBBATCH_SIZE = exports.EMBED_INPUT_MAX_CHARS = void 0;
10
10
  exports.embed = embed;
11
11
  exports.embedBatch = embedBatch;
12
12
  const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
@@ -16,28 +16,49 @@ const EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";
16
16
  // an oversized body embeds (truncated) instead of throwing and aborting the
17
17
  // caller.
18
18
  exports.EMBED_INPUT_MAX_CHARS = 8000;
19
+ // Batch pacing (Task 960). On a passively-cooled Pi, embedding a whole
20
+ // document's sections in one `/api/embed` POST pins ollama's llama-server
21
+ // across every core for the entire batch with no idle gaps — the SoC climbs
22
+ // past its soft-temp limit and the board thermally hard-resets. embedBatch
23
+ // splits an oversized input array into sub-batches and waits a cooldown between
24
+ // them so the SoC gets cooling gaps. Centralising here means every batch caller
25
+ // (memory-ingest, memory-archive-write, memory-reindex) inherits the pacing.
26
+ // The systemd CPUQuota cap on ollama.service is the load-bearing thermal fix;
27
+ // this pacing is defence-in-depth and faster recovery. Both knobs are
28
+ // env-overridable so an operator can tune them from on-device thermal
29
+ // measurement without a code change.
30
+ function envInt(name, def) {
31
+ const raw = process.env[name];
32
+ if (raw === undefined || raw === "")
33
+ return def;
34
+ const n = Number(raw);
35
+ return Number.isFinite(n) ? n : def;
36
+ }
37
+ exports.EMBED_SUBBATCH_SIZE = Math.max(1, Math.floor(envInt("EMBED_SUBBATCH_SIZE", 32)));
38
+ exports.EMBED_SUBBATCH_COOLDOWN_MS = Math.max(0, envInt("EMBED_SUBBATCH_COOLDOWN_MS", 1000));
19
39
  function capInput(text) {
20
40
  if (text.length <= exports.EMBED_INPUT_MAX_CHARS)
21
41
  return text;
22
42
  process.stderr.write(`[embed] op=truncate origBytes=${text.length} cappedBytes=${exports.EMBED_INPUT_MAX_CHARS}\n`);
23
43
  return text.slice(0, exports.EMBED_INPUT_MAX_CHARS);
24
44
  }
25
- async function embed(text) {
26
- const input = capInput(text);
45
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+ // POST one already-capped sub-batch of inputs to the embed endpoint.
47
+ async function postBatch(inputs) {
27
48
  const res = await fetch(`${OLLAMA_URL}/api/embed`, {
28
49
  method: "POST",
29
50
  headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify({ model: EMBED_MODEL, input }),
51
+ body: JSON.stringify({ model: EMBED_MODEL, input: inputs }),
31
52
  });
32
53
  if (!res.ok) {
33
54
  const body = await res.text();
34
- throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
55
+ throw new Error(`Ollama batch embedding failed (${res.status}): ${body}`);
35
56
  }
36
57
  const data = (await res.json());
37
- return data.embeddings[0];
58
+ return data.embeddings;
38
59
  }
39
- async function embedBatch(texts) {
40
- const input = texts.map(capInput);
60
+ async function embed(text) {
61
+ const input = capInput(text);
41
62
  const res = await fetch(`${OLLAMA_URL}/api/embed`, {
42
63
  method: "POST",
43
64
  headers: { "Content-Type": "application/json" },
@@ -45,9 +66,28 @@ async function embedBatch(texts) {
45
66
  });
46
67
  if (!res.ok) {
47
68
  const body = await res.text();
48
- throw new Error(`Ollama batch embedding failed (${res.status}): ${body}`);
69
+ throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
49
70
  }
50
71
  const data = (await res.json());
51
- return data.embeddings;
72
+ return data.embeddings[0];
73
+ }
74
+ async function embedBatch(texts) {
75
+ const inputs = texts.map(capInput);
76
+ // Common case — an array within the sub-batch size is one POST, unchanged.
77
+ if (inputs.length <= exports.EMBED_SUBBATCH_SIZE) {
78
+ return postBatch(inputs);
79
+ }
80
+ // Oversized: split into sub-batches, cooling between each (not after the
81
+ // last). Vectors are concatenated in input order so callers can keep their
82
+ // index-aligned `embeddings[i]` ↔ `texts[i]` contract.
83
+ const out = [];
84
+ for (let i = 0; i < inputs.length; i += exports.EMBED_SUBBATCH_SIZE) {
85
+ if (i > 0 && exports.EMBED_SUBBATCH_COOLDOWN_MS > 0) {
86
+ await sleep(exports.EMBED_SUBBATCH_COOLDOWN_MS);
87
+ }
88
+ const chunk = inputs.slice(i, i + exports.EMBED_SUBBATCH_SIZE);
89
+ out.push(...(await postBatch(chunk)));
90
+ }
91
+ return out;
52
92
  }
53
93
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,yEAAyE;AACzE,6EAA6E;AAC7E,2EAA2E;AAC3E,6EAA6E;AAC7E,+EAA+E;AAC/E,gDAAgD;;;AAmBhD,sBAaC;AAED,gCAaC;AA7CD,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,wBAAwB,CAAC;AACtE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,kBAAkB,CAAC;AAElE,2EAA2E;AAC3E,gFAAgF;AAChF,4EAA4E;AAC5E,UAAU;AACG,QAAA,qBAAqB,GAAG,IAAI,CAAC;AAE1C,SAAS,QAAQ,CAAC,IAAY;IAC5B,IAAI,IAAI,CAAC,MAAM,IAAI,6BAAqB;QAAE,OAAO,IAAI,CAAC;IACtD,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iCAAiC,IAAI,CAAC,MAAM,gBAAgB,6BAAqB,IAAI,CACtF,CAAC;IACF,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,6BAAqB,CAAC,CAAC;AAC9C,CAAC;AAEM,KAAK,UAAU,KAAK,CAAC,IAAY;IACtC,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC7B,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,UAAU,YAAY,EAAE;QACjD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;KACpD,CAAC,CAAC;IACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,4BAA4B,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;IAC9D,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;AAC5B,CAAC;AAEM,KAAK,UAAU,UAAU,CAAC,KAAe;IAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAClC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,UAAU,YAAY,EAAE;QACjD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;KACpD,CAAC,CAAC;IACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,kCAAkC,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;IAC9D,OAAO,IAAI,CAAC,UAAU,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,yEAAyE;AACzE,6EAA6E;AAC7E,2EAA2E;AAC3E,6EAA6E;AAC7E,+EAA+E;AAC/E,gDAAgD;;;AA4DhD,sBAaC;AAED,gCAoBC;AA7FD,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,wBAAwB,CAAC;AACtE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,kBAAkB,CAAC;AAElE,2EAA2E;AAC3E,gFAAgF;AAChF,4EAA4E;AAC5E,UAAU;AACG,QAAA,qBAAqB,GAAG,IAAI,CAAC;AAE1C,uEAAuE;AACvE,0EAA0E;AAC1E,4EAA4E;AAC5E,2EAA2E;AAC3E,gFAAgF;AAChF,gFAAgF;AAChF,6EAA6E;AAC7E,8EAA8E;AAC9E,sEAAsE;AACtE,sEAAsE;AACtE,qCAAqC;AACrC,SAAS,MAAM,CAAC,IAAY,EAAE,GAAW;IACvC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC;IAChD,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACtB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;AACtC,CAAC;AACY,QAAA,mBAAmB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;AACjF,QAAA,0BAA0B,GAAG,IAAI,CAAC,GAAG,CAChD,CAAC,EACD,MAAM,CAAC,4BAA4B,EAAE,IAAI,CAAC,CAC3C,CAAC;AAEF,SAAS,QAAQ,CAAC,IAAY;IAC5B,IAAI,IAAI,CAAC,MAAM,IAAI,6BAAqB;QAAE,OAAO,IAAI,CAAC;IACtD,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iCAAiC,IAAI,CAAC,MAAM,gBAAgB,6BAAqB,IAAI,CACtF,CAAC;IACF,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,6BAAqB,CAAC,CAAC;AAC9C,CAAC;AAED,MAAM,KAAK,GAAG,CAAC,EAAU,EAAiB,EAAE,CAC1C,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAEpD,qEAAqE;AACrE,KAAK,UAAU,SAAS,CAAC,MAAgB;IACvC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,UAAU,YAAY,EAAE;QACjD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;KAC5D,CAAC,CAAC;IACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,kCAAkC,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;IAC9D,OAAO,IAAI,CAAC,UAAU,CAAC;AACzB,CAAC;AAEM,KAAK,UAAU,KAAK,CAAC,IAAY;IACtC,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC7B,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,UAAU,YAAY,EAAE;QACjD,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;KACpD,CAAC,CAAC;IACH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,4BAA4B,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;IAC9D,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;AAC5B,CAAC;AAEM,KAAK,UAAU,UAAU,CAAC,KAAe;IAC9C,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAEnC,2EAA2E;IAC3E,IAAI,MAAM,CAAC,MAAM,IAAI,2BAAmB,EAAE,CAAC;QACzC,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC;IAC3B,CAAC;IAED,yEAAyE;IACzE,2EAA2E;IAC3E,uDAAuD;IACvD,MAAM,GAAG,GAAe,EAAE,CAAC;IAC3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,2BAAmB,EAAE,CAAC;QAC5D,IAAI,CAAC,GAAG,CAAC,IAAI,kCAA0B,GAAG,CAAC,EAAE,CAAC;YAC5C,MAAM,KAAK,CAAC,kCAA0B,CAAC,CAAC;QAC1C,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,2BAAmB,CAAC,CAAC;QACvD,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACxC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -14,6 +14,29 @@ const EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";
14
14
  // caller.
15
15
  export const EMBED_INPUT_MAX_CHARS = 8000;
16
16
 
17
+ // Batch pacing (Task 960). On a passively-cooled Pi, embedding a whole
18
+ // document's sections in one `/api/embed` POST pins ollama's llama-server
19
+ // across every core for the entire batch with no idle gaps — the SoC climbs
20
+ // past its soft-temp limit and the board thermally hard-resets. embedBatch
21
+ // splits an oversized input array into sub-batches and waits a cooldown between
22
+ // them so the SoC gets cooling gaps. Centralising here means every batch caller
23
+ // (memory-ingest, memory-archive-write, memory-reindex) inherits the pacing.
24
+ // The systemd CPUQuota cap on ollama.service is the load-bearing thermal fix;
25
+ // this pacing is defence-in-depth and faster recovery. Both knobs are
26
+ // env-overridable so an operator can tune them from on-device thermal
27
+ // measurement without a code change.
28
+ function envInt(name: string, def: number): number {
29
+ const raw = process.env[name];
30
+ if (raw === undefined || raw === "") return def;
31
+ const n = Number(raw);
32
+ return Number.isFinite(n) ? n : def;
33
+ }
34
+ export const EMBED_SUBBATCH_SIZE = Math.max(1, Math.floor(envInt("EMBED_SUBBATCH_SIZE", 32)));
35
+ export const EMBED_SUBBATCH_COOLDOWN_MS = Math.max(
36
+ 0,
37
+ envInt("EMBED_SUBBATCH_COOLDOWN_MS", 1000),
38
+ );
39
+
17
40
  function capInput(text: string): string {
18
41
  if (text.length <= EMBED_INPUT_MAX_CHARS) return text;
19
42
  process.stderr.write(
@@ -22,23 +45,26 @@ function capInput(text: string): string {
22
45
  return text.slice(0, EMBED_INPUT_MAX_CHARS);
23
46
  }
24
47
 
25
- export async function embed(text: string): Promise<number[]> {
26
- const input = capInput(text);
48
+ const sleep = (ms: number): Promise<void> =>
49
+ new Promise((resolve) => setTimeout(resolve, ms));
50
+
51
+ // POST one already-capped sub-batch of inputs to the embed endpoint.
52
+ async function postBatch(inputs: string[]): Promise<number[][]> {
27
53
  const res = await fetch(`${OLLAMA_URL}/api/embed`, {
28
54
  method: "POST",
29
55
  headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify({ model: EMBED_MODEL, input }),
56
+ body: JSON.stringify({ model: EMBED_MODEL, input: inputs }),
31
57
  });
32
58
  if (!res.ok) {
33
59
  const body = await res.text();
34
- throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
60
+ throw new Error(`Ollama batch embedding failed (${res.status}): ${body}`);
35
61
  }
36
62
  const data = (await res.json()) as { embeddings: number[][] };
37
- return data.embeddings[0];
63
+ return data.embeddings;
38
64
  }
39
65
 
40
- export async function embedBatch(texts: string[]): Promise<number[][]> {
41
- const input = texts.map(capInput);
66
+ export async function embed(text: string): Promise<number[]> {
67
+ const input = capInput(text);
42
68
  const res = await fetch(`${OLLAMA_URL}/api/embed`, {
43
69
  method: "POST",
44
70
  headers: { "Content-Type": "application/json" },
@@ -46,8 +72,30 @@ export async function embedBatch(texts: string[]): Promise<number[][]> {
46
72
  });
47
73
  if (!res.ok) {
48
74
  const body = await res.text();
49
- throw new Error(`Ollama batch embedding failed (${res.status}): ${body}`);
75
+ throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
50
76
  }
51
77
  const data = (await res.json()) as { embeddings: number[][] };
52
- return data.embeddings;
78
+ return data.embeddings[0];
79
+ }
80
+
81
+ export async function embedBatch(texts: string[]): Promise<number[][]> {
82
+ const inputs = texts.map(capInput);
83
+
84
+ // Common case — an array within the sub-batch size is one POST, unchanged.
85
+ if (inputs.length <= EMBED_SUBBATCH_SIZE) {
86
+ return postBatch(inputs);
87
+ }
88
+
89
+ // Oversized: split into sub-batches, cooling between each (not after the
90
+ // last). Vectors are concatenated in input order so callers can keep their
91
+ // index-aligned `embeddings[i]` ↔ `texts[i]` contract.
92
+ const out: number[][] = [];
93
+ for (let i = 0; i < inputs.length; i += EMBED_SUBBATCH_SIZE) {
94
+ if (i > 0 && EMBED_SUBBATCH_COOLDOWN_MS > 0) {
95
+ await sleep(EMBED_SUBBATCH_COOLDOWN_MS);
96
+ }
97
+ const chunk = inputs.slice(i, i + EMBED_SUBBATCH_SIZE);
98
+ out.push(...(await postBatch(chunk)));
99
+ }
100
+ return out;
53
101
  }
@@ -24,6 +24,12 @@ export declare function isSelectableModel(id: string): boolean;
24
24
  * real change from the running one — a raw `selected !== live` compare is
25
25
  * always true for `[1m]` models. */
26
26
  export declare function baseModelId(id: string): string;
27
+ /** Map a model id (often the bare live id without `[1m]`) to its canonical
28
+ * *selectable* id by base-id match; returns the input unchanged when no
29
+ * selectable id shares its base. Lets the composer read-only row and the
30
+ * context-window lookup resolve a live `claude-opus-4-8` back to
31
+ * `claude-opus-4-8[1m]` (display name + 1M window). */
32
+ export declare function canonicalModelId(id: string): string;
27
33
  /** The 5 permission modes the /chat composer and re-seat fork may write. */
28
34
  export declare const PERMISSION_MODES: readonly ["default", "acceptEdits", "plan", "auto", "bypassPermissions"];
29
35
  export type PermissionMode = (typeof PERMISSION_MODES)[number];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,YAAY,sBAAsB,CAAC;AAChD,eAAO,MAAM,WAAW,qBAAqB,CAAC;AAE9C,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIvD,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;qEAEqE;AACrE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAItD,CAAC;AAEF;+DAC+D;AAC/D,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;mCAGmC;AACnC,eAAO,MAAM,iBAAiB,EAAE,SAAS,MAAM,EAA4C,CAAC;AAC5F,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;;qCAKqC;AACrC,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE9C;AAUD,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,0EAA2E,CAAC;AACzG,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAC/D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,cAAc,CAEvE;AAED,sDAAsD;AACtD,eAAO,MAAM,aAAa,6CAA8C,CAAC;AACzE,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AACzD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,WAAW,CAEjE;AASD;kFACkF;AAClF,eAAO,MAAM,sBAAsB,qFAA4C,CAAC;AAChF,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E;oEACoE;AACpE,eAAO,MAAM,qBAAqB,4DAA6C,CAAC;AAChF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,YAAY,sBAAsB,CAAC;AAChD,eAAO,MAAM,WAAW,qBAAqB,CAAC;AAE9C,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIvD,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;qEAEqE;AACrE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAItD,CAAC;AAEF;+DAC+D;AAC/D,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;mCAGmC;AACnC,eAAO,MAAM,iBAAiB,EAAE,SAAS,MAAM,EAA4C,CAAC;AAC5F,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;;qCAKqC;AACrC,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;;wDAIwD;AACxD,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEnD;AAUD,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,0EAA2E,CAAC;AACzG,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAC/D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,cAAc,CAEvE;AAED,sDAAsD;AACtD,eAAO,MAAM,aAAa,6CAA8C,CAAC;AACzE,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AACzD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,WAAW,CAEjE;AASD;kFACkF;AAClF,eAAO,MAAM,sBAAsB,qFAA4C,CAAC;AAChF,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E;oEACoE;AACpE,eAAO,MAAM,qBAAqB,4DAA6C,CAAC;AAChF,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -8,6 +8,7 @@ exports.contextWindow = contextWindow;
8
8
  exports.modelDisplayName = modelDisplayName;
9
9
  exports.isSelectableModel = isSelectableModel;
10
10
  exports.baseModelId = baseModelId;
11
+ exports.canonicalModelId = canonicalModelId;
11
12
  exports.isPermissionMode = isPermissionMode;
12
13
  exports.isEffortLevel = isEffortLevel;
13
14
  // Opus defaults to the 1M-context variant ([1m] is Claude Code's
@@ -55,6 +56,14 @@ function isSelectableModel(id) {
55
56
  function baseModelId(id) {
56
57
  return id.replace(/\[1m\]$/, "");
57
58
  }
59
+ /** Map a model id (often the bare live id without `[1m]`) to its canonical
60
+ * *selectable* id by base-id match; returns the input unchanged when no
61
+ * selectable id shares its base. Lets the composer read-only row and the
62
+ * context-window lookup resolve a live `claude-opus-4-8` back to
63
+ * `claude-opus-4-8[1m]` (display name + 1M window). */
64
+ function canonicalModelId(id) {
65
+ return exports.SELECTABLE_MODELS.find((m) => baseModelId(m) === baseModelId(id)) ?? id;
66
+ }
58
67
  // ── Protocol-level Claude Code lever enums ───────────────────────────────────
59
68
  // Two value sets tied to the Claude Code CLI: the composer-writable permission
60
69
  // modes and the settings.json effort levels. Single source of truth, imported
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,iEAAiE;AACjE,6CAA6C;;;AAgB7C,sCAEC;AAaD,4CAEC;AAOD,8CAEC;AAQD,kCAEC;AAaD,4CAEC;AAKD,sCAEC;AAxED,iEAAiE;AACjE,qEAAqE;AACrE,+DAA+D;AAClD,QAAA,UAAU,GAAG,qBAAqB,CAAC;AACnC,QAAA,YAAY,GAAG,mBAAmB,CAAC;AACnC,QAAA,WAAW,GAAG,kBAAkB,CAAC;AAEjC,QAAA,oBAAoB,GAA2B;IAC1D,CAAC,kBAAU,CAAC,EAAE,SAAS;IACvB,CAAC,oBAAY,CAAC,EAAE,OAAO;IACvB,CAAC,mBAAW,CAAC,EAAE,OAAO;CACvB,CAAC;AAEF,wEAAwE;AACxE,SAAgB,aAAa,CAAC,KAAa;IACzC,OAAO,4BAAoB,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC;AAChD,CAAC;AAED;;qEAEqE;AACxD,QAAA,mBAAmB,GAA2B;IACzD,CAAC,kBAAU,CAAC,EAAE,uBAAuB;IACrC,CAAC,oBAAY,CAAC,EAAE,YAAY;IAC5B,CAAC,mBAAW,CAAC,EAAE,WAAW;CAC3B,CAAC;AAEF;+DAC+D;AAC/D,SAAgB,gBAAgB,CAAC,EAAU;IACzC,OAAO,2BAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;AACvC,CAAC;AAED;;;mCAGmC;AACtB,QAAA,iBAAiB,GAAsB,CAAC,kBAAU,EAAE,oBAAY,EAAE,mBAAW,CAAC,CAAC;AAC5F,SAAgB,iBAAiB,CAAC,EAAU;IAC1C,OAAO,yBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;AACxC,CAAC;AAED;;;;;qCAKqC;AACrC,SAAgB,WAAW,CAAC,EAAU;IACpC,OAAO,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;AACnC,CAAC;AAED,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAEhE,4EAA4E;AAC/D,QAAA,gBAAgB,GAAG,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,CAAU,CAAC;AAEzG,SAAgB,gBAAgB,CAAC,KAAa;IAC5C,OAAQ,wBAAsC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AACjE,CAAC;AAED,sDAAsD;AACzC,QAAA,aAAa,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAU,CAAC;AAEzE,SAAgB,aAAa,CAAC,KAAa;IACzC,OAAQ,qBAAmC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAC9D,CAAC;AAED,gFAAgF;AAChF,iFAAiF;AACjF,0EAA0E;AAC1E,gFAAgF;AAChF,6EAA6E;AAC7E,iFAAiF;AAEjF;kFACkF;AACrE,QAAA,sBAAsB,GAAG,CAAC,GAAG,wBAAgB,EAAE,SAAS,CAAU,CAAC;AAGhF;oEACoE;AACvD,QAAA,qBAAqB,GAAG,CAAC,MAAM,EAAE,GAAG,qBAAa,EAAE,KAAK,CAAU,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,iEAAiE;AACjE,6CAA6C;;;AAgB7C,sCAEC;AAaD,4CAEC;AAOD,8CAEC;AAQD,kCAEC;AAOD,4CAEC;AAaD,4CAEC;AAKD,sCAEC;AAjFD,iEAAiE;AACjE,qEAAqE;AACrE,+DAA+D;AAClD,QAAA,UAAU,GAAG,qBAAqB,CAAC;AACnC,QAAA,YAAY,GAAG,mBAAmB,CAAC;AACnC,QAAA,WAAW,GAAG,kBAAkB,CAAC;AAEjC,QAAA,oBAAoB,GAA2B;IAC1D,CAAC,kBAAU,CAAC,EAAE,SAAS;IACvB,CAAC,oBAAY,CAAC,EAAE,OAAO;IACvB,CAAC,mBAAW,CAAC,EAAE,OAAO;CACvB,CAAC;AAEF,wEAAwE;AACxE,SAAgB,aAAa,CAAC,KAAa;IACzC,OAAO,4BAAoB,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC;AAChD,CAAC;AAED;;qEAEqE;AACxD,QAAA,mBAAmB,GAA2B;IACzD,CAAC,kBAAU,CAAC,EAAE,uBAAuB;IACrC,CAAC,oBAAY,CAAC,EAAE,YAAY;IAC5B,CAAC,mBAAW,CAAC,EAAE,WAAW;CAC3B,CAAC;AAEF;+DAC+D;AAC/D,SAAgB,gBAAgB,CAAC,EAAU;IACzC,OAAO,2BAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;AACvC,CAAC;AAED;;;mCAGmC;AACtB,QAAA,iBAAiB,GAAsB,CAAC,kBAAU,EAAE,oBAAY,EAAE,mBAAW,CAAC,CAAC;AAC5F,SAAgB,iBAAiB,CAAC,EAAU;IAC1C,OAAO,yBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;AACxC,CAAC;AAED;;;;;qCAKqC;AACrC,SAAgB,WAAW,CAAC,EAAU;IACpC,OAAO,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;AACnC,CAAC;AAED;;;;wDAIwD;AACxD,SAAgB,gBAAgB,CAAC,EAAU;IACzC,OAAO,yBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,WAAW,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;AACjF,CAAC;AAED,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAEhE,4EAA4E;AAC/D,QAAA,gBAAgB,GAAG,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,CAAU,CAAC;AAEzG,SAAgB,gBAAgB,CAAC,KAAa;IAC5C,OAAQ,wBAAsC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AACjE,CAAC;AAED,sDAAsD;AACzC,QAAA,aAAa,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAU,CAAC;AAEzE,SAAgB,aAAa,CAAC,KAAa;IACzC,OAAQ,qBAAmC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAC9D,CAAC;AAED,gFAAgF;AAChF,iFAAiF;AACjF,0EAA0E;AAC1E,gFAAgF;AAChF,6EAA6E;AAC7E,iFAAiF;AAEjF;kFACkF;AACrE,QAAA,sBAAsB,GAAG,CAAC,GAAG,wBAAgB,EAAE,SAAS,CAAU,CAAC;AAGhF;oEACoE;AACvD,QAAA,qBAAqB,GAAG,CAAC,MAAM,EAAE,GAAG,qBAAa,EAAE,KAAK,CAAU,CAAC"}
@@ -54,6 +54,15 @@ export function baseModelId(id: string): string {
54
54
  return id.replace(/\[1m\]$/, "");
55
55
  }
56
56
 
57
+ /** Map a model id (often the bare live id without `[1m]`) to its canonical
58
+ * *selectable* id by base-id match; returns the input unchanged when no
59
+ * selectable id shares its base. Lets the composer read-only row and the
60
+ * context-window lookup resolve a live `claude-opus-4-8` back to
61
+ * `claude-opus-4-8[1m]` (display name + 1M window). */
62
+ export function canonicalModelId(id: string): string {
63
+ return SELECTABLE_MODELS.find((m) => baseModelId(m) === baseModelId(id)) ?? id;
64
+ }
65
+
57
66
  // ── Protocol-level Claude Code lever enums ───────────────────────────────────
58
67
  // Two value sets tied to the Claude Code CLI: the composer-writable permission
59
68
  // modes and the settings.json effort levels. Single source of truth, imported
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:b03ac4f599a2e70871392e84a2f4eb8ca228961200f713c4dd1579aeccb07641
4
+ content-hash: sha256:009036cb2953a53e5a5d85f5cdf3399d4a7dd12c40fa17e03ebcb46ebeec87a7
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -210,7 +210,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
210
210
 
211
211
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
212
212
 
213
- The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude account, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
213
+ The admin interface is a three-pane layout: a sidebar on the left with navigation (Sessions, People, Agents, Projects, Tasks, Artefacts) and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu, holding the surface side-by-side with the conversation so the chat stays live while you work in it. At the very top of the sidebar — above the nav rows — a borderless row holds two controls: a "+ New session" button on the left that spawns a fresh Claude Code session, and a Mode trigger on the right showing the current permission mode (Ask, Accept edits, Plan, or Auto). The sidebar's vertical order is: new-session strip first, then the nav (Sessions, People, Agents, Projects, Tasks, Artefacts), then the sessions list, then the footer. Both controls render as plain text-plus-icon affordances with no surrounding rectangle. The "+ New session" button is a text-width hit target — its clickable area is exactly the icon plus label, not the whole row — and shows no hover fill; the only hover feedback is the pointer cursor. The Mode trigger is pushed flush to the right edge of the row. Clicking the Mode trigger opens a popover downward from the row whose header reads "Mode" and lists the four permission modes with the current selection check-marked. The sidebar's nav rows swap the list view in place: Sessions shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Each recent session row carries a three-state indicator: three pulsing dots when the session is busy (currently processing a turn), a solid sage dot when it is idle (live PTY waiting for input), and a hollow ring when it is archived (PTY exited, JSONL on disk for audit). The list itself splits into three views via a segmented control above the rows: **Active** shows every live session, **Archived** shows every JSONL on disk whose PTY has exited, and **All** shows both. The view choice persists across reloads. An "Include subagents" toggle inside the Active view surfaces specialist spawns (database-operator, premium-plugin agents, anything spawned with a `--agent` flag) which are hidden by default so the list reflects what you started directly. Each row also carries a small uppercase badge — `admin` for operator-driven sessions, the specialist name (for example `db-op`) for background work — so the source of any row is unambiguous at a glance. The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list, because the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable: type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood; clicking a second project swaps the focus rather than stacking on top. The sidebar's right edge is drag-resizable on every shell-hosted admin page (Sessions root, Chat, Graph, Browser, and Data): drag the handle to widen or narrow the sidebar, and your chosen width is remembered across reloads and shared across all of them. AdminShell mounts the drag handle itself, so every route that wraps its content in `<AdminShell>` gets sidebar resize for free; the handle does not render in the mobile drawer. The chat and artefact divider is also drag-resizable: drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat and artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On every viewport the chat header reads left to right as a triptych: a dedicated sidebar toggle (the panel-right icon, which swaps to panel-right-open when the sidebar is showing), the brand mark next to the title in the centre, and the menu burger on the right. This header toggle is the sole sidebar-toggle button; the sidebar itself no longer carries a duplicate. The burger opens the navigation flyout: Dashboard, Data, Graph, Browser, and Chat at the top — Dashboard routes back to `/`, so every satellite page keeps a one-click way home, and the Chat item carries the Claude mark rather than a generic icon — then Public (the agents list) between the two dividers, and below the second divider the version row, Disconnect Claude, and Log out. Tap the sidebar toggle to show or hide the sidebar: on phones (<720px) it slides the drawer in or out, on wider screens it collapses or expands the sidebar column. The brand mark in the centre is decorative; clicks go through the dedicated toggle so the affordance is unambiguous. The drawer animation only fires on tap (220ms slide in or out); resizing your window across the 720px boundary snaps the layout without animation, so you never see a half-open flash. At ≤640px the session metadata pane stacks each label above its value instead of the desktop two-column grid, and the row of action buttons (Open in new tab / Download JSONL / View JSONL / Rename / Pin / Archive / End or Purge) collapses behind a single Actions trigger that opens a popover upward from the foot of the pane. Breakpoint summary: >1280px = full sidebar + chat + artefact pane (drag-resizable divider); 1280px→1080px = sidebar narrows; 1080px→820px = artefact pane hides (Browser/Data/Graph open as full-window pages instead); 820px→720px = sidebar collapses to 56px icon rail; ≤720px = sidebar becomes off-canvas drawer (vertical stack of nav, recents list, foot, the same shape as the desktop sidebar, just on top of the chat instead of beside it).
214
214
 
215
215
  Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
216
216
 
@@ -2314,7 +2314,7 @@ either is a regression.
2314
2314
  | `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. The Sidebar navigates the opened tab to `claude.ai/code/session_<id>` on `outcome=bound`; on `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
2315
2315
  | `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
2316
2316
 
2317
- **Admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot; the reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. All three levers are now changeable from the `/chat` composer (the model is a friendly-labelled checkmark dropdown via `MODEL_DISPLAY_NAMES`, effort a Faster↔Smarter slider, mode a 5-mode checkmark dropdown), each writing its `account.json` key then `POST /reapply-levers` so the next spawn honours it; the full write path is in `.docs/admin-webchat-native-channel.md`. On an existing chat each picker re-seats the live session and carries the operator's current model+mode+effort so picking one never resets the others. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
2317
+ **Admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot; the reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. **These levers are fixed, not operator-chosen, for admin sessions:** the `/chat` composer carries no mode/model/effort pickers `rc-daemon.ts` pins settings.json `model`/`effortLevel`/`permissions.defaultMode` to `claude-opus-4-8[1m]` / `medium` / `bypassPermissions` regardless of `account.json` (read by the rc-spawn webchat path), and `pty-spawner.ts` forces the claude.ai/code PTY `--permission-mode` to `bypassPermissions` for `role=admin`. Public sessions are fixed to `claude-sonnet-4-6` (the `/public-spawn` chokepoint + `buildPublicWebchatSpawnRequest`), keeping `dontAsk`. The composer footer instead renders a **read-only** params row (mode · model · effort labels) plus a context-window usage figure (`pct% · ~used/~window`), all sourced from the session pointer; the full path is in `.docs/admin-webchat-native-channel.md`. The composer cannot re-seat a session, so the "no transcript" 400 on a brand-new chat is unreachable; the `/api/admin/session-reseat` + `/api/webchat/settings` routes and the dashboard re-seat form stay as off-composer surfaces. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
2318
2318
 
2319
2319
  **Row title resolution.** Both the sidebar (`/sidebar-sessions`) and the manager's own row payload resolve a row's title in the same order: operator rename → Claude Code `ai-title` → first non-CLI user message → 8-char sessionId prefix. The operator-rename tier is the on-disk `<accountDir>/session-titles.json` (the manager's `UserTitleStore`), keyed by the CC sessionId — the sidebar reads that same file, so a write to the store lights up both surfaces with one write.
2320
2320
 
@@ -2394,9 +2394,10 @@ level, and writes the file under its original basename. A flat upload (no
2394
2394
  **`/data` operator surface.** `/data` mounts the operator
2395
2395
  `HeaderMenu` (logo-left / name-centre / burger-right; flyout = Chat / Data /
2396
2396
  Log out) above the toolbar — the burger's Chat item returns to `/`, replacing
2397
- the former standalone Home button. The search bar spans the full toolbar width
2398
- with Refresh and Upload as inline icons at every width (the prior `≤600px`
2399
- ResizeObserver collapse is removed). File rows are two stacked full-width lines
2397
+ the former standalone Home button. The search bar flexes to fill its toolbar
2398
+ row; the four file actions (Refresh, Upload, New folder and the list↔grid view
2399
+ toggle) sit beside it as one uniformly styled icon group, collapsing under a
2400
+ single `⋯` overflow menu at `≤640px`. File rows are two stacked full-width lines
2400
2401
  (name, then `size · modified`) with no file-type icon, no hover download icon,
2401
2402
  and no per-row delete button; a folder row keeps its glyph and navigates on tap,
2402
2403
  a file row downloads on tap. **Long-press** a file row to enter multi-select:
@@ -2994,6 +2995,8 @@ If Ollama is unavailable at write time, the node is stored without an embedding
2994
2995
 
2995
2996
  The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`. It uses the bounded representation (the `summary` for document/archive labels, never the raw body), embeds in batches with a per-node fallback on batch error, and **skips and continues** past a failing node — keeping skipped nodes out of the re-fetch via an exclude set — instead of aborting the whole run. `embed()`/`embedBatch()` cap oversized input (`EMBED_INPUT_MAX_CHARS`) before the Ollama POST, so a large body embeds truncated rather than throwing. Per-node `[reindex] op=embedded|skip-error` lines and a terminal `[reindex] op=done processed=N skipped=M`.
2996
2997
 
2998
+ `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits an input array longer than `EMBED_SUBBATCH_SIZE` (default 32) into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. Both knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
2999
+
2997
3000
  ---
2998
3001
 
2999
3002
  ## Knowledge Document Hierarchy
@@ -3702,6 +3705,7 @@ A single Pi or laptop can host more than one brand (for example Maxy and Real Ag
3702
3705
  - **Brand-isolated Neo4j:** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
3703
3706
  - **Peer-aware system-unit guard:** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the earlier platform fixes reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged. The dedicated unit exports `NEO4J_HOME=<per-brand-data-dir>` alongside `NEO4J_CONF`, so `server.directories.run`, `server.directories.plugins`, and `server.directories.import` resolve per-brand — no collision with `/var/lib/neo4j/run/neo4j.pid`. The conf sed-overrides, mkdir-p, chown, and unit-write are idempotent and re-run on every install, so a host whose prior install left a broken unit recovers on retry.
3704
3707
  - **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
3708
+ - **Thermal cap on shared Ollama:** the installer writes a host-wide systemd `CPUQuota` drop-in (`/etc/systemd/system/ollama.service.d/10-maxy-cpuquota.conf`) bounding `ollama.service` to half the cores (200% on a 4-core Pi). An embedding burst — a document's summary plus every section body in one POST — otherwise pins `llama-server` across all cores with no idle gaps until a passively-cooled Pi crosses its soft-temp limit and hard-resets. The cap keeps peak temperature below the limit regardless of which brand or platform path issues the embeds. The drop-in has a neutral filename and is rewritten only when its content changes, so it is idempotent across brands and reinstalls. Applied only where the `ollama.service` system unit exists; a detached `ollama serve` has no unit to cap.
3705
3709
 
3706
3710
  To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:
3707
3711
 
@@ -3976,7 +3980,7 @@ tail -200 ~/.maxy/logs/maxy-ui.log | rg '\[remote-auth\].*resolvedKind='
3976
3980
  2. `diff <(jq .claudeAiOauth.accessToken ~/.maxy/.claude/.credentials.json) <(jq .claudeAiOauth.accessToken ~/.realagent/.claude/.credentials.json)` — must be non-empty after each brand's operator has run `claude /login` against distinct Anthropic accounts; if it's empty, both brands are still logged in to the same account (operator action, not a code bug).
3977
3981
  3. `grep "\[install\] claude-creds pickup" ~/.${brand}/logs/install-*.log` — fires once on the first install of any brand and moves the legacy `~/.claude/.credentials.json` into that brand's path. Subsequent brands install with no credentials and require a fresh `claude /login` inside that brand's chat (which writes to the brand-scoped path because the systemd unit env is in scope).
3978
3982
 
3979
- To deliberately disconnect the wrong account, use the header menu's **Disconnect Claude account** item (the supported path; no SSH or manual file deletion needed). It runs `claude auth logout`, then verifies the brand's `~/${brand.configDir}/.claude/.credentials.json` is actually gone before reporting success — a disconnect that left the credential in place reports failure in the menu rather than false-confirming. The server logs one line per attempt: `grep '\[onboarding\] op=claude-logout' ~/.${brand}/logs/server.log` — `credentialsPresent=true` after the call is a failed disconnect the operator may believe succeeded.
3983
+ To deliberately disconnect the wrong account, use the header menu's **Disconnect Claude** item (the supported path; no SSH or manual file deletion needed). It runs `claude auth logout`, then verifies the brand's `~/${brand.configDir}/.claude/.credentials.json` is actually gone before reporting success — a disconnect that left the credential in place reports failure in the menu rather than false-confirming. The server logs one line per attempt: `grep '\[onboarding\] op=claude-logout' ~/.${brand}/logs/server.log` — `credentialsPresent=true` after the call is a failed disconnect the operator may believe succeeded.
3980
3984
 
3981
3985
  **All sessions on the brand stopped responding after a token expiry.** Symptom on the operator side: every spawn dies at `pid-file-timeout` and the dashboard health probe reports auth dead. Diagnose the OAuth refresh path before anything else:
3982
3986
 
@@ -49,7 +49,7 @@ either is a regression.
49
49
  | `/session-rc-spawn` | POST `{ sessionId?, name? }`. Fire-and-forget `claude --remote-control [name] [--session-id <sid>]`. Present `sessionId` resumes; absent starts a fresh session (also used by the sidebar's "New session" button — it no longer opens claude.ai/code directly). Proxies to the manager's `/rc-spawn`, which waits up to **60 s** (raised from 12 s) for the spawned PTY to bind and returns `{ spawnedPid, sessionId, bridgeSessionId, slug, outcome, reason }`. The Sidebar navigates the opened tab to `claude.ai/code/session_<id>` on `outcome=bound`; on `timeout` or `spawn-failed` it shows an error modal (reason + sessionId) and **never** opens a bare claude.ai/code tab. The new process registers itself as its own Remote Control entry in claude.ai/code. | `POST /` |
50
50
  | `/claude-sessions` | **Spawn surface only**. `POST /` is the Sidebar new-session-with-prompt path, cookie-auth only (the recorder loopback caller was removed; LinkedIn ingest moved to `/rc-spawn`). The former UI-facing handlers (SSE row feed, list, resume, stop, rename, archive, delete, `/:id/meta`, `/:id/input`, `/:id/log`) were removed — the maxy dashboard no longer manages or displays sessions. | `POST /` |
51
51
 
52
- **Admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot; the reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. All three levers are now changeable from the `/chat` composer (the model is a friendly-labelled checkmark dropdown via `MODEL_DISPLAY_NAMES`, effort a Faster↔Smarter slider, mode a 5-mode checkmark dropdown), each writing its `account.json` key then `POST /reapply-levers` so the next spawn honours it; the full write path is in `.docs/admin-webchat-native-channel.md`. On an existing chat each picker re-seats the live session and carries the operator's current model+mode+effort so picking one never resets the others. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
52
+ **Admin session management moved entirely to claude's own interfaces** (claude.ai/code, claude desktop). A manager-owned per-account `claude rc --spawn same-dir` daemon registers the device as a Remote Control target there; the composer creates / resumes / stops / renames / archives / deletes sessions, with model + permission-mode applied at inception. The model lever is `account.json.adminModel` → `CLAUDE_CONFIG_DIR/settings.json "model"`, written by the daemon supervisor at boot; the reasoning-effort lever is `account.json.effort` → `settings.json "effortLevel"` (accepted values `low|medium|high|xhigh`; any other value, e.g. legacy `"auto"`, leaves the key unset so claude's own default governs), written by the same read-merge-write at boot. A third inception lever, permission mode, is added: `account.json.adminPermissionMode` → `settings.json "permissions.defaultMode"` (the 5 composer-writable modes `default|acceptEdits|plan|auto|bypassPermissions`; `dontAsk` is out of scope). Unlike model/effort (top-level keys), this lever **deep-merges** the nested `permissions.defaultMode`, owning only that key and preserving sibling `permissions` keys (allow/deny/…) — what the binary and the spawn lifeline actually read. **These levers are fixed, not operator-chosen, for admin sessions:** the `/chat` composer carries no mode/model/effort pickers `rc-daemon.ts` pins settings.json `model`/`effortLevel`/`permissions.defaultMode` to `claude-opus-4-8[1m]` / `medium` / `bypassPermissions` regardless of `account.json` (read by the rc-spawn webchat path), and `pty-spawner.ts` forces the claude.ai/code PTY `--permission-mode` to `bypassPermissions` for `role=admin`. Public sessions are fixed to `claude-sonnet-4-6` (the `/public-spawn` chokepoint + `buildPublicWebchatSpawnRequest`), keeping `dontAsk`. The composer footer instead renders a **read-only** params row (mode · model · effort labels) plus a context-window usage figure (`pct% · ~used/~window`), all sourced from the session pointer; the full path is in `.docs/admin-webchat-native-channel.md`. The composer cannot re-seat a session, so the "no transcript" 400 on a brand-new chat is unreachable; the `/api/admin/session-reseat` + `/api/webchat/settings` routes and the dashboard re-seat form stay as off-composer surfaces. When the live session runs in `default` ("Ask permissions") mode, a tool the agent attempts that needs approval surfaces an Allow/Deny card in the composer (tool, description, argument preview); a click sends the verdict over Claude Code's channel permission relay and the tool runs or is refused — webchat is the only interactive ask surface (WhatsApp stays text-only; Telegram has no agent channel). Beyond the composer, the maxy admin UI keeps a single "New session" link (`https://claude.ai/code`, opens in a new tab) and no separate session list, viewer, controls, or model/mode picker. The daemon supervisor lives at [`platform/services/claude-session-manager/src/rc-daemon.ts`](../../../services/claude-session-manager/src/rc-daemon.ts). The `/session-defaults` route and `SpawnPreference` node were deleted with the picker. `/new-session-failure`, `/new-session-submit`, and `/claude-capabilities` are now orphaned (consumed only by the deleted NewSessionModal) — see [`.tasks/501`](../../../.tasks/) for their removal.
53
53
 
54
54
  **Row title resolution.** Both the sidebar (`/sidebar-sessions`) and the manager's own row payload resolve a row's title in the same order: operator rename → Claude Code `ai-title` → first non-CLI user message → 8-char sessionId prefix. The operator-rename tier is the on-disk `<accountDir>/session-titles.json` (the manager's `UserTitleStore`), keyed by the CC sessionId — the sidebar reads that same file, so a write to the store lights up both surfaces with one write.
55
55
 
@@ -129,9 +129,10 @@ level, and writes the file under its original basename. A flat upload (no
129
129
  **`/data` operator surface.** `/data` mounts the operator
130
130
  `HeaderMenu` (logo-left / name-centre / burger-right; flyout = Chat / Data /
131
131
  Log out) above the toolbar — the burger's Chat item returns to `/`, replacing
132
- the former standalone Home button. The search bar spans the full toolbar width
133
- with Refresh and Upload as inline icons at every width (the prior `≤600px`
134
- ResizeObserver collapse is removed). File rows are two stacked full-width lines
132
+ the former standalone Home button. The search bar flexes to fill its toolbar
133
+ row; the four file actions (Refresh, Upload, New folder and the list↔grid view
134
+ toggle) sit beside it as one uniformly styled icon group, collapsing under a
135
+ single `⋯` overflow menu at `≤640px`. File rows are two stacked full-width lines
135
136
  (name, then `size · modified`) with no file-type icon, no hover download icon,
136
137
  and no per-row delete button; a folder row keeps its glyph and navigates on tap,
137
138
  a file row downloads on tap. **Long-press** a file row to enter multi-select:
@@ -253,6 +253,7 @@ A single Pi or laptop can host more than one brand (for example Maxy and Real Ag
253
253
  - **Brand-isolated Neo4j:** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
254
254
  - **Peer-aware system-unit guard:** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the earlier platform fixes reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged. The dedicated unit exports `NEO4J_HOME=<per-brand-data-dir>` alongside `NEO4J_CONF`, so `server.directories.run`, `server.directories.plugins`, and `server.directories.import` resolve per-brand — no collision with `/var/lib/neo4j/run/neo4j.pid`. The conf sed-overrides, mkdir-p, chown, and unit-write are idempotent and re-run on every install, so a host whose prior install left a broken unit recovers on retry.
255
255
  - **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
256
+ - **Thermal cap on shared Ollama:** the installer writes a host-wide systemd `CPUQuota` drop-in (`/etc/systemd/system/ollama.service.d/10-maxy-cpuquota.conf`) bounding `ollama.service` to half the cores (200% on a 4-core Pi). An embedding burst — a document's summary plus every section body in one POST — otherwise pins `llama-server` across all cores with no idle gaps until a passively-cooled Pi crosses its soft-temp limit and hard-resets. The cap keeps peak temperature below the limit regardless of which brand or platform path issues the embeds. The drop-in has a neutral filename and is rewritten only when its content changes, so it is idempotent across brands and reinstalls. Applied only where the `ollama.service` system unit exists; a detached `ollama serve` has no unit to cap.
256
257
 
257
258
  To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:
258
259
 
@@ -95,6 +95,8 @@ If Ollama is unavailable at write time, the node is stored without an embedding
95
95
 
96
96
  The `memory-reindex` tool backfills missing embeddings by iterating nodes where `embedding IS NULL`. It uses the bounded representation (the `summary` for document/archive labels, never the raw body), embeds in batches with a per-node fallback on batch error, and **skips and continues** past a failing node — keeping skipped nodes out of the re-fetch via an exclude set — instead of aborting the whole run. `embed()`/`embedBatch()` cap oversized input (`EMBED_INPUT_MAX_CHARS`) before the Ollama POST, so a large body embeds truncated rather than throwing. Per-node `[reindex] op=embedded|skip-error` lines and a terminal `[reindex] op=done processed=N skipped=M`.
97
97
 
98
+ `embedBatch()` also paces large batches. A document's summary plus every section body in one `/api/embed` POST pins ollama's `llama-server` across all cores for the whole batch with no idle gaps; on a passively-cooled Pi the SoC climbs past its soft-temp limit and the board thermally hard-resets. `embedBatch` splits an input array longer than `EMBED_SUBBATCH_SIZE` (default 32) into sub-batches, waiting `EMBED_SUBBATCH_COOLDOWN_MS` (default 1000) between them so the SoC gets cooling gaps; vectors are concatenated in input order. Both knobs are env-overridable for on-device tuning. The load-bearing thermal protection is the installer's `CPUQuota` cap on `ollama.service` (every embed caller is bounded there regardless of batch shape); this pacing is defence-in-depth. The UI's file-index path uses its own single-input embed client in `platform/ui/app/lib/neo4j-store.ts` (one embed per file, not a batch), so it is covered by the CPU cap, not by `embedBatch` pacing.
99
+
98
100
  ---
99
101
 
100
102
  ## Knowledge Document Hierarchy