@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.
- package/docs/09-runtimes.md +2 -0
- package/docs/ru/09-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213.md +2 -0
- package/package.json +1 -1
- package/src/cli/index.ts +49 -24
- package/src/codec/index.ts +23 -13
- package/src/core/constants.ts +13 -4
- package/src/identity/index.ts +6 -0
- package/src/init/index.ts +9 -2
- package/src/launch/adapters/web.ts +107 -0
- package/src/launch/index.ts +7 -0
- package/src/launch/launchd.ts +151 -18
- package/src/launch/launchdRun.ts +1 -0
- package/src/launch/types.ts +2 -0
- package/src/lifecycle/index.ts +27 -11
- package/src/provision/index.ts +5 -2
- package/src/runtime/deploy.ts +1 -0
- package/src/transport/index.ts +7 -5
- package/src/update/index.ts +13 -9
package/docs/09-runtimes.md
CHANGED
|
@@ -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
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)/${
|
|
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
|
-
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
//
|
|
797
|
-
bootoutNote
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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 =
|
|
831
|
+
plistTeardown = notes.join('; ')
|
|
807
832
|
}
|
|
808
833
|
}
|
|
809
834
|
await removePeer(personality, { env })
|
package/src/codec/index.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
// IAP envelope codec — encoder +
|
|
1
|
+
// IAP envelope codec — wire encoder + read-both decoder + agent-facing render.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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()}-${
|
|
59
|
-
`T${
|
|
60
|
-
`${sign}${
|
|
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
|
|
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()}-${
|
|
368
|
+
return sameLocalDay ? time : `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${time}`
|
|
359
369
|
}
|
|
360
370
|
|
|
361
371
|
/**
|
package/src/core/constants.ts
CHANGED
|
@@ -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
|
|
93
|
-
// (inbound messages / voice), notifier receives
|
|
94
|
-
// registration/live-reload — outbound to each is
|
|
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
|
package/src/identity/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -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
|
|
package/src/launch/launchd.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
589
|
-
*
|
|
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
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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
|
-
//
|
|
609
|
-
|
|
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}:
|
|
613
|
-
`
|
|
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:
|
|
798
|
+
label: target.label,
|
|
666
799
|
programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
|
|
667
800
|
workingDirectory: opts.cwd,
|
|
668
801
|
environment,
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -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,
|
package/src/launch/types.ts
CHANGED
|
@@ -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 {
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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:
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
144
|
-
// the
|
|
145
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
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)) {
|
package/src/provision/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/deploy.ts
CHANGED
|
@@ -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
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
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()
|
package/src/update/index.ts
CHANGED
|
@@ -42,8 +42,7 @@ import {
|
|
|
42
42
|
cycleDaemon,
|
|
43
43
|
cycleLaunchdJob,
|
|
44
44
|
isFoundationOwnedPlist,
|
|
45
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
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
|
}
|