@agfpd/iapeer 0.2.15 → 0.2.17

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.15",
3
+ "version": "0.2.17",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,12 +4,12 @@
4
4
  // persistent-peer launchd plist must be refused.
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
8
  import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
- import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
12
+ import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
14
 
15
15
  let root: string
@@ -73,6 +73,24 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
73
73
  test('stop an unregistered peer → throws', () => {
74
74
  expect(() => stopPeer('nobody', undefined, { env: env() })).toThrow(/not registered/)
75
75
  })
76
+ test('stop is a CLEAN PARK: leaves the resume marker and drops the session-state (boris 10.06)', async () => {
77
+ await register('parked')
78
+ const e = env()
79
+ const cfg = loadLifecycleConfig(e)
80
+ // a live session-state the daemon would otherwise tag as a death post-kill
81
+ mkdirSync(cfg.stateDir, { recursive: true })
82
+ writeFileSync(
83
+ join(cfg.stateDir, 'claude-parked.session'),
84
+ JSON.stringify({ identity: 'claude-parked', runtime: 'claude', personality: 'parked', cwd: '/tmp/parked', wokeAt: Date.now() }),
85
+ )
86
+ stopPeer('parked', undefined, { env: e })
87
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true) // park marker → post-start wake RESUMES
88
+ expect(existsSync(join(cfg.stateDir, 'claude-parked.session'))).toBe(false) // not a death for supervise
89
+ // start clears only the stop flag — the park marker survives for the wake
90
+ startPeer('parked', undefined, { env: e })
91
+ expect(isStopped(cfg, 'claude-parked')).toBe(false)
92
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
93
+ })
76
94
  })
77
95
 
78
96
  describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
package/src/cli/index.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isStopped,
34
34
  killSession,
35
35
  loadLifecycleConfig,
36
+ removeSessionState,
37
+ setIdleReaped,
36
38
  setNewEager,
37
39
  setStopped,
38
40
  wakeOrSpawn,
@@ -196,8 +198,15 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
196
198
  killSession(sock, identity)
197
199
  out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
198
200
  } else {
201
+ // A deliberate stop is a CLEAN PARK, not a death (boris 10.06 — stop→start
202
+ // must survive ≥ idle-reap): park-mark BEFORE the kill so the post-`start`
203
+ // wake RESUMES, and drop the supervise session-state with the session so
204
+ // the tick never tags this kill as a death (crash-loop ring stays clean,
205
+ // no reaped-gone death class for a state the daemon knows 100%).
199
206
  setStopped(cfg, identity)
207
+ setIdleReaped(cfg, identity)
200
208
  killSession(sock, identity)
209
+ removeSessionState(cfg, identity)
201
210
  out.push({ personality, runtime: rt, action: 'stopped' })
202
211
  }
203
212
  }
@@ -363,6 +372,7 @@ const USAGE = `usage: iapeer <verb> [args]
363
372
  backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
364
373
  status host snapshot: version, daemon health, memory slot (<provider> | none)
365
374
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
375
+ update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
366
376
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
367
377
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
368
378
  list [--json] registered peers + per-runtime liveness
@@ -371,6 +381,7 @@ const USAGE = `usage: iapeer <verb> [args]
371
381
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
372
382
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
373
383
  <runtime> launch the cwd's peer (ALWAYS fresh)
384
+ connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
374
385
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
375
386
  attach <peer> [runtime] ensure-live + resume, then tmux attach
376
387
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
@@ -543,6 +554,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
543
554
  out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
544
555
  return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
545
556
  }
557
+ case 'update-runtime': {
558
+ // §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
559
+ // forced re-npx → idempotent re-provision (same path as install-runtime) →
560
+ // restart the runtime's peers via the regular stop/start. The core's own
561
+ // `update` stays foundation-only — this is the runtimes' counterpart.
562
+ const all = flags.all === true
563
+ if (!all && !positionals[0]) return usage(errOut)
564
+ const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
565
+ const results = all
566
+ ? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
567
+ : [await updateRuntime({ runtime: positionals[0] as Runtime, force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })]
568
+ let failed = false
569
+ for (const r of results) {
570
+ const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
571
+ out(`${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
572
+ for (const p of r.peers) out(` re-provisioned ${p.personality}: self-config ${p.selfConfig ?? 'n/a'}\n`)
573
+ for (const p of r.restarted) out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
574
+ if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
575
+ if (r.restarted.some(p => p.state === 'failed')) failed = true
576
+ if (!all && r.state === 'not-installed') failed = true
577
+ }
578
+ return failed ? 1 : 0
579
+ }
546
580
  case 'init': {
547
581
  // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
548
582
  // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
@@ -830,6 +864,36 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
830
864
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
831
865
  return 0
832
866
  }
867
+ case 'connect': {
868
+ // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
869
+ // `connect telegram <peer> [--token <t>]`. The human owes only the token;
870
+ // alias/bot-add/interface/router-restart are resolved by the system. The
871
+ // FIRST message from the human to the bot activates the chat (platform rule).
872
+ if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
873
+ const { connectTelegram } = await import('../connect/index.ts')
874
+ const r = await connectTelegram({
875
+ peer: positionals[1],
876
+ token: typeof flags.token === 'string' ? flags.token : undefined,
877
+ env,
878
+ })
879
+ if (r.state === 'noop-same-token') {
880
+ out(`connect telegram ${r.peer}: ${r.detail}\n`)
881
+ return 0
882
+ }
883
+ if (r.state !== 'connected') {
884
+ errOut(`connect telegram ${r.peer}: ${r.state}${r.detail ? ` — ${r.detail}` : ''}\n`)
885
+ return 1
886
+ }
887
+ const rs = r.restart!
888
+ out(`bot ${r.username ?? `for "${r.peer}"`} added + interfaced to "${r.peer}"\n`)
889
+ out(
890
+ rs.state === 'restarted'
891
+ ? `router restarted — credentials loaded\n`
892
+ : `router restart ${rs.state}${rs.detail ? ` — ${rs.detail}` : ''} (the channel stays dead until the router restarts)\n`,
893
+ )
894
+ out(`activation: send the bot ${r.username ?? '(see @BotFather)'} its FIRST message — Telegram does not let a bot start the chat\n`)
895
+ return rs.state === 'restarted' ? 0 : 1
896
+ }
833
897
  case 'enable': {
834
898
  // Per-peer capability install (contract Установка §3): install <plugin>@agfpd
835
899
  // 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
+ }
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
148
148
  }
149
149
  }
150
150
 
151
- function removeSessionState(cfg: LifecycleConfig, identity: string): void {
151
+ /** Drop the supervise session-state. Exported for the STOP verb: a deliberate stop
152
+ * removes the state with the session so superviseTick never tags the kill as a
153
+ * death (no crash-loop entry, no reaped-gone death class). */
154
+ export function removeSessionState(cfg: LifecycleConfig, identity: string): void {
152
155
  try {
153
156
  rmSync(sessionStatePath(cfg, identity), { force: true })
154
157
  } catch {
@@ -198,10 +201,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
198
201
  // Lifecycle markers — the DAEMON decides fresh-vs-resume by the DEATH CAUSE it
199
202
  // tracks itself (TARGET redesign). Plain files in state/lifecycle/<identity>.* :
200
203
  //
201
- // .idle-reaped : written ONLY when the daemon idle-reaps the session (the only
202
- // death the daemon initiates). Presence on the next wake = session was parked
203
- // cleanly = RESUME-eligible. ABSENT on a dead session = it died on its own
204
- // (crash / self-close) = FRESH. (resolver branch 3.)
204
+ // .idle-reaped : the CLEAN-PARK marker — written when the daemon idle-reaps the
205
+ // session AND by the deliberate `stop` verb (both are daemon/operator-initiated
206
+ // parks, not faults; boris 10.06: stop→start must survive idle-reap).
207
+ // Presence on the next wake = session was parked cleanly = RESUME-eligible.
208
+ // ABSENT on a dead session = it died on its own (crash / self-close) = FRESH.
209
+ // (resolver branch 3.)
205
210
  // .new-eager : set when /new is invoked (owner reset, via `iapeer self-fresh`).
206
211
  // Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
207
212
  // wait for a message) and injects initial_prompt. Consumed on the relaunch.
@@ -230,7 +235,9 @@ export function hasIdleReaped(cfg: LifecycleConfig, identity: string): boolean {
230
235
  return existsSync(idleReapedPath(cfg, identity))
231
236
  }
232
237
 
233
- /** Write the idle-reaped marker ONLY the idle-reap path in superviseTick does this. */
238
+ /** Write the clean-park marker. Three writers, all deliberate parks: the idle-reap
239
+ * path in superviseTick, the `stop` verb (park before kill), and superviseTick's
240
+ * stopped-catch-up branch (a stop that raced the tick). Never a crash path. */
234
241
  export function setIdleReaped(cfg: LifecycleConfig, identity: string): void {
235
242
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
236
243
  writeFileSync(idleReapedPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
@@ -462,8 +469,15 @@ export function resolveWakeMode(
462
469
  clearIdleReaped(cfg, identity)
463
470
  return { resume: false, cause: 'ephemeral-policy' }
464
471
  }
465
- // 3a. NOT idle-reapedit died on its own (crash / self-close) clean FRESH.
466
- if (!hasIdleReaped(cfg, identity)) return { resume: false, cause: 'crash-or-self-close' }
472
+ // 3a. NOT parked-cleanFRESH either way, but tell the two cases apart in the
473
+ // cause (boris finding, 10.06): a peer with NO transcript at all never ran here —
474
+ // that is its FIRST-ever wake, not a crash. (A peer whose transcripts were rotated
475
+ // away also reads first-wake — equally honest: there is nothing it could resume.)
476
+ if (!hasIdleReaped(cfg, identity)) {
477
+ return resolveResume(cwd).ok
478
+ ? { resume: false, cause: 'crash-or-self-close' }
479
+ : { resume: false, cause: 'first-wake' }
480
+ }
467
481
  // 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
468
482
  clearIdleReaped(cfg, identity)
469
483
  // human-conversational dialogue never auto-freshes; only an explicit /new resets it.
@@ -967,7 +981,7 @@ export function killSession(sock: string, identity: string): void {
967
981
 
968
982
  export interface SuperviseOutcome {
969
983
  identity: string
970
- action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'alive' | 'needs-eager-fresh'
984
+ action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
971
985
  reason?: string
972
986
  /** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
973
987
  * carrying a .new-eager mark). The daemon timer drives the async relaunch.
@@ -1002,6 +1016,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1002
1016
  }
1003
1017
  const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
1004
1018
  if (!sessionAlive(sock, s.identity)) {
1019
+ // A DELIBERATE stop is not a death (boris 10.06): the stop verb parks clean
1020
+ // (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
1021
+ // a stop that raced this tick (or a pre-fix stop): drop the state quietly —
1022
+ // no crash-loop entry, no death class — and ensure the clean-park marker so
1023
+ // the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
1024
+ if (isStopped(cfg, s.identity)) {
1025
+ removeSessionState(cfg, s.identity)
1026
+ clearEphemeralArmed(cfg, s.identity)
1027
+ setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
1028
+ out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
1029
+ trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
1030
+ continue
1031
+ }
1005
1032
  // A dead session: record a death for crash-loop accounting, then branch on the
1006
1033
  // .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
1007
1034
  // the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
@@ -218,6 +218,24 @@ describe('superviseTick H4 guard', () => {
218
218
  expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
219
219
  })
220
220
 
221
+ test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
222
+ // boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
223
+ // death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
224
+ // stop is a clean park the daemon knows 100% — never a death.
225
+ const c = cfg()
226
+ const id = writeState('iapeer-supstopped')
227
+ setStopped(c, id)
228
+ const out = superviseTick(c, { env: env(), nowMs: Date.now() })
229
+ const o = out.find(x => x.identity === id)
230
+ expect(o?.action).toBe('skipped-stopped')
231
+ expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
232
+ expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
233
+ expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
234
+ const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
235
+ expect(logged).toContain(`identity=${id} action=skipped-stopped`)
236
+ expect(logged).not.toContain('death=') // no death class for a deliberate stop
237
+ })
238
+
221
239
  test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
222
240
  const c = cfg()
223
241
  const id = writeState('iapeer-supgone') // no plist, no live tmux session
@@ -433,6 +451,19 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
433
451
  expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
434
452
  })
435
453
 
454
+ test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
455
+ // boris finding (10.06): the first-ever wake of a freshly created peer used to
456
+ // read crash-or-self-close — a mis-classification. No transcript = never ran.
457
+ expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
458
+ })
459
+
460
+ // ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
461
+ test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
462
+ const c = cfg()
463
+ setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
464
+ expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
465
+ })
466
+
436
467
  // ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
437
468
  test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
438
469
  const c = cfg()
@@ -64,8 +64,12 @@ 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' })
67
+ // bare name → resolved by spawnSync against PATH; probe with `which`-free spawn.
68
+ // HARD TIMEOUT (live find 10.06): `codex --version` HANGS FOREVER in a non-tty
69
+ // environment (three stray probes sat 25+ min; an onboard --dry-run piped to a
70
+ // file never printed a byte). A hung probe must degrade to 'runtime-missing',
71
+ // not wedge the whole onboard.
72
+ const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore', timeout: 10_000 })
69
73
  return r.error === undefined && r.status !== null
70
74
  }
71
75
 
@@ -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
+ }