@agfpd/iapeer 0.4.61 → 0.4.63
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/15-fleet-api.md +142 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +142 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/daemon/fleet.ts +654 -0
- package/src/daemon/index.ts +66 -14
- package/src/daemon/main.ts +6 -0
- package/src/launch/adapters/claude.ts +44 -0
- package/src/launch/types.ts +16 -0
- package/src/storage/rotatelog.ts +0 -0
- package/src/supervisor/boot.ts +19 -9
- package/src/supervisor/daemon.ts +17 -1
- package/src/tui/dashboard/data.ts +5 -21
- package/src/tui/dashboard/model.ts +12 -25
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// Fleet-API — the daemon's OPERATOR-CLIENT HTTP surface (Ф0 of iapeer-tray: one
|
|
2
|
+
// fleet-API in the daemon, many thin faces on top — SwiftBar / native tray / web
|
|
3
|
+
// gateway / telegram approval buttons). Plain HTTP+JSON + SSE, DELIBERATELY NOT an
|
|
4
|
+
// MCP tool: MCP is the AGENT surface and the contract keeps it minimal
|
|
5
|
+
// (send_to_peer only — an extra tool occupies every session's context window);
|
|
6
|
+
// fleet clients are GUIs/scripts that consume HTTP natively.
|
|
7
|
+
//
|
|
8
|
+
// Design pillars (spec accepted by the PM 05.07.2026; normative client contract in
|
|
9
|
+
// docs/15-fleet-api.md):
|
|
10
|
+
// • SNAPSHOT truth = the SAME in-process primitives `iapeer list` uses
|
|
11
|
+
// (cli.listPeers) — agreement with the CLI is by construction, not by parsing.
|
|
12
|
+
// • EVENTS = tail-follow of the THREE durable logs (lifecycle.log / delivery.log /
|
|
13
|
+
// exits.log under cfg.eventLogDir). Those logs are written by EVERY process
|
|
14
|
+
// (daemon + CLI attach/stop/wake + supervisor deaths) — an in-memory daemon bus
|
|
15
|
+
// would silently miss the CLI-originated events. At-least-once semantics;
|
|
16
|
+
// clients MUST ignore unknown `ev` kinds (the approval-request growth seam).
|
|
17
|
+
// • COMMANDS = the EXISTING verb functions called in-process (stopPeer/startPeer/
|
|
18
|
+
// newPeer/refreshPeer/compactPeer/wakeOrSpawn/routeControl) — H4, the fleet
|
|
19
|
+
// guard and the crash-loop guard all live INSIDE those functions, so the API
|
|
20
|
+
// cannot bypass them. send routes through daemon.callTool — the SAME path as
|
|
21
|
+
// the MCP tool (delivery.log line + all post-delivery hooks included).
|
|
22
|
+
//
|
|
23
|
+
// Security class: unchanged. The endpoints listen on the SAME two listeners
|
|
24
|
+
// (0600 unix socket + TCP loopback) behind the SAME optional H8 bearer gate
|
|
25
|
+
// (checked in daemon/index.ts BEFORE dispatch); command power ≤ what the same-uid
|
|
26
|
+
// CLI already has. NO CORS headers — a browser page must NOT be able to drive
|
|
27
|
+
// loopback fleet commands cross-origin; the future web gateway is its own decision
|
|
28
|
+
// with bearer mandatory (see the frozen TCP open-auth decision + its triggers).
|
|
29
|
+
//
|
|
30
|
+
// Layering: this module is a COMPOSITION-SIDE sibling of daemon/main.ts (wired via
|
|
31
|
+
// StartDaemonOptions.fleet — the same injected-seam pattern as wake/supervise), so
|
|
32
|
+
// the daemon library stays lifecycle-free. The CLI verb functions are loaded via a
|
|
33
|
+
// LAZY dynamic import: cli/index.ts statically imports daemon/main.ts, so a static
|
|
34
|
+
// import back would create the repo's first import cycle.
|
|
35
|
+
|
|
36
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
37
|
+
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
|
+
import { buildProcessAddress } from '../core/socket.ts'
|
|
39
|
+
import { IAPEER_VERSION } from '../core/version.ts'
|
|
40
|
+
import {
|
|
41
|
+
ephemeralQueueDepth,
|
|
42
|
+
isEphemeralPeer,
|
|
43
|
+
isLaunchdManaged,
|
|
44
|
+
loadLifecycleConfig,
|
|
45
|
+
wakeOrSpawn,
|
|
46
|
+
type LifecycleConfig,
|
|
47
|
+
} from '../lifecycle/index.ts'
|
|
48
|
+
import { lifecycleLogPath } from '../lifecycle/eventlog.ts'
|
|
49
|
+
import { exitLogPath } from '../launch/index.ts'
|
|
50
|
+
import { hasAttachedSupervisorClient } from '../launch/ptyHost.ts'
|
|
51
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
52
|
+
import { resolveCallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
53
|
+
import { routeControl } from '../transport/index.ts'
|
|
54
|
+
import {
|
|
55
|
+
eventConcernsPeer,
|
|
56
|
+
parseEventLine,
|
|
57
|
+
readLogTail,
|
|
58
|
+
type ParsedEventLine,
|
|
59
|
+
} from '../storage/rotatelog.ts'
|
|
60
|
+
import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
|
|
61
|
+
import { deliveryLogPath } from './deliverylog.ts'
|
|
62
|
+
import { callTool, type CallToolDeps } from './index.ts'
|
|
63
|
+
|
|
64
|
+
/** The API generation baked into every snapshot (additive evolution within v1 —
|
|
65
|
+
* see docs/15-fleet-api.md «Compatibility»). */
|
|
66
|
+
export const FLEET_API_VERSION = 1
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Snapshot
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface FleetRuntimeStatus {
|
|
73
|
+
runtime: string
|
|
74
|
+
status: 'live' | 'asleep' | 'stopped'
|
|
75
|
+
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
76
|
+
attached?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FleetPeer {
|
|
80
|
+
personality: string
|
|
81
|
+
description: string
|
|
82
|
+
intelligence: string
|
|
83
|
+
default_runtime: string
|
|
84
|
+
cwd: string
|
|
85
|
+
runtimes: FleetRuntimeStatus[]
|
|
86
|
+
last_active_runtime?: string
|
|
87
|
+
last_active_ms?: number
|
|
88
|
+
/** Any runtime session with a human attached. */
|
|
89
|
+
attached: boolean
|
|
90
|
+
/** H4 — launchd owns this peer's lifecycle; the daemon (and the wake/stop/start
|
|
91
|
+
* commands) treat it read-only / guarded. */
|
|
92
|
+
launchd_managed: boolean
|
|
93
|
+
wake_policy: 'warm' | 'ephemeral'
|
|
94
|
+
/** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
|
|
95
|
+
queue_depth?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FleetHost {
|
|
99
|
+
version: string
|
|
100
|
+
pid: number
|
|
101
|
+
startedAt: string
|
|
102
|
+
uptimeSecs: number
|
|
103
|
+
memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
104
|
+
voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
105
|
+
fda: boolean | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FleetSnapshot {
|
|
109
|
+
api: typeof FLEET_API_VERSION
|
|
110
|
+
version: string
|
|
111
|
+
ts: string
|
|
112
|
+
host: FleetHost
|
|
113
|
+
peers: FleetPeer[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
|
|
117
|
+
// call time (see the layering note in the module header).
|
|
118
|
+
export interface FleetOps {
|
|
119
|
+
listPeers: (opts: { env?: NodeJS.ProcessEnv }) => import('../cli/index.ts').PeerListing[]
|
|
120
|
+
stopPeer: typeof import('../cli/index.ts').stopPeer
|
|
121
|
+
startPeer: typeof import('../cli/index.ts').startPeer
|
|
122
|
+
refreshPeer: typeof import('../cli/index.ts').refreshPeer
|
|
123
|
+
newPeer: typeof import('../cli/index.ts').newPeer
|
|
124
|
+
compactPeer: typeof import('../cli/index.ts').compactPeer
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function defaultOps(): Promise<FleetOps> {
|
|
128
|
+
const cli = await import('../cli/index.ts')
|
|
129
|
+
return {
|
|
130
|
+
listPeers: cli.listPeers,
|
|
131
|
+
stopPeer: cli.stopPeer,
|
|
132
|
+
startPeer: cli.startPeer,
|
|
133
|
+
refreshPeer: cli.refreshPeer,
|
|
134
|
+
newPeer: cli.newPeer,
|
|
135
|
+
compactPeer: cli.compactPeer,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Assemble the full fleet snapshot. Peer rows come from the SAME listPeers the CLI
|
|
140
|
+
* `list` verb renders — agreement with `iapeer list` is by construction. On top:
|
|
141
|
+
* the occupancy/ownership facts the registry row alone does not carry (attached
|
|
142
|
+
* human, H4 launchd ownership, wake_policy, ephemeral queue depth). */
|
|
143
|
+
export function buildFleetSnapshot(
|
|
144
|
+
env: NodeJS.ProcessEnv,
|
|
145
|
+
cfg: LifecycleConfig,
|
|
146
|
+
ops: Pick<FleetOps, 'listPeers'>,
|
|
147
|
+
startedAtMs: number,
|
|
148
|
+
nowMs: number = Date.now(),
|
|
149
|
+
): FleetSnapshot {
|
|
150
|
+
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
151
|
+
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
152
|
+
const attached =
|
|
153
|
+
s.status === 'live' && hasAttachedSupervisorClient(buildProcessAddress(s.runtime, r.personality), env)
|
|
154
|
+
return { runtime: s.runtime, status: s.status, ...(attached ? { attached: true } : {}) }
|
|
155
|
+
})
|
|
156
|
+
let ephemeral = false
|
|
157
|
+
try {
|
|
158
|
+
ephemeral = isEphemeralPeer(r.cwd)
|
|
159
|
+
} catch {
|
|
160
|
+
/* unreadable profile → treated as warm */
|
|
161
|
+
}
|
|
162
|
+
let queueDepth = 0
|
|
163
|
+
if (ephemeral) {
|
|
164
|
+
for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
personality: r.personality,
|
|
168
|
+
description: r.description,
|
|
169
|
+
intelligence: r.intelligence,
|
|
170
|
+
default_runtime: r.default_runtime,
|
|
171
|
+
cwd: r.cwd,
|
|
172
|
+
runtimes,
|
|
173
|
+
...(r.last_active_runtime ? { last_active_runtime: r.last_active_runtime } : {}),
|
|
174
|
+
...(r.last_active_ms !== undefined ? { last_active_ms: r.last_active_ms } : {}),
|
|
175
|
+
attached: runtimes.some(s => s.attached === true),
|
|
176
|
+
launchd_managed: isLaunchdManaged(r.personality, env),
|
|
177
|
+
wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
|
|
178
|
+
...(ephemeral ? { queue_depth: queueDepth } : {}),
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
const mem = readMemoryProvider(env)
|
|
182
|
+
const voice = readVoiceProvider(env)
|
|
183
|
+
return {
|
|
184
|
+
api: FLEET_API_VERSION,
|
|
185
|
+
version: IAPEER_VERSION,
|
|
186
|
+
ts: new Date(nowMs).toISOString(),
|
|
187
|
+
host: {
|
|
188
|
+
version: IAPEER_VERSION,
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
191
|
+
uptimeSecs: Math.max(0, Math.floor((nowMs - startedAtMs) / 1000)),
|
|
192
|
+
memory: mem
|
|
193
|
+
? { provider: mem.provider, version: mem.version, heartbeatAgeSecs: heartbeatAgeSecs(mem, nowMs) }
|
|
194
|
+
: null,
|
|
195
|
+
voice: voice
|
|
196
|
+
? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
|
|
197
|
+
: null,
|
|
198
|
+
fda: probeFullDiskAccess(env),
|
|
199
|
+
},
|
|
200
|
+
peers,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// Events — tail-follow of the durable logs → SSE
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface FleetEvent extends ParsedEventLine {
|
|
209
|
+
/** Which log the event came from: lifecycle | delivery | exits. */
|
|
210
|
+
src: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The three durable event logs under cfg.eventLogDir (exits.log shares the dir —
|
|
214
|
+
* lifecycle/index.ts passes exitLogDir: cfg.eventLogDir). */
|
|
215
|
+
export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src: string }> {
|
|
216
|
+
return [
|
|
217
|
+
{ path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
|
|
218
|
+
{ path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
|
|
219
|
+
{ path: exitLogPath(cfg.eventLogDir), src: 'exits' },
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The newest `limit` events across the given logs (merged by timestamp) — the SSE
|
|
224
|
+
* `replay=N` primer and the peer-detail recent-events reader. Reads only the last
|
|
225
|
+
* 64 KiB of each file. */
|
|
226
|
+
export function readRecentEvents(
|
|
227
|
+
files: Array<{ path: string; src: string }>,
|
|
228
|
+
limit: number,
|
|
229
|
+
concerning?: string,
|
|
230
|
+
): FleetEvent[] {
|
|
231
|
+
const events: FleetEvent[] = []
|
|
232
|
+
for (const f of files) {
|
|
233
|
+
for (const line of readLogTail(f.path).split('\n')) {
|
|
234
|
+
const e = parseEventLine(line)
|
|
235
|
+
if (!e) continue
|
|
236
|
+
if (concerning && !eventConcernsPeer(e, concerning)) continue
|
|
237
|
+
events.push({ ...e, src: f.src })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
events.sort((a, b) => a.tsMs - b.tsMs)
|
|
241
|
+
return events.slice(-limit)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface TailState {
|
|
245
|
+
path: string
|
|
246
|
+
src: string
|
|
247
|
+
offset: number
|
|
248
|
+
partial: string
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Shared poll-based tail-follower over the durable logs: ONE timer regardless of
|
|
253
|
+
* how many SSE subscribers are connected (starts with the first, stops with the
|
|
254
|
+
* last). Rotation-aware: a size SMALLER than the held offset means the base file
|
|
255
|
+
* was rotated away (base → .1) — reading restarts at 0 on the fresh file. Poll is
|
|
256
|
+
* deliberately chosen over fs.watch: rotation-robust, no platform edge cases, and
|
|
257
|
+
* a few-hundred-ms latency is invisible on a dashboard.
|
|
258
|
+
*/
|
|
259
|
+
export class FleetEventTailer {
|
|
260
|
+
private readonly files: TailState[]
|
|
261
|
+
private readonly subs = new Set<(e: FleetEvent) => void>()
|
|
262
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
263
|
+
constructor(
|
|
264
|
+
files: Array<{ path: string; src: string }>,
|
|
265
|
+
private readonly pollMs = 400,
|
|
266
|
+
private readonly statSize: (path: string) => number | null = defaultStatSize,
|
|
267
|
+
private readonly readFrom: (path: string, offset: number, length: number) => string | null = defaultReadFrom,
|
|
268
|
+
) {
|
|
269
|
+
this.files = files.map(f => ({ ...f, offset: -1, partial: '' }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Subscribe to live events; returns the unsubscribe function. The FIRST
|
|
273
|
+
* subscriber primes every offset to the current EOF (history is the replay
|
|
274
|
+
* reader's job) and starts the poll timer; the last unsubscribe stops it. */
|
|
275
|
+
subscribe(fn: (e: FleetEvent) => void): () => void {
|
|
276
|
+
if (this.subs.size === 0) {
|
|
277
|
+
for (const f of this.files) {
|
|
278
|
+
f.offset = this.statSize(f.path) ?? 0
|
|
279
|
+
f.partial = ''
|
|
280
|
+
}
|
|
281
|
+
this.timer = setInterval(() => this.tick(), this.pollMs)
|
|
282
|
+
this.timer.unref?.()
|
|
283
|
+
}
|
|
284
|
+
this.subs.add(fn)
|
|
285
|
+
return () => {
|
|
286
|
+
this.subs.delete(fn)
|
|
287
|
+
if (this.subs.size === 0 && this.timer) {
|
|
288
|
+
clearInterval(this.timer)
|
|
289
|
+
this.timer = undefined
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One poll pass over every file — exposed for tests (deterministic, no timer). */
|
|
295
|
+
tick(): void {
|
|
296
|
+
for (const f of this.files) {
|
|
297
|
+
const size = this.statSize(f.path)
|
|
298
|
+
if (size === null) {
|
|
299
|
+
// file gone (rotation window / never created yet) — restart at 0 when it appears
|
|
300
|
+
f.offset = 0
|
|
301
|
+
f.partial = ''
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (size < f.offset) {
|
|
305
|
+
// rotated: base moved to .1, a fresh base is being written — restart
|
|
306
|
+
f.offset = 0
|
|
307
|
+
f.partial = ''
|
|
308
|
+
}
|
|
309
|
+
if (size === f.offset) continue
|
|
310
|
+
const chunk = this.readFrom(f.path, f.offset, size - f.offset)
|
|
311
|
+
if (chunk === null) continue // transient read failure — retry next tick
|
|
312
|
+
f.offset = size
|
|
313
|
+
const text = f.partial + chunk
|
|
314
|
+
const lines = text.split('\n')
|
|
315
|
+
f.partial = lines.pop() ?? '' // the trailing piece before the next newline
|
|
316
|
+
for (const line of lines) {
|
|
317
|
+
const e = parseEventLine(line)
|
|
318
|
+
if (!e) continue
|
|
319
|
+
for (const s of [...this.subs]) {
|
|
320
|
+
try {
|
|
321
|
+
s({ ...e, src: f.src })
|
|
322
|
+
} catch {
|
|
323
|
+
/* a subscriber must never break the tailer */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function defaultStatSize(path: string): number | null {
|
|
332
|
+
try {
|
|
333
|
+
return statSync(path).size
|
|
334
|
+
} catch {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function defaultReadFrom(path: string, offset: number, length: number): string | null {
|
|
340
|
+
try {
|
|
341
|
+
const fd = openSync(path, 'r')
|
|
342
|
+
try {
|
|
343
|
+
const buf = Buffer.alloc(length)
|
|
344
|
+
const n = readSync(fd, buf, 0, length, offset)
|
|
345
|
+
return buf.subarray(0, n).toString('utf8')
|
|
346
|
+
} finally {
|
|
347
|
+
closeSync(fd)
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// HTTP handler
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
const JSON_HEADERS = { 'content-type': 'application/json' }
|
|
359
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
360
|
+
const SSE_HEARTBEAT_MS = 15_000
|
|
361
|
+
const MAX_REPLAY = 500
|
|
362
|
+
|
|
363
|
+
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
|
364
|
+
res.writeHead(status, JSON_HEADERS)
|
|
365
|
+
res.end(`${JSON.stringify(value)}\n`)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
const chunks: Buffer[] = []
|
|
371
|
+
let size = 0
|
|
372
|
+
req.on('data', (c: Buffer) => {
|
|
373
|
+
size += c.length
|
|
374
|
+
if (size > MAX_BODY_BYTES) {
|
|
375
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
|
|
376
|
+
req.destroy()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
chunks.push(c)
|
|
380
|
+
})
|
|
381
|
+
req.on('end', () => {
|
|
382
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
383
|
+
if (!raw) {
|
|
384
|
+
resolve({})
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(raw) as unknown
|
|
389
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
390
|
+
reject(new Error('request body must be a JSON object'))
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
resolve(parsed as Record<string, unknown>)
|
|
394
|
+
} catch (e) {
|
|
395
|
+
reject(new Error(`invalid JSON body: ${e instanceof Error ? e.message : String(e)}`))
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
req.on('error', reject)
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Resolve the SENDER identity for POST /fleet/v1/send. Explicit `from` (either a
|
|
403
|
+
* full `<runtime>-<personality>` or a bare personality → its default runtime) wins;
|
|
404
|
+
* otherwise the SINGLE natural-intelligence peer of the registry (the host owner —
|
|
405
|
+
* a GUI-originated message is the human's). Ambiguity → an instructive error. */
|
|
406
|
+
export function resolveSendCaller(from: string | undefined, env: NodeJS.ProcessEnv): ResolvedCaller {
|
|
407
|
+
const index = readPeersIndex({ env })
|
|
408
|
+
if (from?.trim()) {
|
|
409
|
+
const raw = from.trim()
|
|
410
|
+
const byName = index.peers.find(p => p.personality === raw)
|
|
411
|
+
if (byName) return resolveCallerIdentity({ personality: byName.personality, runtime: byName.runtime }, index)
|
|
412
|
+
const dash = raw.indexOf('-')
|
|
413
|
+
if (dash > 0) {
|
|
414
|
+
return resolveCallerIdentity({ runtime: raw.slice(0, dash), personality: raw.slice(dash + 1) }, index)
|
|
415
|
+
}
|
|
416
|
+
throw new Error(`unknown from peer "${raw}"`)
|
|
417
|
+
}
|
|
418
|
+
const natural = index.peers.filter(p => p.intelligence === 'natural')
|
|
419
|
+
if (natural.length === 1) {
|
|
420
|
+
return resolveCallerIdentity({ personality: natural[0]!.personality, runtime: natural[0]!.runtime }, index)
|
|
421
|
+
}
|
|
422
|
+
throw new Error(
|
|
423
|
+
natural.length === 0
|
|
424
|
+
? 'no natural-intelligence peer registered — pass "from" explicitly'
|
|
425
|
+
: `ambiguous default sender (${natural.length} natural peers) — pass "from" explicitly`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface FleetHandlerOptions {
|
|
430
|
+
env?: NodeJS.ProcessEnv
|
|
431
|
+
/** Injectable CLI ops (tests). Default: lazy dynamic import of cli/index.ts. */
|
|
432
|
+
ops?: FleetOps
|
|
433
|
+
/** Injectable tailer poll interval (tests). */
|
|
434
|
+
pollMs?: number
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export type FleetHandler = (
|
|
438
|
+
req: IncomingMessage,
|
|
439
|
+
res: ServerResponse,
|
|
440
|
+
ctx: { deps: CallToolDeps },
|
|
441
|
+
) => Promise<void>
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Build the /fleet/v1 request handler the composition point injects into
|
|
445
|
+
* startDaemon (StartDaemonOptions.fleet). One handler instance per daemon —
|
|
446
|
+
* it owns the shared event tailer and the daemon start timestamp.
|
|
447
|
+
*/
|
|
448
|
+
export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler {
|
|
449
|
+
const env = opts.env ?? process.env
|
|
450
|
+
const cfg = loadLifecycleConfig(env)
|
|
451
|
+
const startedAtMs = Date.now()
|
|
452
|
+
const files = fleetEventFiles(cfg)
|
|
453
|
+
const tailer = new FleetEventTailer(files, opts.pollMs)
|
|
454
|
+
let opsPromise: Promise<FleetOps> | undefined
|
|
455
|
+
const getOps = (): Promise<FleetOps> => {
|
|
456
|
+
if (opts.ops) return Promise.resolve(opts.ops)
|
|
457
|
+
opsPromise ??= defaultOps()
|
|
458
|
+
return opsPromise
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return async (req, res, ctx) => {
|
|
462
|
+
const url = new URL(req.url ?? '/', 'http://fleet.local')
|
|
463
|
+
const path = url.pathname
|
|
464
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
465
|
+
try {
|
|
466
|
+
// ── reads ────────────────────────────────────────────────────────────
|
|
467
|
+
if (path === '/fleet/v1/snapshot') {
|
|
468
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
|
|
469
|
+
return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs))
|
|
470
|
+
}
|
|
471
|
+
if (path === '/fleet/v1/events') {
|
|
472
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
|
|
473
|
+
serveEvents(req, res, url, tailer, files)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
const peerMatch = /^\/fleet\/v1\/peers\/([^/]+)$/.exec(path)
|
|
477
|
+
if (peerMatch) {
|
|
478
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/peers/<peer>' })
|
|
479
|
+
const personality = decodeURIComponent(peerMatch[1]!)
|
|
480
|
+
const snapshot = buildFleetSnapshot(env, cfg, await getOps(), startedAtMs)
|
|
481
|
+
const peer = snapshot.peers.find(p => p.personality === personality)
|
|
482
|
+
if (!peer) return sendJson(res, 404, { error: `unknown peer "${personality}"` })
|
|
483
|
+
const events = readRecentEvents(files, 50, personality)
|
|
484
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: snapshot.ts, peer, events })
|
|
485
|
+
}
|
|
486
|
+
// ── commands ─────────────────────────────────────────────────────────
|
|
487
|
+
const cmdMatch = /^\/fleet\/v1\/peers\/([^/]+)\/(wake|stop|start|new|refresh|interrupt|compact)$/.exec(path)
|
|
488
|
+
if (cmdMatch) {
|
|
489
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST' })
|
|
490
|
+
const personality = decodeURIComponent(cmdMatch[1]!)
|
|
491
|
+
const command = cmdMatch[2]!
|
|
492
|
+
let body: Record<string, unknown>
|
|
493
|
+
try {
|
|
494
|
+
body = await readBody(req)
|
|
495
|
+
} catch (e) {
|
|
496
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
497
|
+
}
|
|
498
|
+
const runtime = typeof body.runtime === 'string' ? body.runtime : undefined
|
|
499
|
+
const topic = typeof body.topic === 'string' ? body.topic : undefined
|
|
500
|
+
return await runCommand(res, command, { personality, runtime, topic }, cfg, env, await getOps())
|
|
501
|
+
}
|
|
502
|
+
if (path === '/fleet/v1/send') {
|
|
503
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/send' })
|
|
504
|
+
let body: Record<string, unknown>
|
|
505
|
+
try {
|
|
506
|
+
body = await readBody(req)
|
|
507
|
+
} catch (e) {
|
|
508
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
509
|
+
}
|
|
510
|
+
if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
|
|
511
|
+
return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
|
|
512
|
+
}
|
|
513
|
+
let caller: ResolvedCaller
|
|
514
|
+
try {
|
|
515
|
+
caller = resolveSendCaller(typeof body.from === 'string' ? body.from : undefined, env)
|
|
516
|
+
} catch (e) {
|
|
517
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
518
|
+
}
|
|
519
|
+
// The SAME path the MCP tool takes (routeSend + delivery.log + post-delivery
|
|
520
|
+
// hooks) — ctx.deps is the daemon's own wired CallToolDeps.
|
|
521
|
+
const result = await callTool(
|
|
522
|
+
caller,
|
|
523
|
+
'send_to_peer',
|
|
524
|
+
{
|
|
525
|
+
personality: body.personality,
|
|
526
|
+
runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
|
|
527
|
+
message: body.message,
|
|
528
|
+
topic: typeof body.topic === 'string' ? body.topic : undefined,
|
|
529
|
+
},
|
|
530
|
+
ctx.deps,
|
|
531
|
+
)
|
|
532
|
+
const text = result.content[0]?.text ?? ''
|
|
533
|
+
if (result.isError) return sendJson(res, 502, { error: text })
|
|
534
|
+
return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
|
|
535
|
+
}
|
|
536
|
+
return sendJson(res, 404, {
|
|
537
|
+
error: `unknown fleet endpoint ${method} ${path}`,
|
|
538
|
+
endpoints: [
|
|
539
|
+
'GET /fleet/v1/snapshot',
|
|
540
|
+
'GET /fleet/v1/peers/<peer>',
|
|
541
|
+
'GET /fleet/v1/events?replay=N',
|
|
542
|
+
'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
|
|
543
|
+
'POST /fleet/v1/send',
|
|
544
|
+
],
|
|
545
|
+
})
|
|
546
|
+
} catch (e) {
|
|
547
|
+
if (!res.headersSent) {
|
|
548
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) })
|
|
549
|
+
} else {
|
|
550
|
+
try {
|
|
551
|
+
res.end()
|
|
552
|
+
} catch {
|
|
553
|
+
/* connection already gone */
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function safeParse(text: string): unknown {
|
|
561
|
+
try {
|
|
562
|
+
return JSON.parse(text)
|
|
563
|
+
} catch {
|
|
564
|
+
return text
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function runCommand(
|
|
569
|
+
res: ServerResponse,
|
|
570
|
+
command: string,
|
|
571
|
+
args: { personality: string; runtime?: string; topic?: string },
|
|
572
|
+
cfg: LifecycleConfig,
|
|
573
|
+
env: NodeJS.ProcessEnv,
|
|
574
|
+
ops: FleetOps,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const { personality, runtime, topic } = args
|
|
577
|
+
switch (command) {
|
|
578
|
+
case 'wake': {
|
|
579
|
+
// Same H4 / crash-loop / wake-lock semantics as a message wake — wakeOrSpawn
|
|
580
|
+
// owns them. task:'' = bare bring-up (the seed/initial prompt is the first turn).
|
|
581
|
+
const r = await wakeOrSpawn({ personality, runtime, topic, task: '' }, { cfg, env })
|
|
582
|
+
return sendJson(res, r.status === 'FAILED' ? 502 : 200, { command, ...r })
|
|
583
|
+
}
|
|
584
|
+
case 'stop': {
|
|
585
|
+
const outcomes = ops.stopPeer(personality, runtime, { env })
|
|
586
|
+
const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootout' && o.reason !== undefined))
|
|
587
|
+
return sendJson(res, failed ? 502 : 200, { command, outcomes })
|
|
588
|
+
}
|
|
589
|
+
case 'start': {
|
|
590
|
+
const outcomes = ops.startPeer(personality, runtime, { env })
|
|
591
|
+
const failed = outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootstrap' && o.reason !== undefined))
|
|
592
|
+
return sendJson(res, failed ? 502 : 200, { command, outcomes })
|
|
593
|
+
}
|
|
594
|
+
case 'new': {
|
|
595
|
+
const o = await ops.newPeer(personality, runtime, { env })
|
|
596
|
+
return sendJson(res, o.action === 'fresh' ? 200 : 502, { command, ...o })
|
|
597
|
+
}
|
|
598
|
+
case 'refresh': {
|
|
599
|
+
const outcomes = ops.refreshPeer(personality, runtime, { env })
|
|
600
|
+
return sendJson(res, 200, { command, outcomes })
|
|
601
|
+
}
|
|
602
|
+
case 'interrupt': {
|
|
603
|
+
const r = await routeControl(personality, runtime, { name: 'interrupt' })
|
|
604
|
+
if (!r.ok) return sendJson(res, 502, { command, error: r.error.message })
|
|
605
|
+
return sendJson(res, 200, { ...r.value, command }) // routeControl echoes its own `command` — ours (the endpoint name) wins
|
|
606
|
+
}
|
|
607
|
+
case 'compact': {
|
|
608
|
+
const o = await ops.compactPeer(personality, runtime, { env })
|
|
609
|
+
return sendJson(res, o.action === 'compacted' ? 200 : 502, { command, ...o })
|
|
610
|
+
}
|
|
611
|
+
default:
|
|
612
|
+
return sendJson(res, 404, { error: `unknown command "${command}"` })
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Serve the SSE event stream: optional replay of the newest N historical events,
|
|
617
|
+
* then live tail. At-least-once semantics (a reconnect may re-see events — `id`
|
|
618
|
+
* is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
|
|
619
|
+
function serveEvents(
|
|
620
|
+
req: IncomingMessage,
|
|
621
|
+
res: ServerResponse,
|
|
622
|
+
url: URL,
|
|
623
|
+
tailer: FleetEventTailer,
|
|
624
|
+
files: Array<{ path: string; src: string }>,
|
|
625
|
+
): void {
|
|
626
|
+
res.writeHead(200, {
|
|
627
|
+
'content-type': 'text/event-stream',
|
|
628
|
+
'cache-control': 'no-cache',
|
|
629
|
+
connection: 'keep-alive',
|
|
630
|
+
})
|
|
631
|
+
const writeEvent = (e: FleetEvent): void => {
|
|
632
|
+
const data = JSON.stringify({ src: e.src, ev: e.ev, ...e.fields })
|
|
633
|
+
res.write(`event: ${e.ev}\nid: ${e.tsMs}\ndata: ${data}\n\n`)
|
|
634
|
+
}
|
|
635
|
+
const replayRaw = parseInt(url.searchParams.get('replay') ?? '', 10)
|
|
636
|
+
const replay = Number.isFinite(replayRaw) ? Math.max(0, Math.min(MAX_REPLAY, replayRaw)) : 0
|
|
637
|
+
if (replay > 0) for (const e of readRecentEvents(files, replay)) writeEvent(e)
|
|
638
|
+
res.write(`: connected\n\n`)
|
|
639
|
+
const unsubscribe = tailer.subscribe(writeEvent)
|
|
640
|
+
const heartbeat = setInterval(() => {
|
|
641
|
+
try {
|
|
642
|
+
res.write(`: hb\n\n`)
|
|
643
|
+
} catch {
|
|
644
|
+
/* torn connection — close handler cleans up */
|
|
645
|
+
}
|
|
646
|
+
}, SSE_HEARTBEAT_MS)
|
|
647
|
+
heartbeat.unref?.()
|
|
648
|
+
const cleanup = (): void => {
|
|
649
|
+
clearInterval(heartbeat)
|
|
650
|
+
unsubscribe()
|
|
651
|
+
}
|
|
652
|
+
res.on('close', cleanup)
|
|
653
|
+
req.on('close', cleanup)
|
|
654
|
+
}
|