@agfpd/iapeer 0.2.6 → 0.2.8
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/package.json +2 -2
- package/src/cli/cli.test.ts +1 -1
- package/src/core/constants.ts +9 -1
- package/src/daemon/daemon.test.ts +1 -1
- package/src/daemon/index.ts +1 -1
- package/src/launch/adapters/claude.ts +1 -1
- package/src/launch/composeSystemPrompt.layers.test.ts +93 -0
- package/src/launch/composeSystemPrompt.ts +84 -52
- package/src/launch/index.ts +74 -0
- package/src/launch/launch.test.ts +34 -1
- package/src/launch/launchdRun.ts +7 -1
- package/src/launch/types.ts +33 -8
- package/src/lifecycle/index.ts +4 -0
- package/src/transport/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Foundation core for the
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"iapeer": "bin/iapeer"
|
package/src/cli/cli.test.ts
CHANGED
|
@@ -128,7 +128,7 @@ describe('send validation', () => {
|
|
|
128
128
|
await register('alpha')
|
|
129
129
|
await expect(
|
|
130
130
|
sendMessage({ from: 'claude-alpha', target: 'nobody', message: 'hi', env: env() }),
|
|
131
|
-
).rejects.toThrow(/not in the
|
|
131
|
+
).rejects.toThrow(/not in the iapeer peers index|self/)
|
|
132
132
|
})
|
|
133
133
|
})
|
|
134
134
|
|
package/src/core/constants.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Canonical constants for the
|
|
1
|
+
// Canonical constants for the iapeer foundation.
|
|
2
2
|
// Consolidated from inter-agent-protocol/src/lib/constants.ts (wins as-is) and
|
|
3
3
|
// extended with storage-layer path names (blueprint §1 core/constants).
|
|
4
4
|
|
|
@@ -131,6 +131,14 @@ export function resolveSockDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
131
131
|
// === per-peer cwd scope ===
|
|
132
132
|
export const IAPEER_DIR = '.iapeer'
|
|
133
133
|
export const PEER_PROFILE_FILE = 'peer-profile.json'
|
|
134
|
+
// Doctrine-fragments layer (Канал A, слой 5): a primitive-owned `.iapeer/fragments/`
|
|
135
|
+
// subdir under BOTH the global root (`~/.iapeer/fragments/`, host-wide) and the
|
|
136
|
+
// per-peer cwd (`<cwd>/.iapeer/fragments/`, per-peer). Holds machine-regenerated
|
|
137
|
+
// `*.md` fragments that an ecosystem primitive writes + rotates itself (e.g.
|
|
138
|
+
// iapeer-memory's guide + per-peer note index) — merged into the system prompt so
|
|
139
|
+
// the context survives compaction, yet kept OUT of the hand-authored IAPEER.md
|
|
140
|
+
// doctrine (two writers, mutual-rollback). See composeSystemPrompt Layer 5.
|
|
141
|
+
export const FRAGMENTS_DIR = 'fragments'
|
|
134
142
|
|
|
135
143
|
// === global scope ~/.iapeer/ ===
|
|
136
144
|
export const IAP_PLUGIN_DIR = 'iap'
|
|
@@ -93,7 +93,7 @@ describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', ()
|
|
|
93
93
|
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
94
94
|
const r = await callTool(caller, 'send_to_peer', { personality: 'nobody', message: 'hi' })
|
|
95
95
|
expect(r.isError).toBe(true)
|
|
96
|
-
expect(r.content[0].text).toMatch(/not in the
|
|
96
|
+
expect(r.content[0].text).toMatch(/not in the iapeer peers index/)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
test('unknown tool → error', async () => {
|
package/src/daemon/index.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function resolveCallerFromHeader(value: string | undefined, index: PeersI
|
|
|
77
77
|
|
|
78
78
|
export function buildSendDescription(index: PeersIndex): string {
|
|
79
79
|
const lines = [
|
|
80
|
-
'Send a message to a known
|
|
80
|
+
'Send a message to a known iapeer peer through Inter-Agent-Protocol.',
|
|
81
81
|
'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
|
|
82
82
|
'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
|
|
83
83
|
'',
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (the dev-channels "I am using this for local development" Enter, 335-345).
|
|
11
11
|
// - Spawned-Peer/src/spawner.ts — buildClaudeArgv (759-787: same flags +
|
|
12
12
|
// optional --resume <uuid>) and findLatestTranscript (522-538: resume uuid).
|
|
13
|
-
// -
|
|
13
|
+
// - iapeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
|
|
14
14
|
// newestClaudeTranscriptMtime / findLatestClaudeTranscript. This is the
|
|
15
15
|
// canonical port: the slug is realpath(cwd).replace(/[^a-zA-Z0-9]/g,'-') —
|
|
16
16
|
// claude encodes EVERY non-alphanumeric char (not just '/'); the old
|
|
@@ -130,6 +130,65 @@ describe('composeSystemPrompt — layer 4 (other domains)', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
describe('composeSystemPrompt — layer 5 (doctrine fragments)', () => {
|
|
134
|
+
const frag = (over: Partial<PromptDomainBlock>): PromptDomainBlock => ({ domain: 'F', ...over })
|
|
135
|
+
|
|
136
|
+
test('empty/omitted fragments add NOTHING (legacy bytes preserved)', () => {
|
|
137
|
+
const legacy = composeSystemPrompt(base)
|
|
138
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [] })).toBe(legacy)
|
|
139
|
+
expect(composeSystemPrompt({ ...base, promptFragments: undefined })).toBe(legacy)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('a presence-marker fragment (empty halves) emits no text and no dangling separator', () => {
|
|
143
|
+
const legacy = composeSystemPrompt(base)
|
|
144
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [frag({ local: '' })] })).toBe(legacy)
|
|
145
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [frag({ global: '', local: '' })] })).toBe(legacy)
|
|
146
|
+
const out = composeSystemPrompt({
|
|
147
|
+
...base,
|
|
148
|
+
promptFragments: [frag({ domain: 'EMPTY', local: '' }), frag({ domain: 'REAL', local: 'idx-content\n' })],
|
|
149
|
+
})
|
|
150
|
+
expect(out).toContain('idx-content')
|
|
151
|
+
expect(out).not.toMatch(/\n{4,}/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('within a fragment stem, global (host-wide guide) precedes local (per-peer index)', () => {
|
|
155
|
+
const out = composeSystemPrompt({
|
|
156
|
+
...base,
|
|
157
|
+
promptFragments: [frag({ domain: 'MEM', global: 'mem-guide', local: 'mem-index' })],
|
|
158
|
+
})
|
|
159
|
+
expect(out).toContain('mem-guide\nmem-index')
|
|
160
|
+
expect(out.indexOf('mem-guide')).toBeLessThan(out.indexOf('mem-index'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('layer 5 sits AFTER layer 4 (most-volatile last), separated by a blank line', () => {
|
|
164
|
+
const out = composeSystemPrompt({
|
|
165
|
+
...base,
|
|
166
|
+
pluginDomains: [{ domain: 'D', global: 'domain-text' }],
|
|
167
|
+
promptFragments: [{ domain: 'MEM', local: 'fragment-text' }],
|
|
168
|
+
})
|
|
169
|
+
expect(out.indexOf('domain-text')).toBeLessThan(out.indexOf('fragment-text'))
|
|
170
|
+
expect(out).toContain('domain-text\n\nfragment-text')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('full 5-layer ordering: YAML → doctrine → registry → domains → fragments', () => {
|
|
174
|
+
const out = composeSystemPrompt({
|
|
175
|
+
...base,
|
|
176
|
+
peers: [peerA],
|
|
177
|
+
pluginDomains: [{ domain: 'D', global: 'domain-text' }],
|
|
178
|
+
promptFragments: [{ domain: 'MEM', global: 'fragment-text' }],
|
|
179
|
+
})
|
|
180
|
+
const order = [
|
|
181
|
+
out.indexOf('personality:'),
|
|
182
|
+
out.indexOf('Be the implementer.'),
|
|
183
|
+
out.indexOf('## Known peers'),
|
|
184
|
+
out.indexOf('domain-text'),
|
|
185
|
+
out.indexOf('fragment-text'),
|
|
186
|
+
]
|
|
187
|
+
expect(order.every(i => i >= 0)).toBe(true)
|
|
188
|
+
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
133
192
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
193
|
// gatherPromptInput — FS discovery over ~/.iapeer + <cwd>/.iapeer
|
|
135
194
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -161,6 +220,17 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
161
220
|
// a non-.md file must be ignored
|
|
162
221
|
writeFileSync(join(localRoot, 'peer-profile.json'), '{}')
|
|
163
222
|
|
|
223
|
+
// Layer 5 — fragments/ subdir of both roots: a paired stem (host-wide guide +
|
|
224
|
+
// per-peer index) and a global-only fragment; a non-.md file must be ignored.
|
|
225
|
+
const globalFrags = join(globalRoot, 'fragments')
|
|
226
|
+
const localFrags = join(localRoot, 'fragments')
|
|
227
|
+
mkdirSync(globalFrags, { recursive: true })
|
|
228
|
+
mkdirSync(localFrags, { recursive: true })
|
|
229
|
+
writeFileSync(join(globalFrags, 'iapeer-memory.md'), 'mem-guide\n') // host-wide guide
|
|
230
|
+
writeFileSync(join(localFrags, 'iapeer-memory.md'), 'mem-index\n') // per-peer index
|
|
231
|
+
writeFileSync(join(globalFrags, 'other-primitive.md'), 'other-frag\n')
|
|
232
|
+
writeFileSync(join(localFrags, 'notes.txt'), 'ignored') // non-.md, must be ignored
|
|
233
|
+
|
|
164
234
|
// Layer 3 — registry at the global root (deliberately UNSORTED on disk)
|
|
165
235
|
const index = {
|
|
166
236
|
version: 2,
|
|
@@ -207,6 +277,23 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
207
277
|
expect(byName.MARKER).toEqual({ domain: 'MARKER', local: '' }) // present but empty
|
|
208
278
|
})
|
|
209
279
|
|
|
280
|
+
test('layer 5: fragments/ subdir scanned, paired by stem (guide global + index local), sorted', () => {
|
|
281
|
+
const input = gather()
|
|
282
|
+
const frags = input.promptFragments ?? []
|
|
283
|
+
expect(frags.map(f => f.domain)).toEqual(['iapeer-memory', 'other-primitive'])
|
|
284
|
+
const byName = Object.fromEntries(frags.map(f => [f.domain, f]))
|
|
285
|
+
expect(byName['iapeer-memory']).toEqual({ domain: 'iapeer-memory', global: 'mem-guide\n', local: 'mem-index\n' })
|
|
286
|
+
expect(byName['other-primitive']).toEqual({ domain: 'other-primitive', global: 'other-frag\n' })
|
|
287
|
+
// the non-.md notes.txt never became a fragment
|
|
288
|
+
expect(frags.map(f => f.domain)).not.toContain('notes')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('layer 5: fragments are NOT confused with the root-level Layer-4 domains', () => {
|
|
292
|
+
const input = gather()
|
|
293
|
+
// the fragments dir itself is a directory, not a `.md` file → never a Layer-4 domain
|
|
294
|
+
expect((input.pluginDomains ?? []).map(d => d.domain)).not.toContain('fragments')
|
|
295
|
+
})
|
|
296
|
+
|
|
210
297
|
test('layer 3: registry projected to EXACTLY 5 fields, sorted by personality', () => {
|
|
211
298
|
const input = gather()
|
|
212
299
|
expect((input.peers ?? []).map(p => p.personality)).toEqual(['arthur', 'boris'])
|
|
@@ -237,14 +324,19 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
237
324
|
expect(prompt).toContain('aaa-global-only')
|
|
238
325
|
expect(prompt).toContain('mm-global\nmm-local')
|
|
239
326
|
expect(prompt).toContain('zzz-local-only')
|
|
327
|
+
// Layer 5 (fragments: guide+index paired, global-only fragment)
|
|
328
|
+
expect(prompt).toContain('mem-guide\nmem-index')
|
|
329
|
+
expect(prompt).toContain('other-frag')
|
|
240
330
|
|
|
241
331
|
const order = [
|
|
242
332
|
prompt.indexOf('personality: "iapeer"'),
|
|
243
333
|
prompt.indexOf('LOCAL ROLE'),
|
|
244
334
|
prompt.indexOf('## Known peers'),
|
|
245
335
|
prompt.indexOf('aaa-global-only'),
|
|
336
|
+
prompt.indexOf('mem-guide'), // Layer 5 — after every Layer-4 domain
|
|
246
337
|
]
|
|
247
338
|
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
339
|
+
expect(prompt.indexOf('zzz-local-only')).toBeLessThan(prompt.indexOf('mem-guide'))
|
|
248
340
|
// marker file produced no content and no blank-block artifact
|
|
249
341
|
expect(prompt).not.toContain('MARKER')
|
|
250
342
|
expect(prompt).not.toMatch(/\n{4,}/)
|
|
@@ -263,6 +355,7 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
263
355
|
expect(input.peerDoctrine).toBe('')
|
|
264
356
|
expect(input.globalDoctrine).toBeUndefined()
|
|
265
357
|
expect(input.pluginDomains).toEqual([])
|
|
358
|
+
expect(input.promptFragments).toEqual([]) // no fragments/ subdir → empty layer
|
|
266
359
|
expect(input.peers).toEqual([])
|
|
267
360
|
// composes to just the YAML block (+ empty doctrine), no extra layers
|
|
268
361
|
const prompt = composeSystemPrompt(input)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
|
-
import { IAPEER_DIR } from '../core/constants.ts'
|
|
13
|
+
import { FRAGMENTS_DIR, IAPEER_DIR } from '../core/constants.ts'
|
|
14
14
|
import { publicPeerSummary, readPeersIndex, type PublicPeerSummary } from '../registry/index.ts'
|
|
15
15
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
16
16
|
import type { ComposePromptInput, PromptDomainBlock } from './types.ts'
|
|
@@ -93,11 +93,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
|
93
93
|
// jq output — the golden test pins this exactly.
|
|
94
94
|
const layer12 = yaml + global + input.peerDoctrine
|
|
95
95
|
|
|
96
|
-
// Layer 3 (registry)
|
|
97
|
-
// have content.
|
|
96
|
+
// Layer 3 (registry), Layer 4 (operator/plugin domains) and Layer 5 (primitive
|
|
97
|
+
// doctrine fragments) are appended ONLY when they have content. Layer 5 sits LAST
|
|
98
|
+
// — it is the most volatile (machine-regenerated on every source change), so it
|
|
99
|
+
// never disturbs the stable prefix (YAML + doctrine + registry + domains) above it.
|
|
98
100
|
const layer3 = renderRegistry(input.peers ?? [])
|
|
99
|
-
const layer4 =
|
|
100
|
-
const
|
|
101
|
+
const layer4 = renderMergedBlocks(input.pluginDomains ?? [])
|
|
102
|
+
const layer5 = renderMergedBlocks(input.promptFragments ?? [])
|
|
103
|
+
const sections = [layer12, layer3, layer4, layer5].filter(section => section.length > 0)
|
|
101
104
|
|
|
102
105
|
// ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
|
|
103
106
|
// the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
|
|
@@ -129,36 +132,42 @@ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
135
|
+
// Layers 4 & 5 — merged global/local blocks. Shared renderer:
|
|
136
|
+
// • Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root (operator /
|
|
137
|
+
// plugin user-settings; SPAWNER_INSTRUCTIONS.md, … flow in organically).
|
|
138
|
+
// • Layer 5: every `<STEM>.md` pair in .iapeer/fragments/ (primitive-owned,
|
|
139
|
+
// machine-regenerated doctrine fragments — iapeer-memory's guide + note index).
|
|
140
|
+
// Both merge general → specific (global then local). The merge is ORGANIC: stems
|
|
141
|
+
// are used only for stable ordering (done by the gatherer), never emitted. An empty
|
|
142
|
+
// file is a presence marker (Канал B) and contributes no text — so a fragment dir
|
|
143
|
+
// that drops to a 0-byte placeholder, or whose file vanishes mid-rotation, yields
|
|
144
|
+
// nothing here rather than a dangling separator.
|
|
137
145
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
146
|
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
for (const
|
|
142
|
-
// Per
|
|
147
|
+
function renderMergedBlocks(blocks: readonly PromptDomainBlock[]): string {
|
|
148
|
+
const out: string[] = []
|
|
149
|
+
for (const b of blocks) {
|
|
150
|
+
// Per stem: global then local (general → specific), each trimmed of its
|
|
143
151
|
// trailing newlines so a 0-byte marker (or a newline-only file) drops out and
|
|
144
|
-
// the spacing is uniform. Halves of one
|
|
145
|
-
// distinct
|
|
146
|
-
const halves = [
|
|
152
|
+
// the spacing is uniform. Halves of one stem are kept tight (single '\n');
|
|
153
|
+
// distinct stems are separated by a blank line below.
|
|
154
|
+
const halves = [b.global, b.local]
|
|
147
155
|
.filter((s): s is string => s !== undefined)
|
|
148
156
|
.map(s => s.replace(/\n+$/, ''))
|
|
149
157
|
.filter(s => s.length > 0)
|
|
150
|
-
if (halves.length > 0)
|
|
158
|
+
if (halves.length > 0) out.push(halves.join('\n'))
|
|
151
159
|
}
|
|
152
|
-
return
|
|
160
|
+
return out.join('\n\n')
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
164
|
// gatherPromptInput — the FS-discovery half: scan ~/.iapeer/*.md (global) and
|
|
157
165
|
// <cwd>/.iapeer/*.md (local), split IAPEER.md (Layer 2) from every other domain
|
|
158
|
-
// (Layer 4),
|
|
159
|
-
// into a ready-to-render
|
|
160
|
-
//
|
|
161
|
-
// This is the ONLY FS-touching
|
|
166
|
+
// (Layer 4), scan the fragments/ subdir of both roots (Layer 5), and project the
|
|
167
|
+
// live registry through publicPeerSummary (Layer 3), into a ready-to-render
|
|
168
|
+
// ComposePromptInput. Layer-1 identity/host facts are supplied by the caller
|
|
169
|
+
// (lifecycle gathers them via sw_vers/hostname/etc). This is the ONLY FS-touching
|
|
170
|
+
// part; composeSystemPrompt itself stays pure.
|
|
162
171
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
172
|
|
|
164
173
|
export interface GatherPromptOptions {
|
|
@@ -187,28 +196,51 @@ function readFileIfPresent(path: string): string | undefined {
|
|
|
187
196
|
}
|
|
188
197
|
}
|
|
189
198
|
|
|
190
|
-
/** `*.md` files directly
|
|
191
|
-
*
|
|
192
|
-
|
|
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
|
+
/** `*.md` files directly in `dir` (NOT recursing). Missing dir → []. The `.md`
|
|
200
|
+
* test is CASE-INSENSITIVE so an uppercase-extension `NOTES.MD` is picked up too. */
|
|
201
|
+
function listMdFiles(dir: string): string[] {
|
|
199
202
|
try {
|
|
200
|
-
return readdirSync(
|
|
201
|
-
.filter(e =>
|
|
202
|
-
if (!e.isFile()) return false
|
|
203
|
-
const lower = e.name.toLowerCase()
|
|
204
|
-
return lower.endsWith('.md') && lower !== doctrineLower
|
|
205
|
-
})
|
|
203
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
204
|
+
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
206
205
|
.map(e => e.name)
|
|
207
206
|
} catch {
|
|
208
207
|
return []
|
|
209
208
|
}
|
|
210
209
|
}
|
|
211
210
|
|
|
211
|
+
/** Layer-4 domain candidates: `*.md` directly at a `.iapeer` root, excluding
|
|
212
|
+
* IAPEER.md (Layer 2). The doctrine exclusion is CASE-INSENSITIVE: the Layer-2
|
|
213
|
+
* read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
|
|
214
|
+
* insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
|
|
215
|
+
* file ALSO surface as a Layer-4 domain and duplicate the doctrine. */
|
|
216
|
+
function listDomainFiles(root: string): string[] {
|
|
217
|
+
const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
|
|
218
|
+
return listMdFiles(root).filter(name => name.toLowerCase() !== doctrineLower)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Build a list of merged global/local blocks (Layer 4 OR Layer 5) from the
|
|
222
|
+
* union of `*.md` stems present in the global and/or local dir, sorted by
|
|
223
|
+
* filename (plain codepoint order) for a deterministic prompt. `lister` selects
|
|
224
|
+
* the candidate `.md` files — Layer 4 excludes the IAPEER.md doctrine, Layer 5
|
|
225
|
+
* takes every fragment. A read that races a rotation (file removed between listdir
|
|
226
|
+
* and read) → that half is undefined and drops out; a 0-byte file → '' and is
|
|
227
|
+
* dropped by the renderer. */
|
|
228
|
+
function gatherMergedBlocks(
|
|
229
|
+
globalDir: string,
|
|
230
|
+
localDir: string,
|
|
231
|
+
lister: (dir: string) => string[] = listMdFiles,
|
|
232
|
+
): PromptDomainBlock[] {
|
|
233
|
+
const names = new Set<string>([...lister(globalDir), ...lister(localDir)])
|
|
234
|
+
return [...names].sort().map(name => {
|
|
235
|
+
const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
|
|
236
|
+
const global = readFileIfPresent(join(globalDir, name))
|
|
237
|
+
const local = readFileIfPresent(join(localDir, name))
|
|
238
|
+
if (global !== undefined) block.global = global
|
|
239
|
+
if (local !== undefined) block.local = local
|
|
240
|
+
return block
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
212
244
|
export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput {
|
|
213
245
|
const env = opts.env ?? process.env
|
|
214
246
|
const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
|
|
@@ -220,20 +252,19 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
220
252
|
const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
|
|
221
253
|
|
|
222
254
|
// Layer 4 — union of every non-IAPEER domain present globally and/or locally,
|
|
223
|
-
// sorted by filename for a deterministic prompt (
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
})
|
|
255
|
+
// sorted by filename for a deterministic prompt. Empty (0-byte) files stay as
|
|
256
|
+
// presence markers — readFileIfPresent returns '', and the renderer drops empty
|
|
257
|
+
// halves, so a marker contributes no text/separator.
|
|
258
|
+
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, listDomainFiles)
|
|
259
|
+
|
|
260
|
+
// Layer 5 — doctrine fragments: every `*.md` in the fragments/ subdir of both
|
|
261
|
+
// roots, merged per stem (global guide → per-peer specifics). Primitive-owned and
|
|
262
|
+
// machine-regenerated; an atomic rotation that briefly removes/replaces a file is
|
|
263
|
+
// tolerated (a racing read just drops that half — no partial bytes, no throw).
|
|
264
|
+
const promptFragments = gatherMergedBlocks(
|
|
265
|
+
join(globalRoot, FRAGMENTS_DIR),
|
|
266
|
+
join(localRoot, FRAGMENTS_DIR),
|
|
267
|
+
)
|
|
237
268
|
|
|
238
269
|
// Layer 3 — peers projected through the shared normalizer, sorted by personality
|
|
239
270
|
// (determinism even if the on-disk file was hand-edited). `opts.peers` lets a
|
|
@@ -257,5 +288,6 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
257
288
|
...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
|
|
258
289
|
peers,
|
|
259
290
|
pluginDomains,
|
|
291
|
+
promptFragments,
|
|
260
292
|
}
|
|
261
293
|
}
|
package/src/launch/index.ts
CHANGED
|
@@ -107,6 +107,72 @@ function ready(identity: string): LaunchResult {
|
|
|
107
107
|
return { status: 'READY', identity, process_address: identity }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Exit-cause observability — capture WHY a session's process died, AT THE MOMENT
|
|
112
|
+
// of death. The daemon's 60 s supervise tick only learns of a death post-factum
|
|
113
|
+
// (reaped-gone), by which time the exit code/signal — and often the whole tmux
|
|
114
|
+
// server — is gone (the boris-fresh-style blind spot one level deeper than the
|
|
115
|
+
// supervise log). A tmux `pane-died` hook closes it: it fires the instant the
|
|
116
|
+
// pane's leader process exits (with `remain-on-exit on` retaining the dead pane so
|
|
117
|
+
// `#{pane_dead_status}`/`#{pane_dead_signal}` are populated), logs one logfmt line,
|
|
118
|
+
// then kill-sessions the now-dead pane so the daemon's `has-session` death
|
|
119
|
+
// detection (and the always-on KeepAlive block-watch) stay intact.
|
|
120
|
+
//
|
|
121
|
+
// Scope — verified live on tmux 3.6a (3 death modes + the daemon-reap path):
|
|
122
|
+
// • graceful exit → `dead_status=<code> dead_signal=` (code, no signal)
|
|
123
|
+
// • SIGTERM/SIGKILL/crash to the PROCESS → `dead_status= dead_signal=<name>`
|
|
124
|
+
// • daemon-initiated `kill-session` (idle-reap / self-TTL / stop) does NOT fire
|
|
125
|
+
// pane-died → NO line here (those are already in lifecycle.log — no double-log).
|
|
126
|
+
// IRREDUCIBLE GAP: SIGKILL to the tmux SERVER process itself runs no hook (the
|
|
127
|
+
// event loop is gone); only the daemon's post-factum reaped-gone catches that.
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** The exit-cause log file inside `exitLogDir` (sibling to lifecycle.log). */
|
|
131
|
+
export function exitLogPath(exitLogDir: string): string {
|
|
132
|
+
return `${exitLogDir}/exits.log`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the tmux `pane-died` hook command string (the value of `set-hook -t <id>
|
|
137
|
+
* pane-died <value>`). On the pane leader's death it appends ONE logfmt line —
|
|
138
|
+
* `ts=<ISO> ev=session-exit identity=<id> dead_status=#{…} dead_signal=#{…}`
|
|
139
|
+
* — to `exitLogFile`, then runs a tmux-NATIVE `kill-session` (no shell `tmux`, so
|
|
140
|
+
* it needs no PATH — launchd gives always-on servers a minimal one). Pure (no I/O)
|
|
141
|
+
* so the exact string is unit-testable. Quoting: the `run-shell` arg is wrapped in
|
|
142
|
+
* tmux SINGLE quotes (literal at the tmux layer, still `#{}`-format-expanded) with
|
|
143
|
+
* sh DOUBLE quotes inside — the two levels never collide; `\n`/`$(…)` pass through
|
|
144
|
+
* tmux untouched to sh. `identity`/`exitLogFile` are assumed free of single quotes
|
|
145
|
+
* (runtime-personality identities and the ~/.iapeer/logs path always are). */
|
|
146
|
+
export function exitCauseHook(identity: string, exitLogFile: string): string {
|
|
147
|
+
const line =
|
|
148
|
+
`ts=%s ev=session-exit identity=${identity} ` +
|
|
149
|
+
`dead_status=#{pane_dead_status} dead_signal=#{pane_dead_signal}\\n`
|
|
150
|
+
const log =
|
|
151
|
+
`printf "${line}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${exitLogFile}"`
|
|
152
|
+
return `run-shell '${log}' ; kill-session -t "${identity}"`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Install the exit-cause observability on a freshly-created session: ensure the
|
|
156
|
+
* exit-log dir exists, turn `remain-on-exit` on (so pane-died can read the dead
|
|
157
|
+
* pane's status/signal) and register the hook. Best-effort — a tmux/FS hiccup
|
|
158
|
+
* here must never fail the launch (observability is never load-bearing). No-op
|
|
159
|
+
* when `exitLogDir` is falsy (a partial/test cfg): `remain-on-exit` stays OFF so
|
|
160
|
+
* behavior is byte-identical to before (and no dead pane can linger un-reaped). */
|
|
161
|
+
function installExitHook(sock: string, identity: string, exitLogDir: string | undefined): void {
|
|
162
|
+
if (!exitLogDir) return
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(exitLogDir, { recursive: true, mode: 0o700 })
|
|
165
|
+
// remain-on-exit must be ON before the process can die, else pane-died won't
|
|
166
|
+
// retain the dead pane and the status/signal are lost. Set it (and the hook)
|
|
167
|
+
// immediately after new-session — the only un-coverable window is the few ms
|
|
168
|
+
// before this runs, irrelevant for a runtime that takes seconds to initialize.
|
|
169
|
+
tmux(sock, 'set-option', '-t', identity, 'remain-on-exit', 'on')
|
|
170
|
+
tmux(sock, 'set-hook', '-t', identity, 'pane-died', exitCauseHook(identity, exitLogPath(exitLogDir)))
|
|
171
|
+
} catch {
|
|
172
|
+
/* observability is best-effort — never block a wake on a hook-install hiccup */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
110
176
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
177
|
// launch — bring up ONE session (runtime-agnostic via the adapter)
|
|
112
178
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -178,6 +244,14 @@ export const launch: LaunchFn = async (
|
|
|
178
244
|
return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
|
|
179
245
|
}
|
|
180
246
|
|
|
247
|
+
// (2.5) Exit-cause observability: a `pane-died` hook that records WHY this
|
|
248
|
+
// session's process dies (status/signal) at the moment of death into
|
|
249
|
+
// <exitLogDir>/exits.log, then kill-sessions the dead pane (so the
|
|
250
|
+
// daemon's has-session death detection + always-on KeepAlive stay intact).
|
|
251
|
+
// Installed ASAP — before pipe-pane — so even a runtime that dies during
|
|
252
|
+
// boot leaves a cause. No-op without cfg.exitLogDir (remain-on-exit off).
|
|
253
|
+
installExitHook(sock, identity, cfg.exitLogDir)
|
|
254
|
+
|
|
181
255
|
// (3) pipe-pane the session output to the per-identity log.
|
|
182
256
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 })
|
|
183
257
|
const paneLog = `${cfg.logDir}/${identity}.log`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { getAdapter, launch } from './index.ts'
|
|
2
|
+
import { exitCauseHook, exitLogPath, getAdapter, launch } from './index.ts'
|
|
3
3
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
4
4
|
import { codexAdapter } from './adapters/codex.ts'
|
|
5
5
|
import { telegramAdapter } from './adapters/telegram.ts'
|
|
@@ -187,6 +187,39 @@ describe('claudeAdapter', () => {
|
|
|
187
187
|
})
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
+
// ─── exit-cause observability: the pane-died hook builder (pure string) ──────
|
|
191
|
+
describe('exitCauseHook (exit-cause observability)', () => {
|
|
192
|
+
const hook = exitCauseHook('claude-iapeer', '/r/logs/iapeer/exits.log')
|
|
193
|
+
|
|
194
|
+
test('exitLogPath → exits.log sibling to lifecycle.log', () => {
|
|
195
|
+
expect(exitLogPath('/r/logs/iapeer')).toBe('/r/logs/iapeer/exits.log')
|
|
196
|
+
})
|
|
197
|
+
test('reads pane_dead_status AND pane_dead_signal (both death classes)', () => {
|
|
198
|
+
// graceful exit populates #{pane_dead_status}; a signal populates #{pane_dead_signal}.
|
|
199
|
+
expect(hook).toContain('dead_status=#{pane_dead_status}')
|
|
200
|
+
expect(hook).toContain('dead_signal=#{pane_dead_signal}')
|
|
201
|
+
})
|
|
202
|
+
test('one logfmt line: ts + ev=session-exit + identity, appended to the exit log', () => {
|
|
203
|
+
expect(hook).toContain('ev=session-exit')
|
|
204
|
+
expect(hook).toContain('identity=claude-iapeer')
|
|
205
|
+
expect(hook).toContain('ts=%s')
|
|
206
|
+
expect(hook).toContain('>> "/r/logs/iapeer/exits.log"')
|
|
207
|
+
expect(hook).toContain('\\n') // literal backslash-n for sh printf, not a real newline
|
|
208
|
+
})
|
|
209
|
+
test('logs BEFORE it reaps: run-shell (sync, no -b) then tmux-native kill-session', () => {
|
|
210
|
+
// run-shell must NOT be backgrounded (-b) — the printf has to finish before the
|
|
211
|
+
// kill tears the server down, else the line is lost to the race (verified live).
|
|
212
|
+
expect(hook).not.toContain('run-shell -b')
|
|
213
|
+
expect(hook.indexOf('run-shell')).toBeLessThan(hook.indexOf('kill-session'))
|
|
214
|
+
// tmux-NATIVE kill-session (no shell `tmux`) → needs no PATH (launchd minimal env).
|
|
215
|
+
expect(hook).toContain('kill-session -t "claude-iapeer"')
|
|
216
|
+
})
|
|
217
|
+
test('quoting: single-quoted run-shell arg (tmux layer) wrapping double-quoted sh', () => {
|
|
218
|
+
expect(hook).toMatch(/run-shell '.*'/)
|
|
219
|
+
expect(hook).not.toContain("''") // no empty/again-collapsed single-quote pair
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
190
223
|
// ─── Ф-A #2: deliveryMarkers OWNED by the adapter (07.06 refactor) ───────────
|
|
191
224
|
describe('deliveryMarkers (adapter-owned, was transport PROMPT_GLYPHS)', () => {
|
|
192
225
|
test('claude: ❯ glyph + paste patterns', () => {
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { spawnSync } from 'child_process'
|
|
|
20
20
|
import { join } from 'path'
|
|
21
21
|
import { INFRA_RUNTIME_BIN_ENV, isInfraRuntime, resolveSockDir } from '../core/constants.ts'
|
|
22
22
|
import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
23
|
-
import { peerLogsDir } from '../storage/index.ts'
|
|
23
|
+
import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
|
|
24
24
|
import { readPeerProfile } from '../identity/index.ts'
|
|
25
25
|
import { getAdapter, launch } from './index.ts'
|
|
26
26
|
import type { LaunchConfig, LaunchSpec } from './types.ts'
|
|
@@ -102,6 +102,12 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
102
102
|
// GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/ — match the plist's
|
|
103
103
|
// stdout/stderr dir (installAlwaysOnPlist), not per-peer <cwd>/.iapeer/logs/.
|
|
104
104
|
logDir: peerLogsDir(personality, { env }),
|
|
105
|
+
// Exit-cause log → the shared ~/.iapeer/logs/iapeer (== lifecycle eventLogDir),
|
|
106
|
+
// so an infra peer's self-death is recorded next to lifecycle.log too. The hook
|
|
107
|
+
// also reaps the dead pane: without it remain-on-exit would linger a dead pane,
|
|
108
|
+
// keeping sessionAlive() true so runAlwaysOn block-watches forever and KeepAlive
|
|
109
|
+
// never respawns — the hook prevents that regression as well as logging the cause.
|
|
110
|
+
exitLogDir: pluginLogsDir('iapeer', { env }),
|
|
105
111
|
env,
|
|
106
112
|
alwaysOn: true,
|
|
107
113
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
20
20
|
|
|
21
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
22
|
// composeSystemPrompt — the layered Канал-A merge (docs/Сборка системного
|
|
23
|
-
// промпта — слои и каналы.md).
|
|
23
|
+
// промпта — слои и каналы.md). Five layers, general → specific (local overrides
|
|
24
24
|
// global):
|
|
25
25
|
// 1. System YAML (identity + host facts) — jq-GOLDEN, byte-for-byte.
|
|
26
26
|
// 2. iapeer doctrine: ~/.iapeer/IAPEER.md (global) + <cwd>/.iapeer/IAPEER.md (local).
|
|
@@ -28,19 +28,27 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
28
28
|
// 4. Plugin user-settings: every OTHER <DOMAIN>.md at the .iapeer/ root, global
|
|
29
29
|
// + local merged per domain. Custom files (SPAWNER_INSTRUCTIONS.md, …) flow
|
|
30
30
|
// in organically here — no special-casing.
|
|
31
|
+
// 5. Doctrine fragments: every `*.md` in the `.iapeer/fragments/` subdir, global
|
|
32
|
+
// (~/.iapeer/fragments/) + local (<cwd>/.iapeer/fragments/) merged per stem.
|
|
33
|
+
// PRIMITIVE-owned, machine-regenerated (iapeer-memory's guide + note index, …)
|
|
34
|
+
// — a dedicated namespace kept OUT of the hand-authored IAPEER.md doctrine so
|
|
35
|
+
// the auto-writer and the human writer never share a file. Sits LAST: the most
|
|
36
|
+
// volatile layer, so edits to it never disturb the stable prefix above.
|
|
31
37
|
// composeSystemPrompt is a PURE renderer over already-gathered data; the FS
|
|
32
38
|
// discovery lives in gatherPromptInput (mirrors the bash split: shell read the
|
|
33
39
|
// files, the renderer just laid out bytes).
|
|
34
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
41
|
|
|
36
|
-
/** One
|
|
37
|
-
*
|
|
38
|
-
*
|
|
42
|
+
/** One merged block: the global + local halves of a single stem (either may be
|
|
43
|
+
* absent), used for BOTH Layer 4 (`<DOMAIN>.md` at the .iapeer/ root) and Layer 5
|
|
44
|
+
* (`<STEM>.md` in .iapeer/fragments/). `domain` is the filename stem, used only
|
|
45
|
+
* for stable ordering — it is NOT emitted (the merge is organic). Within a block
|
|
46
|
+
* global precedes local (general → specific). */
|
|
39
47
|
export interface PromptDomainBlock {
|
|
40
48
|
domain: string
|
|
41
|
-
/** ~/.iapeer/<DOMAIN>.md
|
|
49
|
+
/** Global half content (general): ~/.iapeer/<DOMAIN>.md or ~/.iapeer/fragments/<STEM>.md. */
|
|
42
50
|
global?: string
|
|
43
|
-
/**
|
|
51
|
+
/** Local half content (specific — overrides global): <cwd>/.iapeer/… counterpart. */
|
|
44
52
|
local?: string
|
|
45
53
|
}
|
|
46
54
|
|
|
@@ -66,6 +74,10 @@ export interface ComposePromptInput {
|
|
|
66
74
|
/** Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root. Empty/
|
|
67
75
|
* omitted → the layer emits nothing. */
|
|
68
76
|
pluginDomains?: PromptDomainBlock[]
|
|
77
|
+
/** Layer 5: every `<STEM>.md` pair in the .iapeer/fragments/ subdir (global +
|
|
78
|
+
* local), primitive-owned and machine-regenerated. Empty/omitted → the layer
|
|
79
|
+
* emits nothing. Appended LAST (after Layer 4). */
|
|
80
|
+
promptFragments?: PromptDomainBlock[]
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
@@ -87,10 +99,12 @@ export interface ComposePromptInput {
|
|
|
87
99
|
* peerDoctrine
|
|
88
100
|
* [\n\n + registry section — only when peers.length > 0]
|
|
89
101
|
* [\n\n + merged domains — only when pluginDomains is non-empty]
|
|
102
|
+
* [\n\n + merged fragments — only when promptFragments is non-empty]
|
|
90
103
|
*
|
|
91
|
-
* Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4 are appended
|
|
104
|
+
* Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4+5 are appended
|
|
92
105
|
* (each as a `\n\n`-separated section) ONLY when they have content, so a peer
|
|
93
|
-
* with no registry
|
|
106
|
+
* with no registry, no extra domains and no fragments produces the exact legacy
|
|
107
|
+
* bytes.
|
|
94
108
|
*
|
|
95
109
|
* Each YAML value is a JSON string literal (jq @json: JSON.stringify), which is
|
|
96
110
|
* also a valid YAML double-quoted scalar — safe against colons/quotes/newlines.
|
|
@@ -266,6 +280,17 @@ export interface LaunchConfig extends LaunchAdapterConfig {
|
|
|
266
280
|
maxAgeSecs: number
|
|
267
281
|
/** Log dir for pipe-pane output. */
|
|
268
282
|
logDir: string
|
|
283
|
+
/**
|
|
284
|
+
* Durable EXIT-CAUSE log dir (~/.iapeer/logs/iapeer — next to lifecycle.log,
|
|
285
|
+
* where the investigator looks). When set, launch installs a tmux `pane-died`
|
|
286
|
+
* hook that records WHY the session's process died (exit status / signal) AT THE
|
|
287
|
+
* MOMENT of death, into `<exitLogDir>/exits.log` — the blind spot the daemon's
|
|
288
|
+
* 60 s supervise tick (reaped-gone) can only see post-factum, after the exit code
|
|
289
|
+
* is already lost. Routed through cfg (NOT re-resolved from env) so it is isolated
|
|
290
|
+
* by the same sandbox as the rest of launch; a FALSY dir → no hook installed and
|
|
291
|
+
* `remain-on-exit` stays off (original behavior — a partial/test cfg never writes
|
|
292
|
+
* and never lingers a dead pane). See exitCauseHook in index.ts. */
|
|
293
|
+
exitLogDir?: string
|
|
269
294
|
env?: NodeJS.ProcessEnv
|
|
270
295
|
/**
|
|
271
296
|
* Always-on bring-up (infra runtimes held by launchd KeepAlive): SKIP the
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -805,6 +805,10 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
805
805
|
readyGateSecs: cfg.readyGateSecs,
|
|
806
806
|
maxAgeSecs: cfg.maxAgeSecs,
|
|
807
807
|
logDir: cfg.logDir,
|
|
808
|
+
// Exit-cause log → next to lifecycle.log (~/.iapeer/logs/iapeer), where the
|
|
809
|
+
// investigator already looks: a self-death now leaves `exits.log` with the
|
|
810
|
+
// status/signal the daemon's post-factum reaped-gone could never recover.
|
|
811
|
+
exitLogDir: cfg.eventLogDir,
|
|
808
812
|
env,
|
|
809
813
|
}
|
|
810
814
|
// C2 — initial_prompt (launch-seed): on a FRESH wake, seed the first turn with
|
package/src/transport/index.ts
CHANGED
|
@@ -469,7 +469,7 @@ export async function routeSend(
|
|
|
469
469
|
const index = readPeersIndex()
|
|
470
470
|
const peer = findPeer(index, personality)
|
|
471
471
|
if (!peer) {
|
|
472
|
-
return err(`peer "${personality}" is not in the
|
|
472
|
+
return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
// Built once — it is both the live-delivery payload and, on a miss, the wake
|