@aiwg/cli 2026.8.2 → 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
@@ -1062,6 +1062,7 @@ export const installPluginHandler = {
1062
1062
  const runner = createScriptRunner(frameworkRoot);
1063
1063
  return runner.run("tools/plugin/plugin-installer-cli.mjs", ctx.args, {
1064
1064
  cwd: ctx.cwd,
1065
+ env: { AIWG_ROOT: frameworkRoot },
1065
1066
  });
1066
1067
  },
1067
1068
  };
@@ -31,7 +31,7 @@ import { PROJECT_LOCAL_TYPE_TO_DIR } from '../../extensions/project-local-paths.
31
31
  import { buildUpstreamRegistry } from '../../extensions/upstream-registry.js';
32
32
  import { resolveShadows, formatShadowReport, } from '../../extensions/shadow-resolver.js';
33
33
  import { appendProjectLocalActivity, emitDiscoverEventsDeduped, } from '../../extensions/project-local-activity.js';
34
- import { hashBundleArtifacts } from '../../extensions/project-local-remove.js';
34
+ import { hashBundleArtifacts, hashDeployedBundleArtifacts, } from '../../extensions/project-local-remove.js';
35
35
  import { installAiwgHooks } from '../../extensions/claude-hooks-installer.js';
36
36
  import { detectScope, mirrorToUserScope, rejectOpenClawProjectScope, USER_SCOPE_PATHS, } from '../scope-resolver.js';
37
37
  import { maybeWarnProjectIsolation } from '../project-isolation/index.js';
@@ -1185,6 +1185,7 @@ async function deployProjectLocalBundles(opts) {
1185
1185
  // #1037 — record per-artifact source hashes so `aiwg remove` can
1186
1186
  // detect pristine vs mutated vs replaced deployed files.
1187
1187
  const artifactHashes = await hashBundleArtifacts(bundle.artifactPath);
1188
+ const deployedArtifactHashes = await hashDeployedBundleArtifacts(projectDir, provider, artifactHashes);
1188
1189
  const updated = updateInstalled(config, bundle.id, provider, result.counts, {
1189
1190
  version: bundle.manifest.version,
1190
1191
  source: 'project-local',
@@ -1193,6 +1194,7 @@ async function deployProjectLocalBundles(opts) {
1193
1194
  localType: bundle.type,
1194
1195
  manifestVersion: bundle.manifest.manifestVersion,
1195
1196
  artifactHashes,
1197
+ deployedArtifactHashes,
1196
1198
  });
1197
1199
  await writeAiwgConfig(projectDir, updated);
1198
1200
  }
@@ -784,6 +784,10 @@ export function updateInstalled(config, name, provider, counts, opts) {
784
784
  existing.manifestVersion = opts.manifestVersion;
785
785
  if (opts.artifactHashes)
786
786
  existing.artifactHashes = opts.artifactHashes;
787
+ if (opts.deployedArtifactHashes) {
788
+ existing.deployedArtifactHashes ??= {};
789
+ existing.deployedArtifactHashes[provider] = opts.deployedArtifactHashes;
790
+ }
787
791
  }
788
792
  else {
789
793
  // Clear stale project-local fields if a previously project-local entry is
@@ -792,6 +796,7 @@ export function updateInstalled(config, name, provider, counts, opts) {
792
796
  delete existing.localType;
793
797
  delete existing.manifestVersion;
794
798
  delete existing.artifactHashes;
799
+ delete existing.deployedArtifactHashes;
795
800
  }
796
801
  config.installed[name] = existing;
797
802
  return config;
@@ -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];
@@ -12,8 +12,7 @@
12
12
  * @design @.aiwg/architecture/design-doctor-log-promote.md
13
13
  * @implements #1037
14
14
  */
15
- import { join, resolve } from 'path';
16
- import { homedir } from 'os';
15
+ import { join } from 'path';
17
16
  import { discoverProjectLocalBundles } from './project-local-discovery.js';
18
17
  import { buildUpstreamRegistry } from './upstream-registry.js';
19
18
  import { resolveShadows } from './shadow-resolver.js';
@@ -22,6 +21,7 @@ import { sha256OfFileRawAndNormalized } from './managed-marker.js';
22
21
  import { projectAiwgPath } from '../config/project-artifacts.js';
23
22
  import { projectRelativePathIfInside } from './project-local-paths.js';
24
23
  import { auditProjectQuickref } from './project-quickref.js';
24
+ import { artifactHashesForProvider, candidateDeployedPaths } from './project-local-remove.js';
25
25
  /**
26
26
  * Hash a deployed file in raw and managed-marker-normalized forms. Returns
27
27
  * null on read errors (e.g., file missing — caller treats as
@@ -48,23 +48,6 @@ const TYPE_DIR = {
48
48
  plugin: 'plugins',
49
49
  provider: 'providers',
50
50
  };
51
- // Per PUW-026 (#1127): home-deploying providers get absolute prefixes so
52
- // `resolve(projectDir, prefix)` correctly produces the home-rooted path
53
- // (resolve treats absolute paths as authoritative). Previously these were
54
- // `null`, which silently skipped lifecycle operations against home-deployed
55
- // project-local bundles.
56
- const PROVIDER_PREFIX = {
57
- claude: '.claude',
58
- cursor: '.cursor',
59
- factory: '.factory',
60
- opencode: '.opencode',
61
- windsurf: '.windsurf',
62
- warp: '.warp',
63
- codex: '.codex',
64
- copilot: '.github',
65
- openclaw: resolve(homedir(), '.openclaw'),
66
- hermes: resolve(homedir(), '.hermes'),
67
- };
68
51
  export async function buildProjectLocalDoctorSection(opts) {
69
52
  const { projectDir, frameworkRoot, config, quiet = false } = opts;
70
53
  const discovery = await discoverProjectLocalBundles(projectDir);
@@ -165,18 +148,19 @@ export async function buildProjectLocalDoctorSection(opts) {
165
148
  const entry = config.installed[bundle.id];
166
149
  if (!entry || entry.source !== 'project-local')
167
150
  continue;
168
- const hashes = entry.artifactHashes;
169
- if (!hashes) {
151
+ if (!entry.artifactHashes && !entry.deployedArtifactHashes) {
170
152
  unhashedSeen = true;
171
153
  continue;
172
154
  }
173
155
  for (const provider of Object.keys(entry.deployedTo)) {
174
- const prefix = PROVIDER_PREFIX[provider];
175
- if (!prefix)
176
- continue;
156
+ const hashes = artifactHashesForProvider(entry, provider);
177
157
  for (const [sourceRel, expectedHash] of Object.entries(hashes)) {
178
- const deployedAbs = resolve(projectDir, `${prefix}/${sourceRel}`);
179
- const actualHash = await hashDeployed(deployedAbs);
158
+ let actualHash = null;
159
+ for (const deployedAbs of candidateDeployedPaths(projectDir, provider, sourceRel)) {
160
+ actualHash = await hashDeployed(deployedAbs);
161
+ if (actualHash)
162
+ break;
163
+ }
180
164
  if (actualHash === null) {
181
165
  // Missing — not drift, deploy is just absent
182
166
  continue;