agentxchain 2.37.0 → 2.38.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.
@@ -154,8 +154,19 @@ function renderContinuityPanel(continuity) {
154
154
  html += `<div class="turn-detail"><span class="detail-label">Checkpoint:</span> No session checkpoint recorded</div>`;
155
155
  }
156
156
 
157
- if (continuity.restart_recommended) {
158
- html += `<div class="turn-detail"><span class="detail-label">Restart:</span> <span class="mono">agentxchain restart</span></div>`;
157
+ if (continuity.drift_detected === true && Array.isArray(continuity.drift_warnings) && continuity.drift_warnings.length > 0) {
158
+ html += `<div class="turn-detail risks"><span class="detail-label">Drift:</span><ul>`;
159
+ for (const warning of continuity.drift_warnings) {
160
+ html += `<li>${esc(warning)}</li>`;
161
+ }
162
+ html += `</ul></div>`;
163
+ } else if (continuity.drift_detected === false) {
164
+ html += `<div class="turn-detail"><span class="detail-label">Drift:</span> none detected since checkpoint</div>`;
165
+ }
166
+
167
+ if (continuity.recommended_command) {
168
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
169
+ html += `<div class="turn-detail"><span class="detail-label">Action:</span> <span class="mono">${esc(continuity.recommended_command)}</span>${esc(detail)}</div>`;
159
170
  }
160
171
 
161
172
  if (continuity.recovery_report_path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.37.0",
3
+ "version": "2.38.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,13 +23,13 @@ import {
23
23
  HISTORY_PATH,
24
24
  LEDGER_PATH,
25
25
  } from '../lib/governed-state.js';
26
- import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
26
+ import { readSessionCheckpoint, writeSessionCheckpoint, captureBaselineRef, SESSION_PATH } from '../lib/session-checkpoint.js';
27
27
 
28
28
  /**
29
29
  * Generate a session recovery report summarizing the run state
30
30
  * so a new agent session can orient quickly.
31
31
  */
32
- function generateRecoveryReport(root, state, checkpoint) {
32
+ function generateRecoveryReport(root, state, checkpoint, driftWarnings = []) {
33
33
  const lines = [
34
34
  '# Session Recovery Report',
35
35
  '',
@@ -102,7 +102,50 @@ function generateRecoveryReport(root, state, checkpoint) {
102
102
  }
103
103
  }
104
104
 
105
- lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
105
+ // Pending gate / run completion surfacing
106
+ if (state.pending_phase_transition) {
107
+ const pt = state.pending_phase_transition;
108
+ lines.push(
109
+ '## Pending Phase Transition',
110
+ '',
111
+ `- **From**: ${pt.from}`,
112
+ `- **To**: ${pt.to}`,
113
+ `- **Gate**: ${pt.gate}`,
114
+ `- **Requested by**: ${pt.requested_by_turn || 'unknown'}`,
115
+ `- **Action**: Run \`agentxchain approve-transition\` to approve`,
116
+ '',
117
+ );
118
+ }
119
+
120
+ if (state.pending_run_completion) {
121
+ const pc = state.pending_run_completion;
122
+ lines.push(
123
+ '## Pending Run Completion',
124
+ '',
125
+ `- **Gate**: ${pc.gate}`,
126
+ `- **Requested by**: ${pc.requested_by_turn || 'unknown'}`,
127
+ `- **Action**: Run \`agentxchain approve-completion\` to approve`,
128
+ '',
129
+ );
130
+ }
131
+
132
+ // Repo-drift warnings
133
+ if (checkpoint?.baseline_ref && driftWarnings?.length > 0) {
134
+ lines.push('## Continuity Warnings', '');
135
+ for (const warning of driftWarnings) {
136
+ lines.push(`- ⚠ ${warning}`);
137
+ }
138
+ lines.push('');
139
+ }
140
+
141
+ lines.push('## Next Steps', '');
142
+ if (state.pending_phase_transition) {
143
+ lines.push('A phase transition is pending approval. Run `agentxchain approve-transition` before assigning new turns.', '');
144
+ } else if (state.pending_run_completion) {
145
+ lines.push('A run completion is pending approval. Run `agentxchain approve-completion` to finalize.', '');
146
+ } else {
147
+ lines.push('The next turn has been assigned. Check the dispatch bundle for context.', '');
148
+ }
106
149
 
107
150
  return lines.join('\n');
108
151
  }
@@ -160,6 +203,42 @@ export async function restartCommand(opts) {
160
203
  process.exit(1);
161
204
  }
162
205
 
206
+ // ── Repo-drift detection ────────────────────────────────────────────────
207
+ const driftWarnings = [];
208
+ if (checkpoint?.baseline_ref) {
209
+ const currentBaseline = captureBaselineRef(root);
210
+ const prev = checkpoint.baseline_ref;
211
+
212
+ if (prev.git_head && currentBaseline.git_head && prev.git_head !== currentBaseline.git_head) {
213
+ driftWarnings.push(`Git HEAD has moved since checkpoint: ${prev.git_head.slice(0, 8)} → ${currentBaseline.git_head.slice(0, 8)}`);
214
+ }
215
+ if (prev.git_branch && currentBaseline.git_branch && prev.git_branch !== currentBaseline.git_branch) {
216
+ driftWarnings.push(`Branch changed since checkpoint: ${prev.git_branch} → ${currentBaseline.git_branch}`);
217
+ }
218
+ if (prev.workspace_dirty === false && currentBaseline.workspace_dirty === true) {
219
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
220
+ }
221
+ }
222
+
223
+ if (driftWarnings.length > 0) {
224
+ for (const warning of driftWarnings) {
225
+ console.log(chalk.yellow(`⚠ ${warning}`));
226
+ }
227
+ }
228
+
229
+ // ── Pending gate / completion check ────────────────────────────────────
230
+ if (state.pending_phase_transition) {
231
+ const pt = state.pending_phase_transition;
232
+ console.log(chalk.yellow(`Pending phase transition: ${pt.from} → ${pt.to} (gate: ${pt.gate})`));
233
+ console.log(chalk.dim('Run `agentxchain approve-transition` to approve before assigning new turns.'));
234
+ }
235
+
236
+ if (state.pending_run_completion) {
237
+ const pc = state.pending_run_completion;
238
+ console.log(chalk.yellow(`Pending run completion (gate: ${pc.gate})`));
239
+ console.log(chalk.dim('Run `agentxchain approve-completion` to finalize.'));
240
+ }
241
+
163
242
  // Handle abandoned active turns (assigned but never completed)
164
243
  const activeTurns = getActiveTurns(state);
165
244
  const activeTurnCount = getActiveTurnCount(state);
@@ -167,9 +246,29 @@ export async function restartCommand(opts) {
167
246
  const turnIds = Object.keys(activeTurns);
168
247
  console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
169
248
  console.log(chalk.dim('These turns will be available for the next agent to complete.'));
249
+
250
+ // Fail closed if retained turn + irreconcilable drift
251
+ if (driftWarnings.length > 0) {
252
+ console.log(chalk.yellow('Active turns exist with repo drift since checkpoint. Reconnecting with warnings.'));
253
+ console.log(chalk.dim('Inspect the drift before continuing work on the retained turns.'));
254
+ }
170
255
  }
171
256
 
172
- // If paused, reactivate
257
+ // If pending gate/completion, do not bypass — surface and exit with recovery
258
+ if (state.pending_phase_transition || state.pending_run_completion) {
259
+ // Write checkpoint for the reconnect
260
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
261
+
262
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
263
+ const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
264
+ const recoveryDir = dirname(recoveryPath);
265
+ if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
266
+ writeFileSync(recoveryPath, recoveryReport);
267
+ console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
268
+ return;
269
+ }
270
+
271
+ // If paused or idle without a pending approval gate, reactivate.
173
272
  if (state.status === 'paused' || state.status === 'idle') {
174
273
  const reactivated = reactivateGovernedRun(root, state, {
175
274
  reason: 'session_restart',
@@ -198,6 +297,8 @@ export async function restartCommand(opts) {
198
297
  process.exit(1);
199
298
  }
200
299
 
300
+ // assignGovernedTurn already writes a checkpoint at turn_assigned
301
+
201
302
  console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
202
303
  console.log(chalk.dim(` Phase: ${phase}`));
203
304
  console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
@@ -206,6 +307,9 @@ export async function restartCommand(opts) {
206
307
  console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
207
308
  }
208
309
  } else {
310
+ // Reconnect to existing active turns — write checkpoint
311
+ writeSessionCheckpoint(root, state, 'restart_reconnect');
312
+
209
313
  console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
210
314
  console.log(chalk.dim(` Phase: ${phase}`));
211
315
  console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
@@ -213,7 +317,7 @@ export async function restartCommand(opts) {
213
317
  }
214
318
 
215
319
  // Write session recovery report
216
- const recoveryReport = generateRecoveryReport(root, state, checkpoint);
320
+ const recoveryReport = generateRecoveryReport(root, state, checkpoint, driftWarnings);
217
321
  const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
218
322
  const recoveryDir = dirname(recoveryPath);
219
323
  if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
@@ -268,8 +268,21 @@ function renderContinuityStatus(continuity, state) {
268
268
  console.log(` ${chalk.dim('Checkpoint:')} ${chalk.yellow('No session checkpoint recorded')}`);
269
269
  }
270
270
 
271
- if (continuity.restart_recommended) {
272
- console.log(` ${chalk.dim('Restart:')} ${chalk.cyan('agentxchain restart')} (rebuild session context from disk)`);
271
+ if (continuity.drift_detected === true) {
272
+ const [firstWarning, ...remainingWarnings] = continuity.drift_warnings || [];
273
+ if (firstWarning) {
274
+ console.log(` ${chalk.dim('Drift:')} ${chalk.yellow(firstWarning)}`);
275
+ }
276
+ for (const warning of remainingWarnings) {
277
+ console.log(` ${chalk.dim(' ')} ${chalk.yellow(warning)}`);
278
+ }
279
+ } else if (continuity.drift_detected === false) {
280
+ console.log(` ${chalk.dim('Drift:')} ${chalk.green('none detected since checkpoint')}`);
281
+ }
282
+
283
+ if (continuity.recommended_command) {
284
+ const detail = continuity.recommended_detail ? ` (${continuity.recommended_detail})` : '';
285
+ console.log(` ${chalk.dim('Action:')} ${chalk.cyan(continuity.recommended_command)}${chalk.dim(detail)}`);
273
286
  }
274
287
 
275
288
  if (continuity.recovery_report_path) {
@@ -1,9 +1,111 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { readSessionCheckpoint } from './session-checkpoint.js';
3
+ import { captureBaselineRef, readSessionCheckpoint } from './session-checkpoint.js';
4
4
 
5
5
  export const SESSION_RECOVERY_PATH = '.agentxchain/SESSION_RECOVERY.md';
6
6
 
7
+ function deriveRecommendedContinuityAction(state) {
8
+ if (!state) {
9
+ return {
10
+ recommended_command: null,
11
+ recommended_reason: 'no_state',
12
+ recommended_detail: null,
13
+ restart_recommended: false,
14
+ };
15
+ }
16
+
17
+ if (state.pending_phase_transition) {
18
+ const pt = state.pending_phase_transition;
19
+ return {
20
+ recommended_command: 'agentxchain approve-transition',
21
+ recommended_reason: 'pending_phase_transition',
22
+ recommended_detail: `${pt.from || 'unknown'} -> ${pt.to || 'unknown'} (gate: ${pt.gate || 'unknown'})`,
23
+ restart_recommended: false,
24
+ };
25
+ }
26
+
27
+ if (state.pending_run_completion) {
28
+ const pc = state.pending_run_completion;
29
+ return {
30
+ recommended_command: 'agentxchain approve-completion',
31
+ recommended_reason: 'pending_run_completion',
32
+ recommended_detail: pc.gate ? `gate: ${pc.gate}` : null,
33
+ restart_recommended: false,
34
+ };
35
+ }
36
+
37
+ if (!['blocked', 'completed', 'failed'].includes(state.status)) {
38
+ return {
39
+ recommended_command: 'agentxchain restart',
40
+ recommended_reason: 'restart_available',
41
+ recommended_detail: 'rebuild session context from disk',
42
+ restart_recommended: true,
43
+ };
44
+ }
45
+
46
+ return {
47
+ recommended_command: null,
48
+ recommended_reason: state.status === 'blocked' ? 'blocked' : 'terminal_state',
49
+ recommended_detail: null,
50
+ restart_recommended: false,
51
+ };
52
+ }
53
+
54
+ function deriveCheckpointDrift(root, checkpoint, staleCheckpoint) {
55
+ if (!checkpoint?.baseline_ref || staleCheckpoint) {
56
+ return {
57
+ drift_detected: null,
58
+ drift_warnings: [],
59
+ };
60
+ }
61
+
62
+ const currentBaseline = captureBaselineRef(root);
63
+ const previousBaseline = checkpoint.baseline_ref;
64
+
65
+ if (
66
+ currentBaseline.git_head == null
67
+ && currentBaseline.git_branch == null
68
+ && currentBaseline.workspace_dirty == null
69
+ ) {
70
+ return {
71
+ drift_detected: null,
72
+ drift_warnings: [],
73
+ };
74
+ }
75
+
76
+ const driftWarnings = [];
77
+
78
+ if (
79
+ previousBaseline.git_head
80
+ && currentBaseline.git_head
81
+ && previousBaseline.git_head !== currentBaseline.git_head
82
+ ) {
83
+ driftWarnings.push(
84
+ `Git HEAD has moved since checkpoint: ${previousBaseline.git_head.slice(0, 8)} -> ${currentBaseline.git_head.slice(0, 8)}`
85
+ );
86
+ }
87
+
88
+ if (
89
+ previousBaseline.git_branch
90
+ && currentBaseline.git_branch
91
+ && previousBaseline.git_branch !== currentBaseline.git_branch
92
+ ) {
93
+ driftWarnings.push(`Branch changed since checkpoint: ${previousBaseline.git_branch} -> ${currentBaseline.git_branch}`);
94
+ }
95
+
96
+ if (
97
+ previousBaseline.workspace_dirty === false
98
+ && currentBaseline.workspace_dirty === true
99
+ ) {
100
+ driftWarnings.push('Workspace was clean at checkpoint but is now dirty');
101
+ }
102
+
103
+ return {
104
+ drift_detected: driftWarnings.length > 0,
105
+ drift_warnings: driftWarnings,
106
+ };
107
+ }
108
+
7
109
  export function getContinuityStatus(root, state) {
8
110
  const checkpoint = readSessionCheckpoint(root);
9
111
  const recoveryReportPath = existsSync(join(root, SESSION_RECOVERY_PATH))
@@ -18,10 +120,18 @@ export function getContinuityStatus(root, state) {
18
120
  && checkpoint.run_id !== state.run_id
19
121
  );
20
122
 
123
+ const action = deriveRecommendedContinuityAction(state);
124
+ const drift = deriveCheckpointDrift(root, checkpoint, staleCheckpoint);
125
+
21
126
  return {
22
127
  checkpoint,
23
128
  stale_checkpoint: staleCheckpoint,
24
129
  recovery_report_path: recoveryReportPath,
25
- restart_recommended: !!state && !['blocked', 'completed', 'failed'].includes(state.status),
130
+ restart_recommended: action.restart_recommended,
131
+ recommended_command: action.recommended_command,
132
+ recommended_reason: action.recommended_reason,
133
+ recommended_detail: action.recommended_detail,
134
+ drift_detected: drift.drift_detected,
135
+ drift_warnings: drift.drift_warnings,
26
136
  };
27
137
  }
@@ -1352,6 +1352,11 @@ export function markRunBlocked(root, details) {
1352
1352
 
1353
1353
  writeState(root, updatedState);
1354
1354
 
1355
+ // Session checkpoint — non-fatal, written after blocked state is persisted
1356
+ writeSessionCheckpoint(root, updatedState, 'blocked', {
1357
+ role: turnId ? (getActiveTurns(updatedState)[turnId]?.assigned_role || null) : null,
1358
+ });
1359
+
1355
1360
  emitBlockedNotification(root, details.notificationConfig, updatedState, {
1356
1361
  category: details.category,
1357
1362
  blockedOn: details.blockedOn,
@@ -1753,6 +1758,13 @@ export function assignGovernedTurn(root, config, roleId) {
1753
1758
  };
1754
1759
 
1755
1760
  writeState(root, updatedState);
1761
+
1762
+ // Session checkpoint — non-fatal, written after every successful turn assignment
1763
+ writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
1764
+ role: roleId,
1765
+ dispatch_dir: `.agentxchain/dispatch/turns/${turnId}`,
1766
+ });
1767
+
1756
1768
  const assignedTurn = updatedState.active_turns[turnId];
1757
1769
  const result = { ok: true, state: attachLegacyCurrentTurnAlias(updatedState), turn: assignedTurn };
1758
1770
  if (warnings.length > 0) {
@@ -2,9 +2,10 @@
2
2
  * Session checkpoint — automatic state markers for cross-session restart.
3
3
  *
4
4
  * Writes .agentxchain/session.json at every governance boundary
5
- * (turn acceptance, phase transition, gate approval, run completion)
6
- * so that `agentxchain restart` can reconstruct dispatch context
7
- * without any in-memory session state.
5
+ * (turn assignment, acceptance, phase transition, blocked state,
6
+ * gate approval, run completion, restart/reconnect) so that
7
+ * `agentxchain restart` can reconstruct dispatch context without
8
+ * any in-memory session state.
8
9
  *
9
10
  * Design rules:
10
11
  * - Checkpoint writes are non-fatal: failures log a warning and do not
@@ -12,11 +13,13 @@
12
13
  * - The file is always overwritten, not appended.
13
14
  * - run_id in session.json must agree with state.json; mismatch is a
14
15
  * corruption signal.
16
+ * - state.json is always authoritative; session.json is recovery metadata.
15
17
  */
16
18
 
17
19
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
18
20
  import { join, dirname } from 'path';
19
21
  import { randomBytes } from 'crypto';
22
+ import { execSync as shellExec } from 'child_process';
20
23
 
21
24
  const SESSION_PATH = '.agentxchain/session.json';
22
25
 
@@ -27,6 +30,25 @@ function generateSessionId() {
27
30
  return `session_${randomBytes(8).toString('hex')}`;
28
31
  }
29
32
 
33
+ /**
34
+ * Capture git baseline ref for repo-drift detection.
35
+ * Non-fatal: returns partial/null fields on failure.
36
+ */
37
+ export function captureBaselineRef(root) {
38
+ try {
39
+ const gitHead = shellExec('git rev-parse HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
40
+ const gitBranch = shellExec('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
41
+ const statusOutput = shellExec('git status --porcelain', { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
42
+ return {
43
+ git_head: gitHead,
44
+ git_branch: gitBranch,
45
+ workspace_dirty: statusOutput.length > 0,
46
+ };
47
+ } catch {
48
+ return { git_head: null, git_branch: null, workspace_dirty: null };
49
+ }
50
+ }
51
+
30
52
  /**
31
53
  * Read the current session checkpoint, or null if none exists.
32
54
  */
@@ -40,12 +62,21 @@ export function readSessionCheckpoint(root) {
40
62
  }
41
63
  }
42
64
 
65
+ /**
66
+ * Extract active turn IDs from governed state.
67
+ */
68
+ function getActiveTurnIds(state) {
69
+ const turns = state.active_turns || {};
70
+ return Object.keys(turns);
71
+ }
72
+
43
73
  /**
44
74
  * Write or update the session checkpoint.
45
75
  *
46
76
  * @param {string} root - project root
47
77
  * @param {object} state - current governed state (from state.json)
48
- * @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
78
+ * @param {string} reason - checkpoint reason (e.g. 'turn_assigned', 'turn_accepted',
79
+ * 'phase_approved', 'run_completed', 'blocked', 'restart_reconnect')
49
80
  * @param {object} [extra] - optional extra context fields
50
81
  */
51
82
  export function writeSessionCheckpoint(root, state, reason, extra = {}) {
@@ -58,20 +89,40 @@ export function writeSessionCheckpoint(root, state, reason, extra = {}) {
58
89
  : generateSessionId();
59
90
 
60
91
  const currentTurn = state.current_turn || null;
61
- const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
92
+ const activeTurnIds = getActiveTurnIds(state);
93
+ const lastTurnId = currentTurn?.id || currentTurn?.turn_id
94
+ || (activeTurnIds.length > 0 ? activeTurnIds[activeTurnIds.length - 1] : null)
95
+ || state.last_completed_turn_id || null;
62
96
  const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
63
97
  const lastPhase = state.current_phase || state.phase || null;
64
98
 
99
+ // Derive last_completed_turn_id from history or state
100
+ const lastCompletedTurnId = state.last_completed_turn_id || existing?.last_completed_turn_id || null;
101
+
102
+ // Derive pending gates
103
+ const pendingGate = state.pending_phase_transition?.gate || state.pending_transition?.gate || null;
104
+ const pendingRunCompletion = state.pending_run_completion?.gate || null;
105
+
106
+ // Capture git baseline for repo-drift detection
107
+ const baselineRef = extra.baseline_ref || captureBaselineRef(root);
108
+
65
109
  const checkpoint = {
66
110
  session_id: sessionId,
67
111
  run_id: state.run_id,
68
112
  started_at: existing?.started_at || new Date().toISOString(),
69
113
  last_checkpoint_at: new Date().toISOString(),
114
+ checkpoint_reason: reason,
115
+ run_status: state.status || null,
116
+ phase: lastPhase,
117
+ last_phase: lastPhase, // backward compat alias for report.js consumers
70
118
  last_turn_id: lastTurnId,
71
- last_phase: lastPhase,
119
+ last_completed_turn_id: lastCompletedTurnId,
120
+ active_turn_ids: activeTurnIds,
72
121
  last_role: lastRole,
73
- run_status: state.status || null,
74
- checkpoint_reason: reason,
122
+ pending_gate: pendingGate,
123
+ pending_run_completion: pendingRunCompletion ? true : null,
124
+ blocked: state.status === 'blocked',
125
+ baseline_ref: baselineRef,
75
126
  agent_context: {
76
127
  adapter: extra.adapter || null,
77
128
  dispatch_dir: extra.dispatch_dir || null,