@agfpd/iapeer 0.4.0 → 0.4.2
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/07-install-and-update.md +17 -10
- 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/docs/ru/07-/321/203/321/201/321/202/320/260/320/275/320/276/320/262/320/272/320/260-/320/270-/320/276/320/261/320/275/320/276/320/262/320/273/320/265/320/275/320/270/320/265.md +17 -10
- package/package.json +1 -1
- package/src/cli/index.ts +305 -91
- 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/onboard/memory.ts +71 -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/src/update/index.ts +51 -3
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,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
type Runtime,
|
|
21
22
|
} from '../core/constants.ts'
|
|
22
23
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
23
|
-
import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
|
|
24
|
+
import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
|
|
24
25
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
25
26
|
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
26
27
|
import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
|
|
@@ -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,149 @@ 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
|
+
{
|
|
959
|
+
sig: 'update [version] [--force] [--foundation-only]',
|
|
960
|
+
desc: 'cascade-update the WHOLE stack (foundation + installed runtimes + memory) from npm + restart; --foundation-only = core only; a pinned <version> updates the core only',
|
|
961
|
+
},
|
|
962
|
+
{ sig: 'rollback', desc: 'revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs' },
|
|
963
|
+
{
|
|
964
|
+
sig: 'uninstall [--dry-run] [--yes] [--remove-codesign-identity]',
|
|
965
|
+
desc: 'remove THIS foundation install (binary, ~/.iapeer, daemon plist, PATH lines); refuses if a persistent-peer fleet is present',
|
|
966
|
+
},
|
|
967
|
+
{ sig: 'version | --version | -v', desc: "print the installed binary's version" },
|
|
968
|
+
{ sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
|
|
969
|
+
{ sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
|
|
970
|
+
{
|
|
971
|
+
sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]',
|
|
972
|
+
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',
|
|
973
|
+
},
|
|
974
|
+
{ sig: 'status', desc: 'host snapshot: version, daemon health, memory slot (<provider> | none)' },
|
|
975
|
+
{ sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
|
|
976
|
+
{ sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
|
|
977
|
+
{ sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
|
|
978
|
+
{ sig: 'create <personality> [--runtime r] [--path dir] [--bin abs]', desc: 'create a peer anywhere (default ~/.iapeer/peers/<p>) + provision' },
|
|
979
|
+
{ sig: 'list [--json]', desc: 'registered peers + per-runtime liveness' },
|
|
980
|
+
{
|
|
981
|
+
sig: 'verify [--json] [--fix]',
|
|
982
|
+
desc: 'profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)',
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
sig: 'stop <peer> [runtime] | --all',
|
|
986
|
+
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',
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
sig: 'start <peer> [runtime] | --all',
|
|
990
|
+
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",
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
sig: 'refresh <peer> [runtime] | --all',
|
|
994
|
+
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)',
|
|
995
|
+
},
|
|
996
|
+
{ sig: 'remove <peer> [--force]', desc: 'delete a peer\'s registry record (locked writer); refuses a LIVE peer unless --force' },
|
|
997
|
+
{
|
|
998
|
+
sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
|
|
999
|
+
desc: 'manual IAP send (fallback)',
|
|
1000
|
+
},
|
|
1001
|
+
{ sig: '<runtime>', desc: "launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached" },
|
|
1002
|
+
{ sig: 'connect telegram <peer> [--token <t>]', desc: 'attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)' },
|
|
1003
|
+
{ sig: 'enable <plugin> [peer] [--no-setup]', desc: 'install + enable an agfpd capability for a peer' },
|
|
1004
|
+
{
|
|
1005
|
+
sig: 'new <peer> [runtime]',
|
|
1006
|
+
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)',
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
sig: 'add-runtime <runtime> (--peer <p> | --all)',
|
|
1010
|
+
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',
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
sig: 'default-runtime <runtime> (--peer <p> | --all)',
|
|
1014
|
+
desc: 'flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command',
|
|
1015
|
+
},
|
|
1016
|
+
{ sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then tmux attach' },
|
|
1017
|
+
{ sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact' },
|
|
1018
|
+
{ sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
|
|
1019
|
+
{ sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
|
|
1020
|
+
{ sig: 'self-done', desc: '(agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one' },
|
|
1021
|
+
{ sig: 'native-memory <off|on> (--peer <p> | --all)', desc: "gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)" },
|
|
1022
|
+
{ sig: 'trust-hooks <hooks.json> [--check]', desc: 'pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report' },
|
|
1023
|
+
{
|
|
1024
|
+
sig: 'shadow [--once | --minutes <m>] [--interval <ms>]',
|
|
1025
|
+
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.',
|
|
1026
|
+
},
|
|
1027
|
+
{ sig: 'shadow-install', desc: 'install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback' },
|
|
1028
|
+
{ 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)' },
|
|
1029
|
+
{
|
|
1030
|
+
sig: 'supervisor up|start|attach|list|kill <sess> [runtime]',
|
|
1031
|
+
desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
|
|
1032
|
+
},
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
/** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
|
|
1036
|
+
* line (overflows rather than being split — never break a flag/path mid-token). */
|
|
1037
|
+
export function wrapText(text: string, width: number): string[] {
|
|
1038
|
+
const words = text.split(/\s+/).filter(Boolean)
|
|
1039
|
+
if (words.length === 0) return ['']
|
|
1040
|
+
const out: string[] = []
|
|
1041
|
+
let cur = ''
|
|
1042
|
+
for (const w of words) {
|
|
1043
|
+
if (cur === '') cur = w
|
|
1044
|
+
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`
|
|
1045
|
+
else {
|
|
1046
|
+
out.push(cur)
|
|
1047
|
+
cur = w
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (cur !== '') out.push(cur)
|
|
1051
|
+
return out
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** The terminal width to lay help out for — capped so an ultrawide terminal doesn't
|
|
1055
|
+
* stretch descriptions into unreadably long lines; 80 when not a TTY. */
|
|
1056
|
+
function helpWidth(): number {
|
|
1057
|
+
const c = process.stdout.columns
|
|
1058
|
+
return c && c > 0 ? Math.min(c, 100) : 80
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Render `iapeer help` as an aligned two-column block that wraps to `width`.
|
|
1062
|
+
* Signatures that fit the left column print inline with their (wrapped) description
|
|
1063
|
+
* in the right column; over-long signatures wrap on their own lines first, then the
|
|
1064
|
+
* description follows at the same right-column indent. Deterministic + width-pure
|
|
1065
|
+
* so it is unit-testable. */
|
|
1066
|
+
export function renderUsage(width: number = helpWidth()): string {
|
|
1067
|
+
const W = Math.max(40, width)
|
|
1068
|
+
const INDENT = 2
|
|
1069
|
+
const GUTTER = 2
|
|
1070
|
+
// Left column: longest signature, but capped (≤44, and shrunk on a narrow
|
|
1071
|
+
// terminal) so the right column always keeps a readable ~20+ cols.
|
|
1072
|
+
const longest = Math.max(...VERBS.map(v => v.sig.length))
|
|
1073
|
+
const leftCol = Math.min(longest, 44, Math.max(16, W - 26))
|
|
1074
|
+
const rightStart = INDENT + leftCol + GUTTER
|
|
1075
|
+
const rightWidth = Math.max(20, W - rightStart)
|
|
1076
|
+
const rightPad = ' '.repeat(rightStart)
|
|
1077
|
+
const lines: string[] = ['usage: iapeer <verb> [args]']
|
|
1078
|
+
for (const v of VERBS) {
|
|
1079
|
+
const desc = wrapText(v.desc, rightWidth)
|
|
1080
|
+
if (v.sig.length <= leftCol) {
|
|
1081
|
+
lines.push(`${' '.repeat(INDENT)}${v.sig.padEnd(leftCol)}${' '.repeat(GUTTER)}${desc[0] ?? ''}`)
|
|
1082
|
+
for (const d of desc.slice(1)) lines.push(rightPad + d)
|
|
1083
|
+
} else {
|
|
1084
|
+
// Over-long signature: wrap it across its own lines (continuation indented),
|
|
1085
|
+
// then the description at the shared right-column indent. Wrap to leave room
|
|
1086
|
+
// for the deeper continuation indent (INDENT+2) so no wrapped sig line spills.
|
|
1087
|
+
const sigLines = wrapText(v.sig, W - INDENT - 2)
|
|
1088
|
+
lines.push(' '.repeat(INDENT) + sigLines[0])
|
|
1089
|
+
for (const s of sigLines.slice(1)) lines.push(' '.repeat(INDENT + 2) + s)
|
|
1090
|
+
for (const d of desc) lines.push(rightPad + d)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return lines.join('\n') + '\n'
|
|
1094
|
+
}
|
|
985
1095
|
|
|
986
1096
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
987
1097
|
const [verb, ...rest] = argv
|
|
@@ -993,7 +1103,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
993
1103
|
// match is safe: the look-ahead parser never consumes a `--`-token as a value, so
|
|
994
1104
|
// a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
|
|
995
1105
|
if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
|
996
|
-
process.stdout.write(
|
|
1106
|
+
process.stdout.write(renderUsage())
|
|
997
1107
|
return 0
|
|
998
1108
|
}
|
|
999
1109
|
const { positionals, flags } = parseArgs(rest)
|
|
@@ -1297,10 +1407,40 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1297
1407
|
// scaffold the folder (no-clobber), then init it. Operator-add for an infra
|
|
1298
1408
|
// human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
|
|
1299
1409
|
if (!positionals[0]) return usage(errOut)
|
|
1410
|
+
const explicitRuntime = typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined
|
|
1411
|
+
// FU5 — never SILENTLY default to claude when both agentic runtimes are
|
|
1412
|
+
// installed. `--runtime` selects the DEFAULT (not the sole runtime); the
|
|
1413
|
+
// model is `runtimes` = all installed agentic runtimes, `default_runtime` =
|
|
1414
|
+
// the one chosen. So: resolve the default explicitly (prompt on a TTY, a loud
|
|
1415
|
+
// note off it) and auto-configure the other installed runtime(s) so the
|
|
1416
|
+
// `runtimes` list is TRUTHFUL — each listed runtime is actually wired.
|
|
1417
|
+
let chosenRuntime = explicitRuntime
|
|
1418
|
+
let extraRuntimes: Runtime[] = []
|
|
1419
|
+
if (!explicitRuntime || !isInfraRuntime(explicitRuntime)) {
|
|
1420
|
+
const { isRuntimeInstalled } = await import('../init/index.ts')
|
|
1421
|
+
const { planCreateRuntimes, secondaryRuntimes } = await import('../create/index.ts')
|
|
1422
|
+
const installed = (['claude', 'codex'] as Runtime[]).filter(rt => isRuntimeInstalled(rt, env))
|
|
1423
|
+
const plan = planCreateRuntimes(explicitRuntime, installed)
|
|
1424
|
+
if (plan.ambiguous) {
|
|
1425
|
+
chosenRuntime =
|
|
1426
|
+
process.stdin.isTTY === true && process.stdout.isTTY === true
|
|
1427
|
+
? await promptDefaultRuntime(plan.installedAgentic)
|
|
1428
|
+
: (errOut(
|
|
1429
|
+
`note: ${plan.installedAgentic.join(' and ')} are both installed — defaulting to "${plan.fallbackDefault}". ` +
|
|
1430
|
+
'Pass --runtime to choose the default, or run create in a terminal to be asked.\n',
|
|
1431
|
+
),
|
|
1432
|
+
plan.fallbackDefault)
|
|
1433
|
+
} else {
|
|
1434
|
+
chosenRuntime = plan.resolvedDefault
|
|
1435
|
+
}
|
|
1436
|
+
// installed.length === 0 → chosenRuntime stays undefined; createPeer surfaces
|
|
1437
|
+
// the clear "no agentic runtime installed" error.
|
|
1438
|
+
extraRuntimes = secondaryRuntimes(chosenRuntime, installed)
|
|
1439
|
+
}
|
|
1300
1440
|
const { createPeer } = await import('../create/index.ts')
|
|
1301
1441
|
const r = await createPeer({
|
|
1302
1442
|
personality: positionals[0],
|
|
1303
|
-
runtime:
|
|
1443
|
+
runtime: chosenRuntime,
|
|
1304
1444
|
path: typeof flags.path === 'string' ? flags.path : undefined,
|
|
1305
1445
|
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
1306
1446
|
intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
|
|
@@ -1309,8 +1449,24 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1309
1449
|
env,
|
|
1310
1450
|
warn: m => errOut(`warn: ${m}\n`),
|
|
1311
1451
|
})
|
|
1452
|
+
// Wire the other installed agentic runtime(s) — addRuntime is capability-only
|
|
1453
|
+
// (merges `runtimes`, runs the runtime's birth chain, NEVER flips the default).
|
|
1454
|
+
// Best-effort: a peer is usable on its default even if a secondary warns.
|
|
1455
|
+
const addedRuntimes: Runtime[] = []
|
|
1456
|
+
for (const rt of extraRuntimes) {
|
|
1457
|
+
try {
|
|
1458
|
+
const outcomes = await addRuntime(rt, { peer: r.personality, env })
|
|
1459
|
+
const o = outcomes.find(x => x.personality === r.personality)
|
|
1460
|
+
if (o && (o.action === 'added' || o.action === 'already')) addedRuntimes.push(rt)
|
|
1461
|
+
else errOut(`warn: could not add runtime "${rt}": ${o?.detail ?? o?.action ?? 'failed'}\n`)
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
errOut(`warn: could not add runtime "${rt}": ${e instanceof Error ? e.message : String(e)}\n`)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const allRuntimes = [r.runtime, ...addedRuntimes]
|
|
1312
1467
|
out(
|
|
1313
|
-
`created "${r.personality}" (${r.runtime}
|
|
1468
|
+
`created "${r.personality}" (default: ${r.runtime}; runtimes: ${allRuntimes.join(', ')}) at ${r.location}; ` +
|
|
1469
|
+
`mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
|
|
1314
1470
|
`${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
|
|
1315
1471
|
)
|
|
1316
1472
|
return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
|
|
@@ -1563,49 +1719,72 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1563
1719
|
return 0
|
|
1564
1720
|
}
|
|
1565
1721
|
case 'update': {
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1722
|
+
// CASCADE deploy (FU12): a bare `iapeer update` updates the WHOLE host stack —
|
|
1723
|
+
// foundation → every installed runtime → the memory provider — in one command.
|
|
1724
|
+
// Foundation goes FIRST and ABORTS the cascade on a hard failure (never update
|
|
1725
|
+
// runtimes onto a broken core). Runtimes + memory are BEST-EFFORT (reported, not
|
|
1726
|
+
// fatal). Two narrowings stay foundation-ONLY: `--foundation-only` (explicit) and
|
|
1727
|
+
// a pinned `update <version>` (a version pin is foundation-specific — downgrade /
|
|
1728
|
+
// recover). H4 holds throughout (foreign persistent-peer fleet is refused, never
|
|
1729
|
+
// forced — the runtime restart goes via the guarded stop/start). Cloud-only; the
|
|
1730
|
+
// first-ever install is `npx @agfpd/iapeer`.
|
|
1731
|
+
const pinned = positionals[0]
|
|
1732
|
+
const foundationOnly = flags['foundation-only'] === true
|
|
1733
|
+
const cascading = !foundationOnly && !pinned
|
|
1734
|
+
if (cascading) {
|
|
1735
|
+
// no-surprise heads-up: this restarts peers and can run minutes.
|
|
1736
|
+
out(
|
|
1737
|
+
'iapeer update — updating the WHOLE stack: foundation + installed runtimes + memory provider.\n' +
|
|
1738
|
+
'This rebuilds the daemon, restarts the telegram/notifier services and the memory daemon,\n' +
|
|
1739
|
+
'and can take a few minutes; agentic peers pick up the new code on their next wake.\n' +
|
|
1740
|
+
'(`iapeer update --foundation-only` updates just the core.)\n\n',
|
|
1741
|
+
)
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// ── Foundation (the core; a hard failure aborts the cascade) ──
|
|
1745
|
+
const r = updateIapeer({ env, force: flags.force === true, targetVersion: pinned })
|
|
1746
|
+
let foundationHardFail = false
|
|
1573
1747
|
if (r.status === 'failed') {
|
|
1574
1748
|
errOut(`update failed: ${r.reason}\n`)
|
|
1575
|
-
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
// 'updated': the binary is swapped. If the daemon was restarted, VERIFY it
|
|
1582
|
-
// actually came back up before declaring success — a new binary that fails to
|
|
1583
|
-
// boot must not read as a healthy deploy (it's the cue to roll back).
|
|
1584
|
-
if (r.daemon === 'restarted') {
|
|
1749
|
+
foundationHardFail = true
|
|
1750
|
+
} else if (r.status === 'already-latest') {
|
|
1751
|
+
out(`foundation: already at version ${r.from}\n`)
|
|
1752
|
+
} else if (r.daemon === 'restarted') {
|
|
1753
|
+
// 'updated' + daemon restarted: VERIFY it actually came back up (a binary that
|
|
1754
|
+
// fails to boot must not read as a healthy deploy — it's the cue to roll back).
|
|
1585
1755
|
const h = await waitForDaemonHealthy({ env })
|
|
1586
1756
|
if (!h.healthy) {
|
|
1587
|
-
errOut(`updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\n`
|
|
1588
|
-
|
|
1589
|
-
|
|
1757
|
+
errOut(`foundation: updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\nroll back now: iapeer rollback\n`)
|
|
1758
|
+
foundationHardFail = true
|
|
1759
|
+
} else {
|
|
1760
|
+
const note = infraRecycleNote(r.infra)
|
|
1761
|
+
;(infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1590
1762
|
}
|
|
1763
|
+
} else {
|
|
1764
|
+
const daemonNote =
|
|
1765
|
+
r.daemon === 'not-loaded'
|
|
1766
|
+
? 'daemon not loaded — new binary will be used on next start'
|
|
1767
|
+
: r.daemon === 'failed'
|
|
1768
|
+
? `WARNING — ${r.reason}; roll back with: iapeer rollback`
|
|
1769
|
+
: String(r.daemon)
|
|
1770
|
+
if (r.daemon === 'failed') foundationHardFail = true
|
|
1591
1771
|
const note = infraRecycleNote(r.infra)
|
|
1592
|
-
|
|
1593
|
-
errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1594
|
-
return 1
|
|
1595
|
-
}
|
|
1596
|
-
out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
|
|
1597
|
-
return 0
|
|
1772
|
+
;(r.daemon === 'failed' || infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
|
|
1598
1773
|
}
|
|
1599
|
-
const
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
1607
|
-
|
|
1608
|
-
|
|
1774
|
+
const foundationExit = foundationHardFail || infraRecycleFailed(r.infra) ? 1 : 0
|
|
1775
|
+
|
|
1776
|
+
// foundation-only / pinned / hard-fail → stop here (don't cascade onto a broken core).
|
|
1777
|
+
if (!cascading || foundationHardFail) return foundationExit
|
|
1778
|
+
|
|
1779
|
+
// ── Cascade tail: runtimes + memory provider (best-effort) ──
|
|
1780
|
+
const { updateAllRuntimes } = await import('../runtime/update.ts')
|
|
1781
|
+
const { updateMemoryProvider } = await import('../onboard/memory.ts')
|
|
1782
|
+
const tail = await cascadeTail({
|
|
1783
|
+
runtimes: () => updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) }),
|
|
1784
|
+
memory: () => updateMemoryProvider({ env }),
|
|
1785
|
+
out,
|
|
1786
|
+
})
|
|
1787
|
+
return foundationExit === 0 && !tail.failed ? 0 : 1
|
|
1609
1788
|
}
|
|
1610
1789
|
case 'rollback': {
|
|
1611
1790
|
// Recovery: restore the .prev binary kept by the last install, restart the
|
|
@@ -1699,9 +1878,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1699
1878
|
// Bootstrap path — run from the src tree (`bun src/cli/index.ts install`) or
|
|
1700
1879
|
// npx; the compiled binary cannot rebuild itself from source (its
|
|
1701
1880
|
// import.meta.url is the binary → build fails with a clear error).
|
|
1702
|
-
const { installIapeer } = await import('../install/index.ts')
|
|
1881
|
+
const { installIapeer, scaffoldHostDocs } = await import('../install/index.ts')
|
|
1882
|
+
const { ensureGlobalDoctrineTemplate } = await import('../init/index.ts')
|
|
1703
1883
|
ensureGlobalIapScaffold({ env })
|
|
1884
|
+
// FU11 — scaffold the GLOBAL host-doctrine stub ~/.iapeer/IAPEER.md (Layer-2
|
|
1885
|
+
// global doctrine, consumed at launch but previously never created — asymmetric
|
|
1886
|
+
// with the per-peer stub). Idempotent: never overwrites an owner's host doctrine.
|
|
1887
|
+
const hostDoctrine = ensureGlobalDoctrineTemplate(env)
|
|
1704
1888
|
const r = installIapeer(fileURLToPath(import.meta.url), env)
|
|
1889
|
+
// FU6 — copy the foundation contract docs to the stable per-package host path
|
|
1890
|
+
// ~/.iapeer/docs/iapeer/ (the ecosystem convention: each package copies its OWN
|
|
1891
|
+
// docs on its OWN install, version = its binary). install runs from source, so
|
|
1892
|
+
// docs/ sits at <entry>/../../docs. Best-effort: never fails the install.
|
|
1893
|
+
const docs = scaffoldHostDocs('iapeer', join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docs'), env)
|
|
1705
1894
|
const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
|
|
1706
1895
|
const signingLine =
|
|
1707
1896
|
r.signing == null
|
|
@@ -1715,6 +1904,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1715
1904
|
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
1716
1905
|
signingLine +
|
|
1717
1906
|
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
|
1907
|
+
` host doctrine: ${hostDoctrine.path}${hostDoctrine.created ? ' (stub created — fill it in)' : ' (kept)'}\n` +
|
|
1908
|
+
` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
|
|
1718
1909
|
` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
|
|
1719
1910
|
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
|
1720
1911
|
)
|
|
@@ -1988,10 +2179,33 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1988
2179
|
}
|
|
1989
2180
|
|
|
1990
2181
|
function usage(errOut: (s: string) => void): number {
|
|
1991
|
-
errOut(
|
|
2182
|
+
errOut(renderUsage())
|
|
1992
2183
|
return 2
|
|
1993
2184
|
}
|
|
1994
2185
|
|
|
2186
|
+
/** Interactive default-runtime picker (FU5) — only called on a real TTY when more
|
|
2187
|
+
* than one agentic runtime is installed and no `--runtime` was given. Accepts a
|
|
2188
|
+
* number or the runtime name; empty input takes the first (the listed default). */
|
|
2189
|
+
async function promptDefaultRuntime(installed: Runtime[]): Promise<Runtime> {
|
|
2190
|
+
const { createInterface } = await import('node:readline/promises')
|
|
2191
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
2192
|
+
try {
|
|
2193
|
+
process.stdout.write('More than one agent runtime is installed. Which should this peer default to?\n')
|
|
2194
|
+
installed.forEach((rt, i) => process.stdout.write(` [${i + 1}] ${rt}\n`))
|
|
2195
|
+
for (;;) {
|
|
2196
|
+
const ans = (await rl.question(`Choose [1-${installed.length}] (default 1): `)).trim().toLowerCase()
|
|
2197
|
+
if (ans === '') return installed[0]!
|
|
2198
|
+
const n = Number(ans)
|
|
2199
|
+
if (Number.isInteger(n) && n >= 1 && n <= installed.length) return installed[n - 1]!
|
|
2200
|
+
const byName = installed.find(rt => rt === ans)
|
|
2201
|
+
if (byName) return byName
|
|
2202
|
+
process.stdout.write(`Please enter a number 1-${installed.length} or a runtime name (${installed.join(' / ')}).\n`)
|
|
2203
|
+
}
|
|
2204
|
+
} finally {
|
|
2205
|
+
rl.close()
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1995
2209
|
/** Default --from for `send`: the identity of the peer in the current cwd (contract:
|
|
1996
2210
|
* "по умолчанию — identity пира текущей папки"). Requires running from a peer cwd. */
|
|
1997
2211
|
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
|
|