@rubytech/create-maxy-code 0.1.492 → 0.1.493

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 (50) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
  4. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
  5. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
  6. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +11 -1
  7. package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
  8. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
  11. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
  12. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
  13. package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
  14. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
  16. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
  18. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  20. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
  24. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
  25. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
  26. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
  27. package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
  28. package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
  29. package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
  30. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
  31. package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
  32. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
  33. package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
  34. package/payload/platform/templates/specialists/agents/data-manager.md +1 -0
  35. package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
  36. package/payload/platform/templates/specialists/agents/librarian.md +1 -0
  37. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
  38. package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
  39. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
  40. package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
  41. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
  42. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
  43. package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
  44. package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
  45. package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
  46. package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
  47. package/payload/server/public/chat.html +2 -2
  48. package/payload/server/public/operator.html +2 -2
  49. package/payload/server/public/public.html +2 -2
  50. package/payload/server/server.js +35 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.492",
3
+ "version": "0.1.493",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -188,6 +188,7 @@ Tools are available via the `admin` MCP server.
188
188
  ## Hooks
189
189
 
190
190
  - `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` — the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML → `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
191
+ - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
191
192
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
192
193
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
193
194
  - `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your channel reply tool and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_askgate` (`platform/scripts/lib/account-settings-askgate.sh`) on every install to retrofit the carrier gate onto every existing account's `settings.json` — idempotent surgical jq patch of the `AskUserQuestion` matcher, one `[backfill-1683] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-render-pdf-conformance.sh.
3
+ #
4
+ # PostToolUse hook on browser-pdf-save. For a gated client-quote PDF whose render
5
+ # receipt names the source HTML, it blocks when the PDF dropped links (its /URI
6
+ # count is below the HTML's absolute-link count) or when an embedded https link
7
+ # is not live. Everything else passes; it fails open on anything uninspectable.
8
+ #
9
+ # Covers (all offline-deterministic — the one network case points at a closed
10
+ # local port so the connection refusal is immediate and reproducible):
11
+ # A. non-quote PDF → ALLOWED (not gated)
12
+ # B. quote PDF, no receipt → ALLOWED (PreToolUse owns that block)
13
+ # C. quote PDF, HTML has no absolute links → ALLOWED (0 >= 0, no fetch)
14
+ # D. quote PDF, PDF /URI < HTML links → BLOCKED (link-loss, no fetch)
15
+ # E. quote PDF, /URI ok but an https link is dead → BLOCKED (link not live)
16
+ # F. quote PDF, /URI ok and the only link is 200 → not asserted (needs a live server)
17
+ # G. empty stdin → ALLOWED (fail-open)
18
+ # H. non-browser tool → ALLOWED (matcher defence-in-depth)
19
+ # I. a block emits the op=bypass fault line
20
+
21
+ set -u
22
+
23
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-render-pdf-conformance.sh"
24
+ if [[ ! -x "$HOOK" ]]; then
25
+ echo "FAIL: $HOOK not executable" >&2
26
+ exit 1
27
+ fi
28
+
29
+ ACCT=$(mktemp -d)
30
+ cleanup() { rm -rf "$ACCT"; }
31
+ trap cleanup EXIT
32
+
33
+ DOCS="$ACCT/memory/users/44700900000/documents"
34
+ mkdir -p "$DOCS"
35
+
36
+ # Receipt + filed HTML for each scenario job. The receipt names the filed quote
37
+ # HTML; the hook reads its absolute links and compares to the PDF's /URI count.
38
+ mk_job() { # jobId, html_body, pdf_uri_repeat
39
+ local job="$1" body="$2" uris="$3"
40
+ mkdir -p "$ACCT/quoting/jobs/$job"
41
+ local html="$DOCS/quote-$job.html"
42
+ printf '%s' "$body" > "$html"
43
+ printf '{"jobId":"%s","filed":{"internal":"x","breakdown":"x","quote":"%s"}}' "$job" "$html" \
44
+ > "$ACCT/quoting/jobs/$job/render-receipt.json"
45
+ # A minimal PDF-ish file carrying `uris` copies of the /URI token.
46
+ { printf '%%PDF-1.4\n'; local i=0; while [[ $i -lt $uris ]]; do printf '/URI (x)\n'; i=$((i+1)); done; } \
47
+ > "$DOCS/quote-$job.pdf"
48
+ }
49
+
50
+ # C: no absolute links, PDF has none → allow.
51
+ mk_job "clean" '<html><body><a href="#top">top</a><p>no links</p></body></html>' 0
52
+ # D: two https links, PDF dropped them (0 /URI) → block on count.
53
+ mk_job "dropped" '<html><body><a href="https://a.example/1">a</a><a href="https://b.example/2">b</a></body></html>' 0
54
+ # E: one https link, PDF kept a /URI, but the link points at a closed local port → block on liveness.
55
+ mk_job "dead" '<html><body><a href="https://127.0.0.1:1/never">sign</a></body></html>' 1
56
+
57
+ PASS=0
58
+ FAIL=0
59
+
60
+ run_case() {
61
+ local name="$1" stdin="$2" expected_exit="$3" actual_exit
62
+ ( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
63
+ actual_exit=$?
64
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
65
+ echo "PASS: $name (exit=$actual_exit)"; PASS=$((PASS + 1))
66
+ else
67
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2; FAIL=$((FAIL + 1))
68
+ fi
69
+ }
70
+
71
+ envelope() { printf '{"hook_event_name":"PostToolUse","tool_name":"%s","tool_input":{"path":"%s"}}' "$1" "$2"; }
72
+ BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
73
+
74
+ run_case "A: non-quote PDF → ALLOWED" \
75
+ "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
76
+ run_case "B: quote PDF, no receipt → ALLOWED (PreToolUse owns it)" \
77
+ "$(envelope "$BROWSER" "$DOCS/quote-nojob.pdf")" 0
78
+ run_case "C: HTML has no absolute links → ALLOWED" \
79
+ "$(envelope "$BROWSER" "$DOCS/quote-clean.pdf")" 0
80
+ run_case "D: PDF /URI below HTML link count → BLOCKED" \
81
+ "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" 2
82
+ run_case "E: an embedded https link is not live → BLOCKED" \
83
+ "$(envelope "$BROWSER" "$DOCS/quote-dead.pdf")" 2
84
+ run_case "G: empty stdin → ALLOWED (fail-open)" \
85
+ "" 0
86
+ run_case "H: non-browser tool → ALLOWED" \
87
+ '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"path":"'"$DOCS"'/quote-dropped.pdf"}}' 0
88
+
89
+ # I: a block emits the op=bypass fault line on stderr.
90
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
91
+ if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=dropped reason=pdf-link-conformance'; then
92
+ echo "PASS: I: block emits op=bypass jobId=dropped"; PASS=$((PASS + 1))
93
+ else
94
+ echo "FAIL: I: op=bypass line absent (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
95
+ fi
96
+
97
+ echo "──────── quote-render-pdf-conformance test summary ────────"
98
+ echo "PASS=$PASS FAIL=$FAIL"
99
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # quote-render-pdf-conformance — PostToolUse gate on browser-pdf-save.
3
+ #
4
+ # The companion to the PreToolUse quote-render-gate: that gate checks the
5
+ # rendered HTML on the deterministic path (via the render receipt) before the
6
+ # PDF is printed; the HTML the tool gated cannot yet be a PDF at PreToolUse, so
7
+ # the two PDF-only conformance checks run here, once the PDF exists.
8
+ #
9
+ # For a gated client-quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf
10
+ # or ...-breakdown.pdf) whose render receipt names the source HTML, it asserts:
11
+ # 1. the PDF's /URI annotation count >= the source HTML's absolute-link count
12
+ # (a print that dropped links — a rasterised or flattened copy — carries
13
+ # fewer /URI than the HTML had anchors);
14
+ # 2. every embedded https:// anchor in the source HTML answers 200 (a pending
15
+ # redeploy that would 404 the Sign-Online link is caught before the PDF is
16
+ # sent).
17
+ # The /URI-count check is offline and runs first; the network fetch runs only
18
+ # when the count check passes, so a link-loss regression never depends on the
19
+ # network being up.
20
+ #
21
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
22
+ # uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
23
+ # written): the no-receipt case is already the PreToolUse gate's to block, and
24
+ # browser-pdf-save is general-purpose, so failing open here bricks no unrelated
25
+ # PDF. No task numbers or internal refs in any operator-visible string.
26
+
27
+ set -uo pipefail
28
+
29
+ if [ -t 0 ]; then exit 0; fi
30
+ INPUT=$(cat)
31
+ [ -z "$INPUT" ] && exit 0
32
+
33
+ RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
34
+ import os, json, re, sys, urllib.request
35
+
36
+ try:
37
+ d = json.loads(os.environ["INPUT"])
38
+ except Exception:
39
+ sys.exit(0) # uninspectable envelope -> fail open
40
+
41
+ tool = d.get("tool_name", "") or ""
42
+ if not tool.endswith("browser-pdf-save"):
43
+ sys.exit(0)
44
+ path = (d.get("tool_input", {}) or {}).get("path", "") or ""
45
+ if not path:
46
+ sys.exit(0)
47
+ norm = os.path.normpath(path)
48
+ m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
49
+ if not m:
50
+ sys.exit(0) # not a gated client-quote artefact
51
+ jobid = m.group(1)
52
+ if "/" in jobid or ".." in jobid:
53
+ sys.exit(0) # the PreToolUse gate owns bad-jobid; here fail open
54
+ variant = m.group(2) # "-breakdown" or None
55
+
56
+ base = os.environ["BASE"]
57
+ receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")
58
+ try:
59
+ receipt = json.load(open(receipt_path))
60
+ except Exception:
61
+ sys.exit(0) # no receipt -> PreToolUse gate already blocks; fail open
62
+
63
+ filed = receipt.get("filed", {}) or {}
64
+ html_path = filed.get("breakdown" if variant else "quote", "")
65
+ if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
66
+ sys.exit(0) # nothing to compare against -> fail open
67
+
68
+ html = open(html_path, encoding="utf-8", errors="replace").read()
69
+ # Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
70
+ # mailto hrefs. Fragment and relative anchors do not, so they are not counted.
71
+ uri_anchors = re.findall(r'href="((?:https?|mailto):[^"]*)"', html, re.I)
72
+ https_anchors = [u for u in uri_anchors if u.lower().startswith("https:")]
73
+
74
+ # /URI occurrences in the printed PDF. This is the same primitive the e-sign
75
+ # stamp gate uses (grep -a -c '/URI'); on real browser-pdf-save output it was
76
+ # measured to carry one greppable /URI per surviving link (base PDFs counted 4
77
+ # to 6, one per anchor), so the count is a faithful per-link tally, not stream-
78
+ # compressed away. A rasterised or flattened reprint drops to a lower count.
79
+ uri_count = open(path, "rb").read().count(b"/URI")
80
+ if uri_count < len(uri_anchors):
81
+ print("BLOCK|%s|uri-count pdf=%d html_links=%d" % (jobid, uri_count, len(uri_anchors)))
82
+ sys.exit(0)
83
+
84
+ for u in https_anchors:
85
+ try:
86
+ with urllib.request.urlopen(u, timeout=6) as r:
87
+ code = r.status
88
+ except Exception as e:
89
+ code = getattr(e, "code", 0) or 0
90
+ if code != 200:
91
+ print("BLOCK|%s|link %s -> %s" % (jobid, u, code))
92
+ sys.exit(0)
93
+
94
+ sys.exit(0)
95
+ PY
96
+ )
97
+
98
+ case "$RESULT" in
99
+ BLOCK\|*)
100
+ rest=${RESULT#BLOCK|}
101
+ jobid=${rest%%|*}
102
+ detail=${rest#*|}
103
+ echo "[quote-render] op=bypass jobId=$jobid reason=pdf-link-conformance detail=$detail" >&2
104
+ echo "Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live ($detail). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares." >&2
105
+ exit 2
106
+ ;;
107
+ esac
108
+ exit 0
@@ -110,12 +110,24 @@ Two further clauses bind the Output contract itself. **Recency**, universal: a t
110
110
  claim carries the date it was observed, and a date the agent cannot see is one it flags, never
111
111
  one it infers. **Corroboration**, for agents whose deliverable is a pattern, a theme, a trend, a
112
112
  signal: the agent names the independent sources behind it and does not report one as established
113
- on a single occurrence. Corroboration is authoring guidance enforced by review, not by the gate,
114
- because the subset it applies to cannot be detected statically.
115
-
116
- `agent-grounding-drift.test.ts` fails if any shipped prompt carries either sentence anything
117
- other than exactly once, and fails if the number of prompts it inspected is not the number it
118
- expects. A new agent that skips them does not ship.
113
+ on a single occurrence.
114
+
115
+ Corroboration binds only that subset, and the subset cannot be read out of a prompt's text, so
116
+ every prompt declares it. The flat specialists carry `pattern_deliverable: true` or
117
+ `pattern_deliverable: false` as a frontmatter key; the role `IDENTITY.md` files, which have no
118
+ frontmatter and are injected raw, carry the same marker as a trailing HTML comment
119
+ `<!-- pattern_deliverable: true -->`. Absent is a gate failure, not a default. A prompt declaring
120
+ true carries this sentence, character for character, as a third paragraph of its `## Grounding`
121
+ section; a prompt declaring false does not carry it at all:
122
+
123
+ > Where what you report is a pattern, a theme, a trend, or a signal, name the independent occurrences behind it; one occurrence is a single observation you report as such, never a pattern you assert.
124
+
125
+ `agent-grounding-drift.test.ts` fails if any shipped prompt carries the provenance or
126
+ person-attribute sentence anything other than exactly once, if a prompt does not declare
127
+ `pattern_deliverable` exactly once, if a true-declaring prompt carries the corroboration sentence
128
+ anything other than exactly once, if a false-declaring prompt carries it at all, and if the
129
+ number of prompts it inspected is not the number it expects. The marker and the sentence cannot
130
+ drift apart, and a new agent that skips any of the three does not ship.
119
131
 
120
132
  ## Skills owned
121
133
 
@@ -477,7 +477,15 @@ Dispatch runs **only** inside a live operator session on the account's own devic
477
477
 
478
478
  2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
479
479
 
480
- - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it all with a pure PDF library (**`pdf-lib`**, run as a small Node script; pick another pure library if the account standardises on one, but it must add no browser and no network call):
480
+ - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it, all with a PDF library that loads the base and copies its pages through intact (**`pdf-lib`**, run as a small Node script). The stamp copies existing pages; it never renders them to an image. Rasterising the base (ghostscript to PNG, or any image-then-redraw rebuild) discards every link annotation and all selectable text, so the signer receives a flattened copy with dead links; it is prohibited whatever is or is not installed. A substitute for pdf-lib is allowed only if it imports existing PDF pages: pypdf, qpdf, or pdftk. A canvas-only generator such as reportlab cannot import a base and is not a substitute. Whatever the library, it adds no browser and no network call.
481
+
482
+ **Pre-flight: the page-copying library must resolve before any stamp runs.** A stamp attempted without it is the exact history this guards against, where a prior session met the missing library by rasterising instead of installing one:
483
+
484
+ ```bash
485
+ node -e 'import("pdf-lib").then(()=>console.log("ok"),e=>{console.error("pdf-lib missing: npm i pdf-lib at the account directory root");process.exit(1)})'
486
+ ```
487
+
488
+ On failure the one remediation is that install; a fallback that renders pages to images is never one. The stamp script itself:
481
489
 
482
490
  ```js
483
491
  // stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
@@ -509,6 +517,8 @@ Dispatch runs **only** inside a live operator session on the account's own devic
509
517
 
510
518
  The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
511
519
 
520
+ **Post-stamp gate: prove fidelity before any send.** For both `base.pdf` and the stamped copy, count link annotations with `grep -a -c '/URI'` and measure page-1 text extent with ghostscript text extraction (`gs -q -dNOPAUSE -dBATCH -sDEVICE=txtwrite -dFirstPage=1 -dLastPage=1 -o - <file> | wc -c`). The stamped copy passes only when its `/URI` count and its page-1 text extent are each at least the base's. On either regression the stamp rasterised or otherwise flattened the base, so delete the stamped copy, skip both sends, and leave the row `swept = 0` so the next sweep re-surfaces it. This gate converts a link-stripped copy from a no-event failure (nothing logs until a signer clicks a dead link) into a rejection at stamp time. Record the base and stamped `/URI` counts alongside every dispatch, pass or fail, so a fidelity regression is a number rather than a complaint.
521
+
512
522
  - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
513
523
  - once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
514
524
  - once to the **signer's captured `email`** — this copy is the **statutory record provided to the signer**, the evidentiary copy the legal basis (intro) relies on.
@@ -18,7 +18,7 @@ set -u
18
18
 
19
19
  BIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
20
20
  SCRIPT="$BIN_DIR/portal-brand-css.mjs"
21
- TEMPLATE="$BIN_DIR/../skills/data-portal/template/public/portal.css"
21
+ TEMPLATE="$BIN_DIR/../skills/data-portal/template/portal.css"
22
22
  [ -f "$SCRIPT" ] || { echo "FAIL: $SCRIPT not found" >&2; exit 1; }
23
23
  [ -f "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE not found" >&2; exit 1; }
24
24
  # Case 2 strips the :root block with perl and diffs what is left. Without perl
@@ -198,6 +198,28 @@ export async function pushAccount(opts) {
198
198
  const current = Number(state[0]?.currentGeneration ?? 0)
199
199
  const next = current + 1
200
200
 
201
+ // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
+ // INSERT — leaves rows at `next` while the pointer stays at `current`. Every
203
+ // later run recomputes the same `next`, and its first colliding INSERT then
204
+ // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
+ // stale generation until an operator hand-clears the orphans. Clearing `next`
206
+ // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
+ // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
+ // the log line always means a recovered wedge. The count comes from a SELECT
209
+ // because the D1 client returns rows, not the affected-row meta.
210
+ const orphan = await client.query(
211
+ 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
+ [accountId, next],
213
+ )
214
+ const orphanCount = Number(orphan[0]?.n ?? 0)
215
+ if (orphanCount > 0) {
216
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
+ accountId,
218
+ next,
219
+ ])
220
+ log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
+ }
222
+
201
223
  for (const row of rows) {
202
224
  await client.query(
203
225
  'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +
@@ -309,13 +309,85 @@ function generationClient() {
309
309
  return []
310
310
  }
311
311
  if (/DELETE FROM directory\b/i.test(sql)) {
312
- const keep = Number(params[1])
313
- for (let i = rows.length - 1; i >= 0; i--) {
314
- if (rows[i].generation !== keep) rows.splice(i, 1)
312
+ const g = Number(params[1])
313
+ // Two DELETE shapes reach this client: the post-flip sweep removes
314
+ // every OTHER generation (`generation != ?`); the pre-stage clear
315
+ // (Task 1923) removes exactly THIS generation (`generation = ?`).
316
+ if (/generation\s*!=\s*\?/i.test(sql)) {
317
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
318
+ } else {
319
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
315
320
  }
316
321
  return []
317
322
  }
318
323
  if (/SELECT COUNT/i.test(sql)) {
324
+ // The pre-stage orphan count (Task 1923) filters `generation = ?`; the
325
+ // verify count joins the pointer. Only the former binds a generation.
326
+ if (/generation\s*=\s*\?/i.test(sql)) {
327
+ const g = Number(params[1])
328
+ return [{ n: rows.filter((r) => r.generation === g).length }]
329
+ }
330
+ return [{ n: rows.filter((r) => r.generation === pointer).length }]
331
+ }
332
+ return []
333
+ },
334
+ }
335
+ }
336
+
337
+ // The wedge Task 1923 fixes: a stage that half-completed on a D1 500 leaves
338
+ // orphan rows at the NEXT generation while the pointer stays at current. This
339
+ // client models the one property that turns that into a permanent freeze — the
340
+ // D1 UNIQUE(accountId, relPath, generation) constraint — by throwing on a
341
+ // colliding INSERT, exactly as every later cycle's first re-stage did.
342
+ function wedgeableClient(seed: { pointer: number; current?: string[]; orphans: string[] }) {
343
+ const calls: { sql: string; params: unknown[] }[] = []
344
+ const rows: { accountId: string; relPath: string; generation: number }[] = []
345
+ let pointer: number | null = seed.pointer
346
+ const orphanGen = seed.pointer + 1
347
+ // The last good tree the pointer names, present in a real wedge alongside the
348
+ // orphans. Seeding it proves the pre-stage clear scopes to `next` and leaves
349
+ // the reader-visible current generation untouched.
350
+ for (const relPath of seed.current ?? []) rows.push({ accountId: 'acc-1', relPath, generation: seed.pointer })
351
+ for (const relPath of seed.orphans) rows.push({ accountId: 'acc-1', relPath, generation: orphanGen })
352
+ return {
353
+ calls,
354
+ rows,
355
+ pointerValue: () => pointer,
356
+ async query(sql: string, params: unknown[] = []) {
357
+ calls.push({ sql, params })
358
+ if (/SELECT\s+currentGeneration/i.test(sql)) {
359
+ return pointer === null ? [] : [{ currentGeneration: pointer }]
360
+ }
361
+ if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
362
+ pointer = Number(params[1] ?? params[0])
363
+ return []
364
+ }
365
+ if (/INSERT INTO directory \(/i.test(sql)) {
366
+ const accountId = String(params[0])
367
+ const relPath = String(params[1])
368
+ const generation = Number(params[6])
369
+ if (rows.some((r) => r.accountId === accountId && r.relPath === relPath && r.generation === generation)) {
370
+ throw new Error(
371
+ 'd1 query failed: HTTP 500 — UNIQUE constraint failed: directory.accountId, directory.relPath, directory.generation',
372
+ )
373
+ }
374
+ rows.push({ accountId, relPath, generation })
375
+ return []
376
+ }
377
+ if (/DELETE FROM directory\b/i.test(sql)) {
378
+ const g = Number(params[1])
379
+ if (/generation\s*!=\s*\?/i.test(sql)) {
380
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
381
+ } else {
382
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
383
+ }
384
+ return []
385
+ }
386
+ if (/SELECT COUNT/i.test(sql)) {
387
+ if (/generation\s*=\s*\?/i.test(sql)) {
388
+ const g = Number(params[1])
389
+ return [{ n: rows.filter((r) => r.generation === g).length }]
390
+ }
319
391
  return [{ n: rows.filter((r) => r.generation === pointer).length }]
320
392
  }
321
393
  return []
@@ -323,6 +395,42 @@ function generationClient() {
323
395
  }
324
396
  }
325
397
 
398
+ describe('a partial stage self-heals (Task 1923)', () => {
399
+ it('clears orphans at the next generation, flips onto it, and logs stage-clear', async () => {
400
+ // Pointer at 5 naming the last good tree; three orphan rows already at
401
+ // generation 6 for relPaths the current walk re-stages ('output' is the
402
+ // walk's first row, so the current code's first INSERT is the one that
403
+ // collides). Without the pre-stage clear that INSERT throws on the UNIQUE
404
+ // constraint and the push fails, which is what froze gls-data for ~6 h on
405
+ // 2026-07-22.
406
+ const c = wedgeableClient({
407
+ pointer: 5,
408
+ current: ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'],
409
+ orphans: ['output', 'output/report.pdf', 'quotes'],
410
+ })
411
+ const lines: string[] = []
412
+ const r = await push(c, { log: (l: string) => lines.push(l) })
413
+
414
+ expect(r.rows).toBe(7)
415
+ expect(c.pointerValue()).toBe(6)
416
+ // Nothing survives at a generation the pointer does not name.
417
+ expect(c.rows.every((row) => row.generation === 6)).toBe(true)
418
+ const clear = lines.find((l) => l.includes('op=stage-clear'))!
419
+ expect(clear).toContain('account=acc-1')
420
+ expect(clear).toContain('generation=6')
421
+ // Exactly the 3 orphans at generation 6, not the 4 current-tree rows the
422
+ // pointer names: the clear scopes to `next`, never to a reader-visible row.
423
+ expect(clear).toContain('cleared=3')
424
+ })
425
+
426
+ it('emits no stage-clear line on a clean cycle with no orphans', async () => {
427
+ const c = wedgeableClient({ pointer: 5, orphans: [] })
428
+ const lines: string[] = []
429
+ await push(c, { log: (l: string) => lines.push(l) })
430
+ expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
431
+ })
432
+ })
433
+
326
434
  describe('the index replace is atomic', () => {
327
435
  it('never issues a delete before the pointer flip', async () => {
328
436
  // The delete-then-insert window this replaces was Task 1842: a client
@@ -60,6 +60,7 @@ const deadHead = async () => {
60
60
  throw new Error('ECONNREFUSED')
61
61
  }
62
62
  const errHead = async () => ({ ok: false, status: 502 })
63
+ const missingHead = async () => ({ ok: false, status: 404 })
63
64
 
64
65
  describe('processIndexedDownload', () => {
65
66
  it('returns a signed url for an indexed file when the device answers', async () => {
@@ -87,6 +88,18 @@ describe('processIndexedDownload', () => {
87
88
  expect(r.payload.error).toBe('device-offline')
88
89
  })
89
90
 
91
+ // A live device that answers 404 means the indexed row is stale: the file was
92
+ // renamed or removed while the index was frozen. That is "refresh the list",
93
+ // not "the device is down", so it must not collapse into device-offline.
94
+ it('reports file-missing when a reachable device 404s the path', async () => {
95
+ const lines: string[] = []
96
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, missingHead as never)
97
+ expect(r.status).toBe(404)
98
+ expect(r.payload.error).toBe('file-missing')
99
+ expect(r.url).toBeUndefined()
100
+ expect(lines.some((l) => l.includes('result=file-missing'))).toBe(true)
101
+ })
102
+
90
103
  it('404s a path that is not in the index, without contacting the device', async () => {
91
104
  let touched = false
92
105
  const spy = async () => {
@@ -171,6 +171,18 @@ export async function processIndexedDownload(
171
171
  try {
172
172
  const head = await fetchFn(url, { method: 'HEAD' })
173
173
  if (!head.ok) {
174
+ if (head.status === 404) {
175
+ // The device answered, so it is not down: this row is stale. The file
176
+ // was renamed or removed while the index was frozen, and the device
177
+ // returns the same 404 for a path that is missing and one that is not
178
+ // exposed — so this cannot and must not try to tell those apart. The
179
+ // client's remedy is "refresh the list", not "wait for the device".
180
+ log(
181
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
182
+ `status=404 ms=${Date.now() - started} result=file-missing`,
183
+ )
184
+ return { status: 404, payload: { ok: false, error: 'file-missing' } }
185
+ }
174
186
  log(
175
187
  `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
176
188
  `status=${head.status} ms=${Date.now() - started} result=device-error`,
@@ -292,6 +292,14 @@
292
292
  say('That file is on the device, which is not reachable right now. Try again shortly.');
293
293
  return;
294
294
  }
295
+ /* A reachable device that 404s a stale row sends error:'file-missing'.
296
+ The message says refresh, not wait. Keyed on the string, so the
297
+ edge's own not-found 404s (not-granted, absent, is-directory) carry
298
+ error:'not found' and keep the generic message below. */
299
+ if (res.d && res.d.error === 'file-missing') {
300
+ say('That file is no longer available under this name. Refresh the list and try again.');
301
+ return;
302
+ }
295
303
  if (!res.d.ok || !res.d.url) {
296
304
  say('That file could not be opened.');
297
305
  return;
@@ -128,6 +128,12 @@ provision_account_dir() {
128
128
  { "type": "command", "command": "bash $HOOKS_PATH/post-tool-use-agent.sh" }
129
129
  ]
130
130
  },
131
+ {
132
+ "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
133
+ "hooks": [
134
+ { "type": "command", "command": "bash $HOOKS_PATH/quote-render-pdf-conformance.sh" }
135
+ ]
136
+ },
131
137
  {
132
138
  "matcher": "mcp__.*__.*-(export|import)-parse$",
133
139
  "hooks": [
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
@@ -2071,6 +2071,10 @@ export function buildHttpApp(deps) {
2071
2071
  }
2072
2072
  const byte = body.byte;
2073
2073
  const trigger = typeof body.trigger === 'string' ? body.trigger : 'manual';
2074
+ // Task 1920 — the stop button sets escalate:true so that when ESC proves
2075
+ // ineffective (turn blocked in a tool/subagent call) the manager escalates
2076
+ // once to Ctrl-C. Absent/false keeps the ESC-only re-send path.
2077
+ const escalate = body.escalate === true;
2074
2078
  const intId = `${sessionId.slice(0, 8)}-${start}`;
2075
2079
  deps.logger(`[webchat-interrupt] op=interrupt-requested intId=${intId} sessionId=${sessionId.slice(0, 8)} trigger=${trigger}`);
2076
2080
  const result = writeInterruptToPty(sessionId, byte, deps.logger);
@@ -2115,6 +2119,11 @@ export function buildHttpApp(deps) {
2115
2119
  let lastSendAt = sentAt;
2116
2120
  let escSends = 1; // the initial ESC already written above
2117
2121
  let canResend = true;
2122
+ // Task 1920 — the one-shot escalation guard. Seed it from the initial byte:
2123
+ // if the pre-loop write above already sent Ctrl-C (byte='ctrl-c'), the loop
2124
+ // must not send a second one. The invariant is "exactly one Ctrl-C ever per
2125
+ // call" because a second SIGINT can exit the CLI and kill the session.
2126
+ let ctrlCSent = byte === 'ctrl-c';
2118
2127
  while (Date.now() - sentAt < stopMs) {
2119
2128
  const st = deps.watcher.getBySessionId(sessionId)?.status;
2120
2129
  if (st !== 'busy') {
@@ -2122,14 +2131,34 @@ export function buildHttpApp(deps) {
2122
2131
  break;
2123
2132
  }
2124
2133
  if (canResend && Date.now() - lastSendAt >= reescalateMs) {
2125
- const re = writeInterruptToPty(sessionId, byte, deps.logger);
2126
- lastSendAt = Date.now();
2127
- if (re.ok) {
2128
- escSends += 1;
2129
- deps.logger(`[webchat-interrupt] op=interrupt-reescalated intId=${intId} sends=${escSends}`);
2134
+ if (escalate) {
2135
+ // Task 1920 — ESC ran for a full sub-window while still busy, so it is
2136
+ // ineffective (turn blocked in a tool/subagent call). Escalate once to
2137
+ // Ctrl-C (SIGINT reaches the harness mid-tool-call); never a second
2138
+ // time a second SIGINT can exit the CLI and kill the session. After
2139
+ // the one Ctrl-C, stop sending and just poll for busy-clear.
2140
+ if (!ctrlCSent) {
2141
+ const esc = writeInterruptToPty(sessionId, 'ctrl-c', deps.logger);
2142
+ lastSendAt = Date.now();
2143
+ if (esc.ok) {
2144
+ ctrlCSent = true;
2145
+ deps.logger(`[webchat-interrupt] op=interrupt-escalated intId=${intId} mechanism=ctrl-c sends=${escSends + 1}`);
2146
+ }
2147
+ else {
2148
+ canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2149
+ }
2150
+ }
2130
2151
  }
2131
2152
  else {
2132
- canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2153
+ const re = writeInterruptToPty(sessionId, byte, deps.logger);
2154
+ lastSendAt = Date.now();
2155
+ if (re.ok) {
2156
+ escSends += 1;
2157
+ deps.logger(`[webchat-interrupt] op=interrupt-reescalated intId=${intId} sends=${escSends}`);
2158
+ }
2159
+ else {
2160
+ canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2161
+ }
2133
2162
  }
2134
2163
  }
2135
2164
  await new Promise((r) => setTimeout(r, 120));