@agfpd/iapeer 0.4.128 → 0.4.130

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.
@@ -47,7 +47,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
47
47
  | `refresh <peer> [runtime] \| --all` | A lazy "soft restart": an agent peer comes up fresh (re-reads the doctrine/fragments from disk) on its NEXT natural wake. Doesn't kill a live session, doesn't wake the rest, doesn't do an immediate restart — only sets a marker. Routers (notifier/telegram) and absent runtimes are skipped (they have no doctrine). `--all` — all peers. |
48
48
  | `remove <peer> [--force]` | Delete the registry entry (doesn't touch the peer's folder). Refuses a live peer without `--force`. Idempotent. |
49
49
  | `attach <peer> [runtime]` | Bring up (if asleep), resume the conversation, open the session under the supervisor in the terminal (detach — `Ctrl-]`). |
50
- | `interrupt <peer> [runtime]` | Interrupt the current turn (Escape), context intact. |
50
+ | `interrupt <peer> [runtime]` | Interrupt the current turn (Escape / abort), context intact. **Without a runtime: EVERY live runtime of the peer, in parallel** (operator stop = the peer stops); with a runtime: that endpoint only. |
51
51
  | `compact <peer> [runtime]` | Compact the conversation; a sleeper is resumed first after idle sleep. |
52
52
  | `add-runtime <runtime> (--peer <p> \| --all)` | Add a runtime to existing peers. Doesn't touch `default_runtime`. An infrastructure runtime attaches an always-on channel and is `--peer`-only (never an `--all` sweep). |
53
53
  | `default-runtime <runtime> (--peer <p> \| --all)` | Switch the primary runtime (routing / first bring-up). Refuses if the runtime isn't declared. |
@@ -203,7 +203,7 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
203
203
  | `start` | clear the stop flag (warm peers become wakeable; no session is launched) / bootstrap always-on | `200` / `502` mirror of `stop` |
204
204
  | `new` | **unconditional** fresh restart (the emergency lever) | `200` + `action:"fresh"` |
205
205
  | `refresh` | lazy soft-reload: fresh on next natural wake, no kill | `200` + `outcomes[]` |
206
- | `interrupt` | interrupt a stuck/raving turn (Escape into the live session) | `200` / `502` |
206
+ | `interrupt` | interrupt the current turn. **No `runtime` in the body → every live runtime of the peer, in parallel** (response `controlledAll`, partial failures in `failed`); with `runtime` → that endpoint only. An operator `/stop` must send NO runtime | `200` / `502` |
207
207
  | `compact` | compact the resumable dialogue (honest failure when none) | `200` + `action:"compacted"` / `502` |
208
208
 
209
209
  `POST /fleet/v1/send` — deliver a message through the same routing path as the `send_to_peer` tool (wake-on-miss, queueing, delivery log — identical semantics):
@@ -336,7 +336,9 @@ nothing to spare). A sweep only opens files whose mtime moved since the last pas
336
336
 
337
337
  Attribution does not derive the path from `cwd`. It reads the cwd each file states **about
338
338
  itself** (claude: the `cwd` field on every line; codex: `session_meta.payload.cwd`) and matches
339
- it against the registry, case-insensitively. A file whose cwd belongs to no peer is ignored —
339
+ it against the registry, case-insensitively — the peer whose cwd is that path or its NEAREST
340
+ ancestor (a peer that `cd`-ed into a subdirectory stays itself, 18.10.2026; ancestors shallower
341
+ than `/<a>/<b>/<c>` match only exactly). A file whose cwd belongs to no peer is ignored —
340
342
  a human's own session never notifies.
341
343
 
342
344
  The reason is that one rule must serve both runtimes: the claude slug is a naming convention we
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.128",
3
+ "version": "0.4.130",
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
@@ -1716,7 +1716,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1716
1716
  desc: 'switch/set the PRIMARY runtime of peer(s) (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command',
1717
1717
  },
1718
1718
  { sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then attach to the pty session (Ctrl-] detaches)' },
1719
- { sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact' },
1719
+ { sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact; no runtime = EVERY live runtime of the peer' },
1720
1720
  { sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
1721
1721
  { sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
1722
1722
  { sig: 'self-done', desc: '(agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one' },
@@ -2998,7 +2998,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2998
2998
  errOut(`${verb}: ${r.error.message}\n`)
2999
2999
  return 1
3000
3000
  }
3001
- out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
3001
+ const rts = (r.value.controlledAll ?? [r.value.controlled]).map(c => c.runtime).join(', ')
3002
+ out(`${verb} → ${r.value.controlled.personality} (${rts})\n`)
3003
+ for (const f of r.value.failed ?? []) errOut(`${verb}: ${r.value.controlled.personality} (${f.runtime}) failed: ${f.error}\n`)
3002
3004
  return 0
3003
3005
  }
3004
3006
  case 'compact': {
@@ -557,6 +557,28 @@ export function muteWatchTick(deps: MuteWatchDeps, sinceMs: number): { raised: n
557
557
  return { raised, sinceMs: startedMs }
558
558
  }
559
559
 
560
+ /**
561
+ * personality ← a session's self-stated cwd: the peer whose registered cwd is that path OR its
562
+ * NEAREST ancestor. A session records the cwd it is IN at each line, and a peer that `cd`s into a
563
+ * subdirectory of its project (observed 18.10.2026: `…/oc-test-nemo/.iapeer`) would otherwise fall
564
+ * out of attribution — no notice, no limit-park. Longest prefix wins, so a peer nested inside
565
+ * another peer's tree is attributed to itself. Keys are normalized (trailing slash off, lowercase).
566
+ */
567
+ export function peerByCwdPrefix(byCwd: Map<string, string>, cwd: string): string | undefined {
568
+ const exact = cwd.replace(/\/+$/, '').toLowerCase()
569
+ let p = exact
570
+ while (p) {
571
+ const hit = byCwd.get(p)
572
+ // An ANCESTOR hit counts only below /<a>/<b>/<c> depth: a peer whose cwd is $HOME (or shallower)
573
+ // must not swallow every session on the host — such a peer attributes by exact match only.
574
+ if (hit && (p === exact || p.split('/').length > 4)) return hit
575
+ const i = p.lastIndexOf('/')
576
+ if (i <= 0) return undefined
577
+ p = p.slice(0, i)
578
+ }
579
+ return undefined
580
+ }
581
+
560
582
  export interface StartMuteWatchOptions {
561
583
  env?: NodeJS.ProcessEnv
562
584
  intervalMs?: number
@@ -604,7 +626,7 @@ export function startMuteWatch(cfg: LifecycleConfig, board: NoticeBoard, opts: S
604
626
  now,
605
627
  onError: opts.onError,
606
628
  onLimit: opts.onLimit,
607
- peerByCwd: (cwd: string) => byCwd.get(cwd.replace(/\/+$/, '').toLowerCase()),
629
+ peerByCwd: (cwd: string) => peerByCwdPrefix(byCwd, cwd),
608
630
  opencodeJournals: () =>
609
631
  rows
610
632
  .filter(r => (r.runtimes ?? []).includes('opencode'))
@@ -1182,6 +1182,10 @@ export async function executeControlOnTarget(
1182
1182
  export interface ControlResult {
1183
1183
  ok: true
1184
1184
  controlled: { personality: string; runtime: string }
1185
+ /** interrupt WITHOUT a runtime: every live endpoint it acted on (>= 1; `controlled` is the first). */
1186
+ controlledAll?: Array<{ personality: string; runtime: string }>
1187
+ /** interrupt WITHOUT a runtime: live endpoints whose interrupt FAILED (others still interrupted). */
1188
+ failed?: Array<{ runtime: string; error: string }>
1185
1189
  command: string
1186
1190
  ts: string
1187
1191
  }
@@ -1217,6 +1221,7 @@ export async function routeControl(personality: string, runtime: string | undefi
1217
1221
  return err(`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
1218
1222
  }
1219
1223
  if (runtime && !isRuntime(runtime)) return err(`invalid runtime "${runtime}"`)
1224
+ if (command.name === 'interrupt' && !runtime) return interruptAllLive(personality, command)
1220
1225
  const target = resolveControlTarget(personality, runtime)
1221
1226
  if (!target.ok) return err(`cannot control "${personality}": ${target.error.message}`)
1222
1227
  const done = await executeControlOnTarget(target.value, command)
@@ -1229,6 +1234,48 @@ export async function routeControl(personality: string, runtime: string | undefi
1229
1234
  })
1230
1235
  }
1231
1236
 
1237
+ /**
1238
+ * Operator STOP = the PEER stops, not one of its sessions (Artur 18.10: «Стоп должен работать на все
1239
+ * рантаймы. Один пир может одновременно в нескольких рантаймах»). A runtime-less interrupt acts on
1240
+ * EVERY live endpoint of the peer, in parallel (the whole stop lands within one control round-trip, not
1241
+ * N of them). An explicit runtime stays exact (routeControl above). /compact and /new are NOT widened:
1242
+ * they act on a dialogue's context, and a peer's dialogues are per-runtime by design.
1243
+ * Zero live → the usual `peer offline: <p>`; all failed → error; partial → ok with `failed` listed.
1244
+ */
1245
+ export async function interruptAllLive(
1246
+ personality: string,
1247
+ command: ControlCommand,
1248
+ seam: { env?: NodeJS.ProcessEnv; live?: () => Array<{ personality: string; runtime: string }>; exec?: typeof executeControlOnTarget } = {},
1249
+ ): Promise<Result<ControlResult>> {
1250
+ const env = seam.env ?? process.env
1251
+ const exec = seam.exec ?? executeControlOnTarget
1252
+ const live = (seam.live?.() ?? listOnlinePeers(resolveSockDir(env), env)).filter(p => p.personality === personality)
1253
+ if (live.length === 0) return err(`cannot control "${personality}": peer offline: ${personality}`)
1254
+ const sockDir = resolveSockDir(env)
1255
+ const results = await Promise.all(
1256
+ live.map(async p => {
1257
+ const target: DeliveryTarget = {
1258
+ runtime: p.runtime,
1259
+ personality: p.personality,
1260
+ address: buildProcessAddress(p.runtime, p.personality),
1261
+ socketPath: buildSocketPath(p.runtime, p.personality, sockDir),
1262
+ }
1263
+ return { runtime: p.runtime, r: await exec(target, command) }
1264
+ }),
1265
+ )
1266
+ const done = results.filter(x => x.r.ok).map(x => ({ personality, runtime: x.runtime }))
1267
+ const failed = results.flatMap(x => (x.r.ok ? [] : [{ runtime: x.runtime, error: x.r.error.message }]))
1268
+ if (done.length === 0) return err(`interrupt failed on every live runtime of "${personality}": ${failed.map(f => `${f.runtime}: ${f.error}`).join('; ')}`)
1269
+ return ok({
1270
+ ok: true,
1271
+ controlled: done[0]!,
1272
+ controlledAll: done,
1273
+ ...(failed.length ? { failed } : {}),
1274
+ command: command.name,
1275
+ ts: new Date().toISOString(),
1276
+ })
1277
+ }
1278
+
1232
1279
  // ─── Route + deliver orchestration (Ф1: no wake; miss → explicit offline) ────
1233
1280
 
1234
1281
  export interface SendToPeerInput {