@rubytech/create-maxy-code 0.1.286 → 0.1.287

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 (100) hide show
  1. package/dist/__tests__/samba-provision.test.js +84 -16
  2. package/dist/index.js +17 -8
  3. package/dist/samba-provision.js +81 -24
  4. package/dist/uninstall.js +5 -1
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  7. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
  8. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
  9. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
  10. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
  11. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
  12. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  13. package/payload/platform/plugins/docs/references/deployment.md +1 -1
  14. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  15. package/payload/platform/plugins/docs/references/samba.md +25 -12
  16. package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
  17. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
  18. package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
  19. package/payload/platform/scripts/installer-device-verify.sh +74 -3
  20. package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
  21. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
  22. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  23. package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
  24. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  25. package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
  26. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  27. package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
  28. package/payload/server/maxy-edge.js +1 -1
  29. package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
  30. package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
  31. package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
  32. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
  33. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
  34. package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
  35. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
  36. package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
  37. package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
  38. package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
  39. package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
  40. package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
  41. package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
  42. package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
  43. package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
  44. package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
  45. package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
  46. package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
  47. package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
  48. package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
  49. package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
  50. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
  51. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
  52. package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
  53. package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
  54. package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
  55. package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
  56. package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
  57. package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
  58. package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
  59. package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
  60. package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
  61. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
  62. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
  63. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
  64. package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
  65. package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
  66. package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
  67. package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
  68. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
  69. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
  70. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
  71. package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
  72. package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
  73. package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
  74. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
  75. package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
  76. package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
  77. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
  78. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
  79. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
  80. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
  81. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
  82. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
  83. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
  84. package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
  85. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
  86. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
  87. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
  88. package/payload/server/public/browser.html +4 -4
  89. package/payload/server/public/data.html +5 -5
  90. package/payload/server/public/graph.html +6 -6
  91. package/payload/server/public/index.html +5 -5
  92. package/payload/server/public/public.html +4 -4
  93. package/payload/server/server.js +269 -81
  94. package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
  95. package/payload/server/public/assets/channel-yIp47StE.js +0 -1
  96. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
  97. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
  98. package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
  99. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
  100. /package/payload/server/public/assets/{useSelectionMode-DMNfbxHW.js → useSelectionMode-hZ5bdL8b.js} +0 -0
@@ -39,6 +39,31 @@ HOOK_INPUT=$(cat 2>/dev/null || true)
39
39
  # Fail-open if the JSON encoder is unavailable.
40
40
  command -v python3 >/dev/null 2>&1 || exit 0
41
41
 
42
+ # Task 753 — suppress the directive on a native channel turn. A channel inbound
43
+ # arrives as a `<channel source="..." ...>` event; the channel service already
44
+ # reframes that event text into a select-and-dispatch instruction, so injecting
45
+ # the standing routing ladder on top is redundant. The marker is matched on the
46
+ # parsed `.prompt` field (not raw stdin) and only at the start of the prompt, so
47
+ # a normal admin prompt that merely mentions "channel" is never suppressed.
48
+ # Fail-open: if the prompt cannot be parsed, do NOT suppress (inject as usual).
49
+ IS_CHANNEL_TURN=$(printf '%s' "$HOOK_INPUT" | python3 -c '
50
+ import json, sys
51
+ try:
52
+ p = json.load(sys.stdin).get("prompt", "")
53
+ except Exception:
54
+ print("no"); sys.exit(0)
55
+ print("yes" if isinstance(p, str) and p.lstrip().startswith("<channel source=") else "no")
56
+ ' 2>/dev/null || echo "no")
57
+ if [ "$IS_CHANNEL_TURN" = "yes" ]; then
58
+ SID_C=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
59
+ try:
60
+ print(json.load(sys.stdin).get("session_id","") or "?")
61
+ except Exception:
62
+ print("?")' 2>/dev/null)
63
+ echo "[prompt-optimiser-directive] skipped reason=channel-turn session=${SID_C:-?}" >&2
64
+ exit 0
65
+ fi
66
+
42
67
  # Read a generated list from the account dir (cwd). On a missing file, emit a
43
68
  # visible breadcrumb to stderr and echo a one-line placeholder so the ladder
44
69
  # still names the tier.
@@ -97,21 +122,45 @@ print(json.dumps({
97
122
  [ -n "$OUT" ] || exit 0
98
123
  printf '%s\n' "$OUT"
99
124
 
100
- # Durable breadcrumb: capture the per-turn injection in $LOG_DIR so "directive
101
- # fired on this turn" is greppable for compliance review, not only on stderr
102
- # (Task 719). Fail-open: no LOG_DIR, missing dir, or unwritable -> skip the file;
103
- # the stderr line below still emits. session_id is parsed from the captured
104
- # stdin; absent -> "?".
105
- if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ]; then
106
- SID=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
125
+ # Session id, content hash, timestamp computed unconditionally (cheap) so the
126
+ # stored file path and the breadcrumb can reference them even when LOG_DIR is
127
+ # absent (file=-). session_id is parsed from the captured stdin; absent -> "?".
128
+ SID=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
107
129
  try:
108
130
  print(json.load(sys.stdin).get("session_id","") or "")
109
131
  except Exception:
110
132
  print("")' 2>/dev/null)
111
- TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
112
- printf '%s [prompt-optimiser-directive] injected len=%s session=%s\n' \
113
- "$TS" "${#DIRECTIVE}" "${SID:-?}" >> "$LOG_DIR/prompt-optimiser-directive.log" 2>/dev/null || true
133
+ SID=${SID:-?}
134
+ SHA=$(printf '%s' "$DIRECTIVE" | python3 -c 'import hashlib,sys
135
+ sys.stdout.write(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' 2>/dev/null || echo "?")
136
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
137
+
138
+ # Task 746: persist the EXACT emitted additionalContext bytes per turn, so the
139
+ # full directive is recoverable from an operator surface (the JSONL attachment
140
+ # row Claude Code keeps is truncated to <=2.6 KB; this file is not). Keyed by
141
+ # session + epoch seconds + pid -> one file per injection. Fail-open: a missing
142
+ # LOG_DIR, an unknown session, an un-creatable dir, or an unwritable file all
143
+ # skip the write and leave DIR_FILE=- ; the turn is never blocked.
144
+ DIR_FILE="-"
145
+ if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ] && [ "$SID" != "?" ]; then
146
+ STORE="$LOG_DIR/prompt-optimiser-directives/$SID"
147
+ if mkdir -p "$STORE" 2>/dev/null; then
148
+ CAND="$STORE/$(date +%s)-$$.txt"
149
+ if printf '%s' "$DIRECTIVE" > "$CAND" 2>/dev/null; then
150
+ DIR_FILE="$CAND"
151
+ fi
152
+ fi
153
+ fi
154
+
155
+ # Durable breadcrumb (Task 719 + 746): length + content hash + stored path +
156
+ # session, so "directive fired on this turn" carries enough to recover and
157
+ # verify the exact bytes. The `injected len=` and `session=` substrings are
158
+ # preserved for the existing greps/tests.
159
+ if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ]; then
160
+ printf '%s [prompt-optimiser-directive] injected len=%s sha256=%s file=%s session=%s\n' \
161
+ "$TS" "${#DIRECTIVE}" "$SHA" "$DIR_FILE" "$SID" \
162
+ >> "$LOG_DIR/prompt-optimiser-directive.log" 2>/dev/null || true
114
163
  fi
115
164
 
116
- echo "[prompt-optimiser-directive] injected len=${#DIRECTIVE}" >&2
165
+ echo "[prompt-optimiser-directive] injected len=${#DIRECTIVE} sha256=$SHA file=$DIR_FILE session=$SID" >&2
117
166
  exit 0
@@ -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:57caddb053d6974f88299198468d31050c72eb02f3310a04eaf2a1d81fdb444c
4
+ content-hash: sha256:a058a1f676b20300c5b0cbba3a8eb4f1ccb4d3e127a19865286b1500a23b9cd0
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -344,7 +344,7 @@ These are part of Maxy's foundation and cannot be disabled:
344
344
  | `tasks` | Task lifecycle — create, update, list, relate, complete |
345
345
  | `workflows` | Persistent named workflows — reusable instruction sets |
346
346
  | `contacts` | CRM contact management — create, lookup, update, list |
347
- | `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. |
347
+ | `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. The hook also persists the exact injected `additionalContext` per turn to `$LOG_DIR/prompt-optimiser-directives/<session>/<epoch>-<pid>.txt` and records `len`, `sha256`, and the stored `file=` path in `$LOG_DIR/prompt-optimiser-directive.log`, so any past turn's full ~17 KB directive is recoverable and verifiable (the JSONL attachment row Claude Code keeps is truncated to ≤2.6 KB). `platform/scripts/prompt-optimiser-audit.sh` reconciles breadcrumbs against stored files per session (`injected=N stored=M`; `N!=M` is the leak signature, and any breadcrumb whose `file=` is missing is named). |
348
348
  | `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |
349
349
 
350
350
  ### Maxy Plugins (user-selectable)
@@ -2443,8 +2443,8 @@ authoritative. This section names the surfaces and what backs each.
2443
2443
  | Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
2444
2444
  | 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` |
2445
2445
  | 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` |
2446
- | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows the **operator display name** (`Person givenName familyName`, resolved server-side from `senderId`; falls back to the raw `senderId` when unbound/unresolved), with the agent session title as the hover tooltip only. | `/whatsapp-reader/conversations` |
2447
- | Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. | `/whatsapp-reader/stream` |
2446
+ | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows a **display name** resolved by precedence `operatorName ?? whatsappName ?? senderId ?? title` (Task 747): the bound `Person givenName familyName` when a `senderId`→`userId` binding exists, else the persisted WhatsApp display name (`pushName`, read from the latest `:Message:WhatsAppMessage.senderName`), else the raw `senderId`, else the agent title; the agent session title is the hover tooltip only. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`, same markup as the Sessions rows) formatted from the row's `lastMessageAt` (the last parseable turn's `ts`); a session with no parseable turn shows no time, never "Invalid Date". A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route (`/graph`, `/data`, `/browser`) it navigates to `/?wa=<sessionId>&projectDir=<dir>`, which the root shell hydrates on mount (then strips the query so a later refresh does not re-pin a closed conversation). Server logs `[wa-reader] op=operator-name-fallback senderId=… source=whatsapp-pushname` when the pushName fallback fires and `[wa-reader] op=conversations count=<n> withTimestamp=<m>` per list build; the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ …`. | `/whatsapp-reader/conversations` |
2447
+ | Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. While the operator is scrolled up a **jump-to-bottom control** (`.wa-jump`, bottom-right of the viewport) appears; clicking it scrolls to the newest turn and re-arms follow-tail, and it hides again at the bottom (Task 747). The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The thread also interleaves a collapsed `⚙ directive injected (N B)` row per turn (Task 746), sorted by timestamp just before the turn it informed; expanding one fetches the exact stored `prompt-optimiser-directive` bytes for that turn. The entries are listed by `GET /whatsapp-reader/directives?sessionId=` and the bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`, both admin-gated and account-scoped; a missing store degrades to no rows. | `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
2448
2448
  | 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` |
2449
2449
  | 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` |
2450
2450
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
@@ -3421,7 +3421,7 @@ If you need to restart the service manually (rare), ask Maxy to do it for you.
3421
3421
 
3422
3422
  ## Browsing the brand filesystem on your LAN (SMB)
3423
3423
 
3424
- Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only binding posture.
3424
+ Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the interface-binding posture (LAN-only on a private-LAN host, loopback-only on a public-only cloud host).
3425
3425
 
3426
3426
  ## Remote Access via Cloudflare
3427
3427
 
@@ -3604,9 +3604,9 @@ The share lives next to the rest of the brand. On a device that runs more than o
3604
3604
  The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
3605
3605
 
3606
3606
  1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
3607
- 2. **conf** — writes `/etc/samba/smb.conf` with a LAN-only `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`.
3607
+ 2. **conf** — writes `/etc/samba/smb.conf` with a `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`. The `[global]` bind posture depends on the host's interfaces (see "Interface binding" below); the marker records which posture fired — `bind=lan iface=<name>` or `bind=loopback-only`.
3608
3608
  3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
3609
- 4. **units** — `systemctl enable --now smbd nmbd` so the share is reachable as soon as the install finishes.
3609
+ 4. **units** — `systemctl enable --now smbd` so the share is reachable as soon as the install finishes. `nmbd` (NetBIOS name service) is never enabled: nothing mounts the share by NetBIOS name, and on a public-facing host it answers on `:137`/`:138` as a DDoS-reflection vector.
3610
3610
 
3611
3611
  macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
3612
3612
 
@@ -3641,18 +3641,31 @@ So:
3641
3641
  - Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
3642
3642
  - If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
3643
3643
 
3644
- ## LAN-only binding
3644
+ ## Interface binding
3645
3645
 
3646
- The `[global]` section binds smbd to loopback plus one LAN interface:
3646
+ `bind interfaces only = yes` is always set, which makes the `interfaces` line an allow-list rather than a hint. Smbd binds only to the interfaces named there. The installer classifies the host's interfaces and writes one of two postures:
3647
3647
 
3648
- ```
3649
- interfaces = lo <lan>
3650
- bind interfaces only = yes
3651
- ```
3648
+ - **Private LAN host (Raspberry Pi).** At least one non-loopback interface carries a private address — RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). The installer binds loopback plus that interface (`wlan0` preferred, then `eth0`, then the first other private interface):
3649
+
3650
+ ```
3651
+ interfaces = lo <lan>
3652
+ bind interfaces only = yes
3653
+ ```
3654
+
3655
+ The share is reachable on the LAN and nowhere else.
3656
+
3657
+ - **Public-only host (Hetzner Cloud).** The only non-loopback interface carries a routable public address (typically `eth0` with a `/32`, no private LAN). Binding `lo eth0` here would put smbd directly on the public internet, so the installer binds **loopback only**:
3658
+
3659
+ ```
3660
+ interfaces = lo
3661
+ bind interfaces only = yes
3662
+ ```
3663
+
3664
+ The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
3652
3665
 
3653
- `<lan>` is whichever non-loopback interface has an IPv4 address — `wlan0` preferred, then `eth0`, then the first other interface with an address. If the device has no LAN interface at all, the installer refuses to provision and exits — there is nothing safe to bind to.
3666
+ If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
3654
3667
 
3655
- This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB. **On a Hetzner box the share is therefore not reachable from the public internet** — operators reach it by `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445`, or by running the Hetzner box on a private network that the operator's machine also joins.
3668
+ This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
3656
3669
 
3657
3670
  ## Peer-brand lifecycle
3658
3671
 
@@ -3669,8 +3682,8 @@ The brand-stanza name is the only identifier the uninstaller matches on, so two
3669
3682
 
3670
3683
  - **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
3671
3684
  - **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
3672
- - **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd nmbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
3673
- - **Hetzner share not reachable from outside the box.** This is by design — see "LAN-only binding" above. Use SSH port forwarding.
3685
+ - **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
3686
+ - **Hetzner share not reachable from outside the box.** This is by design — see "Interface binding" above. A public-only host binds loopback only; use SSH port forwarding (`ssh -L 4445:localhost:445 …`).
3674
3687
 
3675
3688
  Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
3676
3689
 
@@ -216,8 +216,8 @@ authoritative. This section names the surfaces and what backs each.
216
216
  | Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
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
- | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows the **operator display name** (`Person givenName familyName`, resolved server-side from `senderId`; falls back to the raw `senderId` when unbound/unresolved), with the agent session title as the hover tooltip only. | `/whatsapp-reader/conversations` |
220
- | Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. | `/whatsapp-reader/stream` |
219
+ | Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows a **display name** resolved by precedence `operatorName ?? whatsappName ?? senderId ?? title` (Task 747): the bound `Person givenName familyName` when a `senderId`→`userId` binding exists, else the persisted WhatsApp display name (`pushName`, read from the latest `:Message:WhatsAppMessage.senderName`), else the raw `senderId`, else the agent title; the agent session title is the hover tooltip only. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`, same markup as the Sessions rows) formatted from the row's `lastMessageAt` (the last parseable turn's `ts`); a session with no parseable turn shows no time, never "Invalid Date". A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route (`/graph`, `/data`, `/browser`) it navigates to `/?wa=<sessionId>&projectDir=<dir>`, which the root shell hydrates on mount (then strips the query so a later refresh does not re-pin a closed conversation). Server logs `[wa-reader] op=operator-name-fallback senderId=… source=whatsapp-pushname` when the pushName fallback fires and `[wa-reader] op=conversations count=<n> withTimestamp=<m>` per list build; the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ …`. | `/whatsapp-reader/conversations` |
220
+ | Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. While the operator is scrolled up a **jump-to-bottom control** (`.wa-jump`, bottom-right of the viewport) appears; clicking it scrolls to the newest turn and re-arms follow-tail, and it hides again at the bottom (Task 747). The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The thread also interleaves a collapsed `⚙ directive injected (N B)` row per turn (Task 746), sorted by timestamp just before the turn it informed; expanding one fetches the exact stored `prompt-optimiser-directive` bytes for that turn. The entries are listed by `GET /whatsapp-reader/directives?sessionId=` and the bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`, both admin-gated and account-scoped; a missing store degrades to no rows. | `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
221
221
  | 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` |
222
222
  | 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` |
223
223
  | System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
@@ -134,7 +134,7 @@ If you need to restart the service manually (rare), ask {{productName}} to do it
134
134
 
135
135
  ## Browsing the brand filesystem on your LAN (SMB)
136
136
 
137
- Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only binding posture.
137
+ Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the interface-binding posture (LAN-only on a private-LAN host, loopback-only on a public-only cloud host).
138
138
 
139
139
  ## Remote Access via Cloudflare
140
140
 
@@ -26,7 +26,7 @@ These are part of {{productName}}'s foundation and cannot be disabled:
26
26
  | `tasks` | Task lifecycle — create, update, list, relate, complete |
27
27
  | `workflows` | Persistent named workflows — reusable instruction sets |
28
28
  | `contacts` | CRM contact management — create, lookup, update, list |
29
- | `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. |
29
+ | `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. The hook also persists the exact injected `additionalContext` per turn to `$LOG_DIR/prompt-optimiser-directives/<session>/<epoch>-<pid>.txt` and records `len`, `sha256`, and the stored `file=` path in `$LOG_DIR/prompt-optimiser-directive.log`, so any past turn's full ~17 KB directive is recoverable and verifiable (the JSONL attachment row Claude Code keeps is truncated to ≤2.6 KB). `platform/scripts/prompt-optimiser-audit.sh` reconciles breadcrumbs against stored files per session (`injected=N stored=M`; `N!=M` is the leak signature, and any breadcrumb whose `file=` is missing is named). |
30
30
  | `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |
31
31
 
32
32
  ### {{productName}} Plugins (user-selectable)
@@ -9,9 +9,9 @@ The share lives next to the rest of the brand. On a device that runs more than o
9
9
  The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
10
10
 
11
11
  1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
12
- 2. **conf** — writes `/etc/samba/smb.conf` with a LAN-only `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`.
12
+ 2. **conf** — writes `/etc/samba/smb.conf` with a `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`. The `[global]` bind posture depends on the host's interfaces (see "Interface binding" below); the marker records which posture fired — `bind=lan iface=<name>` or `bind=loopback-only`.
13
13
  3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
14
- 4. **units** — `systemctl enable --now smbd nmbd` so the share is reachable as soon as the install finishes.
14
+ 4. **units** — `systemctl enable --now smbd` so the share is reachable as soon as the install finishes. `nmbd` (NetBIOS name service) is never enabled: nothing mounts the share by NetBIOS name, and on a public-facing host it answers on `:137`/`:138` as a DDoS-reflection vector.
15
15
 
16
16
  macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
17
17
 
@@ -46,18 +46,31 @@ So:
46
46
  - Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
47
47
  - If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
48
48
 
49
- ## LAN-only binding
49
+ ## Interface binding
50
50
 
51
- The `[global]` section binds smbd to loopback plus one LAN interface:
51
+ `bind interfaces only = yes` is always set, which makes the `interfaces` line an allow-list rather than a hint. Smbd binds only to the interfaces named there. The installer classifies the host's interfaces and writes one of two postures:
52
52
 
53
- ```
54
- interfaces = lo <lan>
55
- bind interfaces only = yes
56
- ```
53
+ - **Private LAN host (Raspberry Pi).** At least one non-loopback interface carries a private address — RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). The installer binds loopback plus that interface (`wlan0` preferred, then `eth0`, then the first other private interface):
57
54
 
58
- `<lan>` is whichever non-loopback interface has an IPv4 address — `wlan0` preferred, then `eth0`, then the first other interface with an address. If the device has no LAN interface at all, the installer refuses to provision and exits — there is nothing safe to bind to.
55
+ ```
56
+ interfaces = lo <lan>
57
+ bind interfaces only = yes
58
+ ```
59
59
 
60
- This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB. **On a Hetzner box the share is therefore not reachable from the public internet** — operators reach it by `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445`, or by running the Hetzner box on a private network that the operator's machine also joins.
60
+ The share is reachable on the LAN and nowhere else.
61
+
62
+ - **Public-only host (Hetzner Cloud).** The only non-loopback interface carries a routable public address (typically `eth0` with a `/32`, no private LAN). Binding `lo eth0` here would put smbd directly on the public internet, so the installer binds **loopback only**:
63
+
64
+ ```
65
+ interfaces = lo
66
+ bind interfaces only = yes
67
+ ```
68
+
69
+ The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
70
+
71
+ If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
72
+
73
+ This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
61
74
 
62
75
  ## Peer-brand lifecycle
63
76
 
@@ -74,7 +87,7 @@ The brand-stanza name is the only identifier the uninstaller matches on, so two
74
87
 
75
88
  - **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
76
89
  - **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
77
- - **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd nmbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
78
- - **Hetzner share not reachable from outside the box.** This is by design — see "LAN-only binding" above. Use SSH port forwarding.
90
+ - **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
91
+ - **Hetzner share not reachable from outside the box.** This is by design — see "Interface binding" above. A public-only host binds loopback only; use SSH port forwarding (`ssh -L 4445:localhost:445 …`).
79
92
 
80
93
  Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
@@ -82,6 +82,10 @@ The agent never responds in a group. `checkGroupAccess` ([`inbound/access-contro
82
82
 
83
83
  Observation is fully preserved: group messages are stored in the ring buffer, recorded as activity, and persisted to the graph at the access-independent capture site, and remain visible via `whatsapp-conversations`, `whatsapp-group-info`, `list-groups`, and the `/whatsapp` reader. This is a Meta-policy constraint (automated messaging from an unofficial linked device is penalised, the same class as the reply-only DM posture); for agent-driven group messaging, Telegram is the recommended channel.
84
84
 
85
+ ## Native channel reply surface (Task 751)
86
+
87
+ A native admin channel session runs a per-sender `whatsapp-channel` MCP server (`platform/services/whatsapp-channel/`) exposing two reply-only tools: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to the gateway's `POST /wa-channel/reply-document`, which funnels through `sendWhatsAppDocument` — the single send core that owns account-dir path validation, the 100 MB ceiling, the canonical `[whatsapp:outbound] sent document …` log, and per-file reconciliation (`op=reply-document-reconciled` on delivery, `file-delivery-unreconciled` on a miss — no silent failure). A bare `SendUserFile` is also caught and delivered through the same core by a read-only JSONL follower on the session, so an attachment reaches the sender even when the agent skips the explicit tool. Both surfaces are reply-only: a file reply to a sender with no live conversation is refused. See [channels-whatsapp.md](references/channels-whatsapp.md) and `.docs/whatsapp-inbound-lifeline.md`.
88
+
85
89
  ## Live persistence
86
90
 
87
91
  Every `messages.upsert` event (both `notify` and `append`, both `fromMe` directions) writes a `:Message:WhatsAppMessage` row to Neo4j attached to the sessionKey-keyed `:Conversation`. A single capture site at `platform/ui/app/lib/whatsapp/manager.ts` covers inbound, outbound (Baileys echoes agent-sent messages back through `messages.upsert` with `fromMe=true`), and owner-mirror — without touching `outbound/send.ts`. `messageId` namespace is `whatsapp-live:<waName>:<remoteJid>:<msg.key.id>` where `<waName>` is the Baileys credential dirname (e.g. `default`); distinct from the `:ConversationArchive` `:Section` chunks written by the source-agnostic `conversation-archive` skill (parent `:ConversationArchive` keyed on `conversationIdentity`, plus `:Section` children) — live and archive live in disjoint shapes. Persist failures are loud (`[whatsapp-persist] FAIL …`) and never block dispatch — silent loss is the worse failure mode.
@@ -10,7 +10,7 @@ WhatsApp connects via Baileys multi-account:
10
10
 
11
11
  The agent has **no tool to initiate a WhatsApp message to an arbitrary number.** The `whatsapp-send` and `whatsapp-send-document` MCP tools were removed after an agent-initiated cold first-contact send (to a number with no prior conversation) tripped WhatsApp's anti-automation guard and restricted the operator's account. Initiating is intentionally unavailable, not merely gated — a soft approval gate already existed and did not prevent the incident.
12
12
 
13
- What the agent **can** still do: reply within an existing chat. The whatsapp-channel `reply` tool (per-sender stdio channel, `platform/services/whatsapp-channel/`) and reply-path document delivery (`SendUserFile` → `file-delivery-bridge.ts` → `POST /api/whatsapp/send-document`) respond to a sender who messaged first. Replying within your chats is what WhatsApp permits; starting new chats from a linked device is what it penalises.
13
+ What the agent **can** still do: reply within an existing chat. On a native admin channel session the whatsapp-channel server (`platform/services/whatsapp-channel/`) exposes two reply tools, both reply-only: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to `POST /wa-channel/reply-document` → `sendWhatsAppDocument`, the one send core that owns path validation, the 100 MB ceiling, the canonical log, and per-file reconciliation (Task 751). A bare `SendUserFile` is also caught and delivered through the same core by a read-only JSONL follower on the session, so an attachment reaches the sender even if the agent skips the explicit tool. File delivery is refused for a sender with no live conversation — same reply-only posture as text. (The removed `whatsapp-send-document` route tool and the bridge path `SendUserFile` → `file-delivery-bridge.ts` → `POST /api/whatsapp/send-document` are the legacy webchat/email bridge surface, not the native WhatsApp channel.) Replying within your chats is what WhatsApp permits; starting new chats from a linked device is what it penalises.
14
14
 
15
15
  ## Observation-only in groups (Task 726)
16
16
 
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # Reconciliation audit for the per-turn directive store (Task 746). For each
3
+ # session it emits `[prompt-optimiser-audit] session=<id> injected=<N> stored=<M>`
4
+ # (N = breadcrumb lines, M = content files) and names any breadcrumb whose
5
+ # file= path is missing on disk (the precise silent loss).
6
+ set -u
7
+ SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/prompt-optimiser-audit.sh"
8
+ [[ -x "$SCRIPT" ]] || { echo "FAIL: $SCRIPT not executable" >&2; exit 1; }
9
+ PASS=0; FAIL=0
10
+
11
+ LD=$(mktemp -d)
12
+ mkdir -p "$LD/prompt-optimiser-directives/sessA"
13
+ f1="$LD/prompt-optimiser-directives/sessA/100-11.txt"; printf 'one' > "$f1"
14
+ f2="$LD/prompt-optimiser-directives/sessA/101-12.txt"; printf 'two' > "$f2"
15
+ crumb="$LD/prompt-optimiser-directive.log"
16
+ printf 'T [prompt-optimiser-directive] injected len=3 sha256=x file=%s session=sessA\n' "$f1" >> "$crumb"
17
+ printf 'T [prompt-optimiser-directive] injected len=3 sha256=y file=%s session=sessA\n' "$f2" >> "$crumb"
18
+
19
+ # Healthy: 2 breadcrumbs, 2 files.
20
+ out=$(bash "$SCRIPT" --log-dir "$LD" sessA 2>/dev/null)
21
+ if printf '%s' "$out" | grep -q "\[prompt-optimiser-audit\] session=sessA injected=2 stored=2"; then
22
+ echo "PASS: healthy session reconciles injected==stored"; PASS=$((PASS+1))
23
+ else
24
+ echo "FAIL: healthy reconcile wrong: $out" >&2; FAIL=$((FAIL+1))
25
+ fi
26
+
27
+ # Leak: delete one content file -> injected=2 stored=1 + a missing-file line.
28
+ rm -f "$f2"
29
+ out=$(bash "$SCRIPT" --log-dir "$LD" sessA 2>/dev/null)
30
+ if printf '%s' "$out" | grep -q "session=sessA injected=2 stored=1" \
31
+ && printf '%s' "$out" | grep -q "missing-file=$f2"; then
32
+ echo "PASS: deleted file flagged as leak (injected>stored + missing-file)"; PASS=$((PASS+1))
33
+ else
34
+ echo "FAIL: leak not flagged: $out" >&2; FAIL=$((FAIL+1))
35
+ fi
36
+
37
+ # No argument -> walks every session in the breadcrumb log.
38
+ printf 'T [prompt-optimiser-directive] injected len=1 sha256=z file=- session=sessB\n' >> "$crumb"
39
+ out=$(bash "$SCRIPT" --log-dir "$LD" 2>/dev/null)
40
+ if printf '%s' "$out" | grep -q "session=sessA " && printf '%s' "$out" | grep -q "session=sessB "; then
41
+ echo "PASS: no-arg mode walks all sessions"; PASS=$((PASS+1))
42
+ else
43
+ echo "FAIL: no-arg mode missed a session: $out" >&2; FAIL=$((FAIL+1))
44
+ fi
45
+ # An explicitly-named session with no breadcrumbs reconciles cleanly to 0==0 on
46
+ # a single line (regression: grep -c's exit-1 must not double-emit "0\n0").
47
+ out=$(bash "$SCRIPT" --log-dir "$LD" sessZ 2>/dev/null); rc=$?
48
+ nlines=$(printf '%s\n' "$out" | grep -c 'session=sessZ injected=')
49
+ if [[ "$rc" -eq 0 ]] \
50
+ && printf '%s' "$out" | grep -Fqx "[prompt-optimiser-audit] session=sessZ injected=0 stored=0" \
51
+ && [[ "$nlines" -eq 1 ]]; then
52
+ echo "PASS: empty named session -> single clean injected=0 stored=0, rc=0"; PASS=$((PASS+1))
53
+ else
54
+ echo "FAIL: empty named session mis-reported (rc=$rc lines=$nlines out=$out)" >&2; FAIL=$((FAIL+1))
55
+ fi
56
+ rm -rf "$LD"
57
+
58
+ echo "----"; echo "PASS=$PASS FAIL=$FAIL"
59
+ [[ "$FAIL" -eq 0 ]]
@@ -229,11 +229,82 @@ REMOTE
229
229
  CHECK_RC=$?
230
230
  set -e
231
231
 
232
- if [[ $CHECK_RC -eq 0 ]]; then
233
- echo "OK $NAME — CDP banner present" | tee -a "$SUMMARY_LOG"
234
- else
232
+ if [[ $CHECK_RC -ne 0 ]]; then
235
233
  echo "FAIL $NAME — CDP banner missing (rc=$CHECK_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
236
234
  FAILED=1
235
+ continue
236
+ fi
237
+
238
+ # Step 3 — standing Samba socket audit (Task 755). Reconcile the *actual*
239
+ # listening sockets against the bind invariant, independent of any install
240
+ # log, so this catches a drifted or re-opened box as well as a fresh
241
+ # mis-provision. Two assertions:
242
+ # • smbd (TCP :445/:139) listens only on loopback or a private LAN address.
243
+ # A public or wildcard (0.0.0.0 / [::] / public IP) listener is internet
244
+ # exposure — the BSI-reported defect. Private LAN listeners are expected
245
+ # on a Pi (LAN+loopback); loopback-only is expected on a cloud box. The
246
+ # private-vs-public test mirrors isPrivateIPv4() in samba-provision.ts.
247
+ # • nmbd is not active (the NetBIOS DDoS-reflection service is never run).
248
+ # Matched by port, so no root is needed to read process names.
249
+ #
250
+ # Loaded via `read -d ''` rather than `$(cat <<…)`: the audit body contains a
251
+ # `case` and process substitution, whose parens break bash 3.2's command-
252
+ # substitution scanner (operators may run this script from a macOS bash 3.2).
253
+ # The body itself runs on the device's modern bash over ssh.
254
+ IFS= read -r -d '' SOCKET_AUDIT <<'REMOTE' || true
255
+ set -uo pipefail
256
+
257
+ is_private_v4() {
258
+ local ip="$1" a b _rest
259
+ IFS=. read -r a b _rest <<<"$ip"
260
+ [[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ ]] || return 1
261
+ (( a == 10 )) && return 0
262
+ (( a == 172 && b >= 16 && b <= 31 )) && return 0
263
+ (( a == 192 && b == 168 )) && return 0
264
+ (( a == 100 && b >= 64 && b <= 127 )) && return 0
265
+ (( a == 169 && b == 254 )) && return 0
266
+ return 1
267
+ }
268
+
269
+ BAD=""
270
+ while read -r localaddr; do
271
+ [[ -z "$localaddr" ]] && continue
272
+ host="${localaddr%:*}" # strip :port
273
+ host="${host#'['}"; host="${host%']'}" # strip IPv6 brackets (quoted: '[' is a glob class)
274
+ case "$host" in
275
+ 127.*|::1) continue ;; # loopback — allowed
276
+ esac
277
+ if [[ "$host" == *.*.*.* ]] && is_private_v4 "$host"; then
278
+ continue # private IPv4 LAN (Pi) — allowed
279
+ fi
280
+ BAD+="$localaddr"$'\n'
281
+ done < <(ss -tuln 2>/dev/null | awk '{print $5}' | grep -E ':(445|139)$' || true)
282
+
283
+ NMBD=$(systemctl is-active nmbd 2>/dev/null || true)
284
+ FAIL=0
285
+ if [[ -n "${BAD//$'\n'/}" ]]; then
286
+ echo "smbd-exposed-listener:"
287
+ printf '%s' "$BAD"
288
+ FAIL=1
289
+ fi
290
+ if [[ "$NMBD" == "active" ]]; then
291
+ echo "nmbd-active"
292
+ FAIL=1
293
+ fi
294
+ [[ $FAIL -eq 0 ]] && echo "socket-audit-ok smbd=loopback-or-private nmbd=$NMBD"
295
+ exit $FAIL
296
+ REMOTE
297
+
298
+ set +e
299
+ run_ssh "$TARGET" "$PASS" "$SOCKET_AUDIT" >>"$SUMMARY_LOG" 2>&1
300
+ AUDIT_RC=$?
301
+ set -e
302
+
303
+ if [[ $AUDIT_RC -eq 0 ]]; then
304
+ echo "OK $NAME — CDP banner present; smbd loopback/LAN-only, nmbd inactive" | tee -a "$SUMMARY_LOG"
305
+ else
306
+ echo "FAIL $NAME — Samba socket audit failed (rc=$AUDIT_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
307
+ FAILED=1
237
308
  fi
238
309
  done
239
310
 
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # prompt-optimiser-audit.sh — reconcile per-turn directive-injection breadcrumbs
3
+ # against the stored content files (Task 746). A missing content write emits no
4
+ # error on its own, so this standing check is the leak detector: for each
5
+ # session it prints
6
+ # [prompt-optimiser-audit] session=<id> injected=<N> stored=<M>
7
+ # where N is the count of breadcrumb injection lines and M is the count of
8
+ # content files. N != M is the leak signature. Every breadcrumb whose file=
9
+ # path is absent on disk is named on its own `missing-file=` line.
10
+ #
11
+ # Usage:
12
+ # prompt-optimiser-audit.sh [--log-dir <dir>] [<session_id>]
13
+ # With no <session_id> it walks every session that appears in the breadcrumb
14
+ # log. --log-dir overrides the resolved account logs dir (used by tests); the
15
+ # default resolves the single account's logs dir, the same dir the hook writes.
16
+ #
17
+ # Exit codes:
18
+ # 0 every reported session reconciles (injected == stored)
19
+ # 1 at least one session leaks (injected != stored)
20
+ # 2 no log dir could be resolved
21
+ set -uo pipefail
22
+
23
+ LOG_DIR=""
24
+ SESSION=""
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --log-dir) LOG_DIR="$2"; shift 2 ;;
28
+ *) SESSION="$1"; shift ;;
29
+ esac
30
+ done
31
+
32
+ # Resolve the account logs dir when not overridden: {installDir}/data/accounts/*/logs.
33
+ if [ -z "$LOG_DIR" ]; then
34
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
36
+ INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"
37
+ for d in "$INSTALL_DIR"/data/accounts/*/logs; do
38
+ [ -d "$d" ] && LOG_DIR="$d" && break
39
+ done
40
+ fi
41
+ if [ -z "$LOG_DIR" ] || [ ! -d "$LOG_DIR" ]; then
42
+ echo "[prompt-optimiser-audit] no-log-dir resolved=${LOG_DIR:-<none>}" >&2
43
+ exit 2
44
+ fi
45
+
46
+ CRUMB="$LOG_DIR/prompt-optimiser-directive.log"
47
+ STORE_ROOT="$LOG_DIR/prompt-optimiser-directives"
48
+
49
+ # Sessions to report: the explicit arg, else every distinct non-"?" session in
50
+ # the breadcrumb log (the breadcrumb's session= is always the last field).
51
+ sessions() {
52
+ if [ -n "$SESSION" ]; then
53
+ echo "$SESSION"; return
54
+ fi
55
+ [ -f "$CRUMB" ] || return
56
+ grep -o 'session=[^ ]*$' "$CRUMB" 2>/dev/null | cut -d= -f2 | grep -v '^?$' | sort -u
57
+ }
58
+
59
+ rc=0
60
+ while IFS= read -r sid; do
61
+ [ -n "$sid" ] || continue
62
+ injected=0
63
+ # grep -c prints a count AND exits 1 on zero matches, so a `|| echo 0` would
64
+ # double-emit "0\n0"; capture the count and default an empty/unreadable file.
65
+ if [ -f "$CRUMB" ]; then
66
+ injected=$(grep -c "session=$sid\$" "$CRUMB" 2>/dev/null)
67
+ injected=${injected:-0}
68
+ fi
69
+ stored=0
70
+ [ -d "$STORE_ROOT/$sid" ] && stored=$(find "$STORE_ROOT/$sid" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l | tr -d ' ')
71
+ echo "[prompt-optimiser-audit] session=$sid injected=$injected stored=$stored"
72
+ [ "$injected" != "$stored" ] && rc=1
73
+ # Name every breadcrumb whose stored file is absent.
74
+ if [ -f "$CRUMB" ]; then
75
+ grep "session=$sid\$" "$CRUMB" 2>/dev/null | grep -o 'file=[^ ]*' | cut -d= -f2- | while IFS= read -r f; do
76
+ [ "$f" = "-" ] && continue
77
+ [ -e "$f" ] || echo "[prompt-optimiser-audit] session=$sid missing-file=$f"
78
+ done
79
+ fi
80
+ done < <(sessions)
81
+
82
+ exit "$rc"