@agfpd/iapeer 0.4.118 → 0.4.120

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.
@@ -139,6 +139,19 @@ there. Owner policy (2026-10-18): this is allowed, but never silent.
139
139
  - The model actually used is reported by the plugin (`state.json` `model`) and
140
140
  appears as the fleet snapshot field `runtimes[].model` (docs/15).
141
141
 
142
+ ## Host-default provider
143
+
144
+ `<root>/runtimes/opencode/opencode.json` (operator-owned) is the host default:
145
+ every opencode peer starts from it, and the peer's own
146
+ `<private_dir>/opencode.json` overrides it key by key (deep merge; the inline
147
+ `OPENCODE_CONFIG_CONTENT` and fragments follow; provider pinning applies to the
148
+ result). Credentials stay references (`{env:NVIDIA_API_KEY}`); their values live
149
+ in the HOST launch.env `<root>/runtimes/opencode/launch.env`. Its env vars apply
150
+ to every peer's session of that runtime, under the peer's own launch.env (the
151
+ peer wins); launch directives (`PEER_START_ARGS`, `PEER_DISALLOWED_TOOLS`) stay
152
+ per-peer. With a host default in place, `add-runtime opencode --all` yields
153
+ working peers with no per-peer step.
154
+
142
155
  ## Durable inbound across a session death (OC07, all agent runtimes)
143
156
 
144
157
  Invariant: an inbound the core acknowledged is either processed by a later
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.118",
3
+ "version": "0.4.120",
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
@@ -48,6 +48,7 @@ import {
48
48
  killSession,
49
49
  loadLifecycleConfig,
50
50
  purgeIdentityState,
51
+ setLastDelivered,
51
52
  removeSessionState,
52
53
  resolvePeerRuntime,
53
54
  setEphemeralArmed,
@@ -1378,7 +1379,7 @@ export async function sendMessage(
1378
1379
  // unawaited in-process wake would die with it; the daemon's supervise-tick
1379
1380
  // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
1380
1381
  // kicks, not a new mechanism.
1381
- const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps, makeNoteLiveTopic } = await import('../daemon/main.ts')
1382
+ const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps, makeNoteInflight, makeNoteLiveTopic } = await import('../daemon/main.ts')
1382
1383
  const cfg = loadLifecycleConfig(env)
1383
1384
  const t0 = Date.now()
1384
1385
  const result = await routeSend(
@@ -1397,6 +1398,10 @@ export async function sendMessage(
1397
1398
  wake: cliWake,
1398
1399
  ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}),
1399
1400
  noteLiveTopic: makeNoteLiveTopic(cfg, env),
1401
+ // CLI-path parity: the idle-proxy floor (В7) and the inflight ledger (OC07) — the daemon tick
1402
+ // redelivers a CLI-sent message too if its receiving session dies before answering.
1403
+ noteDelivered: identity => setLastDelivered(cfg, identity),
1404
+ noteInflight: makeNoteInflight(cfg),
1400
1405
  originGuardBypass: opts.originGuardBypass,
1401
1406
  },
1402
1407
  )
@@ -677,6 +677,16 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
677
677
  // preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
678
678
  intelligence = resolvedIntelligence(options.runtime)
679
679
  }
680
+ // OC01 — an EXPLICIT runtime the peer's nature does not allow is refused LOUDLY, exactly as on the
681
+ // new-profile branch (create). Before this, add-runtime <agentic> on a natural peer passed: the
682
+ // gate exempts the explicit runtime by design (an operator command is not discovery), so a human
683
+ // peer got an agent endpoint while its other agentic runtimes were dropped (linus 18.10).
684
+ if (!isIntelligenceAllowedForRuntime(options.runtime, intelligence)) {
685
+ throw new IapError(
686
+ `runtime "${options.runtime}" requires intelligence ∈ {${allowedIntelligencesForRuntime(options.runtime).join(', ')}}, ` +
687
+ `peer "${existing.personality}" is ${intelligence} — refusing to attach it`,
688
+ )
689
+ }
680
690
  // Merge AFTER the intelligence is final, and GATE the WHOLE merged set (not just the
681
691
  // fresh discoveries): for a natural/absent peer this both blocks a new agentic pickup
682
692
  // AND heals a previously-leaked one (the arthur claude-leak class) on the next
@@ -688,6 +698,13 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
688
698
  options.warn,
689
699
  )
690
700
  ensureLocalRuntimeScopes(cwd, mergedRuntimes)
701
+ const defaultRt = existing.runtime // the normalized default_runtime
702
+ if (defaultRt && !mergedRuntimes.includes(defaultRt)) {
703
+ options.warn?.(
704
+ `default_runtime "${defaultRt}" of "${existing.personality}" is not among its runtimes [${mergedRuntimes.join(', ')}] — ` +
705
+ `sends without a runtime will fail; set it with \`iapeer default-runtime <rt> --peer ${existing.personality}\``,
706
+ )
707
+ }
691
708
  // В36 — an explicit description on RE-provision updates the local profile too (H1 in
692
709
  // writePeerProfileAtomic still guards: absent/empty never wipes an existing value).
693
710
  const explicitDescription = options.description?.trim()
package/src/init/index.ts CHANGED
@@ -558,18 +558,22 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
558
558
  // exact file to fill, instead of letting the first wake be the first signal.
559
559
  if (provisioned.runtime === 'opencode') {
560
560
  const { opencodePaths } = await import('../opencode/config.ts')
561
+ const { opencodeHostConfigPath } = await import('../storage/index.ts')
561
562
  const cfgPath = opencodePaths(provisioned.personality, { env }).config
562
- let model: unknown
563
- try {
564
- model = (JSON.parse(readFileSync(cfgPath, 'utf8')) as { model?: unknown }).model
565
- } catch {
566
- model = undefined
563
+ const modelOf = (path: string): unknown => {
564
+ try {
565
+ return (JSON.parse(readFileSync(path, 'utf8')) as { model?: unknown }).model
566
+ } catch {
567
+ return undefined
568
+ }
567
569
  }
570
+ // the peer's own config wins; else the host default (docs/20 §Host-default provider)
571
+ const model = modelOf(cfgPath) ?? modelOf(opencodeHostConfigPath({ env }))
568
572
  if (typeof model !== 'string' || !model.includes('/')) {
569
573
  opts.warn?.(
570
574
  `opencode provider not configured for "${provisioned.personality}": it will run on OpenCode's own default ` +
571
- `provider (doctrine and messages go there) until ${cfgPath} names "model": "<provider>/<model>" ` +
572
- `and that provider (docs/20 §Provider pinning)`,
575
+ `provider (doctrine and messages go there) until ${cfgPath} (per peer) or the host default ` +
576
+ `<root>/runtimes/opencode/opencode.json names "model": "<provider>/<model>" and that provider (docs/20)`,
573
577
  )
574
578
  }
575
579
  }
@@ -7,7 +7,7 @@
7
7
  // array straight to Bun.spawn — same argv, same env, different transport.
8
8
  import { homedir } from 'os'
9
9
  import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER } from '../core/constants.ts'
10
- import { readLaunchEnv } from '../storage/index.ts'
10
+ import { readHostLaunchEnv, readLaunchEnv } from '../storage/index.ts'
11
11
  import type { LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from './types.ts'
12
12
 
13
13
  export interface LaunchInvocation {
@@ -46,6 +46,8 @@ export function buildLaunchInvocation(
46
46
  const argv = adapter.buildArgv(specWithArgs, cfg)
47
47
  const childEnv: NodeJS.ProcessEnv = {
48
48
  ...env,
49
+ // host-level launch.env of this runtime (e.g. a host-default provider credential), UNDER the peer's
50
+ ...readHostLaunchEnv(spec.runtime, { env }),
49
51
  ...launchEnv.env,
50
52
  // codex token-free MCP import: the bearer env var is set to the PUBLIC, non-secret stub so codex
51
53
  // flips authStatus unsupported→bearer_token and imports send_to_peer (the OPEN daemon ignores it,
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
6
  import { isValidName } from '../core/constants.ts'
7
- import { opencodePrivateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
7
+ import { opencodeHostConfigPath, opencodePrivateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
8
8
  import pluginSource from './iapeer-plugin.mjs' with { type: 'text' }
9
9
  import { listCapabilities } from '../capability/index.ts'
10
10
 
@@ -118,7 +118,10 @@ export const OPENCODE_GATED_PERMISSION = { bash: 'ask', edit: 'ask', webfetch: '
118
118
  export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options: StorageOptions = {}): JsonObject {
119
119
  if (!input.doctrine.trim()) throw new Error('OpenCode requires a non-empty composed system prompt')
120
120
  const paths = opencodePaths(input.personality, options)
121
- let result: JsonObject = existsSync(paths.config) ? object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config) : {}
121
+ // Host default first (docs/20 §Host-default provider), then the peer's own config overrides it.
122
+ const hostPath = opencodeHostConfigPath(options)
123
+ let result: JsonObject = existsSync(hostPath) ? object(JSON.parse(readFileSync(hostPath, 'utf8')), hostPath) : {}
124
+ if (existsSync(paths.config)) result = mergeOpencodeConfig(result, object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config))
122
125
  if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
123
126
  // Fragment owners: the memory provider + every REGISTERED capability (docs/21) — never a
124
127
  // hardcoded plugin list. An unregistered plugin's leftover fragment is not merged.
@@ -283,6 +283,32 @@ export function readLaunchEnv(cwd: string, runtime: Runtime): LaunchEnv {
283
283
  } catch {
284
284
  return { startArgs: [], env: {} }
285
285
  }
286
+ return parseLaunchEnv(text)
287
+ }
288
+
289
+ /** HOST-level launch.env of a runtime: `<root>/runtimes/<rt>/launch.env` (same format). Its env vars
290
+ * apply to EVERY peer's session of that runtime, under the peer's own launch.env (the peer wins) — the
291
+ * place for a host-default provider credential (docs/20 §Host-default provider). Directives
292
+ * (PEER_START_ARGS / PEER_DISALLOWED_TOOLS) stay per-peer only. */
293
+ export function hostLaunchEnvPath(runtime: Runtime, options: StorageOptions = {}): string {
294
+ return join(options.rootDir ?? resolveGlobalRoot(options.env), RUNTIMES_DIR, runtime, LAUNCH_ENV_FILE)
295
+ }
296
+
297
+ export function readHostLaunchEnv(runtime: Runtime, options: StorageOptions = {}): Record<string, string> {
298
+ try {
299
+ return parseLaunchEnv(readFileSync(hostLaunchEnvPath(runtime, options), 'utf8')).env
300
+ } catch {
301
+ return {}
302
+ }
303
+ }
304
+
305
+ /** The host-default OpenCode configuration (operator-owned): `<root>/runtimes/opencode/opencode.json`.
306
+ * Every opencode peer starts from it; the peer's own `<private_dir>/opencode.json` overrides it. */
307
+ export function opencodeHostConfigPath(options: StorageOptions = {}): string {
308
+ return join(options.rootDir ?? resolveGlobalRoot(options.env), RUNTIMES_DIR, 'opencode', 'opencode.json')
309
+ }
310
+
311
+ function parseLaunchEnv(text: string): LaunchEnv {
286
312
  const env: Record<string, string> = {}
287
313
  for (const rawLine of text.split(/\r?\n/)) {
288
314
  const line = rawLine.trim()
@@ -1080,12 +1080,31 @@ export interface ControlResult {
1080
1080
  * "specify runtime"). The clean-slash control namespace (interrupt/compact/…) is the
1081
1081
  * caller's; /alias_* expansions are message, not control (docs/Control §namespace).
1082
1082
  */
1083
+ /**
1084
+ * Which session an operator control (interrupt / compact / …) WITHOUT a runtime acts on. An operator
1085
+ * commands the DIALOGUE they see, not a session they must name: the dialogue a runtime-less send
1086
+ * reaches is the peer's default_runtime (config-anchored, resolvePeerDeliveryTarget), so a LIVE
1087
+ * default wins even when other runtimes are live too (incident 18.10: /stop on a peer with claude AND
1088
+ * opencode live answered «specify runtime» while the opencode turn ran on). A dead default with
1089
+ * exactly one live endpoint → that one (the only running session). Only a dead default with SEVERAL
1090
+ * live endpoints stays ambiguous → explicit refusal. An explicit runtime is exact.
1091
+ */
1092
+ export function resolveControlTarget(personality: string, runtime: string | undefined, env: NodeJS.ProcessEnv = process.env): Result<DeliveryTarget> {
1093
+ if (runtime) return resolveDeliveryTarget({ personality, runtime, env })
1094
+ const peer = findPeer(readPeersIndex({ env }), personality)
1095
+ if (peer) {
1096
+ const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env })
1097
+ if (anchored.ok) return anchored
1098
+ }
1099
+ return resolveDeliveryTarget({ personality, env })
1100
+ }
1101
+
1083
1102
  export async function routeControl(personality: string, runtime: string | undefined, command: ControlCommand): Promise<Result<ControlResult>> {
1084
1103
  if (!isValidName(personality)) {
1085
1104
  return err(`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
1086
1105
  }
1087
1106
  if (runtime && !isRuntime(runtime)) return err(`invalid runtime "${runtime}"`)
1088
- const target = resolveDeliveryTarget({ personality, runtime })
1107
+ const target = resolveControlTarget(personality, runtime)
1089
1108
  if (!target.ok) return err(`cannot control "${personality}": ${target.error.message}`)
1090
1109
  const done = await executeControlOnTarget(target.value, command)
1091
1110
  if (!done.ok) return done