@agfpd/iapeer 0.4.129 → 0.4.131
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/docs/12-cli-reference.md +1 -1
- package/docs/15-fleet-api.md +8 -3
- package/package.json +1 -1
- package/src/cli/index.ts +4 -2
- package/src/daemon/turnwatch.ts +19 -5
- package/src/transport/index.ts +47 -0
package/docs/12-cli-reference.md
CHANGED
|
@@ -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. |
|
package/docs/15-fleet-api.md
CHANGED
|
@@ -126,7 +126,8 @@ mtime, never a reconstruction:
|
|
|
126
126
|
|
|
127
127
|
- **codex** — the rollout's `event_msg` payloads `task_started` → `working`, `task_complete` →
|
|
128
128
|
`idle`; last one wins. Codex emits `task_complete` for a turn that ends on an error too, so
|
|
129
|
-
the terminal-error path needs no separate marker.
|
|
129
|
+
the terminal-error path needs no separate marker. An INTERRUPTED turn ends on `turn_aborted`
|
|
130
|
+
(no `task_complete` follows) → `idle`.
|
|
130
131
|
- **claude** — the transcript's assistant `stop_reason`. **Only `tool_use` means the turn
|
|
131
132
|
continues**; every other terminal reason (`end_turn`, `stop_sequence`, `max_tokens`, a future
|
|
132
133
|
value, `null`) means it ended. The polarity is deliberately asymmetric: an unrecognised reason
|
|
@@ -134,7 +135,11 @@ mtime, never a reconstruction:
|
|
|
134
135
|
tool_result to consume), but compaction summaries (`isCompactSummary`), sub-agent sidechains
|
|
135
136
|
(`isSidechain`) and injected meta lines (`isMeta`) are excluded — each is a measured
|
|
136
137
|
false-`working` vector, the worst being a `/compact` run while idle leaving a phantom
|
|
137
|
-
`working` forever.
|
|
138
|
+
`working` forever. An operator interrupt ends the turn with a user line whose text is
|
|
139
|
+
`[Request interrupted by user]` / `[Request interrupted by user for tool use]` → `idle` (it
|
|
140
|
+
used to read `working` forever on a stopped session, 18.10.2026).
|
|
141
|
+
- **Attribution** — a session is the peer whose registered cwd is the session's cwd or its
|
|
142
|
+
nearest ancestor (a peer that `cd`-ed into a subdirectory stays itself).
|
|
138
143
|
|
|
139
144
|
### Bounds a client can rely on
|
|
140
145
|
|
|
@@ -203,7 +208,7 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
|
|
|
203
208
|
| `start` | clear the stop flag (warm peers become wakeable; no session is launched) / bootstrap always-on | `200` / `502` mirror of `stop` |
|
|
204
209
|
| `new` | **unconditional** fresh restart (the emergency lever) | `200` + `action:"fresh"` |
|
|
205
210
|
| `refresh` | lazy soft-reload: fresh on next natural wake, no kill | `200` + `outcomes[]` |
|
|
206
|
-
| `interrupt` | interrupt
|
|
211
|
+
| `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
212
|
| `compact` | compact the resumable dialogue (honest failure when none) | `200` + `action:"compacted"` / `502` |
|
|
208
213
|
|
|
209
214
|
`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):
|
package/package.json
CHANGED
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
|
-
|
|
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': {
|
package/src/daemon/turnwatch.ts
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
// IDLE codex pane-log freezes (39s→46s). The signal means opposite things per runtime; one
|
|
57
57
|
// clock that lies differently on each runtime is worse than no clock.
|
|
58
58
|
|
|
59
|
+
import { peerByCwdPrefix } from './mutewatch.ts'
|
|
59
60
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
60
61
|
import { join } from 'node:path'
|
|
61
62
|
import type { LifecycleConfig } from '../lifecycle/index.ts'
|
|
@@ -101,6 +102,13 @@ function toMs(ts: unknown): number | null {
|
|
|
101
102
|
return Number.isFinite(ms) ? ms : null
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Claude's own record of an interrupted turn: a user line whose text is the interrupt marker. */
|
|
106
|
+
export function isClaudeInterruptMarker(d: Record<string, unknown>): boolean {
|
|
107
|
+
const c = (d.message as { content?: unknown } | undefined)?.content
|
|
108
|
+
const texts = typeof c === 'string' ? [c] : Array.isArray(c) ? c.filter(b => b?.type === 'text').map(b => String(b.text ?? '')) : []
|
|
109
|
+
return texts.some(t => /^\[Request interrupted by user/.test(t.trim()))
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
/**
|
|
105
113
|
* The claude transcript's turn state, or null if the file states nothing usable.
|
|
106
114
|
*
|
|
@@ -136,8 +144,12 @@ export function parseClaudeTurn(text: string): TurnState | null {
|
|
|
136
144
|
state = msg?.stop_reason === 'tool_use' ? 'working' : 'idle'
|
|
137
145
|
} else {
|
|
138
146
|
// A real user line: a prompt to answer or a tool_result to consume — either way the
|
|
139
|
-
// model is on the hook. (The non-model user lines were filtered above.)
|
|
140
|
-
|
|
147
|
+
// model is on the hook. (The non-model user lines were filtered above.) EXCEPT the
|
|
148
|
+
// interrupt marker: an operator interrupt (Escape / `iapeer interrupt`) ENDS the turn, and
|
|
149
|
+
// claude records that end as a user line `[Request interrupted by user]` / `… for tool
|
|
150
|
+
// use]` (real bytes 18.10.2026) — no assistant stop_reason follows. Read as working, it was
|
|
151
|
+
// a permanent phantom «working» on a stopped session.
|
|
152
|
+
state = isClaudeInterruptMarker(d) ? 'idle' : 'working'
|
|
141
153
|
}
|
|
142
154
|
found = { state, atMs, cwd }
|
|
143
155
|
}
|
|
@@ -171,7 +183,7 @@ export function parseCodexTurn(text: string, head = ''): TurnState | null {
|
|
|
171
183
|
let found: TurnState | null = null
|
|
172
184
|
for (const line of text.split('\n')) {
|
|
173
185
|
const s = line.trim()
|
|
174
|
-
if (!s.startsWith('{') || !s.includes('task_')) continue
|
|
186
|
+
if (!s.startsWith('{') || (!s.includes('task_') && !s.includes('turn_aborted'))) continue
|
|
175
187
|
let d: { timestamp?: unknown; type?: unknown; payload?: { type?: unknown } }
|
|
176
188
|
try {
|
|
177
189
|
d = JSON.parse(s)
|
|
@@ -180,7 +192,9 @@ export function parseCodexTurn(text: string, head = ''): TurnState | null {
|
|
|
180
192
|
}
|
|
181
193
|
if (d.type !== 'event_msg') continue
|
|
182
194
|
const p = d.payload?.type
|
|
183
|
-
|
|
195
|
+
// `turn_aborted` (reason "interrupted") is how codex ends a turn the operator interrupted — NO
|
|
196
|
+
// task_complete follows (real rollouts). Without it an interrupted codex turn stayed «working».
|
|
197
|
+
if (p !== 'task_started' && p !== 'task_complete' && p !== 'turn_aborted') continue
|
|
184
198
|
const atMs = toMs(d.timestamp)
|
|
185
199
|
if (atMs === null) continue
|
|
186
200
|
found = { state: p === 'task_started' ? 'working' : 'idle', atMs, cwd }
|
|
@@ -407,7 +421,7 @@ export function startTurnWatch(cfg: LifecycleConfig, board: ActivityBoard, opts:
|
|
|
407
421
|
env,
|
|
408
422
|
now,
|
|
409
423
|
onError: opts.onError,
|
|
410
|
-
peerByCwd: (cwd: string) => byCwd
|
|
424
|
+
peerByCwd: (cwd: string) => peerByCwdPrefix(byCwd, cwd),
|
|
411
425
|
},
|
|
412
426
|
since,
|
|
413
427
|
).sinceMs
|
package/src/transport/index.ts
CHANGED
|
@@ -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 {
|