@aiwg/cli 2026.8.3 → 2026.8.4

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.
@@ -14,10 +14,11 @@
14
14
  import * as ui from '../ui.js';
15
15
  import { promises as fs } from 'node:fs';
16
16
  import { join } from 'node:path';
17
- import { randomBytes } from 'node:crypto';
17
+ import { createHash, randomBytes } from 'node:crypto';
18
18
  // ── Constants ────────────────────────────────────────────────
19
19
  const MC_ROOT = '.aiwg/ralph-external/mc';
20
20
  const SESSIONS_DIR = join(MC_ROOT, 'sessions');
21
+ const CONTROL_ID_RE = /^[a-zA-Z0-9._-]+$/;
21
22
  // ── Helpers ──────────────────────────────────────────────────
22
23
  function genId(prefix) {
23
24
  return `${prefix}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
@@ -26,6 +27,8 @@ async function ensureDir(dir) {
26
27
  await fs.mkdir(dir, { recursive: true });
27
28
  }
28
29
  async function readSession(sessionId) {
30
+ if (!CONTROL_ID_RE.test(sessionId))
31
+ return null;
29
32
  const path = join(SESSIONS_DIR, sessionId, 'session.json');
30
33
  try {
31
34
  const raw = await fs.readFile(path, 'utf-8');
@@ -35,17 +38,88 @@ async function readSession(sessionId) {
35
38
  return null;
36
39
  }
37
40
  }
38
- async function writeSession(session) {
41
+ async function writeSession(session, { touch = true } = {}) {
39
42
  const dir = join(SESSIONS_DIR, session.id);
40
43
  await ensureDir(dir);
41
- session.updatedAt = new Date().toISOString();
42
- await fs.writeFile(join(dir, 'session.json'), JSON.stringify(session, null, 2));
44
+ if (touch)
45
+ session.updatedAt = new Date().toISOString();
46
+ const destination = join(dir, 'session.json');
47
+ const temporary = join(dir, `.session.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
48
+ await fs.writeFile(temporary, JSON.stringify(session, null, 2));
49
+ await fs.rename(temporary, destination);
43
50
  }
44
51
  async function appendLog(sessionId, event) {
45
52
  const logPath = join(SESSIONS_DIR, sessionId, 'log.jsonl');
46
53
  const entry = JSON.stringify({ ...event, ts: new Date().toISOString() });
47
54
  await fs.appendFile(logPath, entry + '\n');
48
55
  }
56
+ /**
57
+ * Apply one durable Mission Control mutation under a cross-process lock.
58
+ * `expectedUpdatedAt` provides optimistic concurrency and `requestId` makes
59
+ * retries idempotent across CLI/Cockpit reconnects.
60
+ */
61
+ async function mutateSession(sessionId, action, target, expectedUpdatedAt, requestId, mutate) {
62
+ if (!CONTROL_ID_RE.test(sessionId) || !CONTROL_ID_RE.test(target)) {
63
+ return { ok: false, code: 'target_not_found', message: 'invalid Mission control identifier' };
64
+ }
65
+ const dir = join(SESSIONS_DIR, sessionId);
66
+ await ensureDir(dir);
67
+ const lockPath = join(dir, '.control.lock');
68
+ let lock;
69
+ try {
70
+ lock = await fs.open(lockPath, 'wx', 0o600);
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'EEXIST') {
74
+ return { ok: false, code: 'mission_conflict', message: 'another Mission control mutation is in progress' };
75
+ }
76
+ throw error;
77
+ }
78
+ try {
79
+ const session = await readSession(sessionId);
80
+ if (!session)
81
+ return { ok: false, code: 'session_not_found', message: `Session not found: ${sessionId}` };
82
+ if (expectedUpdatedAt && session.updatedAt !== expectedUpdatedAt) {
83
+ return { ok: false, code: 'mission_conflict', message: `expected revision ${expectedUpdatedAt}; current revision is ${session.updatedAt}` };
84
+ }
85
+ if (requestId && session.mutationKeys?.[requestId]) {
86
+ const prior = session.mutationKeys[requestId];
87
+ if (prior.action !== action || prior.target !== target) {
88
+ return { ok: false, code: 'mission_conflict', message: `request id '${requestId}' was already used for another mutation` };
89
+ }
90
+ return { ok: true, session, replayed: true };
91
+ }
92
+ const errorCode = mutate(session);
93
+ if (errorCode === 'target_not_found')
94
+ return { ok: false, code: errorCode, message: `Mission not found: ${target}` };
95
+ if (errorCode === 'invalid_state')
96
+ return { ok: false, code: errorCode, message: `Mutation '${action}' is invalid from state '${session.state}'` };
97
+ if (errorCode === 'capacity')
98
+ return { ok: false, code: errorCode, message: `Session at capacity (${session.maxMissions} missions)` };
99
+ const nextUpdatedAt = new Date().toISOString();
100
+ session.updatedAt = nextUpdatedAt;
101
+ if (requestId) {
102
+ session.mutationKeys = { ...(session.mutationKeys ?? {}), [requestId]: { action, target, updatedAt: nextUpdatedAt } };
103
+ }
104
+ await writeSession(session, { touch: false });
105
+ await appendLog(session.id, { event: `control_${action}`, target, requestId: requestId ?? null, replayed: false, revision: session.updatedAt });
106
+ return { ok: true, session, replayed: false };
107
+ }
108
+ finally {
109
+ await lock.close();
110
+ await fs.unlink(lockPath).catch(() => undefined);
111
+ }
112
+ }
113
+ function mutationFlags(args) {
114
+ return {
115
+ expectedUpdatedAt: parseFlag(args, '--expected-updated-at'),
116
+ requestId: parseFlag(args, '--request-id'),
117
+ };
118
+ }
119
+ function mutationFailure(result) {
120
+ ui.error(`${result.code}: ${result.message}`);
121
+ return { exitCode: result.code === 'mission_conflict' ? 3 : 1, message: `${result.code}: ${result.message}` };
122
+ }
49
123
  async function listSessions() {
50
124
  try {
51
125
  const entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true });
@@ -142,7 +216,9 @@ Queue a mission onto a session. Does NOT execute — use 'aiwg mc run' to launch
142
216
  (off unless declared; no default K)
143
217
  --budget-stop-policy P completion-wins (default) | budget-wins
144
218
  --mode pty-orchestrator PTY-orchestrator mode (requires --target-agent)
145
- --target-agent <id> Required for --mode pty-orchestrator`,
219
+ --target-agent <id> Required for --mode pty-orchestrator
220
+ --expected-updated-at <time> Reject stale state with exit 3
221
+ --request-id <id> Idempotent dispatch key`,
146
222
  run: `Usage: aiwg mc run [<session-id>] [--accept-cost]
147
223
 
148
224
  Drain queued missions in a session by launching each as a ralph loop. Missions
@@ -160,13 +236,22 @@ Show mission status for a session. Auto-syncs from ralph loop state files.
160
236
  Live-monitor mission progress (non-interactive context prints status once).`,
161
237
  abort: `Usage: aiwg mc abort <session-id> <mission-id>
162
238
 
163
- Mark a specific mission as aborted.`,
239
+ Mark a specific mission as aborted.
240
+
241
+ --expected-updated-at <timestamp> Reject stale state with exit 3
242
+ --request-id <id> Idempotent mutation key`,
164
243
  pause: `Usage: aiwg mc pause [<session-id>]
165
244
 
166
- Pause an active session; running missions transition to 'paused' status.`,
245
+ Pause an active session; running missions transition to 'paused' status.
246
+
247
+ --expected-updated-at <timestamp> Reject stale state with exit 3
248
+ --request-id <id> Idempotent mutation key`,
167
249
  resume: `Usage: aiwg mc resume [<session-id>]
168
250
 
169
- Resume a paused session; paused missions transition back to 'running'.`,
251
+ Resume a paused session; paused missions transition back to 'running'.
252
+
253
+ --expected-updated-at <timestamp> Reject stale state with exit 3
254
+ --request-id <id> Idempotent mutation key`,
170
255
  stop: `Usage: aiwg mc stop [<session-id>] [--drain]
171
256
 
172
257
  Shut down a session.
@@ -289,15 +374,11 @@ async function mcDispatch(ctx) {
289
374
  ui.error('--mode pty-orchestrator requires --target-agent <agent-id>');
290
375
  return { exitCode: 1 };
291
376
  }
292
- const session = await findActiveSession(sessionId);
293
- if (!session) {
377
+ const selected = await findActiveSession(sessionId);
378
+ if (!selected) {
294
379
  ui.error(sessionId ? `Session not found: ${sessionId}` : 'No active session. Run `aiwg mc start` first.');
295
380
  return { exitCode: 1 };
296
381
  }
297
- if (session.missions.length >= session.maxMissions) {
298
- ui.error(`Session at capacity (${session.maxMissions} missions). Increase with --max-missions or stop completed missions.`);
299
- return { exitCode: 1 };
300
- }
301
382
  // #1361: Check project-level parallelism cap. Active missions = running or
302
383
  // queued; if at cap, warn but still queue (FIFO behavior — the mission goes
303
384
  // into the session as 'queued' and will run when a slot frees up).
@@ -307,7 +388,7 @@ async function mcDispatch(ctx) {
307
388
  const cfg = await readAiwgConfig(ctx.cwd || process.cwd());
308
389
  if (cfg) {
309
390
  const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]);
310
- const activeCount = session.missions.filter(m => m.status === 'running' || m.status === 'queued' || m.status === 'paused').length;
391
+ const activeCount = selected.missions.filter(m => m.status === 'running' || m.status === 'queued' || m.status === 'paused').length;
311
392
  if (activeCount >= resolved.max_parallel_mc_missions) {
312
393
  capWarning = `Active missions (${activeCount}) at or above project parallelism cap (${resolved.max_parallel_mc_missions}). Mission will queue; bump via 'aiwg config set --project parallelism.max_parallel_mc_missions N'.`;
313
394
  }
@@ -316,8 +397,11 @@ async function mcDispatch(ctx) {
316
397
  catch {
317
398
  // Non-fatal — config read failure doesn't block dispatch
318
399
  }
400
+ const flags = mutationFlags(ctx.args);
319
401
  const mission = {
320
- id: genId('m'),
402
+ id: flags.requestId
403
+ ? `m-${createHash('sha256').update(flags.requestId).digest('hex').slice(0, 16)}`
404
+ : genId('m'),
321
405
  objective,
322
406
  completion,
323
407
  status: 'queued',
@@ -334,27 +418,38 @@ async function mcDispatch(ctx) {
334
418
  mode,
335
419
  targetAgent: targetAgent || undefined,
336
420
  };
337
- session.missions.push(mission);
338
- await writeSession(session);
339
- await appendLog(session.id, {
340
- event: 'mission_dispatched',
341
- missionId: mission.id,
342
- objective,
343
- priority,
344
- mode,
345
- targetAgent,
346
- lfdBudgets: {
347
- maxTotalTokens,
348
- maxOutputTokens,
349
- maxToolCalls,
350
- maxTotalCost,
351
- maxWallClockMinutes,
352
- explorationQuota,
353
- },
421
+ const result = await mutateSession(selected.id, 'dispatch', mission.id, flags.expectedUpdatedAt, flags.requestId, session => {
422
+ if (session.state !== 'active')
423
+ return 'invalid_state';
424
+ if (session.missions.length >= session.maxMissions)
425
+ return 'capacity';
426
+ session.missions.push(mission);
427
+ return undefined;
354
428
  });
429
+ if (!result.ok)
430
+ return mutationFailure(result);
431
+ if (!result.replayed) {
432
+ await appendLog(selected.id, {
433
+ event: 'mission_dispatched',
434
+ missionId: mission.id,
435
+ objective,
436
+ priority,
437
+ mode,
438
+ targetAgent,
439
+ requestId: flags.requestId ?? null,
440
+ lfdBudgets: {
441
+ maxTotalTokens,
442
+ maxOutputTokens,
443
+ maxToolCalls,
444
+ maxTotalCost,
445
+ maxWallClockMinutes,
446
+ explorationQuota,
447
+ },
448
+ });
449
+ }
355
450
  if (capWarning)
356
451
  ui.warn(capWarning);
357
- ui.success(`Dispatched mission ${mission.id}: ${objective}`);
452
+ ui.success(`${result.replayed ? 'Replayed dispatch for' : 'Dispatched mission'} ${mission.id}: ${objective}`);
358
453
  const modeLabel = mode === 'pty-orchestrator' ? ` | Mode: PTY orchestrator → ${targetAgent}` : '';
359
454
  ui.info(`Priority: ${priority} | Max iterations: ${maxIterations}${modeLabel}`);
360
455
  const lfdLimits = [
@@ -370,7 +465,7 @@ async function mcDispatch(ctx) {
370
465
  }
371
466
  // #1439: dispatch alone does NOT execute the mission. Surface the next step
372
467
  // so the user knows the queue won't drain on its own.
373
- ui.info(`Next: run \`aiwg mc run ${session.id}\` to launch queued missions as ralph loops.`);
468
+ ui.info(`Next: run \`aiwg mc run ${selected.id}\` to launch queued missions as ralph loops.`);
374
469
  return { exitCode: 0, message: mission.id };
375
470
  }
376
471
  /**
@@ -445,6 +540,18 @@ async function mcRun(ctx) {
445
540
  let failed = 0;
446
541
  const projectRoot = ctx.cwd || process.cwd();
447
542
  const frameworkRoot = ctx.frameworkRoot;
543
+ const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
544
+ const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
545
+ const cfg = await readAiwgConfig(projectRoot).catch(() => null);
546
+ const provider = cfg?.providers[0] ?? 'unknown';
547
+ const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
548
+ const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), {
549
+ maxConcurrent,
550
+ leaseTtlMs: 5 * 60_000,
551
+ agingIntervalMs: 30_000,
552
+ allowPreemption: false,
553
+ defaultHostQuota: maxConcurrent,
554
+ });
448
555
  for (const mission of queued) {
449
556
  if (mission.mode === 'pty-orchestrator') {
450
557
  ui.warn(`Mission ${mission.id} mode=pty-orchestrator is not yet wired to mc run; skipping. Use 'aiwg ralph' directly for PTY-orchestrator workflows.`);
@@ -456,6 +563,40 @@ async function mcRun(ctx) {
456
563
  skipped += 1;
457
564
  continue;
458
565
  }
566
+ const admissionRequestId = mission.admissionState === 'released'
567
+ ? `${session.id}.${mission.id}.${Date.now().toString(36)}`
568
+ : mission.admissionRequestId ?? `${session.id}.${mission.id}`;
569
+ const admissionSubmittedAt = mission.admissionState === 'released'
570
+ ? new Date().toISOString()
571
+ : mission.admissionSubmittedAt ?? new Date().toISOString();
572
+ const admission = scheduler.submit({
573
+ requestId: admissionRequestId,
574
+ orchestratorId: session.id,
575
+ environment: process.env.AIWG_ENVIRONMENT ?? 'default',
576
+ provider,
577
+ runtimeKind: 'host',
578
+ priority: mission.priority === 'critical' ? 100 : mission.priority === 'high' ? 50 : mission.priority === 'low' ? 0 : 10,
579
+ submittedAt: admissionSubmittedAt,
580
+ queueTimeoutMs: 24 * 60 * 60_000,
581
+ preemptible: false,
582
+ metadata: { missionId: mission.id },
583
+ });
584
+ mission.admissionRequestId = admissionRequestId;
585
+ mission.admissionSubmittedAt = admissionSubmittedAt;
586
+ mission.admissionState = admission.state;
587
+ mission.admissionLeaseExpiresAt = admission.leaseExpiresAt;
588
+ await writeSession(session);
589
+ if (admission.state !== 'admitted') {
590
+ await appendLog(session.id, {
591
+ event: 'mission_admission_queued',
592
+ missionId: mission.id,
593
+ admissionRequestId,
594
+ reason: admission.reason,
595
+ });
596
+ ui.info(`Queued ${mission.id}: ${admission.reason}`);
597
+ skipped += 1;
598
+ continue;
599
+ }
459
600
  try {
460
601
  const result = await launchExternalRalph(frameworkRoot, projectRoot, {
461
602
  objective: mission.objective,
@@ -490,6 +631,9 @@ async function mcRun(ctx) {
490
631
  mission.status = 'failed';
491
632
  mission.error = msg;
492
633
  mission.completedAt = new Date().toISOString();
634
+ scheduler.release(admissionRequestId);
635
+ mission.admissionState = 'released';
636
+ mission.admissionLeaseExpiresAt = undefined;
493
637
  await writeSession(session);
494
638
  await appendLog(session.id, {
495
639
  event: 'mission_launch_failed',
@@ -522,6 +666,12 @@ async function mcRun(ctx) {
522
666
  */
523
667
  async function syncMissionsFromRalph(session, projectRoot) {
524
668
  let mutated = false;
669
+ const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
670
+ const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
671
+ const cfg = await readAiwgConfig(projectRoot).catch(() => null);
672
+ const provider = cfg?.providers[0] ?? 'unknown';
673
+ const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
674
+ const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), { maxConcurrent, leaseTtlMs: 5 * 60_000, agingIntervalMs: 30_000, allowPreemption: false, defaultHostQuota: maxConcurrent });
525
675
  for (const mission of session.missions) {
526
676
  if (mission.status !== 'running')
527
677
  continue;
@@ -560,6 +710,25 @@ async function syncMissionsFromRalph(session, projectRoot) {
560
710
  mission.error = state.error;
561
711
  }
562
712
  mutated = true;
713
+ if (mission.admissionRequestId) {
714
+ try {
715
+ scheduler.release(mission.admissionRequestId);
716
+ }
717
+ catch { /* already expired or released */ }
718
+ mission.admissionState = 'released';
719
+ mission.admissionLeaseExpiresAt = undefined;
720
+ }
721
+ }
722
+ else if (mission.admissionRequestId) {
723
+ try {
724
+ const renewed = scheduler.renew(mission.admissionRequestId);
725
+ mission.admissionState = renewed.state;
726
+ mission.admissionLeaseExpiresAt = renewed.leaseExpiresAt;
727
+ mutated = true;
728
+ }
729
+ catch {
730
+ // An expired lease is reconciled on the next run; status remains best-effort.
731
+ }
563
732
  }
564
733
  }
565
734
  catch {
@@ -662,58 +831,85 @@ async function mcAbort(ctx) {
662
831
  ui.error('Usage: aiwg mc abort <session-id> <mission-id>');
663
832
  return { exitCode: 1 };
664
833
  }
665
- const session = await readSession(sessionId);
666
- if (!session) {
667
- ui.error(`Session not found: ${sessionId}`);
668
- return { exitCode: 1 };
669
- }
670
- const mission = session.missions.find(m => m.id === missionId);
671
- if (!mission) {
672
- ui.error(`Mission not found: ${missionId}`);
673
- return { exitCode: 1 };
834
+ const flags = mutationFlags(ctx.args);
835
+ const result = await mutateSession(sessionId, 'cancel', missionId, flags.expectedUpdatedAt, flags.requestId, session => {
836
+ const mission = session.missions.find(m => m.id === missionId);
837
+ if (!mission)
838
+ return 'target_not_found';
839
+ if (mission.status === 'done' || mission.status === 'failed' || mission.status === 'aborted')
840
+ return 'invalid_state';
841
+ mission.status = 'aborted';
842
+ mission.completedAt = new Date().toISOString();
843
+ return undefined;
844
+ });
845
+ if (!result.ok)
846
+ return mutationFailure(result);
847
+ if (!result.replayed) {
848
+ const mission = result.session.missions.find(candidate => candidate.id === missionId);
849
+ if (mission?.admissionRequestId) {
850
+ try {
851
+ const projectRoot = ctx.cwd || process.cwd();
852
+ const { readAiwgConfig, resolveParallelism } = await import('../../config/aiwg-config.js');
853
+ const { FileAdmissionStore, SharedHostScheduler } = await import('../../serve/shared-host-scheduler.js');
854
+ const cfg = await readAiwgConfig(projectRoot).catch(() => null);
855
+ const provider = cfg?.providers[0] ?? 'unknown';
856
+ const maxConcurrent = resolveParallelism(cfg?.parallelism, provider).max_parallel_mc_missions;
857
+ const scheduler = new SharedHostScheduler(new FileAdmissionStore(join(projectRoot, MC_ROOT, 'admission.json')), { maxConcurrent, leaseTtlMs: 5 * 60_000, agingIntervalMs: 30_000, allowPreemption: false, defaultHostQuota: maxConcurrent });
858
+ scheduler.cancel(mission.admissionRequestId);
859
+ }
860
+ catch {
861
+ // The mission state is authoritative; lease expiry provides recovery.
862
+ }
863
+ }
674
864
  }
675
- mission.status = 'aborted';
676
- mission.completedAt = new Date().toISOString();
677
- await writeSession(session);
678
- await appendLog(session.id, { event: 'mission_aborted', missionId });
679
- ui.success(`Aborted mission: ${missionId}`);
680
- return { exitCode: 0 };
865
+ ui.success(`${result.replayed ? 'Replayed' : 'Aborted'} mission: ${missionId}`);
866
+ return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
681
867
  }
682
868
  async function mcPause(ctx) {
683
869
  const positional = getPositionalArgs(ctx.args);
684
870
  const sessionId = positional[0];
685
- const session = await findActiveSession(sessionId);
686
- if (!session) {
871
+ const selected = await findActiveSession(sessionId);
872
+ if (!selected) {
687
873
  ui.error('No active session to pause.');
688
874
  return { exitCode: 1 };
689
875
  }
690
- session.state = 'paused';
691
- for (const m of session.missions) {
692
- if (m.status === 'running')
693
- m.status = 'paused';
694
- }
695
- await writeSession(session);
696
- await appendLog(session.id, { event: 'session_paused' });
697
- ui.success(`Paused session: ${session.id}`);
698
- return { exitCode: 0 };
876
+ const flags = mutationFlags(ctx.args);
877
+ const result = await mutateSession(selected.id, 'pause', selected.id, flags.expectedUpdatedAt, flags.requestId, session => {
878
+ if (session.state !== 'active')
879
+ return 'invalid_state';
880
+ session.state = 'paused';
881
+ for (const mission of session.missions)
882
+ if (mission.status === 'running')
883
+ mission.status = 'paused';
884
+ return undefined;
885
+ });
886
+ if (!result.ok)
887
+ return mutationFailure(result);
888
+ ui.success(`${result.replayed ? 'Replayed pause for' : 'Paused'} session: ${selected.id}`);
889
+ return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
699
890
  }
700
891
  async function mcResume(ctx) {
701
892
  const positional = getPositionalArgs(ctx.args);
702
893
  const sessionId = positional[0];
703
- const session = await findActiveSession(sessionId);
704
- if (!session || session.state !== 'paused') {
894
+ const selected = await findActiveSession(sessionId);
895
+ if (!selected || selected.state !== 'paused') {
705
896
  ui.error('No paused session to resume.');
706
897
  return { exitCode: 1 };
707
898
  }
708
- session.state = 'active';
709
- for (const m of session.missions) {
710
- if (m.status === 'paused')
711
- m.status = 'running';
712
- }
713
- await writeSession(session);
714
- await appendLog(session.id, { event: 'session_resumed' });
715
- ui.success(`Resumed session: ${session.id}`);
716
- return { exitCode: 0 };
899
+ const flags = mutationFlags(ctx.args);
900
+ const result = await mutateSession(selected.id, 'resume', selected.id, flags.expectedUpdatedAt, flags.requestId, session => {
901
+ if (session.state !== 'paused')
902
+ return 'invalid_state';
903
+ session.state = 'active';
904
+ for (const mission of session.missions)
905
+ if (mission.status === 'paused')
906
+ mission.status = 'running';
907
+ return undefined;
908
+ });
909
+ if (!result.ok)
910
+ return mutationFailure(result);
911
+ ui.success(`${result.replayed ? 'Replayed resume for' : 'Resumed'} session: ${selected.id}`);
912
+ return { exitCode: 0, message: JSON.stringify({ ok: true, replayed: result.replayed, updated_at: result.session.updatedAt }) };
717
913
  }
718
914
  async function mcStop(ctx) {
719
915
  const positional = getPositionalArgs(ctx.args);
@@ -912,6 +1108,7 @@ const subcommands = {
912
1108
  status: mcStatus,
913
1109
  watch: mcWatch,
914
1110
  abort: mcAbort,
1111
+ cancel: mcAbort,
915
1112
  pause: mcPause,
916
1113
  resume: mcResume,
917
1114
  stop: mcStop,
@@ -945,7 +1142,7 @@ function showMcHelp() {
945
1142
  Cost gate warns/refuses above ~$5 estimate
946
1143
  status [<id>] [--json] View mission status dashboard
947
1144
  watch [<id>] Live monitor (streaming)
948
- abort <session> <mission> Abort a specific mission
1145
+ abort|cancel <session> <mission> Abort a specific mission
949
1146
  pause [<id>] Pause active session
950
1147
  resume [<id>] Resume paused session
951
1148
  stop [<id>] [--drain] Shut down session
@@ -34,9 +34,12 @@ import * as path from 'node:path';
34
34
  * Per the script-source comments at agentic/code/addons/aiwg-hooks/hooks/.
35
35
  */
36
36
  const HOOK_SCRIPTS = [
37
- { file: 'aiwg-permissions.cjs', events: ['PermissionRequest'] },
38
- { file: 'aiwg-session.cjs', events: ['SessionStart'] },
39
- { file: 'aiwg-trace.cjs', events: ['SubagentStart', 'SubagentStop'] },
37
+ { file: 'aiwg-permissions.cjs', events: { PermissionRequest: undefined } },
38
+ { file: 'aiwg-session.cjs', events: { SessionStart: undefined } },
39
+ {
40
+ file: 'aiwg-trace.cjs',
41
+ events: { SubagentStart: 'start', SubagentStop: 'stop' },
42
+ },
40
43
  ];
41
44
  /**
42
45
  * Migrate a legacy array-shaped `hooks` field to the object form Claude
@@ -185,9 +188,10 @@ export async function installAiwgHooks(opts) {
185
188
  // embedded script path POSIX-style on every host: native Windows
186
189
  // backslashes are interpreted as escapes and collapse into a nonexistent
187
190
  // path such as `.claudehooksaiwg-session.cjs` (#133).
188
- const command = `node ${path.posix.join('.claude', 'hooks', file)}`;
191
+ const script = path.posix.join('.claude', 'hooks', file);
189
192
  const hookId = file.replace(/\.(cjs|js)$/, '');
190
- for (const event of events) {
193
+ for (const [event, argument] of Object.entries(events)) {
194
+ const command = `node ${script}${argument ? ` ${argument}` : ''}`;
191
195
  if (!hooksObj[event])
192
196
  hooksObj[event] = [];
193
197
  const groups = hooksObj[event];
@@ -47,6 +47,8 @@ Server Options (for add/update):
47
47
  --args <a1,a2,...> Command arguments (comma-separated, for stdio)
48
48
  --env <K=V,...> Environment variables (comma-separated K=V pairs)
49
49
  --headers <K=V,...> HTTP headers (comma-separated K=V pairs)
50
+ --header-env <K=ENV,...>
51
+ Resolve HTTP header values from environment variables
50
52
  --description <text> Optional description
51
53
 
52
54
  Inject Options:
@@ -62,6 +64,8 @@ Serve Options:
62
64
  Examples:
63
65
  # Define MCP servers
64
66
  aiwg mcp add fortemi --url https://memory.s9.internal/mcp --type http
67
+ aiwg mcp add fortemi-enterprise --url https://memory.example.internal/mcp --type http \
68
+ --header-env Authorization=AIWG_FORTEMI_TOKEN
65
69
  aiwg mcp add gitea --url https://mcp-gitea.integrolabs.net/mcp
66
70
  aiwg mcp add mytools --type stdio --command npx --args mcp-server-mytools
67
71
 
@@ -498,6 +502,7 @@ async function handleAdd(args) {
498
502
  const argsStr = parseFlag(args, '--args');
499
503
  const envStr = parseFlag(args, '--env');
500
504
  const headersStr = parseFlag(args, '--headers');
505
+ const headerEnvStr = parseFlag(args, '--header-env');
501
506
  const description = parseFlag(args, '--description');
502
507
 
503
508
  if (type === 'stdio' && !command) {
@@ -518,6 +523,7 @@ async function handleAdd(args) {
518
523
  args: argsStr ? argsStr.split(',') : undefined,
519
524
  env: parseKVPairs(envStr),
520
525
  headers: parseKVPairs(headersStr),
526
+ headerEnv: parseKVPairs(headerEnvStr),
521
527
  description,
522
528
  });
523
529
 
@@ -564,6 +570,7 @@ async function handleUpdate(args) {
564
570
  const argsStr = parseFlag(args, '--args');
565
571
  const envStr = parseFlag(args, '--env');
566
572
  const headersStr = parseFlag(args, '--headers');
573
+ const headerEnvStr = parseFlag(args, '--header-env');
567
574
  const description = parseFlag(args, '--description');
568
575
 
569
576
  if (url !== undefined) updates.url = url;
@@ -572,6 +579,7 @@ async function handleUpdate(args) {
572
579
  if (argsStr !== undefined) updates.args = argsStr.split(',');
573
580
  if (envStr !== undefined) updates.env = parseKVPairs(envStr);
574
581
  if (headersStr !== undefined) updates.headers = parseKVPairs(headersStr);
582
+ if (headerEnvStr !== undefined) updates.headerEnv = parseKVPairs(headerEnvStr);
575
583
  if (description !== undefined) updates.description = description;
576
584
 
577
585
  if (Object.keys(updates).length === 0) {
@@ -608,6 +616,10 @@ async function handleList() {
608
616
  console.log(` Type: ${server.type}`);
609
617
  if (server.url) console.log(` URL: ${server.url}`);
610
618
  if (server.command) console.log(` Command: ${server.command}${server.args ? ' ' + server.args.join(' ') : ''}`);
619
+ if (server.headerEnv) {
620
+ const refs = Object.entries(server.headerEnv).map(([header, envName]) => `${header}←${envName}`);
621
+ console.log(` Credential refs: ${refs.join(', ')}`);
622
+ }
611
623
  if (server.description) console.log(` Description: ${server.description}`);
612
624
  if (server.injectedProviders && server.injectedProviders.length > 0) {
613
625
  console.log(` Injected into: ${server.injectedProviders.join(', ')}`);
@@ -20,6 +20,16 @@ const DEFAULT_REGISTRY = {
20
20
  kind: 'McpServerRegistry',
21
21
  servers: {},
22
22
  };
23
+ const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ function validateCredentialReferences(def) {
25
+ for (const [header, envName] of Object.entries(def.headerEnv ?? {})) {
26
+ if (!header.trim())
27
+ throw new Error('MCP header-env header name must not be empty');
28
+ if (!ENV_REFERENCE_NAME.test(envName)) {
29
+ throw new Error(`Invalid MCP header environment variable reference "${envName}"`);
30
+ }
31
+ }
32
+ }
23
33
  export class McpServerRegistry {
24
34
  configDir;
25
35
  cache = null;
@@ -59,6 +69,7 @@ export class McpServerRegistry {
59
69
  }
60
70
  /** Add a new MCP server definition */
61
71
  async add(def) {
72
+ validateCredentialReferences(def);
62
73
  const data = await this.load();
63
74
  if (data.servers[def.name]) {
64
75
  throw new Error(`Server "${def.name}" already exists. Use "update" to modify it.`);
@@ -86,12 +97,14 @@ export class McpServerRegistry {
86
97
  if (!data.servers[name]) {
87
98
  throw new Error(`Server "${name}" not found.`);
88
99
  }
89
- data.servers[name] = {
100
+ const next = {
90
101
  ...data.servers[name],
91
102
  ...updates,
92
103
  name, // preserve original name
93
104
  updatedAt: new Date().toISOString(),
94
105
  };
106
+ validateCredentialReferences(next);
107
+ data.servers[name] = next;
95
108
  await this.save();
96
109
  }
97
110
  /** Get a specific server definition */