@agfpd/iapeer 0.4.86 → 0.4.88

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.
@@ -43,6 +43,8 @@ These are long-lived router processes, not AI. They run continuously under launc
43
43
 
44
44
  Infrastructure peers' plists, unlike the daemon's plist, are **loaded automatically** on `iapeer create --runtime telegram|notifier` and on `iapeer onboard` (unless `--no-bootstrap` is passed). So no separate `launchctl bootstrap` is needed for them.
45
45
 
46
+ **Plist scheme (multi-infra).** One personality can carry several always-on channels (e.g. a human peer with both a Telegram bridge and a web console). Each (personality, runtime) pair resolves to its own launchd label: a **legacy base plist** `com.iapeer.<personality>` stays the channel of the runtime it already holds (no forced migration — existing single-channel hosts keep their plists untouched), while every new install gets the **per-runtime plist** `com.iapeer.<personality>.<runtime>`. Both channels are held by launchd KeepAlive simultaneously and independently; `stop`/`start <peer> <runtime>` bootout/bootstrap exactly that runtime's label, `remove` tears down **all** the personality's plists. The suffixed label can't collide with anything: peer names can't contain dots.
47
+
46
48
  ## Several runtimes on a peer
47
49
 
48
50
  A peer profile has two runtime fields: `runtimes` (all the peer can be present on) and `default_runtime` (the primary — where messages route and which comes up first). Extending the options and switching the primary runtime are different commands.
@@ -43,6 +43,8 @@ iapeer codex # в Codex
43
43
 
44
44
  Плисты инфраструктурных пиров, в отличие от плиста демона, **загружаются автоматически** при `iapeer create --runtime telegram|notifier` и при `iapeer onboard` (если не передан `--no-bootstrap`). Поэтому отдельный `launchctl bootstrap` для них не нужен.
45
45
 
46
+ **Схема плистов (мульти-инфра).** Одна personality может нести несколько always-on каналов (например, человек с Telegram-мостом и веб-консолью одновременно). Каждая пара (personality, runtime) резолвится в собственный launchd-label: **легаси базовый плист** `com.iapeer.<personality>` остаётся каналом того рантайма, который он уже держит (без принудительной миграции — существующие одноканальные хосты не трогаются), а каждая новая установка получает **per-runtime плист** `com.iapeer.<personality>.<runtime>`. Оба канала держатся launchd KeepAlive одновременно и независимо; `stop`/`start <peer> <runtime>` делают bootout/bootstrap ровно label этого рантайма, `remove` сносит **все** плисты personality. Суффиксный label ни с чем не коллидирует: имена пиров не могут содержать точек.
47
+
46
48
  ## Несколько рантаймов у пира
47
49
 
48
50
  В профиле пира два поля про рантаймы: `runtimes` (все, на которых пир может присутствовать) и `default_runtime` (основной — куда маршрутизируются сообщения и какой поднимается первым). Расширить возможности и переключить основной рантайм — разные команды.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.86",
3
+ "version": "0.4.88",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -52,7 +52,12 @@ import {
52
52
  import { getAdapter } from '../launch/index.ts'
53
53
  import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
54
54
  import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
55
- import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
55
+ import {
56
+ cycleDaemon,
57
+ existingAlwaysOnPlists,
58
+ launchctlBootstrap,
59
+ resolveAlwaysOnTarget,
60
+ } from '../launch/launchd.ts'
56
61
  import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
57
62
  import { claudeProjectsRoot, transcriptSlug } from '../launch/adapters/claude.ts'
58
63
  import { peerProfilePath } from '../storage/index.ts'
@@ -206,9 +211,13 @@ function uid(): string {
206
211
  }
207
212
 
208
213
  /** FLEET GUARD: a peer launchd-managed by a NON-foundation plist (persistent-peer)
209
- * is off-limits to stop/start — the foundation is read-only for it (H4). */
214
+ * is off-limits to stop/start — the foundation is read-only for it (H4). Multi-infra
215
+ * (0.4.88): the peer is foreign iff ANY of its existing template plists (legacy base
216
+ * OR per-runtime suffixed) lacks the ownership sentinel. A personality whose only
217
+ * plists are our suffixed ones must NOT read as foreign (the pre-multi-infra check
218
+ * keyed on the base path alone, which is absent in that layout). */
210
219
  function isForeignLaunchd(personality: string, env: NodeJS.ProcessEnv): boolean {
211
- return isLaunchdManaged(personality, env) && !isFoundationOwnedPlist(launchdPlistPath(personality, env))
220
+ return existingAlwaysOnPlists(personality, env).some(p => !p.foundationOwned)
212
221
  }
213
222
 
214
223
  function targetRuntimes(peer: PeerRecord, runtime: string | undefined): Runtime[] {
@@ -249,7 +258,13 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
249
258
  // stop: the desired state ("not running") already holds, so it carries NO reason
250
259
  // and must not fail the verb. Any other non-zero is a REAL error and sets reason
251
260
  // (the CLI maps a reasoned bootout to exit 1).
252
- const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
261
+ // Multi-infra: bootout the label that ACTUALLY holds this (personality, runtime)
262
+ // channel — the legacy base or the per-runtime suffixed one (resolveAlwaysOnTarget,
263
+ // the same authority install uses). A runtime with no plist at all (manually-run
264
+ // session) resolves to its suffixed label → bootout exit 3 (benign) → killSession
265
+ // below still tears the session down.
266
+ const target = resolveAlwaysOnTarget(personality, rt, env)
267
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${target.label}`], { encoding: 'utf8' })
253
268
  killSession(sock, identity, env)
254
269
  const stderrText = (r.stderr ?? '').trim()
255
270
  const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
@@ -295,7 +310,9 @@ export function startPeer(personality: string, runtime: string | undefined, opts
295
310
  for (const rt of targetRuntimes(peer, runtime)) {
296
311
  const identity = buildProcessAddress(rt, personality)
297
312
  if (isInfraRuntime(rt)) {
298
- const plist = launchdPlistPath(personality, env)
313
+ // Multi-infra: load the plist that holds THIS (personality, runtime) channel
314
+ // (legacy base or per-runtime suffixed — the same resolution install/stop use).
315
+ const plist = resolveAlwaysOnTarget(personality, rt, env).path
299
316
  // UNDEAD-JOB-SAFE start: a bootstrap right after a bootout used to hit the
300
317
  // still-dismantling job (exit 5 I/O
301
318
  // error) and leave the router DOWN. launchctlBootstrap now waits for the
@@ -552,7 +569,7 @@ export async function newPeer(
552
569
  personality,
553
570
  runtime: rt,
554
571
  action: 'refused-infra',
555
- reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${launchdLabel(personality)}`,
572
+ reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${resolveAlwaysOnTarget(personality, rt, env).label}`,
556
573
  }
557
574
  }
558
575
  const identity = buildProcessAddress(rt, personality)
@@ -785,25 +802,33 @@ export async function removePeerCli(
785
802
  }
786
803
  plistTeardown = 'skipped-foreign (H4 — foreign plist left intact; --force dropped only the registry record)'
787
804
  } else {
788
- const plistPath = launchdPlistPath(personality, env)
789
- // Sandbox/test: never invoke real launchctl (a test peer's label isn't a real job);
790
- // still remove the (temp-dir) plist file so the teardown is observable.
791
- let bootoutNote: string
792
- if (env.IAPEER_TEST_SANDBOX === '1') {
793
- bootoutNote = 'skipped-sandbox'
794
- } else {
795
- const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
796
- // bootout exits non-zero when the job was already unloaded — benign for a teardown.
797
- bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
798
- }
799
- let rmNote: string
800
- try {
801
- rmSync(plistPath, { force: true })
802
- rmNote = ' + plist removed'
803
- } catch (e) {
804
- rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${plistPath} manually)`
805
+ // Multi-infra (0.4.88): tear down EVERY foundation plist of the personality —
806
+ // the legacy base AND each per-runtime suffixed one (a two-channel peer like
807
+ // arthur telegram+web holds two). A forgotten plist would leave launchd
808
+ // KeepAlive crash-looping run-infra against the deleted record (the exact
809
+ // "remove must dismantle ALL artifacts" class).
810
+ const notes: string[] = []
811
+ for (const entry of existingAlwaysOnPlists(personality, env)) {
812
+ // Sandbox/test: never invoke real launchctl (a test peer's label isn't a real
813
+ // job); still remove the (temp-dir) plist file so the teardown is observable.
814
+ let bootoutNote: string
815
+ if (env.IAPEER_TEST_SANDBOX === '1') {
816
+ bootoutNote = 'skipped-sandbox'
817
+ } else {
818
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${entry.label}`], { encoding: 'utf8' })
819
+ // bootout exits non-zero when the job was already unloaded — benign for a teardown.
820
+ bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
821
+ }
822
+ let rmNote: string
823
+ try {
824
+ rmSync(entry.path, { force: true })
825
+ rmNote = ' + plist removed'
826
+ } catch (e) {
827
+ rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${entry.path} manually)`
828
+ }
829
+ notes.push(`${entry.label}: ${bootoutNote}${rmNote}`)
805
830
  }
806
- plistTeardown = `${bootoutNote}${rmNote}`
831
+ plistTeardown = notes.join('; ')
807
832
  }
808
833
  }
809
834
  await removePeer(personality, { env })
@@ -1,10 +1,20 @@
1
- // IAP envelope codec — encoder + decoder, both halves in one module.
1
+ // IAP envelope codec — wire encoder + read-both decoder + agent-facing render.
2
2
  //
3
- // Encoder: inter-agent-protocol/src/lib/preamble.ts buildEnvelope (wins as-is).
4
- // Decoder: telegram-runtime/src/cli.ts parseIapEnvelope/extractIapEnvelopes,
5
- // rewritten CDATA-AWARE (blueprint-v2 codec-fixes) so that an envelope whose
6
- // message contains `</iap>`, `</message>`, `]]>` or a literal CR round-trips
7
- // by FIELD-semantic equivalence, not byte-equality.
3
+ // Three halves (envelope-compaction F, 0.4.86):
4
+ // buildEnvelope → the WIRE form (legacy attr names, always-CDATA, full-ISO ts) —
5
+ // what bridges parse and durable stores hold; never slims.
6
+ // decodeEnvelope → READ-BOTH (legacy + compact names, <message>/<msg>, ts),
7
+ // CDATA-AWARE so a body containing `</iap>`, `</message>`,
8
+ // `]]>` or a literal CR round-trips by FIELD-semantic
9
+ // equivalence, not byte-equality.
10
+ // renderEnvelopeForAgent → the compact presentation for LLM-context hops (bottom).
11
+ //
12
+ // Historic origin: encoder from inter-agent-protocol preamble.ts; decoder from
13
+ // telegram-runtime parseIapEnvelope/extractIapEnvelopes. The sibling runtimes run
14
+ // their own copies of the decoder, SYNCED to this canon 14.07.2026 (telegram-runtime
15
+ // 0.26.0, notifier-runtime 0.3.6, voicetalk-runtime 0.1.1 — anchored attrValue, В37/В38,
16
+ // read-both); a decoder-behavior change here should be mirrored there (or flagged to
17
+ // their implementers). The wire form stays compatible with all of them regardless.
8
18
  //
9
19
  // Deliberately NO CR→LF fold here. The old telegram decoder did
10
20
  // `xml.replace(/\r\n?/g, '\n')` to repair tmux paste (which rewrites LF→CR).
@@ -45,19 +55,20 @@ export interface EnvelopeInput {
45
55
  message: string
46
56
  }
47
57
 
58
+ const pad2 = (n: number): string => String(n).padStart(2, '0')
59
+
48
60
  /** Format a Date as ISO-8601 LOCAL time with numeric offset, second precision:
49
61
  * `2026-07-14T01:23:45+03:00`. Local (not UTC-Z) on purpose: the fleet's agents
50
62
  * compare envelope timestamps against their LOCAL statusline clock — a Z-form
51
63
  * invites silent off-by-offset misreads by an LLM reader. */
52
64
  export function formatSentAt(date: Date): string {
53
- const pad = (n: number): string => String(n).padStart(2, '0')
54
65
  const offMin = -date.getTimezoneOffset() // minutes EAST of UTC
55
66
  const sign = offMin >= 0 ? '+' : '-'
56
67
  const abs = Math.abs(offMin)
57
68
  return (
58
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
59
- `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
60
- `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
69
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` +
70
+ `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}` +
71
+ `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
61
72
  )
62
73
  }
63
74
 
@@ -351,11 +362,10 @@ function presentBody(value: string): string {
351
362
  function renderTsForAgent(sentAt: string, now: Date): string {
352
363
  const d = new Date(sentAt)
353
364
  if (Number.isNaN(d.getTime())) return sentAt
354
- const pad = (n: number): string => String(n).padStart(2, '0')
355
- const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
365
+ const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
356
366
  const sameLocalDay =
357
367
  d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
358
- return sameLocalDay ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
368
+ return sameLocalDay ? time : `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${time}`
359
369
  }
360
370
 
361
371
  /**
@@ -89,11 +89,18 @@ export function isIntelligenceAllowedForRuntime(runtime: string, intelligence: I
89
89
  // Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
90
90
  // warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
91
91
  // a property of the RUNTIME, not the personality (zone Идентичность). Infra runtimes
92
- // are routers hosted by the pty supervisor: telegram/voicetalk carry a human channel
93
- // (inbound messages / voice), notifier receives send_to_peer(timer|watcher, …)
94
- // registration/live-reload — outbound to each is deliverHosted → the child's stdin.
92
+ // are routers hosted by the pty supervisor: telegram/voicetalk/web carry a human
93
+ // channel (inbound messages / voice / browser console), notifier receives
94
+ // send_to_peer(timer|watcher, …) registration/live-reload — outbound to each is
95
+ // deliverHosted → the child's stdin.
95
96
  // This set gates always-on launchd plist generation (src/launch/launchd.ts).
96
- const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk'])
97
+ const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk', 'web'])
98
+
99
+ /** The infra runtimes as a list — for callers that must ENUMERATE the always-on
100
+ * plist candidates of a personality (H4 detector, remove teardown, update
101
+ * re-cycle walk the per-runtime plist scheme `com.iapeer.<p>.<rt>.plist`).
102
+ * Same membership as isInfraRuntime — single source, they cannot drift. */
103
+ export const INFRA_RUNTIME_LIST: readonly string[] = [...INFRA_RUNTIMES]
97
104
 
98
105
  export function isInfraRuntime(runtime: string): boolean {
99
106
  return INFRA_RUNTIMES.has(runtime)
@@ -111,6 +118,7 @@ export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
111
118
  notifier: 'NOTIFIER_RUNTIME_BIN',
112
119
  telegram: 'TELEGRAM_RUNTIME_BIN',
113
120
  voicetalk: 'VOICETALK_RUNTIME_BIN',
121
+ web: 'WEB_RUNTIME_BIN',
114
122
  }
115
123
 
116
124
  /** The PATH-resolvable default launcher name per infra runtime (when no abs path
@@ -120,6 +128,7 @@ export const INFRA_RUNTIME_DEFAULT_BIN: Readonly<Record<string, string>> = {
120
128
  notifier: 'notifier-runtime',
121
129
  telegram: 'telegram-runtime',
122
130
  voicetalk: 'voicetalk-runtime',
131
+ web: 'web-runtime',
123
132
  }
124
133
 
125
134
  // codex MCP token-free import (contract Установка §codex MCP). codex REFUSES to import
@@ -623,6 +623,12 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
623
623
  // and leaves no half-updated profile. Idempotent for our own (sentinel) plist.
624
624
  let intelligence = existing.intelligence
625
625
  if (isInfraRuntime(options.runtime)) {
626
+ // MULTI-INFRA (0.4.88): installAlwaysOnPlist resolves the per-(personality,
627
+ // runtime) target itself — the legacy base plist when it already carries this
628
+ // runtime, else the suffixed `com.iapeer.<p>.<rt>`. A second infra channel
629
+ // (arthur: telegram, now declaring web) therefore gets its OWN plist and the
630
+ // first channel's plist is untouched — the 0.4.87 declare-without-plist bridge
631
+ // (manual `run-infra` bring-up) is retired.
626
632
  installAlwaysOnPlist({
627
633
  personality: existing.personality,
628
634
  runtime: options.runtime,
package/src/init/index.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  } from '../storage/index.ts'
31
31
  import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
32
32
  import { claudeSettingsPath } from '../launch/nativeMemory.ts'
33
- import { launchctlBootstrap, launchdPlistPath, type BootstrapResult } from '../launch/launchd.ts'
33
+ import { launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
34
34
  import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
35
35
  import type { Intelligence } from '../core/constants.ts'
36
36
 
@@ -568,7 +568,14 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
568
568
  } else if (opts.bootstrap !== false) {
569
569
  // (2) AUTO-bootstrap (contract Фаза §5): load the plist NOW instead of
570
570
  // write-and-wait. Fleet-safe / idempotent / sandbox-skipped inside.
571
- bootstrapped = launchctlBootstrap(provisioned.personality, launchdPlistPath(provisioned.personality, env), env)
571
+ // Multi-infra: load the plist provision actually installed for THIS runtime
572
+ // (legacy base or per-runtime suffixed — resolveAlwaysOnTarget is stable
573
+ // post-install, so this is the same file installAlwaysOnPlist wrote).
574
+ bootstrapped = launchctlBootstrap(
575
+ provisioned.personality,
576
+ resolveAlwaysOnTarget(provisioned.personality, provisioned.runtime, env).path,
577
+ env,
578
+ )
572
579
  if (bootstrapped.state === 'failed' || bootstrapped.state === 'refused-foreign') {
573
580
  opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
574
581
  }
@@ -0,0 +1,107 @@
1
+ // web RuntimeAdapter — the "HOW to launch / observe ONE web session" half of the
2
+ // launch contract (src/launch/types.ts). Like telegram/voicetalk, web is a
3
+ // long-running ROUTER (`web-runtime run`: the owner's browser fleet-console —
4
+ // local HTTP/WebSocket server the browser client connects to → IAP envelopes to
5
+ // the fleet; <iap>-marked stdin → rendered into the console chat), NOT an LLM TUI.
6
+ // So kind:'router' tells launch.launch to SKIP every TUI phase — no pane boot
7
+ // dialog, no ready-marker, no first-user-turn delivery, no activity-proxy
8
+ // ready-gate, no permission modal, no transcript to resume. The pane predicates
9
+ // below are therefore trivial constants, present only to satisfy the frozen
10
+ // interface.
11
+ //
12
+ // web is a presence-runtime for a HUMAN (the owner's browser console), modelled on
13
+ // telegram/voicetalk per the foundation presence-runtime contract: kind:'router',
14
+ // usesDoctrine:false, allowedIntelligences:['natural','absent'], argv = `<bin> run
15
+ // <extra>`. The server/client mechanics (port, tunnel, auth) live in the
16
+ // @agfpd/web-runtime package, NOT in this adapter and NOT interpreted by the
17
+ // foundation — interfaces.web (if the package declares one) is opaque here.
18
+ //
19
+ // NO sender-guard for web (the voicetalk precedent): telegramSenderGuard is
20
+ // telegram-specific (the bridge picks the SENDING bot from the sender's
21
+ // bot_username); web is a single local owner endpoint — the console renders the
22
+ // sender from the envelope's `from`, so any peer may write to it freely.
23
+ //
24
+ // NO currency on this path: no marketplace check, no plugin install/update — that
25
+ // is install-time (the package self-deploys its runtime.json manifest), not
26
+ // session bring-up.
27
+
28
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
29
+ import { allowedIntelligencesForRuntime } from '../../core/constants.ts'
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // webAdapter
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ export const webAdapter: RuntimeAdapter = {
36
+ runtime: 'web',
37
+ kind: 'router',
38
+ usesDoctrine: false,
39
+
40
+ /** No submit surface — a router takes no bracketed-paste-then-Enter turn; delivery
41
+ * is deliverHosted → child stdin (<iap> reader), not submitIntoTui. Empty glyphs. */
42
+ deliveryMarkers: { promptGlyphs: [] },
43
+
44
+ /**
45
+ * web carries a HUMAN (natural) OR a FACELESS SERVICE bot (absent) — never an LLM
46
+ * agent (artificial), which would route the owner's console channel to an agent/script.
47
+ * Same nature gate as telegram/voicetalk, sourced from the single Ф0 truth
48
+ * (allowedIntelligencesForRuntime); enforced by the launch primitive against
49
+ * LaunchSpec.intelligence. Source → docs/Идентичность.
50
+ */
51
+ allowedIntelligences: allowedIntelligencesForRuntime('web'),
52
+
53
+ /**
54
+ * argv = webBin + 'run' + extraArgs.
55
+ * - cfg.webBin ?? 'web-runtime' the launch binary (the always-on plist pins an
56
+ * absolute path via WEB_RUNTIME_BIN — launchd-minimal PATH; here we defer to
57
+ * cfg.webBin when set, else emit the PATH-resolvable literal for launch.launch
58
+ * to resolve).
59
+ * - 'run' the router subcommand: brings up the console server (browser client ↔
60
+ * fleet) and pumps IAP envelopes both directions through the session's stdio
61
+ * (outbound <iap> stdin → console chat; inbound browser input → fleet via the
62
+ * daemon send_to_peer).
63
+ * - ...extraArgs LaunchSpec.extraArgs passthrough.
64
+ *
65
+ * NO --system-prompt-file: usesDoctrine:false — a router is not an LLM, no doctrine to
66
+ * merge. NO --resume: a router has no transcript. NO currency on this path.
67
+ */
68
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
69
+ return [cfg.webBin ?? 'web-runtime', 'run', ...(spec.extraArgs ?? [])]
70
+ },
71
+
72
+ /** No startup dialogs — a router has no pane TUI to answer. launch.launch skips the
73
+ * boot phase for kind:'router', so this is never consulted; null = "no dialog". */
74
+ bootDialogKeys(_pane: string): string[] | null {
75
+ return null
76
+ },
77
+
78
+ /** Always ready: a router has no input surface waiting for a first message — it is
79
+ * "up" the instant `web-runtime run` is launched. launch.launch skips the
80
+ * ready-gate for kind:'router'; true = "nothing to wait for". */
81
+ isInputReady(_pane: string): boolean {
82
+ return true
83
+ },
84
+
85
+ /** No activity proxy: a router writes no transcript/session jsonl to mtime as a
86
+ * ready-gate or idle signal. launch.launch skips the activity-proxy gate for a
87
+ * router; null per the contract ("null for a router (no proxy)"). */
88
+ newestActivityMtime(_cwd: string): number | null {
89
+ return null
90
+ },
91
+
92
+ lastTurnMtime(_cwd: string): number | null {
93
+ return null // router — no transcript
94
+ },
95
+
96
+ /** Nothing to resume: a router does not replay a transcript, so resume cannot
97
+ * fail-loud the way claude/codex do. Always {ok:true}. */
98
+ resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
99
+ return { ok: true }
100
+ },
101
+
102
+ /** No in-session control: a router has no TUI turn to interrupt/compact. null for
103
+ * every command → the daemon/CLI surfaces an explicit "unsupported" refusal. */
104
+ executeControl(_command: ControlCommand): ControlPlan | null {
105
+ return null
106
+ },
107
+ }
@@ -24,6 +24,7 @@ import { codexAdapter } from './adapters/codex.ts'
24
24
  import { telegramAdapter } from './adapters/telegram.ts'
25
25
  import { notifierAdapter } from './adapters/notifier.ts'
26
26
  import { voicetalkAdapter } from './adapters/voicetalk.ts'
27
+ import { webAdapter } from './adapters/web.ts'
27
28
  import type {
28
29
  LaunchConfig,
29
30
  LaunchFn,
@@ -54,8 +55,11 @@ export {
54
55
  launchdLabel,
55
56
  launchAgentsDir,
56
57
  launchdPlistPath,
58
+ resolveAlwaysOnTarget,
59
+ existingAlwaysOnPlists,
57
60
  renderLaunchdPlist,
58
61
  installAlwaysOnPlist,
62
+ installedPlistRuntime,
59
63
  isFoundationOwnedPlist,
60
64
  launchctlBootstrap,
61
65
  bootstrapJobCore,
@@ -67,6 +71,8 @@ export {
67
71
  IAPEER_PLIST_OWNER_KEY,
68
72
  } from './launchd.ts'
69
73
  export type {
74
+ AlwaysOnTarget,
75
+ PeerPlistEntry,
70
76
  LaunchdPlistSpec,
71
77
  InstallAlwaysOnPlistOptions,
72
78
  BootstrapResult,
@@ -90,6 +96,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
90
96
  if (runtime === 'telegram') return telegramAdapter
91
97
  if (runtime === 'notifier') return notifierAdapter
92
98
  if (runtime === 'voicetalk') return voicetalkAdapter
99
+ if (runtime === 'web') return webAdapter
93
100
  throw new Error(`no launch adapter for runtime "${runtime}"`)
94
101
  }
95
102
 
@@ -23,6 +23,7 @@ import {
23
23
  IAPEER_DIR,
24
24
  INFRA_RUNTIME_BIN_ENV,
25
25
  INFRA_RUNTIME_DEFAULT_BIN,
26
+ INFRA_RUNTIME_LIST,
26
27
  LAUNCHD_LABEL_PREFIX,
27
28
  isInfraRuntime,
28
29
  type Runtime,
@@ -81,6 +82,20 @@ function readPlistEnvVar(path: string, key: string): string | undefined {
81
82
  }
82
83
  }
83
84
 
85
+ /**
86
+ * The PEER_RUNTIME baked into the personality's EXISTING foundation-owned BASE
87
+ * always-on plist (`com.iapeer.<p>.plist` — the legacy default channel), or
88
+ * undefined when there is no base plist / it is foreign-owned / it carries no
89
+ * runtime. This is the "who occupies the base label" probe resolveAlwaysOnTarget
90
+ * builds on: the base stays that runtime's channel (no forced migration), every
91
+ * other runtime resolves to its per-runtime suffixed plist.
92
+ */
93
+ export function installedPlistRuntime(personality: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
94
+ const path = launchdPlistPath(personality, env)
95
+ if (!existsSync(path) || !isFoundationOwnedPlist(path)) return undefined
96
+ return readPlistEnvVar(path, 'PEER_RUNTIME')
97
+ }
98
+
84
99
  /**
85
100
  * Resolve an executable to an ABSOLUTE path against `env.PATH` (the RICH
86
101
  * provisioning PATH), so the result can be baked into a launchd plist whose own
@@ -122,6 +137,93 @@ export function launchdPlistPath(personality: string, env: NodeJS.ProcessEnv = p
122
137
  return join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`)
123
138
  }
124
139
 
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+ // MULTI-INFRA plist scheme — per-(personality, runtime) resolution
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+
144
+ export interface AlwaysOnTarget {
145
+ /** The launchd Label — also the plist basename stem. */
146
+ label: string
147
+ /** The plist path under launchAgentsDir. */
148
+ path: string
149
+ /** Which scheme resolved: the legacy one-per-personality base plist
150
+ * (`com.iapeer.<p>`) or the per-runtime suffixed one (`com.iapeer.<p>.<rt>`). */
151
+ scheme: 'legacy-base' | 'per-runtime'
152
+ }
153
+
154
+ /**
155
+ * Resolve the always-on plist TARGET for one (personality, runtime) pair — the
156
+ * single scheme authority every side shares (install / stop / start / remove /
157
+ * update re-cycle), so the writer and the operators can never disagree on which
158
+ * label holds which channel.
159
+ *
160
+ * Resolution: the LEGACY base plist (`com.iapeer.<p>`) is the personality's
161
+ * default channel — it is the target iff it EXISTS, is foundation-owned and
162
+ * carries THIS runtime (its baked PEER_RUNTIME matches). Every other case —
163
+ * base absent, base occupied by another infra runtime, base foreign — resolves
164
+ * to the per-runtime label `com.iapeer.<p>.<rt>`. So a legacy single-infra host
165
+ * keeps its base plists untouched (no forced migration), while every NEW install
166
+ * (and every second channel of a personality) gets its own plist and the two
167
+ * channels can be KeepAlive simultaneously.
168
+ *
169
+ * The suffixed label is structurally collision-free: NAME_RE forbids '.' in
170
+ * personalities and RUNTIME_RE forbids it in runtimes, so `com.iapeer.<p>.<rt>`
171
+ * can never spell another personality's base label (and the persistent-peer
172
+ * fleet only ever uses base-shaped labels).
173
+ *
174
+ * Resolution is STABLE across install→bootstrap→stop/start: installing a
175
+ * suffixed plist never flips a later resolve (the base check looks at the base
176
+ * file only), and re-resolving after an install sees the same target.
177
+ */
178
+ export function resolveAlwaysOnTarget(
179
+ personality: string,
180
+ runtime: string,
181
+ env: NodeJS.ProcessEnv = process.env,
182
+ ): AlwaysOnTarget {
183
+ if (installedPlistRuntime(personality, env) === runtime) {
184
+ return { label: launchdLabel(personality), path: launchdPlistPath(personality, env), scheme: 'legacy-base' }
185
+ }
186
+ const label = `${launchdLabel(personality)}.${runtime}`
187
+ return { label, path: join(launchAgentsDir(env), `${label}.plist`), scheme: 'per-runtime' }
188
+ }
189
+
190
+ export interface PeerPlistEntry {
191
+ label: string
192
+ path: string
193
+ /** The runtime the plist holds: the base plist's baked PEER_RUNTIME (undefined
194
+ * when unreadable/foreign), or the suffix of a per-runtime plist. */
195
+ runtime?: string
196
+ foundationOwned: boolean
197
+ }
198
+
199
+ /**
200
+ * Every EXISTING always-on plist of a personality under the foundation TEMPLATE
201
+ * scheme: the base `com.iapeer.<p>.plist` (legacy default channel — also the shape
202
+ * a foreign persistent-peer plist takes) plus the suffixed `com.iapeer.<p>.<rt>.plist`
203
+ * for each known infra runtime. The enumerator for personality-wide operations:
204
+ * the H4 detector mirror, remove's full teardown, and the update re-cycle.
205
+ */
206
+ export function existingAlwaysOnPlists(personality: string, env: NodeJS.ProcessEnv = process.env): PeerPlistEntry[] {
207
+ const out: PeerPlistEntry[] = []
208
+ const basePath = launchdPlistPath(personality, env)
209
+ if (existsSync(basePath)) {
210
+ out.push({
211
+ label: launchdLabel(personality),
212
+ path: basePath,
213
+ runtime: readPlistEnvVar(basePath, 'PEER_RUNTIME'),
214
+ foundationOwned: isFoundationOwnedPlist(basePath),
215
+ })
216
+ }
217
+ for (const rt of INFRA_RUNTIME_LIST) {
218
+ const label = `${launchdLabel(personality)}.${rt}`
219
+ const path = join(launchAgentsDir(env), `${label}.plist`)
220
+ if (existsSync(path)) {
221
+ out.push({ label, path, runtime: rt, foundationOwned: isFoundationOwnedPlist(path) })
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
125
227
  // XML-escape a plist <string> text node. cwd / personality / PATH can carry '&',
126
228
  // '<', '>' (or unicode) — a literal '&' or '<' would corrupt the XML, so escape the
127
229
  // three significant characters. Also DROP XML-1.0-illegal control characters (NUL +
@@ -524,12 +626,17 @@ export function launchctlBootstrap(
524
626
  plistPath: string,
525
627
  env: NodeJS.ProcessEnv = process.env,
526
628
  ): BootstrapResult {
527
- const label = launchdLabel(personality)
629
+ // The label is the plist BASENAME stem — true for BOTH schemes (legacy base
630
+ // `com.iapeer.<p>.plist` and per-runtime `com.iapeer.<p>.<rt>.plist`), and the
631
+ // single derivation that cannot drift from the file actually being loaded.
632
+ // (Deriving from `personality` alone would mis-label a per-runtime plist.)
633
+ const base = plistPath.slice(plistPath.lastIndexOf('/') + 1)
634
+ const label = base.endsWith('.plist') ? base.slice(0, -'.plist'.length) : launchdLabel(personality)
528
635
  if (!isFoundationOwnedPlist(plistPath)) {
529
636
  return {
530
637
  state: 'refused-foreign',
531
638
  label,
532
- detail: `${plistPath} is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
639
+ detail: `${plistPath} (peer "${personality}") is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
533
640
  }
534
641
  }
535
642
  // SANDBOX FAIL-CLOSED: `launchctl bootstrap gui/<uid>` is HOST-GLOBAL — it loads a
@@ -585,18 +692,24 @@ export interface InstallAlwaysOnPlistOptions {
585
692
  * entrypoint with PEER_* env + WorkingDirectory=cwd; logs land under
586
693
  * <cwd>/.iapeer/logs/<runtime>/.
587
694
  *
588
- * COLLISION GUARD (H4 — shared label namespace): the launchd Label is
589
- * com.iapeer.<personality> — keyed on PERSONALITY, not identity, and SHARED with the
695
+ * LABEL SCHEME (multi-infra, 0.4.88): the target label/path come from
696
+ * resolveAlwaysOnTarget — the legacy base `com.iapeer.<personality>` when that plist
697
+ * already carries this runtime (default channel, no forced migration), else the
698
+ * per-runtime `com.iapeer.<personality>.<runtime>`. Two infra channels of one
699
+ * personality (e.g. arthur: telegram + web) therefore hold two separate KeepAlive
700
+ * plists and never clobber each other.
701
+ *
702
+ * COLLISION GUARD (H4 — shared label namespace): the BASE label is SHARED with the
590
703
  * already-deployed persistent-peer fleet (~/Library/LaunchAgents/
591
704
  * com.iapeer.<persistent-peer>.plist run by start.sh). A personality collision (a
592
- * notifier peer named like a live PP peer) must NOT silently overwrite that foreign
593
- * plist — doing so would tear a live PP peer off launchd. So before writing we
594
- * REFUSE when a plist already sits at the target and is not foundation-owned
595
- * (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds). The
596
- * label prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the
597
- * sentinel can. Re-installing our OWN plist (sentinel present) is allowed
598
- * (idempotent re-provision). The guard is checked FIRST, before any mkdir/write, so
599
- * a refusal leaves the filesystem untouched.
705
+ * notifier peer named like a live PP peer) must NOT put a second manager on that
706
+ * name — so we REFUSE when the base plist exists and is not foundation-owned
707
+ * (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds), and
708
+ * equally refuse a foreign file squatting the resolved per-runtime path. The label
709
+ * prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the sentinel
710
+ * can. Re-installing our OWN plist (sentinel present) is allowed (idempotent
711
+ * re-provision). The guards run FIRST, before any mkdir/write, so a refusal leaves
712
+ * the filesystem untouched.
600
713
  */
601
714
  export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string {
602
715
  if (!isInfraRuntime(opts.runtime)) {
@@ -605,13 +718,33 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
605
718
  )
606
719
  }
607
720
  const env = opts.env ?? process.env
608
- // Collision guard FIRST — never clobber a plist the foundation does not own.
609
- const path = launchdPlistPath(opts.personality, env)
721
+ // PERSONALITY COLLISION GUARD FIRST — never touch a personality whose BASE label is
722
+ // owned by a foreign manager (the live persistent-peer fleet writes base-shaped
723
+ // com.iapeer.<p> plists). Even though a second channel would land on the suffixed
724
+ // label, installing ANY foundation plist for a foreign-managed personality would
725
+ // put two managers on one peer name — refuse before any write.
726
+ const basePath = launchdPlistPath(opts.personality, env)
727
+ if (existsSync(basePath) && !isFoundationOwnedPlist(basePath)) {
728
+ throw new IapError(
729
+ `refusing to install an always-on plist for "${opts.personality}": label ` +
730
+ `${launchdLabel(opts.personality)} is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} ` +
731
+ `sentinel) — a persistent-peer or other manager owns it; rename the peer to avoid ` +
732
+ `the com.iapeer.<personality> collision`,
733
+ )
734
+ }
735
+ // MULTI-INFRA (per-runtime plists): resolve the (personality, runtime) target —
736
+ // the legacy base plist iff it already carries THIS runtime (default channel, no
737
+ // forced migration), else the suffixed com.iapeer.<p>.<rt>. A second infra runtime
738
+ // therefore gets its OWN plist and can be KeepAlive alongside the first channel —
739
+ // the pre-0.4.88 fail-loud "one plist per personality" refusal is retired.
740
+ const target = resolveAlwaysOnTarget(opts.personality, opts.runtime, env)
741
+ const path = target.path
742
+ // Belt for the suffixed path too: a hand-planted foreign file at our per-runtime
743
+ // label is never clobbered (same sentinel rule as the base guard above).
610
744
  if (existsSync(path) && !isFoundationOwnedPlist(path)) {
611
745
  throw new IapError(
612
- `refusing to overwrite launchd plist ${path}: label ${launchdLabel(opts.personality)} ` +
613
- `is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — a persistent-peer ` +
614
- `or other manager owns it; rename the peer to avoid the com.iapeer.<personality> collision`,
746
+ `refusing to overwrite launchd plist ${path}: it exists but is not foundation-managed ` +
747
+ `(no ${IAPEER_PLIST_OWNER_KEY} sentinel) — remove or rename the foreign file first`,
615
748
  )
616
749
  }
617
750
  // GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/, NOT per-peer
@@ -662,7 +795,7 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
662
795
  if (fallback) environment.NOTIFIER_FALLBACK_TARGET = fallback
663
796
  }
664
797
  const spec: LaunchdPlistSpec = {
665
- label: launchdLabel(opts.personality),
798
+ label: target.label,
666
799
  programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
667
800
  workingDirectory: opts.cwd,
668
801
  environment,
@@ -97,6 +97,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
97
97
  telegramBin: env[INFRA_RUNTIME_BIN_ENV.telegram],
98
98
  notifierBin: env[INFRA_RUNTIME_BIN_ENV.notifier],
99
99
  voicetalkBin: env[INFRA_RUNTIME_BIN_ENV.voicetalk],
100
+ webBin: env[INFRA_RUNTIME_BIN_ENV.web],
100
101
  sockDir,
101
102
  bootDeadlineSecs: 30,
102
103
  readyGateSecs: 30,
@@ -141,6 +141,8 @@ export interface LaunchAdapterConfig {
141
141
  notifierBin?: string
142
142
  /** voicetalk-runtime launch binary (router runtime, infra/always-on — the owner's voice channel). */
143
143
  voicetalkBin?: string
144
+ /** web-runtime launch binary (router runtime, infra/always-on — the owner's browser fleet-console). */
145
+ webBin?: string
144
146
  }
145
147
 
146
148
  export interface LaunchSpec {
@@ -18,6 +18,7 @@ import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
19
19
  import * as lockfile from 'proper-lockfile'
20
20
  import {
21
+ INFRA_RUNTIME_LIST,
21
22
  STATE_DIR,
22
23
  LOGS_DIR,
23
24
  isRuntime,
@@ -131,18 +132,26 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
131
132
  // ─────────────────────────────────────────────────────────────────────────────
132
133
 
133
134
  /**
134
- * True iff the peer is launchd-managed: `~/Library/LaunchAgents/
135
- * com.iapeer.<personality>.plist` exists. A launchd peer is in the launchd
136
- * domain — KeepAlive owns its lifecycle — and the daemon must be READ-ONLY for
137
- * it (deliver to it if live, but never wake / reap / respawn / sweep it). This
138
- * is the hard guard against fighting launchd on the live fleet.
135
+ * True iff the peer is launchd-managed: ANY foundation-template always-on plist
136
+ * of the personality exists under ~/Library/LaunchAgents — the legacy base
137
+ * `com.iapeer.<personality>.plist` (also the shape a foreign persistent-peer
138
+ * plist takes) OR a per-runtime `com.iapeer.<personality>.<runtime>.plist`
139
+ * (multi-infra, 0.4.88). A launchd peer is in the launchd domain — KeepAlive
140
+ * owns its lifecycle — and the daemon must be READ-ONLY for it (deliver to it
141
+ * if live, but never wake / reap / respawn / sweep it). This is the hard guard
142
+ * against fighting launchd on the live fleet. H4 stays PERSONALITY-scoped: one
143
+ * launchd-held channel marks the whole peer launchd-owned, same as before.
139
144
  */
140
145
  export function isLaunchdManaged(personality: string, env: NodeJS.ProcessEnv = process.env): boolean {
141
146
  // Label + LaunchAgents dir come from the SAME helpers the plist generator uses
142
147
  // (launch/launchd.ts), so this H4 detector and installAlwaysOnPlist can never
143
- // disagree on `com.iapeer.<personality>.plist`. IAPEER_LAUNCHAGENTS_DIR overrides
144
- // the dir for tests (so an H4-guard test never touches ~/Library/LaunchAgents).
145
- return existsSync(join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`))
148
+ // disagree on the scheme. The per-runtime candidates are enumerated from
149
+ // INFRA_RUNTIME_LIST (the same set that gates plist generation) — cheap
150
+ // existsSync probes, no file reads, since this runs on every route/sweep.
151
+ // IAPEER_LAUNCHAGENTS_DIR overrides the dir for tests.
152
+ const dir = launchAgentsDir(env)
153
+ if (existsSync(join(dir, `${launchdLabel(personality)}.plist`))) return true
154
+ return INFRA_RUNTIME_LIST.some(rt => existsSync(join(dir, `${launchdLabel(personality)}.${rt}.plist`)))
146
155
  }
147
156
 
148
157
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1320,10 +1329,17 @@ interface SuperviseCtx {
1320
1329
  */
1321
1330
  function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
1322
1331
  const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
1323
- // H4 — FIRST, before any reap. A launchd-managed peer is read-only.
1332
+ // H4 — FIRST, before any reap. A launchd-managed peer is read-only: never wake,
1333
+ // never kill, never sweep its PROCESS. Its stale `.session` however is the
1334
+ // DAEMON'S own supervisory record (written back when the peer was daemon-owned),
1335
+ // not the peer's — a peer that moved under launchd would otherwise re-enter this
1336
+ // skip on every tick forever (follow-up 09.06: latent garbage, not a delivery
1337
+ // bug). Drop the record once; if the plist is ever removed again, the next wake
1338
+ // recreates it.
1324
1339
  if (isLaunchdManaged(s.personality, env)) {
1325
- if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
1326
- return { identity: s.identity, action: 'skipped-launchd' }
1340
+ removeSessionState(cfg, s.identity)
1341
+ if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4-stale-state-dropped' })
1342
+ return { identity: s.identity, action: 'skipped-launchd', reason: 'launchd-managed; stale daemon .session dropped' }
1327
1343
  }
1328
1344
  const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
1329
1345
  if (!isAlive(sock, s.identity)) {
@@ -24,7 +24,7 @@ import { IapError } from '../core/errors.ts'
24
24
  import { peerProfilePath } from '../storage/index.ts'
25
25
  import { ensurePeerProfile } from '../identity/index.ts'
26
26
  import { readPeersIndex, upsertPeer } from '../registry/index.ts'
27
- import { launchdPlistPath, resolveExecutable } from '../launch/launchd.ts'
27
+ import { resolveAlwaysOnTarget, resolveExecutable } from '../launch/launchd.ts'
28
28
 
29
29
  export interface ProvisionPeerOptions {
30
30
  /** The peer's working directory. personality defaults to normalized basename. */
@@ -222,7 +222,10 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
222
222
  cwd,
223
223
  profilePath: peerProfilePath(cwd),
224
224
  intelligence: opts.intelligence ?? profile.intelligence,
225
- plistPath: isInfraRuntime(opts.runtime) ? launchdPlistPath(profile.personality, env) : undefined,
225
+ // Multi-infra: the plist ensurePeerProfile actually installed for THIS runtime
226
+ // (legacy base or per-runtime suffixed) — resolveAlwaysOnTarget re-resolves to
227
+ // the same target the install used (resolution is stable once the file exists).
228
+ plistPath: isInfraRuntime(opts.runtime) ? resolveAlwaysOnTarget(profile.personality, opts.runtime, env).path : undefined,
226
229
  runtimeBin,
227
230
  }
228
231
  }
@@ -33,6 +33,7 @@ export const RUNTIME_PACKAGES: Readonly<Record<string, string>> = {
33
33
  telegram: '@agfpd/telegram-runtime',
34
34
  notifier: '@agfpd/notifier-runtime',
35
35
  voicetalk: '@agfpd/voicetalk-runtime',
36
+ web: '@agfpd/web-runtime',
36
37
  }
37
38
 
38
39
  /** Resolve the npm package for a runtime: explicit override wins, else the built-in
@@ -308,11 +308,13 @@ async function deliverViaHost(
308
308
  // codex took the socket-ack short-circuit above). A bare transcript/pane-log mtime BUMP is NOT proof
309
309
  // the session took THIS message: a receiver in an active turn advances both mtimes with its OWN
310
310
  // rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23: ok=true,
311
- // message gone). The ONLY proof is a NEW transcript record CARRYING our envelope — for claude, a
312
- // queue-operation `content` (busy → enqueued) or the user-turn message (idle), written sub-second so
313
- // a short grace covers it. The receiver's own assistant/tool turn never reproduces the
314
- // `<iap from-personality=…>` wrapper, so it cannot forge a confirm. No such record within the grace →
315
- // false-FAIL (the sender retries) — NOT a false-OK (silent loss the contract forbids).
311
+ // message gone). The ONLY proof is a NEW transcript record CARRYING the DELIVERED payload (for an
312
+ // agent target that is the compact `<iap from=…>` presentation — deliverWarm rendered it before this
313
+ // point, so the needle matches what was actually pasted) — for claude, a queue-operation `content`
314
+ // (busy → enqueued) or the user-turn message (idle), written sub-second so a short grace covers it.
315
+ // The receiver's own assistant/tool turn never reproduces the full delivered envelope verbatim, so it
316
+ // cannot forge a confirm. No such record within the grace → false-FAIL (the sender retries) — NOT a
317
+ // false-OK (silent loss the contract forbids).
316
318
  const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
317
319
  const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
318
320
  const graceMs = confirmGraceMs()
@@ -42,8 +42,7 @@ import {
42
42
  cycleDaemon,
43
43
  cycleLaunchdJob,
44
44
  isFoundationOwnedPlist,
45
- launchdLabel,
46
- launchdPlistPath,
45
+ resolveAlwaysOnTarget,
47
46
  type DaemonRestartResult,
48
47
  type LaunchdJobCycleResult,
49
48
  } from '../launch/launchd.ts'
@@ -261,13 +260,18 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
261
260
  const out: InfraJobRecycleResult[] = []
262
261
  const index = readPeersIndex({ env })
263
262
  for (const peer of index.peers) {
264
- const runtime = (isInfraRuntime(peer.runtime) ? peer.runtime : peer.runtimes.find(isInfraRuntime)) ?? ''
265
- if (!runtime) continue
266
- const plist = launchdPlistPath(peer.personality, env)
267
- if (!isFoundationOwnedPlist(plist)) continue
268
- const label = launchdLabel(peer.personality)
269
- const r = cycleLaunchdJob(label, plist, env)
270
- out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
263
+ // Multi-infra (0.4.88): a personality can hold SEVERAL always-on channels
264
+ // (e.g. arthur: telegram on the legacy base plist + web on the per-runtime
265
+ // one) — re-cycle EVERY declared infra runtime's job, not just the first.
266
+ // resolveAlwaysOnTarget is the same scheme authority install/stop/start use;
267
+ // an absent plist reads as not-foundation-owned → skipped.
268
+ const infraRuntimes = [...new Set([peer.runtime, ...peer.runtimes])].filter(isInfraRuntime)
269
+ for (const runtime of infraRuntimes) {
270
+ const target = resolveAlwaysOnTarget(peer.personality, runtime, env)
271
+ if (!isFoundationOwnedPlist(target.path)) continue
272
+ const r = cycleLaunchdJob(target.label, target.path, env)
273
+ out.push({ personality: peer.personality, runtime, label: target.label, state: r.state, detail: r.detail })
274
+ }
271
275
  }
272
276
  return out
273
277
  }