@agfpd/iapeer 0.4.40 → 0.4.41

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.40",
3
+ "version": "0.4.41",
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
@@ -64,7 +64,7 @@ import {
64
64
  validateProfileStandard,
65
65
  } from '../identity/profileStandard.ts'
66
66
  import { runAlwaysOn } from '../launch/launchdRun.ts'
67
- import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
67
+ import { ensureDaemonStarted, installDaemonPlist, parseDaemonPort, startConfiguredDaemon } from '../daemon/main.ts'
68
68
  import { MARKETPLACE_NAME, onboardHost, runtimeAuthNote, tccFullDiskAccessNote } from '../onboard/index.ts'
69
69
  import { probeFullDiskAccess, readMemoryProvider } from '../status/index.ts'
70
70
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
@@ -2242,7 +2242,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2242
2242
  return 0
2243
2243
  }
2244
2244
  const handle = await startConfiguredDaemon({
2245
- port: env.IAPEER_PORT?.trim() ? Number(env.IAPEER_PORT) : undefined,
2245
+ port: parseDaemonPort(env.IAPEER_PORT), // В29 — strict: NaN/out-of-range → default, never an ephemeral port
2246
2246
  socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
2247
2247
  env,
2248
2248
  })
@@ -21,6 +21,7 @@
21
21
  // same-uid and should add a bearer token before production exposure — OPEN.
22
22
 
23
23
  import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'http'
24
+ import { createConnection } from 'net'
24
25
  import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs'
25
26
  import { dirname, join } from 'path'
26
27
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
@@ -49,6 +50,30 @@ import { appendDeliveryEvent } from './deliverylog.ts'
49
50
  export const CALLER_HEADER = 'x-iapeer-identity'
50
51
  const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
51
52
 
53
+ /** Does a LIVE listener answer on this unix socket path? (В28) A successful connect ⇒ a daemon is
54
+ * running; ECONNREFUSED ⇒ a stale socket file (safe to unlink). Bounded by a short timeout so a wedged
55
+ * socket never hangs startup. */
56
+ function socketIsLive(socketPath: string, timeoutMs = 500): Promise<boolean> {
57
+ return new Promise<boolean>(resolve => {
58
+ let settled = false
59
+ const finish = (live: boolean): void => {
60
+ if (settled) return
61
+ settled = true
62
+ try {
63
+ c.destroy()
64
+ } catch {
65
+ /* */
66
+ }
67
+ resolve(live)
68
+ }
69
+ const c = createConnection(socketPath)
70
+ c.once('connect', () => finish(true))
71
+ c.once('error', () => finish(false))
72
+ const t = setTimeout(() => finish(false), timeoutMs)
73
+ if (typeof t.unref === 'function') t.unref()
74
+ })
75
+ }
76
+
52
77
  // ─────────────────────────────────────────────────────────────────────────────
53
78
  // Caller identity from the request header
54
79
  // ─────────────────────────────────────────────────────────────────────────────
@@ -487,7 +512,16 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
487
512
  // Unix-socket listener (H8 same-uid base, 0600).
488
513
  if (socketPath) {
489
514
  mkdirSync(dirname(socketPath), { recursive: true, mode: 0o700 })
490
- if (existsSync(socketPath)) unlinkSync(socketPath)
515
+ if (existsSync(socketPath)) {
516
+ // В28 — a LIVE daemon may already own this socket. Unlinking it out from under a live listener
517
+ // silently breaks every unix-socket caller (CLI/notifier/telegram) until a restart. Probe first:
518
+ // if a daemon answers, REFUSE to start (touch nothing). Only a STALE socket file (no listener,
519
+ // ECONNREFUSED) is removed. Guards the documented `iapeer daemon` foreground acceptance mode.
520
+ if (await socketIsLive(socketPath)) {
521
+ throw new Error(`a daemon is already listening on ${socketPath} — refusing to start a second instance`)
522
+ }
523
+ unlinkSync(socketPath)
524
+ }
491
525
  const s = createHttpServer(handle)
492
526
  await new Promise<void>((resolve, reject) => {
493
527
  s.once('error', reject)
@@ -72,6 +72,19 @@ import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index
72
72
  * a STABLE port, not an ephemeral one. Override with IAPEER_PORT. */
73
73
  export const DEFAULT_DAEMON_PORT = 8765
74
74
 
75
+ /** Parse IAPEER_PORT STRICTLY (В29). `Number('abc')` = NaN passes `?? DEFAULT` (NaN is not nullish) →
76
+ * listen(NaN) binds an EPHEMERAL port while the fleet's .mcp.json points at the fixed 8765 → the whole
77
+ * TCP-MCP surface is silently offline yet the daemon reads "healthy" (health probes only the unix
78
+ * socket). Unset/empty → the default; anything not an integer in 1–65535 → a LOUD warn + the default. */
79
+ export function parseDaemonPort(raw: string | undefined, dflt: number = DEFAULT_DAEMON_PORT): number {
80
+ const t = raw?.trim()
81
+ if (!t) return dflt
82
+ const n = Number(t)
83
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) return n
84
+ process.stderr.write(`[iapeer-daemon] WARN invalid IAPEER_PORT=${JSON.stringify(raw)} — falling back to ${dflt}\n`)
85
+ return dflt
86
+ }
87
+
75
88
  /** Default supervise-tick cadence (idle-reap / zombie-sweep). idleSecs (1h default)
76
89
  * is the reap threshold; this is just how often the timer checks. */
77
90
  export const DEFAULT_SUPERVISE_INTERVAL_MS = 60_000
@@ -213,7 +226,8 @@ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): stri
213
226
  export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
214
227
  const noteLiveTopic = makeNoteLiveTopic(cfg, env)
215
228
  const onDelivered = makeArmEphemeralOnDelivered(cfg)
216
- const wake = makeWakeFn(cfg, env)
229
+ // NB: no `wake` here — the composer-queue failure outlet (notifyFailed) deliberately does NOT wake an
230
+ // offline sender (В30); it fires on failAll during shutdown/update.
217
231
  const ephemeral = makeEphemeralRouteDeps(cfg, env)
218
232
 
219
233
  return createComposerDeliveryQueue({
@@ -269,7 +283,12 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
269
283
  // Do not pass composerQueue here: this path is the queue's failure outlet
270
284
  // (including SIGTERM/update). Recursively queueing a failure notification
271
285
  // could turn shutdown re-fail into another queued item.
272
- { wake, ephemeral, noteLiveTopic },
286
+ // В30 — NO wake dep either: this outlet fires on failAll (SIGTERM/update). Waking an offline
287
+ // sender to notify it takes 30-60s (ready-gate), blowing past launchd's ExitTimeOut → SIGKILL
288
+ // drops the remaining notifications (the very silent loss failAll exists to prevent) and stalls
289
+ // the restart. An ephemeral sender still gets a DURABLE-queued notification (ephemeral dep); a
290
+ // non-ephemeral offline sender degrades to a delivery.log record (best-effort) — never a wake.
291
+ { ephemeral, noteLiveTopic },
273
292
  )
274
293
  appendDeliveryEvent(cfg.eventLogDir, {
275
294
  ev: 'composer-queue-failed-notify',
@@ -542,7 +561,7 @@ if (import.meta.main) {
542
561
  } else {
543
562
  const env = process.env
544
563
  const handle = await startConfiguredDaemon({
545
- port: Number(env.IAPEER_PORT ?? DEFAULT_DAEMON_PORT),
564
+ port: parseDaemonPort(env.IAPEER_PORT),
546
565
  socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
547
566
  env,
548
567
  })