@agfpd/iapeer 0.4.85 → 0.4.87
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/10-messaging.md +19 -1
- package/docs/16-tray.md +1 -1
- package/docs/ru/16-tray.md +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +3 -1
- package/src/codec/index.ts +160 -12
- package/src/core/constants.ts +10 -6
- package/src/daemon/index.ts +1 -1
- package/src/daemon/main.ts +2 -2
- package/src/identity/index.ts +25 -8
- package/src/launch/adapters/web.ts +107 -0
- package/src/launch/index.ts +3 -0
- package/src/launch/launchd.ts +30 -0
- package/src/launch/launchdRun.ts +1 -0
- package/src/launch/types.ts +2 -0
- package/src/lifecycle/index.ts +20 -6
- package/src/runtime/deploy.ts +1 -0
- package/src/transport/index.ts +37 -17
- package/src/tray/install.ts +17 -3
package/docs/10-messaging.md
CHANGED
|
@@ -34,7 +34,25 @@ The daemon finds the recipient in the registry and looks at its state:
|
|
|
34
34
|
|
|
35
35
|
The key guarantee: `delivered` means the message actually landed in a live session (the daemon waited for confirmation), not that it was just sent into the void. If the session didn't confirm receipt in the allotted time, the daemon treats it as dead and doesn't lie about success.
|
|
36
36
|
|
|
37
|
-
In every message's envelope travels the sender's nature (`intelligence`) — the recipient knows whether a human, an agent, or a service process is writing to it.
|
|
37
|
+
In every message's envelope travels the sender's nature (`intelligence`) — the recipient knows whether a human, an agent, or a service process is writing to it — and the dispatch instant (`ts`): the moment the router accepted the send. The same instant is returned to the sender as the `ts` field of its `send_to_peer` result, so both sides of a conversation can literally compare timestamps — which is what makes an async desync (a reply addressing an already-outdated state) visible to the recipient instead of silent.
|
|
38
|
+
|
|
39
|
+
## Envelope: wire form vs agent presentation
|
|
40
|
+
|
|
41
|
+
The envelope exists in two renderings of the same content:
|
|
42
|
+
|
|
43
|
+
- **Wire form** — the canonical durable shape: legacy attribute names (`from-personality` / `from-runtime` / `from-intelligence`), `ts` as a full local ISO instant with offset, the body always CDATA-wrapped, `<message>` tag. This is what travels through disk queues, what bridge runtimes (telegram / notifier / voicetalk) receive and parse, and what their pane logs show. It never slims: its bytes cost no tokens, and every deployed parser keeps working unchanged.
|
|
44
|
+
- **Agent presentation** — rendered at the last hop wherever text enters an LLM context (a live delivery to a claude/codex session, a wake boot first-message, an ephemeral-queue drain):
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
<iap from="boris" runtime="claude" intelligence="artificial" ts="01:23:45">
|
|
48
|
+
Reply via send_to_peer.
|
|
49
|
+
<msg>message body…</msg>
|
|
50
|
+
</iap>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Short attribute names, `ts` compacted to local host time — time-only when the message was sent the same local calendar day it is delivered, date-prefixed (`2026-07-13 01:23:45`) otherwise, so a queue-drained or long-delayed message can never masquerade as fresh. The body is raw (no CDATA) unless it quotes envelope machinery itself — then it keeps the CDATA wrapping (a cosmetic fallback, nothing parses the presentation). The render is fail-open: any problem delivers the wire form as-is rather than risking the message.
|
|
54
|
+
|
|
55
|
+
So host logs deliberately show two envelope shapes: bridge-bound pane logs carry the wire form, agent-bound pane logs and transcripts carry the compact presentation. The core decoder reads both name sets (and both `<message>`/`<msg>` tags), so any envelope ever emitted on the host — including historic transcripts — still decodes.
|
|
38
56
|
|
|
39
57
|
## Message limits
|
|
40
58
|
|
package/docs/16-tray.md
CHANGED
|
@@ -85,7 +85,7 @@ whole dashboard twice.
|
|
|
85
85
|
| `iapeer tray render [--stream]` | Print the SwiftBar plugin output. `--stream` is the streamable loop the plugin runs; the one-shot form is the poll fallback and the test surface. |
|
|
86
86
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
|
|
87
87
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Resolve a pending human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` over the same unix-first fleet client. The menu's Allow/Deny items call these; the single-queue invariant means the resolution is seen by every channel (CLI, telegram). |
|
|
88
|
-
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present, **is the tray-host supervisor registered
|
|
88
|
+
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present, **is the tray-host supervisor registered**, and **is the menu-bar icon visible right now** (the persisted `NSStatusItem VisibleCC` state — the ground truth to read first when "the icon is gone" is reported; no screen needed). Repairs nothing. |
|
|
89
89
|
|
|
90
90
|
## How it is wired
|
|
91
91
|
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -81,7 +81,7 @@ SwiftBar.app, если его нет.
|
|
|
81
81
|
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
|
|
82
82
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
|
|
83
83
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
|
|
84
|
-
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли супервизор tray
|
|
84
|
+
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли супервизор tray-хоста** и **видима ли иконка в менюбаре прямо сейчас** (персистентное состояние `NSStatusItem VisibleCC` — ground truth, который читают первым при жалобе «иконка пропала»; экран не нужен). Ничего не чинит. |
|
|
85
85
|
|
|
86
86
|
## Как устроено
|
|
87
87
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -2607,8 +2607,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2607
2607
|
` sock: ${s.daemon.sock ?? '—'}\n` +
|
|
2608
2608
|
` tcp: ${s.daemon.tcp ?? '—'}\n` +
|
|
2609
2609
|
`SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
|
|
2610
|
+
// the persisted NSStatusItem visibility — ground truth for "the icon is gone"
|
|
2611
|
+
`menu-bar icon: ${s.swiftbar.iconVisible === undefined ? 'unknown (no persisted state)' : s.swiftbar.iconVisible ? 'visible' : 'HIDDEN (persisted NSStatusItem VisibleCC=0)'}\n` +
|
|
2610
2612
|
`plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n` +
|
|
2611
|
-
`
|
|
2613
|
+
`supervisor (autostart): ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
|
|
2612
2614
|
)
|
|
2613
2615
|
return 0
|
|
2614
2616
|
}
|
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).
|
|
@@ -30,11 +40,38 @@ export interface EnvelopeInput {
|
|
|
30
40
|
fromPersonality: string
|
|
31
41
|
fromRuntime: Runtime
|
|
32
42
|
fromIntelligence: Intelligence
|
|
43
|
+
/** ISO-8601 LOCAL instant with offset (formatSentAt) — the moment the router
|
|
44
|
+
* ACCEPTED the send; the same string is returned to the sender as the `ts`
|
|
45
|
+
* field of its send_to_peer result, so recipient-side `ts` and sender-side
|
|
46
|
+
* `ts` correspond literally. Rendered as the `ts` open-tag attribute.
|
|
47
|
+
* ADDITIVE and optional: every known envelope consumer reads attributes by
|
|
48
|
+
* NAME and ignores unknown ones; absent on legacy envelopes. On the WIRE this
|
|
49
|
+
* is always the FULL date-time (durable stores — disk queues, pane logs —
|
|
50
|
+
* must be unambiguous); the agent-facing render compacts it (see
|
|
51
|
+
* renderEnvelopeForAgent). */
|
|
52
|
+
sentAt?: string
|
|
33
53
|
topic?: string
|
|
34
54
|
attachments?: readonly string[]
|
|
35
55
|
message: string
|
|
36
56
|
}
|
|
37
57
|
|
|
58
|
+
const pad2 = (n: number): string => String(n).padStart(2, '0')
|
|
59
|
+
|
|
60
|
+
/** Format a Date as ISO-8601 LOCAL time with numeric offset, second precision:
|
|
61
|
+
* `2026-07-14T01:23:45+03:00`. Local (not UTC-Z) on purpose: the fleet's agents
|
|
62
|
+
* compare envelope timestamps against their LOCAL statusline clock — a Z-form
|
|
63
|
+
* invites silent off-by-offset misreads by an LLM reader. */
|
|
64
|
+
export function formatSentAt(date: Date): string {
|
|
65
|
+
const offMin = -date.getTimezoneOffset() // minutes EAST of UTC
|
|
66
|
+
const sign = offMin >= 0 ? '+' : '-'
|
|
67
|
+
const abs = Math.abs(offMin)
|
|
68
|
+
return (
|
|
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)}`
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
function escapeAttr(value: string): string {
|
|
39
76
|
return value
|
|
40
77
|
.replace(/&/g, '&')
|
|
@@ -49,12 +86,20 @@ function cdata(value: string): string {
|
|
|
49
86
|
return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
|
|
50
87
|
}
|
|
51
88
|
|
|
89
|
+
/** WIRE encoder — the canonical durable form (disk queues, bridge deliveries,
|
|
90
|
+
* pane logs): legacy attribute names + always-CDATA body, so every deployed
|
|
91
|
+
* sibling parser (telegram-/notifier-/voicetalk-runtime) keeps working
|
|
92
|
+
* unchanged. `ts` is the only additive attribute (envelope-compaction F).
|
|
93
|
+
* Agent-bound deliveries are re-rendered compactly at the LAST hop — see
|
|
94
|
+
* renderEnvelopeForAgent; the wire form itself never slims (its bytes cost no
|
|
95
|
+
* tokens; only the LLM-context presentation does). */
|
|
52
96
|
export function buildEnvelope(input: EnvelopeInput): string {
|
|
53
97
|
const attrs = [
|
|
54
98
|
`from-personality="${escapeAttr(input.fromPersonality)}"`,
|
|
55
99
|
`from-runtime="${escapeAttr(input.fromRuntime)}"`,
|
|
56
100
|
`from-intelligence="${escapeAttr(input.fromIntelligence)}"`,
|
|
57
101
|
]
|
|
102
|
+
if (input.sentAt) attrs.push(`ts="${escapeAttr(input.sentAt)}"`)
|
|
58
103
|
const topic = input.topic?.trim()
|
|
59
104
|
if (topic) {
|
|
60
105
|
attrs.push(`topic="${escapeAttr(topic.slice(0, MAX_TOPIC_LEN))}"`)
|
|
@@ -146,13 +191,21 @@ export interface IapEnvelope {
|
|
|
146
191
|
fromPersonality: string
|
|
147
192
|
fromRuntime: Runtime
|
|
148
193
|
fromIntelligence?: Intelligence
|
|
194
|
+
/** The sender's dispatch instant (`ts` attribute) — full local ISO on the wire;
|
|
195
|
+
* absent on legacy envelopes. */
|
|
196
|
+
sentAt?: string
|
|
149
197
|
topic?: string
|
|
150
198
|
attachments: string[]
|
|
151
199
|
message: string
|
|
152
200
|
}
|
|
153
201
|
|
|
202
|
+
/** NAME-ANCHORED attribute lookup. The anchor `(^|\s)` is load-bearing: the old
|
|
203
|
+
* unanchored regex made `runtime="` match the TAIL of `from-runtime="…"` (and
|
|
204
|
+
* `intelligence="` the tail of `from-intelligence="…"`) — a latent mine that the
|
|
205
|
+
* read-both decoder below would have stepped on (the short-name lookup must NOT
|
|
206
|
+
* be satisfied by a legacy long-name attribute). */
|
|
154
207
|
function attrValue(attrs: string, name: string): string | undefined {
|
|
155
|
-
const re = new RegExp(
|
|
208
|
+
const re = new RegExp(`(?:^|\\s)${name}="([^"]*)"`)
|
|
156
209
|
const m = re.exec(attrs)
|
|
157
210
|
return m ? unescapeAttr(m[1]) : undefined
|
|
158
211
|
}
|
|
@@ -167,25 +220,34 @@ function unescapeAttr(value: string): string {
|
|
|
167
220
|
.replace(/&/g, '&')
|
|
168
221
|
}
|
|
169
222
|
|
|
223
|
+
/** READ-BOTH decoder: accepts the legacy wire names (`from-personality` /
|
|
224
|
+
* `from-runtime` / `from-intelligence`, `<message>`) AND the compact
|
|
225
|
+
* presentation names (`from` / `runtime` / `intelligence`, `<msg>`) — so any
|
|
226
|
+
* envelope form ever emitted on this host (wire, agent-facing render, historic
|
|
227
|
+
* transcripts) decodes. Short names win when both are present. */
|
|
170
228
|
export function decodeEnvelope(xml: string): IapEnvelope {
|
|
171
229
|
const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
|
|
172
230
|
if (!open) throw new IapError('invalid IAP envelope: missing <iap ...>')
|
|
173
|
-
const fromPersonality = attrValue(open[1], 'from-personality')
|
|
174
|
-
const fromRuntime = attrValue(open[1], 'from-runtime')
|
|
231
|
+
const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
|
|
232
|
+
const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
|
|
175
233
|
if (!fromPersonality || !fromRuntime) {
|
|
176
234
|
throw new IapError('invalid IAP envelope: missing from-personality/from-runtime')
|
|
177
235
|
}
|
|
178
236
|
// READ-COMPAT: a legacy peer (live telegram) may stamp from-intelligence="human";
|
|
179
237
|
// normalize human→natural / scripted→absent, drop a genuinely unknown value.
|
|
180
|
-
const fromIntelligence = normalizeIntelligenceValue(
|
|
181
|
-
|
|
238
|
+
const fromIntelligence = normalizeIntelligenceValue(
|
|
239
|
+
attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
|
|
240
|
+
)
|
|
241
|
+
const message = readTagContent(xml, 'message') ?? readTagContent(xml, 'msg')
|
|
182
242
|
if (message === undefined) throw new IapError('invalid IAP envelope: missing message')
|
|
183
243
|
const attachmentsRaw = readTagContent(xml, 'attachments')
|
|
184
244
|
const topic = attrValue(open[1], 'topic')
|
|
245
|
+
const sentAt = attrValue(open[1], 'ts')
|
|
185
246
|
return {
|
|
186
247
|
fromPersonality,
|
|
187
248
|
fromRuntime,
|
|
188
249
|
...(fromIntelligence ? { fromIntelligence } : {}),
|
|
250
|
+
...(sentAt ? { sentAt } : {}),
|
|
189
251
|
...(topic ? { topic } : {}),
|
|
190
252
|
attachments: attachmentsRaw
|
|
191
253
|
? attachmentsRaw.split('\n').map(item => item.trim()).filter(Boolean)
|
|
@@ -209,7 +271,12 @@ function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
|
|
|
209
271
|
return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
|
|
210
272
|
}
|
|
211
273
|
if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
|
|
212
|
-
|
|
274
|
+
// Read-both (envelope-compaction F): a real open tag carries the required pair
|
|
275
|
+
// under EITHER naming — legacy wire (from-personality/from-runtime) or compact
|
|
276
|
+
// presentation (from/runtime, name-anchored so a legacy tail never satisfies it).
|
|
277
|
+
const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
|
|
278
|
+
const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
|
|
279
|
+
return hasPersonality && hasRuntime ? 'valid' : 'invalid'
|
|
213
280
|
}
|
|
214
281
|
|
|
215
282
|
/**
|
|
@@ -257,3 +324,84 @@ export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: s
|
|
|
257
324
|
rest = rest.slice(envelopeEnd)
|
|
258
325
|
}
|
|
259
326
|
}
|
|
327
|
+
|
|
328
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
329
|
+
// Agent-facing presentation render (envelope-compaction F)
|
|
330
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/** Presentation-hybrid gate: a body that itself contains envelope machinery
|
|
333
|
+
* (a quoted envelope, tag markers, CDATA syntax) or a literal CR keeps its
|
|
334
|
+
* CDATA wrapping in the presentation too — the cost of the gate firing is
|
|
335
|
+
* purely COSMETIC (the agent sees today's CDATA form), whereas rendering such
|
|
336
|
+
* a body raw would show the agent a "broken" nested envelope. Plain `<` (code
|
|
337
|
+
* snippets etc.) renders raw: nothing parses the presentation. */
|
|
338
|
+
const PRESENTATION_CDATA_MARKERS = [
|
|
339
|
+
'<iap ',
|
|
340
|
+
'</iap>',
|
|
341
|
+
'<message>',
|
|
342
|
+
'</message>',
|
|
343
|
+
'<msg>',
|
|
344
|
+
'</msg>',
|
|
345
|
+
'<attachments>',
|
|
346
|
+
'</attachments>',
|
|
347
|
+
'<![CDATA[',
|
|
348
|
+
']]>',
|
|
349
|
+
'\r',
|
|
350
|
+
] as const
|
|
351
|
+
|
|
352
|
+
function presentBody(value: string): string {
|
|
353
|
+
return PRESENTATION_CDATA_MARKERS.some(marker => value.includes(marker)) ? cdata(value) : value
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Compact the wire `ts` (full local ISO with offset) for the agent's eyes:
|
|
357
|
+
* time-only `01:23:45` when the send happened on the SAME local calendar day
|
|
358
|
+
* as the render (the normal case), date-prefixed `2026-07-13 01:23:45` when it
|
|
359
|
+
* did not (queue drains, long sleeps, midnight crossings) — so a stale message
|
|
360
|
+
* can never masquerade as fresh. Renders happen at the LAST delivery hop, so
|
|
361
|
+
* "today" is delivery-time local. An unparseable ts passes through verbatim. */
|
|
362
|
+
function renderTsForAgent(sentAt: string, now: Date): string {
|
|
363
|
+
const d = new Date(sentAt)
|
|
364
|
+
if (Number.isNaN(d.getTime())) return sentAt
|
|
365
|
+
const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
|
366
|
+
const sameLocalDay =
|
|
367
|
+
d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
|
368
|
+
return sameLocalDay ? time : `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${time}`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Render a WIRE envelope into the compact agent-facing presentation:
|
|
373
|
+
*
|
|
374
|
+
* <iap from="boris" runtime="claude" intelligence="artificial" ts="01:23:45">
|
|
375
|
+
* Reply via send_to_peer.
|
|
376
|
+
* <msg>raw body…</msg>
|
|
377
|
+
* </iap>
|
|
378
|
+
*
|
|
379
|
+
* Short attribute names, time-only `ts` (cross-day rule above), raw body
|
|
380
|
+
* (hybrid CDATA when the body quotes envelope machinery). Called ONLY at the
|
|
381
|
+
* two hops where text enters an LLM context (composeFirstMessage — wake boot +
|
|
382
|
+
* ephemeral drain; deliverWarm — live paths, gated on agent runtimes); bridge
|
|
383
|
+
* targets always receive the wire form their parsers expect.
|
|
384
|
+
*
|
|
385
|
+
* FAIL-OPEN: any decode/render problem returns the wire text unchanged — a
|
|
386
|
+
* presentation hiccup must never lose or mangle a delivery.
|
|
387
|
+
*/
|
|
388
|
+
export function renderEnvelopeForAgent(wire: string, now: Date = new Date()): string {
|
|
389
|
+
try {
|
|
390
|
+
const env = decodeEnvelope(wire)
|
|
391
|
+
const attrs = [`from="${escapeAttr(env.fromPersonality)}"`, `runtime="${escapeAttr(env.fromRuntime)}"`]
|
|
392
|
+
if (env.fromIntelligence) attrs.push(`intelligence="${escapeAttr(env.fromIntelligence)}"`)
|
|
393
|
+
if (env.sentAt) attrs.push(`ts="${escapeAttr(renderTsForAgent(env.sentAt, now))}"`)
|
|
394
|
+
if (env.topic) attrs.push(`topic="${escapeAttr(env.topic)}"`)
|
|
395
|
+
return [
|
|
396
|
+
`<iap ${attrs.join(' ')}>`,
|
|
397
|
+
IAP_INSTRUCTION,
|
|
398
|
+
...(env.attachments.length
|
|
399
|
+
? [`<attachments>${presentBody(env.attachments.join('\n'))}</attachments>`]
|
|
400
|
+
: []),
|
|
401
|
+
`<msg>${presentBody(env.message)}</msg>`,
|
|
402
|
+
'</iap>',
|
|
403
|
+
].join('\n')
|
|
404
|
+
} catch {
|
|
405
|
+
return wire // fail-open: deliver the wire form rather than risk the message
|
|
406
|
+
}
|
|
407
|
+
}
|
package/src/core/constants.ts
CHANGED
|
@@ -89,11 +89,12 @@ 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'])
|
|
97
98
|
|
|
98
99
|
export function isInfraRuntime(runtime: string): boolean {
|
|
99
100
|
return INFRA_RUNTIMES.has(runtime)
|
|
@@ -111,6 +112,7 @@ export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
|
|
|
111
112
|
notifier: 'NOTIFIER_RUNTIME_BIN',
|
|
112
113
|
telegram: 'TELEGRAM_RUNTIME_BIN',
|
|
113
114
|
voicetalk: 'VOICETALK_RUNTIME_BIN',
|
|
115
|
+
web: 'WEB_RUNTIME_BIN',
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
/** The PATH-resolvable default launcher name per infra runtime (when no abs path
|
|
@@ -120,6 +122,7 @@ export const INFRA_RUNTIME_DEFAULT_BIN: Readonly<Record<string, string>> = {
|
|
|
120
122
|
notifier: 'notifier-runtime',
|
|
121
123
|
telegram: 'telegram-runtime',
|
|
122
124
|
voicetalk: 'voicetalk-runtime',
|
|
125
|
+
web: 'web-runtime',
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
// codex MCP token-free import (contract Установка §codex MCP). codex REFUSES to import
|
|
@@ -197,8 +200,9 @@ export const PEERS_HOME_DIR = 'peers'
|
|
|
197
200
|
export const LAUNCHD_LABEL_PREFIX = 'com.iapeer.'
|
|
198
201
|
export const DAEMON_PLIST_LABEL = 'com.agfpd.iapeer'
|
|
199
202
|
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
// Shortened 0.4.86 (envelope-compaction F): the long preamble duplicated doctrine
|
|
204
|
+
// (origin-routing) for live sessions; the reply-routing cue is the load-bearing part.
|
|
205
|
+
export const IAP_INSTRUCTION = 'Reply via send_to_peer.'
|
|
202
206
|
|
|
203
207
|
export const ALWAYS_LOAD_META = {
|
|
204
208
|
'anthropic/alwaysLoad': true,
|
package/src/daemon/index.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function buildSendDescription(): string {
|
|
|
121
121
|
'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
|
|
122
122
|
'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
|
|
123
123
|
'',
|
|
124
|
-
"Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Inbound peer messages arrive as <iap from
|
|
124
|
+
"Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Inbound peer messages arrive as <iap from=\"...\" runtime=\"...\" intelligence=\"...\" ts=\"...\"> blocks; `ts` is the sender's dispatch time (local host time; date-prefixed when not sent today) — compare it with the current time to spot a stale/desynced message. The topic attribute is optional.",
|
|
125
125
|
].join('\n')
|
|
126
126
|
}
|
|
127
127
|
|
package/src/daemon/main.ts
CHANGED
|
@@ -181,7 +181,7 @@ export function makeEphemeralRouteDeps(
|
|
|
181
181
|
): EphemeralRouteDeps {
|
|
182
182
|
return {
|
|
183
183
|
isEphemeral: cwd => isEphemeralPeer(cwd),
|
|
184
|
-
deliver: async ({ peer, envelope, topic, runtime }) => {
|
|
184
|
+
deliver: async ({ peer, envelope, topic, runtime, sentAt }) => {
|
|
185
185
|
const rt = resolveWakeRuntime(runtime, peer)
|
|
186
186
|
if (!rt.ok) return rt
|
|
187
187
|
const identity = buildProcessAddress(rt.value, peer.personality)
|
|
@@ -201,7 +201,7 @@ export function makeEphemeralRouteDeps(
|
|
|
201
201
|
queued: true,
|
|
202
202
|
queuedBy: 'ephemeral',
|
|
203
203
|
queueDepth: depth,
|
|
204
|
-
ts: new Date().toISOString(),
|
|
204
|
+
ts: sentAt ?? new Date().toISOString(),
|
|
205
205
|
})
|
|
206
206
|
},
|
|
207
207
|
}
|
package/src/identity/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
// KeepAlive, so creating one installs its always-on plist. Imported from the
|
|
27
27
|
// launchd module directly (not the launch barrel) to keep the dependency surface
|
|
28
28
|
// minimal — launchd.ts pulls only core/*, so identity → launch introduces no cycle.
|
|
29
|
-
import { installAlwaysOnPlist } from '../launch/launchd.ts'
|
|
29
|
+
import { installAlwaysOnPlist, installedPlistRuntime } from '../launch/launchd.ts'
|
|
30
30
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
31
31
|
import { IapError } from '../core/errors.ts'
|
|
32
32
|
import {
|
|
@@ -623,13 +623,30 @@ 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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
626
|
+
// MULTI-INFRA BRIDGE: the always-on plist scheme is ONE per personality, so when the
|
|
627
|
+
// personality's plist is already OCCUPIED by ANOTHER infra runtime (arthur: telegram,
|
|
628
|
+
// now declaring web), installing here would clobber the live channel — and the
|
|
629
|
+
// installAlwaysOnPlist collision guard would fail the whole declaration. Instead:
|
|
630
|
+
// DECLARE the runtime (runtimes += <rt> below — identity `<rt>-<p>` resolves, delivery
|
|
631
|
+
// to a LIVE hosted session works) but leave the first channel's plist untouched. The
|
|
632
|
+
// new runtime's session is NOT launchd-owned until per-runtime plists (multi-infra)
|
|
633
|
+
// land — bring it up manually via `iapeer run-infra <p> <rt>` meanwhile.
|
|
634
|
+
const occupied = installedPlistRuntime(existing.personality, options.env)
|
|
635
|
+
if (occupied && occupied !== options.runtime) {
|
|
636
|
+
options.warn?.(
|
|
637
|
+
`always-on plist for "${existing.personality}" already holds runtime "${occupied}" — ` +
|
|
638
|
+
`"${options.runtime}" is DECLARED but gets no launchd plist (one plist per personality); ` +
|
|
639
|
+
`until per-runtime plists land, bring the session up manually: iapeer run-infra ${existing.personality} ${options.runtime}`,
|
|
640
|
+
)
|
|
641
|
+
} else {
|
|
642
|
+
installAlwaysOnPlist({
|
|
643
|
+
personality: existing.personality,
|
|
644
|
+
runtime: options.runtime,
|
|
645
|
+
cwd,
|
|
646
|
+
runtimeBin: options.runtimeBin,
|
|
647
|
+
env: options.env,
|
|
648
|
+
})
|
|
649
|
+
}
|
|
633
650
|
// Re-provision honors an EXPLICIT nature (a manifest-declared absent service bot survives
|
|
634
651
|
// update-runtime re-provision), else the runtime default (the legacy vocab-flip this fix
|
|
635
652
|
// preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
|
|
@@ -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,
|
|
@@ -56,6 +57,7 @@ export {
|
|
|
56
57
|
launchdPlistPath,
|
|
57
58
|
renderLaunchdPlist,
|
|
58
59
|
installAlwaysOnPlist,
|
|
60
|
+
installedPlistRuntime,
|
|
59
61
|
isFoundationOwnedPlist,
|
|
60
62
|
launchctlBootstrap,
|
|
61
63
|
bootstrapJobCore,
|
|
@@ -90,6 +92,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
|
|
|
90
92
|
if (runtime === 'telegram') return telegramAdapter
|
|
91
93
|
if (runtime === 'notifier') return notifierAdapter
|
|
92
94
|
if (runtime === 'voicetalk') return voicetalkAdapter
|
|
95
|
+
if (runtime === 'web') return webAdapter
|
|
93
96
|
throw new Error(`no launch adapter for runtime "${runtime}"`)
|
|
94
97
|
}
|
|
95
98
|
|
package/src/launch/launchd.ts
CHANGED
|
@@ -81,6 +81,20 @@ function readPlistEnvVar(path: string, key: string): string | undefined {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The PEER_RUNTIME baked into the personality's EXISTING foundation-owned always-on
|
|
86
|
+
* plist, or undefined when there is no plist / it is foreign-owned / it carries no
|
|
87
|
+
* runtime. The plist scheme is ONE per personality (`com.iapeer.<p>.plist` — the
|
|
88
|
+
* runtime rides only in ProgramArguments/env), so this is how a caller detects that
|
|
89
|
+
* the personality's always-on slot is already OCCUPIED by another infra runtime
|
|
90
|
+
* (e.g. arthur's telegram) before asking for a second one (multi-infra collision).
|
|
91
|
+
*/
|
|
92
|
+
export function installedPlistRuntime(personality: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
93
|
+
const path = launchdPlistPath(personality, env)
|
|
94
|
+
if (!existsSync(path) || !isFoundationOwnedPlist(path)) return undefined
|
|
95
|
+
return readPlistEnvVar(path, 'PEER_RUNTIME')
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/**
|
|
85
99
|
* Resolve an executable to an ABSOLUTE path against `env.PATH` (the RICH
|
|
86
100
|
* provisioning PATH), so the result can be baked into a launchd plist whose own
|
|
@@ -614,6 +628,22 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
|
|
|
614
628
|
`or other manager owns it; rename the peer to avoid the com.iapeer.<personality> collision`,
|
|
615
629
|
)
|
|
616
630
|
}
|
|
631
|
+
// MULTI-INFRA COLLISION GUARD (fail-loud, never a silent clobber): the plist scheme
|
|
632
|
+
// is ONE per personality, so installing a SECOND infra runtime for the same
|
|
633
|
+
// personality would OVERWRITE the first channel's plist (e.g. web over arthur's
|
|
634
|
+
// telegram) and kill that channel on the next bootout/reboot. Refuse loudly until
|
|
635
|
+
// per-runtime plists (multi-infra) land. A deliberate runtime SWITCH is an operator
|
|
636
|
+
// act: `iapeer stop <p>` + delete the plist, then re-provision.
|
|
637
|
+
const bakedRuntime = existsSync(path) ? readPlistEnvVar(path, 'PEER_RUNTIME') : undefined
|
|
638
|
+
if (bakedRuntime && bakedRuntime !== opts.runtime) {
|
|
639
|
+
throw new IapError(
|
|
640
|
+
`refusing to overwrite launchd plist ${path}: it already holds the always-on ` +
|
|
641
|
+
`"${bakedRuntime}" session for "${opts.personality}" (the plist scheme is one per ` +
|
|
642
|
+
`personality) — installing "${opts.runtime}" here would silently kill that channel. ` +
|
|
643
|
+
`Multi-infra per personality needs per-runtime plists (not yet supported); for a ` +
|
|
644
|
+
`deliberate runtime switch: iapeer stop ${opts.personality}, delete the plist, re-provision.`,
|
|
645
|
+
)
|
|
646
|
+
}
|
|
617
647
|
// GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/, NOT per-peer
|
|
618
648
|
// <cwd>/.iapeer/logs/ — host-service logs live in the global log area.
|
|
619
649
|
const logDir = peerLogsDir(opts.personality, { env })
|
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
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type Runtime,
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
|
+
import { renderEnvelopeForAgent } from '../codec/index.ts'
|
|
28
29
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
30
|
import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
|
|
30
31
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
@@ -917,17 +918,23 @@ function composePeerPrompt(
|
|
|
917
918
|
* yields no seed and never blocks the wake (the seed is optional).
|
|
918
919
|
*/
|
|
919
920
|
export function composeFirstMessage(cwd: string, task: string, fresh: boolean): string {
|
|
920
|
-
|
|
921
|
+
// Envelope-compaction F: the boot first-message enters an LLM context (wake
|
|
922
|
+
// targets are agent runtimes by construction — launchd-held bridges are never
|
|
923
|
+
// woken, H4), so the routed WIRE envelope is rendered into the compact
|
|
924
|
+
// agent-facing presentation here — the boot-side twin of deliverWarm's render.
|
|
925
|
+
// fail-open inside the renderer (non-envelope/empty task passes through as-is).
|
|
926
|
+
const presented = task ? renderEnvelopeForAgent(task) : task
|
|
927
|
+
if (!fresh) return presented
|
|
921
928
|
let seed: string | undefined
|
|
922
929
|
try {
|
|
923
930
|
seed = readPeerProfile(cwd)?.initial_prompt
|
|
924
931
|
} catch {
|
|
925
932
|
/* invalid/absent profile → no seed */
|
|
926
933
|
}
|
|
927
|
-
if (!seed) return
|
|
934
|
+
if (!seed) return presented
|
|
928
935
|
// seed + the routed message (both delivered, seed first). When there is NO incoming
|
|
929
936
|
// message (an eager /new re-launch, C4b — task is empty), the seed is self-sufficient.
|
|
930
|
-
return
|
|
937
|
+
return presented ? `${seed}\n\n${presented}` : seed
|
|
931
938
|
}
|
|
932
939
|
|
|
933
940
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1313,10 +1320,17 @@ interface SuperviseCtx {
|
|
|
1313
1320
|
*/
|
|
1314
1321
|
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1315
1322
|
const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
|
|
1316
|
-
// H4 — FIRST, before any reap. A launchd-managed peer is read-only
|
|
1323
|
+
// H4 — FIRST, before any reap. A launchd-managed peer is read-only: never wake,
|
|
1324
|
+
// never kill, never sweep its PROCESS. Its stale `.session` however is the
|
|
1325
|
+
// DAEMON'S own supervisory record (written back when the peer was daemon-owned),
|
|
1326
|
+
// not the peer's — a peer that moved under launchd would otherwise re-enter this
|
|
1327
|
+
// skip on every tick forever (follow-up 09.06: latent garbage, not a delivery
|
|
1328
|
+
// bug). Drop the record once; if the plist is ever removed again, the next wake
|
|
1329
|
+
// recreates it.
|
|
1317
1330
|
if (isLaunchdManaged(s.personality, env)) {
|
|
1318
|
-
|
|
1319
|
-
|
|
1331
|
+
removeSessionState(cfg, s.identity)
|
|
1332
|
+
if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4-stale-state-dropped' })
|
|
1333
|
+
return { identity: s.identity, action: 'skipped-launchd', reason: 'launchd-managed; stale daemon .session dropped' }
|
|
1320
1334
|
}
|
|
1321
1335
|
const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
|
|
1322
1336
|
if (!isAlive(sock, s.identity)) {
|
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
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
buildSocketPath,
|
|
33
33
|
type ProcessAddress,
|
|
34
34
|
} from '../core/socket.ts'
|
|
35
|
-
import { buildEnvelope } from '../codec/index.ts'
|
|
35
|
+
import { buildEnvelope, formatSentAt, renderEnvelopeForAgent } from '../codec/index.ts'
|
|
36
36
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
37
37
|
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
38
38
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
@@ -238,12 +238,18 @@ export async function deliverWarm(
|
|
|
238
238
|
seam: WarmDeliverSeam = {},
|
|
239
239
|
env: NodeJS.ProcessEnv = process.env,
|
|
240
240
|
): Promise<Result<void>> {
|
|
241
|
+
// Envelope-compaction F: an AGENT target (claude/codex — an LLM reads the text)
|
|
242
|
+
// receives the compact presentation; a bridge target (telegram/notifier/…)
|
|
243
|
+
// receives the WIRE form its parser expects. Rendered BEFORE deliverViaHost so
|
|
244
|
+
// the transcript-confirm matches the text actually delivered. fail-open inside
|
|
245
|
+
// the renderer: a render problem degrades to the wire form, never to a loss.
|
|
246
|
+
const payload = isSupportedLocalRuntime(target.runtime) ? renderEnvelopeForAgent(envelope) : envelope
|
|
241
247
|
const address = target.address
|
|
242
248
|
const prev = deliverTails.get(address) ?? Promise.resolve()
|
|
243
249
|
// run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
|
|
244
250
|
const run = prev.then(
|
|
245
|
-
() => deliverViaHost(target,
|
|
246
|
-
() => deliverViaHost(target,
|
|
251
|
+
() => deliverViaHost(target, payload, cwd, seam, env),
|
|
252
|
+
() => deliverViaHost(target, payload, cwd, seam, env),
|
|
247
253
|
)
|
|
248
254
|
deliverTails.set(
|
|
249
255
|
address,
|
|
@@ -302,11 +308,13 @@ async function deliverViaHost(
|
|
|
302
308
|
// codex took the socket-ack short-circuit above). A bare transcript/pane-log mtime BUMP is NOT proof
|
|
303
309
|
// the session took THIS message: a receiver in an active turn advances both mtimes with its OWN
|
|
304
310
|
// rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23: ok=true,
|
|
305
|
-
// message gone). The ONLY proof is a NEW transcript record CARRYING
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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).
|
|
310
318
|
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
|
|
311
319
|
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
312
320
|
const graceMs = confirmGraceMs()
|
|
@@ -884,6 +892,9 @@ export interface EphemeralRouteDeps {
|
|
|
884
892
|
envelope: string
|
|
885
893
|
topic?: string
|
|
886
894
|
runtime?: string
|
|
895
|
+
/** Router-acceptance instant (formatSentAt) — the enqueue ACK's `ts` mirrors
|
|
896
|
+
* it so sender-side ts ≡ envelope ts holds on the queued path too. */
|
|
897
|
+
sentAt?: string
|
|
887
898
|
}) => Promise<Result<RouteResult>>
|
|
888
899
|
}
|
|
889
900
|
|
|
@@ -904,6 +915,8 @@ export interface ComposerQueueTryEnqueueArgs {
|
|
|
904
915
|
target: DeliveryTarget
|
|
905
916
|
envelope: string
|
|
906
917
|
topic?: string
|
|
918
|
+
/** Router-acceptance instant (formatSentAt) — the queued ACK's `ts` mirrors it. */
|
|
919
|
+
sentAt?: string
|
|
907
920
|
}
|
|
908
921
|
|
|
909
922
|
export interface ComposerQueueRouteDeps {
|
|
@@ -1192,7 +1205,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1192
1205
|
queued: true,
|
|
1193
1206
|
queuedBy: 'composer',
|
|
1194
1207
|
queueDepth: q.length,
|
|
1195
|
-
ts: new Date().toISOString(),
|
|
1208
|
+
ts: args.sentAt ?? new Date().toISOString(),
|
|
1196
1209
|
})
|
|
1197
1210
|
},
|
|
1198
1211
|
failAll: async reason => {
|
|
@@ -1317,12 +1330,19 @@ export async function routeSend(
|
|
|
1317
1330
|
const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
|
|
1318
1331
|
if (!intendedGuard.ok) return intendedGuard
|
|
1319
1332
|
|
|
1333
|
+
// Stamped ONCE at router acceptance: the same instant travels as the envelope's
|
|
1334
|
+
// `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
|
|
1335
|
+
// side) — the two correspond literally, which is what makes async-desync
|
|
1336
|
+
// visible (the recipient can tell how old the message it is reading is).
|
|
1337
|
+
const sentAt = formatSentAt(new Date())
|
|
1320
1338
|
// Built once — it is both the live-delivery payload and, on a miss, the wake
|
|
1321
|
-
// first-message (the woken session receives it as its boot task).
|
|
1339
|
+
// first-message (the woken session receives it as its boot task). This is the
|
|
1340
|
+
// WIRE form; agent-bound hops re-render it compactly at delivery.
|
|
1322
1341
|
const envelope = buildEnvelope({
|
|
1323
1342
|
fromPersonality: caller.personality,
|
|
1324
1343
|
fromRuntime: caller.runtime,
|
|
1325
1344
|
fromIntelligence: caller.intelligence,
|
|
1345
|
+
sentAt,
|
|
1326
1346
|
topic,
|
|
1327
1347
|
attachments: attachmentsResult.value,
|
|
1328
1348
|
message,
|
|
@@ -1336,7 +1356,7 @@ export async function routeSend(
|
|
|
1336
1356
|
// would deadlock its own die-after-reply reap on a forever-non-empty queue).
|
|
1337
1357
|
if (deps.ephemeral?.isEphemeral(peer.cwd)) {
|
|
1338
1358
|
if (caller.personality === peer.personality) return err('cannot send to self')
|
|
1339
|
-
return deps.ephemeral.deliver({ peer, envelope, topic, runtime })
|
|
1359
|
+
return deps.ephemeral.deliver({ peer, envelope, topic, runtime, sentAt })
|
|
1340
1360
|
}
|
|
1341
1361
|
|
|
1342
1362
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer, env)
|
|
@@ -1346,7 +1366,7 @@ export async function routeSend(
|
|
|
1346
1366
|
// land on telegram without an override and without telegram being the default).
|
|
1347
1367
|
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
1348
1368
|
if (!liveGuard.ok) return liveGuard
|
|
1349
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic })
|
|
1369
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
|
|
1350
1370
|
if (queued) return queued
|
|
1351
1371
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
1352
1372
|
// deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
|
|
@@ -1359,7 +1379,7 @@ export async function routeSend(
|
|
|
1359
1379
|
ok: true,
|
|
1360
1380
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
1361
1381
|
woke: false,
|
|
1362
|
-
ts:
|
|
1382
|
+
ts: sentAt,
|
|
1363
1383
|
})
|
|
1364
1384
|
}
|
|
1365
1385
|
|
|
@@ -1391,7 +1411,7 @@ export async function routeSend(
|
|
|
1391
1411
|
ok: true,
|
|
1392
1412
|
delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
|
|
1393
1413
|
woke: false,
|
|
1394
|
-
ts:
|
|
1414
|
+
ts: sentAt,
|
|
1395
1415
|
})
|
|
1396
1416
|
}
|
|
1397
1417
|
// resolved but the deliver didn't land (session still settling) → keep polling
|
|
@@ -1427,7 +1447,7 @@ export async function routeSend(
|
|
|
1427
1447
|
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
1428
1448
|
// surfaced loudly (false-FAIL over false-OK).
|
|
1429
1449
|
if (woke.taskDelivered === false) {
|
|
1430
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
|
|
1450
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt })
|
|
1431
1451
|
if (queued) return queued
|
|
1432
1452
|
const delivered = await deliverWarm(live.value, envelope, peer.cwd, {}, env)
|
|
1433
1453
|
if (!delivered.ok) return delivered
|
|
@@ -1436,7 +1456,7 @@ export async function routeSend(
|
|
|
1436
1456
|
ok: true,
|
|
1437
1457
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
1438
1458
|
woke: false, // honest: this sender's envelope went the LIVE path, not a boot
|
|
1439
|
-
ts:
|
|
1459
|
+
ts: sentAt,
|
|
1440
1460
|
})
|
|
1441
1461
|
}
|
|
1442
1462
|
// The envelope was delivered as the boot first-message during wake.
|
|
@@ -1444,6 +1464,6 @@ export async function routeSend(
|
|
|
1444
1464
|
ok: true,
|
|
1445
1465
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
1446
1466
|
woke: true,
|
|
1447
|
-
ts:
|
|
1467
|
+
ts: sentAt,
|
|
1448
1468
|
})
|
|
1449
1469
|
}
|
package/src/tray/install.ts
CHANGED
|
@@ -486,9 +486,18 @@ export function trayAttachTerm(opts: {
|
|
|
486
486
|
|
|
487
487
|
export interface TrayStatus {
|
|
488
488
|
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
489
|
-
swiftbar: {
|
|
489
|
+
swiftbar: {
|
|
490
|
+
installed: boolean
|
|
491
|
+
pluginDir?: string
|
|
492
|
+
/** GROUND TRUTH of the menu-bar icon: AppKit persists NSStatusItem visibility per
|
|
493
|
+
* autosaveName in SwiftBar's defaults (`NSStatusItem VisibleCC <plugin>`). false =
|
|
494
|
+
* the icon is hidden RIGHT NOW (whatever hid it — a mid-chunk decode nil on an old
|
|
495
|
+
* binary, a ⌘-drag removal); undefined = key absent / SwiftBar not installed. The
|
|
496
|
+
* first thing to read when "the icon is gone" is reported — no screen needed. */
|
|
497
|
+
iconVisible?: boolean
|
|
498
|
+
}
|
|
490
499
|
plugin: { installed: boolean; path?: string }
|
|
491
|
-
/**
|
|
500
|
+
/** Tray-host supervisor LaunchAgent: registered (our plist present) + its path. */
|
|
492
501
|
autostart: { registered: boolean; path?: string }
|
|
493
502
|
}
|
|
494
503
|
|
|
@@ -503,9 +512,14 @@ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeA
|
|
|
503
512
|
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
504
513
|
const autostartPath = swiftBarAutostartPlistPath(env)
|
|
505
514
|
const autostartOurs = existsSync(autostartPath) && isFoundationOwnedPlist(autostartPath)
|
|
515
|
+
let iconVisible: boolean | undefined
|
|
516
|
+
if (installed) {
|
|
517
|
+
const r = run('defaults', ['read', SWIFTBAR_DOMAIN, `NSStatusItem VisibleCC ${PLUGIN_BASENAME}`])
|
|
518
|
+
if (r.status === 0) iconVisible = r.stdout.trim() === '1'
|
|
519
|
+
}
|
|
506
520
|
return {
|
|
507
521
|
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
508
|
-
swiftbar: { installed, pluginDir: cfgDir },
|
|
522
|
+
swiftbar: { installed, pluginDir: cfgDir, iconVisible },
|
|
509
523
|
plugin: { installed: Boolean(found), path: found },
|
|
510
524
|
autostart: { registered: autostartOurs, path: autostartOurs ? autostartPath : undefined },
|
|
511
525
|
}
|