@agfpd/iapeer 0.4.46 → 0.4.48

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.46",
3
+ "version": "0.4.48",
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/init/index.ts CHANGED
@@ -130,7 +130,7 @@ export function writeClaudeMcpConfig(cwd: string, personality: string, daemonUrl
130
130
  * peer has no one to approve it, so the wake stalls. This flag pre-approves the cwd's MCP
131
131
  * servers so the dialog never appears (verified live, claude 2.1.183). Set unconditionally.
132
132
  * - `"tui": "default"` — pins the CLASSIC renderer so claude never pops its one-time
133
- * "Try the new fullscreen renderer?" upsell modal (a BLOCKING mid-session nag that froze
133
+ * "Try the new fullscreen-renderer?" upsell modal (a BLOCKING mid-session nag that froze
134
134
  * live fleet peers; empirically an explicit tui choice suppresses it — verified live). This
135
135
  * is the PREVENTION belt to the supervisor nag-watcher: init keeps the modal from appearing
136
136
  * for a new peer at all, the watcher dismisses the class on every peer mid-session. Set ONLY
@@ -216,7 +216,34 @@ export function codexConfigPath(options: StorageOptions = {}): string {
216
216
  * serves every codex peer with its own identity (env_http_headers, verified live).
217
217
  * Returns {path, added} (added=false when the block already existed).
218
218
  */
219
- export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions = {}): { path: string; added: boolean } {
219
+ /**
220
+ * Rewrite the `url = …` line INSIDE the `[mcp_servers.iapeer]` section to `url`, leaving
221
+ * the rest of the shared config byte-for-byte. The section runs from its header to the next
222
+ * `[…]` header (the `.env_http_headers` sub-table ends it — the url sits above that anyway).
223
+ * Returns the input unchanged when the url already matches (idempotent) or no url line exists.
224
+ */
225
+ function healCodexMcpUrl(text: string, url: string): string {
226
+ const want = `url = ${JSON.stringify(url)}`
227
+ const lines = text.split('\n')
228
+ let inSection = false
229
+ for (let i = 0; i < lines.length; i++) {
230
+ const header = lines[i]!.match(/^\s*\[([^\]]+)\]\s*$/)
231
+ if (header) {
232
+ inSection = header[1] === `mcp_servers.${IAPEER_MCP_SERVER_NAME}`
233
+ continue
234
+ }
235
+ if (inSection && /^\s*url\s*=/.test(lines[i]!)) {
236
+ if (lines[i]!.trim() !== want) lines[i] = want
237
+ break // exactly one url line per server section
238
+ }
239
+ }
240
+ return lines.join('\n')
241
+ }
242
+
243
+ export function writeCodexMcpConfig(
244
+ daemonUrl: string,
245
+ options: StorageOptions = {},
246
+ ): { path: string; added: boolean; healed?: boolean } {
220
247
  const path = codexConfigPath(options)
221
248
  let existing = ''
222
249
  try {
@@ -224,7 +251,17 @@ export function writeCodexMcpConfig(daemonUrl: string, options: StorageOptions =
224
251
  } catch {
225
252
  /* no config yet → create it */
226
253
  }
227
- if (/\[mcp_servers\.iapeer\]/.test(existing)) return { path, added: false } // idempotent
254
+ if (/\[mcp_servers\.iapeer\]/.test(existing)) {
255
+ // В44 — the block already exists, but the OLD code returned added:false WITHOUT checking
256
+ // the url. init bakes a fallback :8765 endpoint BEFORE the daemon publishes its real port;
257
+ // a later IAPEER_PORT change re-inits the claude side but left this codex block pinned to a
258
+ // now-DEAD port forever, while initPeer reported success. Heal the url in place (idempotent
259
+ // by content — only our section's `url=` line is touched, everything else is preserved).
260
+ const healed = healCodexMcpUrl(existing, daemonUrl)
261
+ if (healed === existing) return { path, added: false } // already correct → no write
262
+ writeFileAtomic(path, healed, 0o644)
263
+ return { path, added: false, healed: true }
264
+ }
228
265
  const block =
229
266
  `\n[mcp_servers.${IAPEER_MCP_SERVER_NAME}]\n` +
230
267
  `url = ${JSON.stringify(daemonUrl)}\n` +
@@ -49,6 +49,7 @@ import { createHash } from 'crypto'
49
49
  import { dirname } from 'path'
50
50
  import { writeFileAtomic } from '../storage/index.ts'
51
51
  import { codexGlobalConfigPath } from './nativeMemory.ts'
52
+ import { assertTomlSafeKey } from './tomlKey.ts'
52
53
 
53
54
  /** hooks.json event names → the snake_case labels codex keys state with
54
55
  * (upstream `hook_event_key_label`). */
@@ -226,14 +227,6 @@ function resolveHooksSource(hooksJsonPath: string): string {
226
227
  return realpathSync(hooksJsonPath)
227
228
  }
228
229
 
229
- /** TOML basic strings treat `\` and `"` specially — paths carrying them would
230
- * need escaping codex-side too; refuse instead of guessing. */
231
- function assertTomlSafeKey(key: string): void {
232
- if (key.includes('"') || key.includes('\\')) {
233
- throw new Error(`hooks.json path is not TOML-key-safe (contains " or \\): ${key}`)
234
- }
235
- }
236
-
237
230
  /**
238
231
  * Pre-seed the codex hooks trust state for every command hook in the given
239
232
  * hooks.json — the deterministic, headless replacement for the "Hooks need
@@ -67,5 +67,17 @@ export function buildLaunchInvocation(
67
67
  for (const k of Object.keys(childEnv)) {
68
68
  if (k.startsWith('CLAUDE_CODE_') || k === 'CLAUDECODE') delete childEnv[k]
69
69
  }
70
+ // В59 — pin claude to the CLASSIC (main-screen) renderer. Two load-bearing effects: (1) the whole
71
+ // supervisor model — pane-log serialize-snapshot, composer-occupancy, the ready-gate flip — assumes
72
+ // claude's DEFAULT no-alt-screen relative-cursor rendering (the daemon census). Pinning it makes that
73
+ // assumption UNCONDITIONAL: no future claude feature, and no mis-accepted mid-session upsell, can
74
+ // silently switch to the alt-screen renderer and corrupt the surface the supervisor reads off. This
75
+ // NEUTRALIZES the consequence of the В40 fullscreen-renderer nag even if a stray keystroke accepts it.
76
+ // (2) It disables the very feature that upsell offers, so the modal has nothing to enable — expected to
77
+ // suppress it at the SOURCE (the root-cause complement to the nag-watcher backstop). Set AFTER the
78
+ // CLAUDE_CODE_* strip above (which would otherwise delete it); claude-only — codex/router runtimes
79
+ // ignore CLAUDE_CODE_*. Safe by construction: claude already renders classic by default (peers boot and
80
+ // attach today), so this pins the current behavior, it does not change it.
81
+ if (spec.runtime === 'claude') childEnv.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = '1'
70
82
  return { argv, env: childEnv }
71
83
  }
@@ -21,10 +21,11 @@
21
21
  // (slot-gated, see provision/index.ts).
22
22
 
23
23
  import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
24
- import { dirname, join } from 'path'
24
+ import { basename, dirname, join, resolve } from 'path'
25
25
  import { homedir } from 'os'
26
26
  import type { Runtime } from '../core/constants.ts'
27
27
  import { writeFileAtomic } from '../storage/index.ts'
28
+ import { assertTomlSafeKey } from './tomlKey.ts'
28
29
 
29
30
  export type NativeMemoryState = 'off' | 'on'
30
31
 
@@ -161,6 +162,11 @@ export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.e
161
162
  } catch {
162
163
  /* cwd not on disk yet → keep as given */
163
164
  }
165
+ // В42 — the cwd becomes a QUOTED TOML key `[projects."<real>"]`. An operator-supplied
166
+ // `create --path` cwd carrying `"`/`\` (legal on APFS) would corrupt the SHARED global
167
+ // config and break codex for every peer on the host. Refuse (the try/catch reports it
168
+ // as a 'failed' lever); same guard the sibling hooks-trust writer uses.
169
+ assertTomlSafeKey(real)
164
170
  const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
165
171
  if (text.includes(`[projects."${real}"]`)) return { runtime: 'codex', path, state: 'already' }
166
172
  const block = `${text.length && !text.endsWith('\n') ? '\n' : ''}${text.trim().length ? '\n' : ''}[projects."${real}"]\ntrust_level = "trusted"\n`
@@ -172,6 +178,30 @@ export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.e
172
178
  }
173
179
  }
174
180
 
181
+ /**
182
+ * Resolve `p` to its real path even when the leaf (or several trailing components) no
183
+ * longer exists: realpath the nearest EXISTING ancestor and re-append the missing tail.
184
+ * Returns null only if nothing on the chain up to the root exists (impossible for `/`).
185
+ * Used by removeCodexCwdTrust to reconstruct the realpath-form key of an already-deleted
186
+ * cwd so a stale trust entry written under a symlink-resolved path is still matched.
187
+ */
188
+ function realpathViaExistingAncestor(p: string): string | null {
189
+ let cur = resolve(p)
190
+ const tail: string[] = []
191
+ // Guard against an unbounded loop: dirname('/') === '/'.
192
+ for (let depth = 0; depth < 4096; depth++) {
193
+ try {
194
+ return tail.length ? join(realpathSync(cur), ...tail.slice().reverse()) : realpathSync(cur)
195
+ } catch {
196
+ const parent = dirname(cur)
197
+ if (parent === cur) return null // reached the root and even it did not resolve
198
+ tail.push(basename(cur))
199
+ cur = parent
200
+ }
201
+ }
202
+ return null
203
+ }
204
+
175
205
  /**
176
206
  * Remove a peer's pre-trust entry from the codex GLOBAL config — the reap-side
177
207
  * counterpart of preTrustCodexCwd (backlog «remove не чистит pre-trust», made
@@ -192,7 +222,14 @@ export function removeCodexCwdTrust(cwd: string, env: NodeJS.ProcessEnv = proces
192
222
  } catch {
193
223
  /* cwd already deleted → only the literal form can match */
194
224
  }
195
- const candidates = new Set([real, cwd])
225
+ // В43 — the writer keyed the entry on the RESOLVED real path. When the cwd is already
226
+ // DELETED (the throwaway-sandbox reap case), realpathSync(cwd) above throws and `real`
227
+ // falls back to the LITERAL cwd — which MISSES if any path component was a symlink
228
+ // (e.g. codex stored /private/tmp/… for a /tmp/… cwd). Reconstruct the resolved form
229
+ // from the nearest still-existing ANCESTOR + the deleted remainder, so the stale entry
230
+ // is found and removed instead of lingering as a phantom trust for that path.
231
+ const viaAncestor = realpathViaExistingAncestor(cwd)
232
+ const candidates = new Set([real, cwd, ...(viaAncestor ? [viaAncestor] : [])])
196
233
  const lines = readFileSync(path, 'utf8').split('\n')
197
234
  const kept: string[] = []
198
235
  let inDoomed = false
@@ -0,0 +1,17 @@
1
+ // Shared TOML-key safety for the codex GLOBAL config.toml writers. codex's host-wide
2
+ // config addresses per-project state by a QUOTED-string key — `[projects."<path>"]`,
3
+ // `[hooks_trust."<source>"]`, `[mcp_servers.<name>]`. A TOML basic string treats `"`
4
+ // and `\` specially, so a key carrying either would need codex-side escaping we cannot
5
+ // guarantee round-trips; writing it raw corrupts the whole SHARED config and breaks
6
+ // codex for EVERY peer on the host. Refuse instead of guessing.
7
+ //
8
+ // Extracted from codexHooksTrust.ts (which introduced it for hooks sources) so the
9
+ // native-memory pre-trust writer reuses the exact same guard — one definition, one
10
+ // behavior, no drift.
11
+
12
+ /** Throw if `key` cannot be a raw TOML basic-string key (contains `"` or `\`). */
13
+ export function assertTomlSafeKey(key: string): void {
14
+ if (key.includes('"') || key.includes('\\')) {
15
+ throw new Error(`path is not TOML-key-safe (contains " or \\): ${key}`)
16
+ }
17
+ }
@@ -288,7 +288,7 @@ export interface RuntimeAdapter {
288
288
  /**
289
289
  * MID-SESSION nag/upsell auto-dismiss (livability). Distinct from bootDialogKeys:
290
290
  * the boot-driver stops at ready, but claude/codex can pop a ONE-TIME interactive
291
- * upsell modal AFTER the session is live (e.g. "Try the new fullscreen renderer?")
291
+ * upsell modal AFTER the session is live (e.g. "Try the new fullscreen-renderer?")
292
292
  * that BLOCKS the pty waiting for a keypress no headless peer answers — it froze the
293
293
  * live fleet until a human cleared it. A persistent supervisor watcher (daemon.ts)
294
294
  * runs THIS off the authoritative model for the whole session and writes the keys.
@@ -44,7 +44,7 @@ export type NagAction = { kind: 'dismiss'; keys: string[]; bytes: Buffer } | { k
44
44
  /**
45
45
  * MID-SESSION nag/upsell step (livability) — the persistent sibling of nextBootAction. Unlike the
46
46
  * boot-driver, which STOPS at ready, the daemon loops this for the WHOLE session: a one-time CC upsell
47
- * modal (e.g. "Try the new fullscreen renderer?") can pop AFTER the session is live and BLOCK the pty on
47
+ * modal (e.g. "Try the new fullscreen-renderer?") can pop AFTER the session is live and BLOCK the pty on
48
48
  * a keypress no headless peer answers. If the adapter recognizes the (FULL-signature) modal it returns
49
49
  * the verified-safe DECLINE keys; the caller writes `action.bytes` to the pty (cooldown-guarded so a
50
50
  * cleared modal is never double-answered). An adapter with no nagDismissKeys → always 'none'.
@@ -599,7 +599,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
599
599
 
600
600
  // ── mid-session nag-watcher (livability) ────────────────────────────────────────
601
601
  // The boot-driver above STOPS at ready. But claude/codex can pop a ONE-TIME upsell modal
602
- // MID-SESSION (e.g. "Try the new fullscreen renderer?", which appears on the return-to-prompt
602
+ // MID-SESSION (e.g. "Try the new fullscreen-renderer?", which appears on the return-to-prompt
603
603
  // boundary after a tool result) that BLOCKS the pty on a keypress no headless peer answers — it
604
604
  // froze live fleet peers (boris, doc) until a human cleared it by hand. A PERSISTENT watcher answers
605
605
  // the curated, verified-safe DECLINE for that class off the SAME authoritative @xterm model, for the