@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.
- package/dist/__tests__/cloudflared-slice.test.js +3 -2
- package/dist/__tests__/cloudflared-version-pin.test.js +24 -0
- package/dist/__tests__/samba-provision.test.js +84 -16
- package/dist/index.js +29 -12
- package/dist/port-resolution.js +4 -3
- package/dist/samba-provision.js +81 -24
- package/dist/uninstall.js +16 -11
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +8 -4
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +18 -2
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
- package/payload/platform/plugins/docs/references/deployment.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/docs/references/samba.md +25 -12
- package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
- package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +47 -10
- package/payload/platform/scripts/installer-device-verify.sh +74 -3
- package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
- package/payload/platform/scripts/resume-tunnel.sh +45 -28
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
- package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
- package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
- package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
- package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
- package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
- package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
- package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
- package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
- package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
- package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
- package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
- package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +5 -5
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +269 -81
- package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
- package/payload/server/public/assets/channel-yIp47StE.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
- package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
- /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
|
|
16
|
-
# or
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
156
|
+
if not isinstance(name, str):
|
|
157
|
+
return False
|
|
158
|
+
return name in ROUTE_TOOLS or name.endswith("__skill-load")
|
|
145
159
|
|
|
146
|
-
|
|
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"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
101
|
-
#
|
|
102
|
-
# (
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
3644
|
+
## Interface binding
|
|
3645
3645
|
|
|
3646
|
-
|
|
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
|
-
|
|
3650
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
3673
|
-
- **Hetzner share not reachable from outside the box.** This is by design — see "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
49
|
+
## Interface binding
|
|
50
50
|
|
|
51
|
-
|
|
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
|
-
|
|
55
|
+
```
|
|
56
|
+
interfaces = lo <lan>
|
|
57
|
+
bind interfaces only = yes
|
|
58
|
+
```
|
|
59
59
|
|
|
60
|
-
|
|
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
|
|
78
|
-
- **Hetzner share not reachable from outside the box.** This is by design — see "
|
|
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.
|
|
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
|
|