@agfpd/iapeer 0.4.88 → 0.4.90

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.
@@ -85,3 +85,7 @@ A peer with the wake policy `wake_policy: "ephemeral"` in its profile works diff
85
85
  - **`iapeer self-done`** — a self-call by a worker that has nothing to send: it frees the queue for the next task without waking anyone. The doctrine is "nothing to send — `self-done` instead of an empty reply".
86
86
 
87
87
  This model keeps a stream of one-off tasks orderly: one worker per task, with no accumulation of parallel sessions and no empty wakes of the recipient.
88
+
89
+ ## Replying to a human: the origin guard
90
+
91
+ An agent's reply to a human must land in the channel the human wrote from. The router enforces this mechanically: while the human's latest message to that agent is unanswered, a send targeting a *different* channel is held for a one-command confirmation (`iapeer confirm-send <id>`) instead of being delivered. Same-channel replies and initiative pass with no friction. The full contract — the armed-pair model, TTLs, `confirm-send` — is [18 — Origin guard](18-origin-guard.md).
@@ -59,6 +59,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
59
59
  | Command | What it does |
60
60
  |---|---|
61
61
  | `send <target> (--message <text> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <path>]… [--topic <t>]` | Manual IAP send over the same path as `send_to_peer`. |
62
+ | `confirm-send <id> [--runtime <rt>]` | Deliver a held send ([origin guard](18-origin-guard.md)): as addressed, or `--runtime` redirects it to the origin channel. No re-send of the message text. |
62
63
 
63
64
  `--message-file -` reads the body from stdin. `--attachment` is repeatable. `--from` defaults to the current folder's peer identity.
64
65
 
@@ -0,0 +1,66 @@
1
+ # 18 — Origin guard
2
+
3
+ [Русский](ru/18-origin-guard.md) · **English**
4
+
5
+ When a human writes to an agent from one channel (say, the web console) and the agent replies into another (say, Telegram), the reply lands where the human is not looking. Until now this error class was held back only by agent doctrine («reply where the message came from») — memory, not mechanics. The **origin guard** enforces it at the router: an agent's *reply* to a human must target the channel the human last wrote from, or it is **held** for an explicit one-command confirmation instead of being delivered.
6
+
7
+ This is a **normative contract** for the guard's behavior, state, and CLI surface.
8
+
9
+ ## What counts as a reply
10
+
11
+ The guard keeps one entry per **(human, agent)** pair — humans are peers with `intelligence: natural`:
12
+
13
+ - an ok **human → agent** delivery stamps the pair: the human's channel (`rt` = the sender's runtime) and the instant (`inboundTs`);
14
+ - an ok **agent → human** delivery stamps `answeredTs`.
15
+
16
+ The pair is **armed** when the latest inbound is *unanswered* (`inboundTs > answeredTs`) and younger than the arm TTL (48 h by default). An agent's send to the human **while armed is presumed a reply** and must target the origin channel. Once answered — or once the TTL cuts a stale thread — any further send is *initiative* and passes with no friction.
17
+
18
+ Why not a plain time window: a long task is exactly the high-value catch (asked from web, the agent finishes two hours later — the reply must still land in web), while a long window would false-hold initiative after an already-answered question. Unansweredness is the semantically precise discriminator; the TTL only bounds staleness (a thread closed outside the router — in an attached terminal, by voice — is invisible to the guard and must not arm it forever).
19
+
20
+ ## The hold
21
+
22
+ On an armed mismatch — the **intended** channel (`runtime` argument, or the human's default runtime when omitted) differs from the armed origin — the send is **not delivered**. It is persisted verbatim as a *pending held send* and the sender gets an instructive error:
23
+
24
+ ```text
25
+ origin-guard: "arthur" last wrote to you from "web" (2026-07-15T12:58:03+03:00) and that
26
+ message is not yet answered — this send targets "telegram". Message NOT delivered; held
27
+ verbatim as og-1a2b3c4d (expires in 15 min).
28
+ - cross-channel is intentional → deliver as addressed: iapeer confirm-send og-1a2b3c4d
29
+ - reply to the origin channel instead: iapeer confirm-send og-1a2b3c4d --runtime web
30
+ Do not re-send the message text — confirm-send delivers the held message.
31
+ ```
32
+
33
+ Both exits cost one shell command and **zero message regeneration**: the held send carries the full original input (message, topic, attachments).
34
+
35
+ ```bash
36
+ iapeer confirm-send og-1a2b3c4d # deliver as addressed (intentional cross-channel)
37
+ iapeer confirm-send og-1a2b3c4d --runtime web # deliver the same message to the origin channel
38
+ ```
39
+
40
+ `confirm-send` re-sends under the **original sender identity**. A peer session (carrying `PEER_IDENTITY`) may confirm only its own held sends; a bare operator shell may confirm any (same-uid trust domain). The claim is atomic — of two concurrent confirms exactly one wins. A failed delivery puts the hold back (retry until TTL); an expired hold answers «re-send the message instead».
41
+
42
+ ## Where it runs
43
+
44
+ The guard and both stamps live in `routeSend` — the one routing point **both** entry paths share: the daemon's `send_to_peer` tool *and* the in-process CLI `iapeer send` (which is how the channel bridges deliver human inbound). A daemon-only guard would never see the inbound half and never arm. The bypass used by `confirm-send` is a routing dependency wired by the entry points — it is **not reachable from agent-supplied tool arguments**, so an agent cannot forge it.
45
+
46
+ State is on disk (it must survive daemon restarts and short-lived CLI processes), under the state root:
47
+
48
+ ```text
49
+ ~/.iapeer/state/iapeer/origin/
50
+ state.json # (agent → human → {rt, inboundTs, answeredTs}) — last-wins, atomic writes
51
+ pending/<id>.json # held sends, verbatim; lazily GC'd past the pending TTL
52
+ ```
53
+
54
+ ## Scope and non-interference
55
+
56
+ The guard touches exactly one traffic class: **agent → human** sends while armed. Everything else is untouched by construction: agent→agent, human→agent, service (`absent`) senders and targets, same-channel replies (armed origin = intended channel), initiative (disarmed), and every flow with the guard disabled. Input typed directly into an attached terminal never passes the router, so it neither arms nor disarms the pair — the arm TTL bounds the resulting staleness. Hard delivery policies (e.g. the telegram sender-face policy) still apply at delivery time, including on the confirm path.
57
+
58
+ Delivery outcomes stay observable in `delivery.log`: a hold is an `ok=false` line whose `err` carries the origin-guard note (grep `origin-guard`); a confirmed delivery carries `og=<id>`, correlating it with its earlier refusal.
59
+
60
+ ## Knobs
61
+
62
+ | Env | Default | Meaning |
63
+ |---|---|---|
64
+ | `IAPEER_ORIGIN_GUARD` | on | `0` disables the guard and the stamps host-wide |
65
+ | `IAPEER_ORIGIN_GUARD_ARM_TTL_MS` | 48 h | how long an unanswered inbound keeps the pair armed |
66
+ | `IAPEER_ORIGIN_GUARD_PENDING_TTL_MS` | 15 min | how long a held send stays confirmable |
package/docs/README.md CHANGED
@@ -79,6 +79,7 @@ The sections are layered: from "install and use" to "understand the architecture
79
79
  - [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
80
80
  - [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
81
81
  - [17 — Human approval](17-approval.md) — per-peer gated/yolo mode: routing a runtime's blocking approvals to a human through the daemon broker (hook interception, the broker, Fleet API + CLI)
82
+ - [18 — Origin guard](18-origin-guard.md) — an agent's reply to a human must target the channel the human wrote from: the armed-pair model, the hold, `confirm-send`
82
83
 
83
84
  ## Status
84
85
 
@@ -67,3 +67,7 @@ iapeer compact assistant # сжать диалог
67
67
  - **`iapeer self-done`** — самовызов воркера, которому нечего отправить: освобождает очередь под следующую задачу, не разбудив никого. Доктрина «нечего отправлять — `self-done` вместо пустого ответа».
68
68
 
69
69
  Эта модель держит поток одноразовых задач упорядоченным: один воркер на задачу, без накопления параллельных сессий, без пустых пробуждений адресата.
70
+
71
+ ## Ответ человеку: origin guard
72
+
73
+ Ответ агента человеку обязан лечь в канал, из которого человек писал. Роутер закрепляет это механически: пока последнее сообщение человека этому агенту не отвечено, отправка в *другой* канал удерживается до подтверждения одной командой (`iapeer confirm-send <id>`) — вместо доставки. Ответы в тот же канал и инициатива проходят без трения. Полный контракт — модель взведённой пары, TTL, `confirm-send` — в [18 — Origin guard](18-origin-guard.md).
@@ -59,6 +59,7 @@
59
59
  | Команда | Что делает |
60
60
  |---|---|
61
61
  | `send <цель> (--message <текст> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <путь>]… [--topic <t>]` | Ручная отправка IAP по тому же пути, что `send_to_peer`. |
62
+ | `confirm-send <id> [--runtime <rt>]` | Доставить удержанную отправку ([origin guard](18-origin-guard.md)): как адресована, либо `--runtime` перенаправляет её в канал-источник. Без повторной отправки текста. |
62
63
 
63
64
  `--message-file -` читает тело из stdin. `--attachment` повторяемый. `--from` по умолчанию — идентичность пира текущей папки.
64
65
 
@@ -0,0 +1,66 @@
1
+ # 18 — Origin guard (страж канала-источника)
2
+
3
+ **Русский** · [English](../18-origin-guard.md)
4
+
5
+ Когда человек пишет агенту из одного канала (скажем, из web-консоли), а агент отвечает в другой (скажем, в Telegram), ответ уходит туда, куда человек не смотрит. До сих пор этот класс ошибок держался только на доктрине агента («отвечай туда, откуда пришло») — на памяти, не на механике. **Origin guard** закрепляет правило на роутере: *ответ* агента человеку обязан целиться в канал, из которого человек писал в последний раз, иначе он **удерживается** до явного подтверждения одной командой — вместо доставки.
6
+
7
+ Это **нормативный контракт** поведения стража, его состояния и CLI-поверхности.
8
+
9
+ ## Что считается ответом
10
+
11
+ Страж ведёт одну запись на пару **(человек, агент)** — люди суть пиры с `intelligence: natural`:
12
+
13
+ - успешная доставка **человек → агент** штампует пару: канал человека (`rt` = рантайм отправителя) и момент (`inboundTs`);
14
+ - успешная доставка **агент → человек** штампует `answeredTs`.
15
+
16
+ Пара **взведена**, когда последнее входящее *не отвечено* (`inboundTs > answeredTs`) и моложе TTL взвода (по умолчанию 48 ч). Отправка агента человеку **при взведённой паре презюмируется ответом** и обязана целиться в канал-источник. После ответа — либо после того как TTL отсёк залежалую нить — любая следующая отправка есть *инициатива* и проходит без трения.
17
+
18
+ Почему не простое окно времени: долгая задача — ровно тот ценный класс, который нужно ловить (спросили из web, агент закончил через два часа — ответ всё равно должен лечь в web), а длинное окно ложно удерживало бы инициативу после уже отвеченного вопроса. Неотвеченность — семантически точный дискриминатор; TTL лишь ограничивает залежалость (нить, закрытая мимо роутера — в приаттаченном терминале, голосом — стражу не видна и не должна держать пару взведённой вечно).
19
+
20
+ ## Удержание
21
+
22
+ При взведённом несовпадении — **намеченный** канал (аргумент `runtime`, а при его отсутствии дефолтный рантайм человека) отличается от взведённого источника — отправка **не доставляется**. Она сохраняется дословно как *удержанная* (pending), а отправитель получает инструктивную ошибку:
23
+
24
+ ```text
25
+ origin-guard: "arthur" last wrote to you from "web" (2026-07-15T12:58:03+03:00) and that
26
+ message is not yet answered — this send targets "telegram". Message NOT delivered; held
27
+ verbatim as og-1a2b3c4d (expires in 15 min).
28
+ - cross-channel is intentional → deliver as addressed: iapeer confirm-send og-1a2b3c4d
29
+ - reply to the origin channel instead: iapeer confirm-send og-1a2b3c4d --runtime web
30
+ Do not re-send the message text — confirm-send delivers the held message.
31
+ ```
32
+
33
+ Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт полный исходный ввод (текст, топик, вложения).
34
+
35
+ ```bash
36
+ iapeer confirm-send og-1a2b3c4d # доставить как адресовано (кросс-канал намеренный)
37
+ iapeer confirm-send og-1a2b3c4d --runtime web # доставить то же сообщение в канал-источник
38
+ ```
39
+
40
+ `confirm-send` отправляет от **исходной идентичности отправителя**. Сессия пира (несущая `PEER_IDENTITY`) может подтверждать только свои удержанные отправки; голый операторский шелл — любые (домен доверия same-uid). Захват атомарен — из двух конкурентных подтверждений побеждает ровно одно. Провал доставки возвращает удержание на место (повтор до TTL); истёкшее отвечает «отправь сообщение заново».
41
+
42
+ ## Где это работает
43
+
44
+ Страж и оба штампа живут в `routeSend` — единственной точке маршрутизации, общей для **обоих** путей входа: инструмента `send_to_peer` демона *и* внутрипроцессного CLI `iapeer send` (которым мосты каналов доставляют входящие от человека). Страж только на стороне демона никогда не видел бы входящую половину и никогда бы не взводился. Обход (bypass), которым пользуется `confirm-send`, — зависимость маршрутизации, которую прошивают точки входа; он **недостижим из tool-аргументов агента**, подделать его агент не может.
45
+
46
+ Состояние — на диске (обязано переживать рестарты демона и короткоживущие CLI-процессы), под корнем состояния:
47
+
48
+ ```text
49
+ ~/.iapeer/state/iapeer/origin/
50
+ state.json # (агент → человек → {rt, inboundTs, answeredTs}) — last-wins, атомарные записи
51
+ pending/<id>.json # удержанные отправки, дословно; ленивый GC после TTL
52
+ ```
53
+
54
+ ## Границы и невмешательство
55
+
56
+ Страж касается ровно одного класса трафика: отправки **агент → человек** при взведённой паре. Всё прочее не затронуто по построению: агент→агент, человек→агент, сервисные (`absent`) отправители и цели, ответы в тот же канал (источник = намеченный канал), инициатива (пара разряжена) и любые потоки при выключенном страже. Ввод, набранный прямо в приаттаченном терминале, роутер не проходит — он не взводит и не разряжает пару; итоговую залежалость ограничивает TTL взвода. Жёсткие политики доставки (например, telegram-политика лица отправителя) продолжают действовать в момент доставки, включая путь подтверждения.
57
+
58
+ Исходы доставки наблюдаемы в `delivery.log`: удержание — строка `ok=false`, чей `err` несёт ноут стража (grep `origin-guard`); подтверждённая доставка несёт `og=<id>`, связывающий её с прежним отказом.
59
+
60
+ ## Ручки
61
+
62
+ | Env | По умолчанию | Смысл |
63
+ |---|---|---|
64
+ | `IAPEER_ORIGIN_GUARD` | вкл | `0` выключает стража и штампы на всём хосте |
65
+ | `IAPEER_ORIGIN_GUARD_ARM_TTL_MS` | 48 ч | сколько неотвеченное входящее держит пару взведённой |
66
+ | `IAPEER_ORIGIN_GUARD_PENDING_TTL_MS` | 15 мин | сколько удержанная отправка остаётся подтверждаемой |
package/docs/ru/README.md CHANGED
@@ -79,6 +79,7 @@ iapeer send assistant --message "привет, ты на связи?"
79
79
  - [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
80
80
  - [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
81
81
  - [17 — Подтверждение человеком](17-approval.md) — режим gated/yolo на пира: маршрутизация блокирующих подтверждений рантайма человеку через брокер демона (перехват хуком, брокер, Fleet API + CLI)
82
+ - [18 — Origin guard](18-origin-guard.md) — ответ агента человеку обязан целиться в канал, из которого человек писал: модель взведённой пары, удержание, `confirm-send`
82
83
 
83
84
  ## Статус
84
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.88",
3
+ "version": "0.4.90",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -1079,6 +1079,12 @@ export interface SendOptions extends CliEnvOptions {
1079
1079
  message: string
1080
1080
  topic?: string
1081
1081
  attachments?: string[]
1082
+ /** Origin-guard bypass (docs/18) — set ONLY by `iapeer confirm-send` (the held-send
1083
+ * delivery). Wired into RouteDeps, never derivable from agent-supplied tool args. */
1084
+ originGuardBypass?: boolean
1085
+ /** Held-send id (confirm-send) — stamped into delivery.log as `og=` so a confirmed
1086
+ * delivery is correlatable with its earlier origin-hold refusal. */
1087
+ og?: string
1082
1088
  }
1083
1089
 
1084
1090
  const cliWake: WakeFn = req =>
@@ -1112,7 +1118,12 @@ export async function sendMessage(
1112
1118
  // noteLiveTopic — CLI-path parity (same seam the daemon wires): a live delivery
1113
1119
  // through the CLI fallback must update the target's .topic marker too, or a
1114
1120
  // daemon-restart-window send re-opens the stale-marker false-fresh.
1115
- { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}), noteLiveTopic: makeNoteLiveTopic(cfg, env) },
1121
+ {
1122
+ wake: cliWake,
1123
+ ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}),
1124
+ noteLiveTopic: makeNoteLiveTopic(cfg, env),
1125
+ originGuardBypass: opts.originGuardBypass,
1126
+ },
1116
1127
  )
1117
1128
  // delivery.log sink — CLI-path parity (observability gap: enqueues routed
1118
1129
  // through the CLI left to=<peer> at ZERO while real wakes happened; the daemon
@@ -1135,6 +1146,7 @@ export async function sendMessage(
1135
1146
  len: opts.message.length,
1136
1147
  att: opts.attachments?.length || undefined,
1137
1148
  topic: opts.topic,
1149
+ og: opts.og, // held-send id (confirm-send) — correlates with the origin-hold refusal
1138
1150
  err: result.ok ? undefined : result.error.message,
1139
1151
  })
1140
1152
  if (!result.ok) throw new Error(result.error.message)
@@ -1277,6 +1289,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1277
1289
  sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
1278
1290
  desc: 'manual IAP send (fallback)',
1279
1291
  },
1292
+ {
1293
+ sig: 'confirm-send <id> [--runtime <rt>]',
1294
+ desc: 'deliver a held send (origin-guard, docs/18): as addressed, or --runtime redirects it to the origin channel — no re-send of the message text',
1295
+ },
1280
1296
  { sig: '<runtime>', desc: "launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached" },
1281
1297
  { sig: 'connect telegram <peer> [--token <t>]', desc: 'attach a telegram bot to a peer (bot add → interface → router restart; asks only the token). Alias: `iapeer enable telegram <peer>`' },
1282
1298
  { sig: 'enable <plugin> [peer] [--no-setup]', desc: 'install + enable an agfpd capability for a peer' },
@@ -2136,6 +2152,54 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2136
2152
  )
2137
2153
  return 0
2138
2154
  }
2155
+ case 'confirm-send': {
2156
+ // Origin-guard (docs/18): deliver a HELD send — as addressed (intentional
2157
+ // cross-channel), or redirected to the origin channel via --runtime. The held
2158
+ // message is re-sent VERBATIM under the ORIGINAL sender identity; the bypass
2159
+ // flag lives in RouteDeps (not tool args), so this verb is the only gate.
2160
+ const id = positionals[0]
2161
+ if (!id) return argErr(errOut, 'confirm-send needs an id — usage: iapeer confirm-send <id> [--runtime <rt>]')
2162
+ const rtOverride = typeof flags.runtime === 'string' ? flags.runtime : undefined
2163
+ const { claimHeldSend, restoreHeldSend, discardHeldSend } = await import('../transport/originGuard.ts')
2164
+ const claimed = claimHeldSend(id, { env })
2165
+ if (!claimed.ok) {
2166
+ errOut(`${claimed.error.message}\n`)
2167
+ return 1
2168
+ }
2169
+ const held = claimed.value
2170
+ // The confirmer must BE the holder: a peer session carries PEER_IDENTITY, and a
2171
+ // held send may only be confirmed by the peer that produced it. Absent identity
2172
+ // (a bare operator shell) is trusted — same-uid operator, same trust domain.
2173
+ const confirmer = env.PEER_IDENTITY?.trim()
2174
+ if (confirmer && confirmer !== held.caller) {
2175
+ restoreHeldSend(id, { env })
2176
+ errOut(`confirm-send: held send ${id} belongs to ${held.caller}, not ${confirmer} — refused\n`)
2177
+ return 1
2178
+ }
2179
+ try {
2180
+ const r = await sendMessage({
2181
+ target: held.personality,
2182
+ from: held.caller,
2183
+ message: held.message,
2184
+ runtime: rtOverride ?? held.runtime,
2185
+ topic: held.topic,
2186
+ attachments: held.attachments,
2187
+ env,
2188
+ originGuardBypass: true,
2189
+ og: held.id,
2190
+ })
2191
+ discardHeldSend(id, { env })
2192
+ out(`delivered held send ${id} to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
2193
+ return 0
2194
+ } catch (e) {
2195
+ restoreHeldSend(id, { env })
2196
+ errOut(
2197
+ `confirm-send: delivery failed — ${e instanceof Error ? e.message : String(e)}; ` +
2198
+ `the held send remains until its TTL — retry: iapeer confirm-send ${id}\n`,
2199
+ )
2200
+ return 1
2201
+ }
2202
+ }
2139
2203
  case 'version':
2140
2204
  case '--version':
2141
2205
  case '-v': {
@@ -213,6 +213,41 @@ export function discoverPeerRuntimes(cwd: string, currentRuntime: Runtime): Runt
213
213
  return uniqueRuntimes(discovered)
214
214
  }
215
215
 
216
+ /** Agentic runtimes only an ARTIFICIAL peer can drive. A natural (human) or absent
217
+ * (no mind) peer has nobody to sit in a claude/codex session under its identity. */
218
+ const AGENTIC_GATED_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
219
+
220
+ /**
221
+ * DISCOVERY GATE (хвост-2, boris 15.07): drop agentic runtimes a NON-artificial peer
222
+ * would otherwise pick up from folder artifacts — a stray `.claude`/`.codex` dir or a
223
+ * leftover `.iapeer/<rt>` runtime scope in the cwd is NOT a declaration (live leak: a
224
+ * long-forgotten .claude/ in the human peer arthur's cwd made every create-прогон
225
+ * attach `claude` to his profile). The gate is by INTELLIGENCE, so it is robust to the
226
+ * artifacts' PRESENCE — the folder stays, the runtime is simply not attached. It also
227
+ * HEALS an already-leaked profile: applied to the merged set on re-provision, it
228
+ * scrubs previously-attached agentic runtimes off a natural/absent peer (with a warn).
229
+ * The EXPLICIT `options.runtime` always passes — an operator's deliberate command is
230
+ * not discovery (and explicit nature/runtime mismatches fail loud elsewhere:
231
+ * isIntelligenceAllowedForRuntime).
232
+ */
233
+ export function gateRuntimesForIntelligence(
234
+ runtimes: Runtime[],
235
+ intelligence: Intelligence,
236
+ explicitRuntime: Runtime,
237
+ warn?: (message: string) => void,
238
+ ): Runtime[] {
239
+ if (intelligence === 'artificial') return runtimes
240
+ const isGated = (rt: Runtime): boolean => AGENTIC_GATED_RUNTIMES.has(rt) && rt !== explicitRuntime
241
+ const dropped = runtimes.filter(isGated)
242
+ if (dropped.length === 0) return runtimes
243
+ warn?.(
244
+ `discovery gate: dropped agentic runtime(s) [${dropped.join(', ')}] for "${intelligence}" peer — ` +
245
+ `a .claude/.codex folder or leftover runtime scope in the cwd is not a declaration; ` +
246
+ `agentic runtimes attach only to artificial peers`,
247
+ )
248
+ return runtimes.filter(rt => !isGated(rt))
249
+ }
250
+
216
251
  export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null {
217
252
  const path = peerProfilePath(cwd)
218
253
  if (!existsSync(path)) return null
@@ -566,14 +601,17 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
566
601
  `PEER_PERSONALITY "${options.env.PEER_PERSONALITY}" does not match the peer being initialized ("${personality}")`,
567
602
  )
568
603
  }
604
+ // Intelligence FIRST — the discovery gate below keys on it (a natural/absent
605
+ // peer must not be born with agentic runtimes picked up from folder artifacts).
606
+ const intelligence = resolvedIntelligence(options.runtime)
569
607
  const profile: PeerProfile = {
570
608
  personality,
571
609
  runtime: options.runtime,
572
- runtimes: discoveredRuntimes,
610
+ runtimes: gateRuntimesForIntelligence(discoveredRuntimes, intelligence, options.runtime, options.warn),
573
611
  // В36 — the description lands in the LOCAL profile at birth (source of truth);
574
612
  // the registry row is a projection of this file, not the other way around.
575
613
  description: options.description?.trim() ?? '',
576
- intelligence: resolvedIntelligence(options.runtime),
614
+ intelligence,
577
615
  }
578
616
  ensureLocalRuntimeScopes(cwd, profile.runtimes)
579
617
  // INFRA runtime → provision the always-on launchd plist that holds it live.
@@ -609,8 +647,6 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
609
647
  `PEER_PERSONALITY must match ${IAPEER_DIR}/${PEER_PROFILE_FILE} personality "${existing.personality}", got "${options.env.PEER_PERSONALITY}"`,
610
648
  )
611
649
  }
612
- const mergedRuntimes = uniqueRuntimes([...existing.runtimes, ...discoveredRuntimes])
613
- ensureLocalRuntimeScopes(cwd, mergedRuntimes)
614
650
  // RE-PROVISION parity with the new-profile branch: an EXISTING infra peer (a
615
651
  // migration / re-provision — e.g. `iapeer create alice --runtime telegram` to move
616
652
  // a live telegram human onto the foundation) must ALSO get its always-on plist
@@ -641,11 +677,25 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
641
677
  // preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
642
678
  intelligence = resolvedIntelligence(options.runtime)
643
679
  }
680
+ // Merge AFTER the intelligence is final, and GATE the WHOLE merged set (not just the
681
+ // fresh discoveries): for a natural/absent peer this both blocks a new agentic pickup
682
+ // AND heals a previously-leaked one (the arthur claude-leak class) on the next
683
+ // re-provision — with the folder artifacts still in place.
684
+ const mergedRuntimes = gateRuntimesForIntelligence(
685
+ uniqueRuntimes([...existing.runtimes, ...discoveredRuntimes]),
686
+ intelligence,
687
+ options.runtime,
688
+ options.warn,
689
+ )
690
+ ensureLocalRuntimeScopes(cwd, mergedRuntimes)
644
691
  // В36 — an explicit description on RE-provision updates the local profile too (H1 in
645
692
  // writePeerProfileAtomic still guards: absent/empty never wipes an existing value).
646
693
  const explicitDescription = options.description?.trim()
647
694
  const descriptionChanged = Boolean(explicitDescription) && explicitDescription !== existing.description
648
- if (mergedRuntimes.length !== existing.runtimes.length || intelligence !== existing.intelligence || descriptionChanged) {
695
+ // CONTENT comparison, not length: the gate can DROP a leaked runtime while discovery
696
+ // ADDS a new one in the same pass (arthur: [telegram, claude] + web → [telegram, web])
697
+ // — equal lengths, different sets; a length check would silently skip the heal-write.
698
+ if (mergedRuntimes.join(',') !== existing.runtimes.join(',') || intelligence !== existing.intelligence || descriptionChanged) {
649
699
  const updated = {
650
700
  ...existing,
651
701
  runtimes: mergedRuntimes,
@@ -57,6 +57,17 @@ import {
57
57
  listHostedPeers,
58
58
  } from '../launch/ptyHost.ts'
59
59
  import { sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
60
+ // Origin-guard (docs/18) — reply-channel mechanic for agent→human sends. Lives in
61
+ // transport because routeSend is the ONLY choke point both directions share (human
62
+ // inbound arrives via the CLI entry path, past the daemon — see originGuard.ts header).
63
+ import {
64
+ armedOrigin,
65
+ buildHoldNote,
66
+ holdSend,
67
+ noteHumanAnswered,
68
+ noteHumanInbound,
69
+ originGuardEnabled,
70
+ } from './originGuard.ts'
60
71
  import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
61
72
  import { paneLogComposerOccupied } from '../launch/readyGateModel.ts' // spawn-flip Ф0b-3 slice 3c: hosted busy-composer detector (slice 2). @xterm dynamic-loaded inside it → warm path stays @xterm-free with the flip off
62
73
 
@@ -971,6 +982,11 @@ export interface RouteDeps {
971
982
  isLaunchdManaged?: (personality: string) => boolean
972
983
  /** Injectable async sleep for the launchd-revive retry poll (tests). Default: setTimeout. */
973
984
  sleep?: (ms: number) => Promise<void>
985
+ /** Origin-guard bypass — set ONLY by `iapeer confirm-send` (the held-send delivery).
986
+ * DELIBERATELY a RouteDeps field, not a SendToPeerInput field: tool arguments are
987
+ * agent-supplied, so an input flag would let any agent forge the bypass; deps are
988
+ * wired by the entry points (daemon/CLI), out of the agent's reach. */
989
+ originGuardBypass?: boolean
974
990
  }
975
991
 
976
992
  /** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
@@ -1295,6 +1311,36 @@ export async function routeSend(
1295
1311
  caller: ResolvedCaller,
1296
1312
  input: SendToPeerInput,
1297
1313
  deps: RouteDeps = {},
1314
+ ): Promise<Result<RouteResult>> {
1315
+ const result = await routeSendInner(caller, input, deps)
1316
+ // Origin-guard stamps — AFTER an ok outcome only (a failed send answered nothing and
1317
+ // carried no inbound). Best-effort: a stamp failure must never fail a delivered
1318
+ // message. Covers EVERY ok path (live / queued / wake / launchd-revive) in one place.
1319
+ if (result.ok) stampOriginOnDelivered(caller, input.personality, deps.env ?? process.env)
1320
+ return result
1321
+ }
1322
+
1323
+ /** Post-ok origin stamps: human→agent ⇒ inbound (arms the pair with the human's origin
1324
+ * channel); agent→human ⇒ answered (disarms). Any other pair — no-op. */
1325
+ function stampOriginOnDelivered(caller: ResolvedCaller, targetPersonality: string, env: NodeJS.ProcessEnv): void {
1326
+ try {
1327
+ if (!originGuardEnabled(env)) return
1328
+ const peer = findPeer(readPeersIndex({ env }), targetPersonality)
1329
+ if (!peer) return
1330
+ if (caller.intelligence === 'natural' && peer.intelligence === 'artificial') {
1331
+ noteHumanInbound(peer.personality, caller.personality, caller.runtime, { env })
1332
+ } else if (caller.intelligence === 'artificial' && peer.intelligence === 'natural') {
1333
+ noteHumanAnswered(caller.personality, peer.personality, { env })
1334
+ }
1335
+ } catch {
1336
+ /* stamps are observability for the guard — never fail a delivered message */
1337
+ }
1338
+ }
1339
+
1340
+ async function routeSendInner(
1341
+ caller: ResolvedCaller,
1342
+ input: SendToPeerInput,
1343
+ deps: RouteDeps = {},
1298
1344
  ): Promise<Result<RouteResult>> {
1299
1345
  const { personality, runtime, message } = input
1300
1346
  const topic = truncateTopic(input.topic)
@@ -1324,6 +1370,35 @@ export async function routeSend(
1324
1370
  return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
1325
1371
  }
1326
1372
 
1373
+ // Origin-guard (docs/18) — an agent's PRESUMED REPLY to a human (the human's latest
1374
+ // inbound to this agent is unanswered) must target the channel the human wrote from.
1375
+ // Checked FIRST, before any resolve/wake side-effect and before the telegram policy:
1376
+ // a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
1377
+ // agent then confirms cross-channel or redirects to the origin, zero regeneration.
1378
+ // Intended runtime = the explicit override or the human's default; exact for the
1379
+ // natural-peer class (their channels are always-on launchd routers, so the
1380
+ // only-live-session fallback cannot silently diverge outside a seconds-wide crash
1381
+ // window). Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
1382
+ if (
1383
+ !deps.originGuardBypass &&
1384
+ originGuardEnabled(env) &&
1385
+ caller.intelligence === 'artificial' &&
1386
+ peer.intelligence === 'natural'
1387
+ ) {
1388
+ const armed = armedOrigin(caller.personality, peer.personality, { env })
1389
+ const intendedRt = runtime ?? peer.runtime
1390
+ if (armed && armed.rt !== intendedRt) {
1391
+ const held = holdSend(
1392
+ caller.address,
1393
+ { personality, runtime, message, topic, attachments: attachmentsResult.value },
1394
+ armed,
1395
+ intendedRt,
1396
+ { env },
1397
+ )
1398
+ return err(buildHoldNote(held, { env }))
1399
+ }
1400
+ }
1401
+
1327
1402
  // Telegram sender policy — INTENDED channel (explicit override, or the target's
1328
1403
  // default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
1329
1404
  // the boot first-message, past the resolved-target guards further down.
@@ -0,0 +1,300 @@
1
+ // Origin-guard (docs/18) — the daemon-layer mechanic against the "agent answered the
2
+ // human in the wrong channel" error class. Doctrine (origin-routing: reply where the
3
+ // human wrote from) lives in every agent's memory — unreliable by construction; this
4
+ // module enforces it MECHANICALLY at the delivery choke point, routeSend (§3 of the
5
+ // host rules: fix errors at the right layer).
6
+ //
7
+ // WHY transport, not the daemon: live forensics of delivery.log showed that ALL
8
+ // human-inbound traffic (telegram-arthur / web-arthur) arrives via `iapeer send`
9
+ // (path=cli — the bridges run the CLI verb, an IN-PROCESS routeSend that never touches
10
+ // the daemon's callTool), and codex agents send through the CLI shim too. routeSend is
11
+ // the ONLY point both directions and both entry paths (daemon MCP + CLI) share — a
12
+ // daemon-side guard would be blind to the inbound half and never arm.
13
+ //
14
+ // Model (per (human, agent) pair, humans = intelligence:natural):
15
+ // • inbound stamp — an ok human→agent delivery records {rt, inboundTs}.
16
+ // • answered stamp — an ok agent→human delivery records answeredTs.
17
+ // • armed — inboundTs > answeredTs (the latest inbound is UNANSWERED) and
18
+ // younger than ARM_TTL. Discriminates reply-vs-initiative
19
+ // semantically: an outbound while armed is presumed a REPLY (must
20
+ // match the origin channel); once answered (or TTL-stale), any
21
+ // further outbound is INITIATIVE — no friction. A pure time
22
+ // window would either miss the long-task class (asked from web,
23
+ // answered 2h later into telegram) or false-hold initiative.
24
+ // • hold — an armed mismatch (intended runtime ≠ origin runtime) is NOT
25
+ // delivered: the send is persisted verbatim as a pending file and
26
+ // the sender gets an instructive error. `iapeer confirm-send <id>`
27
+ // delivers it as addressed; `--runtime <origin>` redirects it to
28
+ // the origin channel — zero message regeneration either way.
29
+ //
30
+ // State is on DISK (not daemon memory) because the CLI entry path is a short-lived
31
+ // process, and stamps must survive daemon restarts. Writes are atomic
32
+ // (storage.writeFileAtomic); concurrent read-modify-write is LAST-WINS by design — the
33
+ // guard is soft, stamp frequency is human-paced, and a lost stamp degrades to at most
34
+ // one extra confirm (owner-approved v1 fork; add locking only if it bites live).
35
+ //
36
+ // Paths resolve from the injected env (the readPeersIndex({env}) pattern) so the
37
+ // IAPEER_ROOT sandbox isolates tests — never from a re-resolved process.env.
38
+
39
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'fs'
40
+ import { join } from 'path'
41
+ import { randomBytes } from 'crypto'
42
+ import { err, ok, type Result } from '../core/errors.ts'
43
+ import { pluginStateDir, writeFileAtomic } from '../storage/index.ts'
44
+ import { formatSentAt } from '../codec/index.ts'
45
+
46
+ /** Kill-switch: IAPEER_ORIGIN_GUARD=0 disables the guard AND the stamps host-wide. */
47
+ export function originGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return env.IAPEER_ORIGIN_GUARD !== '0'
49
+ }
50
+
51
+ const DEFAULT_ARM_TTL_MS = 48 * 60 * 60 * 1000 // 48 h — covers a realistic reply latency, cuts staleness
52
+ const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000 // 15 min — the agent confirms within its current turn
53
+
54
+ function envPosInt(raw: string | undefined, dflt: number): number {
55
+ const n = parseInt(raw ?? '', 10)
56
+ return Number.isFinite(n) && n > 0 ? n : dflt
57
+ }
58
+
59
+ export function armTtlMs(env: NodeJS.ProcessEnv = process.env): number {
60
+ return envPosInt(env.IAPEER_ORIGIN_GUARD_ARM_TTL_MS, DEFAULT_ARM_TTL_MS)
61
+ }
62
+
63
+ export function pendingTtlMs(env: NodeJS.ProcessEnv = process.env): number {
64
+ return envPosInt(env.IAPEER_ORIGIN_GUARD_PENDING_TTL_MS, DEFAULT_PENDING_TTL_MS)
65
+ }
66
+
67
+ export interface OriginGuardOptions {
68
+ env?: NodeJS.ProcessEnv
69
+ /** Injectable clock (tests). Default Date.now. */
70
+ nowMs?: number
71
+ }
72
+
73
+ // ── Origin state (last inbound / answered, per agent→human pair) ─────────────────
74
+
75
+ export interface OriginEntry {
76
+ /** The runtime the human's latest message arrived FROM (its origin channel). */
77
+ rt: string
78
+ /** When that inbound was delivered ok (epoch ms). */
79
+ inboundTs: number
80
+ /** When the agent last answered this human ok (epoch ms). Absent = never. */
81
+ answeredTs?: number
82
+ }
83
+
84
+ /** agent personality → human personality → entry. */
85
+ type OriginState = Record<string, Record<string, OriginEntry>>
86
+
87
+ function originDir(env: NodeJS.ProcessEnv): string {
88
+ return join(pluginStateDir('iapeer', { env }), 'origin')
89
+ }
90
+
91
+ function statePath(env: NodeJS.ProcessEnv): string {
92
+ return join(originDir(env), 'state.json')
93
+ }
94
+
95
+ function pendingDir(env: NodeJS.ProcessEnv): string {
96
+ return join(originDir(env), 'pending')
97
+ }
98
+
99
+ function readState(env: NodeJS.ProcessEnv): OriginState {
100
+ try {
101
+ const parsed = JSON.parse(readFileSync(statePath(env), 'utf8')) as unknown
102
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as OriginState
103
+ } catch {
104
+ /* missing / corrupt → empty (the guard fails open: no state, no holds) */
105
+ }
106
+ return {}
107
+ }
108
+
109
+ function writeState(env: NodeJS.ProcessEnv, state: OriginState): void {
110
+ mkdirSync(originDir(env), { recursive: true })
111
+ writeFileAtomic(statePath(env), `${JSON.stringify(state)}\n`)
112
+ }
113
+
114
+ /** Record an ok human→agent delivery: the human's origin channel + instant. */
115
+ export function noteHumanInbound(agent: string, human: string, rt: string, opts: OriginGuardOptions = {}): void {
116
+ const env = opts.env ?? process.env
117
+ const now = opts.nowMs ?? Date.now()
118
+ const state = readState(env)
119
+ const perAgent = (state[agent] ??= {})
120
+ const prev = perAgent[human]
121
+ // Keep answeredTs — a NEWER inbound re-arms regardless, since inboundTs > answeredTs.
122
+ perAgent[human] = { rt, inboundTs: now, ...(prev?.answeredTs !== undefined ? { answeredTs: prev.answeredTs } : {}) }
123
+ writeState(env, state)
124
+ }
125
+
126
+ /** Record an ok agent→human delivery: the pair is answered (disarms the guard). */
127
+ export function noteHumanAnswered(agent: string, human: string, opts: OriginGuardOptions = {}): void {
128
+ const env = opts.env ?? process.env
129
+ const now = opts.nowMs ?? Date.now()
130
+ const state = readState(env)
131
+ const entry = state[agent]?.[human]
132
+ if (!entry) return // nothing to disarm — never wrote to us via the router
133
+ entry.answeredTs = now
134
+ writeState(env, state)
135
+ }
136
+
137
+ /** The ARMED origin for (agent, human), or null: the latest inbound is unanswered AND
138
+ * younger than ARM_TTL. Null = initiative / already answered / stale — no friction. */
139
+ export function armedOrigin(agent: string, human: string, opts: OriginGuardOptions = {}): OriginEntry | null {
140
+ const env = opts.env ?? process.env
141
+ const now = opts.nowMs ?? Date.now()
142
+ const entry = readState(env)[agent]?.[human]
143
+ if (!entry) return null
144
+ if (entry.answeredTs !== undefined && entry.answeredTs >= entry.inboundTs) return null
145
+ if (now - entry.inboundTs > armTtlMs(env)) return null
146
+ return entry
147
+ }
148
+
149
+ // ── Pending (held) sends ─────────────────────────────────────────────────────────
150
+
151
+ export interface HeldSend {
152
+ id: string
153
+ /** The sender's address (`<runtime>-<personality>`) — the identity confirm-send re-sends AS. */
154
+ caller: string
155
+ personality: string
156
+ runtime?: string
157
+ message: string
158
+ topic?: string
159
+ attachments?: string[]
160
+ /** The armed origin channel the human last wrote from (unanswered). */
161
+ originRt: string
162
+ /** When that unanswered inbound arrived (epoch ms) — shown in the hold note. */
163
+ originTs: number
164
+ /** The channel this send would have landed in (input.runtime ?? the human's default). */
165
+ intendedRt: string
166
+ createdMs: number
167
+ }
168
+
169
+ function pendingPath(env: NodeJS.ProcessEnv, id: string): string {
170
+ return join(pendingDir(env), `${id}.json`)
171
+ }
172
+
173
+ function claimedPath(env: NodeJS.ProcessEnv, id: string): string {
174
+ return join(pendingDir(env), `${id}.json.claimed`)
175
+ }
176
+
177
+ /** Lazy GC — unlink pending files older than the pending TTL (by mtime). Called on
178
+ * every hold/claim touch; cheap (one readdir of a near-empty dir), no timers. */
179
+ export function sweepExpiredPendings(opts: OriginGuardOptions = {}): void {
180
+ const env = opts.env ?? process.env
181
+ const now = opts.nowMs ?? Date.now()
182
+ const ttl = pendingTtlMs(env)
183
+ let entries: string[]
184
+ try {
185
+ entries = readdirSync(pendingDir(env))
186
+ } catch {
187
+ return
188
+ }
189
+ for (const name of entries) {
190
+ if (!name.endsWith('.json') && !name.endsWith('.json.claimed')) continue
191
+ const path = join(pendingDir(env), name)
192
+ try {
193
+ if (now - statSync(path).mtimeMs > ttl) unlinkSync(path)
194
+ } catch {
195
+ /* raced away / unreadable — best-effort */
196
+ }
197
+ }
198
+ }
199
+
200
+ /** Persist a held send verbatim and return its record. */
201
+ export function holdSend(
202
+ callerAddress: string,
203
+ input: { personality: string; runtime?: string; message: string; topic?: string; attachments?: string[] },
204
+ origin: OriginEntry,
205
+ intendedRt: string,
206
+ opts: OriginGuardOptions = {},
207
+ ): HeldSend {
208
+ const env = opts.env ?? process.env
209
+ const now = opts.nowMs ?? Date.now()
210
+ sweepExpiredPendings(opts)
211
+ const id = `og-${randomBytes(4).toString('hex')}`
212
+ const held: HeldSend = {
213
+ id,
214
+ caller: callerAddress,
215
+ personality: input.personality,
216
+ ...(input.runtime !== undefined ? { runtime: input.runtime } : {}),
217
+ message: input.message,
218
+ ...(input.topic !== undefined ? { topic: input.topic } : {}),
219
+ ...(input.attachments?.length ? { attachments: [...input.attachments] } : {}),
220
+ originRt: origin.rt,
221
+ originTs: origin.inboundTs,
222
+ intendedRt,
223
+ createdMs: now,
224
+ }
225
+ mkdirSync(pendingDir(env), { recursive: true })
226
+ writeFileAtomic(pendingPath(env, id), `${JSON.stringify(held, null, 2)}\n`)
227
+ return held
228
+ }
229
+
230
+ /** The instructive error the sender receives INSTEAD of a delivery. Self-sufficient:
231
+ * it names the mismatch, both zero-regeneration exits, and the TTL. */
232
+ export function buildHoldNote(held: HeldSend, opts: OriginGuardOptions = {}): string {
233
+ const env = opts.env ?? process.env
234
+ const ttlMin = Math.round(pendingTtlMs(env) / 60_000)
235
+ return (
236
+ `origin-guard: "${held.personality}" last wrote to you from "${held.originRt}" ` +
237
+ `(${formatSentAt(new Date(held.originTs))}) and that message is not yet answered — ` +
238
+ `this send targets "${held.intendedRt}". Message NOT delivered; held verbatim as ${held.id} (expires in ${ttlMin} min).\n` +
239
+ `- cross-channel is intentional → deliver as addressed: iapeer confirm-send ${held.id}\n` +
240
+ `- reply to the origin channel instead: iapeer confirm-send ${held.id} --runtime ${held.originRt}\n` +
241
+ `Do not re-send the message text — confirm-send delivers the held message.`
242
+ )
243
+ }
244
+
245
+ /** Claim a pending send for delivery — atomic rename (<id>.json → <id>.json.claimed),
246
+ * so a concurrent second confirm loses cleanly (ENOENT → "no pending"). TTL-checked. */
247
+ export function claimHeldSend(id: string, opts: OriginGuardOptions = {}): Result<HeldSend> {
248
+ const env = opts.env ?? process.env
249
+ const now = opts.nowMs ?? Date.now()
250
+ sweepExpiredPendings(opts)
251
+ const src = pendingPath(env, id)
252
+ const dst = claimedPath(env, id)
253
+ let raw: string
254
+ try {
255
+ renameSync(src, dst) // atomic claim — exactly one confirmer wins
256
+ raw = readFileSync(dst, 'utf8')
257
+ } catch {
258
+ return err(`no pending held send "${id}" (expired, already confirmed, or unknown) — re-send the message instead`)
259
+ }
260
+ let held: HeldSend
261
+ try {
262
+ held = JSON.parse(raw) as HeldSend
263
+ } catch {
264
+ try {
265
+ unlinkSync(dst)
266
+ } catch {
267
+ /* best-effort */
268
+ }
269
+ return err(`held send "${id}" is unreadable (corrupt pending file) — re-send the message instead`)
270
+ }
271
+ if (now - held.createdMs > pendingTtlMs(env)) {
272
+ try {
273
+ unlinkSync(dst)
274
+ } catch {
275
+ /* best-effort */
276
+ }
277
+ return err(`held send "${id}" expired (${Math.round(pendingTtlMs(env) / 60_000)} min TTL) — re-send the message instead`)
278
+ }
279
+ return ok(held)
280
+ }
281
+
282
+ /** Delivery failed — put the claimed pending back so confirm-send can be retried until TTL. */
283
+ export function restoreHeldSend(id: string, opts: OriginGuardOptions = {}): void {
284
+ const env = opts.env ?? process.env
285
+ try {
286
+ renameSync(claimedPath(env, id), pendingPath(env, id))
287
+ } catch {
288
+ /* best-effort */
289
+ }
290
+ }
291
+
292
+ /** Delivery succeeded — drop the claimed pending. */
293
+ export function discardHeldSend(id: string, opts: OriginGuardOptions = {}): void {
294
+ const env = opts.env ?? process.env
295
+ try {
296
+ unlinkSync(claimedPath(env, id))
297
+ } catch {
298
+ /* best-effort */
299
+ }
300
+ }