agentxchain 2.126.0 → 2.127.0

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.
@@ -95,6 +95,30 @@ function formatPercent(value) {
95
95
  return `${Math.round(value * 100)}%`;
96
96
  }
97
97
 
98
+ function formatDispatchActivity(progress) {
99
+ if (!progress || typeof progress !== 'object') return null;
100
+ const lastActivityAt = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
101
+ const agoSec = lastActivityAt && !Number.isNaN(lastActivityAt.getTime())
102
+ ? Math.round((Date.now() - lastActivityAt.getTime()) / 1000)
103
+ : null;
104
+
105
+ if (progress.activity_type === 'silent') {
106
+ const silentSince = progress.silent_since ? new Date(progress.silent_since) : null;
107
+ const silentSec = silentSince && !Number.isNaN(silentSince.getTime())
108
+ ? Math.round((Date.now() - silentSince.getTime()) / 1000)
109
+ : agoSec;
110
+ return `Silent for ${silentSec ?? 0}s (${progress.output_lines || 0} lines total, last output ${agoSec ?? 0}s ago)`;
111
+ }
112
+ if (progress.activity_type === 'request') {
113
+ return `API request in flight (${agoSec ?? 0}s ago)`;
114
+ }
115
+ if (progress.activity_type === 'response') {
116
+ return 'API response received';
117
+ }
118
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
119
+ return `Producing output (${progress.output_lines || 0} lines${agoLabel})`;
120
+ }
121
+
98
122
  function statusBadge(status) {
99
123
  const colors = {
100
124
  running: 'var(--green)',
@@ -411,6 +435,9 @@ export function render({ state, continuity, history, events = null, annotations,
411
435
 
412
436
  const turnCount = Array.isArray(history) ? history.length : 0;
413
437
  const activeTurns = state.active_turns ? Object.values(state.active_turns) : [];
438
+ const dispatchProgress = state.dispatch_progress && typeof state.dispatch_progress === 'object'
439
+ ? state.dispatch_progress
440
+ : {};
414
441
 
415
442
  let html = `<div class="timeline-view">`;
416
443
 
@@ -436,6 +463,7 @@ export function render({ state, continuity, history, events = null, annotations,
436
463
  for (const turn of activeTurns) {
437
464
  const elapsedMs = computeElapsed(turn.started_at);
438
465
  const elapsedStr = formatDuration(elapsedMs);
466
+ const activity = formatDispatchActivity(dispatchProgress[turn.turn_id]);
439
467
  html += `<div class="turn-card active">
440
468
  <div class="turn-header">
441
469
  ${roleBadge(getRole(turn))}
@@ -445,6 +473,7 @@ export function render({ state, continuity, history, events = null, annotations,
445
473
  </div>
446
474
  ${renderDelegationContext(turn.delegation_context)}
447
475
  ${renderDelegationReview(turn.delegation_review)}
476
+ ${activity ? `<div class="turn-detail"><span class="detail-label">Activity:</span> ${esc(activity)}</div>` : ''}
448
477
  </div>`;
449
478
  }
450
479
  html += `</div></div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.126.0",
3
+ "version": "2.127.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,8 @@ import {
46
46
  } from '../lib/turn-paths.js';
47
47
  import { resolveChainOptions, executeChainedRun } from '../lib/run-chain.js';
48
48
  import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuous-run.js';
49
+ import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
50
+ import { emitRunEvent } from '../lib/run-events.js';
49
51
 
50
52
  export async function runCommand(opts) {
51
53
  const context = loadProjectContext();
@@ -299,6 +301,10 @@ export async function executeGovernedRun(context, opts = {}) {
299
301
  }
300
302
 
301
303
  // ── Route to adapter ──────────────────────────────────────────────
304
+ const tracker = createDispatchProgressTracker(projectRoot, turn, {
305
+ adapter_type: runtimeType,
306
+ });
307
+
302
308
  const adapterOpts = {
303
309
  signal: combineAbortSignals(controller.signal, ctx.dispatchAbortSignal),
304
310
  onStatus: (msg) => log(chalk.dim(` ${msg}`)),
@@ -306,31 +312,103 @@ export async function executeGovernedRun(context, opts = {}) {
306
312
  turnId: turn.turn_id,
307
313
  };
308
314
 
315
+ const recordOutputActivity = (stream, text) => {
316
+ const lines = text.split('\n').length - 1 || 1;
317
+ const wasSilent = tracker.onOutput(stream, lines);
318
+ if (wasSilent) {
319
+ const progressState = tracker.getState();
320
+ emitRunEvent(projectRoot, 'dispatch_progress', {
321
+ run_id: state.run_id,
322
+ phase: state.phase,
323
+ status: state.status,
324
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
325
+ payload: {
326
+ milestone: 'output_resumed',
327
+ output_lines: progressState.output_lines,
328
+ elapsed_seconds: Math.round((Date.now() - new Date(progressState.started_at)) / 1000),
329
+ silent_seconds: 0,
330
+ },
331
+ });
332
+ }
333
+ };
334
+
309
335
  if (verbose) {
310
- adapterOpts.onStdout = (text) => process.stdout.write(chalk.dim(text));
311
- adapterOpts.onStderr = (text) => process.stderr.write(chalk.yellow(text));
336
+ adapterOpts.onStdout = (text) => {
337
+ process.stdout.write(chalk.dim(text));
338
+ recordOutputActivity('stdout', text);
339
+ };
340
+ adapterOpts.onStderr = (text) => {
341
+ process.stderr.write(chalk.yellow(text));
342
+ recordOutputActivity('stderr', text);
343
+ };
344
+ } else {
345
+ // Even in non-verbose mode, track output activity for progress visibility
346
+ adapterOpts.onStdout = (text) => {
347
+ recordOutputActivity('stdout', text);
348
+ };
349
+ adapterOpts.onStderr = (text) => {
350
+ recordOutputActivity('stderr', text);
351
+ };
312
352
  }
313
353
 
314
354
  let adapterResult;
315
355
 
316
- if (runtimeType === 'api_proxy') {
317
- log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
318
- adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
319
- } else if (runtimeType === 'mcp') {
320
- const transport = resolveMcpTransport(runtime);
321
- log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
322
- adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
323
- } else if (runtimeType === 'local_cli') {
324
- const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
325
- log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
326
- adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
327
- } else if (runtimeType === 'remote_agent') {
328
- log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
329
- adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
330
- } else {
331
- return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
356
+ // Emit dispatch_progress started event and begin tracking
357
+ tracker.start();
358
+ emitRunEvent(projectRoot, 'dispatch_progress', {
359
+ run_id: state.run_id,
360
+ phase: state.phase,
361
+ status: state.status,
362
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
363
+ payload: { milestone: 'started', output_lines: 0, elapsed_seconds: 0, silent_seconds: 0 },
364
+ });
365
+
366
+ try {
367
+ if (runtimeType === 'api_proxy') {
368
+ log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
369
+ tracker.requestStarted();
370
+ adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
371
+ if (adapterResult.ok) tracker.responseReceived();
372
+ } else if (runtimeType === 'mcp') {
373
+ const transport = resolveMcpTransport(runtime);
374
+ log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
375
+ tracker.requestStarted();
376
+ adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
377
+ if (adapterResult.ok) tracker.responseReceived();
378
+ } else if (runtimeType === 'local_cli') {
379
+ const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
380
+ log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
381
+ adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
382
+ } else if (runtimeType === 'remote_agent') {
383
+ log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
384
+ tracker.requestStarted();
385
+ adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
386
+ if (adapterResult.ok) tracker.responseReceived();
387
+ } else {
388
+ tracker.fail();
389
+ return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
390
+ }
391
+ } catch (err) {
392
+ tracker.fail();
393
+ emitRunEvent(projectRoot, 'dispatch_progress', {
394
+ run_id: state.run_id, phase: state.phase, status: state.status,
395
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
396
+ payload: { milestone: 'failed', output_lines: tracker.getState().output_lines, elapsed_seconds: Math.round((Date.now() - new Date(tracker.getState().started_at)) / 1000), silent_seconds: 0 },
397
+ });
398
+ throw err;
332
399
  }
333
400
 
401
+ // Emit completion/failure progress event and clean up tracker
402
+ const progressState = tracker.getState();
403
+ const elapsedSec = Math.round((Date.now() - new Date(progressState.started_at)) / 1000);
404
+ const milestone = adapterResult.ok ? 'completed' : (adapterResult.timedOut ? 'timed_out' : 'failed');
405
+ if (adapterResult.ok) { tracker.complete(); } else { tracker.fail(); }
406
+ emitRunEvent(projectRoot, 'dispatch_progress', {
407
+ run_id: state.run_id, phase: state.phase, status: state.status,
408
+ turn: { turn_id: turn.turn_id, assigned_role: roleId },
409
+ payload: { milestone, output_lines: progressState.output_lines, elapsed_seconds: elapsedSec, silent_seconds: progressState.silent_since ? Math.round((Date.now() - new Date(progressState.silent_since)) / 1000) : 0 },
410
+ });
411
+
334
412
  // Save adapter logs
335
413
  if (adapterResult.logs?.length) {
336
414
  saveDispatchLogs(projectRoot, turn.turn_id, adapterResult.logs);
@@ -23,6 +23,7 @@ import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
23
23
  import { getDashboardPid, getDashboardSession } from './dashboard.js';
24
24
  import { readPreemptionMarker } from '../lib/intake.js';
25
25
  import { readContinuousSession } from '../lib/continuous-run.js';
26
+ import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
26
27
 
27
28
  export async function statusCommand(opts) {
28
29
  const context = loadStatusContext();
@@ -142,6 +143,9 @@ function renderGovernedStatus(context, opts) {
142
143
  // Fire approval SLA reminders as a side effect (webhook-only, no CLI output)
143
144
  evaluateApprovalSlaReminders(root, config, state);
144
145
 
146
+ const activeTurns = getActiveTurns(state);
147
+ const dispatchProgress = filterDispatchProgressForActiveTurns(readAllDispatchProgress(root), activeTurns);
148
+
145
149
  if (opts.json) {
146
150
  const dashPid = getDashboardPid(root);
147
151
  const dashSession = getDashboardSession(root);
@@ -168,6 +172,7 @@ function renderGovernedStatus(context, opts) {
168
172
  next_actions: nextActions,
169
173
  connector_health: connectorHealth,
170
174
  recent_event_summary: recentEventSummary,
175
+ dispatch_progress: dispatchProgress,
171
176
  human_escalation: humanEscalation,
172
177
  preemption_marker: preemptionMarker,
173
178
  continuous_session: continuousSession,
@@ -262,7 +267,6 @@ function renderGovernedStatus(context, opts) {
262
267
  renderRecentEventSummary(recentEventSummary);
263
268
 
264
269
  const activeTurnCount = getActiveTurnCount(state);
265
- const activeTurns = getActiveTurns(state);
266
270
  const singleActiveTurn = getActiveTurn(state);
267
271
  const approvalPending = Boolean(state?.pending_phase_transition || state?.pending_run_completion);
268
272
  if (activeTurnCount > 1) {
@@ -296,6 +300,10 @@ function renderGovernedStatus(context, opts) {
296
300
  }
297
301
  }
298
302
  console.log(` ${marker} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${statusLabel}) [attempt ${turn.attempt}]${elapsedTag}${budgetTag}`);
303
+ const activityLine = formatDispatchActivityLine(dispatchProgress[turn.turn_id]);
304
+ if (activityLine) {
305
+ console.log(` ${chalk.dim('Activity:')} ${activityLine}`);
306
+ }
299
307
  if (turn.status === 'conflicted' && turn.conflict_state) {
300
308
  const cs = turn.conflict_state;
301
309
  const files = cs.conflict_error?.conflicting_files || [];
@@ -341,6 +349,11 @@ function renderGovernedStatus(context, opts) {
341
349
  }
342
350
  }
343
351
  }
352
+ // Dispatch progress activity line (DEC-DISPATCH-PROGRESS-001)
353
+ const activityLine = formatDispatchActivityLine(dispatchProgress[singleActiveTurn.turn_id]);
354
+ if (activityLine) {
355
+ console.log(` ${chalk.dim('Activity:')} ${activityLine}`);
356
+ }
344
357
  if (singleActiveTurn.status === 'conflicted' && singleActiveTurn.conflict_state) {
345
358
  const cs = singleActiveTurn.conflict_state;
346
359
  const files = cs.conflict_error?.conflicting_files || [];
@@ -743,6 +756,45 @@ function pluralizeRepoDecisionCount(count, singular, plural) {
743
756
  return `${count} ${count === 1 ? singular : plural}`;
744
757
  }
745
758
 
759
+ function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
760
+ const filtered = {};
761
+ if (!progressByTurn || typeof progressByTurn !== 'object') {
762
+ return filtered;
763
+ }
764
+ for (const turn of Object.values(activeTurns || {})) {
765
+ const turnId = turn?.turn_id;
766
+ if (turnId && progressByTurn[turnId]) {
767
+ filtered[turnId] = progressByTurn[turnId];
768
+ }
769
+ }
770
+ return filtered;
771
+ }
772
+
773
+ function formatDispatchActivityLine(progress) {
774
+ if (!progress || typeof progress !== 'object') return null;
775
+ const lastAct = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
776
+ const agoSec = lastAct && !Number.isNaN(lastAct.getTime())
777
+ ? Math.round((Date.now() - lastAct.getTime()) / 1000)
778
+ : null;
779
+
780
+ if (progress.activity_type === 'silent') {
781
+ const silentAt = progress.silent_since ? new Date(progress.silent_since) : null;
782
+ const silentSec = silentAt && !Number.isNaN(silentAt.getTime())
783
+ ? Math.round((Date.now() - silentAt.getTime()) / 1000)
784
+ : agoSec;
785
+ return chalk.yellow(`Silent for ${silentSec}s`) +
786
+ ` (${progress.output_lines || 0} lines total, last output ${agoSec ?? 0}s ago)`;
787
+ }
788
+ if (progress.activity_type === 'request') {
789
+ return chalk.cyan('API request in flight') + ` (${agoSec ?? 0}s ago)`;
790
+ }
791
+ if (progress.activity_type === 'response') {
792
+ return chalk.green('API response received');
793
+ }
794
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
795
+ return chalk.green('Producing output') + ` (${progress.output_lines || 0} lines${agoLabel})`;
796
+ }
797
+
746
798
  function renderLastGateFailure(failure, config) {
747
799
  const entryRole = config?.routing?.[failure.phase]?.entry_role || null;
748
800
  const suggestedCommand = entryRole ? `agentxchain step --role ${entryRole}` : 'agentxchain step --role <role>';
@@ -15,6 +15,7 @@ import {
15
15
  import { loadProjectContext } from '../config.js';
16
16
  import { getContinuityStatus } from '../continuity-status.js';
17
17
  import { readRepoDecisions, summarizeRepoDecisions } from '../repo-decisions.js';
18
+ import { readAllDispatchProgress } from '../dispatch-progress.js';
18
19
 
19
20
  const STATE_FILE = 'state.json';
20
21
  const SESSION_FILE = 'session.json';
@@ -80,6 +81,9 @@ export function normalizeRelativePath(filePath) {
80
81
 
81
82
  export function resourcesForRelativePath(filePath) {
82
83
  const normalized = normalizeRelativePath(filePath);
84
+ if (/^dispatch-progress-[^/]+\.json$/.test(normalized)) {
85
+ return ['/api/state'];
86
+ }
83
87
  if (normalized.startsWith('missions/plans/') && normalized.endsWith('.json')) {
84
88
  return ['/api/plans', '/api/missions'];
85
89
  }
@@ -136,6 +140,7 @@ function enrichGovernedState(agentxchainDir, state) {
136
140
  ...state,
137
141
  runtime_guidance: deriveRuntimeBlockedGuidance(state, context.config),
138
142
  next_actions: deriveGovernedRunNextActions(state, context.config),
143
+ dispatch_progress: readAllDispatchProgress(workspacePath),
139
144
  };
140
145
  }
141
146
 
@@ -0,0 +1,298 @@
1
+ /**
2
+ * dispatch-progress.js — Real-time adapter dispatch progress tracking.
3
+ *
4
+ * Writes `.agentxchain/dispatch-progress.json` during in-flight adapter
5
+ * dispatch so operators can distinguish "adapter is working" from "adapter
6
+ * is hung" via `agentxchain status` and the dashboard file-watcher.
7
+ *
8
+ * DEC-DISPATCH-PROGRESS-001: progress writes are best-effort and never
9
+ * block or delay the governed turn.
10
+ */
11
+
12
+ import { writeFileSync, unlinkSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
13
+ import { join, dirname, basename } from 'node:path';
14
+
15
+ export const LEGACY_DISPATCH_PROGRESS_PATH = '.agentxchain/dispatch-progress.json';
16
+ export const DISPATCH_PROGRESS_FILE_PREFIX = '.agentxchain/dispatch-progress-';
17
+
18
+ export function getDispatchProgressRelativePath(turnId) {
19
+ return `${DISPATCH_PROGRESS_FILE_PREFIX}${turnId}.json`;
20
+ }
21
+
22
+ function getDispatchProgressFilePath(root, turnId) {
23
+ return join(root, getDispatchProgressRelativePath(turnId));
24
+ }
25
+
26
+ function listDispatchProgressFiles(root) {
27
+ const agentxchainDir = join(root, '.agentxchain');
28
+ if (!existsSync(agentxchainDir)) return [];
29
+ try {
30
+ return readdirSync(agentxchainDir)
31
+ .filter((entry) => entry.startsWith('dispatch-progress-') && entry.endsWith('.json'))
32
+ .map((entry) => join(agentxchainDir, entry));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Create a dispatch progress tracker for a single turn.
40
+ *
41
+ * Usage:
42
+ * const tracker = createDispatchProgressTracker(root, turn, runtime);
43
+ * tracker.start();
44
+ * // ... during dispatch:
45
+ * tracker.onOutput('stdout', lineCount);
46
+ * tracker.onOutput('stderr', lineCount);
47
+ * // ... when done:
48
+ * tracker.complete(); // or tracker.fail();
49
+ *
50
+ * @param {string} root - project root
51
+ * @param {object} turn - turn object with turn_id, runtime_id, assigned_role
52
+ * @param {object} options
53
+ * @param {string} options.adapter_type - 'local_cli' | 'api_proxy' | 'mcp' | 'remote_agent'
54
+ * @param {number} [options.pid] - subprocess PID (local_cli only)
55
+ * @param {number} [options.writeIntervalMs=1000] - min interval between file writes
56
+ * @param {number} [options.silenceThresholdMs=30000] - silence detection threshold
57
+ * @returns {DispatchProgressTracker}
58
+ */
59
+ export function createDispatchProgressTracker(root, turn, options = {}) {
60
+ const {
61
+ adapter_type = 'local_cli',
62
+ pid = null,
63
+ writeIntervalMs = 1000,
64
+ silenceThresholdMs = 30000,
65
+ } = options;
66
+
67
+ const filePath = getDispatchProgressFilePath(root, turn.turn_id);
68
+
69
+ let state = {
70
+ turn_id: turn.turn_id,
71
+ runtime_id: turn.runtime_id || null,
72
+ adapter_type,
73
+ started_at: null,
74
+ last_activity_at: null,
75
+ activity_type: 'output',
76
+ activity_summary: 'Dispatch starting',
77
+ output_lines: 0,
78
+ stderr_lines: 0,
79
+ silent_since: null,
80
+ pid,
81
+ };
82
+
83
+ let lastWriteAt = 0;
84
+ let silenceTimer = null;
85
+ let dirty = false;
86
+
87
+ function writeProgress() {
88
+ try {
89
+ const dir = dirname(filePath);
90
+ if (!existsSync(dir)) {
91
+ mkdirSync(dir, { recursive: true });
92
+ }
93
+ writeFileSync(filePath, JSON.stringify(state, null, 2) + '\n');
94
+ lastWriteAt = Date.now();
95
+ dirty = false;
96
+ } catch {
97
+ // Best-effort — never interrupt dispatch.
98
+ }
99
+ }
100
+
101
+ function maybeWrite() {
102
+ if (!dirty) return;
103
+ const now = Date.now();
104
+ if (now - lastWriteAt >= writeIntervalMs) {
105
+ writeProgress();
106
+ }
107
+ }
108
+
109
+ function resetSilenceTimer() {
110
+ if (silenceTimer) clearTimeout(silenceTimer);
111
+ silenceTimer = setTimeout(() => {
112
+ state.activity_type = 'silent';
113
+ state.silent_since = state.silent_since || new Date().toISOString();
114
+ state.activity_summary = `No output for ${Math.round(silenceThresholdMs / 1000)}s`;
115
+ dirty = true;
116
+ writeProgress();
117
+ }, silenceThresholdMs);
118
+ }
119
+
120
+ return {
121
+ /** Start tracking — call once at dispatch start. */
122
+ start() {
123
+ const now = new Date().toISOString();
124
+ state.started_at = now;
125
+ state.last_activity_at = now;
126
+ state.activity_type = 'output';
127
+ state.activity_summary = 'Subprocess started';
128
+ dirty = true;
129
+ writeProgress();
130
+ if (adapter_type === 'local_cli') {
131
+ resetSilenceTimer();
132
+ }
133
+ },
134
+
135
+ /** Record output activity from the subprocess. */
136
+ onOutput(stream, lineCount = 1) {
137
+ const now = new Date().toISOString();
138
+ const wasSilent = state.activity_type === 'silent';
139
+ state.last_activity_at = now;
140
+ state.activity_type = 'output';
141
+ state.silent_since = null;
142
+ if (stream === 'stderr') {
143
+ state.stderr_lines += lineCount;
144
+ } else {
145
+ state.output_lines += lineCount;
146
+ }
147
+ state.activity_summary = `Producing output (${state.output_lines} lines)`;
148
+ dirty = true;
149
+ maybeWrite();
150
+ if (adapter_type === 'local_cli') {
151
+ resetSilenceTimer();
152
+ }
153
+ return wasSilent; // caller can use this to emit a "resumed" event
154
+ },
155
+
156
+ /** Mark as API request in flight (api_proxy, mcp, remote_agent). */
157
+ requestStarted() {
158
+ state.activity_type = 'request';
159
+ state.activity_summary = 'API request in flight';
160
+ state.last_activity_at = new Date().toISOString();
161
+ dirty = true;
162
+ writeProgress();
163
+ },
164
+
165
+ /** Mark API response received. */
166
+ responseReceived() {
167
+ state.activity_type = 'response';
168
+ state.activity_summary = 'API response received';
169
+ state.last_activity_at = new Date().toISOString();
170
+ dirty = true;
171
+ writeProgress();
172
+ },
173
+
174
+ /** Update PID after spawn (local_cli). */
175
+ setPid(newPid) {
176
+ state.pid = newPid;
177
+ dirty = true;
178
+ maybeWrite();
179
+ },
180
+
181
+ /** Get current progress state snapshot. */
182
+ getState() {
183
+ return { ...state };
184
+ },
185
+
186
+ /** Clean up — dispatch completed successfully. */
187
+ complete() {
188
+ if (silenceTimer) clearTimeout(silenceTimer);
189
+ deleteProgressFile(root, turn.turn_id);
190
+ },
191
+
192
+ /** Clean up — dispatch failed. */
193
+ fail() {
194
+ if (silenceTimer) clearTimeout(silenceTimer);
195
+ deleteProgressFile(root, turn.turn_id);
196
+ },
197
+
198
+ /** Clean up timers without deleting file (for abort paths). */
199
+ dispose() {
200
+ if (silenceTimer) clearTimeout(silenceTimer);
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Delete the dispatch progress file.
207
+ * @param {string} root - project root
208
+ */
209
+ export function deleteProgressFile(root, turnId = null) {
210
+ try {
211
+ if (turnId) {
212
+ const filePath = getDispatchProgressFilePath(root, turnId);
213
+ if (existsSync(filePath)) {
214
+ unlinkSync(filePath);
215
+ }
216
+ return;
217
+ }
218
+
219
+ const legacyPath = join(root, LEGACY_DISPATCH_PROGRESS_PATH);
220
+ if (existsSync(legacyPath)) {
221
+ unlinkSync(legacyPath);
222
+ }
223
+ for (const filePath of listDispatchProgressFiles(root)) {
224
+ if (existsSync(filePath)) {
225
+ unlinkSync(filePath);
226
+ }
227
+ }
228
+ } catch {
229
+ // Best-effort.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Read the current dispatch progress file.
235
+ * Returns null if no file exists or it's malformed.
236
+ *
237
+ * @param {string} root - project root
238
+ * @returns {object|null}
239
+ */
240
+ export function readDispatchProgress(root, turnId = null) {
241
+ try {
242
+ let filePath;
243
+ if (turnId) {
244
+ filePath = getDispatchProgressFilePath(root, turnId);
245
+ if (!existsSync(filePath)) return null;
246
+ } else {
247
+ const files = listDispatchProgressFiles(root);
248
+ if (files.length === 0) {
249
+ const legacyPath = join(root, LEGACY_DISPATCH_PROGRESS_PATH);
250
+ if (!existsSync(legacyPath)) return null;
251
+ filePath = legacyPath;
252
+ } else if (files.length === 1) {
253
+ filePath = files[0];
254
+ } else {
255
+ return null;
256
+ }
257
+ }
258
+ const raw = readFileSync(filePath, 'utf8');
259
+ const data = JSON.parse(raw);
260
+ if (!data.turn_id || !data.started_at) return null;
261
+ return data;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Read all current per-turn dispatch progress files.
269
+ *
270
+ * @param {string} root - project root
271
+ * @returns {Record<string, object>}
272
+ */
273
+ export function readAllDispatchProgress(root) {
274
+ const progressByTurn = {};
275
+
276
+ for (const filePath of listDispatchProgressFiles(root)) {
277
+ try {
278
+ const raw = readFileSync(filePath, 'utf8');
279
+ const data = JSON.parse(raw);
280
+ const turnId = typeof data?.turn_id === 'string' && data.turn_id.length > 0
281
+ ? data.turn_id
282
+ : basename(filePath).replace(/^dispatch-progress-/, '').replace(/\.json$/, '');
283
+ if (!turnId || !data?.started_at) continue;
284
+ progressByTurn[turnId] = data;
285
+ } catch {
286
+ // Ignore malformed files.
287
+ }
288
+ }
289
+
290
+ if (Object.keys(progressByTurn).length === 0) {
291
+ const legacy = readDispatchProgress(root);
292
+ if (legacy?.turn_id) {
293
+ progressByTurn[legacy.turn_id] = legacy;
294
+ }
295
+ }
296
+
297
+ return progressByTurn;
298
+ }
@@ -24,6 +24,7 @@ import { join } from 'path';
24
24
 
25
25
  const OPERATIONAL_PATH_PREFIXES = [
26
26
  '.agentxchain/dispatch/',
27
+ '.agentxchain/dispatch-progress-',
27
28
  '.agentxchain/staging/',
28
29
  '.agentxchain/intake/',
29
30
  '.agentxchain/locks/',
@@ -28,6 +28,7 @@ export const VALID_RUN_EVENTS = [
28
28
  'budget_exceeded_warn',
29
29
  'human_escalation_raised',
30
30
  'human_escalation_resolved',
31
+ 'dispatch_progress',
31
32
  ];
32
33
 
33
34
  /**