@agfpd/iapeer 0.4.85 → 0.4.86

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.
@@ -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**. Repairs nothing. |
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.85",
3
+ "version": "0.4.86",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -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
- `login autostart: ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
2613
+ `supervisor (autostart): ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
2612
2614
  )
2613
2615
  return 0
2614
2616
  }
@@ -30,11 +30,37 @@ export interface EnvelopeInput {
30
30
  fromPersonality: string
31
31
  fromRuntime: Runtime
32
32
  fromIntelligence: Intelligence
33
+ /** ISO-8601 LOCAL instant with offset (formatSentAt) — the moment the router
34
+ * ACCEPTED the send; the same string is returned to the sender as the `ts`
35
+ * field of its send_to_peer result, so recipient-side `ts` and sender-side
36
+ * `ts` correspond literally. Rendered as the `ts` open-tag attribute.
37
+ * ADDITIVE and optional: every known envelope consumer reads attributes by
38
+ * NAME and ignores unknown ones; absent on legacy envelopes. On the WIRE this
39
+ * is always the FULL date-time (durable stores — disk queues, pane logs —
40
+ * must be unambiguous); the agent-facing render compacts it (see
41
+ * renderEnvelopeForAgent). */
42
+ sentAt?: string
33
43
  topic?: string
34
44
  attachments?: readonly string[]
35
45
  message: string
36
46
  }
37
47
 
48
+ /** Format a Date as ISO-8601 LOCAL time with numeric offset, second precision:
49
+ * `2026-07-14T01:23:45+03:00`. Local (not UTC-Z) on purpose: the fleet's agents
50
+ * compare envelope timestamps against their LOCAL statusline clock — a Z-form
51
+ * invites silent off-by-offset misreads by an LLM reader. */
52
+ export function formatSentAt(date: Date): string {
53
+ const pad = (n: number): string => String(n).padStart(2, '0')
54
+ const offMin = -date.getTimezoneOffset() // minutes EAST of UTC
55
+ const sign = offMin >= 0 ? '+' : '-'
56
+ const abs = Math.abs(offMin)
57
+ return (
58
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
59
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
60
+ `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
61
+ )
62
+ }
63
+
38
64
  function escapeAttr(value: string): string {
39
65
  return value
40
66
  .replace(/&/g, '&amp;')
@@ -49,12 +75,20 @@ function cdata(value: string): string {
49
75
  return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
50
76
  }
51
77
 
78
+ /** WIRE encoder — the canonical durable form (disk queues, bridge deliveries,
79
+ * pane logs): legacy attribute names + always-CDATA body, so every deployed
80
+ * sibling parser (telegram-/notifier-/voicetalk-runtime) keeps working
81
+ * unchanged. `ts` is the only additive attribute (envelope-compaction F).
82
+ * Agent-bound deliveries are re-rendered compactly at the LAST hop — see
83
+ * renderEnvelopeForAgent; the wire form itself never slims (its bytes cost no
84
+ * tokens; only the LLM-context presentation does). */
52
85
  export function buildEnvelope(input: EnvelopeInput): string {
53
86
  const attrs = [
54
87
  `from-personality="${escapeAttr(input.fromPersonality)}"`,
55
88
  `from-runtime="${escapeAttr(input.fromRuntime)}"`,
56
89
  `from-intelligence="${escapeAttr(input.fromIntelligence)}"`,
57
90
  ]
91
+ if (input.sentAt) attrs.push(`ts="${escapeAttr(input.sentAt)}"`)
58
92
  const topic = input.topic?.trim()
59
93
  if (topic) {
60
94
  attrs.push(`topic="${escapeAttr(topic.slice(0, MAX_TOPIC_LEN))}"`)
@@ -146,13 +180,21 @@ export interface IapEnvelope {
146
180
  fromPersonality: string
147
181
  fromRuntime: Runtime
148
182
  fromIntelligence?: Intelligence
183
+ /** The sender's dispatch instant (`ts` attribute) — full local ISO on the wire;
184
+ * absent on legacy envelopes. */
185
+ sentAt?: string
149
186
  topic?: string
150
187
  attachments: string[]
151
188
  message: string
152
189
  }
153
190
 
191
+ /** NAME-ANCHORED attribute lookup. The anchor `(^|\s)` is load-bearing: the old
192
+ * unanchored regex made `runtime="` match the TAIL of `from-runtime="…"` (and
193
+ * `intelligence="` the tail of `from-intelligence="…"`) — a latent mine that the
194
+ * read-both decoder below would have stepped on (the short-name lookup must NOT
195
+ * be satisfied by a legacy long-name attribute). */
154
196
  function attrValue(attrs: string, name: string): string | undefined {
155
- const re = new RegExp(`${name}="([^"]*)"`)
197
+ const re = new RegExp(`(?:^|\\s)${name}="([^"]*)"`)
156
198
  const m = re.exec(attrs)
157
199
  return m ? unescapeAttr(m[1]) : undefined
158
200
  }
@@ -167,25 +209,34 @@ function unescapeAttr(value: string): string {
167
209
  .replace(/&amp;/g, '&')
168
210
  }
169
211
 
212
+ /** READ-BOTH decoder: accepts the legacy wire names (`from-personality` /
213
+ * `from-runtime` / `from-intelligence`, `<message>`) AND the compact
214
+ * presentation names (`from` / `runtime` / `intelligence`, `<msg>`) — so any
215
+ * envelope form ever emitted on this host (wire, agent-facing render, historic
216
+ * transcripts) decodes. Short names win when both are present. */
170
217
  export function decodeEnvelope(xml: string): IapEnvelope {
171
218
  const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
172
219
  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')
220
+ const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
221
+ const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
175
222
  if (!fromPersonality || !fromRuntime) {
176
223
  throw new IapError('invalid IAP envelope: missing from-personality/from-runtime')
177
224
  }
178
225
  // READ-COMPAT: a legacy peer (live telegram) may stamp from-intelligence="human";
179
226
  // normalize human→natural / scripted→absent, drop a genuinely unknown value.
180
- const fromIntelligence = normalizeIntelligenceValue(attrValue(open[1], 'from-intelligence'))
181
- const message = readTagContent(xml, 'message')
227
+ const fromIntelligence = normalizeIntelligenceValue(
228
+ attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
229
+ )
230
+ const message = readTagContent(xml, 'message') ?? readTagContent(xml, 'msg')
182
231
  if (message === undefined) throw new IapError('invalid IAP envelope: missing message')
183
232
  const attachmentsRaw = readTagContent(xml, 'attachments')
184
233
  const topic = attrValue(open[1], 'topic')
234
+ const sentAt = attrValue(open[1], 'ts')
185
235
  return {
186
236
  fromPersonality,
187
237
  fromRuntime,
188
238
  ...(fromIntelligence ? { fromIntelligence } : {}),
239
+ ...(sentAt ? { sentAt } : {}),
189
240
  ...(topic ? { topic } : {}),
190
241
  attachments: attachmentsRaw
191
242
  ? attachmentsRaw.split('\n').map(item => item.trim()).filter(Boolean)
@@ -209,7 +260,12 @@ function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
209
260
  return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
210
261
  }
211
262
  if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
212
- return m[1].includes('from-personality="') && m[1].includes('from-runtime="') ? 'valid' : 'invalid'
263
+ // Read-both (envelope-compaction F): a real open tag carries the required pair
264
+ // under EITHER naming — legacy wire (from-personality/from-runtime) or compact
265
+ // presentation (from/runtime, name-anchored so a legacy tail never satisfies it).
266
+ const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
267
+ const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
268
+ return hasPersonality && hasRuntime ? 'valid' : 'invalid'
213
269
  }
214
270
 
215
271
  /**
@@ -257,3 +313,85 @@ export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: s
257
313
  rest = rest.slice(envelopeEnd)
258
314
  }
259
315
  }
316
+
317
+ // ─────────────────────────────────────────────────────────────────────────────
318
+ // Agent-facing presentation render (envelope-compaction F)
319
+ // ─────────────────────────────────────────────────────────────────────────────
320
+
321
+ /** Presentation-hybrid gate: a body that itself contains envelope machinery
322
+ * (a quoted envelope, tag markers, CDATA syntax) or a literal CR keeps its
323
+ * CDATA wrapping in the presentation too — the cost of the gate firing is
324
+ * purely COSMETIC (the agent sees today's CDATA form), whereas rendering such
325
+ * a body raw would show the agent a "broken" nested envelope. Plain `<` (code
326
+ * snippets etc.) renders raw: nothing parses the presentation. */
327
+ const PRESENTATION_CDATA_MARKERS = [
328
+ '<iap ',
329
+ '</iap>',
330
+ '<message>',
331
+ '</message>',
332
+ '<msg>',
333
+ '</msg>',
334
+ '<attachments>',
335
+ '</attachments>',
336
+ '<![CDATA[',
337
+ ']]>',
338
+ '\r',
339
+ ] as const
340
+
341
+ function presentBody(value: string): string {
342
+ return PRESENTATION_CDATA_MARKERS.some(marker => value.includes(marker)) ? cdata(value) : value
343
+ }
344
+
345
+ /** Compact the wire `ts` (full local ISO with offset) for the agent's eyes:
346
+ * time-only `01:23:45` when the send happened on the SAME local calendar day
347
+ * as the render (the normal case), date-prefixed `2026-07-13 01:23:45` when it
348
+ * did not (queue drains, long sleeps, midnight crossings) — so a stale message
349
+ * can never masquerade as fresh. Renders happen at the LAST delivery hop, so
350
+ * "today" is delivery-time local. An unparseable ts passes through verbatim. */
351
+ function renderTsForAgent(sentAt: string, now: Date): string {
352
+ const d = new Date(sentAt)
353
+ if (Number.isNaN(d.getTime())) return sentAt
354
+ const pad = (n: number): string => String(n).padStart(2, '0')
355
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
356
+ const sameLocalDay =
357
+ d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
358
+ return sameLocalDay ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
359
+ }
360
+
361
+ /**
362
+ * Render a WIRE envelope into the compact agent-facing presentation:
363
+ *
364
+ * <iap from="boris" runtime="claude" intelligence="artificial" ts="01:23:45">
365
+ * Reply via send_to_peer.
366
+ * <msg>raw body…</msg>
367
+ * </iap>
368
+ *
369
+ * Short attribute names, time-only `ts` (cross-day rule above), raw body
370
+ * (hybrid CDATA when the body quotes envelope machinery). Called ONLY at the
371
+ * two hops where text enters an LLM context (composeFirstMessage — wake boot +
372
+ * ephemeral drain; deliverWarm — live paths, gated on agent runtimes); bridge
373
+ * targets always receive the wire form their parsers expect.
374
+ *
375
+ * FAIL-OPEN: any decode/render problem returns the wire text unchanged — a
376
+ * presentation hiccup must never lose or mangle a delivery.
377
+ */
378
+ export function renderEnvelopeForAgent(wire: string, now: Date = new Date()): string {
379
+ try {
380
+ const env = decodeEnvelope(wire)
381
+ const attrs = [`from="${escapeAttr(env.fromPersonality)}"`, `runtime="${escapeAttr(env.fromRuntime)}"`]
382
+ if (env.fromIntelligence) attrs.push(`intelligence="${escapeAttr(env.fromIntelligence)}"`)
383
+ if (env.sentAt) attrs.push(`ts="${escapeAttr(renderTsForAgent(env.sentAt, now))}"`)
384
+ if (env.topic) attrs.push(`topic="${escapeAttr(env.topic)}"`)
385
+ return [
386
+ `<iap ${attrs.join(' ')}>`,
387
+ IAP_INSTRUCTION,
388
+ ...(env.attachments.length
389
+ ? [`<attachments>${presentBody(env.attachments.join('\n'))}</attachments>`]
390
+ : []),
391
+ `<msg>${presentBody(env.message)}</msg>`,
392
+ '</iap>',
393
+ ].join('\n')
394
+ } catch {
395
+ return wire // fail-open: deliver the wire form rather than risk the message
396
+ }
397
+ }
@@ -197,8 +197,9 @@ export const PEERS_HOME_DIR = 'peers'
197
197
  export const LAUNCHD_LABEL_PREFIX = 'com.iapeer.'
198
198
  export const DAEMON_PLIST_LABEL = 'com.agfpd.iapeer'
199
199
 
200
- export const IAP_INSTRUCTION =
201
- 'IAP message from known peers. Reply via send_to_peer.'
200
+ // Shortened 0.4.86 (envelope-compaction F): the long preamble duplicated doctrine
201
+ // (origin-routing) for live sessions; the reply-routing cue is the load-bearing part.
202
+ export const IAP_INSTRUCTION = 'Reply via send_to_peer.'
202
203
 
203
204
  export const ALWAYS_LOAD_META = {
204
205
  'anthropic/alwaysLoad': true,
@@ -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-personality=\"...\" from-runtime=\"...\" from-intelligence=\"...\"> blocks. The topic attribute is optional.",
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
 
@@ -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
  }
@@ -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
- if (!fresh) return task
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 task
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 task ? `${seed}\n\n${task}` : seed
937
+ return presented ? `${seed}\n\n${presented}` : seed
931
938
  }
932
939
 
933
940
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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, envelope, cwd, seam, env),
246
- () => deliverViaHost(target, envelope, cwd, seam, env),
251
+ () => deliverViaHost(target, payload, cwd, seam, env),
252
+ () => deliverViaHost(target, payload, cwd, seam, env),
247
253
  )
248
254
  deliverTails.set(
249
255
  address,
@@ -884,6 +890,9 @@ export interface EphemeralRouteDeps {
884
890
  envelope: string
885
891
  topic?: string
886
892
  runtime?: string
893
+ /** Router-acceptance instant (formatSentAt) — the enqueue ACK's `ts` mirrors
894
+ * it so sender-side ts ≡ envelope ts holds on the queued path too. */
895
+ sentAt?: string
887
896
  }) => Promise<Result<RouteResult>>
888
897
  }
889
898
 
@@ -904,6 +913,8 @@ export interface ComposerQueueTryEnqueueArgs {
904
913
  target: DeliveryTarget
905
914
  envelope: string
906
915
  topic?: string
916
+ /** Router-acceptance instant (formatSentAt) — the queued ACK's `ts` mirrors it. */
917
+ sentAt?: string
907
918
  }
908
919
 
909
920
  export interface ComposerQueueRouteDeps {
@@ -1192,7 +1203,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1192
1203
  queued: true,
1193
1204
  queuedBy: 'composer',
1194
1205
  queueDepth: q.length,
1195
- ts: new Date().toISOString(),
1206
+ ts: args.sentAt ?? new Date().toISOString(),
1196
1207
  })
1197
1208
  },
1198
1209
  failAll: async reason => {
@@ -1317,12 +1328,19 @@ export async function routeSend(
1317
1328
  const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
1318
1329
  if (!intendedGuard.ok) return intendedGuard
1319
1330
 
1331
+ // Stamped ONCE at router acceptance: the same instant travels as the envelope's
1332
+ // `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
1333
+ // side) — the two correspond literally, which is what makes async-desync
1334
+ // visible (the recipient can tell how old the message it is reading is).
1335
+ const sentAt = formatSentAt(new Date())
1320
1336
  // 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).
1337
+ // first-message (the woken session receives it as its boot task). This is the
1338
+ // WIRE form; agent-bound hops re-render it compactly at delivery.
1322
1339
  const envelope = buildEnvelope({
1323
1340
  fromPersonality: caller.personality,
1324
1341
  fromRuntime: caller.runtime,
1325
1342
  fromIntelligence: caller.intelligence,
1343
+ sentAt,
1326
1344
  topic,
1327
1345
  attachments: attachmentsResult.value,
1328
1346
  message,
@@ -1336,7 +1354,7 @@ export async function routeSend(
1336
1354
  // would deadlock its own die-after-reply reap on a forever-non-empty queue).
1337
1355
  if (deps.ephemeral?.isEphemeral(peer.cwd)) {
1338
1356
  if (caller.personality === peer.personality) return err('cannot send to self')
1339
- return deps.ephemeral.deliver({ peer, envelope, topic, runtime })
1357
+ return deps.ephemeral.deliver({ peer, envelope, topic, runtime, sentAt })
1340
1358
  }
1341
1359
 
1342
1360
  const target = resolvePeerDeliveryTarget(personality, runtime, peer, env)
@@ -1346,7 +1364,7 @@ export async function routeSend(
1346
1364
  // land on telegram without an override and without telegram being the default).
1347
1365
  const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
1348
1366
  if (!liveGuard.ok) return liveGuard
1349
- const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic })
1367
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
1350
1368
  if (queued) return queued
1351
1369
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
1352
1370
  // deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
@@ -1359,7 +1377,7 @@ export async function routeSend(
1359
1377
  ok: true,
1360
1378
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
1361
1379
  woke: false,
1362
- ts: new Date().toISOString(),
1380
+ ts: sentAt,
1363
1381
  })
1364
1382
  }
1365
1383
 
@@ -1391,7 +1409,7 @@ export async function routeSend(
1391
1409
  ok: true,
1392
1410
  delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
1393
1411
  woke: false,
1394
- ts: new Date().toISOString(),
1412
+ ts: sentAt,
1395
1413
  })
1396
1414
  }
1397
1415
  // resolved but the deliver didn't land (session still settling) → keep polling
@@ -1427,7 +1445,7 @@ export async function routeSend(
1427
1445
  // a false ok:true — the class the contract forbids. A delivery failure here is
1428
1446
  // surfaced loudly (false-FAIL over false-OK).
1429
1447
  if (woke.taskDelivered === false) {
1430
- const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
1448
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt })
1431
1449
  if (queued) return queued
1432
1450
  const delivered = await deliverWarm(live.value, envelope, peer.cwd, {}, env)
1433
1451
  if (!delivered.ok) return delivered
@@ -1436,7 +1454,7 @@ export async function routeSend(
1436
1454
  ok: true,
1437
1455
  delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
1438
1456
  woke: false, // honest: this sender's envelope went the LIVE path, not a boot
1439
- ts: new Date().toISOString(),
1457
+ ts: sentAt,
1440
1458
  })
1441
1459
  }
1442
1460
  // The envelope was delivered as the boot first-message during wake.
@@ -1444,6 +1462,6 @@ export async function routeSend(
1444
1462
  ok: true,
1445
1463
  delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
1446
1464
  woke: true,
1447
- ts: new Date().toISOString(),
1465
+ ts: sentAt,
1448
1466
  })
1449
1467
  }
@@ -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: { installed: boolean; pluginDir?: string }
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
- /** Login-autostart LaunchAgent: registered (our plist present) + its path. */
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
  }