@agfpd/iapeer 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  **Host-level core for a team of AI agents on one machine.**
4
4
 
5
+ [![CI](https://github.com/agfpd/iapeer/actions/workflows/ci.yml/badge.svg)](https://github.com/agfpd/iapeer/actions/workflows/ci.yml)
5
6
  [![npm](https://img.shields.io/npm/v/@agfpd/iapeer)](https://www.npmjs.com/package/@agfpd/iapeer)
6
- [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
7
+ [![license](https://img.shields.io/npm/l/@agfpd/iapeer)](./LICENSE)
7
8
  [![platform](https://img.shields.io/badge/platform-macOS-lightgrey)](#quick-start)
8
9
 
9
10
  iapeer keeps a team of AI agents, humans, and services on one host **always reachable — every one of them a single message away.** Each participant is a **peer**: a Claude Code or Codex CLI agent, a person on Telegram, or a scheduler/watcher service. Any agent reaches any other with one call — `send_to_peer(<name>, <text>)` — and a peer that is asleep is still reachable: the daemon wakes it and delivers. Agents on Claude and on Codex — different AI runtimes from different vendors — talk as equal peers; humans take part over Telegram, or directly in the terminal — attached to a peer's live session with `iapeer attach`.
@@ -95,7 +95,7 @@ Identity has two storage places, and it matters which is authoritative.
95
95
  "intelligence": "artificial",
96
96
  "initial_prompt": "New session. Greet the owner.",
97
97
  "interfaces": {
98
- "telegram": { "bot": "assistant", "bot_username": "assistant_bot" }
98
+ "telegram": { "bot": "assistant" }
99
99
  }
100
100
  }
101
101
  ```
@@ -33,6 +33,9 @@ The same plugin can store data at both levels: shared in `~/.iapeer/`, peer-boun
33
33
  ├── cache/
34
34
  │ ├── iapeer/ core cache
35
35
  │ └── <plugin>/ plugin cache (safe to delete)
36
+ ├── docs/
37
+ │ ├── iapeer/ foundation reference docs (this host's installed version)
38
+ │ └── <package>/ each ecosystem package's docs (copied on its install)
36
39
  ├── plugins/
37
40
  │ ├── iap/ the core IAP plugin's install
38
41
  │ └── <plugin>/ a plugin's install (immutable)
@@ -53,6 +56,15 @@ A few nodes worth highlighting:
53
56
  - **`state/iapeer/`** — where the daemon lives: its local socket `router.sock` and config `router.json`.
54
57
  - **`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.
55
58
  - **`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
+ - **`docs/<package>/`** — on-host reference docs, one folder per ecosystem package (see below).
60
+
61
+ ## On-host ecosystem docs
62
+
63
+ `~/.iapeer/docs/<package>/` holds a copy of each ecosystem package's reference docs, so an agent can read the contract offline at a stable, versioned path. It exists because a compiled binary embeds no docs and the npm tarball's `docs/` is discarded after install — a reference into a transient install or cache directory would go dead on the first update.
64
+
65
+ The convention is **copy-on-install, per package**: every package copies its own public docs (excluding `internals/`) into its own `~/.iapeer/docs/<package>/` on each of its installs and updates, via an atomic swap, best-effort — a copy failure never fails the install. Because the copy happens in the same install that places the artifact, a package's on-host docs always match its installed version. Each package writes only its own subfolder, the same ownership isolation as everywhere else; the folder name is the short unscoped package name (`iapeer`, `iapeer-memory`, `telegram-runtime`, `notifier-runtime`).
66
+
67
+ A peer's system prompt points here — `~/.iapeer/docs/`, one folder per package, starting at `iapeer/README.md` — so a "how does iapeer do X" question is answered from the docs rather than guessed.
56
68
 
57
69
  ## The peer-level layout
58
70
 
@@ -95,7 +95,7 @@ PEER_IDENTITY = PEER_RUNTIME + "-" + PEER_PERSONALITY
95
95
  "intelligence": "artificial",
96
96
  "initial_prompt": "Новая сессия. Поздоровайся с владельцем.",
97
97
  "interfaces": {
98
- "telegram": { "bot": "assistant", "bot_username": "assistant_bot" }
98
+ "telegram": { "bot": "assistant" }
99
99
  }
100
100
  }
101
101
  ```
@@ -33,6 +33,9 @@
33
33
  ├── cache/
34
34
  │ ├── iapeer/ кеш ядра
35
35
  │ └── <плагин>/ кеш плагина (можно удалять)
36
+ ├── docs/
37
+ │ ├── iapeer/ справочные доки фундамента (версия, установленная на хосте)
38
+ │ └── <пакет>/ доки каждого пакета экосистемы (копируются при его установке)
36
39
  ├── plugins/
37
40
  │ ├── iap/ установка ядрового плагина IAP
38
41
  │ └── <плагин>/ установка плагина (неизменяемая)
@@ -53,6 +56,15 @@
53
56
  - **`state/iapeer/`** — здесь живёт демон: его локальный сокет `router.sock` и конфигурация `router.json`.
54
57
  - **`logs/<имя>/`** — журналы инфраструктурных пиров кладутся по имени пира на общем уровне (а не в папке пира), потому что это вывод службы под launchd.
55
58
  - **`runtimes/<рантайм>/`** — двойное владение: корень принадлежит самому рантайму (Telegram держит здесь токены ботов, состояние опроса), а подпапка `plugins/<плагин>/` — это пространство плагинов, где лежит их конфиг под этим рантаймом.
59
+ - **`docs/<пакет>/`** — справочные доки на хосте, по одной папке на каждый пакет экосистемы (см. ниже).
60
+
61
+ ## Доки экосистемы на хосте
62
+
63
+ `~/.iapeer/docs/<пакет>/` хранит копию справочных доков каждого пакета экосистемы, чтобы агент мог читать контракт офлайн по стабильному версионированному пути. Это нужно потому, что скомпилированный бинарь доков не несёт, а `docs/` из npm-тарбола выбрасывается после установки — ссылка во временный каталог установки или кеша умерла бы на первом же обновлении.
64
+
65
+ Конвенция — **копирование при установке, по пакету**: каждый пакет копирует свои публичные доки (исключая `internals/`) в свою папку `~/.iapeer/docs/<пакет>/` при каждой своей установке и обновлении, атомарной заменой, best-effort — сбой копирования никогда не валит установку. Поскольку копирование происходит в той же установке, что кладёт артефакт, доки пакета на хосте всегда соответствуют его установленной версии. Каждый пакет пишет только свою подпапку — та же изоляция владения, что и везде; имя папки — короткое unscoped-имя пакета (`iapeer`, `iapeer-memory`, `telegram-runtime`, `notifier-runtime`).
66
+
67
+ Системный промпт пира указывает сюда — `~/.iapeer/docs/`, по папке на пакет, начиная с `iapeer/README.md`, — чтобы вопрос «как iapeer делает X» отвечался из доков, а не угадывался.
56
68
 
57
69
  ## Раскладка уровня пира
58
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { spawnSync } from 'child_process'
14
14
  import { existsSync, readFileSync } from 'fs'
15
15
  import { fileURLToPath } from 'url'
16
+ import { dirname, join } from 'path'
16
17
  import {
17
18
  isInfraRuntime,
18
19
  isRuntime,
@@ -157,14 +158,23 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
157
158
 
158
159
  const GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
159
160
 
160
- /** Render the scriptable list table (non-tty default). */
161
+ /** Render the scriptable list table (non-tty default). Columns are PADDED to align
162
+ * (name · [default] · nature · liveness) — a ragged table reads as noise. The
163
+ * trailing liveness column is variable-width, so it needs no padding. */
161
164
  export function formatListTable(rows: PeerListing[]): string {
162
165
  if (rows.length === 0) return 'no peers registered\n'
163
- const lines = rows.map(r => {
164
- const status = r.runtimes.map(s => `${GLYPH[s.status]} ${s.runtime}`).join(' ')
165
- const la = r.last_active_runtime ? ` last-active:${r.last_active_runtime}` : ''
166
- return `${r.personality} [${r.default_runtime}] ${r.intelligence} ${status}${la}`
167
- })
166
+ const cells = rows.map(r => ({
167
+ name: r.personality,
168
+ rt: `[${r.default_runtime}]`,
169
+ intel: r.intelligence,
170
+ status:
171
+ r.runtimes.map(s => `${GLYPH[s.status]} ${s.runtime}`).join(' ') +
172
+ (r.last_active_runtime ? ` last-active:${r.last_active_runtime}` : ''),
173
+ }))
174
+ const wName = Math.max(...cells.map(c => c.name.length))
175
+ const wRt = Math.max(...cells.map(c => c.rt.length))
176
+ const wIntel = Math.max(...cells.map(c => c.intel.length))
177
+ const lines = cells.map(c => `${c.name.padEnd(wName)} ${c.rt.padEnd(wRt)} ${c.intel.padEnd(wIntel)} ${c.status}`)
168
178
  return lines.join('\n') + '\n'
169
179
  }
170
180
 
@@ -939,48 +949,146 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
939
949
  return { positionals, flags }
940
950
  }
941
951
 
942
- const USAGE = `usage: iapeer <verb> [args]
943
- install build binary + global scaffold + daemon plist (one bootstrap)
944
- update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
945
- rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
946
- version | --version | -v print the installed binary's version
947
- help | --help | -h print this usage (works appended to any verb; executes nothing)
948
- daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
949
- onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
950
- backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively
951
- status host snapshot: version, daemon health, memory slot (<provider> | none)
952
- install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
953
- update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
954
- init [cwd] [--runtime r] [--description d] onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)
955
- create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
956
- list [--json] registered peers + per-runtime liveness
957
- verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
958
- stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
959
- until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
960
- start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
961
- it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
962
- refresh <peer> [runtime] | --all LAZY soft-reload: agentic peer comes up FRESH (re-reads doctrine) on its NEXT natural wake — no
963
- kill, no burst-wake; non-agentic runtimes skipped. --all = whole fleet. (eager: \`new\`/self-fresh)
964
- remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
965
- send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
966
- <runtime> launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached
967
- connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
968
- enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
969
- new <peer> [runtime] UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)
970
- add-runtime <runtime> (--peer <p> | --all) add an agentic runtime to existing peer(s): runtimes-merge + scaffold + codex birth chain (pre-trust, native-memory, memory provision, MCP). default_runtime untouched
971
- default-runtime <runtime> (--peer <p> | --all) flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command
972
- attach <peer> [runtime] ensure-live + resume, then tmux attach
973
- interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
974
- compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
975
- self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
976
- self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
977
- native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
978
- trust-hooks <hooks.json> [--check] pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report
979
- shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
980
- shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
981
- shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
982
- supervisor up|start|attach|list|kill <sess> [runtime] DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)
983
- `
952
+ /** Verb catalog for `help` — structured so the renderer can wrap descriptions to
953
+ * the terminal width instead of overflowing a hand-aligned column (the old static
954
+ * USAGE ran long descriptions off the right edge → collided with the next row on a
955
+ * narrow terminal). `sig` = the invocation signature, `desc` = one prose line. */
956
+ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
957
+ { sig: 'install', desc: 'build binary + global scaffold + daemon plist (one bootstrap)' },
958
+ { sig: 'update [version] [--force]', desc: 'pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs' },
959
+ { sig: 'rollback', desc: 'revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs' },
960
+ {
961
+ sig: 'uninstall [--dry-run] [--yes] [--remove-codesign-identity]',
962
+ desc: 'remove THIS foundation install (binary, ~/.iapeer, daemon plist, PATH lines); refuses if a persistent-peer fleet is present',
963
+ },
964
+ { sig: 'version | --version | -v', desc: "print the installed binary's version" },
965
+ { sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
966
+ { sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
967
+ {
968
+ sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]',
969
+ desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
970
+ },
971
+ { sig: 'status', desc: 'host snapshot: version, daemon health, memory slot (<provider> | none)' },
972
+ { sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
973
+ { sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
974
+ { sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
975
+ { sig: 'create <personality> [--runtime r] [--path dir] [--bin abs]', desc: 'create a peer anywhere (default ~/.iapeer/peers/<p>) + provision' },
976
+ { sig: 'list [--json]', desc: 'registered peers + per-runtime liveness' },
977
+ {
978
+ sig: 'verify [--json] [--fix]',
979
+ desc: 'profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)',
980
+ },
981
+ {
982
+ sig: 'stop <peer> [runtime] | --all',
983
+ desc: 'warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it until `start`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer',
984
+ },
985
+ {
986
+ sig: 'start <peer> [runtime] | --all',
987
+ desc: "warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer",
988
+ },
989
+ {
990
+ sig: 'refresh <peer> [runtime] | --all',
991
+ desc: 'LAZY soft-reload: agentic peer comes up FRESH (re-reads doctrine) on its NEXT natural wake — no kill, no burst-wake; non-agentic runtimes skipped. --all = whole fleet. (eager: `new`/self-fresh)',
992
+ },
993
+ { sig: 'remove <peer> [--force]', desc: 'delete a peer\'s registry record (locked writer); refuses a LIVE peer unless --force' },
994
+ {
995
+ sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
996
+ desc: 'manual IAP send (fallback)',
997
+ },
998
+ { sig: '<runtime>', desc: "launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached" },
999
+ { sig: 'connect telegram <peer> [--token <t>]', desc: 'attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)' },
1000
+ { sig: 'enable <plugin> [peer] [--no-setup]', desc: 'install + enable an agfpd capability for a peer' },
1001
+ {
1002
+ sig: 'new <peer> [runtime]',
1003
+ desc: 'UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)',
1004
+ },
1005
+ {
1006
+ sig: 'add-runtime <runtime> (--peer <p> | --all)',
1007
+ desc: 'add an agentic runtime to existing peer(s): runtimes-merge + scaffold + codex birth chain (pre-trust, native-memory, memory provision, MCP). default_runtime untouched',
1008
+ },
1009
+ {
1010
+ sig: 'default-runtime <runtime> (--peer <p> | --all)',
1011
+ desc: 'flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command',
1012
+ },
1013
+ { sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then tmux attach' },
1014
+ { sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact' },
1015
+ { sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
1016
+ { sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
1017
+ { sig: 'self-done', desc: '(agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one' },
1018
+ { sig: 'native-memory <off|on> (--peer <p> | --all)', desc: "gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)" },
1019
+ { sig: 'trust-hooks <hooks.json> [--check]', desc: 'pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report' },
1020
+ {
1021
+ sig: 'shadow [--once | --minutes <m>] [--interval <ms>]',
1022
+ desc: 'READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.',
1023
+ },
1024
+ { sig: 'shadow-install', desc: 'install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback' },
1025
+ { sig: 'shadow-uninstall', desc: 'bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)' },
1026
+ {
1027
+ sig: 'supervisor up|start|attach|list|kill <sess> [runtime]',
1028
+ desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
1029
+ },
1030
+ ]
1031
+
1032
+ /** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
1033
+ * line (overflows rather than being split — never break a flag/path mid-token). */
1034
+ export function wrapText(text: string, width: number): string[] {
1035
+ const words = text.split(/\s+/).filter(Boolean)
1036
+ if (words.length === 0) return ['']
1037
+ const out: string[] = []
1038
+ let cur = ''
1039
+ for (const w of words) {
1040
+ if (cur === '') cur = w
1041
+ else if (cur.length + 1 + w.length <= width) cur += ` ${w}`
1042
+ else {
1043
+ out.push(cur)
1044
+ cur = w
1045
+ }
1046
+ }
1047
+ if (cur !== '') out.push(cur)
1048
+ return out
1049
+ }
1050
+
1051
+ /** The terminal width to lay help out for — capped so an ultrawide terminal doesn't
1052
+ * stretch descriptions into unreadably long lines; 80 when not a TTY. */
1053
+ function helpWidth(): number {
1054
+ const c = process.stdout.columns
1055
+ return c && c > 0 ? Math.min(c, 100) : 80
1056
+ }
1057
+
1058
+ /** Render `iapeer help` as an aligned two-column block that wraps to `width`.
1059
+ * Signatures that fit the left column print inline with their (wrapped) description
1060
+ * in the right column; over-long signatures wrap on their own lines first, then the
1061
+ * description follows at the same right-column indent. Deterministic + width-pure
1062
+ * so it is unit-testable. */
1063
+ export function renderUsage(width: number = helpWidth()): string {
1064
+ const W = Math.max(40, width)
1065
+ const INDENT = 2
1066
+ const GUTTER = 2
1067
+ // Left column: longest signature, but capped (≤44, and shrunk on a narrow
1068
+ // terminal) so the right column always keeps a readable ~20+ cols.
1069
+ const longest = Math.max(...VERBS.map(v => v.sig.length))
1070
+ const leftCol = Math.min(longest, 44, Math.max(16, W - 26))
1071
+ const rightStart = INDENT + leftCol + GUTTER
1072
+ const rightWidth = Math.max(20, W - rightStart)
1073
+ const rightPad = ' '.repeat(rightStart)
1074
+ const lines: string[] = ['usage: iapeer <verb> [args]']
1075
+ for (const v of VERBS) {
1076
+ const desc = wrapText(v.desc, rightWidth)
1077
+ if (v.sig.length <= leftCol) {
1078
+ lines.push(`${' '.repeat(INDENT)}${v.sig.padEnd(leftCol)}${' '.repeat(GUTTER)}${desc[0] ?? ''}`)
1079
+ for (const d of desc.slice(1)) lines.push(rightPad + d)
1080
+ } else {
1081
+ // Over-long signature: wrap it across its own lines (continuation indented),
1082
+ // then the description at the shared right-column indent. Wrap to leave room
1083
+ // for the deeper continuation indent (INDENT+2) so no wrapped sig line spills.
1084
+ const sigLines = wrapText(v.sig, W - INDENT - 2)
1085
+ lines.push(' '.repeat(INDENT) + sigLines[0])
1086
+ for (const s of sigLines.slice(1)) lines.push(' '.repeat(INDENT + 2) + s)
1087
+ for (const d of desc) lines.push(rightPad + d)
1088
+ }
1089
+ }
1090
+ return lines.join('\n') + '\n'
1091
+ }
984
1092
 
985
1093
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
986
1094
  const [verb, ...rest] = argv
@@ -992,7 +1100,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
992
1100
  // match is safe: the look-ahead parser never consumes a `--`-token as a value, so
993
1101
  // a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
994
1102
  if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
995
- process.stdout.write(USAGE)
1103
+ process.stdout.write(renderUsage())
996
1104
  return 0
997
1105
  }
998
1106
  const { positionals, flags } = parseArgs(rest)
@@ -1296,10 +1404,40 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1296
1404
  // scaffold the folder (no-clobber), then init it. Operator-add for an infra
1297
1405
  // human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
1298
1406
  if (!positionals[0]) return usage(errOut)
1407
+ const explicitRuntime = typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined
1408
+ // FU5 — never SILENTLY default to claude when both agentic runtimes are
1409
+ // installed. `--runtime` selects the DEFAULT (not the sole runtime); the
1410
+ // model is `runtimes` = all installed agentic runtimes, `default_runtime` =
1411
+ // the one chosen. So: resolve the default explicitly (prompt on a TTY, a loud
1412
+ // note off it) and auto-configure the other installed runtime(s) so the
1413
+ // `runtimes` list is TRUTHFUL — each listed runtime is actually wired.
1414
+ let chosenRuntime = explicitRuntime
1415
+ let extraRuntimes: Runtime[] = []
1416
+ if (!explicitRuntime || !isInfraRuntime(explicitRuntime)) {
1417
+ const { isRuntimeInstalled } = await import('../init/index.ts')
1418
+ const { planCreateRuntimes, secondaryRuntimes } = await import('../create/index.ts')
1419
+ const installed = (['claude', 'codex'] as Runtime[]).filter(rt => isRuntimeInstalled(rt, env))
1420
+ const plan = planCreateRuntimes(explicitRuntime, installed)
1421
+ if (plan.ambiguous) {
1422
+ chosenRuntime =
1423
+ process.stdin.isTTY === true && process.stdout.isTTY === true
1424
+ ? await promptDefaultRuntime(plan.installedAgentic)
1425
+ : (errOut(
1426
+ `note: ${plan.installedAgentic.join(' and ')} are both installed — defaulting to "${plan.fallbackDefault}". ` +
1427
+ 'Pass --runtime to choose the default, or run create in a terminal to be asked.\n',
1428
+ ),
1429
+ plan.fallbackDefault)
1430
+ } else {
1431
+ chosenRuntime = plan.resolvedDefault
1432
+ }
1433
+ // installed.length === 0 → chosenRuntime stays undefined; createPeer surfaces
1434
+ // the clear "no agentic runtime installed" error.
1435
+ extraRuntimes = secondaryRuntimes(chosenRuntime, installed)
1436
+ }
1299
1437
  const { createPeer } = await import('../create/index.ts')
1300
1438
  const r = await createPeer({
1301
1439
  personality: positionals[0],
1302
- runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
1440
+ runtime: chosenRuntime,
1303
1441
  path: typeof flags.path === 'string' ? flags.path : undefined,
1304
1442
  description: typeof flags.description === 'string' ? flags.description : undefined,
1305
1443
  intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
@@ -1308,8 +1446,24 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1308
1446
  env,
1309
1447
  warn: m => errOut(`warn: ${m}\n`),
1310
1448
  })
1449
+ // Wire the other installed agentic runtime(s) — addRuntime is capability-only
1450
+ // (merges `runtimes`, runs the runtime's birth chain, NEVER flips the default).
1451
+ // Best-effort: a peer is usable on its default even if a secondary warns.
1452
+ const addedRuntimes: Runtime[] = []
1453
+ for (const rt of extraRuntimes) {
1454
+ try {
1455
+ const outcomes = await addRuntime(rt, { peer: r.personality, env })
1456
+ const o = outcomes.find(x => x.personality === r.personality)
1457
+ if (o && (o.action === 'added' || o.action === 'already')) addedRuntimes.push(rt)
1458
+ else errOut(`warn: could not add runtime "${rt}": ${o?.detail ?? o?.action ?? 'failed'}\n`)
1459
+ } catch (e) {
1460
+ errOut(`warn: could not add runtime "${rt}": ${e instanceof Error ? e.message : String(e)}\n`)
1461
+ }
1462
+ }
1463
+ const allRuntimes = [r.runtime, ...addedRuntimes]
1311
1464
  out(
1312
- `created "${r.personality}" (${r.runtime}) at ${r.location}; mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
1465
+ `created "${r.personality}" (default: ${r.runtime}; runtimes: ${allRuntimes.join(', ')}) at ${r.location}; ` +
1466
+ `mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
1313
1467
  `${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
1314
1468
  )
1315
1469
  return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
@@ -1639,6 +1793,53 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1639
1793
  ;(failed ? errOut : out)(msg)
1640
1794
  return failed ? 1 : 0
1641
1795
  }
1796
+ case 'uninstall': {
1797
+ // Symmetric foundation removal (namespace-safe; REFUSES on a foreign fleet).
1798
+ // DESTRUCTIVE — removes ~/.iapeer (all peers/memory/logs). --dry-run previews;
1799
+ // --yes skips the confirm; --remove-codesign-identity also drops the shared
1800
+ // agfpd signing identity (otherwise kept).
1801
+ const { planUninstall, executeUninstall } = await import('../uninstall/index.ts')
1802
+ const removeCodesign = flags['remove-codesign-identity'] === true
1803
+ const plan = planUninstall({ env, removeCodesignIdentity: removeCodesign })
1804
+ if (plan.refused) {
1805
+ errOut(`uninstall refused — ${plan.refused.reason}\n`)
1806
+ return 1
1807
+ }
1808
+ out('iapeer uninstall — will remove:\n')
1809
+ for (const it of plan.items) {
1810
+ const mark = it.present ? '•' : '·'
1811
+ const tail = it.detail ? ` — ${it.detail}` : it.present ? '' : ' — already gone'
1812
+ out(` ${mark} ${it.what}${it.path ? ` (${it.path})` : ''}${tail}\n`)
1813
+ }
1814
+ if (flags['dry-run'] === true) {
1815
+ out('\n(dry-run — nothing removed)\n')
1816
+ return 0
1817
+ }
1818
+ if (flags.yes !== true) {
1819
+ if (process.stdin.isTTY !== true) {
1820
+ errOut('\nuninstall is destructive (removes ~/.iapeer) — re-run with --yes in a non-interactive shell.\n')
1821
+ return 2
1822
+ }
1823
+ const { createInterface } = await import('node:readline/promises')
1824
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1825
+ let answer = ''
1826
+ try {
1827
+ answer = (await rl.question('\nThis permanently removes iapeer and ALL its data. Continue? [y/N] ')).trim().toLowerCase()
1828
+ } finally {
1829
+ rl.close()
1830
+ }
1831
+ if (answer !== 'y' && answer !== 'yes') {
1832
+ out('uninstall aborted.\n')
1833
+ return 1
1834
+ }
1835
+ }
1836
+ const res = executeUninstall({ env, removeCodesignIdentity: removeCodesign })
1837
+ for (const r of res.removed) out(` removed: ${r}\n`)
1838
+ for (const s of res.skipped) out(` skipped (absent): ${s}\n`)
1839
+ for (const f of res.failed) errOut(` FAILED: ${f.what} — ${f.detail}\n`)
1840
+ out(res.failed.length ? '\nuninstall finished with errors (see above).\n' : '\niapeer uninstalled. (bun left in place.)\n')
1841
+ return res.failed.length ? 1 : 0
1842
+ }
1642
1843
  case 'install': {
1643
1844
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
1644
1845
  // фундамент"): ONE command does all three install-phase steps that used to be
@@ -1651,9 +1852,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1651
1852
  // Bootstrap path — run from the src tree (`bun src/cli/index.ts install`) or
1652
1853
  // npx; the compiled binary cannot rebuild itself from source (its
1653
1854
  // import.meta.url is the binary → build fails with a clear error).
1654
- const { installIapeer } = await import('../install/index.ts')
1855
+ const { installIapeer, scaffoldHostDocs } = await import('../install/index.ts')
1856
+ const { ensureGlobalDoctrineTemplate } = await import('../init/index.ts')
1655
1857
  ensureGlobalIapScaffold({ env })
1858
+ // FU11 — scaffold the GLOBAL host-doctrine stub ~/.iapeer/IAPEER.md (Layer-2
1859
+ // global doctrine, consumed at launch but previously never created — asymmetric
1860
+ // with the per-peer stub). Idempotent: never overwrites an owner's host doctrine.
1861
+ const hostDoctrine = ensureGlobalDoctrineTemplate(env)
1656
1862
  const r = installIapeer(fileURLToPath(import.meta.url), env)
1863
+ // FU6 — copy the foundation contract docs to the stable per-package host path
1864
+ // ~/.iapeer/docs/iapeer/ (the ecosystem convention: each package copies its OWN
1865
+ // docs on its OWN install, version = its binary). install runs from source, so
1866
+ // docs/ sits at <entry>/../../docs. Best-effort: never fails the install.
1867
+ const docs = scaffoldHostDocs('iapeer', join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docs'), env)
1657
1868
  const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
1658
1869
  const signingLine =
1659
1870
  r.signing == null
@@ -1667,6 +1878,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1667
1878
  `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
1668
1879
  signingLine +
1669
1880
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
1881
+ ` host doctrine: ${hostDoctrine.path}${hostDoctrine.created ? ' (stub created — fill it in)' : ' (kept)'}\n` +
1882
+ ` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
1670
1883
  ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
1671
1884
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
1672
1885
  )
@@ -1940,10 +2153,33 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1940
2153
  }
1941
2154
 
1942
2155
  function usage(errOut: (s: string) => void): number {
1943
- errOut(USAGE)
2156
+ errOut(renderUsage())
1944
2157
  return 2
1945
2158
  }
1946
2159
 
2160
+ /** Interactive default-runtime picker (FU5) — only called on a real TTY when more
2161
+ * than one agentic runtime is installed and no `--runtime` was given. Accepts a
2162
+ * number or the runtime name; empty input takes the first (the listed default). */
2163
+ async function promptDefaultRuntime(installed: Runtime[]): Promise<Runtime> {
2164
+ const { createInterface } = await import('node:readline/promises')
2165
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
2166
+ try {
2167
+ process.stdout.write('More than one agent runtime is installed. Which should this peer default to?\n')
2168
+ installed.forEach((rt, i) => process.stdout.write(` [${i + 1}] ${rt}\n`))
2169
+ for (;;) {
2170
+ const ans = (await rl.question(`Choose [1-${installed.length}] (default 1): `)).trim().toLowerCase()
2171
+ if (ans === '') return installed[0]!
2172
+ const n = Number(ans)
2173
+ if (Number.isInteger(n) && n >= 1 && n <= installed.length) return installed[n - 1]!
2174
+ const byName = installed.find(rt => rt === ans)
2175
+ if (byName) return byName
2176
+ process.stdout.write(`Please enter a number 1-${installed.length} or a runtime name (${installed.join(' / ')}).\n`)
2177
+ }
2178
+ } finally {
2179
+ rl.close()
2180
+ }
2181
+ }
2182
+
1947
2183
  /** Default --from for `send`: the identity of the peer in the current cwd (contract:
1948
2184
  * "по умолчанию — identity пира текущей папки"). Requires running from a peer cwd. */
1949
2185
  function defaultFromIdentity(env: NodeJS.ProcessEnv): string {
@@ -28,6 +28,44 @@ import { defaultPeerCwd, ensureGlobalIapScaffold } from '../storage/index.ts'
28
28
  import { readPeerProfile } from '../identity/index.ts'
29
29
  import { initPeer, type InitPeerResult } from '../init/index.ts'
30
30
 
31
+ /** The default-runtime decision for `create` (FU5), factored out pure + testable.
32
+ * `--runtime` selects the DEFAULT, not the sole runtime; the model is `runtimes` =
33
+ * all installed agentic runtimes, `default_runtime` = the chosen one. */
34
+ export interface CreateRuntimePlan {
35
+ /** True only when the default is AMBIGUOUS — more than one agentic runtime is
36
+ * installed and no `--runtime` was given. The caller prompts on a TTY; off a TTY
37
+ * it falls back to `fallbackDefault` with a loud note (never a silent claude). */
38
+ ambiguous: boolean
39
+ /** The resolved default when NOT ambiguous. `undefined` means "let initPeer
40
+ * resolve it" — exactly one installed → that one, none → its clear error. */
41
+ resolvedDefault?: Runtime
42
+ /** The deterministic default to use when ambiguous + non-interactive. */
43
+ fallbackDefault?: Runtime
44
+ /** The installed agentic runtimes — the prompt candidates and the basis for the
45
+ * secondary runtimes to wire (all of them except the chosen default). */
46
+ installedAgentic: Runtime[]
47
+ }
48
+
49
+ /**
50
+ * Decide create's default runtime + whether a prompt is needed, from the explicit
51
+ * `--runtime` (if any) and the set of installed agentic runtimes. Pure: the caller
52
+ * supplies `installedAgentic` (probed via isRuntimeInstalled) and performs the TTY
53
+ * prompt; this only encodes the policy.
54
+ */
55
+ export function planCreateRuntimes(explicit: Runtime | undefined, installedAgentic: Runtime[]): CreateRuntimePlan {
56
+ if (explicit) return { ambiguous: false, resolvedDefault: explicit, installedAgentic }
57
+ if (installedAgentic.length > 1) return { ambiguous: true, fallbackDefault: installedAgentic[0], installedAgentic }
58
+ if (installedAgentic.length === 1) return { ambiguous: false, resolvedDefault: installedAgentic[0], installedAgentic }
59
+ return { ambiguous: false, resolvedDefault: undefined, installedAgentic }
60
+ }
61
+
62
+ /** The other installed agentic runtimes to wire after creating on `chosen` (so the
63
+ * `runtimes` list is truthful). Empty when `chosen` is undefined/infra. */
64
+ export function secondaryRuntimes(chosen: Runtime | undefined, installedAgentic: Runtime[]): Runtime[] {
65
+ if (!chosen) return []
66
+ return installedAgentic.filter(rt => rt !== chosen)
67
+ }
68
+
31
69
  export interface CreatePeerOptions {
32
70
  /** The peer's personality (REQUIRED; validated/normalized). Drives the default
33
71
  * location (~/.iapeer/peers/<personality>) and the registry/profile identity. */
@@ -81,9 +81,11 @@ export function resolveCallerFromHeader(value: string | undefined, index: PeersI
81
81
  // Embedding it here ALSO duplicated the full list into every agent's context on every
82
82
  // turn — confirmed live: an agent saw the identical roster twice. The
83
83
  // description stays light: purpose + parameter semantics only; the model gets the
84
- // roster from its system prompt. Likewise the MCP server-level `instructions` field is
85
- // left unset (createMcpServer) — it would re-inject doctrine into the client system
86
- // prompt, re-creating the very duplication this removed. Static (no index needed).
84
+ // roster from its system prompt. The MCP server-level `instructions` field carries
85
+ // EXACTLY ONE cross-cutting behavioral rule (the owner-approved no-empty-acks guide,
86
+ // SERVER_INSTRUCTIONS) — it loads ONCE per session at initialize (not per turn), so it
87
+ // is cheap; it deliberately does NOT restate the roster/doctrine (that lives in the
88
+ // system prompt — duplicating it here is the mistake this note warns of). Static.
87
89
  export function buildSendDescription(): string {
88
90
  return [
89
91
  'Send a message to a known iapeer peer through Inter-Agent-Protocol.',
@@ -94,6 +96,13 @@ export function buildSendDescription(): string {
94
96
  ].join('\n')
95
97
  }
96
98
 
99
+ // MCP server-level `instructions` — returned in the initialize result, loaded ONCE
100
+ // per session (NOT per turn like the tool description), so it is the cheap home for
101
+ // a single cross-cutting behavioral rule. VERBATIM owner-approved (no-empty-acks):
102
+ // keep the wording exact; do NOT expand it into doctrine/roster (see the note above).
103
+ export const SERVER_INSTRUCTIONS =
104
+ 'Reply only when a message needs one — a question, task, request, or awaited result. Skip bare acks/FYIs/thanks; they just loop. Silence is the right reply when nothing is asked.'
105
+
97
106
  // The daemon serves the AGENT exactly ONE MCP tool: send_to_peer. list_online_peers
98
107
  // is DEPRECATED by contract (docs/Примитивы и CLI: "list_online_peers УПРАЗДНЁН —
99
108
  // ел контекст; список живых пиров — через CLI verb `list`, не через tool агента").
@@ -259,7 +268,7 @@ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string,
259
268
  }
260
269
 
261
270
  export function createMcpServer(deps: CallToolDeps = {}): Server {
262
- const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
271
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS })
263
272
 
264
273
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools() }))
265
274