@agfpd/iapeer 0.4.39 → 0.4.40

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.39",
3
+ "version": "0.4.40",
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",
@@ -2,9 +2,9 @@
2
2
  // Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
3
3
  // port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
4
4
  // via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
5
- import { existsSync, readFileSync, writeSync } from 'node:fs'
5
+ import { writeSync } from 'node:fs'
6
6
  import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
7
- import { pidPath, sockPath } from './paths.ts'
7
+ import { pidStartToken, readPidFile, sockPath } from './paths.ts'
8
8
 
9
9
  const DETACH = 0x1d // Ctrl-]
10
10
 
@@ -16,9 +16,26 @@ const pidAlive = (p: number): boolean => {
16
16
  return false
17
17
  }
18
18
  }
19
+
20
+ // В22 — OWNER-validated liveness. kill(pid,0) is the fast fail (dead pid → not alive). When the pid is
21
+ // alive AND the pidfile carries a start-token, verify the token matches the pid's CURRENT start time so a
22
+ // REUSED pid (a different process that inherited the number) never reads as our live session. `ps` is
23
+ // bounded by a short per-session cache — repeated liveness reads within a tick do not re-spawn it.
24
+ const OWNER_TTL_MS = 3000
25
+ const ownerCache = new Map<string, { pid: number; token: string; verdict: boolean; until: number }>()
26
+
19
27
  export function sessionAlive(runDir: string, session: string): boolean {
20
- const pf = pidPath(runDir, session)
21
- return existsSync(pf) && pidAlive(Number(readFileSync(pf, 'utf8')))
28
+ const rec = readPidFile(runDir, session)
29
+ if (!rec || !pidAlive(rec.pid)) return false
30
+ if (!rec.token) return true // legacy tokenless pidfile → kill-0 only (no regression during rollout)
31
+ const key = `${runDir}\0${session}`
32
+ const now = Date.now()
33
+ const cached = ownerCache.get(key)
34
+ if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
35
+ const live = pidStartToken(rec.pid)
36
+ const verdict = live !== null && live === rec.token // same process instance, not a reused pid
37
+ ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
38
+ return verdict
22
39
  }
23
40
 
24
41
  /** Attach an interactive terminal client to a live daemon session. Resolves when the client
@@ -25,7 +25,7 @@ import {
25
25
  splitDanglingEscape,
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
- import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
28
+ import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
29
  import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
30
30
  import { modelToPlainText } from './render.ts'
31
31
  import { codexAdapter } from '../launch/adapters/codex.ts'
@@ -515,7 +515,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
515
515
  },
516
516
  },
517
517
  })
518
- writeFileSync(pid, String(process.pid))
518
+ writePidFile(runDir, session, process.pid) // В22 — `<pid> <startToken>` so a reused pid can't impersonate this session
519
519
  writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
520
520
 
521
521
  let down = false
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
8
8
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { runSupervisorClient, sessionAlive } from './client.ts'
11
- import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
11
+ import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
12
12
 
13
13
  // daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
14
14
  // daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
@@ -125,17 +125,35 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
125
125
  const deadline = Date.now() + (opts.timeoutMs ?? 5000)
126
126
  while (Date.now() < deadline) {
127
127
  if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
128
- return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
128
+ return { state: 'started', pid: readPidFile(runDir, session)?.pid }
129
129
  }
130
130
  await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
131
131
  }
132
- // Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
132
+ // Failed to come up in time. В20 — KILL the spawned child FIRST. A merely-slow child that becomes the
133
+ // daemon AFTER we unlink the serve-spec would readServeSpec → undefined → come up as a BARE CONDUIT
134
+ // ([bin] runtime with NO PEER_IDENTITY / composed argv / doctrine) that captures the peer's identity:
135
+ // liveness then reads it "online", messages route into an identity-less session, and if it slipped
136
+ // past enroll-on-FAILED it is invisible to idle-reap (an unbounded orphan). Better a dead peer (the
137
+ // next wake respawns clean) than an identity-less conduit. Kill, THEN drop the spec + any stale
138
+ // sock/pid the dying child created (SIGKILL skips the child's own cleanup).
139
+ try {
140
+ child.kill('SIGKILL')
141
+ } catch {
142
+ /* already gone */
143
+ }
133
144
  if (opts.serve)
134
145
  try {
135
146
  unlinkSync(servePath(runDir, session))
136
147
  } catch {
137
148
  /* */
138
149
  }
150
+ for (const p of [sockPath(runDir, session), pidPath(runDir, session)]) {
151
+ try {
152
+ if (existsSync(p)) unlinkSync(p)
153
+ } catch {
154
+ /* best-effort */
155
+ }
156
+ }
139
157
  return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
140
158
  }
141
159
 
@@ -146,16 +164,22 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
146
164
  .map(f => f.replace(/\.sock$/, ''))
147
165
  .map(session => {
148
166
  const alive = sessionAlive(runDir, session)
149
- return { session, alive, pid: alive ? Number(readFileSync(pidPath(runDir, session), 'utf8')) : undefined }
167
+ return { session, alive, pid: alive ? readPidFile(runDir, session)?.pid : undefined }
150
168
  })
151
169
  }
152
170
 
153
171
  export function killSession(runDir: string, session: string): boolean {
154
- const pf = pidPath(runDir, session)
155
- if (!existsSync(pf)) return false
156
- const pid = Number(readFileSync(pf, 'utf8'))
172
+ const rec = readPidFile(runDir, session)
173
+ if (!rec) return false
174
+ // В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
175
+ // kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
176
+ // no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
177
+ if (rec.token) {
178
+ const live = pidStartToken(rec.pid)
179
+ if (live !== null && live !== rec.token) return false // reused pid — NOT our session; do not signal it
180
+ }
157
181
  try {
158
- process.kill(pid, 'SIGTERM')
182
+ process.kill(rec.pid, 'SIGTERM')
159
183
  } catch {
160
184
  /* already gone */
161
185
  }
@@ -3,6 +3,7 @@
3
3
  // socket/pid files never touch a real location.
4
4
  import { renameSync, writeFileSync } from 'node:fs'
5
5
  import { readFileSync } from 'node:fs'
6
+ import { spawnSync } from 'node:child_process'
6
7
  import { join } from 'node:path'
7
8
  import { pluginStateDir } from '../storage/index.ts'
8
9
 
@@ -13,6 +14,49 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
13
14
 
14
15
  export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
15
16
  export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
17
+
18
+ // ── pid-OWNERSHIP token (В22) ───────────────────────────────────────────────────
19
+ // kill(pid,0) proves SOME process holds the pid, not that it is STILL our daemon: after an abnormal
20
+ // death (SIGKILL/panic/reboot) the pidfile survives, and if the OS reuses that pid for another same-uid
21
+ // process, a bare kill(pid,0) reads the dead session as "alive" (peer undeliverable + unreapable) and a
22
+ // kill() would SIGTERM the innocent reuser. The pidfile therefore stores `<pid> <startToken>` where the
23
+ // token is the process's start time; a reused pid has a DIFFERENT start time, so ownership is verifiable.
24
+
25
+ /** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
26
+ * running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
27
+ * liveness cache in client.ts). */
28
+ export function pidStartToken(pid: number): string | null {
29
+ if (!Number.isInteger(pid) || pid <= 0) return null
30
+ try {
31
+ const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' })
32
+ const t = (r.stdout ?? '').trim()
33
+ return t.length > 0 ? t : null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ /** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
40
+ * unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
41
+ export function writePidFile(runDir: string, session: string, pid: number): void {
42
+ const token = pidStartToken(pid)
43
+ writeFileSync(pidPath(runDir, session), token ? `${pid} ${token}` : String(pid))
44
+ }
45
+
46
+ /** Parse the pidfile → { pid, token } (token '' for a legacy bare-pid file). null when absent/invalid. */
47
+ export function readPidFile(runDir: string, session: string): { pid: number; token: string } | null {
48
+ let raw: string
49
+ try {
50
+ raw = readFileSync(pidPath(runDir, session), 'utf8').trim()
51
+ } catch {
52
+ return null
53
+ }
54
+ if (!raw) return null
55
+ const sp = raw.indexOf(' ')
56
+ const pid = Number(sp < 0 ? raw : raw.slice(0, sp))
57
+ if (!Number.isInteger(pid) || pid <= 0) return null
58
+ return { pid, token: sp < 0 ? '' : raw.slice(sp + 1).trim() }
59
+ }
16
60
  export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
17
61
  /** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
18
62
  * Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by