@rubytech/create-maxy-code 0.1.286 → 0.1.288

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 (107) hide show
  1. package/dist/__tests__/cloudflared-slice.test.js +3 -2
  2. package/dist/__tests__/cloudflared-version-pin.test.js +24 -0
  3. package/dist/__tests__/samba-provision.test.js +84 -16
  4. package/dist/index.js +29 -12
  5. package/dist/port-resolution.js +4 -3
  6. package/dist/samba-provision.js +81 -24
  7. package/dist/uninstall.js +16 -11
  8. package/package.json +1 -1
  9. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  10. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
  11. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
  12. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
  13. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
  14. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
  15. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +8 -4
  16. package/payload/platform/plugins/cloudflare/references/manual-setup.md +18 -2
  17. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  18. package/payload/platform/plugins/docs/references/deployment.md +1 -1
  19. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  20. package/payload/platform/plugins/docs/references/samba.md +25 -12
  21. package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
  22. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
  23. package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
  24. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +47 -10
  25. package/payload/platform/scripts/installer-device-verify.sh +74 -3
  26. package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
  27. package/payload/platform/scripts/resume-tunnel.sh +45 -28
  28. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
  29. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  30. package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
  31. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  32. package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
  33. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  34. package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
  35. package/payload/server/maxy-edge.js +1 -1
  36. package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
  37. package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
  38. package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
  39. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
  40. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
  41. package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
  42. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
  43. package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
  44. package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
  45. package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
  46. package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
  47. package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
  48. package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
  49. package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
  50. package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
  51. package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
  52. package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
  53. package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
  54. package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
  55. package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
  56. package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
  57. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
  58. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
  59. package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
  60. package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
  61. package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
  62. package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
  63. package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
  64. package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
  65. package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
  66. package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
  67. package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
  68. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
  69. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
  70. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
  71. package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
  72. package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
  73. package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
  74. package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
  75. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
  76. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
  77. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
  78. package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
  79. package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
  80. package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
  81. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
  82. package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
  83. package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
  84. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
  85. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
  86. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
  87. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
  88. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
  89. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
  90. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
  91. package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
  92. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
  93. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
  94. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
  95. package/payload/server/public/browser.html +4 -4
  96. package/payload/server/public/data.html +5 -5
  97. package/payload/server/public/graph.html +6 -6
  98. package/payload/server/public/index.html +5 -5
  99. package/payload/server/public/public.html +4 -4
  100. package/payload/server/server.js +269 -81
  101. package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
  102. package/payload/server/public/assets/channel-yIp47StE.js +0 -1
  103. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
  104. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
  105. package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
  106. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
  107. /package/payload/server/public/assets/{useSelectionMode-DMNfbxHW.js → useSelectionMode-hZ5bdL8b.js} +0 -0
@@ -131,6 +131,61 @@ else
131
131
  fi
132
132
  rm -rf "$LD"
133
133
 
134
+ # Case 7 — Task 746: full directive persisted per turn. With LOG_DIR + a
135
+ # session id, the hook writes a content file whose bytes equal the emitted
136
+ # additionalContext, and the breadcrumb carries file= pointing at it plus a
137
+ # sha256 matching that file's bytes.
138
+ P746=$(mktemp -d)
139
+ out746=$(printf '%s' '{"prompt":"x","session_id":"sess-746-a"}' | LOG_DIR="$P746" bash "$HOOK" 2>/dev/null)
140
+ printf '%s' "$out746" | python3 -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])' > "$P746/expected.txt" 2>/dev/null
141
+ crumb="$P746/prompt-optimiser-directive.log"
142
+ dfile=$(grep -o 'file=[^ ]*' "$crumb" 2>/dev/null | head -1 | cut -d= -f2-)
143
+ slog=$(grep -o 'sha256=[0-9a-f]*' "$crumb" 2>/dev/null | head -1 | cut -d= -f2)
144
+ sfile=$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$dfile" 2>/dev/null)
145
+ if [[ -f "$dfile" ]] && cmp -s "$dfile" "$P746/expected.txt" \
146
+ && [[ -n "$slog" && "$slog" == "$sfile" ]] \
147
+ && [[ "$dfile" == "$P746/prompt-optimiser-directives/sess-746-a/"*.txt ]]; then
148
+ echo "PASS: full directive persisted; breadcrumb file=/sha256 match content"; PASS=$((PASS+1))
149
+ else
150
+ echo "FAIL: directive content store wrong (file=$dfile slog=$slog sfile=$sfile)" >&2; FAIL=$((FAIL+1))
151
+ fi
152
+
153
+ # Case 8 — two injections in one session produce two distinct content files
154
+ # (one file per injection, so the audit's injected==stored invariant holds).
155
+ printf '%s' '{"prompt":"y","session_id":"sess-746-b"}' | LOG_DIR="$P746" bash "$HOOK" >/dev/null 2>&1
156
+ sleep 1
157
+ printf '%s' '{"prompt":"z","session_id":"sess-746-b"}' | LOG_DIR="$P746" bash "$HOOK" >/dev/null 2>&1
158
+ nfiles=$(ls "$P746/prompt-optimiser-directives/sess-746-b/"*.txt 2>/dev/null | wc -l | tr -d ' ')
159
+ if [[ "$nfiles" -eq 2 ]]; then
160
+ echo "PASS: two injections -> two distinct content files"; PASS=$((PASS+1))
161
+ else
162
+ echo "FAIL: expected 2 content files for sess-746-b, got $nfiles" >&2; FAIL=$((FAIL+1))
163
+ fi
164
+ rm -rf "$P746"
165
+
166
+ # Case 9 — Task 753: a native channel turn (the prompt is a `<channel source=`
167
+ # event) suppresses the directive — the event-text reframe replaces it there.
168
+ # Exit 0, EMPTY stdout (nothing injected), and a visible stderr breadcrumb.
169
+ chan_in='{"hook_event_name":"UserPromptSubmit","prompt":"<channel source=\"whatsapp\" sender_id=\"x\">\nprice the Willow House job\n</channel>","session_id":"chan-1"}'
170
+ chan_out=$(printf '%s' "$chan_in" | bash "$HOOK" 2>/dev/null); chan_exit=$?
171
+ chan_err=$(printf '%s' "$chan_in" | bash "$HOOK" 2>&1 >/dev/null)
172
+ if [[ "$chan_exit" -eq 0 && -z "$chan_out" ]] \
173
+ && printf '%s' "$chan_err" | grep -q "skipped reason=channel-turn"; then
174
+ echo "PASS: channel turn -> directive suppressed (exit 0, empty stdout, breadcrumb)"; PASS=$((PASS+1))
175
+ else
176
+ echo "FAIL: channel-turn suppression wrong (exit=$chan_exit stdout=$chan_out err=$chan_err)" >&2; FAIL=$((FAIL+1))
177
+ fi
178
+
179
+ # Case 10 — Task 753: a normal admin turn that merely MENTIONS the word channel
180
+ # is NOT suppressed — only the `<channel source=` event marker suppresses, so a
181
+ # Terminal/admin prompt still gets the directive.
182
+ norm_out=$(printf '%s' '{"prompt":"please open a support channel for acme"}' | bash "$HOOK" 2>/dev/null); norm_exit=$?
183
+ if [[ "$norm_exit" -eq 0 ]] && printf '%s' "$norm_out" | grep -q "hookSpecificOutput"; then
184
+ echo "PASS: non-channel prompt mentioning 'channel' still gets the directive"; PASS=$((PASS+1))
185
+ else
186
+ echo "FAIL: false-suppression on a normal prompt (exit=$norm_exit stdout=$norm_out)" >&2; FAIL=$((FAIL+1))
187
+ fi
188
+
134
189
  echo "----"
135
190
  echo "PASS=$PASS FAIL=$FAIL"
136
191
  [[ "$FAIL" -eq 0 ]]
@@ -12,14 +12,20 @@
12
12
  # UserPromptSubmit additionalContext is recorded: a
13
13
  # type:"attachment"/hook_success on 2.1.x, a
14
14
  # type:"hook_additional_context" on the Pi).
15
- # route taken = the turn contains a tool_use named Agent, Skill, ToolSearch,
16
- # or matching mcp__*.
15
+ # route taken = the turn contains a tool_use that is a genuine route:
16
+ # Agent/Task dispatch, native Skill, or a *__skill-load MCP
17
+ # tool. ToolSearch (a schema load) and the channel transport
18
+ # reply tools (mcp__*-channel__reply/-document) are NOT routes
19
+ # — counting them was the Task 747 bug that blinded the
20
+ # detector on every channel turn (the reply is mandatory).
17
21
  # trivial = the prompt is a slash-command or a one-word confirmation.
18
22
  # "Direct continuation" is NOT detectable from the transcript and is a stated
19
23
  # known limitation, not guessed.
20
24
  #
21
25
  # Emits, only on (directive fired AND not trivial AND no route):
22
- # <ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"
26
+ # <ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> tools=<csv> prompt="<clip>"
27
+ # tools=<csv> is the comma-joined tool names seen in the slice (empty on a
28
+ # no-tool freestyle turn), so a future false-negative shows what was (mis)counted.
23
29
  # to $LOG_DIR/prompt-optimiser-directive.log (same log as the fired breadcrumb,
24
30
  # so the two interleave into one greppable timeline) and to stderr.
25
31
  #
@@ -138,12 +144,20 @@ is_one_word = len(tokens) <= 1
138
144
  marker = "PROMPT-OPTIMISER DIRECTIVE"
139
145
  directive_fired = any(marker in json.dumps(r, ensure_ascii=False) for r in slice_rows)
140
146
 
141
- # Route taken = an assistant tool_use with a routing tool name.
142
- ROUTE_TOOLS = ("Agent", "Skill", "ToolSearch")
147
+ # Route taken = an assistant tool_use that is a genuine route: specialist
148
+ # dispatch (Agent/Task) or skill load (native Skill, or the *__skill-load MCP
149
+ # tool under either admin namespace). ToolSearch is a schema load, and the channel
150
+ # transport reply tools (mcp__*-channel__reply / reply-document) are mandatory
151
+ # transport, not routes — neither counts. Every tool name in the slice is
152
+ # collected for the tools= breadcrumb so a future false-negative shows what was
153
+ # (mis)counted.
154
+ ROUTE_TOOLS = ("Agent", "Task", "Skill")
143
155
  def is_route(name):
144
- return name in ROUTE_TOOLS or (isinstance(name, str) and name.startswith("mcp__"))
156
+ if not isinstance(name, str):
157
+ return False
158
+ return name in ROUTE_TOOLS or name.endswith("__skill-load")
145
159
 
146
- route_taken = False
160
+ tool_names = []
147
161
  for r in slice_rows:
148
162
  if not isinstance(r, dict) or r.get("type") != "assistant":
149
163
  continue
@@ -151,17 +165,19 @@ for r in slice_rows:
151
165
  content = msg.get("content")
152
166
  if isinstance(content, list):
153
167
  for b in content:
154
- if isinstance(b, dict) and b.get("type") == "tool_use" and is_route(b.get("name")):
155
- route_taken = True
156
- break
157
- if route_taken:
158
- break
168
+ if isinstance(b, dict) and b.get("type") == "tool_use":
169
+ nm = b.get("name")
170
+ if isinstance(nm, str):
171
+ tool_names.append(nm)
172
+
173
+ route_taken = any(is_route(n) for n in tool_names)
159
174
 
160
175
  if directive_fired and not is_slash and not is_one_word and not route_taken:
161
176
  clip = prompt_text.replace("\n", " ")[:80]
162
177
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
163
178
  sid8 = session[:8] if isinstance(session, str) else "?"
164
- print("%s [prompt-optimiser-compliance] directive-fired no-route-taken session=%s prompt=\"%s\"" % (ts, sid8, clip))
179
+ tools_csv = ",".join(tool_names)
180
+ print("%s [prompt-optimiser-compliance] directive-fired no-route-taken session=%s tools=%s prompt=\"%s\"" % (ts, sid8, tools_csv, clip))
165
181
  ' 2>/dev/null) || exit 0
166
182
 
167
183
  [ -n "$RESULT" ] || exit 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
 
@@ -422,7 +422,7 @@ Before persisting, **read every rendered page** of `base.pdf` — full-bleed pag
422
422
 
423
423
  ## 6. Sweep + dispatch
424
424
 
425
- When the durable routine 8.2) runs, or when the operator asks for new acceptances. Mint one **D1-Edit** token here — `${MINTED_D1_EDIT}` — per `api.md` § Minting a narrow token, resolving the permission-group id as `d1-data-capture.md` § 0 does. Reads use the Edit token too: the D1 **query** endpoint rejects a D1-Read token (`d1-data-capture.md`), so a separate read-scoped token is not minted.
425
+ Dispatch runs **only** inside a live operator session on the account's own device — when the operator asks for new acceptances, or when an in-session watcher running inside a still-live session performs the sweep. There is no off-device dispatcher; never register a Claude Code `/schedule` routine or any headless/cloud scheduler to run it (§ 8.2 says why). Mint one **D1-Edit** token here — `${MINTED_D1_EDIT}` — per `api.md` § Minting a narrow token, resolving the permission-group id as `d1-data-capture.md` § 0 does. Reads use the Edit token too: the D1 **query** endpoint rejects a D1-Read token (`d1-data-capture.md`), so a separate read-scoped token is not minted.
426
426
 
427
427
  1. **Read the unswept rows** with `${MINTED_D1_EDIT}`:
428
428
 
@@ -530,16 +530,20 @@ The flow has no application logging — the signal surface is the **D1 row state
530
530
 
531
531
  Signal fires *without reproducing end to end*: the test POST returns non-`ok`, and the confirming `SELECT WHERE swept = 0` shows no new row. Fix: re-mint the token confirming **both** Pages Edit and D1 Edit (`d1-data-capture.md` § 0). First thing to check when acceptances stop arriving.
532
532
 
533
- ### 8.2 Acceptance recorded but never dispatched (no-event) — the durable health check
533
+ ### 8.2 Acceptance recorded but never dispatched (no-event) — the in-session reconciliation check
534
534
 
535
- The sweep never ran, or `email-send` failed after the row existed — no log, because no action was taken. The sweep6) runs from a **durable platform routine**, not a session-only cron: a session cron dies with the session and produces no signal, so aged acceptances would sit undispatched silently. The routine's **health check** is the standing reconciliation:
535
+ The sweep never ran, or `email-send` failed after the row existed — no log, because no action was taken. **Capture is durable; dispatch is not.** The acceptance is recorded at the **edge** in D1 (intro / § 1a) the instant the signer submits nothing is ever lost, whether or not a session is running. **Dispatch** (stamp + email) needs the account's Pi-local tooling — the D1-Edit token, `email-send`, `stamp.mjs`, `ACCOUNT_DIR` — so it happens **only** while an operator session is live. An acceptance captured while no session is running sits **unswept** until the next session runs the sweep; latency is bounded by operator session cadence, not a cron. That is a deliberate limitation of the in-session-only model, not a defect — the durable D1 row guarantees no loss. The standing check is the operator's **session-open / before-close ritual**: run the reconciliation inside a live session, and any aged unswept row is the signal to sweep now.
536
+
537
+ > Dispatch runs **only** inside a live operator session on the account's own device. Never register a Claude Code `/schedule` routine, a `RemoteTrigger`, a `CronCreate` durable job, or any off-device/headless scheduler to run the sweep. `/schedule` spawns a fresh agent on **claude.ai's cloud**, which has none of this account's D1 token, `email-send`, `stamp.mjs`, or `ACCOUNT_DIR` — it physically cannot stamp or send. It is a Claude Code capability, not a Maxy one, and it is not a substitute for the in-session sweep. Monitoring and dispatch happen in-session, while the session is live. That is the only path.
538
+
539
+ The reconciliation query, run inside a live session at open and before close:
536
540
 
537
541
  ```bash
538
542
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
539
543
  "SELECT count(*), min(accepted_at) FROM acceptances WHERE swept = 0;"
540
544
  ```
541
545
 
542
- Any row whose `accepted_at` is older than the routine's cadence is an undispatched acceptance — the failure signal. Because § 6 flips `swept` only after both sends verify (and a text-only degrade is treated as a failed send, § 0 item 3), a failed or degraded dispatch leaves the row here for the next pass.
546
+ Any row whose `accepted_at` is older than the operator's session cadence is an undispatched acceptance — the failure signal. Because § 6 flips `swept` only after both sends verify (and a text-only degrade is treated as a failed send, § 0 item 3), a failed or degraded dispatch leaves the row here for the next pass.
543
547
 
544
548
  ### 8.3 Document modified after an acceptance, or base render missing (signed-content drift)
545
549
 
@@ -226,6 +226,7 @@ Only if that line prints nothing (all vars set) do you proceed to write the conf
226
226
  cat > "${CFG_DIR}/config.yml" <<EOF
227
227
  tunnel: ${TUNNEL_ID}
228
228
  credentials-file: ${CFG_DIR}/${TUNNEL_ID}.json
229
+ no-autoupdate: true
229
230
  ingress:
230
231
  - hostname: admin.maxy.bot
231
232
  service: http://localhost:${PORT}
@@ -235,7 +236,7 @@ ingress:
235
236
  EOF
236
237
  ```
237
238
 
238
- **Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all.
239
+ **Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all. `no-autoupdate: true` stops the connector downloading a new binary and replacing itself (which exits the running process); the cloudflared version is installer-owned and bumped deliberately. This is belt-and-braces with the `--no-autoupdate` flag `resume-tunnel.sh` already passes on the spawn argv — the flag covers every existing install on its next service restart, this line covers a connector started any other way from a freshly-provisioned config. Task 757.
239
240
 
240
241
  **Success:** Confirm with:
241
242
 
@@ -425,7 +426,22 @@ A non-530 response means the tunnel is live.
425
426
 
426
427
  **Do NOT use `sudo cloudflared service install`.** It writes `/etc/systemd/system/cloudflared.service`, copies your config to `/etc/cloudflared/config.yml` (a system-wide path), and runs the connector as root — breaking brand isolation on any device that hosts more than one brand under the same Linux user. We tried this path on 2026-04-19; it created a duplicate connector parallel to the existing user-space one and required an explicit uninstall to undo.
427
428
 
428
- The correct production pattern is already in place on every Maxy device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`. When the user-space service restarts, the connector restarts with it. The connector runs as the Linux user (not root), its config is read from `${CFG_DIR}/config.yml` (brand-scoped, never `/etc`), and multiple brands coexist because each has its own unit file (`maxy.service`, `realagent.service`, etc.).
429
+ The correct production pattern is already in place on every Maxy device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`. The connector runs as the Linux user (not root), its config is read from `${CFG_DIR}/config.yml` (brand-scoped, never `/etc`), and multiple brands coexist because each has its own unit file (`maxy.service`, `realagent.service`, etc.).
430
+
431
+ **The connector is a supervised, self-healing unit (Task 602 + Task 757).** `resume-tunnel.sh` does not run cloudflared as a child of the brand service. It spawns it into its own transient systemd **service** — `cloudflared-<brand>.service` under `cloudflared.slice` — with `Restart=always`. Two consequences: a brand-service restart no longer reaps the connector (Task 602's cgroup decoupling), and systemd resurrects the connector on **any** exit — crash, OOM, manual kill (Task 757). The connector also runs with `--no-autoupdate`, so it never downloads a new binary and replaces itself; the version is installer-owned (pinned in the installer, bumped deliberately). `StartLimitIntervalSec=300`/`StartLimitBurst=5` mean a genuinely broken binary surfaces as a `failed` unit instead of looping forever.
432
+
433
+ **Observability.** A connector death is now a logged, countable event, not an invisible gap:
434
+
435
+ ```
436
+ # Every restart systemd performs is journaled for the connector's own unit:
437
+ journalctl --user -u cloudflared-<brand>.service | grep "Scheduled restart job"
438
+ # (each line: "Scheduled restart job, restart counter is at N")
439
+
440
+ # Confirm auto-update is off — cloudflared prints its effective settings at startup:
441
+ grep "no-autoupdate:true" "${CFG_DIR}/logs/cloudflared.log"
442
+ ```
443
+
444
+ `no-autoupdate:true` in cloudflared's startup `Settings:` line is the per-box rollout acceptance signal. A unit that has tripped its start limit shows `Active: failed` under `systemctl --user status cloudflared-<brand>.service` — investigate the binary rather than restarting blindly.
429
445
 
430
446
  **Durability prerequisites (one-time per device):**
431
447
 
@@ -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