@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// composeSystemPrompt — GOLDEN-equivalent TS reimplementation of the jq
|
|
2
|
+
// doctrine-merge in Persistent-Peer/bin/claude-start.sh:264-290. Produces the
|
|
3
|
+
// merged peer system prompt that --system-prompt-file / model_instructions_file
|
|
4
|
+
// consume: a YAML identity block (dynamic facts) + global doctrine (optional) +
|
|
5
|
+
// per-peer doctrine. Byte-for-byte equivalent to the bash/jq output — see the
|
|
6
|
+
// byte-layout notes below.
|
|
7
|
+
//
|
|
8
|
+
// Ownership: launch (HOW to bring up ONE session); NO currency on this path.
|
|
9
|
+
// Contract is FROZEN in ./types.ts (ComposePromptInput / ComposeSystemPrompt).
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { IAPEER_DIR } from '../core/constants.ts'
|
|
14
|
+
import { publicPeerSummary, readPeersIndex, type PublicPeerSummary } from '../registry/index.ts'
|
|
15
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
16
|
+
import type { ComposePromptInput, PromptDomainBlock } from './types.ts'
|
|
17
|
+
|
|
18
|
+
const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
|
|
19
|
+
|
|
20
|
+
// The bash builds $MERGED as the concatenation of two streams in a `{ … }` group:
|
|
21
|
+
//
|
|
22
|
+
// 1. jq --raw-output over a 10-element string array. --raw-output prints each
|
|
23
|
+
// array element on its own line, every line terminated by '\n' (including
|
|
24
|
+
// the LAST element). The array is:
|
|
25
|
+
// "---", 8×"<key>: <value | @json>", "---", ""
|
|
26
|
+
// so the final "" element emits a line that is just '\n' → the blank line
|
|
27
|
+
// after the closing '---'. Net jq output (each line '\n'-terminated):
|
|
28
|
+
// "---\n" + 8 yaml lines + "---\n" + "\n"
|
|
29
|
+
// i.e. the YAML block ALWAYS ends with "---\n\n".
|
|
30
|
+
//
|
|
31
|
+
// 2a. if the global doctrine file exists: cat "$GLOBAL" then printf "\n".
|
|
32
|
+
// `cat` emits the file verbatim; `printf "\n"` appends exactly ONE '\n'.
|
|
33
|
+
// In TS the file content is `input.globalDoctrine`, so this contributes
|
|
34
|
+
// `globalDoctrine + "\n"` — the trailing "\n" is added UNCONDITIONALLY,
|
|
35
|
+
// whether or not globalDoctrine already ends in a newline (it mirrors the
|
|
36
|
+
// always-on `printf "\n"`). Omitted entirely when global is absent/empty.
|
|
37
|
+
//
|
|
38
|
+
// 2b. cat "$DOCTRINE": the per-peer doctrine verbatim, NO added newline.
|
|
39
|
+
//
|
|
40
|
+
// jq @json renders each value as a JSON string literal. It equals JSON.stringify
|
|
41
|
+
// byte-for-byte across the realistic field domain (empty string, tab/CR/LF/FF/BS,
|
|
42
|
+
// C0 control chars, unicode passthrough, '"'/'\\' escaping, '/' left unescaped) —
|
|
43
|
+
// verified by enumerating every codepoint U+0001..U+0100. The ONE divergence: jq
|
|
44
|
+
// escapes U+007F (DEL) as a \u007f sequence, whereas JSON.stringify emits the raw
|
|
45
|
+
// byte (the JSON spec only mandates escaping U+0000..U+001F, so DEL passes
|
|
46
|
+
// through). jqJson() reproduces jq exactly by post-escaping DEL (and NUL — the
|
|
47
|
+
// same class; unreachable through the bash `--arg` origin, but escaped for total
|
|
48
|
+
// faithfulness). A JSON string literal is also a valid YAML double-quoted scalar,
|
|
49
|
+
// so the YAML stays safe against colons/quotes/newlines in description / paths.
|
|
50
|
+
function jqJson(value: string): string {
|
|
51
|
+
// JSON.stringify, then bring the two control chars jq escapes but the
|
|
52
|
+
// spec-minimal JSON.stringify leaves literal (DEL, NUL) into line with jq.
|
|
53
|
+
return JSON.stringify(value)
|
|
54
|
+
.replace(new RegExp(String.fromCharCode(0x7f), 'g'), '\\u007f')
|
|
55
|
+
.replace(new RegExp(String.fromCharCode(0x00), 'g'), '\\u0000')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compose the merged system prompt (YAML identity block + optional global
|
|
60
|
+
* doctrine + per-peer doctrine), GOLDEN byte-for-byte equivalent to the
|
|
61
|
+
* claude-start.sh:264-290 jq pipeline.
|
|
62
|
+
*/
|
|
63
|
+
export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
64
|
+
// The eight identity lines, in the bash's exact key order. Keys verbatim:
|
|
65
|
+
// hyphen `peer-cwd`, underscore `os_version`. Value = jqJson (= jq @json).
|
|
66
|
+
const yaml =
|
|
67
|
+
'---\n' +
|
|
68
|
+
`personality: ${jqJson(input.personality)}\n` +
|
|
69
|
+
`description: ${jqJson(input.description)}\n` +
|
|
70
|
+
`peer-cwd: ${jqJson(input.cwd)}\n` +
|
|
71
|
+
`platform: ${jqJson(input.platform)}\n` +
|
|
72
|
+
`os_version: ${jqJson(input.osVersion)}\n` +
|
|
73
|
+
`user: ${jqJson(input.user)}\n` +
|
|
74
|
+
`hostname: ${jqJson(input.hostname)}\n` +
|
|
75
|
+
`today: ${jqJson(input.today)}\n` +
|
|
76
|
+
'---\n' +
|
|
77
|
+
// jq's final "" array element → the bare blank line after the closing '---'.
|
|
78
|
+
'\n'
|
|
79
|
+
|
|
80
|
+
// Global doctrine sits between the YAML block and the per-peer doctrine so
|
|
81
|
+
// per-peer rules override global (specific over general). Bash gates on file
|
|
82
|
+
// EXISTENCE (`[ -f "$GLOBAL_DOCTRINE" ]`), NOT non-emptiness — a present but
|
|
83
|
+
// EMPTY global file still emits `cat "" + printf "\n"` = "\n". So the contract
|
|
84
|
+
// is: a present file → globalDoctrine is the string (possibly ""), absent file
|
|
85
|
+
// → undefined. Guard on `!== undefined` (NOT truthiness) to match bash exactly;
|
|
86
|
+
// an empty "" then yields "" + "\n" = "\n" (the 1-byte golden divergence the
|
|
87
|
+
// adversarial verify caught). The trailing "\n" mirrors the unconditional
|
|
88
|
+
// `printf "\n"` after `cat "$GLOBAL"`.
|
|
89
|
+
const global = input.globalDoctrine !== undefined ? input.globalDoctrine + '\n' : ''
|
|
90
|
+
|
|
91
|
+
// Layers 1+2: YAML block + global doctrine + per-peer doctrine verbatim
|
|
92
|
+
// (`cat "$DOCTRINE"`), no trailing newline added. BYTE-IDENTICAL to the legacy
|
|
93
|
+
// jq output — the golden test pins this exactly.
|
|
94
|
+
const layer12 = yaml + global + input.peerDoctrine
|
|
95
|
+
|
|
96
|
+
// Layer 3 (registry) and Layer 4 (other domains) are appended ONLY when they
|
|
97
|
+
// have content.
|
|
98
|
+
const layer3 = renderRegistry(input.peers ?? [])
|
|
99
|
+
const layer4 = renderDomains(input.pluginDomains ?? [])
|
|
100
|
+
const sections = [layer12, layer3, layer4].filter(section => section.length > 0)
|
|
101
|
+
|
|
102
|
+
// ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
|
|
103
|
+
// the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
|
|
104
|
+
// trailing-newline-or-not). MULTIPLE sections → normalize every seam to exactly
|
|
105
|
+
// one blank line: strip each section's trailing newlines, join with '\n\n', end
|
|
106
|
+
// with a single '\n'. This makes the output robust to whether the source files
|
|
107
|
+
// carried trailing newlines (a present-but-newline-only tail never widens a gap).
|
|
108
|
+
if (sections.length === 1) return sections[0]
|
|
109
|
+
return sections.map(section => section.replace(/\n+$/, '')).join('\n\n') + '\n'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Layer 3 — normalized peer registry (publicPeerSummary, exactly 5 fields). The
|
|
114
|
+
// behavioral driver for delegation: without a peer list an executor never enters
|
|
115
|
+
// the model's reasoning (contract §"Реестр пиров"). Empty list → empty layer.
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function renderRegistry(peers: readonly PublicPeerSummary[]): string {
|
|
119
|
+
if (peers.length === 0) return ''
|
|
120
|
+
const lines = ['## Known peers']
|
|
121
|
+
for (const p of peers) {
|
|
122
|
+
const desc = p.description ? ` — ${p.description}` : ''
|
|
123
|
+
lines.push(`- ${p.personality}${desc}`)
|
|
124
|
+
lines.push(` runtime: ${p.runtime}`)
|
|
125
|
+
lines.push(` runtimes: ${p.runtimes.join(', ')}`)
|
|
126
|
+
lines.push(` intelligence: ${p.intelligence}`)
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
// Layer 4 — every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root, merged
|
|
133
|
+
// general → specific (global then local). The merge is ORGANIC: domain stems are
|
|
134
|
+
// used only for stable ordering (done by the gatherer), never emitted — custom
|
|
135
|
+
// operator files (SPAWNER_INSTRUCTIONS.md, …) flow in as ordinary domains. An
|
|
136
|
+
// empty file is a presence marker (Канал B) and contributes no text here.
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function renderDomains(domains: readonly PromptDomainBlock[]): string {
|
|
140
|
+
const blocks: string[] = []
|
|
141
|
+
for (const d of domains) {
|
|
142
|
+
// Per domain: global then local (general → specific), each trimmed of its
|
|
143
|
+
// trailing newlines so a 0-byte marker (or a newline-only file) drops out and
|
|
144
|
+
// the spacing is uniform. Halves of one domain are kept tight (single '\n');
|
|
145
|
+
// distinct domains are separated by a blank line below.
|
|
146
|
+
const halves = [d.global, d.local]
|
|
147
|
+
.filter((s): s is string => s !== undefined)
|
|
148
|
+
.map(s => s.replace(/\n+$/, ''))
|
|
149
|
+
.filter(s => s.length > 0)
|
|
150
|
+
if (halves.length > 0) blocks.push(halves.join('\n'))
|
|
151
|
+
}
|
|
152
|
+
return blocks.join('\n\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
// gatherPromptInput — the FS-discovery half: scan ~/.iapeer/*.md (global) and
|
|
157
|
+
// <cwd>/.iapeer/*.md (local), split IAPEER.md (Layer 2) from every other domain
|
|
158
|
+
// (Layer 4), and project the live registry through publicPeerSummary (Layer 3),
|
|
159
|
+
// into a ready-to-render ComposePromptInput. Layer-1 identity/host facts are
|
|
160
|
+
// supplied by the caller (lifecycle gathers them via sw_vers/hostname/etc).
|
|
161
|
+
// This is the ONLY FS-touching part; composeSystemPrompt itself stays pure.
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export interface GatherPromptOptions {
|
|
165
|
+
/** Layer 1 — identity + host facts (caller-gathered). */
|
|
166
|
+
personality: string
|
|
167
|
+
description: string
|
|
168
|
+
cwd: string
|
|
169
|
+
platform: string
|
|
170
|
+
osVersion: string
|
|
171
|
+
user: string
|
|
172
|
+
hostname: string
|
|
173
|
+
today: string
|
|
174
|
+
/** The global `.iapeer` root; defaults to resolveGlobalRoot(env) (= ~/.iapeer). */
|
|
175
|
+
globalRoot?: string
|
|
176
|
+
env?: NodeJS.ProcessEnv
|
|
177
|
+
/** Layer 3 override (tests); defaults to the live-registry projection. */
|
|
178
|
+
peers?: PublicPeerSummary[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readFileIfPresent(path: string): string | undefined {
|
|
182
|
+
try {
|
|
183
|
+
if (!statSync(path).isFile()) return undefined
|
|
184
|
+
return readFileSync(path, 'utf8')
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `*.md` files directly at a `.iapeer` root (NOT recursing), excluding IAPEER.md
|
|
191
|
+
* (Layer 2) — the domain candidates for Layer 4. Missing dir → [].
|
|
192
|
+
* The `.md` test and the doctrine exclusion are CASE-INSENSITIVE: the Layer-2
|
|
193
|
+
* read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
|
|
194
|
+
* insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
|
|
195
|
+
* file ALSO surface as a Layer-4 domain and duplicate the doctrine. Lowercasing
|
|
196
|
+
* both also picks up an uppercase-extension `NOTES.MD` (spec: no root MD ignored). */
|
|
197
|
+
function listDomainFiles(root: string): string[] {
|
|
198
|
+
const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
|
|
199
|
+
try {
|
|
200
|
+
return readdirSync(root, { withFileTypes: true })
|
|
201
|
+
.filter(e => {
|
|
202
|
+
if (!e.isFile()) return false
|
|
203
|
+
const lower = e.name.toLowerCase()
|
|
204
|
+
return lower.endsWith('.md') && lower !== doctrineLower
|
|
205
|
+
})
|
|
206
|
+
.map(e => e.name)
|
|
207
|
+
} catch {
|
|
208
|
+
return []
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput {
|
|
213
|
+
const env = opts.env ?? process.env
|
|
214
|
+
const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
|
|
215
|
+
const localRoot = join(opts.cwd, IAPEER_DIR)
|
|
216
|
+
|
|
217
|
+
// Layer 2 — IAPEER.md merge. Global is existence-gated (undefined when absent,
|
|
218
|
+
// '' when present-but-empty — golden parity). Local defaults to '' when absent.
|
|
219
|
+
const globalDoctrine = readFileIfPresent(join(globalRoot, IAPEER_DOCTRINE_FILE))
|
|
220
|
+
const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
|
|
221
|
+
|
|
222
|
+
// Layer 4 — union of every non-IAPEER domain present globally and/or locally,
|
|
223
|
+
// sorted by filename for a deterministic prompt (plain codepoint order). Empty
|
|
224
|
+
// (0-byte) files stay as presence markers — readFileIfPresent returns '', and
|
|
225
|
+
// renderDomains drops empty halves, so a marker contributes no text/separator.
|
|
226
|
+
const domainNames = new Set<string>([...listDomainFiles(globalRoot), ...listDomainFiles(localRoot)])
|
|
227
|
+
const pluginDomains: PromptDomainBlock[] = [...domainNames]
|
|
228
|
+
.sort()
|
|
229
|
+
.map(name => {
|
|
230
|
+
const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
|
|
231
|
+
const global = readFileIfPresent(join(globalRoot, name))
|
|
232
|
+
const local = readFileIfPresent(join(localRoot, name))
|
|
233
|
+
if (global !== undefined) block.global = global
|
|
234
|
+
if (local !== undefined) block.local = local
|
|
235
|
+
return block
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Layer 3 — peers projected through the shared normalizer, sorted by personality
|
|
239
|
+
// (determinism even if the on-disk file was hand-edited). `opts.peers` lets a
|
|
240
|
+
// caller that already read the registry (lifecycle's wake path) pass it in,
|
|
241
|
+
// avoiding a second read+parse on the hot launch path; the sort applies either
|
|
242
|
+
// way and `.slice()` keeps the caller's array untouched.
|
|
243
|
+
const peers = (opts.peers ?? readPeersIndex({ env, rootDir: globalRoot }).peers.map(publicPeerSummary))
|
|
244
|
+
.slice()
|
|
245
|
+
.sort((a, b) => (a.personality < b.personality ? -1 : a.personality > b.personality ? 1 : 0))
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
personality: opts.personality,
|
|
249
|
+
description: opts.description,
|
|
250
|
+
cwd: opts.cwd,
|
|
251
|
+
platform: opts.platform,
|
|
252
|
+
osVersion: opts.osVersion,
|
|
253
|
+
user: opts.user,
|
|
254
|
+
hostname: opts.hostname,
|
|
255
|
+
today: opts.today,
|
|
256
|
+
peerDoctrine,
|
|
257
|
+
...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
|
|
258
|
+
peers,
|
|
259
|
+
pluginDomains,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Launch — the single runtime-AGNOSTIC session bring-up primitive + adapter
|
|
2
|
+
// dispatch. `launch` is the generalized form of the Ф2 wakeOrSpawn inline boot
|
|
3
|
+
// loop + ready-gate (lifecycle/index.ts): pre-clean stale tmux server →
|
|
4
|
+
// new-session -d with adapter.buildArgv → pipe-pane → session self-TTL → (tui)
|
|
5
|
+
// boot dialogs + first-message delivery → ready-gate on adapter.newestActivity
|
|
6
|
+
// Mtime. Every runtime specific (argv flags, boot dialogs, ready marker, activity
|
|
7
|
+
// proxy) comes through the RuntimeAdapter; this file holds NO runtime strings.
|
|
8
|
+
//
|
|
9
|
+
// Ownership split (blueprint §1, §7): launch = HOW to bring up ONE session
|
|
10
|
+
// (runtime-agnostic). It carries NO lifecycle concerns (no wake-lock, no
|
|
11
|
+
// registry/findPeer, no launchd guard, no session-state, no supervise/reap —
|
|
12
|
+
// those stay in lifecycle/index.ts, which calls launch) and NO currency (no
|
|
13
|
+
// marketplace / plugin install / update — that is install-time). lifecycle
|
|
14
|
+
// decides WHEN/HOW-MANY and hands launch a fully-resolved LaunchSpec + adapter.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync } from 'fs'
|
|
17
|
+
import { homedir } from 'os'
|
|
18
|
+
import { dirname } from 'path'
|
|
19
|
+
import { spawnSync } from 'child_process'
|
|
20
|
+
import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
|
|
21
|
+
import { readLaunchEnv } from '../storage/index.ts'
|
|
22
|
+
import { claudeAdapter } from './adapters/claude.ts'
|
|
23
|
+
import { codexAdapter } from './adapters/codex.ts'
|
|
24
|
+
import { telegramAdapter } from './adapters/telegram.ts'
|
|
25
|
+
import { notifierAdapter } from './adapters/notifier.ts'
|
|
26
|
+
import type {
|
|
27
|
+
LaunchConfig,
|
|
28
|
+
LaunchFn,
|
|
29
|
+
LaunchResult,
|
|
30
|
+
LaunchSpec,
|
|
31
|
+
RuntimeAdapter,
|
|
32
|
+
} from './types.ts'
|
|
33
|
+
|
|
34
|
+
// Re-export the launch contract so consumers (lifecycle, cli) import from the
|
|
35
|
+
// module index, not the frozen types file directly.
|
|
36
|
+
export type {
|
|
37
|
+
ComposePromptInput,
|
|
38
|
+
ComposeSystemPrompt,
|
|
39
|
+
PromptDomainBlock,
|
|
40
|
+
PublicPeerSummary,
|
|
41
|
+
LaunchAdapterConfig,
|
|
42
|
+
LaunchConfig,
|
|
43
|
+
LaunchFn,
|
|
44
|
+
LaunchResult,
|
|
45
|
+
LaunchSpec,
|
|
46
|
+
RuntimeAdapter,
|
|
47
|
+
} from './types.ts'
|
|
48
|
+
|
|
49
|
+
// Always-on launchd plist generation (infra runtimes). launchdRun.ts is a CLI
|
|
50
|
+
// entrypoint (referenced by the generated plist via its file path) — deliberately
|
|
51
|
+
// NOT re-exported here, to avoid an index ↔ launchdRun import cycle.
|
|
52
|
+
export {
|
|
53
|
+
launchdLabel,
|
|
54
|
+
launchAgentsDir,
|
|
55
|
+
launchdPlistPath,
|
|
56
|
+
renderLaunchdPlist,
|
|
57
|
+
installAlwaysOnPlist,
|
|
58
|
+
isFoundationOwnedPlist,
|
|
59
|
+
launchctlBootstrap,
|
|
60
|
+
resolveExecutable,
|
|
61
|
+
IAPEER_PLIST_OWNER_KEY,
|
|
62
|
+
} from './launchd.ts'
|
|
63
|
+
export type {
|
|
64
|
+
LaunchdPlistSpec,
|
|
65
|
+
InstallAlwaysOnPlistOptions,
|
|
66
|
+
BootstrapResult,
|
|
67
|
+
BootstrapState,
|
|
68
|
+
} from './launchd.ts'
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Adapter dispatch — claude/codex (tui) + telegram/notifier (router, infra).
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function getAdapter(runtime: Runtime): RuntimeAdapter {
|
|
75
|
+
if (runtime === 'claude') return claudeAdapter
|
|
76
|
+
if (runtime === 'codex') return codexAdapter
|
|
77
|
+
if (runtime === 'telegram') return telegramAdapter
|
|
78
|
+
if (runtime === 'notifier') return notifierAdapter
|
|
79
|
+
throw new Error(`no launch adapter for runtime "${runtime}"`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// tmux helpers (direct spawnSync; no lifecycle dependency)
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err: string } {
|
|
87
|
+
const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
|
|
88
|
+
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
|
|
89
|
+
}
|
|
90
|
+
function sessionAlive(sock: string, identity: string): boolean {
|
|
91
|
+
return tmux(sock, 'has-session', '-t', identity).ok
|
|
92
|
+
}
|
|
93
|
+
function capturePane(sock: string, identity: string): string {
|
|
94
|
+
return tmux(sock, 'capture-pane', '-t', identity, '-p').out
|
|
95
|
+
}
|
|
96
|
+
function shellQuote(value: string): string {
|
|
97
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
98
|
+
}
|
|
99
|
+
function sleep(ms: number): Promise<void> {
|
|
100
|
+
return new Promise(r => setTimeout(r, ms))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(identity: string, reason: string): LaunchResult {
|
|
104
|
+
return { status: 'FAILED', identity, process_address: identity, reason }
|
|
105
|
+
}
|
|
106
|
+
function ready(identity: string): LaunchResult {
|
|
107
|
+
return { status: 'READY', identity, process_address: identity }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// launch — bring up ONE session (runtime-agnostic via the adapter)
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export const launch: LaunchFn = async (
|
|
115
|
+
spec: LaunchSpec,
|
|
116
|
+
adapter: RuntimeAdapter,
|
|
117
|
+
firstMessage: string,
|
|
118
|
+
cfg: LaunchConfig,
|
|
119
|
+
): Promise<LaunchResult> => {
|
|
120
|
+
const { identity, socketPath: sock, cwd } = spec
|
|
121
|
+
const env = cfg.env ?? process.env
|
|
122
|
+
|
|
123
|
+
// (0) Intelligence gate (fail-loud BEFORE any tmux work): an adapter that declares
|
|
124
|
+
// requiresIntelligence (telegram → 'natural') refuses a peer whose nature does
|
|
125
|
+
// not match — and refuses too when the nature is unknown (cannot confirm). Ports
|
|
126
|
+
// the persistent-peer FATAL human-channel guard (docs/Рантайм-адаптеры).
|
|
127
|
+
if (adapter.requiresIntelligence && spec.intelligence !== adapter.requiresIntelligence) {
|
|
128
|
+
return fail(
|
|
129
|
+
identity,
|
|
130
|
+
`runtime "${spec.runtime}" requires intelligence=${adapter.requiresIntelligence}, ` +
|
|
131
|
+
`peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// (0.5) Ensure the socket's PARENT dir exists before `tmux new-session -S <sock>` —
|
|
136
|
+
// tmux does NOT create it and fails SILENTLY (the session "dies immediately")
|
|
137
|
+
// when the dir is absent. In prod the sock dir is /tmp (always there) so this
|
|
138
|
+
// never bites; it bites a sandbox/test with an IAPEER_SOCK_DIR override pointing
|
|
139
|
+
// at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
|
|
140
|
+
mkdirSync(dirname(sock), { recursive: true })
|
|
141
|
+
|
|
142
|
+
// (1) Pre-clean any stale tmux server on this socket, then launch detached.
|
|
143
|
+
spawnSync('pkill', ['-f', `tmux -S ${sock} `], { stdio: 'ignore' })
|
|
144
|
+
tmux(sock, 'kill-server')
|
|
145
|
+
|
|
146
|
+
// (2) tmux new-session -d with the adapter-built, shell-quoted argv. The per-peer
|
|
147
|
+
// per-runtime launch.env (zone Хранение / Рантайм-адаптеры) contributes
|
|
148
|
+
// PEER_START_ARGS (APPENDED after the adapter's base flags — extraArgs is last
|
|
149
|
+
// in every buildArgv) and extra child env. spec.extraArgs (an explicit caller
|
|
150
|
+
// override) comes first, then launch.env's; both land after the base flags.
|
|
151
|
+
const launchEnv = readLaunchEnv(cwd, spec.runtime)
|
|
152
|
+
const specWithArgs: LaunchSpec =
|
|
153
|
+
launchEnv.startArgs.length > 0
|
|
154
|
+
? { ...spec, extraArgs: [...(spec.extraArgs ?? []), ...launchEnv.startArgs] }
|
|
155
|
+
: spec
|
|
156
|
+
const runtimeCmd = adapter.buildArgv(specWithArgs, cfg).map(shellQuote).join(' ')
|
|
157
|
+
// Child env: base env + launch.env extras + the identity ABI (identity LAST so a
|
|
158
|
+
// stray PEER_* in launch.env can never override the resolved identity) + PATH.
|
|
159
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
160
|
+
...env,
|
|
161
|
+
...launchEnv.env,
|
|
162
|
+
// codex token-free MCP import: the daemon's bearer env var is set to the PUBLIC,
|
|
163
|
+
// non-secret stub so codex flips authStatus unsupported→bearer_token and imports
|
|
164
|
+
// send_to_peer (the OPEN daemon ignores it, authenticating by PEER_IDENTITY below
|
|
165
|
+
// via env_http_headers). Only for codex — claude carries its identity in .mcp.json.
|
|
166
|
+
...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
|
|
167
|
+
PEER_PERSONALITY: spec.personality,
|
|
168
|
+
PEER_RUNTIME: spec.runtime,
|
|
169
|
+
PEER_IDENTITY: identity,
|
|
170
|
+
PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
|
|
171
|
+
}
|
|
172
|
+
const start = spawnSync(
|
|
173
|
+
'tmux',
|
|
174
|
+
['-S', sock, 'new-session', '-d', '-s', identity, '-x', '220', '-y', '50', '-c', cwd, runtimeCmd],
|
|
175
|
+
{ env: childEnv as Record<string, string>, encoding: 'utf8' },
|
|
176
|
+
)
|
|
177
|
+
if (start.status !== 0) {
|
|
178
|
+
return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// (3) pipe-pane the session output to the per-identity log.
|
|
182
|
+
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 })
|
|
183
|
+
const paneLog = `${cfg.logDir}/${identity}.log`
|
|
184
|
+
tmux(sock, 'pipe-pane', '-t', identity, `cat >> ${shellQuote(paneLog)}`)
|
|
185
|
+
|
|
186
|
+
// (4) Session self-TTL: a tmux server-side timer kills the session at max-age,
|
|
187
|
+
// independent of any supervisor (bounds a zombie even if no sweep runs).
|
|
188
|
+
// SKIPPED for an always-on (infra) session — launchd KeepAlive owns its
|
|
189
|
+
// lifecycle, so a self-kill timer would fight it and tear down the endpoint.
|
|
190
|
+
if (!cfg.alwaysOn) {
|
|
191
|
+
tmux(sock, 'run-shell', '-b', '-d', String(cfg.maxAgeSecs),
|
|
192
|
+
`tmux -S ${shellQuote(sock)} kill-session -t ${shellQuote(identity)}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// (5) Router runtime (telegram): no TUI input surface — there is no boot dialog
|
|
196
|
+
// and no ready marker to gate on. The process is up; return READY.
|
|
197
|
+
if (adapter.kind === 'router') {
|
|
198
|
+
return ready(identity)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// (6) BOOT phase (tui) — baseline the activity proxy, answer startup dialogs,
|
|
202
|
+
// wait for the input surface, then deliver firstMessage (send-keys -l + Enter).
|
|
203
|
+
// An EMPTY firstMessage is a BARE bring-up (folder-launch with no seed, or an
|
|
204
|
+
// attach-resume that carries no message): reach the input surface and return
|
|
205
|
+
// READY — there is no message to deliver and nothing to ready-gate on (the
|
|
206
|
+
// operator drives the session). A non-empty message takes the wake path
|
|
207
|
+
// (deliver + ready-gate on a model turn).
|
|
208
|
+
const hasMessage = firstMessage.trim().length > 0
|
|
209
|
+
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
210
|
+
const bootIters = Math.max(1, Math.ceil(cfg.bootDeadlineSecs / 2))
|
|
211
|
+
let delivered = false
|
|
212
|
+
for (let i = 0; i < bootIters && !delivered; i++) {
|
|
213
|
+
await sleep(2000)
|
|
214
|
+
if (!sessionAlive(sock, identity)) {
|
|
215
|
+
return fail(identity, 'tmux session vanished during boot')
|
|
216
|
+
}
|
|
217
|
+
const pane = capturePane(sock, identity)
|
|
218
|
+
if (!pane) continue
|
|
219
|
+
const dialogKeys = adapter.bootDialogKeys(pane)
|
|
220
|
+
if (dialogKeys) {
|
|
221
|
+
tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (adapter.isInputReady(pane)) {
|
|
225
|
+
if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
|
|
226
|
+
// `--` terminates tmux option parsing (audit #6): without it a firstMessage that
|
|
227
|
+
// starts with '-' (e.g. a task opening with a markdown bullet) is mis-parsed as a
|
|
228
|
+
// send-keys flag, losing the boot message and failing the wake with a wrong reason.
|
|
229
|
+
tmux(sock, 'send-keys', '-t', identity, '-l', '--', firstMessage)
|
|
230
|
+
await sleep(300)
|
|
231
|
+
tmux(sock, 'send-keys', '-t', identity, 'Enter')
|
|
232
|
+
delivered = true
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!delivered) {
|
|
236
|
+
return fail(identity, 'never-became-ready (stuck at a startup prompt or boot hang)')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// (7) READY-GATE — the activity proxy must strictly advance past baseline
|
|
240
|
+
// (the model picked up and processed the first message).
|
|
241
|
+
const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
|
|
242
|
+
while (Date.now() < readyDeadline) {
|
|
243
|
+
await sleep(2000)
|
|
244
|
+
if (!sessionAlive(sock, identity)) {
|
|
245
|
+
return fail(identity, 'tmux session vanished during ready-gate')
|
|
246
|
+
}
|
|
247
|
+
const mt = adapter.newestActivityMtime(cwd) ?? 0
|
|
248
|
+
if (mt > baselineMtime) {
|
|
249
|
+
return ready(identity)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return fail(identity, 'model-did-not-process-task (no activity advance after delivery)')
|
|
253
|
+
}
|