@agfpd/iapeer 0.1.0
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/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// iapeer CLI — the unified operator/agent entrypoint (`iapeer <verb> …`, contract
|
|
2
|
+
// Примитивы §Карта verbs). Thin verbs over the foundation primitives: list (registry
|
|
3
|
+
// + liveness + C1 stopped), stop/start (C1 durable flag for warm; launchctl for
|
|
4
|
+
// always-on), send (routeSend fallback). init delegates to src/init; launch (folder)
|
|
5
|
+
// and attach (last-active resume) land in the next increment.
|
|
6
|
+
//
|
|
7
|
+
// FLEET SAFETY (H4): the live persistent-peer fleet is launchd-managed (com.iapeer.<p>
|
|
8
|
+
// plists the foundation does NOT own). stop/start REFUSE such a peer — the foundation
|
|
9
|
+
// is read-only for it; stopping it would fight PP's KeepAlive / tear a live telegram
|
|
10
|
+
// bridge off launchd. Only foundation-owned peers (warm no-plist, or our own
|
|
11
|
+
// sentinel-marked always-on plist) are stop/start-able.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'child_process'
|
|
14
|
+
import { fileURLToPath } from 'url'
|
|
15
|
+
import {
|
|
16
|
+
isInfraRuntime,
|
|
17
|
+
isRuntime,
|
|
18
|
+
type Intelligence,
|
|
19
|
+
type Runtime,
|
|
20
|
+
} from '../core/constants.ts'
|
|
21
|
+
import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
22
|
+
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
23
|
+
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
24
|
+
import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
|
|
25
|
+
import {
|
|
26
|
+
attachPeer,
|
|
27
|
+
clearStopped,
|
|
28
|
+
folderLaunch,
|
|
29
|
+
isLaunchdManaged,
|
|
30
|
+
isStopped,
|
|
31
|
+
killSession,
|
|
32
|
+
loadLifecycleConfig,
|
|
33
|
+
setStopped,
|
|
34
|
+
wakeOrSpawn,
|
|
35
|
+
} from '../lifecycle/index.ts'
|
|
36
|
+
import { getAdapter } from '../launch/index.ts'
|
|
37
|
+
import { isFoundationOwnedPlist, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
38
|
+
import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
39
|
+
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
40
|
+
import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
41
|
+
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type RuntimeLiveness = 'live' | 'asleep' | 'stopped'
|
|
48
|
+
export interface RuntimeStatus {
|
|
49
|
+
runtime: Runtime
|
|
50
|
+
status: RuntimeLiveness
|
|
51
|
+
}
|
|
52
|
+
export interface PeerListing {
|
|
53
|
+
personality: string
|
|
54
|
+
default_runtime: Runtime
|
|
55
|
+
/** Runtime with the freshest activity (what `attach` resumes); undefined if none. */
|
|
56
|
+
last_active_runtime?: Runtime
|
|
57
|
+
intelligence: Intelligence
|
|
58
|
+
description: string
|
|
59
|
+
runtimes: RuntimeStatus[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CliEnvOptions {
|
|
63
|
+
env?: NodeJS.ProcessEnv
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Gather the peer listing: one row per registered peer, per-runtime liveness (live
|
|
68
|
+
* via tmux has-session / stopped via the C1 durable flag / else asleep) and the
|
|
69
|
+
* last-active runtime by transcript-mtime (the same proxy `attach` keys on).
|
|
70
|
+
*/
|
|
71
|
+
export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
72
|
+
const env = opts.env ?? process.env
|
|
73
|
+
const cfg = loadLifecycleConfig(env)
|
|
74
|
+
const index = readPeersIndex({ env })
|
|
75
|
+
return index.peers.map(peer => {
|
|
76
|
+
const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
|
|
77
|
+
runtime: rt,
|
|
78
|
+
status: isPeerLive(rt, peer.personality, cfg.sockDir)
|
|
79
|
+
? 'live'
|
|
80
|
+
: isStopped(cfg, buildProcessAddress(rt, peer.personality))
|
|
81
|
+
? 'stopped'
|
|
82
|
+
: 'asleep',
|
|
83
|
+
}))
|
|
84
|
+
let lastActive: Runtime | undefined
|
|
85
|
+
let bestMt = -1
|
|
86
|
+
for (const rt of peer.runtimes) {
|
|
87
|
+
try {
|
|
88
|
+
const mt = getAdapter(rt).newestActivityMtime(peer.cwd)
|
|
89
|
+
if (mt !== null && mt > bestMt) {
|
|
90
|
+
bestMt = mt
|
|
91
|
+
lastActive = rt
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* no adapter / no proxy for this runtime */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
personality: peer.personality,
|
|
99
|
+
default_runtime: peer.runtime,
|
|
100
|
+
last_active_runtime: lastActive,
|
|
101
|
+
intelligence: peer.intelligence,
|
|
102
|
+
description: peer.description,
|
|
103
|
+
runtimes,
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
109
|
+
|
|
110
|
+
/** Render the scriptable list table (non-tty default). */
|
|
111
|
+
export function formatListTable(rows: PeerListing[]): string {
|
|
112
|
+
if (rows.length === 0) return 'no peers registered\n'
|
|
113
|
+
const lines = rows.map(r => {
|
|
114
|
+
const status = r.runtimes.map(s => `${GLYPH[s.status]} ${s.runtime}`).join(' ')
|
|
115
|
+
const la = r.last_active_runtime ? ` last-active:${r.last_active_runtime}` : ''
|
|
116
|
+
return `${r.personality} [${r.default_runtime}] ${r.intelligence} ${status}${la}`
|
|
117
|
+
})
|
|
118
|
+
return lines.join('\n') + '\n'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
// stop / start — dispatch by runtime class, with the FLEET GUARD (H4)
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export interface StopStartOutcome {
|
|
126
|
+
personality: string
|
|
127
|
+
runtime: Runtime
|
|
128
|
+
action: 'stopped' | 'started' | 'bootout' | 'bootstrap' | 'refused-foreign-launchd'
|
|
129
|
+
reason?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function uid(): string {
|
|
133
|
+
const r = spawnSync('id', ['-u'], { encoding: 'utf8' })
|
|
134
|
+
const u = (r.stdout ?? '').trim()
|
|
135
|
+
// Audit #29: NEVER fall back to '0' — that would aim launchctl bootout/bootstrap at
|
|
136
|
+
// the ROOT gui domain. A non-numeric/empty result means `id -u` failed; refuse.
|
|
137
|
+
if (!/^\d+$/.test(u)) {
|
|
138
|
+
throw new Error('cannot resolve the current uid (id -u failed) — refusing to target launchctl at an unknown domain')
|
|
139
|
+
}
|
|
140
|
+
return u
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** FLEET GUARD: a peer launchd-managed by a NON-foundation plist (persistent-peer)
|
|
144
|
+
* is off-limits to stop/start — the foundation is read-only for it (H4). */
|
|
145
|
+
function isForeignLaunchd(personality: string, env: NodeJS.ProcessEnv): boolean {
|
|
146
|
+
return isLaunchdManaged(personality, env) && !isFoundationOwnedPlist(launchdPlistPath(personality, env))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function targetRuntimes(peer: PeerRecord, runtime: string | undefined): Runtime[] {
|
|
150
|
+
if (runtime) {
|
|
151
|
+
// Audit #28: an explicit runtime the peer does not declare would act on a PHANTOM
|
|
152
|
+
// identity — a spurious durable stop-flag or a no-op bootout on a label that isn't
|
|
153
|
+
// this peer's. Refuse instead of silently targeting a non-existent runtime.
|
|
154
|
+
if (peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`peer "${peer.personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
return [runtime as Runtime]
|
|
160
|
+
}
|
|
161
|
+
return peer.runtimes
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* stop <peer> [runtime]: warm runtime → durable C1 stop flag + kill the session (the
|
|
166
|
+
* daemon will not wake it until `start`); always-on (infra, foundation-owned) → launchctl
|
|
167
|
+
* bootout + kill. REFUSES a foreign-launchd peer (live PP fleet) — fleet guard.
|
|
168
|
+
*/
|
|
169
|
+
export function stopPeer(personality: string, runtime: string | undefined, opts: CliEnvOptions = {}): StopStartOutcome[] {
|
|
170
|
+
const env = opts.env ?? process.env
|
|
171
|
+
const cfg = loadLifecycleConfig(env)
|
|
172
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
173
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
174
|
+
if (isForeignLaunchd(personality, env)) {
|
|
175
|
+
return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not stop it` }]
|
|
176
|
+
}
|
|
177
|
+
const out: StopStartOutcome[] = []
|
|
178
|
+
for (const rt of targetRuntimes(peer, runtime)) {
|
|
179
|
+
const identity = buildProcessAddress(rt, personality)
|
|
180
|
+
const sock = buildSocketPath(rt, personality, cfg.sockDir)
|
|
181
|
+
if (isInfraRuntime(rt)) {
|
|
182
|
+
// Audit #13: do NOT swallow the launchctl result. (bootout returns non-zero when
|
|
183
|
+
// the service was already not loaded — benign for stop — but surface the detail in
|
|
184
|
+
// the reason rather than silently claiming success.)
|
|
185
|
+
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
|
|
186
|
+
killSession(sock, identity)
|
|
187
|
+
out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
|
|
188
|
+
} else {
|
|
189
|
+
setStopped(cfg, identity)
|
|
190
|
+
killSession(sock, identity)
|
|
191
|
+
out.push({ personality, runtime: rt, action: 'stopped' })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* start <peer> [runtime]: warm runtime → clear the C1 stop flag (wakeable again on
|
|
199
|
+
* the next message); always-on → launchctl bootstrap the plist. REFUSES a foreign-
|
|
200
|
+
* launchd peer (fleet guard).
|
|
201
|
+
*/
|
|
202
|
+
export function startPeer(personality: string, runtime: string | undefined, opts: CliEnvOptions = {}): StopStartOutcome[] {
|
|
203
|
+
const env = opts.env ?? process.env
|
|
204
|
+
const cfg = loadLifecycleConfig(env)
|
|
205
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
206
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
207
|
+
if (isForeignLaunchd(personality, env)) {
|
|
208
|
+
return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not start it` }]
|
|
209
|
+
}
|
|
210
|
+
const out: StopStartOutcome[] = []
|
|
211
|
+
for (const rt of targetRuntimes(peer, runtime)) {
|
|
212
|
+
const identity = buildProcessAddress(rt, personality)
|
|
213
|
+
if (isInfraRuntime(rt)) {
|
|
214
|
+
const plist = launchdPlistPath(personality, env)
|
|
215
|
+
// Audit #13: a failed bootstrap means the peer did NOT start — surface it instead
|
|
216
|
+
// of reporting success silently.
|
|
217
|
+
const r = spawnSync('launchctl', ['bootstrap', `gui/${uid()}`, plist], { encoding: 'utf8' })
|
|
218
|
+
out.push({ personality, runtime: rt, action: 'bootstrap', reason: r.status === 0 ? undefined : `launchctl bootstrap FAILED (exit ${r.status})${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''} — peer not started` })
|
|
219
|
+
} else {
|
|
220
|
+
clearStopped(cfg, identity)
|
|
221
|
+
out.push({ personality, runtime: rt, action: 'started' })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// send — manual IAP send fallback (contract Примитивы §send). Goes through the
|
|
229
|
+
// same router path as send_to_peer (resolve → deliver / wake), in-process so it
|
|
230
|
+
// works even when the daemon HTTP listener is down. --from sets the sender.
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export interface SendOptions extends CliEnvOptions {
|
|
234
|
+
/** Sender identity `<runtime>-<personality>`; default = the cwd peer's identity. */
|
|
235
|
+
from: string
|
|
236
|
+
target: string
|
|
237
|
+
runtime?: string
|
|
238
|
+
message: string
|
|
239
|
+
topic?: string
|
|
240
|
+
attachments?: string[]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const cliWake: WakeFn = req =>
|
|
244
|
+
wakeOrSpawn({ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task })
|
|
245
|
+
|
|
246
|
+
export async function sendMessage(opts: SendOptions): Promise<{ ok: true; delivered_to: { personality: string; runtime: string } }> {
|
|
247
|
+
const env = opts.env ?? process.env
|
|
248
|
+
const caller = resolveCallerIdentity(parseIdentity(opts.from), readPeersIndex({ env }))
|
|
249
|
+
const result = await routeSend(
|
|
250
|
+
caller,
|
|
251
|
+
{
|
|
252
|
+
personality: opts.target,
|
|
253
|
+
runtime: opts.runtime,
|
|
254
|
+
message: opts.message,
|
|
255
|
+
topic: opts.topic,
|
|
256
|
+
attachments: opts.attachments,
|
|
257
|
+
},
|
|
258
|
+
{ wake: cliWake },
|
|
259
|
+
)
|
|
260
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
261
|
+
return { ok: true, delivered_to: result.value.delivered_to }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseIdentity(identity: string): { personality: string; runtime: Runtime } {
|
|
265
|
+
const dash = identity.indexOf('-')
|
|
266
|
+
if (dash <= 0) throw new Error(`invalid --from identity "${identity}" — expected <runtime>-<personality>`)
|
|
267
|
+
const runtime = identity.slice(0, dash)
|
|
268
|
+
if (!isRuntime(runtime)) throw new Error(`invalid runtime in --from "${identity}"`)
|
|
269
|
+
return { runtime, personality: identity.slice(dash + 1) }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// CLI dispatch — `iapeer <verb> …`
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export function parseArgs(argv: string[]): { positionals: string[]; flags: Record<string, string | true> } {
|
|
277
|
+
const positionals: string[] = []
|
|
278
|
+
const flags: Record<string, string | true> = {}
|
|
279
|
+
for (let i = 0; i < argv.length; i++) {
|
|
280
|
+
const a = argv[i]
|
|
281
|
+
if (a.startsWith('--')) {
|
|
282
|
+
// Audit #27: support `--key=value` so a value that itself starts with '--' (e.g.
|
|
283
|
+
// `send --message=--look-at-this`) is not silently dropped by the look-ahead form.
|
|
284
|
+
const eq = a.indexOf('=')
|
|
285
|
+
if (eq > 2) {
|
|
286
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
const key = a.slice(2)
|
|
290
|
+
const next = argv[i + 1]
|
|
291
|
+
if (next === undefined || next.startsWith('--')) flags[key] = true
|
|
292
|
+
else flags[key] = argv[++i]
|
|
293
|
+
} else {
|
|
294
|
+
positionals.push(a)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { positionals, flags }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const USAGE = `usage: iapeer <verb> [args]
|
|
301
|
+
install build binary + global scaffold + daemon plist (one bootstrap)
|
|
302
|
+
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
303
|
+
onboard [--dry-run] [--infra <csv>] register the agfpd marketplace (+ npx-install & deploy infra runtimes)
|
|
304
|
+
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
305
|
+
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
306
|
+
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
307
|
+
list [--json] registered peers + per-runtime liveness
|
|
308
|
+
stop <peer> [runtime] | --all durable-stop a warm peer / bootout an always-on one
|
|
309
|
+
start <peer> [runtime] re-enable a stopped peer / bootstrap an always-on one
|
|
310
|
+
send <target> --message <text> [--from <id>] [--topic <t>] manual IAP send (fallback)
|
|
311
|
+
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
312
|
+
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
313
|
+
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
314
|
+
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
315
|
+
compact <peer> [runtime] compact the peer's context (/compact)
|
|
316
|
+
`
|
|
317
|
+
|
|
318
|
+
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
319
|
+
const [verb, ...rest] = argv
|
|
320
|
+
const { positionals, flags } = parseArgs(rest)
|
|
321
|
+
const out = (s: string) => process.stdout.write(s)
|
|
322
|
+
const errOut = (s: string) => process.stderr.write(s)
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
switch (verb) {
|
|
326
|
+
case 'onboard': {
|
|
327
|
+
// Host-phase: register OUR marketplace in claude + codex (IDEMPOTENT — detect
|
|
328
|
+
// → skip when present; an already-configured host is a no-op). --dry-run
|
|
329
|
+
// reports the would-be actions without touching anything. --infra <csv> ALSO
|
|
330
|
+
// onboards infra runtimes (§6): npx-install each package (auto-resolved) + deploy
|
|
331
|
+
// its declared set. notifier → timer+watcher auto; telegram → operator-add after.
|
|
332
|
+
const r = onboardHost({ dryRun: flags['dry-run'] === true, env })
|
|
333
|
+
for (const m of r.marketplaces) {
|
|
334
|
+
out(`marketplace ${MARKETPLACE_NAME} @ ${m.runtime}: ${m.state}${m.detail ? ` — ${m.detail}` : ''}\n`)
|
|
335
|
+
}
|
|
336
|
+
out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
|
|
337
|
+
let infraFailed = false
|
|
338
|
+
const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
|
|
339
|
+
if (infra.length && flags['dry-run'] !== true) {
|
|
340
|
+
const { onboardRuntime } = await import('../runtime/deploy.ts')
|
|
341
|
+
for (const rt of infra) {
|
|
342
|
+
try {
|
|
343
|
+
const or = await onboardRuntime({ runtime: rt as Runtime, env, warn: m => errOut(`warn: ${m}\n`) })
|
|
344
|
+
out(`infra ${rt}: package ${or.install.package ?? '(none)'} ${or.install.state}; ` +
|
|
345
|
+
(or.deploy!.operatorAddOnly ? `operator-add (use \`iapeer create <peer> --runtime ${rt}\`)` : `${or.deploy!.peers.length} peer(s) deployed`) + '\n')
|
|
346
|
+
if (or.deploy!.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed')) infraFailed = true
|
|
347
|
+
} catch (e) {
|
|
348
|
+
infraFailed = true
|
|
349
|
+
errOut(`infra ${rt}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else if (infra.length) {
|
|
353
|
+
out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
|
|
354
|
+
}
|
|
355
|
+
return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
356
|
+
}
|
|
357
|
+
case 'install-runtime': {
|
|
358
|
+
// §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
|
|
359
|
+
// the built-in runtime→package registry, or --package; self-deploys bin +
|
|
360
|
+
// manifest), THEN deploy its declared peer-set (each → provision + per-peer
|
|
361
|
+
// self-config + auto-bootstrap). A runtime whose manifest declares no peers
|
|
362
|
+
// (telegram) is operator-add — use `iapeer create <human> --runtime telegram`.
|
|
363
|
+
if (!positionals[0]) return usage(errOut)
|
|
364
|
+
const { onboardRuntime } = await import('../runtime/deploy.ts')
|
|
365
|
+
const r = await onboardRuntime({
|
|
366
|
+
runtime: positionals[0] as Runtime,
|
|
367
|
+
package: typeof flags.package === 'string' ? flags.package : undefined,
|
|
368
|
+
npx: flags.npx === true,
|
|
369
|
+
bootstrap: flags['no-bootstrap'] === true ? false : undefined,
|
|
370
|
+
env,
|
|
371
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
372
|
+
})
|
|
373
|
+
out(`package ${r.install.package ?? '(none)'}: ${r.install.state}${r.install.detail ? ` — ${r.install.detail}` : ''}\n`)
|
|
374
|
+
const d = r.deploy!
|
|
375
|
+
if (d.operatorAddOnly) {
|
|
376
|
+
out(`runtime "${d.runtime}": no declared peer-set (operator-add — use \`iapeer create <peer> --runtime ${d.runtime}\`)\n`)
|
|
377
|
+
return 0
|
|
378
|
+
}
|
|
379
|
+
for (const p of d.peers) {
|
|
380
|
+
out(` ${p.personality} @ ${p.location}: self-config ${p.selfConfig ?? 'n/a'}; bootstrap ${p.bootstrap ?? 'n/a'}\n`)
|
|
381
|
+
}
|
|
382
|
+
out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
|
|
383
|
+
return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
|
|
384
|
+
}
|
|
385
|
+
case 'init': {
|
|
386
|
+
// cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
|
|
387
|
+
// identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
|
|
388
|
+
// when not explicit. Auto-bootstraps an infra plist unless --no-bootstrap.
|
|
389
|
+
const { initPeer } = await import('../init/index.ts')
|
|
390
|
+
const r = await initPeer({
|
|
391
|
+
cwd: positionals[0] ?? process.cwd(),
|
|
392
|
+
runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
|
|
393
|
+
personality: typeof flags.personality === 'string' ? flags.personality : undefined,
|
|
394
|
+
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
395
|
+
runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
|
|
396
|
+
bootstrap: flags['no-bootstrap'] === true ? false : undefined,
|
|
397
|
+
env,
|
|
398
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
399
|
+
})
|
|
400
|
+
out(
|
|
401
|
+
`initialized "${r.personality}" (${r.runtime}); mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
|
|
402
|
+
`${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
|
|
403
|
+
)
|
|
404
|
+
return 0
|
|
405
|
+
}
|
|
406
|
+
case 'create': {
|
|
407
|
+
// cwd-INDEPENDENT: resolve a location (default ~/.iapeer/peers/<p> or --path),
|
|
408
|
+
// scaffold the folder (no-clobber), then init it. Operator-add for an infra
|
|
409
|
+
// human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
|
|
410
|
+
if (!positionals[0]) return usage(errOut)
|
|
411
|
+
const { createPeer } = await import('../create/index.ts')
|
|
412
|
+
const r = await createPeer({
|
|
413
|
+
personality: positionals[0],
|
|
414
|
+
runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
|
|
415
|
+
path: typeof flags.path === 'string' ? flags.path : undefined,
|
|
416
|
+
description: typeof flags.description === 'string' ? flags.description : undefined,
|
|
417
|
+
intelligence: typeof flags.intelligence === 'string' ? (flags.intelligence as Intelligence) : undefined,
|
|
418
|
+
runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
|
|
419
|
+
bootstrap: flags['no-bootstrap'] === true ? false : undefined,
|
|
420
|
+
env,
|
|
421
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
422
|
+
})
|
|
423
|
+
out(
|
|
424
|
+
`created "${r.personality}" (${r.runtime}) at ${r.location}; mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
|
|
425
|
+
`${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
|
|
426
|
+
)
|
|
427
|
+
return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
|
|
428
|
+
}
|
|
429
|
+
case 'list': {
|
|
430
|
+
// tty + no --json → the interactive control-panel (↑/↓ · Enter=attach · / · q);
|
|
431
|
+
// non-tty / --json → the scriptable table (machine-parsable).
|
|
432
|
+
if (flags.json !== true && process.stdout.isTTY && process.stdin.isTTY) {
|
|
433
|
+
const { runListTui } = await import('./listTui.ts')
|
|
434
|
+
return await runListTui(env)
|
|
435
|
+
}
|
|
436
|
+
const rows = listPeers({ env })
|
|
437
|
+
out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
|
|
438
|
+
return 0
|
|
439
|
+
}
|
|
440
|
+
case 'stop': {
|
|
441
|
+
// --all stops every registered peer (the fleet guard still refuses foreign
|
|
442
|
+
// persistent-peer plists, so the live fleet stays untouched).
|
|
443
|
+
const peers = flags.all === true
|
|
444
|
+
? readPeersIndex({ env }).peers.map(p => p.personality)
|
|
445
|
+
: positionals[0]
|
|
446
|
+
? [positionals[0]]
|
|
447
|
+
: null
|
|
448
|
+
if (!peers) return usage(errOut)
|
|
449
|
+
const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
|
|
450
|
+
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
451
|
+
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
452
|
+
}
|
|
453
|
+
case 'start': {
|
|
454
|
+
if (!positionals[0]) return usage(errOut)
|
|
455
|
+
const outcomes = startPeer(positionals[0], positionals[1], { env })
|
|
456
|
+
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
457
|
+
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
458
|
+
}
|
|
459
|
+
case 'send': {
|
|
460
|
+
if (!positionals[0] || typeof flags.message !== 'string') return usage(errOut)
|
|
461
|
+
const r = await sendMessage({
|
|
462
|
+
target: positionals[0],
|
|
463
|
+
from: typeof flags.from === 'string' ? flags.from : defaultFromIdentity(env),
|
|
464
|
+
message: flags.message,
|
|
465
|
+
runtime: typeof flags.runtime === 'string' ? flags.runtime : undefined,
|
|
466
|
+
topic: typeof flags.topic === 'string' ? flags.topic : undefined,
|
|
467
|
+
env,
|
|
468
|
+
})
|
|
469
|
+
out(`delivered to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
|
|
470
|
+
return 0
|
|
471
|
+
}
|
|
472
|
+
case 'install': {
|
|
473
|
+
// UNIFIED foundation install (contract Установка §1 — "один npx ставит
|
|
474
|
+
// фундамент"): ONE command does all three install-phase steps that used to be
|
|
475
|
+
// split across `install` + `daemon --install-plist`:
|
|
476
|
+
// (1) global scaffold ~/.iapeer/ (+ peers/, state/logs/cache, runtime scopes)
|
|
477
|
+
// (2) build + place the stable ~/.local/bin/iapeer binary (atomic)
|
|
478
|
+
// (3) WRITE the daemon's com.agfpd.iapeer plist (NOT bootstrapped — a live
|
|
479
|
+
// daemon already runs; migrating it onto the installed binary is a
|
|
480
|
+
// separate coordinated wave, contract Установка §1).
|
|
481
|
+
// Bootstrap path — run from the src tree (`bun src/cli/index.ts install`) or
|
|
482
|
+
// npx; the compiled binary cannot rebuild itself from source (its
|
|
483
|
+
// import.meta.url is the binary → build fails with a clear error).
|
|
484
|
+
const { installIapeer } = await import('../install/index.ts')
|
|
485
|
+
ensureGlobalIapScaffold({ env })
|
|
486
|
+
const r = installIapeer(fileURLToPath(import.meta.url), env)
|
|
487
|
+
const plist = installDaemonPlist({ env })
|
|
488
|
+
out(
|
|
489
|
+
`installed iapeer → ${r.binPath}` +
|
|
490
|
+
`${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
|
|
491
|
+
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
492
|
+
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
|
493
|
+
` daemon plist written: ${plist}\n` +
|
|
494
|
+
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
|
495
|
+
)
|
|
496
|
+
return 0
|
|
497
|
+
}
|
|
498
|
+
case 'daemon': {
|
|
499
|
+
// Ф-F: the prod daemon entrypoint. The launchd plist runs `iapeer daemon`
|
|
500
|
+
// (the INSTALLED binary), decoupling prod from the mutable src tree.
|
|
501
|
+
if (flags['install-plist'] === true) {
|
|
502
|
+
const p = installDaemonPlist({ env })
|
|
503
|
+
out(`installed daemon plist: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
const handle = await startConfiguredDaemon({
|
|
507
|
+
port: env.IAPEER_PORT?.trim() ? Number(env.IAPEER_PORT) : undefined,
|
|
508
|
+
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
509
|
+
env,
|
|
510
|
+
})
|
|
511
|
+
errOut(`[iapeer] daemon READY tcp=${handle.url} sock=${handle.socketPath}\n`)
|
|
512
|
+
const shutdown = () => void handle.close().then(() => process.exit(0))
|
|
513
|
+
process.on('SIGTERM', shutdown)
|
|
514
|
+
process.on('SIGINT', shutdown)
|
|
515
|
+
await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
|
|
516
|
+
return 0
|
|
517
|
+
}
|
|
518
|
+
case 'run-infra': {
|
|
519
|
+
// Ф-F: the always-on infra entrypoint (telegram/notifier), held by launchd.
|
|
520
|
+
// The infra plist runs `iapeer run-infra <personality> <runtime>` (installed
|
|
521
|
+
// binary) instead of `bun launchdRun.ts`. cwd = the launchd WorkingDirectory.
|
|
522
|
+
if (!positionals[0] || !positionals[1]) return usage(errOut)
|
|
523
|
+
return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
|
|
524
|
+
}
|
|
525
|
+
case 'interrupt':
|
|
526
|
+
case 'compact': {
|
|
527
|
+
// In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
|
|
528
|
+
// turn (Escape) / compact context. UNCONDITIONAL — acts on the live session.
|
|
529
|
+
if (!positionals[0]) return usage(errOut)
|
|
530
|
+
const r = routeControl(positionals[0], positionals[1], { name: verb })
|
|
531
|
+
if (!r.ok) {
|
|
532
|
+
errOut(`${verb}: ${r.error.message}\n`)
|
|
533
|
+
return 1
|
|
534
|
+
}
|
|
535
|
+
out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
|
|
536
|
+
return 0
|
|
537
|
+
}
|
|
538
|
+
case 'enable': {
|
|
539
|
+
// Per-peer capability install (contract Установка §3): install <plugin>@agfpd
|
|
540
|
+
// per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
|
|
541
|
+
// call the plugin's `setup` ONLY if its iapeer.json declares it. Idempotent and
|
|
542
|
+
// fleet-safe — claude is keyed by the peer's projectPath. `enable <plugin> [peer]`.
|
|
543
|
+
if (!positionals[0]) return usage(errOut)
|
|
544
|
+
const { enableCapability } = await import('../enable/index.ts')
|
|
545
|
+
const r = enableCapability({
|
|
546
|
+
plugin: positionals[0],
|
|
547
|
+
peer: positionals[1],
|
|
548
|
+
noSetup: flags['no-setup'] === true,
|
|
549
|
+
env,
|
|
550
|
+
})
|
|
551
|
+
for (const rt of r.runtimes) {
|
|
552
|
+
out(` ${rt.runtime}: ${rt.state}${rt.detail ? ` — ${rt.detail}` : ''}\n`)
|
|
553
|
+
}
|
|
554
|
+
out(`enable ${r.plugin} @ ${r.personality}: setup ${r.setup}${r.setupDetail ? ` — ${r.setupDetail}` : ''}\n`)
|
|
555
|
+
return r.runtimes.some(rt => rt.state === 'failed') || r.setup === 'failed' ? 1 : 0
|
|
556
|
+
}
|
|
557
|
+
case 'attach': {
|
|
558
|
+
if (!positionals[0]) return usage(errOut)
|
|
559
|
+
const r = await attachPeer({ personality: positionals[0], runtime: positionals[1], env })
|
|
560
|
+
if (!r.ok) {
|
|
561
|
+
errOut(`attach: ${r.reason}\n`)
|
|
562
|
+
return 1
|
|
563
|
+
}
|
|
564
|
+
out(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
|
|
565
|
+
// Drop the operator into the session. `env -u TMUX` so a nested attach from
|
|
566
|
+
// inside tmux does not error ("sessions should be nested with care").
|
|
567
|
+
const attachEnv = { ...env }
|
|
568
|
+
delete attachEnv.TMUX
|
|
569
|
+
const a = spawnSync('tmux', ['-S', r.socketPath, 'attach', '-t', r.identity], {
|
|
570
|
+
stdio: 'inherit',
|
|
571
|
+
env: attachEnv as Record<string, string>,
|
|
572
|
+
})
|
|
573
|
+
return a.status ?? 0
|
|
574
|
+
}
|
|
575
|
+
default: {
|
|
576
|
+
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
|
|
577
|
+
if (verb && isRuntime(verb)) {
|
|
578
|
+
const r = await folderLaunch({ cwd: process.cwd(), runtime: verb, env })
|
|
579
|
+
if (r.status === 'FAILED') {
|
|
580
|
+
errOut(`launch: ${r.reason}\n`)
|
|
581
|
+
return 1
|
|
582
|
+
}
|
|
583
|
+
out(`launched ${r.process_address} (fresh)\n`)
|
|
584
|
+
return 0
|
|
585
|
+
}
|
|
586
|
+
return usage(errOut)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
errOut(`iapeer ${verb ?? ''}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
591
|
+
return 1
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function usage(errOut: (s: string) => void): number {
|
|
596
|
+
errOut(USAGE)
|
|
597
|
+
return 2
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Default --from for `send`: the identity of the peer in the current cwd (contract:
|
|
601
|
+
* "по умолчанию — identity пира текущей папки"). Requires running from a peer cwd. */
|
|
602
|
+
function defaultFromIdentity(env: NodeJS.ProcessEnv): string {
|
|
603
|
+
return resolveIdentity({ env }).address
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (import.meta.main) {
|
|
607
|
+
runCli(process.argv.slice(2)).then(code => process.exit(code))
|
|
608
|
+
}
|