@agfpd/iapeer 0.2.69 → 0.2.71

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.2.69",
3
+ "version": "0.2.71",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -107,6 +107,46 @@ describe('buildLaunchInvocation — differential vs the verbatim pre-extraction
107
107
  })
108
108
  })
109
109
 
110
+ // Silent-loss guard — the claude-hosting canary caught a warm-deliver false-fail: a launch INITIATED
111
+ // from inside a claude session leaked CLAUDE_CODE_SESSION_ID + CLAUDE_CODE_CHILD_SESSION into the
112
+ // child, which then wrote no own transcript → newestActivityMtime null → landed-confirm blind while
113
+ // the message landed. buildLaunchInvocation must strip the claude-code-internal namespace so a
114
+ // launched claude is always a FRESH top-level session, regardless of initiator env.
115
+ describe('buildLaunchInvocation — strips the claude-code-internal session namespace', () => {
116
+ const DIRTY: LaunchAdapterConfig & { env: NodeJS.ProcessEnv } = {
117
+ claudeBin: '/bin/claude',
118
+ codexBin: '/bin/codex',
119
+ env: {
120
+ PATH: '/test/path',
121
+ BASE_VAR: 'base',
122
+ CLAUDE_CODE_SESSION_ID: 'spawner-session-uuid', // the corruptor (pins the child to the spawner's session)
123
+ CLAUDE_CODE_CHILD_SESSION: '1', // the corruptor (marks a child sub-session → suppresses own transcript)
124
+ CLAUDECODE: '1',
125
+ CLAUDE_CODE_ENTRYPOINT: 'cli',
126
+ CLAUDE_CODE_EXECPATH: '/v/2.1.177',
127
+ CLAUDE_EFFORT: 'xhigh', // NOT in the claude-code namespace → must survive (scoped strip, not a wipe)
128
+ },
129
+ }
130
+ test('claude launch: the whole CLAUDE_CODE_* namespace + CLAUDECODE is removed', () => {
131
+ const { env } = buildLaunchInvocation(spec({ runtime: 'claude', identity: 'claude-p' }), claudeAdapter, DIRTY)
132
+ expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
133
+ expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined()
134
+ expect(env.CLAUDECODE).toBeUndefined()
135
+ expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
136
+ expect(env.CLAUDE_CODE_EXECPATH).toBeUndefined()
137
+ // scoped: non-namespace vars (incl. a CLAUDE_-prefixed-but-not-CLAUDE_CODE_ one) survive
138
+ expect(env.BASE_VAR).toBe('base')
139
+ expect(env.CLAUDE_EFFORT).toBe('xhigh')
140
+ expect(env.PEER_IDENTITY).toBe('claude-p')
141
+ })
142
+ test('codex launch: same strip applies (harmless — codex ignores CLAUDE_*) and the bearer stub stays', () => {
143
+ const { env } = buildLaunchInvocation(spec({ runtime: 'codex', identity: 'codex-p' }), codexAdapter, DIRTY)
144
+ expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined()
145
+ expect(env.CLAUDECODE).toBeUndefined()
146
+ expect(env[CODEX_BEARER_ENV_VAR]).toBe(CODEX_DUMMY_BEARER)
147
+ })
148
+ })
149
+
110
150
  // Independent contract anchors — expectations come from the CONTRACT, not from the function output.
111
151
  describe('buildLaunchInvocation — identity ABI + system-prompt swap (contract values)', () => {
112
152
  test('codex carries the non-secret IAPEER_BEARER stub; claude does NOT (identity in .mcp.json)', () => {
@@ -52,5 +52,20 @@ export function buildLaunchInvocation(
52
52
  PEER_IDENTITY: spec.identity,
53
53
  PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
54
54
  }
55
+ // Strip the claude-code-internal session namespace from the inherited env. When a launch is
56
+ // INITIATED from inside a running claude session (an operator CLI, or a claude peer shelling out),
57
+ // process.env carries that session's CLAUDE_CODE_SESSION_ID + CLAUDE_CODE_CHILD_SESSION. Inherited
58
+ // by a newly-launched claude, they make it believe it is a CHILD sub-session of the initiator → it
59
+ // writes NO independent ~/.claude/projects transcript → newestActivityMtime(cwd) is permanently
60
+ // null → the warm-deliver landed-confirm false-fails WHILE the message actually lands (a silent-
61
+ // loss, the class the contract forbids). Stripping the whole namespace makes a launched claude
62
+ // always start as a FRESH top-level session (it re-establishes its own vars), independent of who
63
+ // initiated the launch — the env-composition analogue of the supervisor's TMUX strip. Caught live
64
+ // by the claude-hosting canary. Harmless for codex (no CLAUDE_* usage). NOTE: a launch driven by
65
+ // the always-on daemon already has a clean env (no claude session) — this hardens the CLI / peer-
66
+ // initiated paths and removes the silent-loss class regardless of initiator.
67
+ for (const k of Object.keys(childEnv)) {
68
+ if (k.startsWith('CLAUDE_CODE_') || k === 'CLAUDECODE') delete childEnv[k]
69
+ }
55
70
  return { argv, env: childEnv }
56
71
  }
@@ -174,4 +174,40 @@ d('supervisor daemon (Bun-native pty, tick runtime)', () => {
174
174
  }
175
175
  }
176
176
  })
177
+
178
+ // FINDING-2 — restore the SERVE geometry on last detach. The daemon resizes the child pty to a
179
+ // client's size on attach and (before this) never restored it, so an unattached session stuck at a
180
+ // departed client's size → the fixed-geometry warm-deliver/ready-gate viewport model mismatched the
181
+ // pty. Drive it with a child that prints its live pty winsize ("ROWS COLS") to the pane-log: attach
182
+ // small → pty shrinks; detach → pty restores to the serve geometry.
183
+ test('FINDING-2: last detach restores the serve geometry (no sticking at the client size)', async () => {
184
+ const gSession = 'ptsgeo'
185
+ const paneLog = join(runDir, 'geo-pane.log')
186
+ const lastSize = (): string => {
187
+ const t = existsSync(paneLog) ? readFileSync(paneLog, 'utf8') : ''
188
+ const m = [...t.matchAll(/(\d+) (\d+)/g)]
189
+ return m.length ? `${m[m.length - 1][1]}x${m[m.length - 1][2]}` : ''
190
+ }
191
+ try {
192
+ const r = startSupervisorDaemon({
193
+ session: gSession,
194
+ runtime: 'tick',
195
+ runDir,
196
+ // serve geometry 200x50 (distinct from defaults); child reports its live pty winsize as "rows cols"
197
+ serve: { argv: ['sh', '-c', 'while :; do stty size 2>/dev/null; sleep 0.3; done'], env: { PATH: process.env.PATH ?? '' }, cwd: runDir, paneLogPath: paneLog, cols: 200, rows: 50 },
198
+ })
199
+ expect(r.state).toBe('started')
200
+ expect(await waitFor(() => lastSize() === '50 200'.replace(' ', 'x'), 6000)).toBe(true) // at serve geometry
201
+ const c = await attach(runDir, gSession, 90, 30) // attach at a DIFFERENT size → pty resizes to it
202
+ expect(await waitFor(() => lastSize() === '30x90', 6000)).toBe(true) // pty shrank to the client size
203
+ c.close() // last client gone
204
+ expect(await waitFor(() => lastSize() === '50x200', 6000)).toBe(true) // RESTORED to serve geometry
205
+ } finally {
206
+ try {
207
+ killSession(runDir, gSession)
208
+ } catch {
209
+ /* */
210
+ }
211
+ }
212
+ })
177
213
  })
@@ -90,6 +90,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
90
90
  const { session, runtime, runDir } = opts
91
91
  let cols = opts.cols ?? 80
92
92
  let rows = opts.rows ?? 30
93
+ // The SERVE geometry — the size the session is hosted at (HOST_COLS×HOST_ROWS for a pty-host peer),
94
+ // immutable. `cols`/`rows` above track the CURRENT (client-driven) size. A client attaches at its own
95
+ // terminal size and the pty resizes to match; on LAST detach we restore the serve geometry (below) so
96
+ // the unattached session never sticks at a departed client's size — otherwise the warm-deliver /
97
+ // ready-gate viewport model (built at the fixed serve geometry) mismatches the actually-resized pty.
98
+ const serveCols = cols
99
+ const serveRows = rows
93
100
  const sock = sockPath(runDir, session)
94
101
  const pid = pidPath(runDir, session)
95
102
 
@@ -217,28 +224,40 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
217
224
  sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
218
225
  }
219
226
 
227
+ // Resize the model + child pty to (c, r). Shared by client FRAME_RESIZE and the last-detach restore.
228
+ const applyGeometry = (c: number, r: number): void => {
229
+ cols = c
230
+ rows = r
231
+ try {
232
+ xterm.resize(c, r)
233
+ child.terminal.resize(c, r)
234
+ } catch {
235
+ /* transient */
236
+ }
237
+ // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child (forces a repaint at the new size); NOT
238
+ // load-bearing for an attached client (the complete snapshot repaint is), but for the unattached
239
+ // restore it is what makes the child redraw into the pane-log at the serve geometry.
240
+ try {
241
+ child.terminal.resize(c, Math.max(1, r - 1))
242
+ child.terminal.resize(c, r)
243
+ } catch {
244
+ /* */
245
+ }
246
+ }
247
+ // Restore the serve geometry when the last client detaches, so the unattached session never sticks at
248
+ // a departed client's size (FINDING-2 — would mismatch the fixed-geometry warm-deliver/ready-gate
249
+ // viewport model). No-op when already at serve geometry or a client is still attached.
250
+ const restoreServeGeometryIfIdle = (): void => {
251
+ if (clients.size === 0 && (cols !== serveCols || rows !== serveRows)) applyGeometry(serveCols, serveRows)
252
+ }
253
+
220
254
  const onClientFrame = (s: { write(b: Buffer): number }, type: number, payload: Buffer): void => {
221
255
  if (type === FRAME_DATA) {
222
256
  child.terminal.write(payload) // keystrokes → child
223
257
  } else if (type === FRAME_RESIZE) {
224
258
  const sz = parseSize(payload)
225
- cols = sz.cols
226
- rows = sz.rows
227
- try {
228
- xterm.resize(cols, rows)
229
- child.terminal.resize(cols, rows)
230
- } catch {
231
- /* transient */
232
- }
259
+ applyGeometry(sz.cols, sz.rows)
233
260
  repaint(s) // instant paint from the snapshot (delivered COMPLETE via backpressure)
234
- // Best-effort SIGWINCH nudge for a SIGWINCH-reactive child; NOT load-bearing (the complete
235
- // snapshot above is the real freshness fix).
236
- try {
237
- child.terminal.resize(cols, Math.max(1, rows - 1))
238
- child.terminal.resize(cols, rows)
239
- } catch {
240
- /* */
241
- }
242
261
  }
243
262
  }
244
263
 
@@ -266,11 +285,13 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
266
285
  clients.delete(s)
267
286
  writers.delete(s)
268
287
  syncAttached()
288
+ restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
269
289
  },
270
290
  error(s: object) {
271
291
  clients.delete(s)
272
292
  writers.delete(s)
273
293
  syncAttached()
294
+ restoreServeGeometryIfIdle()
274
295
  },
275
296
  },
276
297
  })