@agfpd/iapeer 0.4.52 → 0.4.54

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.52",
3
+ "version": "0.4.54",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -244,12 +244,21 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
244
244
  const identity = buildProcessAddress(rt, personality)
245
245
  const sock = buildSocketPath(rt, personality, cfg.sockDir)
246
246
  if (isInfraRuntime(rt)) {
247
- // Audit #13: do NOT swallow the launchctl result. (bootout returns non-zero when
248
- // the service was already not loaded — benign for stop — but surface the detail in
249
- // the reason rather than silently claiming success.)
247
+ // Audit #13 + В48: do NOT swallow the launchctl result — but DISCRIMINATE. An
248
+ // already-unloaded job (exit 3 / "No such process", verified live) is BENIGN for
249
+ // stop: the desired state ("not running") already holds, so it carries NO reason
250
+ // and must not fail the verb. Any other non-zero is a REAL error and sets reason
251
+ // (the CLI maps a reasoned bootout to exit 1).
250
252
  const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
251
253
  killSession(sock, identity, env)
252
- out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
254
+ const stderrText = (r.stderr ?? '').trim()
255
+ const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
256
+ out.push({
257
+ personality,
258
+ runtime: rt,
259
+ action: 'bootout',
260
+ reason: benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`,
261
+ })
253
262
  } else {
254
263
  // A deliberate stop is a CLEAN PARK, not a death (stop→start must survive ≥
255
264
  // idle-reap): park-mark BEFORE the kill so the post-`start`
@@ -1131,6 +1140,32 @@ function parseIdentity(identity: string): { personality: string; runtime: Runtim
1131
1140
  // CLI dispatch — `iapeer <verb> …`
1132
1141
  // ─────────────────────────────────────────────────────────────────────────────
1133
1142
 
1143
+ /** В49 — flags that never take a value. Without the schema the look-ahead form ate the
1144
+ * NEXT positional: `iapeer update --force 0.4.31` parsed as flags.force='0.4.31' with NO
1145
+ * positionals → an UNforced full-cascade update instead of a forced version pin (live
1146
+ * incident shape). A boolean flag is always `true`; forcing a value onto one is only
1147
+ * possible via the explicit `--key=value` form (which stays verbatim). */
1148
+ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
1149
+ 'accept-risk',
1150
+ 'all',
1151
+ 'check',
1152
+ 'dry-run',
1153
+ 'fix',
1154
+ 'force',
1155
+ 'foundation-only',
1156
+ 'install-plist',
1157
+ 'json',
1158
+ 'no-bootstrap',
1159
+ 'no-memory',
1160
+ 'no-notifier',
1161
+ 'no-setup',
1162
+ 'no-telegram',
1163
+ 'no-voice',
1164
+ 'npx',
1165
+ 'remove-codesign-identity',
1166
+ 'yes',
1167
+ ])
1168
+
1134
1169
  export function parseArgs(argv: string[]): { positionals: string[]; flags: Record<string, string | true> } {
1135
1170
  const positionals: string[] = []
1136
1171
  const flags: Record<string, string | true> = {}
@@ -1145,6 +1180,11 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
1145
1180
  continue
1146
1181
  }
1147
1182
  const key = a.slice(2)
1183
+ // В49 — a KNOWN boolean flag never consumes the next token as its value.
1184
+ if (BOOLEAN_FLAGS.has(key)) {
1185
+ flags[key] = true
1186
+ continue
1187
+ }
1148
1188
  const next = argv[i + 1]
1149
1189
  if (next === undefined || next.startsWith('--')) flags[key] = true
1150
1190
  else flags[key] = argv[++i]
@@ -1684,9 +1724,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1684
1724
  out(
1685
1725
  `initialized "${r.personality}" (default: ${r.runtime}; runtimes: ${allRuntimes.join(', ')}); ` +
1686
1726
  `mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
1687
- `${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
1727
+ `${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}` +
1728
+ `${r.selfConfig?.state === 'failed' ? '; self-config: FAILED (infra peer NOT started)' : ''}\n`,
1688
1729
  )
1689
- return 0
1730
+ // В47 — a failed runtime self-config means initPeer deliberately did NOT bootstrap:
1731
+ // the always-on peer is not running. Exit 0 here read as success to automation.
1732
+ if (r.selfConfig?.state === 'failed') return 1
1733
+ return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
1690
1734
  }
1691
1735
  case 'create': {
1692
1736
  // cwd-INDEPENDENT: resolve a location (default ~/.iapeer/peers/<p> or --path),
@@ -1753,8 +1797,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1753
1797
  out(
1754
1798
  `created "${r.personality}" (default: ${r.runtime}; runtimes: ${allRuntimes.join(', ')}) at ${r.location}; ` +
1755
1799
  `mcp: ${r.mcpConfigPaths.join(', ') || r.codexMcpConfigPath || 'none'}` +
1756
- `${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}\n`,
1800
+ `${r.plistPath ? `; plist: ${r.plistPath}` : ''}${r.bootstrapped ? `; bootstrap: ${r.bootstrapped.state}` : ''}` +
1801
+ `${r.selfConfig?.state === 'failed' ? '; self-config: FAILED (infra peer NOT started)' : ''}\n`,
1757
1802
  )
1803
+ // В47 — selfConfig 'failed' deliberately skips the bootstrap (initPeer), so the
1804
+ // always-on peer is NOT running; `create x --runtime telegram` with a broken hook
1805
+ // exited 0 and automation read a dead telegram router as provisioned.
1806
+ if (r.selfConfig?.state === 'failed') return 1
1758
1807
  return r.bootstrapped && (r.bootstrapped.state === 'failed' || r.bootstrapped.state === 'refused-foreign') ? 1 : 0
1759
1808
  }
1760
1809
  case 'list': {
@@ -1865,7 +1914,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1865
1914
  if (!peers) return argErr(errOut, 'stop needs a peer or --all — usage: iapeer stop <peer> [runtime] | --all')
1866
1915
  const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1867
1916
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1868
- return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
1917
+ // В48 — a REAL bootout error (reason set; benign already-unloaded carries none) is exit 1:
1918
+ // automation must see that an always-on peer may still be running.
1919
+ return outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootout' && o.reason !== undefined)) ? 1 : 0
1869
1920
  }
1870
1921
  case 'add-runtime': {
1871
1922
  // Fleet-switch enabler (codex-parity audit): add an agentic runtime to
@@ -1917,7 +1968,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1917
1968
  if (!peers) return argErr(errOut, 'start needs a peer or --all — usage: iapeer start <peer> [runtime] | --all')
1918
1969
  const outcomes = peers.flatMap(p => startPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1919
1970
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1920
- return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
1971
+ // В48 — a failed launchctl bootstrap (reason set) means the always-on peer is NOT
1972
+ // started; exit 0 here let automation read a dead telegram/notifier as running.
1973
+ return outcomes.some(o => o.action === 'refused-foreign-launchd' || (o.action === 'bootstrap' && o.reason !== undefined)) ? 1 : 0
1921
1974
  }
1922
1975
  case 'refresh': {
1923
1976
  // LAZY soft-reload (fleet doctrine refresh): arm `.fresh-next` so each agentic peer comes up FRESH
@@ -112,7 +112,10 @@ function indexOfOutsideCdata(buffer: string, needle: string, start: number): num
112
112
  function readTagContent(xml: string, tag: string): string | undefined {
113
113
  const open = `<${tag}>`
114
114
  const close = `</${tag}>`
115
- const openIdx = xml.indexOf(open)
115
+ // В37 — the OPEN tag must be located CDATA-aware too (the close-side already was): a
116
+ // message whose CDATA body QUOTES `<attachments>…</attachments>` otherwise minted
117
+ // phantom attachments, and a quoted `<message>` corrupted the decoded message.
118
+ const openIdx = indexOfOutsideCdata(xml, open, 0)
116
119
  if (openIdx < 0) return undefined
117
120
  let i = openIdx + open.length
118
121
  let out = ''
@@ -191,12 +194,36 @@ export function decodeEnvelope(xml: string): IapEnvelope {
191
194
  }
192
195
  }
193
196
 
197
+ // В38 — a real envelope's open tag is bounded: personality/runtime ≤32 chars each,
198
+ // topic ≤200, intelligence one word, plus attr names and escaping. 1 KiB is far above
199
+ // any legitimate open tag; a longer '>'-less run after `<iap ` is prose, not a tag.
200
+ const MAX_OPEN_TAG_LEN = 1024
201
+
202
+ /** Classify the text at a `<iap ` occurrence: a complete VALID open tag (required attrs
203
+ * present), a complete-but-INVALID one / overlong tagless run (prose that merely contains
204
+ * the marker), or INCOMPLETE (no '>' yet within bounds — wait for the next chunk). */
205
+ function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
206
+ const m = /^<iap\s+([^>]*)>/.exec(candidate)
207
+ if (!m) {
208
+ const unclosed = candidate.indexOf('>') < 0
209
+ return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
210
+ }
211
+ if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
212
+ return m[1].includes('from-personality="') && m[1].includes('from-runtime="') ? 'valid' : 'invalid'
213
+ }
214
+
194
215
  /**
195
216
  * Pull complete `<iap …>…</iap>` envelopes out of a streaming buffer.
196
217
  * The closing `</iap>` is located CDATA-aware, so an envelope whose message
197
218
  * body contains the literal text `</iap>` is not truncated. `rest` holds the
198
219
  * trailing bytes that do not yet form a complete envelope (incl. an envelope
199
220
  * still mid-CDATA), to be prepended to the next chunk.
221
+ *
222
+ * В38 — a FALSE start (prose that merely contains `<iap `, e.g. a quoted tool
223
+ * description) is never committed to: the open tag is validated first, and a
224
+ * candidate whose decode fails RESYNCS one char forward instead of being kept
225
+ * (which used to either swallow the NEXT real envelope into a mis-attributed
226
+ * blob, or park the buffer forever waiting for a close that never comes).
200
227
  */
201
228
  export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
202
229
  const envelopes: string[] = []
@@ -208,10 +235,25 @@ export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: s
208
235
  return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
209
236
  }
210
237
  if (start > 0) rest = rest.slice(start)
238
+ const verdict = openTagVerdict(rest)
239
+ if (verdict === 'incomplete') return { envelopes, rest } // open tag split across chunks → wait
240
+ if (verdict === 'invalid') {
241
+ rest = rest.slice(1) // false start: skip past this '<' and rescan for the next '<iap '
242
+ continue
243
+ }
211
244
  const close = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
212
245
  if (close < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
213
246
  const envelopeEnd = close + '</iap>'.length
214
- envelopes.push(rest.slice(0, envelopeEnd))
247
+ const candidate = rest.slice(0, envelopeEnd)
248
+ try {
249
+ decodeEnvelope(candidate)
250
+ } catch {
251
+ // Structurally envelope-shaped but undecodable → a false start after all;
252
+ // resync so a REAL envelope inside/behind the candidate is still found.
253
+ rest = rest.slice(1)
254
+ continue
255
+ }
256
+ envelopes.push(candidate)
215
257
  rest = rest.slice(envelopeEnd)
216
258
  }
217
259
  }