@rubytech/create-maxy-code 0.1.303 → 0.1.304

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 (65) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +12 -0
  3. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +10 -6
  4. package/payload/platform/plugins/admin/skills/access-manager/SKILL.md +1 -0
  5. package/payload/platform/plugins/admin/skills/access-manager/references/operations.md +22 -0
  6. package/payload/platform/plugins/docs/references/admin-session.md +2 -0
  7. package/payload/platform/plugins/whatsapp/skills/connect-whatsapp/SKILL.md +4 -4
  8. package/payload/platform/services/claude-session-manager/dist/channel-mcp.d.ts +15 -0
  9. package/payload/platform/services/claude-session-manager/dist/channel-mcp.d.ts.map +1 -0
  10. package/payload/platform/services/claude-session-manager/dist/channel-mcp.js +34 -0
  11. package/payload/platform/services/claude-session-manager/dist/channel-mcp.js.map +1 -0
  12. package/payload/platform/services/claude-session-manager/dist/channel-targets.d.ts +26 -0
  13. package/payload/platform/services/claude-session-manager/dist/channel-targets.d.ts.map +1 -0
  14. package/payload/platform/services/claude-session-manager/dist/channel-targets.js +19 -0
  15. package/payload/platform/services/claude-session-manager/dist/channel-targets.js.map +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/http-server.js +80 -51
  18. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  19. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +15 -1
  20. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  21. package/payload/platform/services/whatsapp-channel/dist/notification.js +13 -1
  22. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  23. package/payload/platform/services/whatsapp-channel/dist/server.d.ts +8 -0
  24. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  25. package/payload/platform/services/whatsapp-channel/dist/server.js +183 -21
  26. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  27. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +82 -0
  28. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -0
  29. package/payload/platform/services/whatsapp-channel/dist/targets.js +131 -0
  30. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -0
  31. package/payload/server/{chunk-JHSF5VPE.js → chunk-TUY3RB5G.js} +4 -0
  32. package/payload/server/maxy-edge.js +2 -2
  33. package/payload/server/public/assets/AdminLoginScreens-BXKCagZn.js +1 -0
  34. package/payload/server/public/assets/{AdminShell-CXYu03dO.js → AdminShell-CxKd_V3O.js} +1 -1
  35. package/payload/server/public/assets/{Checkbox-D01p5NFD.js → Checkbox-BHmeAgrw.js} +1 -1
  36. package/payload/server/public/assets/Transcript-DPDJitcb.js +1 -0
  37. package/payload/server/public/assets/{admin-DdoI2RXi.js → admin-DHLuBUtk.js} +1 -1
  38. package/payload/server/public/assets/{audio-attachment-mime-WRXiXXUG.js → audio-attachment-mime-BxI3Kl4_.js} +1 -1
  39. package/payload/server/public/assets/{browser-Db0TaQdG.js → browser-DgtLMsM1.js} +1 -1
  40. package/payload/server/public/assets/chat-Ib78Kdhg.js +1 -0
  41. package/payload/server/public/assets/data-C317FUk4.js +1 -0
  42. package/payload/server/public/assets/file-download-DXFcVkh6.js +1 -0
  43. package/payload/server/public/assets/{graph-CWxi74vF.js → graph-CHf_9bmD.js} +2 -2
  44. package/payload/server/public/assets/{graph-labels-Bxt4mIUp.js → graph-labels-BbiX-sz-.js} +1 -1
  45. package/payload/server/public/assets/{jsx-runtime-BAMC3pMk.css → jsx-runtime-BkXvvpkS.css} +1 -1
  46. package/payload/server/public/assets/operator-BJ5tN5vl.js +1 -0
  47. package/payload/server/public/assets/page-CjuqiMAQ.js +1 -0
  48. package/payload/server/public/assets/{public-C3iO20f3.js → public-D3G4tgFu.js} +1 -1
  49. package/payload/server/public/assets/{useSelectionMode-DDpddXMY.js → useSelectionMode-DcgaoBP6.js} +1 -1
  50. package/payload/server/public/browser.html +6 -6
  51. package/payload/server/public/chat.html +9 -9
  52. package/payload/server/public/data.html +5 -5
  53. package/payload/server/public/graph.html +8 -8
  54. package/payload/server/public/index.html +9 -9
  55. package/payload/server/public/operator.html +11 -11
  56. package/payload/server/public/public.html +6 -6
  57. package/payload/server/server.js +776 -570
  58. package/payload/server/public/assets/AdminLoginScreens-B11_s63y.js +0 -1
  59. package/payload/server/public/assets/Transcript-NcTfeF7W.js +0 -1
  60. package/payload/server/public/assets/chat-BMTC0f2h.js +0 -1
  61. package/payload/server/public/assets/data-BiWVJQmi.js +0 -1
  62. package/payload/server/public/assets/file-download-7T2ye2nL.js +0 -1
  63. package/payload/server/public/assets/operator-Dw1Sep47.js +0 -1
  64. package/payload/server/public/assets/page-DZ5OxzgL.js +0 -1
  65. /package/payload/server/public/assets/{jsx-runtime-DhMPqcKn.js → jsx-runtime-BabLCDgS.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.303",
3
+ "version": "0.1.304",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -56,6 +56,10 @@ CHANNEL_REFRAME='{"type":"user","isMeta":true,"message":{"role":"user","content"
56
56
  # payload, not the wrapper (the always-present instruction text would otherwise
57
57
  # make every reframed turn look non-trivial).
58
58
  CHANNEL_REFRAME_TRIVIAL='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\">## Context\nyes\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.</channel>"}}'
59
+ # Task 853 — multi-target (unified server) shape: the harness wraps with the
60
+ # registration key `channel`, and the server embeds an inner source tag, so the
61
+ # reframe arrives DOUBLE-wrapped. The strip must iterate to reach ## Context.
62
+ CHANNEL_REFRAME_MULTI='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"channel\">\n<channel source=\"whatsapp\">\n## Context\nplease draft the works order PDF for order 926\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.\n</channel>\n</channel>"}}'
59
63
 
60
64
  assert_flagged() {
61
65
  local name="$1" t="$2" out
@@ -169,6 +173,14 @@ assert_not_flagged "reframe turn + Agent dispatch -> not flagged" \
169
173
  # even though the wrapper text is long.
170
174
  assert_not_flagged "reframe turn with one-word Context payload -> not flagged (trivial)" \
171
175
  "$(mk_transcript "$CHANNEL_REFRAME_TRIVIAL" "$ASSISTANT_TEXT")"
176
+ # 18b. Task 853 — double-wrapped multi-target reframe + inline authoring, no
177
+ # marker -> flagged. A single-strip would leave the inner tag and never see
178
+ # ## Context (the Task 805 re-blinding class).
179
+ assert_flagged "multi-target double-wrapped reframe + inline authoring -> flagged" \
180
+ "$(mk_transcript "$CHANNEL_REFRAME_MULTI" "$ASSISTANT_REPLY" "$ASSISTANT_BASH" "$ASSISTANT_WRITE")"
181
+ # 18c. Same double-wrapped reframe with an Agent dispatch -> not flagged.
182
+ assert_not_flagged "multi-target double-wrapped reframe + Agent dispatch -> not flagged" \
183
+ "$(mk_transcript "$CHANNEL_REFRAME_MULTI" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
172
184
  # 19. Channel-wrapped NON-reframe turn (no ## Instruction section — webchat
173
185
  # today, Task 776) with no marker -> not directive-equivalent, not flagged.
174
186
  assert_not_flagged "channel-wrapped non-reframe turn, no marker -> not flagged" \
@@ -129,16 +129,20 @@ if boundary is None:
129
129
 
130
130
  slice_rows = rows[boundary:]
131
131
 
132
- # Strip a leading <channel ...>...</channel> wrapper to reach the inner prompt
133
- # (WhatsApp/native-channel prompts arrive wrapped).
132
+ # Strip leading <channel ...>...</channel> wrapper(s) to reach the inner
133
+ # prompt. WhatsApp/native-channel prompts arrive wrapped once; a multi-target
134
+ # (unified-server, Task 853) prompt arrives DOUBLE-wrapped — the harness wraps
135
+ # with the registration key `channel` and the server embeds an inner
136
+ # <channel source="<door>"> tag — so iterate until the payload surfaces.
134
137
  inner = prompt_text
135
138
  is_channel = False
136
- if inner.startswith("<channel"):
139
+ while inner.startswith("<channel"):
137
140
  end = inner.find(">")
138
141
  close = inner.rfind("</channel>")
139
- if end != -1 and close != -1 and close > end:
140
- inner = inner[end + 1:close].strip()
141
- is_channel = True
142
+ if end == -1 or close == -1 or close <= end:
143
+ break
144
+ inner = inner[end + 1:close].strip()
145
+ is_channel = True
142
146
 
143
147
  # Task 805 — a channel-wrapped reframe turn (the ## Context / ## Instruction
144
148
  # sections of composeAdminContent) is directive-equivalent: the directive hook suppresses
@@ -16,6 +16,7 @@ Applies when the business owner wants to control who can access a gated public a
16
16
  - Admin asks to revoke or extend someone's access
17
17
  - Admin asks to set an agent's access mode (open or gated)
18
18
  - Admin asks to resend an invitation
19
+ - Admin asks to enrol a worker with both a phone and an email (one person, two contact doors)
19
20
 
20
21
  ## Constraints
21
22
 
@@ -126,6 +126,28 @@ The endpoint is loopback-only; it accepts the element ID returned from the cyphe
126
126
 
127
127
  Present a `confirm` component before the revocation lands. Explain that the visitor is logged out immediately and must wait for a fresh invitation to re-access.
128
128
 
129
+ ## Enrol a Phone+Email Worker
130
+
131
+ **Outcome:** One `Person` node carries both the worker's phone (E.164) and an email `AccessGrant` for the target agent, so the WhatsApp phone door and the email magic-link door resolve to the same person. Returns the `personId` and a grant summary.
132
+
133
+ Do NOT write this via cypher — the UI server owns the enrolment write. After confirming, run:
134
+
135
+ ```bash
136
+ curl -s -X POST http://localhost:19200/api/admin/enrol-person \
137
+ -H 'Content-Type: application/json' \
138
+ -d '{"phone": "+447700900123", "email": "worker@example.com", "agentSlug": "<slug>"}'
139
+ ```
140
+
141
+ The endpoint is loopback-only. It normalises the phone to E.164 digits, lowercases the email, resolves the `accountId` server-side, and MERGEs — re-enrolling the same (account, phone) is idempotent. The response is `{"personId": "...", "grant": {"grantId": "...", "status": "...", "contactValue": "..."}}`; present both to the admin. A 400 names the malformed field (`invalid-phone` / `invalid-email`).
142
+
143
+ The returned `status` is the grant's true state. Enrolling never un-revokes: if `status` is `"revoked"`, the worker stays locked out until the admin explicitly re-invites (the grant was deliberately revoked, and enrolment must not silently undo that). Tell the admin and offer the invite flow.
144
+
145
+ No invite email is sent. If the worker needs web access, they request a magic link themselves from the agent's gate page (the request-magic-link flow), or the admin runs the normal invite/resend operation against the same email — it lands on the same person.
146
+
147
+ This does not touch WhatsApp `dmPolicy` / `allowFrom` — whether the phone may message the agent stays operator config.
148
+
149
+ Present a `confirm` component before enrolling, showing: the worker's phone, email, and the target agent.
150
+
129
151
  ## Extend Access
130
152
 
131
153
  **Outcome:** Grant's `expiresAt` is pushed forward by the requested duration.
@@ -26,6 +26,8 @@ The HMAC secret lives at `~/.${brand}/credentials/admin-session-secret` (mode `0
26
26
 
27
27
  **Observability.** `[session-rehydrate-from-token] sessionKey=… accountId=… userId=… ageMs=…` fires once per successful rehydrate in `server.log`. A tampered token fails HMAC and returns `invalid-token` silently (no log line — would otherwise be a noise/attack-amplifier surface); the request's downstream `[session] middleware-reject status=401 code=session-not-registered` is sufficient.
28
28
 
29
+ **Adjacent 401 surface — remote-auth gate.** The PIN-session reject-code taxonomy (`session-missing`, `session-not-registered`, `session-expired-age`, `grant-expired` — the `AdminRejectCode` union in `platform/ui/app/lib/useAdminFetch.ts`) shares its 401 transport with one code from a different security domain: the remote-auth gate (`server/index.ts`, the `__remote_session` cookie's 24h TTL) answers an expired/missing/invalid remote-auth token on any `/api/*` path with `401 {"code":"remote-auth-required"}` — document navigations keep getting the login HTML with a redirect. Client handling is deliberately split: the PIN codes route to enter-pin recovery, while `remote-auth-required` triggers a full page reload (the gate then serves the login page for the current path), because a PIN screen behind an expired remote-auth token cannot reach its own API. The gate's log line is `[remote-auth] login required … respond=401|html`.
30
+
29
31
  ## SDK-resume contract on PIN-rebind
30
32
 
31
33
  The Anthropic SDK is stateless against its own JSONL: every cold-create with `resume: <agentSessionId>` reads `${CLAUDE_CONFIG_DIR}/projects/<encoded-cwd>/<agentSessionId>.jsonl` verbatim. The Maxy graph (`Conversation.agentSessionId`) is just the pointer into the JSONL, not a parallel transcript. The PIN-rebind contract preserves this:
@@ -47,7 +47,7 @@ The self phone from Phase 1 is automatically registered as an admin phone. If th
47
47
 
48
48
  ### Phase 3: Public Agent Selection
49
49
 
50
- WhatsApp inbound from non-admin phones routes to a public agent configured in the WhatsApp config not the account-level `defaultAgent`. The user must explicitly choose which agent handles WhatsApp. There is no automatic fallback; if no public agent is set, WhatsApp silently rejects non-admin messages.
50
+ WhatsApp inbound from non-admin phones routes only for **authenticated-public** senders: the sender must be allowed by the DM policy **and** resolve to an enrolled `:Person` (their phone is on a Person record for this account). An allowed-but-unenrolled sender is dropped with `[whatsapp:route] op=dropped reason=public-unresolved`. Each routed sender gets their own per-person, reply-only session. The public-agent setting below is the WhatsApp config's designated public agent; it does not change how an authenticated-public sender is routed.
51
51
 
52
52
  1. **Check current state** — Call `whatsapp-config` with `action: "get-public-agent"` to see if one is already set. Also call `account-manage` to list the available public agents.
53
53
 
@@ -55,11 +55,11 @@ WhatsApp inbound from non-admin phones routes to a public agent configured in th
55
55
 
56
56
  3. **Set the public agent** — Call `whatsapp-config` with `action: "set-public-agent"` and `slug: "{chosen-slug}"`.
57
57
 
58
- 4. **Open the DM policy** — Call `whatsapp-config` with `action: "update-config"`, `dmPolicy: "open"`, and `allowFrom: ["*"]`. Without this, all non-admin messages are silently dropped regardless of which public agent is configured.
58
+ 4. **Set the DM policy** — Call `whatsapp-config` with `action: "update-config"`, `dmPolicy: "allowlist"`, and the enrolled customers' phones in `allowFrom`. Without a policy that allows the sender, all non-admin messages are silently dropped regardless of which public agent is configured. Even an allowed sender only routes once enrolled as a `:Person` carrying that phone.
59
59
 
60
- 5. **Confirm** — "WhatsApp messages from customers will be handled by {agent name}."
60
+ 5. **Confirm** — "Enrolled customers on the allowlist will get their own WhatsApp conversation with the agent."
61
61
 
62
- If only one public agent exists, confirm it as the choice rather than asking. If no public agents exist, tell the user: "You don't have a public agent yet. WhatsApp will be connected but won't respond to customer messages until you create one and set it with `whatsapp-config action: set-public-agent`."
62
+ If only one public agent exists, confirm it as the choice rather than asking. If no public agents exist, the public-agent setting can be skipped it does not gate authenticated-public routing.
63
63
 
64
64
  ## Relinking
65
65
 
@@ -0,0 +1,15 @@
1
+ import type { ChannelTargetSet } from './channel-targets.js';
2
+ export declare const CHANNEL_SERVER_NAME = "channel";
3
+ export interface ChannelMcpServerEntry {
4
+ command: string;
5
+ args: string[];
6
+ env: Record<string, string>;
7
+ }
8
+ export declare function buildChannelMcpServers(p: {
9
+ sessionId: string;
10
+ targets: ChannelTargetSet;
11
+ }): Record<string, ChannelMcpServerEntry>;
12
+ export declare function channelDevChannelsArgv(): string[];
13
+ export declare function channelMcpConfigPath(sessionId: string, dir: string): string;
14
+ export declare function buildChannelArgv(mcpConfigPath: string): string[];
15
+ //# sourceMappingURL=channel-mcp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-mcp.d.ts","sourceRoot":"","sources":["../src/channel-mcp.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAE5D,eAAO,MAAM,mBAAmB,YAAY,CAAA;AAE5C,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC5B;AAED,wBAAgB,sBAAsB,CAAC,CAAC,EAAE;IACxC,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,gBAAgB,CAAA;CAC1B,GAAG,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAexC;AAED,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,CAEjD;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE3E;AAED,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CAEhE"}
@@ -0,0 +1,34 @@
1
+ // Task 850 — attach builder for the UNIFIED channel server. Registered (in
2
+ // place of the per-kind wa/webchat servers) only when a session is bound to ≥2
3
+ // channel kinds. The agent sees exactly one `reply` tool; the server reads the
4
+ // whole target set from `CHANNEL_TARGETS` and fans each reply to every bound
5
+ // target's existing gateway reply endpoint. Pure (no fs) like wa-channel-mcp.ts.
6
+ import { join } from 'node:path';
7
+ export const CHANNEL_SERVER_NAME = 'channel';
8
+ export function buildChannelMcpServers(p) {
9
+ // Every target carries the same built server path (the generalized
10
+ // whatsapp-channel dist). Pick whichever kind is bound.
11
+ const anyTarget = p.targets.whatsapp ?? p.targets.webchat;
12
+ if (!anyTarget)
13
+ throw new Error('buildChannelMcpServers: empty target set');
14
+ return {
15
+ [CHANNEL_SERVER_NAME]: {
16
+ command: 'node',
17
+ args: [anyTarget.serverPath],
18
+ env: {
19
+ CHANNEL_SESSION_ID: p.sessionId,
20
+ CHANNEL_TARGETS: JSON.stringify(p.targets),
21
+ },
22
+ },
23
+ };
24
+ }
25
+ export function channelDevChannelsArgv() {
26
+ return ['--dangerously-load-development-channels', `server:${CHANNEL_SERVER_NAME}`];
27
+ }
28
+ export function channelMcpConfigPath(sessionId, dir) {
29
+ return join(dir, `maxy-channel-${sessionId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
30
+ }
31
+ export function buildChannelArgv(mcpConfigPath) {
32
+ return ['--mcp-config', mcpConfigPath, ...channelDevChannelsArgv()];
33
+ }
34
+ //# sourceMappingURL=channel-mcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-mcp.js","sourceRoot":"","sources":["../src/channel-mcp.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,+EAA+E;AAC/E,+EAA+E;AAC/E,6EAA6E;AAC7E,iFAAiF;AAEjF,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAGhC,MAAM,CAAC,MAAM,mBAAmB,GAAG,SAAS,CAAA;AAQ5C,MAAM,UAAU,sBAAsB,CAAC,CAGtC;IACC,mEAAmE;IACnE,wDAAwD;IACxD,MAAM,SAAS,GAAG,CAAC,CAAC,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC,OAAO,CAAC,OAAO,CAAA;IACzD,IAAI,CAAC,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;IAC3E,OAAO;QACL,CAAC,mBAAmB,CAAC,EAAE;YACrB,OAAO,EAAE,MAAM;YACf,IAAI,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC;YAC5B,GAAG,EAAE;gBACH,kBAAkB,EAAE,CAAC,CAAC,SAAS;gBAC/B,eAAe,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;aAC3C;SACF;KACF,CAAA;AACH,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,OAAO,CAAC,yCAAyC,EAAE,UAAU,mBAAmB,EAAE,CAAC,CAAA;AACrF,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,SAAiB,EAAE,GAAW;IACjE,OAAO,IAAI,CAAC,GAAG,EAAE,gBAAgB,SAAS,CAAC,OAAO,CAAC,iBAAiB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAA;AACpF,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,aAAqB;IACpD,OAAO,CAAC,cAAc,EAAE,aAAa,EAAE,GAAG,sBAAsB,EAAE,CAAC,CAAA;AACrE,CAAC"}
@@ -0,0 +1,26 @@
1
+ export interface WhatsAppTarget {
2
+ kind: 'whatsapp';
3
+ senderId: string;
4
+ gatewayUrl: string;
5
+ serverPath: string;
6
+ }
7
+ export interface WebchatTarget {
8
+ kind: 'webchat';
9
+ key: string;
10
+ gatewayUrl: string;
11
+ serverPath: string;
12
+ }
13
+ export type ChannelTarget = WhatsAppTarget | WebchatTarget;
14
+ /** Per-session bound targets, at most one per kind. */
15
+ export interface ChannelTargetSet {
16
+ whatsapp?: WhatsAppTarget;
17
+ webchat?: WebchatTarget;
18
+ }
19
+ /** Add (or replace) a target of its kind. Pure. */
20
+ export declare function mergeTarget(set: ChannelTargetSet, t: ChannelTarget): ChannelTargetSet;
21
+ /** The kinds currently bound, in no guaranteed order. */
22
+ export declare function targetKinds(set: ChannelTargetSet): Array<ChannelTarget['kind']>;
23
+ /** True when ≥2 distinct kinds are bound — the case that needs the unified
24
+ * server (one reply tool fanning to every target). */
25
+ export declare function isMultiTarget(set: ChannelTargetSet): boolean;
26
+ //# sourceMappingURL=channel-targets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-targets.d.ts","sourceRoot":"","sources":["../src/channel-targets.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,UAAU,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AACD,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AACD,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,aAAa,CAAA;AAE1D,uDAAuD;AACvD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;AAED,mDAAmD;AACnD,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,EAAE,CAAC,EAAE,aAAa,GAAG,gBAAgB,CAErF;AAED,yDAAyD;AACzD,wBAAgB,WAAW,CAAC,GAAG,EAAE,gBAAgB,GAAG,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAE/E;AAED;uDACuD;AACvD,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAE5D"}
@@ -0,0 +1,19 @@
1
+ // Task 850 — a session's outbound binding is a SET of channel targets, at most
2
+ // one per kind, replacing the whatsapp-XOR-webchat single binding. The manager
3
+ // assembles this set from the per-kind binding stores and decides whether to
4
+ // register the unified `channel` server (≥2 kinds) or the existing per-kind
5
+ // server (1 kind).
6
+ /** Add (or replace) a target of its kind. Pure. */
7
+ export function mergeTarget(set, t) {
8
+ return { ...set, [t.kind]: t };
9
+ }
10
+ /** The kinds currently bound, in no guaranteed order. */
11
+ export function targetKinds(set) {
12
+ return Object.keys(set).filter((k) => set[k]);
13
+ }
14
+ /** True when ≥2 distinct kinds are bound — the case that needs the unified
15
+ * server (one reply tool fanning to every target). */
16
+ export function isMultiTarget(set) {
17
+ return targetKinds(set).length >= 2;
18
+ }
19
+ //# sourceMappingURL=channel-targets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-targets.js","sourceRoot":"","sources":["../src/channel-targets.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,+EAA+E;AAC/E,6EAA6E;AAC7E,4EAA4E;AAC5E,mBAAmB;AAsBnB,mDAAmD;AACnD,MAAM,UAAU,WAAW,CAAC,GAAqB,EAAE,CAAgB;IACjE,OAAO,EAAE,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAA;AAChC,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,WAAW,CAAC,GAAqB;IAC/C,OAAQ,MAAM,CAAC,IAAI,CAAC,GAAG,CAAkC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;AACjF,CAAC;AAED;uDACuD;AACvD,MAAM,UAAU,aAAa,CAAC,GAAqB;IACjD,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,CAAA;AACrC,CAAC"}
@@ -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;AAU3B,OAAO,EAgBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAqBzB,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AAEvE,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;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAgFhE,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;;;6BAGyB;IACzB,mBAAmB,CAAC,EAAE,mBAAmB,CAAA;IACzC;;gFAE4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAoND;;;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,CAi+DpD"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAY3B,OAAO,EAgBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAqBzB,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AAEvE,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;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAgFhE,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;;;6BAGyB;IACzB,mBAAmB,CAAC,EAAE,mBAAmB,CAAA;IACzC;;gFAE4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAoND;;;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,CA6/DpD"}
@@ -25,6 +25,8 @@ import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync,
25
25
  import { tmpdir } from 'node:os';
26
26
  import { buildWaChannelMcpServers, buildWaChannelArgv, waChannelMcpConfigPath, isDevChannelPrompt } from './wa-channel-mcp.js';
27
27
  import { buildWebchatChannelMcpServers, buildWebchatChannelArgv, webchatChannelMcpConfigPath } from './webchat-channel-mcp.js';
28
+ import { buildChannelMcpServers, buildChannelArgv, channelMcpConfigPath } from './channel-mcp.js';
29
+ import { mergeTarget, isMultiTarget, targetKinds } from './channel-targets.js';
28
30
  import { randomUUID } from 'node:crypto';
29
31
  import { execFileSync } from 'node:child_process';
30
32
  import { buildRcChildEnv, reapplyAdminLevers } from './rc-daemon.js';
@@ -1660,11 +1662,13 @@ export function buildHttpApp(deps) {
1660
1662
  ? webchatChannelRaw
1661
1663
  : undefined;
1662
1664
  // Task 724 — the bridge routes EVERY role=admin channel inbound here, not
1663
- // just WhatsApp, and supplies the real channel (+ role:'admin', senderId) so
1664
- // the classification sidecar below records the actual channel. The WaGateway
1665
+ // just WhatsApp, and supplies the real channel (+ role, senderId) so the
1666
+ // classification sidecar below records the actual channel. The WaGateway
1665
1667
  // supplies channel:'whatsapp'; the one-shot admin jobs (LinkedIn ingest,
1666
- // session-end review) supply no channel and get no sidecar.
1667
- const adminRole = body.role === 'admin' ? 'admin' : undefined;
1668
+ // session-end review) supply no channel and get no sidecar. Task 852 widens
1669
+ // the accepted role to 'public' (authenticated-public WhatsApp person) and
1670
+ // the sidecar records the actual role.
1671
+ const rcRole = body.role === 'admin' || body.role === 'public' ? body.role : undefined;
1668
1672
  const adminChannel = typeof body.channel === 'string' && CHANNELS.includes(body.channel)
1669
1673
  ? body.channel
1670
1674
  : undefined;
@@ -1737,7 +1741,13 @@ export function buildHttpApp(deps) {
1737
1741
  // / PIN / manager) omits it, so we read the binding back. Either way the same
1738
1742
  // per-session --mcp-config + development-channels argv is injected, so
1739
1743
  // `mcp__whatsapp-channel__reply` is present on every live PTY for the session.
1744
+ // Task 850 — resolve the effective binding for EACH kind (no XOR). A gateway
1745
+ // /rc-spawn supplies waChannel/webchatChannel and we write it through; a bare
1746
+ // resume reads it back. The old whatsapp-XOR-webchat refusal is removed: a
1747
+ // session may now be bound to BOTH kinds (one :Person reachable on phone and
1748
+ // web), and the two are fanned by one unified channel server below.
1740
1749
  let effectiveWaChannel;
1750
+ let effectiveWebchatChannel;
1741
1751
  if (sessionId) {
1742
1752
  if (waChannel) {
1743
1753
  effectiveWaChannel = waChannel;
@@ -1750,38 +1760,6 @@ export function buildHttpApp(deps) {
1750
1760
  deps.logger(`[rc-spawn] op=wa-channel-rebind sessionId=${sessionId.slice(0, 8)} senderId=${stored.senderId} source=store`);
1751
1761
  }
1752
1762
  }
1753
- }
1754
- let waPath = null;
1755
- if (effectiveWaChannel && sessionId) {
1756
- const waServers = buildWaChannelMcpServers({
1757
- sessionId,
1758
- senderId: effectiveWaChannel.senderId,
1759
- gatewayUrl: effectiveWaChannel.gatewayUrl,
1760
- serverPath: effectiveWaChannel.serverPath,
1761
- });
1762
- waPath = waChannelMcpConfigPath(sessionId, tmpdir());
1763
- try {
1764
- writeFileSync(waPath, JSON.stringify({ mcpServers: waServers }, null, 2), 'utf8');
1765
- argv.push(...buildWaChannelArgv(waPath));
1766
- deps.logger(`[rc-spawn] op=wa-channel senderId=${effectiveWaChannel.senderId} gateway=${effectiveWaChannel.gatewayUrl} path=${waPath}`);
1767
- }
1768
- catch (err) {
1769
- waPath = null;
1770
- deps.logger(`[rc-spawn] op=wa-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1771
- }
1772
- }
1773
- // Task 772 — webchat native channel, the webchat twin of the waChannel
1774
- // block above. Same contract: a gateway spawn carries `webchatChannel` and
1775
- // write-throughs the binding; a bare resume reads it back, so
1776
- // `mcp__webchat-channel__reply` is present on every live PTY for the
1777
- // session. A session is whatsapp XOR webchat — enforced here: when a
1778
- // WhatsApp binding is already effective for this session, the webchat
1779
- // attach is refused (two channel servers on one PTY is never valid).
1780
- let effectiveWebchatChannel;
1781
- if (sessionId && effectiveWaChannel && (webchatChannel || deps.webchatChannelStore?.get(sessionId))) {
1782
- deps.logger(`[rc-spawn] op=webchat-channel-refused sessionId=${sessionId.slice(0, 8)} reason=wa-channel-already-bound`);
1783
- }
1784
- else if (sessionId) {
1785
1763
  if (webchatChannel) {
1786
1764
  effectiveWebchatChannel = webchatChannel;
1787
1765
  deps.webchatChannelStore?.set(sessionId, webchatChannel);
@@ -1794,23 +1772,71 @@ export function buildHttpApp(deps) {
1794
1772
  }
1795
1773
  }
1796
1774
  }
1775
+ // Task 850 — assemble the bound target set and choose the channel server:
1776
+ // - ≥2 kinds: ONE unified `channel` server (one reply tool fanning to every
1777
+ // target's existing gateway endpoint). The mature per-kind servers are
1778
+ // untouched for single-target sessions.
1779
+ // - 1 kind: the existing per-kind server, unchanged.
1780
+ // waPath/webchatPath are the "is this kind carried on the PTY" signals the
1781
+ // standing binding-lost invariant below reads; the unified server carries
1782
+ // both, so both paths are set to the unified config path.
1783
+ let targetSet = {};
1784
+ if (effectiveWaChannel)
1785
+ targetSet = mergeTarget(targetSet, { kind: 'whatsapp', ...effectiveWaChannel });
1786
+ if (effectiveWebchatChannel)
1787
+ targetSet = mergeTarget(targetSet, { kind: 'webchat', ...effectiveWebchatChannel });
1788
+ let waPath = null;
1797
1789
  let webchatPath = null;
1798
- if (effectiveWebchatChannel && sessionId) {
1799
- const webchatServers = buildWebchatChannelMcpServers({
1800
- sessionId,
1801
- key: effectiveWebchatChannel.key,
1802
- gatewayUrl: effectiveWebchatChannel.gatewayUrl,
1803
- serverPath: effectiveWebchatChannel.serverPath,
1804
- });
1805
- webchatPath = webchatChannelMcpConfigPath(sessionId, tmpdir());
1790
+ if (sessionId && isMultiTarget(targetSet)) {
1791
+ const unifiedServers = buildChannelMcpServers({ sessionId, targets: targetSet });
1792
+ const unifiedPath = channelMcpConfigPath(sessionId, tmpdir());
1806
1793
  try {
1807
- writeFileSync(webchatPath, JSON.stringify({ mcpServers: webchatServers }, null, 2), 'utf8');
1808
- argv.push(...buildWebchatChannelArgv(webchatPath));
1809
- deps.logger(`[rc-spawn] op=webchat-channel key=${effectiveWebchatChannel.key} gateway=${effectiveWebchatChannel.gatewayUrl} path=${webchatPath}`);
1794
+ writeFileSync(unifiedPath, JSON.stringify({ mcpServers: unifiedServers }, null, 2), 'utf8');
1795
+ argv.push(...buildChannelArgv(unifiedPath));
1796
+ waPath = unifiedPath;
1797
+ webchatPath = unifiedPath;
1798
+ deps.logger(`[rc-spawn] op=channel-unified sessionId=${sessionId.slice(0, 8)} bound=[${targetKinds(targetSet).join(',')}] path=${unifiedPath}`);
1810
1799
  }
1811
1800
  catch (err) {
1812
- webchatPath = null;
1813
- deps.logger(`[rc-spawn] op=webchat-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1801
+ deps.logger(`[rc-spawn] op=channel-unified-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1802
+ }
1803
+ }
1804
+ else {
1805
+ if (effectiveWaChannel && sessionId) {
1806
+ const waServers = buildWaChannelMcpServers({
1807
+ sessionId,
1808
+ senderId: effectiveWaChannel.senderId,
1809
+ gatewayUrl: effectiveWaChannel.gatewayUrl,
1810
+ serverPath: effectiveWaChannel.serverPath,
1811
+ });
1812
+ waPath = waChannelMcpConfigPath(sessionId, tmpdir());
1813
+ try {
1814
+ writeFileSync(waPath, JSON.stringify({ mcpServers: waServers }, null, 2), 'utf8');
1815
+ argv.push(...buildWaChannelArgv(waPath));
1816
+ deps.logger(`[rc-spawn] op=wa-channel senderId=${effectiveWaChannel.senderId} gateway=${effectiveWaChannel.gatewayUrl} path=${waPath}`);
1817
+ }
1818
+ catch (err) {
1819
+ waPath = null;
1820
+ deps.logger(`[rc-spawn] op=wa-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1821
+ }
1822
+ }
1823
+ if (effectiveWebchatChannel && sessionId) {
1824
+ const webchatServers = buildWebchatChannelMcpServers({
1825
+ sessionId,
1826
+ key: effectiveWebchatChannel.key,
1827
+ gatewayUrl: effectiveWebchatChannel.gatewayUrl,
1828
+ serverPath: effectiveWebchatChannel.serverPath,
1829
+ });
1830
+ webchatPath = webchatChannelMcpConfigPath(sessionId, tmpdir());
1831
+ try {
1832
+ writeFileSync(webchatPath, JSON.stringify({ mcpServers: webchatServers }, null, 2), 'utf8');
1833
+ argv.push(...buildWebchatChannelArgv(webchatPath));
1834
+ deps.logger(`[rc-spawn] op=webchat-channel key=${effectiveWebchatChannel.key} gateway=${effectiveWebchatChannel.gatewayUrl} path=${webchatPath}`);
1835
+ }
1836
+ catch (err) {
1837
+ webchatPath = null;
1838
+ deps.logger(`[rc-spawn] op=webchat-channel-write-failed err=${JSON.stringify(err instanceof Error ? err.message : String(err))}`);
1839
+ }
1814
1840
  }
1815
1841
  }
1816
1842
  // Task 626 — the initial message is the trailing positional, processed as
@@ -1944,7 +1970,7 @@ export function buildHttpApp(deps) {
1944
1970
  // when one exists. Non-fatal: the PTY here is already serving a live thread —
1945
1971
  // a failed classification sidecar must not tear it down, so it logs and the
1946
1972
  // session keeps running (the pre-fix reader-blind behaviour, no worse).
1947
- const classifyChannel = adminRole === 'admin' ? adminChannel : undefined;
1973
+ const classifyChannel = rcRole !== undefined ? adminChannel : undefined;
1948
1974
  const classifySenderId = adminSenderId ?? effectiveWaChannel?.senderId ?? effectiveWebchatChannel?.key;
1949
1975
  if (sessionId && classifyChannel && classifySenderId) {
1950
1976
  const classifySidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
@@ -1952,7 +1978,10 @@ export function buildHttpApp(deps) {
1952
1978
  try {
1953
1979
  writeSidecar(classifySidecarPath, {
1954
1980
  senderId: classifySenderId,
1955
- role: 'admin',
1981
+ // Task 852 — the actual role from the body ('admin' | 'public'),
1982
+ // not a hardcoded 'admin'; rcRole is defined whenever
1983
+ // classifyChannel is.
1984
+ role: rcRole,
1956
1985
  channel: classifyChannel,
1957
1986
  url: null,
1958
1987
  startedAt: new Date().toISOString(),