@agfpd/iapeer 0.4.130 → 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.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.130",
3
+ "version": "0.4.131",
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",
@@ -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
- state = 'working'
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
- if (p !== 'task_started' && p !== 'task_complete') continue
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.get(cwd.replace(/\/+$/, '').toLowerCase()),
424
+ peerByCwd: (cwd: string) => peerByCwdPrefix(byCwd, cwd),
411
425
  },
412
426
  since,
413
427
  ).sinceMs