@agfpd/iapeer 0.4.122 → 0.4.124

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.122",
3
+ "version": "0.4.124",
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",
@@ -617,7 +617,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
617
617
  // observations in; the snapshot below reads it. Reading is O(1): no file touched per request.
618
618
  const activity = opts.activity ?? new ActivityBoard()
619
619
  const activityOf = (personality: string, runtime: string): TurnActivity | undefined =>
620
- activity.get(personality, runtime, Date.now())
620
+ structuralActivity(personality, runtime, env) ?? activity.get(personality, runtime, Date.now())
621
621
  let opsPromise: Promise<FleetOps> | undefined
622
622
  const getOps = (): Promise<FleetOps> => {
623
623
  if (opts.ops) return Promise.resolve(opts.ops)
@@ -900,6 +900,20 @@ async function runCommand(
900
900
  }
901
901
  }
902
902
 
903
+ /**
904
+ * Activity of a STRUCTURAL runtime straight from its own state (docs/20: state.json `status` is the
905
+ * busy/idle source, not a transcript watch). busy/approval → working, idle → idle; anything else (or
906
+ * no private dir) → undefined, so the transcript turn-watch decides for claude/codex as before.
907
+ */
908
+ export function structuralActivity(personality: string, runtime: string, env: NodeJS.ProcessEnv): TurnActivity | undefined {
909
+ const dir = endpointPrivateDir(runtime, personality, { env })
910
+ if (!dir) return undefined
911
+ const status = readOpencodeState(join(dir, 'state.json'))?.status
912
+ if (status === 'busy' || status === 'approval') return 'working'
913
+ if (status === 'idle') return 'idle'
914
+ return undefined
915
+ }
916
+
903
917
  /** Serve the SSE event stream: optional replay of the newest N historical events,
904
918
  * then live tail. At-least-once semantics (a reconnect may re-see events — `id`
905
919
  * is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
@@ -78,6 +78,8 @@ import { join } from 'node:path'
78
78
  import type { LifecycleConfig } from '../lifecycle/index.ts'
79
79
  import type { NoticeBoard } from './notices.ts'
80
80
  import { readSessionSlices } from './sessionfiles.ts'
81
+ import { endpointPrivateDir } from '../storage/index.ts'
82
+ import { readOpencodeState } from '../opencode/journal.ts'
81
83
 
82
84
  /** How much of a session file's tail is parsed. The evidence is always at the end. */
83
85
  const TAIL_BYTES = 256 * 1024
@@ -89,7 +91,7 @@ const CODEX_WALK_DEPTH = 3
89
91
 
90
92
  export interface MuteDetection {
91
93
  personality: string
92
- runtime: 'claude' | 'codex'
94
+ runtime: 'claude' | 'codex' | 'opencode'
93
95
  /** The runtime's OWN taxonomy value — claude's `error`, codex's rate_limit_reached_type. */
94
96
  errorType: string
95
97
  model?: string
@@ -405,6 +407,41 @@ export interface MuteWatchPaths {
405
407
  export interface ScanDeps extends MuteWatchPaths {
406
408
  /** personality ← cwd. Built from the registry by the composition point. */
407
409
  peerByCwd: (cwd: string) => string | undefined
410
+ /** OpenCode endpoints: personality → its journal (core-owned private dir; docs/20). Optional:
411
+ * absent → no opencode sweep (tests of the claude/codex paths). */
412
+ opencodeJournals?: () => Array<{ personality: string; events: string; model?: string }>
413
+ }
414
+
415
+ /**
416
+ * OpenCode (docs/20): the core plugin journals native `session.error` events unchanged. That IS the
417
+ * structural fact — the error's own `name` (APIError, ProviderAuthError, …) and status are the
418
+ * runtime's taxonomy, the message is verbatim. Read only events newer than the floor (by the journal
419
+ * line's own `time`, so an overlapping sweep re-reads the same occurrence without recounting).
420
+ */
421
+ export function parseOpencodeJournal(tail: string, floorMs: number): Omit<RawDetection, 'cwd'> | null {
422
+ let last: Omit<RawDetection, 'cwd'> | null = null
423
+ for (const line of tail.split('\n')) {
424
+ if (!line.includes('"session.error"')) continue
425
+ let e: { type?: string; time?: number; sessionID?: string | null; properties?: { error?: { name?: string; data?: { message?: string; statusCode?: number } } } }
426
+ try {
427
+ e = JSON.parse(line)
428
+ } catch {
429
+ continue
430
+ }
431
+ if (e.type !== 'session.error' || typeof e.time !== 'number' || e.time < floorMs) continue
432
+ const err = e.properties?.error
433
+ if (!err) continue
434
+ const name = err.name ?? 'UnknownError'
435
+ if (name === 'MessageAbortedError') continue // an interrupt the operator asked for, not a failure
436
+ const status = err.data?.statusCode
437
+ last = {
438
+ errorType: status ? `${name}:${status}` : name,
439
+ content: err.data?.message ?? name,
440
+ ...(e.sessionID ? { sessionId: e.sessionID } : {}),
441
+ atMs: e.time,
442
+ }
443
+ }
444
+ return last
408
445
  }
409
446
 
410
447
  /**
@@ -442,6 +479,19 @@ export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[]
442
479
  const t = readSessionSlices(file, TAIL_BYTES)
443
480
  if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
444
481
  }
482
+ // opencode: the core-owned journal per endpoint (personality is known — no cwd mapping needed).
483
+ for (const j of deps.opencodeJournals?.() ?? []) {
484
+ let mt = 0
485
+ try {
486
+ mt = statSync(j.events).mtimeMs
487
+ } catch {
488
+ continue
489
+ }
490
+ if (mt < floor) continue
491
+ const t = readSessionSlices(j.events, TAIL_BYTES)
492
+ const d = t ? parseOpencodeJournal(t.tail, floor) : null
493
+ if (d) out.push({ ...d, ...(j.model ? { model: j.model } : {}), cwd: '', personality: j.personality, runtime: 'opencode' })
494
+ }
445
495
  return out
446
496
  }
447
497
 
@@ -490,7 +540,7 @@ export interface StartMuteWatchOptions {
490
540
  /** Override the runtime session roots (tests). Default: resolveMuteWatchPaths(env). */
491
541
  paths?: MuteWatchPaths
492
542
  /** Override the registry read (tests). Default: a lazy import of the CLI's listPeers. */
493
- listPeers?: () => Array<{ personality: string; cwd: string }>
543
+ listPeers?: () => Array<{ personality: string; cwd: string; runtimes?: string[] }>
494
544
  now?: () => number
495
545
  onError?: (err: unknown) => void
496
546
  }
@@ -516,7 +566,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
516
566
  try {
517
567
  if (!listPeers) {
518
568
  const cli = await import('../cli/index.ts')
519
- listPeers = () => cli.listPeers({ env }).map(p => ({ personality: p.personality, cwd: p.cwd }))
569
+ listPeers = () => cli.listPeers({ env }).map(p => ({ personality: p.personality, cwd: p.cwd, runtimes: p.runtimes.map(r => r.runtime) }))
520
570
  }
521
571
  const rows = listPeers()
522
572
  // cwd → personality. Compared case-insensitively: macOS paths are case-insensitive and
@@ -530,6 +580,15 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
530
580
  now,
531
581
  onError: opts.onError,
532
582
  peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
583
+ opencodeJournals: () =>
584
+ rows
585
+ .filter(r => (r.runtimes ?? []).includes('opencode'))
586
+ .flatMap(r => {
587
+ const dir = endpointPrivateDir('opencode', r.personality, { env })
588
+ if (!dir) return []
589
+ const model = readOpencodeState(join(dir, 'state.json'))?.model
590
+ return [{ personality: r.personality, events: join(dir, 'events.jsonl'), ...(model ? { model } : {}) }]
591
+ }),
533
592
  }
534
593
  since = muteWatchTick(deps, since).sinceMs
535
594
  } catch (e) {