@agfpd/iapeer 0.4.108 → 0.4.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ### Fixed
14
+
15
+ - **IAP attachments now transfer ownership instead of forwarding fragile source paths.**
16
+ Every `send_to_peer`, `iapeer send`, and Fleet API send copies regular-file sources
17
+ into the target peer's durable, content-addressed foundation inbox before an
18
+ envelope can be accepted. The recipient can read or forward the copy after the
19
+ sender deletes `/tmp` or otherwise removes the source. Copies live for the
20
+ recipient peer's lifetime and `iapeer remove` purges its inbox.
21
+
13
22
  ## [0.4.25] - 2026-06-22
14
23
 
15
24
  ### Fixed
@@ -26,6 +26,7 @@ The same plugin can store data at both levels: shared in `~/.iapeer/`, peer-boun
26
26
  │ └── .iapeer/ (peer-level layout — see below)
27
27
  ├── state/
28
28
  │ ├── iapeer/ daemon state (router.sock, router.json …)
29
+ │ │ └── attachments/ recipient-owned IAP attachment copies
29
30
  │ └── <plugin>/ a plugin's mutable state
30
31
  ├── logs/
31
32
  │ ├── iapeer/ daemon logs: lifecycle.log, delivery.log, exits.log
@@ -53,7 +54,7 @@ The same plugin can store data at both levels: shared in `~/.iapeer/`, peer-boun
53
54
  A few nodes worth highlighting:
54
55
 
55
56
  - **`peers-profiles.json`** — the registry. Written only through a single writer under the file lock `peers-profiles.lock`; direct editing is forbidden at the core level.
56
- - **`state/iapeer/`** — where the daemon lives: its local socket `router.sock` and config `router.json`.
57
+ - **`state/iapeer/`** — where the daemon lives: its local socket `router.sock`, config `router.json`, and `attachments/<recipient>/<sha256>/<basename>` inboxes. Attachment inboxes are durable recipient state (not cache); peer removal cleans that recipient's inbox.
57
58
  - **`logs/<name>/`** — an infrastructure peer's logs are placed by peer name at the shared level (not in the peer's folder), because it's the output of a launchd service.
58
59
  - **`runtimes/<runtime>/`** — dual ownership: the root belongs to the runtime itself (Telegram keeps bot tokens and poll state here), while the `plugins/<plugin>/` subfolder is the plugins' space, holding their config under this runtime.
59
60
  - **`docs/<package>/`** — on-host reference docs, one folder per ecosystem package (see below).
@@ -23,6 +23,18 @@ iapeer send assistant --message "..." --topic "release" --from claude-reviewer
23
23
 
24
24
  The `--from` flag sets the sender (`<runtime>-<name>`); by default it's the identity of the current folder's peer. `--attachment` is repeatable. `--message-file`, with the body from a file or stdin, is handy for long, multi-line messages.
25
25
 
26
+ ## Attachment ownership
27
+
28
+ An attachment argument is a **source path**, not the delivered path. `routeSend` opens every source as a regular file and copies its bytes into the target peer's foundation-owned inbox before it builds the envelope:
29
+
30
+ ```text
31
+ ~/.iapeer/state/iapeer/attachments/<recipient>/<sha256>/<basename>
32
+ ```
33
+
34
+ The envelope contains these absolute inbox paths. Therefore an `ok`/`delivered`/`queued` result also means that every attachment already has a readable recipient copy; the sender may immediately delete or rename its source. A missing, unreadable, or non-regular source fails the send before delivery. Re-sending an inbox file to a third peer makes another copy in that third peer's inbox.
35
+
36
+ The copy is recipient-owned state, not cache: it is not age-GCed and lives for the recipient peer's lifetime. `iapeer remove <recipient>` removes the complete inbox. Removing the sender, cleaning `/tmp`, restarting the daemon, draining an ephemeral queue, and bridge history rotation do not remove it.
37
+
26
38
  ## The delivery path
27
39
 
28
40
  The daemon finds the recipient in the registry and selects its endpoint first. With no explicit `runtime`, the endpoint is always the recipient's `default_runtime`; liveness of other declared runtimes does not change that choice. An explicit `runtime` selects that exact surface. The daemon then looks at the selected endpoint's state:
@@ -61,7 +61,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
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
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. |
63
63
 
64
- `--message-file -` reads the body from stdin. `--attachment` is repeatable. `--from` defaults to the current folder's peer identity.
64
+ `--message-file -` reads the body from stdin. `--attachment` is repeatable and names an absolute source file; the router copies it into recipient-owned state before accepting the send. `--from` defaults to the current folder's peer identity.
65
65
 
66
66
  ## Launch and runtimes
67
67
 
@@ -13,6 +13,7 @@ The system's behavior rests on these:
13
13
  - **A single registry writer.** `peers-profiles.json` is written only through one writer under a file lock. Direct writing of the file is forbidden at the core level — this rules out races.
14
14
  - **Liveness is defined by a peer's live session.** A peer is alive if and only if it has a live session under the supervisor. This isn't a separate state flag that can desync, but an observable fact.
15
15
  - **`delivered` ⟺ landed in a live session.** Delivery success means the message was accepted by a live session (the daemon waited for confirmation). If it didn't confirm — the daemon treats the session as dead rather than lying about success.
16
+ - **Attachments are copied before routing.** Caller paths are only sources. Before an envelope can be accepted as delivered or queued, `routeSend` copies every attachment into the target peer's foundation-owned inbox and puts those copy paths into the envelope. A later delete, rename, or cleanup of the caller's source cannot break the received attachment. The copies live for the lifetime of the recipient peer and `iapeer remove <peer>` removes that inbox.
16
17
  - **The source of truth is the local profile.** The registry is a reprojectable projection of the peer profiles. On a divergence, the profile wins.
17
18
  - **The binary is installed on the host.** The daemon runs from the installed `~/.local/bin/iapeer`, not from a source working tree. An update is replacing the binary and restarting the daemon onto it.
18
19
 
@@ -203,10 +203,10 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
203
203
  `POST /fleet/v1/send` — deliver a message through the same routing path as the `send_to_peer` tool (wake-on-miss, queueing, delivery log — identical semantics):
204
204
 
205
205
  ```json
206
- { "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
206
+ { "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
207
207
  ```
208
208
 
209
- `from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`.
209
+ `from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`. `attachments`, when present, is the same array of absolute source paths as the MCP tool; the shared `routeSend` path copies them into the recipient inbox before acceptance.
210
210
 
211
211
  Not part of the *command* API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them). The **human-approval broker** now lives under `/fleet/v1/approvals*` (`GET` the queue / one item, `POST /<id>/(approve|deny)`) with the `approval-request` / `approval-resolved` SSE `ev` kinds and the `approvals` snapshot field above — the full contract is **docs/17-approval**. The **notice board** lives under `/fleet/v1/notices*` (`GET` the board / one item — **read-only**: a notice is one-way and has nothing to resolve) with the `notice-raised` SSE `ev` kind and the `notices` snapshot field above — the full contract is **docs/19-notices**.
212
212
 
@@ -30,7 +30,7 @@ verbatim as og-1a2b3c4d (expires in 15 min).
30
30
  Do not re-send the message text — confirm-send delivers the held message.
31
31
  ```
32
32
 
33
- Both exits cost one shell command and **zero message regeneration**: the held send carries the full original input (message, topic, attachments).
33
+ Both exits cost one shell command and **zero message regeneration**: the held send carries the original message/topic/runtime and recipient-owned copies of every attachment. Copying happens before the hold is persisted, so deleting a caller's temporary sources during the 15-minute decision window cannot break `confirm-send`.
34
34
 
35
35
  ```bash
36
36
  iapeer confirm-send og-1a2b3c4d # deliver as addressed (intentional cross-channel)
@@ -48,7 +48,7 @@ State is on disk (it must survive daemon restarts and short-lived CLI processes)
48
48
  ```text
49
49
  ~/.iapeer/state/iapeer/origin/
50
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
51
+ pending/<id>.json # held sends; attachment fields already name recipient-inbox copies
52
52
  ```
53
53
 
54
54
  ## Scope and non-interference
@@ -26,6 +26,7 @@
26
26
  │ └── .iapeer/ (раскладка уровня пира — см. ниже)
27
27
  ├── state/
28
28
  │ ├── iapeer/ состояние демона (router.sock, router.json …)
29
+ │ │ └── attachments/ копии IAP-вложений, принадлежащие получателям
29
30
  │ └── <плагин>/ изменяемое состояние плагина
30
31
  ├── logs/
31
32
  │ ├── iapeer/ журналы демона: lifecycle.log, delivery.log, exits.log
@@ -53,7 +54,7 @@
53
54
  Несколько узлов стоит выделить:
54
55
 
55
56
  - **`peers-profiles.json`** — реестр. Пишется только через единственного писателя под файловой блокировкой `peers-profiles.lock`; прямое редактирование запрещено на уровне ядра.
56
- - **`state/iapeer/`** — здесь живёт демон: его локальный сокет `router.sock` и конфигурация `router.json`.
57
+ - **`state/iapeer/`** — здесь живёт демон: его локальный сокет `router.sock`, конфигурация `router.json` и inbox-каталоги `attachments/<получатель>/<sha256>/<имя-файла>`. Inbox — долгоживущее состояние получателя, а не кэш; удаление пира очищает его inbox.
57
58
  - **`logs/<имя>/`** — журналы инфраструктурных пиров кладутся по имени пира на общем уровне (а не в папке пира), потому что это вывод службы под launchd.
58
59
  - **`runtimes/<рантайм>/`** — двойное владение: корень принадлежит самому рантайму (Telegram держит здесь токены ботов, состояние опроса), а подпапка `plugins/<плагин>/` — это пространство плагинов, где лежит их конфиг под этим рантаймом.
59
60
  - **`docs/<пакет>/`** — справочные доки на хосте, по одной папке на каждый пакет экосистемы (см. ниже).
@@ -23,6 +23,18 @@ iapeer send assistant --message "..." --topic "релиз" --from claude-reviewe
23
23
 
24
24
  Флаг `--from` задаёт отправителя (`<рантайм>-<имя>`); по умолчанию — идентичность пира текущей папки. `--attachment` повторяемый. `--message-file` с телом из файла или stdin удобен для длинных и многострочных сообщений.
25
25
 
26
+ ## Владение вложениями
27
+
28
+ Аргумент вложения — **путь источника**, а не доставленный путь. До сборки конверта `routeSend` открывает каждый источник как обычный файл и копирует его байты в foundation-owned inbox целевого пира:
29
+
30
+ ```text
31
+ ~/.iapeer/state/iapeer/attachments/<получатель>/<sha256>/<имя-файла>
32
+ ```
33
+
34
+ В конверт попадают абсолютные пути этих копий. Поэтому исход `ok`/`delivered`/`queued` одновременно значит, что каждое вложение уже имеет читаемую копию получателя; отправитель может сразу удалить или переименовать исходник. Отсутствующий, нечитаемый или необычный файл валит отправку до доставки. Пересылка inbox-файла третьему пиру создаёт отдельную копию в inbox третьего пира.
35
+
36
+ Копия — состояние получателя, а не кэш: по возрасту не удаляется и живёт столько же, сколько пир-получатель. `iapeer remove <получатель>` удаляет весь inbox. Удаление отправителя, очистка `/tmp`, рестарт демона, дренаж ephemeral-очереди и ротация истории моста её не удаляют.
37
+
26
38
  ## Путь доставки
27
39
 
28
40
  Демон находит получателя в реестре и сначала выбирает поверхность доставки. Без явного `runtime` это всегда `default_runtime` адресата; живость других объявленных рантаймов выбор не меняет. Явный `runtime` выбирает ровно указанную поверхность. Затем демон смотрит на состояние выбранной поверхности:
@@ -61,7 +61,7 @@
61
61
  | `send <цель> (--message <текст> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <путь>]… [--topic <t>]` | Ручная отправка IAP по тому же пути, что `send_to_peer`. |
62
62
  | `confirm-send <id> [--runtime <rt>]` | Доставить удержанную отправку ([origin guard](18-origin-guard.md)): как адресована, либо `--runtime` перенаправляет её в канал-источник. Без повторной отправки текста. |
63
63
 
64
- `--message-file -` читает тело из stdin. `--attachment` повторяемый. `--from` по умолчанию — идентичность пира текущей папки.
64
+ `--message-file -` читает тело из stdin. `--attachment` повторяемый и задаёт абсолютный путь файла-источника; до принятия отправки роутер копирует его в состояние получателя. `--from` по умолчанию — идентичность пира текущей папки.
65
65
 
66
66
  ## Запуск и рантаймы
67
67
 
@@ -13,6 +13,7 @@
13
13
  - **Единственный писатель реестра.** `peers-profiles.json` пишется только через одного писателя под файловой блокировкой. Прямая запись файла запрещена на уровне ядра — это исключает гонки.
14
14
  - **Живость определяется live-сессией пира.** Пир жив тогда и только тогда, когда у него есть живая сессия под супервизором. Это не отдельный флаг в состоянии, который можно рассинхронизировать, а наблюдаемый факт.
15
15
  - **`доставлено` ⟺ легло в живую сессию.** Успех доставки означает, что сообщение принято живой сессией (демон дождался подтверждения). Не подтвердила — демон считает сессию мёртвой, а не лжёт об успехе.
16
+ - **Вложения копируются до маршрутизации.** Пути вызывающего — только источники. Прежде чем конверт может быть принят как delivered или queued, `routeSend` копирует каждое вложение в принадлежащий foundation inbox целевого пира и вкладывает в конверт уже пути копий. Последующие удаление, переименование или очистка исходника не ломают полученное вложение. Копии живут столько же, сколько пир-получатель; `iapeer remove <пир>` удаляет его inbox.
16
17
  - **Источник истины — локальный профиль.** Реестр — пересобираемая проекция профилей пиров. При расхождении прав профиль.
17
18
  - **Бинарь установлен на хост.** Демон работает с установленного `~/.local/bin/iapeer`, а не из рабочего дерева исходников. Обновление — это замена бинаря и перезапуск демона на него.
18
19
 
@@ -133,10 +133,10 @@ GET /fleet/v1/events?replay=50
133
133
  `POST /fleet/v1/send` — доставить сообщение тем же маршрутом, что инструмент `send_to_peer` (wake-on-miss, очереди, delivery-лог — семантика идентична):
134
134
 
135
135
  ```json
136
- { "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
136
+ { "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
137
137
  ```
138
138
 
139
- `from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`.
139
+ `from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`. `attachments`, если передан, — тот же массив абсолютных путей источников, что и у MCP-инструмента; общий путь `routeSend` копирует их в inbox получателя до принятия.
140
140
 
141
141
  Не входит в *командный* API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью). **Брокер human-approval** теперь живёт под `/fleet/v1/approvals*` (`GET` очередь / один item, `POST /<id>/(approve|deny)`) с видами SSE `approval-request` / `approval-resolved` и полем `approvals` в снапшоте выше — полный контракт в **docs/17-approval**.
142
142
 
@@ -30,7 +30,7 @@ verbatim as og-1a2b3c4d (expires in 15 min).
30
30
  Do not re-send the message text — confirm-send delivers the held message.
31
31
  ```
32
32
 
33
- Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт полный исходный ввод (текст, топик, вложения).
33
+ Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт исходные текст/топик/рантайм и принадлежащие получателю копии каждого вложения. Копирование идёт до сохранения hold, поэтому удаление временных исходников в 15-минутном окне решения не ломает `confirm-send`.
34
34
 
35
35
  ```bash
36
36
  iapeer confirm-send og-1a2b3c4d # доставить как адресовано (кросс-канал намеренный)
@@ -48,7 +48,7 @@ iapeer confirm-send og-1a2b3c4d --runtime web # доставить то же
48
48
  ```text
49
49
  ~/.iapeer/state/iapeer/origin/
50
50
  state.json # (агент → человек → {rt, inboundTs, answeredTs}) — last-wins, атомарные записи
51
- pending/<id>.json # удержанные отправки, дословно; ленивый GC после TTL
51
+ pending/<id>.json # удержанные отправки; вложения уже указывают на копии inbox
52
52
  ```
53
53
 
54
54
  ## Границы и невмешательство
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.108",
3
+ "version": "0.4.110",
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
@@ -26,7 +26,15 @@ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaem
26
26
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
27
27
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
28
28
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
29
- import { compactDoneBaseline, isPeerLive, routeControl, routeSend, waitForCompactDone, type WakeFn } from '../transport/index.ts'
29
+ import {
30
+ compactDoneBaseline,
31
+ isPeerLive,
32
+ purgeAttachmentInbox,
33
+ routeControl,
34
+ routeSend,
35
+ waitForCompactDone,
36
+ type WakeFn,
37
+ } from '../transport/index.ts'
30
38
  import {
31
39
  attachPeer,
32
40
  clearIdleReaped,
@@ -888,6 +896,9 @@ export interface RemoveOutcome {
888
896
  * personality inherits the dead namesake's parking (defect: stale .stopped →
889
897
  * `mode=refused cause=stopped` on a freshly-created peer). */
890
898
  purgedState?: string[]
899
+ /** Recipient-owned IAP attachment inbox cleanup. Completed attachment copies
900
+ * are state for the peer lifetime, so explicit peer removal owns their purge. */
901
+ attachmentInbox?: string
891
902
  }
892
903
 
893
904
  /**
@@ -1039,11 +1050,19 @@ export async function removePeerCli(
1039
1050
  // remove never half-purges a still-registered peer.
1040
1051
  const cfg = loadLifecycleConfig(env)
1041
1052
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
1053
+ let attachmentInbox: string | undefined
1054
+ try {
1055
+ const purged = purgeAttachmentInbox(personality, env)
1056
+ if (purged.removed) attachmentInbox = `removed ${purged.path}`
1057
+ } catch (e) {
1058
+ attachmentInbox = `FAILED: ${e instanceof Error ? e.message : String(e)}`
1059
+ }
1042
1060
  return {
1043
1061
  personality,
1044
1062
  action: 'removed',
1045
1063
  cwd: peer.cwd,
1046
1064
  purgedState,
1065
+ ...(attachmentInbox ? { attachmentInbox } : {}),
1047
1066
  ...(plistTeardown ? { plistTeardown } : {}),
1048
1067
  ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
1049
1068
  ...(trustCleaned ? { codexTrust: trustCleaned } : {}),
@@ -2280,6 +2299,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2280
2299
  if (o.purgedState?.length) {
2281
2300
  out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
2282
2301
  }
2302
+ if (o.attachmentInbox) {
2303
+ out(`attachment inbox: ${o.attachmentInbox}\n`)
2304
+ }
2283
2305
  // Deliberate: the registry reap never deletes user data — but SAY so, or
2284
2306
  // the default-location peers leave silent orphan folders.
2285
2307
  if (o.cwd && existsSync(o.cwd)) {
@@ -658,7 +658,13 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
658
658
  return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
659
659
  }
660
660
  if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
661
- return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
661
+ return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/attachments/from)' })
662
+ }
663
+ if (
664
+ body.attachments !== undefined &&
665
+ (!Array.isArray(body.attachments) || !body.attachments.every(item => typeof item === 'string'))
666
+ ) {
667
+ return sendJson(res, 400, { error: 'send attachments must be an array of absolute local source-file paths' })
662
668
  }
663
669
  let caller: ResolvedCaller
664
670
  try {
@@ -676,6 +682,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
676
682
  runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
677
683
  message: body.message,
678
684
  topic: typeof body.topic === 'string' ? body.topic : undefined,
685
+ attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
679
686
  },
680
687
  ctx.deps,
681
688
  )
@@ -163,7 +163,7 @@ export function listTools(): unknown[] {
163
163
  topic: { type: 'string', description: 'Optional short topic for threading related peer messages.', maxLength: MAX_TOPIC_LEN },
164
164
  attachments: {
165
165
  type: 'array',
166
- description: 'Optional absolute local file paths. IAP delivers them as a separate <attachments> field, not as part of message text.',
166
+ description: 'Optional absolute local source-file paths. Before accepting the send, IAP copies each regular file into durable recipient-owned state and puts the copy paths in the separate <attachments> field; the sources may be deleted immediately after success.',
167
167
  maxItems: MAX_ATTACHMENTS,
168
168
  items: { type: 'string' },
169
169
  },
@@ -3,7 +3,7 @@
3
3
  // port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
4
4
  // via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
5
5
  import { writeSync } from 'node:fs'
6
- import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
6
+ import { FRAME_DATA, FRAME_RESIZE, TERM_ATTACH_RESET, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
7
7
  import { ownershipVerdict, pidStartToken, readPidFile, sockPath } from './paths.ts'
8
8
 
9
9
  const DETACH = 0x1d // Ctrl-]
@@ -81,6 +81,17 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
81
81
  // Ready the terminal BEFORE connecting, so the repaint snapshot renders the instant it arrives.
82
82
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
83
83
  process.stdin.resume()
84
+ // The daemon owns the emulated session/model; THIS client owns the physical operator viewport.
85
+ // Normalize that viewport before connecting so its one-time snapshot cannot be appended below a
86
+ // shell prompt or a prior attach's differently-wrapped frame. HOME+ED0 is intentional: unlike
87
+ // ED2/ED3 it does not trigger Apple Terminal's clear-to-scrollback trap (protocol.ts).
88
+ if (process.stdout.isTTY) {
89
+ try {
90
+ writeSync(1, TERM_ATTACH_RESET)
91
+ } catch {
92
+ /* a failed cosmetic reset must not block attach */
93
+ }
94
+ }
84
95
 
85
96
  const curSize = (): Buffer => sizePayload(process.stdout.columns || 80, process.stdout.rows || 24)
86
97
  const sock = await Bun.connect({
@@ -142,9 +153,9 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
142
153
  })
143
154
  }
144
155
  process.on('exit', () => restore()) // last-resort sync net
145
- // NO pre-repaint attach banner: any stdout (even stderr — same tty device) printed before the daemon's
146
- // snapshot lands pollutes the fresh terminal's scrollback, defeating native-history reconstruction. The
147
- // detach key (Ctrl-]) is documented in CLI help / the attach verb, not emitted as a scrollback line.
156
+ // NO pre-repaint attach banner: after TERM_ATTACH_RESET, the daemon snapshot must be the first CONTENT
157
+ // painted into the normalized viewport. The detach key (Ctrl-]) is documented in CLI help / the attach
158
+ // verb, not emitted as a scrollback line.
148
159
  // STAY ATTACHED. The client lives on its handles (raw stdin + the socket); EVERY terminal path
149
160
  // (detach Ctrl-], session-end close(), SIGHUP/SIGTERM, socket error) calls process.exit ITSELF.
150
161
  // This promise INTENTIONALLY never resolves: returning here would resolve runSupervisorClient the
@@ -399,8 +399,9 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
399
399
  * Scrollback rows are spinner-stripped; the viewport stays verbatim. The child's SIGWINCH redraw (from
400
400
  * applyGeometry) then settles the live viewport at the client's width. Backpressure-safe
401
401
  * (BackpressureWriter) — a large snapshot MUST NOT drop past the socket buffer (port-dep #2). Called
402
- * EXACTLY ONCE per client (its first FRAME_RESIZE). NO clears (\x1b[2J/3J): a fresh window needs none,
403
- * and Apple Terminal scrolls 2J into scrollback + ignores 3J. */
402
+ * EXACTLY ONCE per client (its first FRAME_RESIZE). The daemon/model sends NO clears
403
+ * (\x1b[2J/3J): Apple Terminal scrolls 2J into scrollback + ignores 3J. The attach client owns its
404
+ * physical viewport and normalizes it once with HOME+ED0 before connecting. */
404
405
  // Await until the model has parsed ALL writes AND none arrived during the wait (stable). Awaiting a single
405
406
  // write promise is not enough: while the client is gated (initializing, live-forward skipped) new child
406
407
  // bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot
@@ -164,6 +164,18 @@ export function keysToBytes(keys: string[], enc: KeyEncoding = {}): Buffer {
164
164
  return Buffer.from(out, 'utf8')
165
165
  }
166
166
 
167
+ // ── client terminal normalization ────────────────────────────────────────────────
168
+ // A (re)attach can start in an already-used Terminal.app normal buffer: the shell prompt and a prior
169
+ // attach's differently-wrapped frame still occupy the physical viewport. The daemon's serialized model
170
+ // deliberately carries NO clear (it is shared protocol data, while the stale viewport belongs to each
171
+ // client), so the client must home + erase the CURRENT display before the one-time snapshot arrives.
172
+ //
173
+ // Use ED0 from HOME, NOT ED2/ED3. Apple Terminal implements neither of the latter as the primitive we
174
+ // need: ED2 pushes the old display into native scrollback and ED3 is ignored, so repeated attaches stack
175
+ // broken-width dumps in history. HOME+ED0 clears the viewport in place; the daemon still sends exactly
176
+ // one model snapshot and later SIGWINCH resizes remain raw/live (no repeated dump).
177
+ export const TERM_ATTACH_RESET = '\x1b[H\x1b[J'
178
+
167
179
  // ── port-dep #1: terminal reset on every client exit path ───────────────────────
168
180
  // The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
169
181
  // focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
@@ -0,0 +1,163 @@
1
+ // IAP attachment ownership — copy-on-send into recipient-owned foundation state.
2
+ //
3
+ // A caller-supplied attachment path is a SOURCE, never the delivered object. Before
4
+ // routeSend builds an envelope, each regular file is copied byte-for-byte into:
5
+ //
6
+ // <IAPEER_ROOT>/state/iapeer/attachments/<recipient>/<sha256>/<basename>
7
+ //
8
+ // and only that durable path enters the envelope. The content hash makes repeat
9
+ // sends idempotent without coupling the copy to an ephemeral message/queue entry;
10
+ // a recipient can forward its copy and the next route creates a separate copy in
11
+ // the next recipient's inbox. Completed copies are STATE (not cache): no age GC.
12
+ // The recipient's explicit peer removal purges its complete inbox.
13
+
14
+ import {
15
+ chmodSync,
16
+ closeSync,
17
+ existsSync,
18
+ fstatSync,
19
+ fsyncSync,
20
+ mkdirSync,
21
+ openSync,
22
+ readSync,
23
+ renameSync,
24
+ rmSync,
25
+ writeSync,
26
+ } from 'node:fs'
27
+ import { createHash, randomUUID } from 'node:crypto'
28
+ import { basename, join } from 'node:path'
29
+ import { STATE_DIR, isValidName } from '../core/constants.ts'
30
+ import { IapError, err, ok, type Result } from '../core/errors.ts'
31
+ import { resolveGlobalRoot } from '../storage/index.ts'
32
+
33
+ const DIR_MODE = 0o700
34
+ const FILE_MODE = 0o600
35
+ const COPY_BUFFER_BYTES = 1024 * 1024
36
+
37
+ export const ATTACHMENT_SPOOL_DIR = 'attachments'
38
+
39
+ export interface SpooledAttachments {
40
+ paths: string[]
41
+ sha256: string[]
42
+ }
43
+
44
+ export interface AttachmentPurgeResult {
45
+ path: string
46
+ removed: boolean
47
+ }
48
+
49
+ export function attachmentSpoolRoot(env: NodeJS.ProcessEnv = process.env): string {
50
+ return join(resolveGlobalRoot(env), STATE_DIR, 'iapeer', ATTACHMENT_SPOOL_DIR)
51
+ }
52
+
53
+ export function attachmentInboxDir(
54
+ recipient: string,
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ ): string {
57
+ if (!isValidName(recipient)) {
58
+ throw new IapError(`invalid attachment recipient "${recipient}"`)
59
+ }
60
+ return join(attachmentSpoolRoot(env), recipient)
61
+ }
62
+
63
+ /**
64
+ * Copy one source through an fsynced staging file while calculating its SHA-256.
65
+ * The staging file lives in the recipient inbox (same filesystem as the final
66
+ * path), so the final rename is atomic. A partial copy is never exposed in an IAP
67
+ * envelope and is cleaned on every failure visible to this process.
68
+ */
69
+ function copyOne(
70
+ source: string,
71
+ recipient: string,
72
+ env: NodeJS.ProcessEnv,
73
+ ): { path: string; sha256: string } {
74
+ const inbox = attachmentInboxDir(recipient, env)
75
+ mkdirSync(inbox, { recursive: true, mode: DIR_MODE })
76
+ const tmp = join(inbox, `.incoming-${process.pid}-${randomUUID()}`)
77
+ let sourceFd: number | undefined
78
+ let targetFd: number | undefined
79
+ try {
80
+ sourceFd = openSync(source, 'r')
81
+ const sourceStat = fstatSync(sourceFd)
82
+ if (!sourceStat.isFile()) throw new Error('source is not a regular file')
83
+
84
+ targetFd = openSync(tmp, 'wx', FILE_MODE)
85
+ const hash = createHash('sha256')
86
+ const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES)
87
+ while (true) {
88
+ const read = readSync(sourceFd, buffer, 0, buffer.length, null)
89
+ if (read === 0) break
90
+ hash.update(buffer.subarray(0, read))
91
+ let written = 0
92
+ while (written < read) {
93
+ const n = writeSync(targetFd, buffer, written, read - written)
94
+ if (n <= 0) {
95
+ throw new Error(`write made no progress (${written}/${read} bytes in current chunk)`)
96
+ }
97
+ written += n
98
+ }
99
+ }
100
+ fsyncSync(targetFd)
101
+ closeSync(targetFd)
102
+ targetFd = undefined
103
+ closeSync(sourceFd)
104
+ sourceFd = undefined
105
+
106
+ const sha256 = hash.digest('hex')
107
+ const hashDir = join(inbox, sha256)
108
+ mkdirSync(hashDir, { recursive: true, mode: DIR_MODE })
109
+ const path = join(hashDir, basename(source))
110
+ // Atomic publish. Replacing an existing same-hash+basename object is safe and
111
+ // repairs a manually damaged copy; concurrent publishers carry identical bytes.
112
+ renameSync(tmp, path)
113
+ chmodSync(path, FILE_MODE)
114
+ return { path, sha256 }
115
+ } finally {
116
+ if (targetFd !== undefined) {
117
+ try { closeSync(targetFd) } catch { /* already closed */ }
118
+ }
119
+ if (sourceFd !== undefined) {
120
+ try { closeSync(sourceFd) } catch { /* already closed */ }
121
+ }
122
+ try { rmSync(tmp, { force: true }) } catch { /* renamed or best-effort cleanup */ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Materialize every attachment in the target peer's inbox. All validation and
128
+ * copying completes before the caller receives paths for envelope construction;
129
+ * any failure aborts the send loudly. Copies already completed before a later
130
+ * item fails stay as harmless recipient state and are content-address deduplicated
131
+ * on retry (removing them here could delete a copy referenced by an earlier send).
132
+ */
133
+ export function spoolAttachments(
134
+ sources: readonly string[],
135
+ recipient: string,
136
+ env: NodeJS.ProcessEnv = process.env,
137
+ ): Result<SpooledAttachments> {
138
+ const paths: string[] = []
139
+ const sha256: string[] = []
140
+ for (let i = 0; i < sources.length; i++) {
141
+ const source = sources[i]!
142
+ try {
143
+ const copied = copyOne(source, recipient, env)
144
+ paths.push(copied.path)
145
+ sha256.push(copied.sha256)
146
+ } catch (e) {
147
+ const reason = e instanceof Error ? e.message : String(e)
148
+ return err(`attachment ${i + 1} "${source}" could not be copied for "${recipient}": ${reason}; message NOT delivered`)
149
+ }
150
+ }
151
+ return ok({ paths, sha256 })
152
+ }
153
+
154
+ /** Recipient-lifetime cleanup: called by `iapeer remove <peer>`. */
155
+ export function purgeAttachmentInbox(
156
+ recipient: string,
157
+ env: NodeJS.ProcessEnv = process.env,
158
+ ): AttachmentPurgeResult {
159
+ const path = attachmentInboxDir(recipient, env)
160
+ const removed = existsSync(path)
161
+ rmSync(path, { recursive: true, force: true })
162
+ return { path, removed }
163
+ }
@@ -71,6 +71,17 @@ import {
71
71
  } from './originGuard.ts'
72
72
  import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
73
73
  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
74
+ import { spoolAttachments } from './attachments.ts'
75
+
76
+ export {
77
+ ATTACHMENT_SPOOL_DIR,
78
+ attachmentInboxDir,
79
+ attachmentSpoolRoot,
80
+ purgeAttachmentInbox,
81
+ spoolAttachments,
82
+ type AttachmentPurgeResult,
83
+ type SpooledAttachments,
84
+ } from './attachments.ts'
74
85
 
75
86
  export interface OnlinePeer {
76
87
  personality: string
@@ -1502,8 +1513,11 @@ async function routeSendInner(
1502
1513
  // Origin-guard (docs/18) — an agent's PRESUMED REPLY to a human (the human's latest
1503
1514
  // inbound to this agent is unanswered) must target the channel the human wrote from.
1504
1515
  // Checked FIRST, before any resolve/wake side-effect and before the telegram policy:
1505
- // a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
1506
- // agent then confirms cross-channel or redirects to the origin, zero regeneration.
1516
+ // a mismatch is HELD, not delivered — the agent then confirms cross-channel or
1517
+ // redirects to the origin, zero regeneration. Attachment PATHS are the deliberate
1518
+ // exception to "verbatim": sources are copied into the intended recipient's durable
1519
+ // inbox before the hold is persisted, so deleting a /tmp source cannot break a later
1520
+ // confirm-send. Message/topic/runtime remain verbatim.
1507
1521
  // Intended runtime = the explicit override or the human's default; exact for the
1508
1522
  // natural-peer class (implicit routing is strictly default_runtime-anchored).
1509
1523
  // Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
@@ -1516,9 +1530,11 @@ async function routeSendInner(
1516
1530
  const armed = armedOrigin(caller.personality, peer.personality, { env })
1517
1531
  const intendedRt = runtime ?? peer.runtime
1518
1532
  if (armed && armed.rt !== intendedRt) {
1533
+ const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
1534
+ if (!spooled.ok) return spooled
1519
1535
  const held = holdSend(
1520
1536
  caller.address,
1521
- { personality, runtime, message, topic, attachments: attachmentsResult.value },
1537
+ { personality, runtime, message, topic, attachments: spooled.value.paths },
1522
1538
  armed,
1523
1539
  intendedRt,
1524
1540
  { env },
@@ -1533,6 +1549,14 @@ async function routeSendInner(
1533
1549
  const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
1534
1550
  if (!intendedGuard.ok) return intendedGuard
1535
1551
 
1552
+ // Attachment ownership boundary: caller paths are SOURCES only. Materialize
1553
+ // recipient-owned, content-addressed copies BEFORE building any live/wake/queue
1554
+ // envelope. Every path below (including ephemeral/composer queues and bridge
1555
+ // runtimes) therefore has the same durability semantics. Copy failure is a
1556
+ // synchronous NOT-delivered error; no route side effect has happened yet.
1557
+ const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
1558
+ if (!spooled.ok) return spooled
1559
+
1536
1560
  // Stamped ONCE at router acceptance: the same instant travels as the envelope's
1537
1561
  // `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
1538
1562
  // side) — the two correspond literally, which is what makes async-desync
@@ -1547,7 +1571,7 @@ async function routeSendInner(
1547
1571
  fromIntelligence: caller.intelligence,
1548
1572
  sentAt,
1549
1573
  topic,
1550
- attachments: attachmentsResult.value,
1574
+ attachments: spooled.value.paths,
1551
1575
  message,
1552
1576
  })
1553
1577