@rubytech/create-maxy-code 0.1.276 → 0.1.277

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 (28) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
  3. package/payload/platform/plugins/docs/references/admin-identity-gate.md +44 -7
  4. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  5. package/payload/platform/scripts/check-no-esm-require.mjs +3 -0
  6. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +5 -0
  7. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/http-server.js +52 -10
  9. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/index.js +8 -0
  11. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +8 -0
  13. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
  14. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +13 -0
  15. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
  16. package/payload/platform/services/claude-session-manager/dist/wa-channel-store.d.ts +29 -0
  17. package/payload/platform/services/claude-session-manager/dist/wa-channel-store.d.ts.map +1 -0
  18. package/payload/platform/services/claude-session-manager/dist/wa-channel-store.js +124 -0
  19. package/payload/platform/services/claude-session-manager/dist/wa-channel-store.js.map +1 -0
  20. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +23 -0
  21. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  22. package/payload/platform/services/whatsapp-channel/dist/notification.js +26 -0
  23. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  24. package/payload/platform/services/whatsapp-channel/dist/server.js +41 -14
  25. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  26. package/payload/server/{chunk-W4EM7RK4.js → chunk-QHD5TKLQ.js} +2 -0
  27. package/payload/server/maxy-edge.js +1 -1
  28. package/payload/server/server.js +411 -172
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.276",
3
+ "version": "0.1.277",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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:0e8368ce205506dec22d5ed93f886c0cfc0f12b5306a6b953b4ddad19e8d3175
4
+ content-hash: sha256:0536d599c193b028cf47a9172677b87814e3e7af2e244051ede138fba340a40b
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2290,7 +2290,7 @@ Task 500 — **admin session management moved entirely to claude's own interface
2290
2290
 
2291
2291
  | Mount | Purpose |
2292
2292
  |---|---|
2293
- | `/sidebar-artefacts` | Lists every editable artefact for the sidebar Artefacts view (KnowledgeDocuments + this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates). |
2293
+ | `/sidebar-artefacts` | Lists the sidebar Artefacts rows: every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'` — all file types, excluding the separate `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates (Task 684). |
2294
2294
  | `/sidebar-artefact-content` | Reads a single artefact's bytes for the artefact pane. |
2295
2295
  | `/sidebar-artefact-save` | Persists an artefact edit. |
2296
2296
  | `/attachment` | Per-attachment binary fetch (images, PDFs, etc.). |
@@ -2437,7 +2437,7 @@ authoritative. This section names the surfaces and what backs each.
2437
2437
  | Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
2438
2438
  | Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
2439
2439
  | Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
2440
- | Artefacts list | Lists every KnowledgeDocument plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
2440
+ | Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree — Task 684) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
2441
2441
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
2442
2442
  | Footer | Operator avatar, name, role, and the actions popover. | `session.ts` |
2443
2443
 
@@ -31,10 +31,47 @@ sessions skip it.
31
31
 
32
32
  ## Accepted boundary
33
33
 
34
- On loopback, multiple admins resolve to the right *profile* conversationally but
35
- share the owner's `userId` at the env/MCP layer. Deterministic per-admin `userId`
36
- on the loopback surface is deliberately out of scope: making it deterministic again
37
- means a per-session identity step, which is exactly the deadlock Task 681 removed.
38
- A per-admin binding table that stamps the correct `userId` for channel sessions
39
- would generalise the channel model further; it was considered and left out of
40
- Task 681 (Option B in the design), not adopted.
34
+ On a channel (WhatsApp today), the inbound sender phone is bound to a person's
35
+ `userId` via `users.json` (`UserEntry.phone`), confirmed against the account's
36
+ `admins[]`. The channel admin session carries that `userId` graph writes and
37
+ the `${USER_ID}` MCP env attribute to the actual sender (Task 682). An unbound
38
+ sender (the owner's self-phone, or an `adminPhones` entry with no `users.json`
39
+ phone) falls back to the boot-time owner `userId`.
40
+
41
+ A WhatsApp admin whose inbound arrives as an unresolved LID (a cold mapping
42
+ cache) resolves to their own `userId` via a runtime LID cache
43
+ (`whatsapp-lid-map.json`), populated the first time their LID resolves to their
44
+ phone (Task 686). A LID that has never resolved has no cache entry and still
45
+ falls back to the owner `userId` — visible as the `op=admin-identity …
46
+ userId=owner-fallback` log line.
47
+
48
+ On loopback (claude.ai/code), multiple admins resolve to the right *profile*
49
+ conversationally but share the owner's `userId` at the env/MCP layer.
50
+ Deterministic per-admin `userId` on loopback is deliberately out of scope:
51
+ making it deterministic again means a per-session identity step, which is exactly
52
+ the deadlock Task 681 removed.
53
+
54
+ ## Boot-time binding drift gate (Task 687)
55
+
56
+ The channel-admin binding is reassembled at read time from three independently
57
+ operator-maintained sources: `adminPhones` (role allowlist), `admins[]`
58
+ (membership), and `users.json` `UserEntry.phone` (the phone→userId binding). If
59
+ they disagree, `resolveAdminUserId` returns null and the session silently runs
60
+ under the owner `userId` — the fallback named in the boundary above, but here as
61
+ a *misconfiguration*, not an intentional unbound sender.
62
+
63
+ On every server start the platform replays the resolution chain for each
64
+ account's `adminPhones` entry and logs one line per entry that would silently
65
+ fall to the owner `userId`:
66
+
67
+ [admin-identity] binding-drift accountId=<id> phone=<p> reason=no-users-binding
68
+ [admin-identity] binding-drift accountId=<id> phone=<p> reason=userid-not-admin userId=<u>
69
+
70
+ - `no-users-binding` — no `users.json` entry's `phone` matches this admin phone,
71
+ so it can never resolve to a userId. Add the phone to the person's `users.json`
72
+ entry.
73
+ - `userid-not-admin` — the phone is bound to `userId`, but that userId is not in
74
+ this account's `admins[]`. Add the userId to `admins[]`.
75
+
76
+ The gate is informational: it never blocks boot, and steady-state boots with
77
+ consistent bindings emit nothing.
@@ -70,7 +70,7 @@ Task 500 — **admin session management moved entirely to claude's own interface
70
70
 
71
71
  | Mount | Purpose |
72
72
  |---|---|
73
- | `/sidebar-artefacts` | Lists every editable artefact for the sidebar Artefacts view (KnowledgeDocuments + this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates). |
73
+ | `/sidebar-artefacts` | Lists the sidebar Artefacts rows: every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'` — all file types, excluding the separate `uploads/<id>/` subtree) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates (Task 684). |
74
74
  | `/sidebar-artefact-content` | Reads a single artefact's bytes for the artefact pane. |
75
75
  | `/sidebar-artefact-save` | Persists an artefact edit. |
76
76
  | `/attachment` | Per-attachment binary fetch (images, PDFs, etc.). |
@@ -217,7 +217,7 @@ authoritative. This section names the surfaces and what backs each.
217
217
  | Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
218
218
  | Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
219
219
  | Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
220
- | Artefacts list | Lists every KnowledgeDocument plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
220
+ | Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree — Task 684) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
221
221
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
222
222
  | Footer | Operator avatar, name, role, and the actions popover. | `session.ts` |
223
223
 
@@ -41,6 +41,9 @@ const ALLOWLIST = new Set([
41
41
  // Same vi.hoisted pattern: Task 549's sidebar-sessions test pre-builds a
42
42
  // tmpdir before the module-under-test's ACCOUNTS_DIR mock is wired.
43
43
  'platform/ui/server/routes/admin/__tests__/sidebar-sessions.test.ts',
44
+ // Same vi.hoisted pattern: Task 684's sidebar-artefacts test pre-builds an
45
+ // empty tmpdir for ACCOUNTS_DIR/PLATFORM_ROOT before the module's mocks fire.
46
+ 'platform/ui/server/routes/admin/__tests__/sidebar-artefacts.test.ts',
44
47
  // Same vi.hoisted pattern: Task 576's claude-auth refresh-lock test
45
48
  // builds a tmpdir + creds path before the module's `../paths` mock fires.
46
49
  'platform/ui/app/lib/__tests__/claude-auth-refresh-lock.test.ts',
@@ -4,6 +4,7 @@ import type { FsWatcher } from './fs-watcher.js';
4
4
  import type { RateLimiter } from './spawn-rate-limiter.js';
5
5
  import type { AuditRegistry } from './public-tool-audit.js';
6
6
  import { type UserTitleStore } from './user-title-store.js';
7
+ import { WaChannelStore } from './wa-channel-store.js';
7
8
  export declare const SESSION_ID_PATTERN: RegExp;
8
9
  export declare const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60000;
9
10
  export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'> {
@@ -19,6 +20,10 @@ export interface HttpDeps extends Omit<SpawnDeps, 'onSessionReady' | 'watcher'>
19
20
  /** Task 253 — operator-stamped title override store. Reads merge into
20
21
  * every displayName resolution site; the /:sessionId/rename route writes. */
21
22
  userTitleStore: UserTitleStore;
23
+ /** Task 683 — per-session WhatsApp channel binding. Gateway `/rc-spawn`
24
+ * write-throughs it; every resume path reads it back to re-apply the
25
+ * channel argv so `reply` is present on every live PTY for the session. */
26
+ waChannelStore: WaChannelStore;
22
27
  /** Task 648 — override the `/rc-spawn` pid/bind poll bound. Production
23
28
  * omits this and gets RC_SPAWN_URL_WAIT_DEFAULT_MS (60s); tests inject a
24
29
  * short value to exercise the bind-timeout response without a 60s wait. */
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAS3B,OAAO,EAeL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAkBzB,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAgF9E,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAS1D,eAAO,MAAM,4BAA4B,QAAS,CAAA;AAIlD,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E;;qEAEiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;gFAE4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AA+MD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAmBD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAyrDpD"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAS3B,OAAO,EAeL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAkBzB,OAAO,KAAK,EAAE,SAAS,EAAc,MAAM,iBAAiB,CAAA;AAE5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAgFtD,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAS1D,eAAO,MAAM,4BAA4B,QAAS,CAAA;AAIlD,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E;;qEAEiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;gFAE4E;IAC5E,cAAc,EAAE,cAAc,CAAA;IAC9B;;gFAE4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AA+MD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAmBD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAsuDpD"}
@@ -23,7 +23,7 @@ import { Hono } from 'hono';
23
23
  import { stream } from 'hono/streaming';
24
24
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
25
25
  import { tmpdir } from 'node:os';
26
- import { buildWaChannelMcpServers, waChannelDevChannelsArgv, isDevChannelPrompt } from './wa-channel-mcp.js';
26
+ import { buildWaChannelMcpServers, buildWaChannelArgv, waChannelMcpConfigPath, isDevChannelPrompt } from './wa-channel-mcp.js';
27
27
  import { randomUUID } from 'node:crypto';
28
28
  import { execFileSync } from 'node:child_process';
29
29
  import { buildRcChildEnv } from './rc-daemon.js';
@@ -1295,6 +1295,10 @@ export function buildHttpApp(deps) {
1295
1295
  // logs its own failure and reads still serve from the in-memory map
1296
1296
  // until next boot.
1297
1297
  deps.userTitleStore.remove(sessionId);
1298
+ // Task 683 — drop the channel binding too so wa-channel-bindings.json
1299
+ // doesn't accrete orphans and a recreated session can't inherit a stale
1300
+ // binding. Best-effort; the store logs its own failure.
1301
+ deps.waChannelStore.remove(sessionId);
1298
1302
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
1299
1303
  timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
1300
1304
  return c.body(null, 204);
@@ -1549,6 +1553,13 @@ export function buildHttpApp(deps) {
1549
1553
  const personId = typeof body.personId === 'string' && body.personId.length > 0 && body.personId.length <= 128
1550
1554
  ? body.personId
1551
1555
  : undefined;
1556
+ // Task 682 — per-session userId override. Server-resolved by the UI from the
1557
+ // authoritative users.json/account.json (never message content) and shape-
1558
+ // validated here the same way as personId. Absent for owner self-phone and
1559
+ // any unbound sender, where the boot-time deps.userId (owner) is used.
1560
+ const overrideUserId = typeof body.userId === 'string' && body.userId.length > 0 && body.userId.length <= 128
1561
+ ? body.userId
1562
+ : undefined;
1552
1563
  // Task 626 — one-shot admin jobs (LinkedIn ingest, public session-end
1553
1564
  // review) carry their prompt as the session's first turn, the same
1554
1565
  // trailing-positional mechanism /public-spawn uses, and close after that
@@ -1595,20 +1606,41 @@ export function buildHttpApp(deps) {
1595
1606
  // development channel. Inbound WhatsApp then arrives as a `<channel
1596
1607
  // source="whatsapp">` event and the agent replies via the channel `reply`
1597
1608
  // tool — no keystroke injection. Pushed before the trailing positional.
1598
- if (waChannel && sessionId) {
1609
+ // Task 683 resolve the effective binding. The gateway `/rc-spawn` supplies
1610
+ // waChannel and we write it through to the store; any later resume (operator
1611
+ // / PIN / manager) omits it, so we read the binding back. Either way the same
1612
+ // per-session --mcp-config + development-channels argv is injected, so
1613
+ // `mcp__whatsapp-channel__reply` is present on every live PTY for the session.
1614
+ let effectiveWaChannel;
1615
+ if (sessionId) {
1616
+ if (waChannel) {
1617
+ effectiveWaChannel = waChannel;
1618
+ deps.waChannelStore.set(sessionId, waChannel);
1619
+ }
1620
+ else {
1621
+ const stored = deps.waChannelStore.get(sessionId);
1622
+ if (stored) {
1623
+ effectiveWaChannel = { senderId: stored.senderId, gatewayUrl: stored.gatewayUrl, serverPath: stored.serverPath };
1624
+ deps.logger(`[rc-spawn] op=wa-channel-rebind sessionId=${sessionId.slice(0, 8)} senderId=${stored.senderId} source=store`);
1625
+ }
1626
+ }
1627
+ }
1628
+ let waPath = null;
1629
+ if (effectiveWaChannel && sessionId) {
1599
1630
  const waServers = buildWaChannelMcpServers({
1600
1631
  sessionId,
1601
- senderId: waChannel.senderId,
1602
- gatewayUrl: waChannel.gatewayUrl,
1603
- serverPath: waChannel.serverPath,
1632
+ senderId: effectiveWaChannel.senderId,
1633
+ gatewayUrl: effectiveWaChannel.gatewayUrl,
1634
+ serverPath: effectiveWaChannel.serverPath,
1604
1635
  });
1605
- const waPath = join(tmpdir(), `maxy-wa-channel-${sessionId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
1636
+ waPath = waChannelMcpConfigPath(sessionId, tmpdir());
1606
1637
  try {
1607
1638
  writeFileSync(waPath, JSON.stringify({ mcpServers: waServers }, null, 2), 'utf8');
1608
- argv.push('--mcp-config', waPath, ...waChannelDevChannelsArgv());
1609
- deps.logger(`[rc-spawn] op=wa-channel senderId=${waChannel.senderId} gateway=${waChannel.gatewayUrl} path=${waPath}`);
1639
+ argv.push(...buildWaChannelArgv(waPath));
1640
+ deps.logger(`[rc-spawn] op=wa-channel senderId=${effectiveWaChannel.senderId} gateway=${effectiveWaChannel.gatewayUrl} path=${waPath}`);
1610
1641
  }
1611
1642
  catch (err) {
1643
+ waPath = null;
1612
1644
  deps.logger(`[rc-spawn] op=wa-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1613
1645
  }
1614
1646
  }
@@ -1622,7 +1654,7 @@ export function buildHttpApp(deps) {
1622
1654
  // so the child's MCP stderr-tee writes its readable per-server log.
1623
1655
  const env = buildRcChildEnv({
1624
1656
  accountId: deps.accountId,
1625
- userId: deps.userId,
1657
+ userId: overrideUserId ?? deps.userId,
1626
1658
  sessionId: sessionId ?? undefined,
1627
1659
  uiPort: process.env.MAXY_UI_INTERNAL_PORT ?? '',
1628
1660
  platformRoot: deps.platformRoot,
@@ -1690,6 +1722,16 @@ export function buildHttpApp(deps) {
1690
1722
  pid: pty.pid,
1691
1723
  managerOpenFds: openFdCount(),
1692
1724
  });
1725
+ // Task 683 — standing invariant. A bound session whose live PTY was spawned
1726
+ // without the channel mcp-config is the silent-revert state (the reply tool
1727
+ // is absent, the agent falls back to whatsapp-send). The resume read-back
1728
+ // makes the resume-without-binding case impossible, so this now fires only
1729
+ // when the mcp-config write itself failed (waPath reset to null) — it
1730
+ // verifies the post-condition (PTY actually carries the channel) rather than
1731
+ // the intent to spawn it.
1732
+ if (sessionId && deps.waChannelStore.get(sessionId) && waPath === null) {
1733
+ deps.logger(`[rc-spawn] op=channel-binding-lost sessionId=${sessionId} pid=${pty.pid} reason=resume-without-mcp-config`);
1734
+ }
1693
1735
  // Tracker registration — closes the leak gap the Task 552 path opened.
1694
1736
  // For fresh spawns without a caller-supplied sessionId we register the
1695
1737
  // tracker under the unitToken so the entry has a stable key; the
@@ -1751,7 +1793,7 @@ export function buildHttpApp(deps) {
1751
1793
  const BIND_BUF_TAIL = 8 * 1024;
1752
1794
  pty.onData((data) => {
1753
1795
  stdoutBytes += data.length;
1754
- if (waChannel && !devChannelConfirmed) {
1796
+ if (effectiveWaChannel && !devChannelConfirmed) {
1755
1797
  devChannelScan += data;
1756
1798
  if (devChannelScan.length > 4096)
1757
1799
  devChannelScan = devChannelScan.slice(devChannelScan.length - 4096);