@agfpd/iapeer 0.4.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 +2 -1
- package/docs/03-peers-runtimes-identity.md +1 -1
- package/docs/06-storage.md +12 -0
- package/docs/ru/03-/320/277/320/270/321/200/321/213-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213-/320/270/320/264/320/265/320/275/321/202/320/270/321/207/320/275/320/276/321/201/321/202/321/214.md +1 -1
- package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +12 -0
- package/package.json +1 -1
- package/src/cli/index.ts +242 -54
- package/src/create/index.ts +38 -0
- package/src/daemon/index.ts +13 -4
- package/src/init/index.ts +31 -0
- package/src/install/index.ts +61 -2
- package/src/launch/composeSystemPrompt.ts +8 -0
- package/src/tui/ansi.ts +71 -0
- package/src/tui/onboard/app.tsx +5 -2
- package/src/tui/onboard/run.tsx +80 -20
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
|
+
[](https://github.com/agfpd/iapeer/actions/workflows/ci.yml)
|
|
5
6
|
[](https://www.npmjs.com/package/@agfpd/iapeer)
|
|
6
|
-
[](./LICENSE)
|
|
7
8
|
[](#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"
|
|
98
|
+
"telegram": { "bot": "assistant" }
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
```
|
package/docs/06-storage.md
CHANGED
|
@@ -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"
|
|
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
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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,49 +949,146 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
|
|
|
939
949
|
return { positionals, flags }
|
|
940
950
|
}
|
|
941
951
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
}
|
|
985
1092
|
|
|
986
1093
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
987
1094
|
const [verb, ...rest] = argv
|
|
@@ -993,7 +1100,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
993
1100
|
// match is safe: the look-ahead parser never consumes a `--`-token as a value, so
|
|
994
1101
|
// a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
|
|
995
1102
|
if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
996
|
-
process.stdout.write(
|
|
1103
|
+
process.stdout.write(renderUsage())
|
|
997
1104
|
return 0
|
|
998
1105
|
}
|
|
999
1106
|
const { positionals, flags } = parseArgs(rest)
|
|
@@ -1297,10 +1404,40 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1297
1404
|
// scaffold the folder (no-clobber), then init it. Operator-add for an infra
|
|
1298
1405
|
// human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
|
|
1299
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
|
+
}
|
|
1300
1437
|
const { createPeer } = await import('../create/index.ts')
|
|
1301
1438
|
const r = await createPeer({
|
|
1302
1439
|
personality: positionals[0],
|
|
1303
|
-
runtime:
|
|
1440
|
+
runtime: chosenRuntime,
|
|
1304
1441
|
path: typeof flags.path === 'string' ? flags.path : undefined,
|
|
1305
1442
|
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
1306
1443
|
intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
|
|
@@ -1309,8 +1446,24 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1309
1446
|
env,
|
|
1310
1447
|
warn: m => errOut(`warn: ${m}\n`),
|
|
1311
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]
|
|
1312
1464
|
out(
|
|
1313
|
-
`created "${r.personality}" (${r.runtime}
|
|
1465
|
+
`created "${r.personality}" (default: ${r.runtime}; runtimes: ${allRuntimes.join(', ')}) at ${r.location}; ` +
|
|
1466
|
+
`mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
|
|
1314
1467
|
`${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
|
|
1315
1468
|
)
|
|
1316
1469
|
return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
|
|
@@ -1699,9 +1852,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1699
1852
|
// Bootstrap path — run from the src tree (`bun src/cli/index.ts install`) or
|
|
1700
1853
|
// npx; the compiled binary cannot rebuild itself from source (its
|
|
1701
1854
|
// import.meta.url is the binary → build fails with a clear error).
|
|
1702
|
-
const { installIapeer } = await import('../install/index.ts')
|
|
1855
|
+
const { installIapeer, scaffoldHostDocs } = await import('../install/index.ts')
|
|
1856
|
+
const { ensureGlobalDoctrineTemplate } = await import('../init/index.ts')
|
|
1703
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)
|
|
1704
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)
|
|
1705
1868
|
const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
|
|
1706
1869
|
const signingLine =
|
|
1707
1870
|
r.signing == null
|
|
@@ -1715,6 +1878,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1715
1878
|
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
1716
1879
|
signingLine +
|
|
1717
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` +
|
|
1718
1883
|
` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
|
|
1719
1884
|
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
|
1720
1885
|
)
|
|
@@ -1988,10 +2153,33 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1988
2153
|
}
|
|
1989
2154
|
|
|
1990
2155
|
function usage(errOut: (s: string) => void): number {
|
|
1991
|
-
errOut(
|
|
2156
|
+
errOut(renderUsage())
|
|
1992
2157
|
return 2
|
|
1993
2158
|
}
|
|
1994
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
|
+
|
|
1995
2183
|
/** Default --from for `send`: the identity of the peer in the current cwd (contract:
|
|
1996
2184
|
* "по умолчанию — identity пира текущей папки"). Requires running from a peer cwd. */
|
|
1997
2185
|
function defaultFromIdentity(env: NodeJS.ProcessEnv): string {
|
package/src/create/index.ts
CHANGED
|
@@ -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. */
|
package/src/daemon/index.ts
CHANGED
|
@@ -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.
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
|
package/src/init/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { IapError } from '../core/errors.ts'
|
|
|
21
21
|
import {
|
|
22
22
|
ensureLocalIapScaffold,
|
|
23
23
|
pluginStateDir,
|
|
24
|
+
resolveGlobalRoot,
|
|
24
25
|
writeFileAtomic,
|
|
25
26
|
type StorageOptions,
|
|
26
27
|
} from '../storage/index.ts'
|
|
@@ -245,6 +246,36 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
|
|
|
245
246
|
return { path, created: true }
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Create the GLOBAL host-doctrine template `~/.iapeer/IAPEER.md` if absent — the
|
|
251
|
+
* host-wide owner doctrine (Layer-2 GLOBAL in composeSystemPrompt, shared by EVERY
|
|
252
|
+
* peer; the general layer ABOVE each peer's own `.iapeer/IAPEER.md`). It is consumed at
|
|
253
|
+
* launch but was never scaffolded, so a fresh install left no host-doctrine file for the
|
|
254
|
+
* owner to find — asymmetric with the per-peer stub (ensureDoctrineTemplate). This
|
|
255
|
+
* mirrors that stub at host scope (discoverability + consistency). NEVER overwrites an
|
|
256
|
+
* existing host doctrine. Returns {path, created}.
|
|
257
|
+
*/
|
|
258
|
+
export function ensureGlobalDoctrineTemplate(env: NodeJS.ProcessEnv = process.env): { path: string; created: boolean } {
|
|
259
|
+
const root = resolveGlobalRoot(env)
|
|
260
|
+
const path = join(root, IAPEER_DOCTRINE_FILE)
|
|
261
|
+
if (existsSync(path)) return { path, created: false }
|
|
262
|
+
mkdirSync(root, { recursive: true })
|
|
263
|
+
writeFileSync(
|
|
264
|
+
path,
|
|
265
|
+
[
|
|
266
|
+
'# Host doctrine',
|
|
267
|
+
'',
|
|
268
|
+
'<!-- Host-wide rules shared by EVERY peer on this machine — the general layer,',
|
|
269
|
+
" merged into every peer's system prompt at launch ABOVE that peer's own",
|
|
270
|
+
' .iapeer/IAPEER.md. Replace this with your host-wide conventions. An empty',
|
|
271
|
+
' file contributes nothing. -->',
|
|
272
|
+
'',
|
|
273
|
+
].join('\n'),
|
|
274
|
+
{ mode: 0o644 },
|
|
275
|
+
)
|
|
276
|
+
return { path, created: true }
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
249
280
|
// initPeer — orchestrate: provision (identity + registry + infra plist) + per-peer
|
|
250
281
|
// ecosystem wiring (MCP transport per agentic runtime + doctrine template)
|
package/src/install/index.ts
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
// signInstalledBinary (signing.ts) re-signs each install with the
|
|
14
14
|
// stable local identity so the designated requirement — and the grants — survive.
|
|
15
15
|
|
|
16
|
-
import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
|
|
16
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
17
17
|
import { homedir } from 'os'
|
|
18
|
-
import { join } from 'path'
|
|
18
|
+
import { basename, join, relative, sep } from 'path'
|
|
19
19
|
import { spawnSync } from 'child_process'
|
|
20
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
20
21
|
import { signInstalledBinary, type SigningOutcome } from './signing.ts'
|
|
21
22
|
|
|
22
23
|
/** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
|
|
@@ -95,6 +96,64 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
|
|
|
95
96
|
return { binPath, prevPath, size, signing }
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Scaffold an ecosystem package's docs to the STABLE, versioned, per-package host
|
|
101
|
+
* path `~/.iapeer/docs/<pkg>/` so an agent can read the contract OFFLINE (a compiled
|
|
102
|
+
* binary embeds no docs; the npm tarball's docs/ is discarded after install — proven:
|
|
103
|
+
* `iapeer update` extracts to a temp dir it rm's in a `finally`, and the bunx cache
|
|
104
|
+
* does not retain the package findably). This is the FOUNDATION-OWNED CONVENTION every
|
|
105
|
+
* ecosystem package follows: each copies its OWN docs on its OWN install/update, so a
|
|
106
|
+
* package's on-host docs always match its installed version.
|
|
107
|
+
*
|
|
108
|
+
* Mechanics: copy <docsSource> → ~/.iapeer/docs/<pkg> EXCLUDING internals/ (mirroring
|
|
109
|
+
* the npm `files` exclusion), via an atomic temp-dir swap so a reader never sees a
|
|
110
|
+
* half-copied tree. BEST-EFFORT — a missing source or copy hiccup NEVER fails the
|
|
111
|
+
* install (the binary works without on-host docs); the caller logs the outcome.
|
|
112
|
+
*/
|
|
113
|
+
export function scaffoldHostDocs(
|
|
114
|
+
pkg: string,
|
|
115
|
+
docsSource: string,
|
|
116
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
117
|
+
): { copied: boolean; dest: string; reason?: string } {
|
|
118
|
+
const root = resolveGlobalRoot(env)
|
|
119
|
+
const dest = join(root, 'docs', pkg)
|
|
120
|
+
// Fail-closed sandbox guard (in spirit with assertInstallSandboxIsolated): never write
|
|
121
|
+
// the REAL machine ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT.
|
|
122
|
+
// Compared against the ACTUAL OS home (homedir()), not env.HOME — the root IS the docs
|
|
123
|
+
// isolation, so an env.HOME-based check would false-trip when a test legitimately
|
|
124
|
+
// points IAPEER_ROOT at <tmp>/.iapeer (with env.HOME also <tmp>).
|
|
125
|
+
if (env.IAPEER_TEST_SANDBOX === '1' && root === join(homedir(), '.iapeer')) {
|
|
126
|
+
throw new Error(`refusing to scaffold docs into the REAL ${join(root, 'docs')} under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT`)
|
|
127
|
+
}
|
|
128
|
+
if (!existsSync(docsSource)) return { copied: false, dest, reason: `docs source not found: ${docsSource}` }
|
|
129
|
+
const tmp = `${dest}.tmp-${process.pid}`
|
|
130
|
+
try {
|
|
131
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
132
|
+
mkdirSync(join(dest, '..'), { recursive: true })
|
|
133
|
+
cpSync(docsSource, tmp, {
|
|
134
|
+
recursive: true,
|
|
135
|
+
// Skip the internals/ subtree (matches package.json files: "!docs/internals";
|
|
136
|
+
// returning false for a directory skips its whole subtree) and macOS .DS_Store
|
|
137
|
+
// cruft (the foundation is macOS-targeted, so every dev/host tree carries it).
|
|
138
|
+
filter: src => {
|
|
139
|
+
if (basename(src) === '.DS_Store') return false
|
|
140
|
+
const rel = relative(docsSource, src)
|
|
141
|
+
return rel !== 'internals' && !rel.startsWith(`internals${sep}`)
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
rmSync(dest, { recursive: true, force: true })
|
|
145
|
+
renameSync(tmp, dest)
|
|
146
|
+
return { copied: true, dest }
|
|
147
|
+
} catch (e) {
|
|
148
|
+
try {
|
|
149
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
150
|
+
} catch {
|
|
151
|
+
/* best-effort cleanup */
|
|
152
|
+
}
|
|
153
|
+
return { copied: false, dest, reason: e instanceof Error ? e.message : String(e) }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
98
157
|
/** The previous-binary path kept by the last install for one-step rollback. */
|
|
99
158
|
export function iapeerPrevBinPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
100
159
|
return `${iapeerBinPath(env)}.prev`
|
|
@@ -103,6 +103,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
|
103
103
|
// The YAML identity block is the only Layer-1 section (synthesized, no marker).
|
|
104
104
|
const sections: string[] = [yaml]
|
|
105
105
|
|
|
106
|
+
// Host-context pointer to the on-host ecosystem docs (FU6). Each package's install
|
|
107
|
+
// scaffolds its own contract docs to the stable, versioned, per-package path
|
|
108
|
+
// ~/.iapeer/docs/<package>/, so an agent can read "how does iapeer do X" offline
|
|
109
|
+
// instead of guessing. Synthesized Layer-1 fact (no marker). Wording: owner-approved.
|
|
110
|
+
sections.push(
|
|
111
|
+
'iapeer reference docs are on this host at ~/.iapeer/docs/ — one folder per package (start at iapeer/README.md). Consult them for how iapeer works instead of guessing.',
|
|
112
|
+
)
|
|
113
|
+
|
|
106
114
|
// Layer 2 — IAPEER.md doctrine, GLOBAL then LOCAL (general → specific), each its
|
|
107
115
|
// OWN marked file-section. The bash existence-gate (present-but-empty global → a
|
|
108
116
|
// bare "\n") is dropped deliberately: an empty doctrine file now contributes no
|
package/src/tui/ansi.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ANSI palette for plain-text CLI output that runs OUTSIDE Ink — chiefly the
|
|
2
|
+
// post-Ink onboard summary (run.tsx), which prints after the wizard unmounts and
|
|
3
|
+
// would otherwise read as a flat, colorless tail to a styled flow.
|
|
4
|
+
//
|
|
5
|
+
// It mirrors the Ink wizard's palette one-for-one (see app.tsx color()/glyph()):
|
|
6
|
+
// ok → green ✓ · warn → yellow ! · fail → red ✗ · pending → gray · ·
|
|
7
|
+
// headers → bold (cyan for emphasis) · secondary detail/paths → dim
|
|
8
|
+
// so the seam between the React-rendered part and the printed part is invisible.
|
|
9
|
+
//
|
|
10
|
+
// Color is GATED (colorEnabled): on by default only on a real TTY, force-on with
|
|
11
|
+
// FORCE_COLOR (covers inherited-pipe passthrough by a child process), and hard-off
|
|
12
|
+
// with NO_COLOR (https://no-color.org — presence disables, any value) so piped /
|
|
13
|
+
// redirected / CI captures stay byte-clean. This is the team-wide convention we
|
|
14
|
+
// hand to peer providers (e.g. iapeer-memory) whose stdout we surface verbatim.
|
|
15
|
+
|
|
16
|
+
const ESC = '\x1b['
|
|
17
|
+
const RESET = `${ESC}0m`
|
|
18
|
+
|
|
19
|
+
const CODE = {
|
|
20
|
+
bold: '1',
|
|
21
|
+
dim: '2',
|
|
22
|
+
red: '31',
|
|
23
|
+
green: '32',
|
|
24
|
+
yellow: '33',
|
|
25
|
+
cyan: '36',
|
|
26
|
+
gray: '90',
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
export type AnsiPaint = (s: string) => string
|
|
30
|
+
|
|
31
|
+
export interface Ansi {
|
|
32
|
+
bold: AnsiPaint
|
|
33
|
+
dim: AnsiPaint
|
|
34
|
+
red: AnsiPaint
|
|
35
|
+
green: AnsiPaint
|
|
36
|
+
yellow: AnsiPaint
|
|
37
|
+
cyan: AnsiPaint
|
|
38
|
+
gray: AnsiPaint
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether to emit ANSI color for `stream`. NO_COLOR (any value) hard-disables;
|
|
42
|
+
* FORCE_COLOR (truthy, not "0"/"false") forces on; otherwise gated on a real TTY. */
|
|
43
|
+
export function colorEnabled(
|
|
44
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
stream: { isTTY?: boolean } = process.stdout,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (env.NO_COLOR != null) return false
|
|
48
|
+
const fc = env.FORCE_COLOR
|
|
49
|
+
if (fc != null && fc !== '0' && fc !== 'false' && fc !== '') return true
|
|
50
|
+
return stream.isTTY === true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a palette. When `enabled` is false every paint fn is identity (no codes),
|
|
54
|
+
* so the same call sites produce clean text for pipes/CI. Codes compose: an inner
|
|
55
|
+
* paint applies before the outer, both before the run resets — `a.bold(a.cyan(s))`
|
|
56
|
+
* renders bold+cyan. */
|
|
57
|
+
export function makeAnsi(enabled: boolean): Ansi {
|
|
58
|
+
const wrap =
|
|
59
|
+
(code: string): AnsiPaint =>
|
|
60
|
+
(s: string) =>
|
|
61
|
+
enabled ? `${ESC}${code}m${s}${RESET}` : s
|
|
62
|
+
return {
|
|
63
|
+
bold: wrap(CODE.bold),
|
|
64
|
+
dim: wrap(CODE.dim),
|
|
65
|
+
red: wrap(CODE.red),
|
|
66
|
+
green: wrap(CODE.green),
|
|
67
|
+
yellow: wrap(CODE.yellow),
|
|
68
|
+
cyan: wrap(CODE.cyan),
|
|
69
|
+
gray: wrap(CODE.gray),
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -219,8 +219,11 @@ export function OnboardApp({
|
|
|
219
219
|
if (installed.length === 0) {
|
|
220
220
|
set('auth', { status: 'warn', detail: 'no runtime installed' })
|
|
221
221
|
adv.push(
|
|
222
|
-
'⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs Claude Code
|
|
223
|
-
'
|
|
222
|
+
'⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs an agent runtime: Claude Code OR Codex (either works).\n' +
|
|
223
|
+
' Install EITHER (commands verified against the official docs), sign in, then re-run `iapeer onboard`:\n' +
|
|
224
|
+
' Claude Code: curl -fsSL https://claude.ai/install.sh | bash (or: brew install --cask claude-code)\n' +
|
|
225
|
+
' Codex: npm install -g @openai/codex (or: brew install --cask codex)\n' +
|
|
226
|
+
' If a download is blocked in your region, try the brew form or the other runtime.',
|
|
224
227
|
)
|
|
225
228
|
} else {
|
|
226
229
|
const notes = installed.map(rt => runtimeAuthNote(rt, env)).filter((n): n is string => n != null)
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import React from 'react'
|
|
20
20
|
import { render } from 'ink'
|
|
21
21
|
import { OnboardApp, type WizardResult } from './app.tsx'
|
|
22
|
+
import { type Ansi, colorEnabled, makeAnsi } from '../ansi.ts'
|
|
22
23
|
|
|
23
24
|
/** Sentinel: the environment is not a real interactive TTY — caller should fall
|
|
24
25
|
* back to the linear non-interactive onboard path (never render raw + hang). */
|
|
@@ -28,6 +29,36 @@ export interface OnboardWizardOptions {
|
|
|
28
29
|
env?: NodeJS.ProcessEnv
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Style one post-Ink summary line to match the Ink wizard exactly: the headline
|
|
33
|
+
* (no leading glyph) bold — red when it announces errors; each item line keeps its
|
|
34
|
+
* glyph colored by status (✓ green / ! yellow / ✗ red / · gray) and dims the
|
|
35
|
+
* trailing " — detail", just as the in-wizard render does. */
|
|
36
|
+
function paintSummaryLine(line: string, a: Ansi): string {
|
|
37
|
+
const m = line.match(/^(\s*)([✓!✗·]) (.*)$/)
|
|
38
|
+
if (!m) return /error/i.test(line) ? a.bold(a.red(line)) : a.bold(line)
|
|
39
|
+
const [, indent, mark, rest] = m
|
|
40
|
+
const paintMark = mark === '✓' ? a.green : mark === '!' ? a.yellow : mark === '✗' ? a.red : a.gray
|
|
41
|
+
const i = rest.indexOf(' — ')
|
|
42
|
+
const label = i >= 0 ? rest.slice(0, i) : rest
|
|
43
|
+
const detail = i >= 0 ? rest.slice(i) : ''
|
|
44
|
+
return `${indent}${paintMark(mark)} ${label}${detail ? a.dim(detail) : ''}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Color a `prefix: state[ rest]` progress line by the state's semantics, matching
|
|
48
|
+
* the wizard palette + the cross-CLI convention agreed with iapeer-memory: red for
|
|
49
|
+
* failure, GRAY for a neutral skip/decline (· semantics — yellow is reserved for
|
|
50
|
+
* warn/attention, not "user chose not to"), yellow for warn, green otherwise. */
|
|
51
|
+
function paintResult(prefix: string, state: string, rest: string, a: Ansi): string {
|
|
52
|
+
const paint = /(fail|error|refus)/i.test(state)
|
|
53
|
+
? a.red
|
|
54
|
+
: /(skip|declin)/i.test(state)
|
|
55
|
+
? a.gray
|
|
56
|
+
: /(warn|missing|incomplete)/i.test(state)
|
|
57
|
+
? a.yellow
|
|
58
|
+
: a.green
|
|
59
|
+
return `${a.bold(`${prefix}:`)} ${paint(state)}${rest}`
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
/** Render the interactive onboard wizard. Resolves with the process exit code, or
|
|
32
63
|
* WIZARD_NOT_INTERACTIVE when there is no real TTY to drive it. */
|
|
33
64
|
export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise<number> {
|
|
@@ -35,6 +66,10 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
35
66
|
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
36
67
|
return WIZARD_NOT_INTERACTIVE
|
|
37
68
|
}
|
|
69
|
+
// Post-Ink lines are plain stdout (Ink has unmounted), so they carry their own
|
|
70
|
+
// ANSI to stay visually continuous with the wizard. Gated: real TTY here (the
|
|
71
|
+
// wizard only runs on one), off under NO_COLOR.
|
|
72
|
+
const a = makeAnsi(colorEnabled(env, process.stdout))
|
|
38
73
|
|
|
39
74
|
let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
40
75
|
try {
|
|
@@ -57,54 +92,79 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
57
92
|
try {
|
|
58
93
|
if (result.telegramConsent) {
|
|
59
94
|
const { onboardTelegramStep } = await import('../../onboard/steps.ts')
|
|
60
|
-
process.stdout.write(
|
|
95
|
+
process.stdout.write(
|
|
96
|
+
'\n' +
|
|
97
|
+
a.bold('Setting up Telegram…') +
|
|
98
|
+
a.dim(' (Ctrl-C to skip — add later: `iapeer create <you> --runtime telegram`)') +
|
|
99
|
+
'\n',
|
|
100
|
+
)
|
|
61
101
|
try {
|
|
62
102
|
const ts = await onboardTelegramStep({ env })
|
|
63
|
-
process.stdout.write(
|
|
103
|
+
process.stdout.write(paintResult('telegram', ts.state, ts.detail ? ` — ${ts.detail}` : '', a) + '\n')
|
|
64
104
|
} catch (e) {
|
|
65
|
-
process.stdout.write(
|
|
105
|
+
process.stdout.write(paintResult('telegram', 'step skipped', ` — ${e instanceof Error ? e.message : String(e)}`, a) + '\n')
|
|
66
106
|
}
|
|
67
107
|
} else {
|
|
68
|
-
process.stdout.write(
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
'\n' + paintResult('telegram', 'skipped', ' — add later with `iapeer create <you> --runtime telegram`', a) + '\n',
|
|
110
|
+
)
|
|
69
111
|
}
|
|
70
112
|
|
|
71
113
|
if (result.memoryConsent) {
|
|
72
114
|
const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
|
|
73
115
|
process.stdout.write(
|
|
74
|
-
'\
|
|
75
|
-
'
|
|
76
|
-
'
|
|
116
|
+
'\n' +
|
|
117
|
+
a.bold('Installing the shared-memory provider (@agfpd/iapeer-memory)…') +
|
|
118
|
+
'\n' +
|
|
119
|
+
a.dim(
|
|
120
|
+
'This can take a few minutes — it builds a native module. Memory is OPTIONAL:\n' +
|
|
121
|
+
'press Ctrl-C to skip it (set it up later with `npx @agfpd/iapeer-memory init`).',
|
|
122
|
+
) +
|
|
123
|
+
'\n\n',
|
|
77
124
|
)
|
|
78
125
|
try {
|
|
79
126
|
const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
|
|
80
127
|
const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
81
|
-
|
|
128
|
+
const rest = `${mem.detail ? ` — ${mem.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
|
|
129
|
+
process.stdout.write('\n' + paintResult('memory', mem.state, rest, a) + '\n')
|
|
82
130
|
} catch (e) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
131
|
+
const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(set up later: `npx @agfpd/iapeer-memory init`)')}`
|
|
132
|
+
process.stdout.write('\n' + paintResult('memory', 'step skipped', rest, a) + '\n')
|
|
86
133
|
}
|
|
87
134
|
} else {
|
|
88
|
-
process.stdout.write(
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
'\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
|
|
137
|
+
)
|
|
89
138
|
}
|
|
90
139
|
} finally {
|
|
91
140
|
process.removeListener('SIGINT', ignore)
|
|
92
141
|
for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
|
|
93
142
|
}
|
|
94
143
|
|
|
95
|
-
// Final summary (
|
|
144
|
+
// Final summary (post-Ink, ANSI-styled to match the wizard's palette).
|
|
96
145
|
process.stdout.write('\n')
|
|
97
|
-
for (const line of result.summary) process.stdout.write(line + '\n')
|
|
146
|
+
for (const line of result.summary) process.stdout.write(paintSummaryLine(line, a) + '\n')
|
|
98
147
|
if (result.advisories.length > 0) {
|
|
99
148
|
process.stdout.write('\n')
|
|
100
|
-
|
|
149
|
+
// Advisories mirror the wizard's yellow box (a multi-line entry stays yellow
|
|
150
|
+
// line-to-line until the trailing reset).
|
|
151
|
+
for (const adv of result.advisories) process.stdout.write(a.yellow(adv) + '\n')
|
|
101
152
|
}
|
|
102
|
-
|
|
103
|
-
|
|
153
|
+
|
|
154
|
+
// Next steps — an aligned 2-column block (label padded, command highlighted), the
|
|
155
|
+
// bot-token left as its own explicit step (no surprise / don't-make-them-guess).
|
|
156
|
+
const next: Array<{ label: string; cmd: string; note?: string }> = [
|
|
157
|
+
{ label: 'Create your first agent', cmd: 'iapeer create <name> --runtime claude' },
|
|
158
|
+
]
|
|
104
159
|
if (result.telegramConsent) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
next.push({ label: 'Activate Telegram', cmd: 'iapeer connect telegram', note: 'paste a bot token from @BotFather' })
|
|
161
|
+
}
|
|
162
|
+
const w = Math.max(...next.map(n => n.label.length))
|
|
163
|
+
process.stdout.write('\n' + a.bold('Next steps:') + '\n')
|
|
164
|
+
for (const n of next) {
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
` ${a.cyan('•')} ${n.label.padEnd(w)} ${a.cyan(n.cmd)}${n.note ? ' ' + a.dim(`(${n.note})`) : ''}\n`,
|
|
167
|
+
)
|
|
108
168
|
}
|
|
109
169
|
return result.code
|
|
110
170
|
}
|