@agfpd/iapeer 0.2.16 → 0.2.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -116,6 +116,9 @@ describe('remove (registry record via the locked writer)', () => {
116
116
  const o = await removePeerCli('zombie', { env: e })
117
117
  expect(o.action).toBe('removed')
118
118
  expect(findPeer(readPeersIndex({ env: e }), 'zombie')).toBeNull()
119
+ // the folder is deliberately KEPT; the outcome carries the cwd so the verb can
120
+ // say so instead of leaving silent orphans (boris 10.06)
121
+ expect(o.cwd).toBe('/tmp/zombie')
119
122
  })
120
123
  test('removing an absent peer is an idempotent no-op (not an error)', async () => {
121
124
  const o = await removePeerCli('never-existed', { env: env() })
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // sentinel-marked always-on plist) are stop/start-able.
12
12
 
13
13
  import { spawnSync } from 'child_process'
14
- import { readFileSync } from 'fs'
14
+ import { existsSync, readFileSync } from 'fs'
15
15
  import { fileURLToPath } from 'url'
16
16
  import {
17
17
  isInfraRuntime,
@@ -255,6 +255,10 @@ export interface RemoveOutcome {
255
255
  personality: string
256
256
  action: 'removed' | 'absent' | 'refused-live'
257
257
  reason?: string
258
+ /** The removed peer's cwd (registry fact, captured BEFORE the removal). remove
259
+ * deliberately keeps the folder — user data is never deleted by a registry reap
260
+ * (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
261
+ cwd?: string
258
262
  }
259
263
 
260
264
  /**
@@ -285,7 +289,7 @@ export async function removePeerCli(
285
289
  }
286
290
  }
287
291
  await removePeer(personality, { env })
288
- return { personality, action: 'removed' }
292
+ return { personality, action: 'removed', cwd: peer.cwd }
289
293
  }
290
294
 
291
295
  // ─────────────────────────────────────────────────────────────────────────────
@@ -372,6 +376,7 @@ const USAGE = `usage: iapeer <verb> [args]
372
376
  backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
373
377
  status host snapshot: version, daemon health, memory slot (<provider> | none)
374
378
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
379
+ update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
375
380
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
376
381
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
377
382
  list [--json] registered peers + per-runtime liveness
@@ -380,6 +385,7 @@ const USAGE = `usage: iapeer <verb> [args]
380
385
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
381
386
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
382
387
  <runtime> launch the cwd's peer (ALWAYS fresh)
388
+ connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
383
389
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
384
390
  attach <peer> [runtime] ensure-live + resume, then tmux attach
385
391
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
@@ -552,6 +558,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
552
558
  out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
553
559
  return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
554
560
  }
561
+ case 'update-runtime': {
562
+ // §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
563
+ // forced re-npx → idempotent re-provision (same path as install-runtime) →
564
+ // restart the runtime's peers via the regular stop/start. The core's own
565
+ // `update` stays foundation-only — this is the runtimes' counterpart.
566
+ const all = flags.all === true
567
+ if (!all && !positionals[0]) return usage(errOut)
568
+ const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
569
+ const results = all
570
+ ? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
571
+ : [await updateRuntime({ runtime: positionals[0] as Runtime, force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })]
572
+ let failed = false
573
+ for (const r of results) {
574
+ const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
575
+ out(`${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
576
+ for (const p of r.peers) out(` re-provisioned ${p.personality}: self-config ${p.selfConfig ?? 'n/a'}\n`)
577
+ for (const p of r.restarted) out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
578
+ if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
579
+ if (r.restarted.some(p => p.state === 'failed')) failed = true
580
+ if (!all && r.state === 'not-installed') failed = true
581
+ }
582
+ return failed ? 1 : 0
583
+ }
555
584
  case 'init': {
556
585
  // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
557
586
  // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
@@ -632,8 +661,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
632
661
  // peer unless --force (orphaning a running session from routing is the risk).
633
662
  if (!positionals[0]) return usage(errOut)
634
663
  const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
635
- if (o.action === 'removed') out(`removed "${o.personality}" from the registry\n`)
636
- else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
664
+ if (o.action === 'removed') {
665
+ out(`removed "${o.personality}" from the registry\n`)
666
+ // Deliberate: the registry reap never deletes user data — but SAY so, or
667
+ // the default-location peers leave silent orphan folders (boris 10.06).
668
+ if (o.cwd && existsSync(o.cwd)) {
669
+ out(`folder kept: ${o.cwd} (remove never deletes peer data — \`rm -rf\` it yourself if it was a throwaway)\n`)
670
+ }
671
+ } else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
637
672
  else errOut(`remove: ${o.reason}\n`)
638
673
  return o.action === 'refused-live' ? 1 : 0
639
674
  }
@@ -839,6 +874,36 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
839
874
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
840
875
  return 0
841
876
  }
877
+ case 'connect': {
878
+ // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
879
+ // `connect telegram <peer> [--token <t>]`. The human owes only the token;
880
+ // alias/bot-add/interface/router-restart are resolved by the system. The
881
+ // FIRST message from the human to the bot activates the chat (platform rule).
882
+ if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
883
+ const { connectTelegram } = await import('../connect/index.ts')
884
+ const r = await connectTelegram({
885
+ peer: positionals[1],
886
+ token: typeof flags.token === 'string' ? flags.token : undefined,
887
+ env,
888
+ })
889
+ if (r.state === 'noop-same-token') {
890
+ out(`connect telegram ${r.peer}: ${r.detail}\n`)
891
+ return 0
892
+ }
893
+ if (r.state !== 'connected') {
894
+ errOut(`connect telegram ${r.peer}: ${r.state}${r.detail ? ` — ${r.detail}` : ''}\n`)
895
+ return 1
896
+ }
897
+ const rs = r.restart!
898
+ out(`bot ${r.username ?? `for "${r.peer}"`} added + interfaced to "${r.peer}"\n`)
899
+ out(
900
+ rs.state === 'restarted'
901
+ ? `router restarted — credentials loaded\n`
902
+ : `router restart ${rs.state}${rs.detail ? ` — ${rs.detail}` : ''} (the channel stays dead until the router restarts)\n`,
903
+ )
904
+ out(`activation: send the bot ${r.username ?? '(see @BotFather)'} its FIRST message — Telegram does not let a bot start the chat\n`)
905
+ return rs.state === 'restarted' ? 0 : 1
906
+ }
842
907
  case 'enable': {
843
908
  // Per-peer capability install (contract Установка §3): install <plugin>@agfpd
844
909
  // per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
@@ -0,0 +1,144 @@
1
+ // connect telegram — the one-flow channel attachment (design §(в)). Sandboxed:
2
+ // IAPEER_ROOT temp dirs, injected telegram-runtime runner + router restart. The
3
+ // runner stub mimics the package: `bot add` writes bots/<alias>/.env (stable ABI).
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test'
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { dirname } from 'path'
8
+ import { tmpdir } from 'os'
9
+ import { join } from 'path'
10
+ import { botEnvPath, connectTelegram, type RestartOutcome, type TgRunner } from './index.ts'
11
+ import { writeRuntimeManifest } from '../runtime/index.ts'
12
+ import { upsertPeer } from '../registry/index.ts'
13
+
14
+ const roots: string[] = []
15
+ function mkTmp(): string {
16
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-connect-'))
17
+ roots.push(d)
18
+ return d
19
+ }
20
+ afterEach(() => {
21
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
22
+ })
23
+
24
+ function envFor(root: string): NodeJS.ProcessEnv {
25
+ return {
26
+ IAPEER_ROOT: join(root, 'iapeer'),
27
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
28
+ IAPEER_TEST_SANDBOX: '1',
29
+ HOME: root,
30
+ } as NodeJS.ProcessEnv
31
+ }
32
+
33
+ async function fixture(): Promise<{ env: NodeJS.ProcessEnv; calls: string[][]; runTg: TgRunner; restarts: string[] }> {
34
+ const env = envFor(mkTmp())
35
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: { command: '/stub/telegram-runtime', args: ['self-config'] } }, { env })
36
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
37
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
38
+ const calls: string[][] = []
39
+ const runTg: TgRunner = (args, e) => {
40
+ calls.push(args)
41
+ if (args[0] === 'bot' && args[1] === 'add') {
42
+ // the package's behavior: token → bots/<alias>/.env; prints the validated @username
43
+ const p = botEnvPath(args[2]!, e)
44
+ mkdirSync(dirname(p), { recursive: true })
45
+ writeFileSync(p, `TELEGRAM_BOT_TOKEN=${args[4]}\n`)
46
+ return { status: 0, stdout: 'bot added: @leo_test_bot\n', stderr: '' }
47
+ }
48
+ return { status: 0, stdout: '', stderr: '' }
49
+ }
50
+ const restarts: string[] = []
51
+ return { env, calls, runTg, restarts }
52
+ }
53
+
54
+ const okRestart =
55
+ (restarts: string[]) =>
56
+ (human: string): RestartOutcome => {
57
+ restarts.push(human)
58
+ return { state: 'restarted' }
59
+ }
60
+
61
+ describe('connectTelegram (one flow: bot add → interface → restart → activation)', () => {
62
+ test('happy path: adds the bot, interfaces the peer, restarts the HUMAN router, surfaces @username', async () => {
63
+ const { env, calls, runTg, restarts } = await fixture()
64
+ const r = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
65
+ expect(r.state).toBe('connected')
66
+ expect(r.username).toBe('@leo_test_bot')
67
+ expect(r.restart?.state).toBe('restarted')
68
+ expect(restarts).toEqual(['arthur']) // the router = the natural telegram peer, not leo
69
+ expect(calls[0]).toEqual(['bot', 'add', 'leo', '--token', 'T1:abc'])
70
+ expect(calls[1]).toEqual(['interface', 'bot', 'leo', '--peer', 'leo'])
71
+ })
72
+
73
+ test('IDEMPOTENT: the same token is a byte-stable no-op — router NOT restarted', async () => {
74
+ const { env, runTg, restarts } = await fixture()
75
+ expect((await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })).state).toBe('connected')
76
+ const second = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
77
+ expect(second.state).toBe('noop-same-token')
78
+ expect(restarts).toEqual(['arthur']) // exactly ONE restart — the first connect
79
+ })
80
+
81
+ test('a NEW token replaces and RESTARTS again (credentials load at start)', async () => {
82
+ const { env, runTg, restarts } = await fixture()
83
+ await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
84
+ const r = await connectTelegram({ peer: 'leo', token: 'T2:new', env, runTg, restart: okRestart(restarts) })
85
+ expect(r.state).toBe('connected')
86
+ expect(restarts).toEqual(['arthur', 'arthur'])
87
+ })
88
+
89
+ test('unregistered peer → refused BEFORE any bot add (interface precondition)', async () => {
90
+ const { env, calls, runTg } = await fixture()
91
+ const r = await connectTelegram({ peer: 'ghost', token: 'T', env, runTg })
92
+ expect(r.state).toBe('unregistered-peer')
93
+ expect(calls.length).toBe(0)
94
+ })
95
+
96
+ test('no telegram manifest → runtime-missing with the install recipe', async () => {
97
+ const env = envFor(mkTmp())
98
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
99
+ const r = await connectTelegram({ peer: 'leo', token: 'T', env })
100
+ expect(r.state).toBe('runtime-missing')
101
+ expect(r.detail).toContain('install-runtime telegram')
102
+ })
103
+
104
+ test('non-tty without --token → explicit refusal with the BotFather recipe', async () => {
105
+ const { env, runTg } = await fixture()
106
+ const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: false })
107
+ expect(r.state).toBe('refused-no-token')
108
+ expect(r.detail).toContain('@BotFather')
109
+ })
110
+
111
+ test('tty prompt path: the asked token flows in; empty answer → refusal', async () => {
112
+ const { env, runTg, restarts } = await fixture()
113
+ const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => 'T9:asked', restart: okRestart(restarts) })
114
+ expect(r.state).toBe('connected')
115
+ const r2 = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => '' })
116
+ expect(r2.state).toBe('refused-no-token')
117
+ })
118
+
119
+ test('bot add failure (getMe refusal on a bad token) → bot-add-failed with the package detail', async () => {
120
+ const { env } = await fixture()
121
+ const failTg: TgRunner = args =>
122
+ args[0] === 'bot' ? { status: 1, stdout: '', stderr: 'getMe failed: 401 Unauthorized' } : { status: 0, stdout: '', stderr: '' }
123
+ const r = await connectTelegram({ peer: 'leo', token: 'bad', env, runTg: failTg })
124
+ expect(r.state).toBe('bot-add-failed')
125
+ expect(r.detail).toContain('401')
126
+ })
127
+
128
+ test('no natural telegram peer in the registry → connected but restart=no-router (loud)', async () => {
129
+ const env = envFor(mkTmp())
130
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: '/stub/telegram-runtime self-config' }, { env })
131
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
132
+ const runTg: TgRunner = (args, e) => {
133
+ if (args[0] === 'bot') {
134
+ const p = botEnvPath('leo', e)
135
+ mkdirSync(dirname(p), { recursive: true })
136
+ writeFileSync(p, 'TELEGRAM_BOT_TOKEN=T\n')
137
+ }
138
+ return { status: 0, stdout: '', stderr: '' }
139
+ }
140
+ const r = await connectTelegram({ peer: 'leo', token: 'T', env, runTg })
141
+ expect(r.state).toBe('connected')
142
+ expect(r.restart?.state).toBe('no-router')
143
+ })
144
+ })
@@ -0,0 +1,212 @@
1
+ // connect — per-peer channel attachment in ONE flow (design «Onboard костяка» §(в),
2
+ // FINAL 10.06; flow facts confirmed by the telegram-runtime owner against v0.10.3).
3
+ // Namespace: `iapeer connect <channel> <peer>` — extensible to future channels;
4
+ // v1 implements `connect telegram <peer> [--token <t>]`.
5
+ //
6
+ // The human owes EXACTLY ONE external fact: the bot token (prompt walks them
7
+ // through @BotFather → /newbot). Everything else the system resolves: the bot
8
+ // alias (= the peer's personality), `telegram-runtime bot add` (owner adds getMe
9
+ // validation — an invalid token fails THERE, early), `telegram-runtime interface
10
+ // bot` (profile merge; precondition: the peer is registered), then the MANDATORY
11
+ // router-session restart (the live poller reads bots/ ONCE at start, no fs-watch —
12
+ // without the restart the channel is dead both ways), and the activation hint:
13
+ // the FIRST message from the human to the bot opens the chat (a Telegram platform
14
+ // rule — a bot cannot initiate; outbound into an unopened chat is 403).
15
+ //
16
+ // IDEMPOTENT: the same token is a byte-stable no-op (bots/<alias>/.env unchanged →
17
+ // no restart needed); a NEW token replaces with an explicit message AND restarts
18
+ // (credentials load at start).
19
+
20
+ import { existsSync, readFileSync } from 'fs'
21
+ import { join } from 'path'
22
+ import { spawnSync } from 'child_process'
23
+ import { normalizeIntelligenceValue } from '../core/constants.ts'
24
+ import { runtimeRoot } from '../storage/index.ts'
25
+ import { findPeer, readPeersIndex } from '../registry/index.ts'
26
+ import { readRuntimeManifest } from '../runtime/index.ts'
27
+
28
+ export interface TgRunResult {
29
+ status: number | null
30
+ stdout: string
31
+ stderr: string
32
+ }
33
+ export type TgRunner = (args: string[], env: NodeJS.ProcessEnv) => TgRunResult
34
+
35
+ export interface RestartOutcome {
36
+ state: 'restarted' | 'refused-foreign-launchd' | 'failed' | 'no-router'
37
+ detail?: string
38
+ }
39
+ export type RestartFn = (humanPeer: string, env: NodeJS.ProcessEnv) => RestartOutcome
40
+
41
+ export interface ConnectTelegramOptions {
42
+ peer: string
43
+ /** --token; absent → tty prompt (BotFather recipe) / non-tty refusal. */
44
+ token?: string
45
+ env?: NodeJS.ProcessEnv
46
+ /** Injectable prompt (tests). Default: readline on the live tty. */
47
+ ask?: (question: string) => Promise<string>
48
+ /** Override tty detection (tests). */
49
+ isTty?: boolean
50
+ /** Injectable telegram-runtime invoker (tests). Default: spawn the manifest's bin. */
51
+ runTg?: TgRunner
52
+ /** Injectable router restart (tests). Default: stopPeer→startPeer strictly in order. */
53
+ restart?: RestartFn
54
+ }
55
+
56
+ export interface ConnectTelegramResult {
57
+ state:
58
+ | 'connected' // bot added + interfaced + router restarted — channel live after activation
59
+ | 'noop-same-token' // byte-stable .env → nothing changed, no restart
60
+ | 'refused-no-token' // non-tty and no --token (or an empty tty answer)
61
+ | 'unregistered-peer' // precondition: the peer must be in the registry
62
+ | 'runtime-missing' // no telegram runtime manifest — install it first
63
+ | 'bot-add-failed' // telegram-runtime bot add exited non-zero (incl. getMe refusal)
64
+ | 'interface-failed' // telegram-runtime interface bot exited non-zero
65
+ peer: string
66
+ /** Bot @username when `bot add` surfaced one (owner obligation) — best-effort. */
67
+ username?: string
68
+ restart?: RestartOutcome
69
+ detail?: string
70
+ }
71
+
72
+ async function ttyAsk(question: string): Promise<string> {
73
+ const { createInterface } = await import('node:readline/promises')
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
75
+ try {
76
+ return (await rl.question(question)).trim()
77
+ } finally {
78
+ rl.close()
79
+ }
80
+ }
81
+
82
+ /** bots/<alias>/.env under the telegram runtime scope (stable ABI — owner's fact). */
83
+ export function botEnvPath(alias: string, env: NodeJS.ProcessEnv): string {
84
+ return join(runtimeRoot('telegram', { env }), 'bots', alias, '.env')
85
+ }
86
+
87
+ function readBotEnv(alias: string, env: NodeJS.ProcessEnv): string | null {
88
+ try {
89
+ const p = botEnvPath(alias, env)
90
+ return existsSync(p) ? readFileSync(p, 'utf8') : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /** Resolve the telegram-runtime CLI bin from the manifest's selfConfig command (the
97
+ * package's own absolute bin — PATH-independent, launchd-safe). */
98
+ function resolveTgBin(env: NodeJS.ProcessEnv): string | null {
99
+ const manifest = readRuntimeManifest('telegram', { env })
100
+ if (!manifest) return null
101
+ const sc = manifest.selfConfig
102
+ if (typeof sc === 'string' && sc.trim()) return sc.trim().split(/\s+/)[0]!
103
+ if (sc && typeof sc === 'object' && sc.command) return sc.command
104
+ return 'telegram-runtime' // manifest present but no hook — fall back to PATH
105
+ }
106
+
107
+ const defaultRunTg =
108
+ (bin: string): TgRunner =>
109
+ (args, env) => {
110
+ const r = spawnSync(bin, args, { encoding: 'utf8', env: env as Record<string, string> })
111
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? (r.error?.message ?? '') }
112
+ }
113
+
114
+ /** The default router restart: stop→start of the human peer's telegram runtime,
115
+ * STRICTLY sequential (the per-bot runtime.lock with live-pid detection refuses a
116
+ * parallel process — a feature). Deferred import: connect → cli would otherwise be
117
+ * a static cycle (cli imports connect). */
118
+ async function defaultRestart(humanPeer: string, env: NodeJS.ProcessEnv): Promise<RestartOutcome> {
119
+ const { stopPeer, startPeer } = await import('../cli/index.ts')
120
+ try {
121
+ const stops = stopPeer(humanPeer, 'telegram', { env })
122
+ if (stops.some(o => o.action === 'refused-foreign-launchd')) {
123
+ return {
124
+ state: 'refused-foreign-launchd',
125
+ detail: `router "${humanPeer}" is persistent-peer-managed (H4 read-only) — restart it yourself to load the new bot`,
126
+ }
127
+ }
128
+ const starts = startPeer(humanPeer, 'telegram', { env })
129
+ const bad = starts.find(o => o.reason && o.action === 'bootstrap')
130
+ return bad ? { state: 'failed', detail: bad.reason } : { state: 'restarted' }
131
+ } catch (e) {
132
+ return { state: 'failed', detail: e instanceof Error ? e.message : String(e) }
133
+ }
134
+ }
135
+
136
+ /** The registry's natural peer — the router-session owner (ONE poller serves all
137
+ * bots; it runs under the human peer's telegram runtime). */
138
+ function findRouterHuman(env: NodeJS.ProcessEnv): string | null {
139
+ try {
140
+ const naturals = readPeersIndex({ env }).peers.filter(
141
+ p => normalizeIntelligenceValue(p.intelligence) === 'natural' && (p.runtime === 'telegram' || p.runtimes.includes('telegram')),
142
+ )
143
+ return naturals[0]?.personality ?? null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<ConnectTelegramResult> {
150
+ const env = opts.env ?? process.env
151
+ const peer = opts.peer.trim()
152
+
153
+ // Precondition (owner's fact): `interface bot` merges into the peer's profile —
154
+ // the peer must exist in the registry first.
155
+ if (!findPeer(readPeersIndex({ env }), peer)) {
156
+ return { state: 'unregistered-peer', peer, detail: `peer "${peer}" is not registered — create it first (iapeer create ${peer})` }
157
+ }
158
+
159
+ const tgBin = resolveTgBin(env)
160
+ if (!tgBin) {
161
+ return { state: 'runtime-missing', peer, detail: 'telegram runtime is not installed — run: iapeer install-runtime telegram' }
162
+ }
163
+
164
+ // The ONE human-owed fact: the bot token.
165
+ let token = opts.token?.trim()
166
+ if (!token) {
167
+ const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
168
+ if (!tty) {
169
+ return { state: 'refused-no-token', peer, detail: 'no tty and no --token — re-run with --token <bot-token> (create one: @BotFather → /newbot)' }
170
+ }
171
+ const ask = opts.ask ?? ttyAsk
172
+ token = (await ask(`bot token for "${peer}" (create: message @BotFather → /newbot → copy the token): `)).trim()
173
+ if (!token) return { state: 'refused-no-token', peer, detail: 'no answer — nothing connected' }
174
+ }
175
+
176
+ const runTg = opts.runTg ?? defaultRunTg(tgBin)
177
+ const alias = peer // bot alias = the peer's personality (design §(в); passes the owner's NAME_RE)
178
+ const before = readBotEnv(alias, env)
179
+
180
+ // (1) bot add — token → bots/<alias>/.env. Owner adds getMe validation here: an
181
+ // invalid token fails EARLY with the platform's reason; we surface it verbatim.
182
+ const add = runTg(['bot', 'add', alias, '--token', token], env)
183
+ if (add.status !== 0) {
184
+ return { state: 'bot-add-failed', peer, detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
185
+ }
186
+ const username = add.stdout.match(/@[A-Za-z0-9_]{3,}/)?.[0]
187
+
188
+ // (2) interface bot — merge the channel binding into the peer's profile.
189
+ const iface = runTg(['interface', 'bot', alias, '--peer', peer], env)
190
+ if (iface.status !== 0) {
191
+ return { state: 'interface-failed', peer, username, detail: (iface.stderr || iface.stdout || `exit ${iface.status}`).trim() }
192
+ }
193
+
194
+ // (3) Idempotency gate: the SAME token leaves bots/<alias>/.env byte-stable →
195
+ // the live poller already loaded these credentials → NO restart, clean no-op.
196
+ const after = readBotEnv(alias, env)
197
+ if (before !== null && after === before) {
198
+ return { state: 'noop-same-token', peer, username, detail: 'same token — byte-stable no-op, router not restarted' }
199
+ }
200
+
201
+ // (4) MANDATORY router restart (new/changed credentials load only at start).
202
+ // Cost: a seconds-long delivery blip for the whole fleet; inbound is NOT lost
203
+ // (long-polling offset, Telegram holds up to 24 h) — known, accepted (design).
204
+ const human = findRouterHuman(env)
205
+ const restart: RestartOutcome = human
206
+ ? opts.restart
207
+ ? opts.restart(human, env)
208
+ : await defaultRestart(human, env)
209
+ : { state: 'no-router', detail: 'no natural telegram peer in the registry — start the router manually after onboarding the human' }
210
+
211
+ return { state: 'connected', peer, username, restart }
212
+ }
@@ -55,7 +55,7 @@ function runtimeBin(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): string {
55
55
  return env.IAPEER_CODEX_BIN?.trim() || 'codex'
56
56
  }
57
57
 
58
- function isExecutable(binOrName: string): boolean {
58
+ function isExecutable(binOrName: string, env: NodeJS.ProcessEnv = process.env): boolean {
59
59
  if (binOrName.includes('/')) {
60
60
  try {
61
61
  accessSync(binOrName, FS.X_OK)
@@ -64,9 +64,22 @@ function isExecutable(binOrName: string): boolean {
64
64
  return false
65
65
  }
66
66
  }
67
- // bare name → resolved by spawnSync against PATH; probe with `which`-free spawn
68
- const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore' })
69
- return r.error === undefined && r.status !== null
67
+ // bare name → PRESENCE probe over PATH (`command -v` semantics), NO spawn.
68
+ // History (both live finds 10.06): the original `--version` ANSWER probe HANGS
69
+ // FOREVER for codex in a non-tty (three stray probes sat 25+ min); the 10 s
70
+ // timeout that replaced it then DEGRADED a LIVE codex to 'runtime-missing' —
71
+ // masking a working runtime (boris's catch). The skip-decision only asks "is
72
+ // the runtime installed", and presence answers that without executing anything.
73
+ for (const dir of (env.PATH ?? '').split(':')) {
74
+ if (!dir) continue
75
+ try {
76
+ accessSync(join(dir, binOrName), FS.X_OK)
77
+ return true
78
+ } catch {
79
+ /* not in this PATH segment */
80
+ }
81
+ }
82
+ return false
70
83
  }
71
84
 
72
85
  /**
@@ -77,7 +90,11 @@ function isExecutable(binOrName: string): boolean {
77
90
  */
78
91
  export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.ProcessEnv = process.env): boolean {
79
92
  const bin = runtimeBin(runtime, env)
80
- const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8' })
93
+ // HARD TIMEOUT the codex CLI hangs FOREVER in a non-tty on ANY subcommand
94
+ // (live 10.06: first `--version`, then `plugin marketplace list` after the
95
+ // presence-probe fix let a live codex through). Timeout → status null →
96
+ // "not registered" → the add (also time-bounded) decides; never a wedge.
97
+ const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8', timeout: 60_000 })
81
98
  if (r.status !== 0) return false
82
99
  return isAgfpdInList(`${r.stdout ?? ''}`)
83
100
  }
@@ -98,8 +115,12 @@ export function isAgfpdInList(listOutput: string): boolean {
98
115
  /** Register OUR marketplace for this runtime (`<runtime> plugin marketplace add <ref>`). */
99
116
  function registerMarketplace(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): { ok: boolean; detail?: string } {
100
117
  const bin = runtimeBin(runtime, env)
101
- const r = spawnSync(bin, ['plugin', 'marketplace', 'add', MARKETPLACE_REF], { encoding: 'utf8' })
102
- return r.status === 0 ? { ok: true } : { ok: false, detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
118
+ // Same hard timeout as the list probe (codex non-tty hang class) — a wedged add
119
+ // degrades to a loud 'failed' line instead of freezing the host phase.
120
+ const r = spawnSync(bin, ['plugin', 'marketplace', 'add', MARKETPLACE_REF], { encoding: 'utf8', timeout: 120_000 })
121
+ return r.status === 0
122
+ ? { ok: true }
123
+ : { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (non-tty hang?)' : `exit ${r.status}`) }
103
124
  }
104
125
 
105
126
  /**
@@ -115,7 +136,7 @@ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
115
136
  const runtimes = opts.runtimes ?? (['claude', 'codex'] as OnboardRuntime[])
116
137
  const marketplaces: OnboardRuntimeResult[] = []
117
138
  for (const runtime of runtimes) {
118
- if (!isExecutable(runtimeBin(runtime, env))) {
139
+ if (!isExecutable(runtimeBin(runtime, env), env)) {
119
140
  marketplaces.push({ runtime, state: 'runtime-missing' })
120
141
  continue
121
142
  }
@@ -51,6 +51,10 @@ export interface RuntimePeerDecl {
51
51
  export interface RuntimeManifest {
52
52
  /** The runtime id this manifest describes (must match the folder it lives in). */
53
53
  runtime: Runtime
54
+ /** OPTIONAL installed package version (the owners' self-install stamp — the
55
+ * telegram owner's 10.06 obligation). `update-runtime` version-gates on it;
56
+ * absent → no gate, the update re-installs idempotently. */
57
+ version?: string
54
58
  /** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
55
59
  selfConfig?: SelfConfigDescriptor
56
60
  /** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
@@ -108,7 +112,8 @@ function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
108
112
  ]
109
113
  })
110
114
  }
111
- return { runtime: declaredRuntime, ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
115
+ const version = typeof obj.version === 'string' && obj.version.trim() ? obj.version.trim() : undefined
116
+ return { runtime: declaredRuntime, ...(version ? { version } : {}), ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
112
117
  }
113
118
 
114
119
  /** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
@@ -0,0 +1,192 @@
1
+ // update-runtime (§(г)) — version-gate / forced re-install / idempotent re-provision /
2
+ // peer restart. Sandboxed (IAPEER_ROOT temp dirs, IAPEER_TEST_SANDBOX), injected
3
+ // npx + npm-version + restart.
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test'
6
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { tmpdir } from 'os'
8
+ import { join } from 'path'
9
+ import { updateAllRuntimes, updateRuntime, type RestartedPeer } from './update.ts'
10
+ import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
11
+ import { upsertPeer } from '../registry/index.ts'
12
+
13
+ const roots: string[] = []
14
+ function mkTmp(): string {
15
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-rtup-'))
16
+ roots.push(d)
17
+ return d
18
+ }
19
+ afterEach(() => {
20
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ function envFor(root: string, path?: string): NodeJS.ProcessEnv {
24
+ return {
25
+ IAPEER_ROOT: join(root, 'iapeer'),
26
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
27
+ IAPEER_TEST_SANDBOX: '1',
28
+ HOME: root,
29
+ ...(path ? { PATH: path } : {}),
30
+ } as NodeJS.ProcessEnv
31
+ }
32
+
33
+ function stubBins(): { dir: string; hook: string } {
34
+ const dir = mkTmp()
35
+ writeFileSync(join(dir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
36
+ const hook = join(dir, 'sc.sh')
37
+ writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
38
+ return { dir, hook }
39
+ }
40
+
41
+ const restartOk =
42
+ (log: string[]) =>
43
+ (personality: string): RestartedPeer => {
44
+ log.push(personality)
45
+ return { personality, state: 'restarted' }
46
+ }
47
+
48
+ describe('updateRuntime (§(г): gate → re-install → re-provision → restart)', () => {
49
+ test('full update: re-npx forced, declared set re-provisioned, peers restarted', async () => {
50
+ const root = mkTmp()
51
+ const { dir, hook } = stubBins()
52
+ const env = envFor(root, dir)
53
+ writeRuntimeManifest(
54
+ { runtime: 'notifier', version: '0.1.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
55
+ { env },
56
+ )
57
+ // the live-host layout: the declared peer already sits in its DEFAULT location
58
+ // (re-provision must be a no-clobber pass over it, not an identity conflict)
59
+ await upsertPeer(
60
+ { personality: 'timer', runtime: 'notifier', cwd: join(root, 'iapeer', 'peers', 'timer'), intelligence: 'absent' },
61
+ { env },
62
+ )
63
+ let npxRan = false
64
+ const restarts: string[] = []
65
+ const r = await updateRuntime({
66
+ runtime: 'notifier',
67
+ env,
68
+ runNpx: (_pkg, e) => {
69
+ npxRan = true
70
+ // the package self-deploys the NEW manifest with the new version stamp
71
+ const m: RuntimeManifest = { runtime: 'notifier', version: '0.2.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] }
72
+ writeRuntimeManifest(m, { env: e })
73
+ return { ok: true }
74
+ },
75
+ npmVersion: () => '0.2.0',
76
+ restartPeer: restartOk(restarts),
77
+ })
78
+ expect(r.state).toBe('updated')
79
+ expect(npxRan).toBe(true) // FORCED re-npx despite the present manifest
80
+ expect(r.from).toBe('0.1.0')
81
+ expect(r.to).toBe('0.2.0')
82
+ expect(r.peers.map(p => p.personality)).toEqual(['timer']) // re-provisioned
83
+ expect(restarts).toEqual(['timer']) // restarted via the injected stop/start
84
+ expect(readRuntimeManifest('notifier', { env })?.version).toBe('0.2.0')
85
+ })
86
+
87
+ test('version-gate: manifest stamp equals npm latest → already-latest, NOTHING runs', async () => {
88
+ const env = envFor(mkTmp())
89
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0' }, { env })
90
+ let npxRan = false
91
+ const r = await updateRuntime({ runtime: 'notifier', env, runNpx: () => ((npxRan = true), { ok: true }), npmVersion: () => '0.2.0' })
92
+ expect(r.state).toBe('already-latest')
93
+ expect(npxRan).toBe(false)
94
+ })
95
+
96
+ test('--force overrides the gate', async () => {
97
+ const root = mkTmp()
98
+ const { dir, hook } = stubBins()
99
+ const env = envFor(root, dir)
100
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0', selfConfig: hook }, { env })
101
+ let npxRan = false
102
+ const r = await updateRuntime({
103
+ runtime: 'notifier',
104
+ env,
105
+ force: true,
106
+ runNpx: () => ((npxRan = true), { ok: true }),
107
+ npmVersion: () => '0.2.0',
108
+ restartPeer: restartOk([]),
109
+ })
110
+ expect(r.state).toBe('updated')
111
+ expect(npxRan).toBe(true)
112
+ })
113
+
114
+ test('no version stamp → gate skipped, idempotent re-install with an honest detail', async () => {
115
+ const root = mkTmp()
116
+ const { dir, hook } = stubBins()
117
+ const env = envFor(root, dir)
118
+ writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env }) // owners have not stamped yet
119
+ const r = await updateRuntime({
120
+ runtime: 'notifier',
121
+ env,
122
+ runNpx: () => ({ ok: true }),
123
+ npmVersion: () => '0.2.0',
124
+ restartPeer: restartOk([]),
125
+ })
126
+ expect(r.state).toBe('updated')
127
+ expect(r.detail).toContain('no version stamp')
128
+ })
129
+
130
+ test('not installed → not-installed with the install-runtime recipe; npm unreachable → loud', async () => {
131
+ const env = envFor(mkTmp())
132
+ const r = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => '0.2.0' })
133
+ expect(r.state).toBe('not-installed')
134
+ expect(r.detail).toContain('install-runtime notifier')
135
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
136
+ const r2 = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => null })
137
+ expect(r2.state).toBe('npm-unreachable')
138
+ })
139
+
140
+ test('failed re-npx → install-failed, no deploy, no restarts', async () => {
141
+ const env = envFor(mkTmp())
142
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
143
+ const restarts: string[] = []
144
+ const r = await updateRuntime({
145
+ runtime: 'notifier',
146
+ env,
147
+ runNpx: () => ({ ok: false, detail: 'network down' }),
148
+ npmVersion: () => '0.2.0',
149
+ restartPeer: restartOk(restarts),
150
+ })
151
+ expect(r.state).toBe('install-failed')
152
+ expect(restarts).toEqual([])
153
+ })
154
+
155
+ test('mode-b runtime (telegram, no declared peers): empty re-provision, registered router restarted', async () => {
156
+ const root = mkTmp()
157
+ const env = envFor(root)
158
+ writeRuntimeManifest({ runtime: 'telegram', version: '0.10.0' }, { env })
159
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
160
+ const restarts: string[] = []
161
+ const r = await updateRuntime({
162
+ runtime: 'telegram',
163
+ env,
164
+ runNpx: (_pkg, e) => {
165
+ writeRuntimeManifest({ runtime: 'telegram', version: '0.10.3' }, { env: e })
166
+ return { ok: true }
167
+ },
168
+ npmVersion: () => '0.10.3',
169
+ restartPeer: restartOk(restarts),
170
+ })
171
+ expect(r.state).toBe('updated')
172
+ expect(r.peers).toEqual([]) // operator-add runtime: nothing declared to re-provision
173
+ expect(restarts).toEqual(['arthur']) // the router still restarts onto the new code
174
+ })
175
+ })
176
+
177
+ describe('updateAllRuntimes (--all)', () => {
178
+ test('updates installed runtimes, reports the rest not-installed (never an error)', async () => {
179
+ const root = mkTmp()
180
+ const env = envFor(root)
181
+ writeRuntimeManifest({ runtime: 'telegram', version: '1.0.0' }, { env })
182
+ const results = await updateAllRuntimes({
183
+ env,
184
+ runNpx: () => ({ ok: true }),
185
+ npmVersion: () => '1.0.0',
186
+ restartPeer: restartOk([]),
187
+ })
188
+ const byRt = Object.fromEntries(results.map(r => [r.runtime, r.state]))
189
+ expect(byRt.telegram).toBe('already-latest')
190
+ expect(byRt.notifier).toBe('not-installed')
191
+ })
192
+ })
@@ -0,0 +1,185 @@
1
+ // update-runtime — the runtime-package update story (design «Onboard костяка» §(г),
2
+ // FINAL 10.06; owner facts: NO state migrations on either package — notifier is
3
+ // stateless-by-design (registrations live durable in the OWNING peers' profiles),
4
+ // telegram's bots/.env is a stable ABI, its lock is transient self-healing).
5
+ //
6
+ // version-gate (npm) → re-install the package → IDEMPOTENT re-provision through
7
+ // the SAME path install-runtime uses (npx self-deploy + declared-set deploy:
8
+ // PEER_BLURB registry sync, re-self-config, manifest refresh; live peers are
9
+ // never clobbered — the notifier owner's point: without the re-provision a
10
+ // version with a new blurb/self-doc leaves stale descriptions) → restart the
11
+ // runtime's infra peers via the REGULAR stop/start verbs.
12
+ //
13
+ // The core's own `iapeer update` deliberately does NOT touch runtimes (foundation-
14
+ // only — the standing contract; the symmetry is conscious).
15
+ //
16
+ // Version-gate honesty: the installed version comes from the manifest's `version`
17
+ // stamp (the owners' self-install obligation, telegram 10.06). A manifest WITHOUT
18
+ // the stamp cannot be gated — the update proceeds idempotently and says so.
19
+
20
+ import { spawnSync } from 'child_process'
21
+ import { type Runtime } from '../core/constants.ts'
22
+ import { readPeersIndex } from '../registry/index.ts'
23
+ import { readRuntimeManifest } from './index.ts'
24
+ import {
25
+ deployRuntime,
26
+ installRuntimePackage,
27
+ resolveRuntimePackage,
28
+ RUNTIME_PACKAGES,
29
+ type DeployedPeer,
30
+ type NpxRunner,
31
+ } from './deploy.ts'
32
+
33
+ /** Injectable npm-version resolver (tests). Default: `npm view <pkg> version`. */
34
+ export type NpmVersionFn = (pkg: string, env: NodeJS.ProcessEnv) => string | null
35
+
36
+ const defaultNpmVersion: NpmVersionFn = (pkg, env) => {
37
+ const r = spawnSync('npm', ['view', pkg, 'version'], { encoding: 'utf8', env: env as Record<string, string>, timeout: 60_000 })
38
+ const v = (r.stdout ?? '').trim()
39
+ return r.status === 0 && v ? v : null
40
+ }
41
+
42
+ export interface RestartedPeer {
43
+ personality: string
44
+ state: 'restarted' | 'refused-foreign-launchd' | 'failed'
45
+ detail?: string
46
+ }
47
+
48
+ export interface UpdateRuntimeResult {
49
+ runtime: Runtime
50
+ package?: string
51
+ state:
52
+ | 'updated' // re-installed + re-provisioned + peers restarted
53
+ | 'already-latest' // version-gate: the manifest stamp equals npm latest
54
+ | 'not-installed' // no manifest — nothing to update (install-runtime first)
55
+ | 'npm-unreachable' // npm view failed — cannot resolve the target version
56
+ | 'install-failed' // the forced re-npx failed — nothing was touched further
57
+ | 'deploy-failed' // re-provision broke (per-peer detail in `peers`)
58
+ from?: string
59
+ to?: string
60
+ peers: DeployedPeer[]
61
+ restarted: RestartedPeer[]
62
+ detail?: string
63
+ }
64
+
65
+ export interface UpdateRuntimeOptions {
66
+ runtime: Runtime
67
+ /** Re-install even when the version-gate says already-latest. */
68
+ force?: boolean
69
+ env?: NodeJS.ProcessEnv
70
+ runNpx?: NpxRunner
71
+ npmVersion?: NpmVersionFn
72
+ /** Injectable restart (tests). Default: the regular stop→start verbs, strictly
73
+ * sequential per peer. */
74
+ restartPeer?: (personality: string, runtime: Runtime, env: NodeJS.ProcessEnv) => RestartedPeer
75
+ warn?: (message: string) => void
76
+ }
77
+
78
+ /** Default per-peer restart: the REGULAR stop→start verbs (infra → launchctl
79
+ * bootout/bootstrap), strictly sequential. H4 stays intact: a PP-managed peer is
80
+ * refused by the verbs themselves and reported, never forced. */
81
+ async function defaultRestartPeer(personality: string, runtime: Runtime, env: NodeJS.ProcessEnv): Promise<RestartedPeer> {
82
+ const { stopPeer, startPeer } = await import('../cli/index.ts')
83
+ try {
84
+ const stops = stopPeer(personality, runtime, { env })
85
+ if (stops.some(o => o.action === 'refused-foreign-launchd')) {
86
+ return { personality, state: 'refused-foreign-launchd', detail: 'persistent-peer-managed (H4) — restart it yourself' }
87
+ }
88
+ const starts = startPeer(personality, runtime, { env })
89
+ const bad = starts.find(o => o.action === 'bootstrap' && o.reason)
90
+ return bad ? { personality, state: 'failed', detail: bad.reason } : { personality, state: 'restarted' }
91
+ } catch (e) {
92
+ return { personality, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
93
+ }
94
+ }
95
+
96
+ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateRuntimeResult> {
97
+ const env = opts.env ?? process.env
98
+ const runtime = opts.runtime
99
+ const manifest = readRuntimeManifest(runtime, { env })
100
+ if (!manifest) {
101
+ return {
102
+ runtime,
103
+ state: 'not-installed',
104
+ peers: [],
105
+ restarted: [],
106
+ detail: `no manifest for "${runtime}" — install first: iapeer install-runtime ${runtime}`,
107
+ }
108
+ }
109
+ const pkg = resolveRuntimePackage(runtime)
110
+ if (!pkg) {
111
+ return { runtime, state: 'not-installed', peers: [], restarted: [], detail: `no package mapping for "${runtime}"` }
112
+ }
113
+
114
+ // Version-gate: npm latest vs the manifest stamp. No stamp → no gate (proceed
115
+ // idempotently — the owners' stamp obligation closes this once shipped).
116
+ const latest = (opts.npmVersion ?? defaultNpmVersion)(pkg, env)
117
+ if (!latest) {
118
+ return { runtime, package: pkg, state: 'npm-unreachable', peers: [], restarted: [], detail: `npm view ${pkg} version failed — cannot resolve the target` }
119
+ }
120
+ const installed = manifest.version
121
+ if (installed && installed === latest && !opts.force) {
122
+ return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
123
+ }
124
+
125
+ // Re-install (forced npx — the package self-deploys its new bin + manifest).
126
+ const install = installRuntimePackage({ runtime, force: true, env, runNpx: opts.runNpx })
127
+ if (install.state !== 'ran') {
128
+ return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
129
+ }
130
+
131
+ // IDEMPOTENT re-provision via the SAME deploy path install-runtime uses: blurb
132
+ // sync, re-self-config, no-clobber on live peers. Mode-b (telegram) declares no
133
+ // peers — the deploy is an empty pass, by design.
134
+ let peers: DeployedPeer[]
135
+ try {
136
+ const d = await deployRuntime({ runtime, env, warn: opts.warn })
137
+ peers = d.peers
138
+ } catch (e) {
139
+ return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
140
+ }
141
+ if (peers.some(p => p.selfConfig === 'failed' || p.bootstrap === 'failed')) {
142
+ return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers, restarted: [], detail: 'a declared peer failed re-provision — see the per-peer lines' }
143
+ }
144
+
145
+ // Restart THIS runtime's registered peers so the new baked code runs (notifier
146
+ // semantics per the owner: cron wall-clock unaffected, @every re-anchors,
147
+ // watcher children relaunch, heartbeat windows reset). Strictly sequential.
148
+ const registered = readPeersIndex({ env }).peers.filter(
149
+ p => p.runtime === runtime || p.runtimes.includes(runtime),
150
+ )
151
+ const restarted: RestartedPeer[] = []
152
+ for (const p of registered) {
153
+ restarted.push(
154
+ opts.restartPeer
155
+ ? opts.restartPeer(p.personality, runtime, env)
156
+ : await defaultRestartPeer(p.personality, runtime, env),
157
+ )
158
+ }
159
+
160
+ return {
161
+ runtime,
162
+ package: pkg,
163
+ state: 'updated',
164
+ from: installed,
165
+ to: latest,
166
+ peers,
167
+ restarted,
168
+ detail: installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
169
+ }
170
+ }
171
+
172
+ /** `--all`: update every KNOWN runtime that is actually installed (manifest present);
173
+ * the rest are reported as not-installed, never an error. */
174
+ export async function updateAllRuntimes(opts: Omit<UpdateRuntimeOptions, 'runtime'> = {}): Promise<UpdateRuntimeResult[]> {
175
+ const env = opts.env ?? process.env
176
+ const out: UpdateRuntimeResult[] = []
177
+ for (const rt of Object.keys(RUNTIME_PACKAGES) as Runtime[]) {
178
+ if (!readRuntimeManifest(rt, { env })) {
179
+ out.push({ runtime: rt, state: 'not-installed', peers: [], restarted: [], detail: 'not installed — skipped' })
180
+ continue
181
+ }
182
+ out.push(await updateRuntime({ ...opts, runtime: rt }))
183
+ }
184
+ return out
185
+ }