instar 1.3.1612 → 1.3.1613

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 (120) hide show
  1. package/dashboard/console/console.js +14 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +31 -7
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/AppConversationStore.d.ts +2 -0
  6. package/dist/core/AppConversationStore.d.ts.map +1 -1
  7. package/dist/core/AppConversationStore.js +8 -1
  8. package/dist/core/AppConversationStore.js.map +1 -1
  9. package/dist/core/AppRecordSignatures.d.ts +23 -2
  10. package/dist/core/AppRecordSignatures.d.ts.map +1 -1
  11. package/dist/core/AppRecordSignatures.js +32 -5
  12. package/dist/core/AppRecordSignatures.js.map +1 -1
  13. package/dist/core/LocalAttentionItems.d.ts +49 -0
  14. package/dist/core/LocalAttentionItems.d.ts.map +1 -0
  15. package/dist/core/LocalAttentionItems.js +107 -0
  16. package/dist/core/LocalAttentionItems.js.map +1 -0
  17. package/dist/core/MacDesktopHost.d.ts +3 -0
  18. package/dist/core/MacDesktopHost.d.ts.map +1 -1
  19. package/dist/core/MacDesktopHost.js +10 -0
  20. package/dist/core/MacDesktopHost.js.map +1 -1
  21. package/dist/core/MachineLockStateReader.d.ts.map +1 -1
  22. package/dist/core/MachineLockStateReader.js +6 -1
  23. package/dist/core/MachineLockStateReader.js.map +1 -1
  24. package/dist/core/PostUpdateMigrator.d.ts +6 -0
  25. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  26. package/dist/core/PostUpdateMigrator.js +33 -0
  27. package/dist/core/PostUpdateMigrator.js.map +1 -1
  28. package/dist/core/types.d.ts +12 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/data/standards-guard-index.json +1 -1
  32. package/dist/data/standards-guard-index.meta.json +2 -2
  33. package/dist/data/standards-registry.meta.json +1 -1
  34. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  35. package/dist/messaging/TelegramAdapter.js +7 -8
  36. package/dist/messaging/TelegramAdapter.js.map +1 -1
  37. package/dist/messaging/shared/MessageLogger.d.ts +2 -0
  38. package/dist/messaging/shared/MessageLogger.d.ts.map +1 -1
  39. package/dist/messaging/shared/MessageLogger.js.map +1 -1
  40. package/dist/messaging/slack/SlackAdapter.d.ts +19 -0
  41. package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
  42. package/dist/messaging/slack/SlackAdapter.js +21 -0
  43. package/dist/messaging/slack/SlackAdapter.js.map +1 -1
  44. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts +13 -0
  45. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts.map +1 -1
  46. package/dist/messaging/telegram-origin/OriginForwardToHolder.js +16 -0
  47. package/dist/messaging/telegram-origin/OriginForwardToHolder.js.map +1 -1
  48. package/dist/messaging/telegram-origin/OriginLegacy.d.ts +8 -0
  49. package/dist/messaging/telegram-origin/OriginLegacy.d.ts.map +1 -1
  50. package/dist/messaging/telegram-origin/OriginLegacy.js +34 -6
  51. package/dist/messaging/telegram-origin/OriginLegacy.js.map +1 -1
  52. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts +3 -0
  53. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts.map +1 -1
  54. package/dist/messaging/telegram-origin/TelegramOriginBoot.js +54 -10
  55. package/dist/messaging/telegram-origin/TelegramOriginBoot.js.map +1 -1
  56. package/dist/messaging/telegram-origin/types.d.ts +11 -0
  57. package/dist/messaging/telegram-origin/types.d.ts.map +1 -1
  58. package/dist/messaging/telegram-origin/types.js +15 -0
  59. package/dist/messaging/telegram-origin/types.js.map +1 -1
  60. package/dist/monitoring/promptRelaySettle.d.ts +35 -0
  61. package/dist/monitoring/promptRelaySettle.d.ts.map +1 -0
  62. package/dist/monitoring/promptRelaySettle.js +58 -0
  63. package/dist/monitoring/promptRelaySettle.js.map +1 -0
  64. package/dist/native/instar-desktop-host +0 -0
  65. package/dist/scaffold/templates.d.ts.map +1 -1
  66. package/dist/scaffold/templates.js +3 -1
  67. package/dist/scaffold/templates.js.map +1 -1
  68. package/dist/server/AgentServer.d.ts +15 -0
  69. package/dist/server/AgentServer.d.ts.map +1 -1
  70. package/dist/server/AgentServer.js +78 -20
  71. package/dist/server/AgentServer.js.map +1 -1
  72. package/dist/server/CapabilityIndex.js +2 -2
  73. package/dist/server/CapabilityIndex.js.map +1 -1
  74. package/dist/server/mobileConversationRoutes.d.ts +16 -3
  75. package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
  76. package/dist/server/mobileConversationRoutes.js +36 -8
  77. package/dist/server/mobileConversationRoutes.js.map +1 -1
  78. package/dist/server/mobilePairingRoutes.d.ts +1 -1
  79. package/dist/server/mobilePairingRoutes.d.ts.map +1 -1
  80. package/dist/server/mobilePairingRoutes.js +5 -2
  81. package/dist/server/mobilePairingRoutes.js.map +1 -1
  82. package/dist/server/mobileSlackRoutes.d.ts +162 -0
  83. package/dist/server/mobileSlackRoutes.d.ts.map +1 -0
  84. package/dist/server/mobileSlackRoutes.js +552 -0
  85. package/dist/server/mobileSlackRoutes.js.map +1 -0
  86. package/dist/server/routes.d.ts +2 -0
  87. package/dist/server/routes.d.ts.map +1 -1
  88. package/dist/server/routes.js +59 -3
  89. package/dist/server/routes.js.map +1 -1
  90. package/package.json +1 -1
  91. package/scripts/build-locked-proof.mjs +125 -0
  92. package/scripts/lib/locked-proof-build.mjs +71 -0
  93. package/scripts/locked-proof/configure-proof-agent.mjs +68 -0
  94. package/scripts/locked-proof/create-proof-user.sh +89 -0
  95. package/scripts/locked-proof/driver.mjs +457 -0
  96. package/scripts/locked-proof/lib.mjs +267 -0
  97. package/scripts/locked-proof/pty-feed.py +59 -0
  98. package/scripts/locked-proof/remove-proof-user.sh +18 -0
  99. package/scripts/locked-proof/reset-attempts.py +52 -0
  100. package/scripts/locked-proof/stage-proof-agents.sh +68 -0
  101. package/src/data/builtin-manifest.json +64 -64
  102. package/src/data/standards-guard-index.json +1 -1
  103. package/src/data/standards-guard-index.meta.json +2 -2
  104. package/src/data/standards-registry.meta.json +1 -1
  105. package/src/scaffold/templates.ts +3 -1
  106. package/upgrades/1.3.1613.md +108 -0
  107. package/upgrades/side-effects/console-partial-survives-reload.eli16.md +5 -0
  108. package/upgrades/side-effects/console-partial-survives-reload.md +34 -0
  109. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.eli16.md +13 -0
  110. package/upgrades/side-effects/desktop-tests-stop-touching-tcc.md +65 -0
  111. package/upgrades/side-effects/held-expiry-one-row-per-topic.eli16.md +5 -0
  112. package/upgrades/side-effects/held-expiry-one-row-per-topic.md +37 -0
  113. package/upgrades/side-effects/locked-proof-driver.md +72 -0
  114. package/upgrades/side-effects/locked-proof-test-build.md +64 -0
  115. package/upgrades/side-effects/locked-security-item-no-telegram.md +78 -0
  116. package/upgrades/side-effects/mobile-native-conversations-inc4.md +115 -0
  117. package/upgrades/side-effects/origin-config-unknown-not-missing.eli16.md +16 -0
  118. package/upgrades/side-effects/origin-config-unknown-not-missing.md +38 -0
  119. package/upgrades/side-effects/prompt-gate-settle-before-relay.eli16.md +11 -0
  120. package/upgrades/side-effects/prompt-gate-settle-before-relay.md +57 -0
@@ -0,0 +1,115 @@
1
+ # Side-Effects Review — Native app conversations, increment 4 (Slack conversations from the app)
2
+
3
+ **Version / slug:** `mobile-native-conversations-inc4`
4
+ **Date:** `2026-11-02`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not run (--second-pass false, as instructed for this build)`
7
+ **Tier:** 2 (spec-driven; inbound dispatch and a signed-record format). Size: 11 source files (~520 added source lines, one new route module) + 3 new test files (~560 lines). No spec review round (the spec was already converged and approved); commit attempts recorded in the PR.
8
+
9
+ ## Summary of the change
10
+
11
+ Increment 4 of docs/specs/mobile-native-conversations.md (§4.9 Slack, §4.10 allowlist), behind the existing boot-read `mobilePairing.native.enabled` gate.
12
+
13
+ - **List:** `GET /mobile/conversations` adds this machine's local Slack conversations (registry entries whose origin is not `replicated`): `{id: <negative durable id>, platform: "slack", title: label or "#<channel>" (+" (thread)"), lastActivity, lastPreview}`, merged on the same lossless cursor as app and Telegram rows (sort key `slack:<id>`). `?scope=pool` accepts peers' Slack rows (re-validated: negative safe integer). With `mobilePairing.slackOwnerUserId` unset the rows are withheld and the response says `slackOwnerUnset: true`.
14
+ - **History:** `GET /mobile/chat/slack/:id/messages` (device, or the agent's Bearer token) merges the tail of `slack-messages.jsonl` (newest 5000 lines within 8 MiB), the adapter's in-memory ring, and the app-sent messages kept in the app conversation store under the registry's canonical key. App-sent rows carry `originVerified` (and their delivery state); rows typed in Slack and agent replies read `none`. Pages backwards by `(at, eventId)`.
15
+ - **Send:** `POST /mobile/chat/slack/:id` follows §4.2: authenticate → look up (`MobileChatRequestStore`) → emergency stop skips the setting and rate limit → preflight (chat setting, 20/min, request-id age, `scanInbound`) → serving-lease check → token removal, credential signal → reserve → persist (fsync) with the signed origin and a **v2 receipt** → a copy in `slack-messages.jsonl` carrying the store `eventId` → stop/pause intercepts → the server's real Slack inbound dispatch with the operator as sender (`mobilePairing.slackOwnerUserId`, `threadline.ownerName`) and `appOrigin` → delivery event → mirror "📱 <owner> (from the app): <text with credential spans hidden>" → done. Boot recovery of an interrupted send is the existing `recoverStranded` (`op: send` with the canonical key).
16
+ - **v2 receipt:** a Slack send path names a machine-local alias, so the receipt also signs the canonical key (`instar-app-receipt-v2`, fields `v: 2`, `conversationKey`). `verifyRecordedOrigin` requires, for a `slack:` record, a POST to `/mobile/chat/slack/-N` and a v2 receipt naming the record's key; a v1 receipt, a different key, or a non-Slack path reads `failed`. Non-Slack records are verified exactly as before (a v2 receipt on them must also name their key).
17
+ - **Slack dispatch outcome:** `slackInboundDispatch` now returns `delivered | queued | refused` (previously `void`); the live and forwarded Slack paths ignore it. A message sent from the app gets ` (📱 signed)` (verified) or ` (📱 from the app)` appended to its tag by the server, after the sanitized name.
18
+ - **Agent replies in the Slack log:** `handleSlackReply` now writes the agent's reply to `slack-messages.jsonl` (Slack never echoes the bot's messages), so the app's Slack history shows both sides. Inbound Slack rows gain `threadTs` when they sit in a thread.
19
+ - **Config:** `mobilePairing.slackOwnerUserId` (optional, `U…`/`W…`). Never inferred.
20
+ - **Awareness:** CLAUDE.md section "Slack conversations from the Instar app" (template + `migrateClaudeMd`, sniffed by heading); CapabilityIndex lists the two routes.
21
+
22
+ ## Decision-point inventory
23
+
24
+ - Slack send preflight — add — invariant: the same content-free checks as the app/Telegram send routes; only exact authority tokens are removed; credential-shaped text is a signal.
25
+ - Owner identity — add — invariant: a config field (`slackOwnerUserId`) that must also be an authorized user on the Slack adapter; never inferred from content.
26
+ - Serving-lease check — add — invariant (as the Telegram chat route): refused before anything is saved.
27
+ - Origin verdict for Slack records — modify — invariant cryptographic checks (v2 receipt).
28
+
29
+ ---
30
+
31
+ ## 1. Over-block
32
+
33
+ - A machine that does not hold the serving lease answers `409 not-serving` before saving, as the Telegram route does; with the session pool on, a Slack conversation owned by another machine is still sent through this machine's dispatch only if this machine holds the lease (the dispatch then re-checks ownership through `SpawnAdmission`). No message is lost: nothing is saved on a refusal and the app keeps the text.
34
+ - `slackOwnerUserId` unset or not an authorized Slack user ⇒ 503 with a plain reason. Intentional (spec: never inferred).
35
+ - A thread conversation's history shows only rows that say they are in that thread; Slack log rows written before this change have no thread field, so older thread replies are not shown in a thread view (they still appear nowhere else either; Slack itself keeps them).
36
+
37
+ ---
38
+
39
+ ## 2. Under-block
40
+
41
+ - History reads a bounded tail of `slack-messages.jsonl`; messages older than the newest 5000 lines are not reachable from the app (app-sent messages are, from the store, never rotated). Slack keeps its own full history.
42
+ - **Slack ids are local to one machine (review fix).** Every Slack list row names the machine that serves it (`machine`), a pool list never includes another machine's Slack rows (a peer row with the same number would otherwise collapse onto this machine's row, or name a different conversation), and both Slack routes require the row's machine (`?machine=` on the read, `machine` in the signed send body): another machine answers 409 `wrong-machine` (with `servedBy`), none answers 400 `machine-required`, before anything is read, saved or sent. A Slack conversation is therefore only reachable through the machine that lists it; with several machines that is the one the phone is paired to.
43
+ - Verification of a Slack record is relative to the canonical key: a workspace id that was `_` on one machine and `T…` on another yields two keys (two store files) for one channel; each record still verifies under its own key.
44
+
45
+ ---
46
+
47
+ ## 3. Level-of-abstraction fit
48
+
49
+ Reuses the app conversation service (request store, store, verify, delivery events, boot recovery), `buildSignedOrigin`, the registry's canonical key, and the server's own Slack inbound dispatch (the same function the live and mesh-forwarded Slack paths run), so a message from the app gets the same session lookup, spawn admission, context and relay instructions as a Slack message. The bridge is a thin adapter over SlackAdapter with an interface small enough to stub in tests.
50
+
51
+ ---
52
+
53
+ ## 4. Signal vs authority compliance
54
+
55
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
56
+
57
+ - [ ] No — this change produces a signal consumed by an existing smart gate.
58
+ - [x] No — this change has no brittle block/allow surface.
59
+ - [ ] Yes — but the logic is a smart gate with full conversational context.
60
+ - [ ] ⚠️ Yes, with brittle logic — STOP.
61
+
62
+ No content check refuses a message. The added refusals are structural (feature setting, rate, request-id age, lease, configured owner identity); credential-shaped text only adds `credentialShaped: true` and is hidden in the mirror copy, never refused.
63
+
64
+ ---
65
+
66
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
67
+
68
+ No new judgment point. Credential-shape detection is the existing `detectOutboundCredential` signal (the spec's decision table classifies it as a judgment candidate whose floor never refuses).
69
+
70
+ ---
71
+
72
+ ## 5. Interactions
73
+
74
+ - **Slack log readers:** PresenceProxy's Slack log check and the Slack compaction-resume path now also see the agent's own replies in `slack-messages.jsonl`. Both look for "did the agent answer after the user's last message"; seeing the reply is the correct answer (previously they only learned it through `onMessageLogged`). `slack-cli` search shows the replies too.
75
+ - **slackInboundDispatch** return type change: callers `await`/`void` it and ignore the value; tsc and the Slack wiring tests pass.
76
+ - **Tag:** the app mark is appended inside the bracket after `(uid:U…)`; the existing tag parser in sessions only reads `[slack:<channel>`.
77
+ - **Mirror loop:** the mirror is posted by the bot; Slack does not deliver bot messages back as inbound, so it cannot re-enter dispatch.
78
+ - **Emergency stop:** an app stop into Slack kills every running session, exactly what a Slack "emergency stop" does.
79
+
80
+ ---
81
+
82
+ ## 6. External surfaces
83
+
84
+ Two new device routes (allowlisted for active devices: `POST /mobile/chat/slack/-N`, `GET /mobile/chat/slack/-N/messages`, both requiring the list row's `machine`), Slack rows in the conversation list, a new optional config field, a new receipt version in stored records (replicated; an older machine reading a v2 Slack record cannot verify it and reads `failed` for a Slack path — such records only exist once this version runs), a new message posted into Slack (the mirror) on each app send, and agent replies added to the Slack log. CLAUDE.md gains a section on update.
85
+
86
+ - **iOS app:** must list `platform: "slack"` rows, send to `/mobile/chat/slack/<id>` with the row's `machine` in the body and read `/mobile/chat/slack/<id>/messages?machine=<row machine>`, show `slackOwnerUnset` as a plain hint, and show marks from `originVerified` (increment 5).
87
+ - **Operator surface:** setting the owner id is done by the agent after the operator's yes in conversation (no PIN, no file editing by the operator).
88
+
89
+ ---
90
+
91
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
92
+
93
+ - App-sent Slack messages: **replicated** in the app conversation store under the canonical Slack key (the existing `appConversations` replicated kind); the v2 receipt makes them checkable on any machine.
94
+ - Slack rows in the list and Slack history: **machine-local BY DESIGN** (a Slack conversation id is minted per machine): the list carries only this machine's Slack rows even with `?scope=pool`, and the routes refuse any other machine's id.
95
+ - Sending: on the serving-lease holder (where Slack inbound is dispatched).
96
+
97
+ ---
98
+
99
+ ## 8. Rollback cost
100
+
101
+ Switch `mobilePairing.native.enabled` off: the Slack routes answer 503 and the list has no Slack rows (the conversation service is not built). Unsetting `slackOwnerUserId` hides Slack alone. Reverting the PR leaves stored Slack records with v2 receipts that an older version reads as `failed`/unknown (display only) and extra agent-reply rows in `slack-messages.jsonl` (harmless log rows). No data migration.
102
+
103
+ ---
104
+
105
+ ## Class-Closure Declaration (display-only mirror)
106
+
107
+ Not applicable: no agent-authored-artifact defect. The mirror post fires once per operator app send (an external event), never on a timer or its own output.
108
+
109
+ ---
110
+
111
+ ## Second-pass review
112
+
113
+ Not run for this build (`--second-pass false`, per the build instructions). The PR is the review surface.
114
+
115
+ Review follow-up (Slack ids are per machine): independent reviewer — Concur with the review. The machine check runs before any read or send in both routes; the query is inside the signed path and the body field inside the body hash; the agent read works with ?machine=; pool lists drop peers' Slack rows; the CLAUDE.md text matches (it now names the 400 too).
@@ -0,0 +1,16 @@
1
+ # Why bug-topic messages from Mama PC failed, in plain words
2
+
3
+ Every few seconds, each machine re-reads its own settings file so it knows where to send Telegram messages. Bug topics live in a separate Telegram group, and the settings say which one.
4
+
5
+ Mama PC sometimes gets so busy that its server freezes for 10 to 30 seconds. When that happens, the settings read takes longer than its 2-second limit and is thrown away. The code then behaved as if the settings had no bug group at all, and refused messages to bug topics with "no messaging config issuesChatId". That was false: the setting was there the whole time.
6
+
7
+ Those refused messages were saved for a retry. But the retry step only trusts a small set of "definitely not sent" answers, and this refusal was not one of them. So it marked them "may already be delivered" and never sent them. They were lost without anyone being told.
8
+
9
+ After this change:
10
+ - If the settings can't be read, the refusal says so honestly ("could not read config", with the reason), and it is marked as safe to retry.
11
+ - The retry step recognises this refusal, and the two older ones (the misleading "no bug group" one, and the bare "display authority unavailable" one that hit a message on the Studio today), as "definitely not sent", so the message goes out once the settings read again. It still passes the usual tone check and duplicate check, and it still expires after 6 hours.
12
+ - The server log now says why a settings read failed, so the next time this happens the cause is written down.
13
+
14
+ Nothing changes about the safety rule that a bug-topic message is never sent to the main group by mistake.
15
+
16
+ Why Mama PC freezes is a separate problem and is reported on its own.
@@ -0,0 +1,38 @@
1
+ # Side-effects review: an unreadable config is "unknown", not "missing"
2
+
3
+ Tier 1. 5 runtime/test files plus artifacts, about 120 lines. One review round.
4
+
5
+ ## Change
6
+ 1. `TelegramOriginBoot.ts`: the 5-second config refresh used to end in `catch { snapshot = null }`. The snapshot then fed `toWireParams` with `issuesChatId: undefined`, so a failed read surfaced as `IssuesChatNotConfiguredError` ("this process has no messaging config issuesChatId"). Now, with no snapshot, an issues-topic send throws `OriginConfigUnavailableError(reason)` instead, and the sender-display check throws the same error when the snapshot is stale. The refresh records a bounded, secret-free reason code and logs it once per change (and on recovery). `refresh()` itself is unchanged in what it accepts.
7
+ 2. `types.ts`: new `OriginConfigUnavailableError` (retryable, `reason`). Its message is still exactly `origin-display-authority-unavailable`, so existing string matchers keep working.
8
+ 3. `routes.ts` (`POST /telegram/reply` catch): this error now answers `503 {error:'origin-config-unavailable', reason, retryable:true, message}` instead of a generic 500.
9
+ 4. `OriginLegacy.ts`: `snapshotLegacyOrigin` treats two exact bodies on the latest attempt as definite non-delivery, the same way PR 2596 does for the boot beacon: that 503 body, and the 500 body `{"error":"issues-chat-not-configured: topic N belongs to the issues group…"}`. It also accepts the bare 500 `{"error":"origin-display-authority-unavailable"}` that earlier releases returned when config could not be read; that text is thrown only by display resolution inside `prepareBot`. All of these are thrown while a send is being prepared, before admission and before any network call.
10
+
11
+ ## 1. Over-block
12
+ None added. A send that was refused before is still refused while config is unreadable; only the reason and status change. Main-group protection is unchanged: with no snapshot an issues topic is never translated to the main chat, and `authorize()` still requires a healthy snapshot.
13
+
14
+ ## 2. Under-block
15
+ The underlying stall on a busy machine is not fixed here (see Interactions). Sends during a stall still wait; they are now retried instead of lost. A row with several attempts qualifies only if every earlier attempt was 401/404 (the table keeps only the latest body), which is the safe direction.
16
+
17
+ ## 3. Level-of-abstraction fit
18
+ The misreport was produced where the snapshot is built, so it is fixed there; the HTTP mapping lives in the existing reply-route catch; the replay proof lives in the existing replay check. No new layer.
19
+
20
+ ## 4. Signal vs authority
21
+ The replay check is an authority (it lets a held message be sent). It is deterministic and narrow: exact bodies from our own reply route, exact key sets, a 1 KiB cap, and a body relayed from another machine (`relayedFromHolder`) is rejected (tested). Replayed rows still pass the legacy tone review, content dedup and the original 6-hour deadline.
22
+
23
+ ## 5. Interactions
24
+ - PR 2596 edits the same lines of `snapshotLegacyOrigin` (boot-beacon 503). The two compose: both are "latest attempt carries an exact pre-dispatch body". Whichever lands second rebases into one helper.
25
+ - The relay script already queues any 5xx ("Queued for recovery"), so the new 503 needs no script change.
26
+ - The stalls behind this (server event loop blocked for 10–30 s, then the lifeline restarting it after 60 s unresponsive) are a separate problem and are reported separately.
27
+
28
+ ## 6. External surfaces
29
+ `POST /telegram/reply` returns 503 with a new error code for this case instead of 500. Callers that treat 5xx as retryable (the relay script) behave as before. Messages refused this way are now delivered later instead of silently dropped.
30
+
31
+ ## 7. Multi-machine posture
32
+ Machine-local by design: each process reads its own config and imports its own queue. This matters most on standbys, which send through the lease holder but prepare locally.
33
+
34
+ ## 8. Rollback cost
35
+ Revert the commit. Rows already replayed were delivered once; nothing to repair.
36
+
37
+ ## Second-pass review
38
+ Concur with the review. Both refusals are raised in `prepareBot` (wireParams, then display) before admission. Every failure after dispatch is a `TelegramOriginHoldError`, and the adapter does not fall back to a second send. There is no partial multi-chunk send, because chunks are children of one sealed operation built after translation. A holder's relayed refusal carries `relayedFromHolder`, which fails the exact key check. The relay script stores the body untruncated (the 1024 cap applies only to the event payload), and both bodies are well under 1 KiB; an oversized body fails safe. `OriginConfigUnavailableError` is matched by `instanceof` only in the reply route; every other caller matches the unchanged message. Main-group protection is intact. Logged reasons are fixed codes only. The integration test's wait was lengthened so a loaded machine does not make it flaky.
@@ -0,0 +1,11 @@
1
+ # Prompt Gate waits before telling you about a prompt (plain-English overview)
2
+
3
+ When one of my sessions stops at a question on its screen (a yes/no confirmation, a menu), a part of Instar called Prompt Gate copies that question to your Telegram topic so you can answer it from your phone. Until now it did that the instant it saw the question.
4
+
5
+ The trouble is that many of those questions get answered without you. When a session hits its account's usage limit, Instar moves it to another account; when Claude Code asks a low-level permission question, a safety floor answers it; sometimes the session is simply restarted. In each case you still got a message asking you to choose, about something that no longer needed you. On 2 November that happened twice in one minute, plus a separate notice saying "Prompt Gate is now active".
6
+
7
+ What changes: after seeing a question, Prompt Gate now waits 45 seconds and looks at the session's screen again. If the question is still there, you get the message as before. If it's gone, or the session no longer exists, nothing is sent. If the screen can't be read, it sends anyway, so a question that really needs you is never dropped. The "now active" notice becomes one short line on the first real question instead of its own message.
8
+
9
+ What you might notice: fewer of these messages, and a real one arrives about 45 seconds after the question appears. The wait can be changed or switched off with one setting.
10
+
11
+ If the screen check itself fails, that counts as "can't tell", and the message is sent.
@@ -0,0 +1,57 @@
1
+ # Side-Effects Review — Prompt Gate waits before relaying
2
+
3
+ **Version / slug:** `prompt-gate-settle-before-relay`
4
+ **Date:** `2026-11-02`
5
+ **Author:** `Echo`
6
+ **Second-pass reviewer:** `independent reviewer subagent (see below)`
7
+
8
+ ## Summary of the change
9
+
10
+ Operator report 2026-11-02 13:50 (topic 134088, with a photo): "I feel like [you] shouldn't be notifying for prompts that are handled automatically." At 13:42 two prompts from the operator's own session (a confirmation and Claude Code's weekly-usage-limit menu) were relayed, plus the standalone "Prompt Gate is now active" disclosure; both prompts were resolved without him (the session moved to another account).
11
+
12
+ 1. `src/monitoring/promptRelaySettle.ts` (new): `settleBeforeRelay(prompt, deps)` waits `settleMs`, then: session not alive → no relay; prompt anchors (option labels, else the raw text's last lines) found in the last 20 pane lines → relay; not found → no relay; pane unreadable or no anchor → relay.
13
+ 2. `src/commands/server.ts`: the relay branch calls it before Telegram/Slack relay, with `sessionManager.isSessionAlive` and `sessionManager.captureOutput`. A skipped relay is logged and resets the detector for that session.
14
+ 3. `src/core/types.ts`: `promptGate.relaySettleMs` (default 45000; 0 = immediate).
15
+ 4. `src/messaging/TelegramAdapter.ts`: the first-use disclosure is appended to the first relayed prompt as one line instead of a separate message.
16
+
17
+ ## Decision-point inventory
18
+
19
+ - `settleBeforeRelay` — add — invariant: whether to send a notification about a prompt. Deterministic by design: it only suppresses a message when the live pane positively no longer shows the prompt or the session is gone; every uncertain case relays.
20
+
21
+ ## 1. Over-block
22
+
23
+ A prompt answered by the operator at the terminal within 45 s is not relayed (correct). A prompt whose menu text is redrawn differently (labels reworded) would read as cleared and not relay; anchors are the detector's own parsed labels from the same pane, so a redraw of the same menu keeps them. A prompt pushed out of the last 20 lines by later output while still waiting is possible but unlikely for a blocking prompt (the session is stopped on it).
24
+
25
+ ## 2. Under-block
26
+
27
+ A prompt that stays on screen but is being handled by another mechanism slower than 45 s is still relayed. The window is configurable.
28
+
29
+ ## 3. Level-of-abstraction fit
30
+
31
+ The relay decision lives in the one server relay branch that both Telegram and Slack share, so both channels get it. The detector and classifier are unchanged.
32
+
33
+ ## 4. Signal vs authority compliance
34
+
35
+ It never answers or blocks a prompt; it only decides whether to send a message, and fails toward sending. Reference: docs/signal-vs-authority.md.
36
+
37
+ ## 5. Interactions
38
+
39
+ Auto-dismiss and auto-approve paths run before it and are unchanged. Relayed prompts arrive ~45 s later, so a reply relayed back targets a prompt that was confirmed still present moments before. Detector dedup/cooldown is reset on a skipped relay, as it is after a handled one.
40
+
41
+ ## 6. External surfaces
42
+
43
+ Fewer Telegram/Slack messages; a ~45 s delay on genuine prompts; one less standalone message per topic.
44
+
45
+ ## 7. Multi-machine posture
46
+
47
+ Machine-local by design (`hardware-bound-resource`: the prompt is on this machine's tmux pane, and only this machine can read it).
48
+
49
+ ## 8. Rollback cost
50
+
51
+ Set `promptGate.relaySettleMs: 0`, or revert. No state.
52
+
53
+ ## Second-pass review
54
+
55
+ Independent reviewer — Concur with the review. Argument types match (prompt.sessionName is the tmux name, as the auto-dismiss sendKey already uses); a prompt is dropped only when the session is gone or no anchor is in the pane tail, every uncertain case relays; a skipped relay resets the detector like the auto-approve path; the 45 s wait is per prompt and does not hold the event loop; the appended note is fixed text. Minor: the topic is marked disclosed before the send succeeds, unchanged from before.
56
+
57
+ Follow-up: Local Verify flagged the pane-read catch for the silent-fallback rule; it now carries the marker explaining that an unreadable pane relays.