@aiwg/cli 2026.8.3 → 2026.8.5
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.
- package/dist/src/a2a/hitl-driver.js +28 -1
- package/dist/src/a2a/hitl.js +32 -8
- package/dist/src/audit/operator-decision.js +180 -0
- package/dist/src/cli/handlers/mc.js +270 -73
- package/dist/src/extensions/claude-hooks-installer.js +9 -5
- package/dist/src/mcp/cli.mjs +12 -0
- package/dist/src/mcp/registry.js +14 -1
- package/dist/src/mcp/registry.mjs +15 -1
- package/dist/src/research/query-cli.js +94 -24
- package/dist/src/resources/web-release.d.ts +7 -0
- package/dist/src/resources/web-release.js +149 -17
- package/dist/src/serve/shared-host-scheduler.js +260 -0
- package/dist/src/storage/backends/fortemi.js +95 -13
- package/dist/src/storage/cli.js +93 -6
- package/package.json +1 -1
- package/tools/plugin/package-plugins.mjs +170 -15
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
|
293
|
-
if (!
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
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 ${
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
686
|
-
if (!
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
if (
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
704
|
-
if (!
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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>
|
|
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:
|
|
38
|
-
{ file: 'aiwg-session.cjs', events:
|
|
39
|
-
{
|
|
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
|
|
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];
|
package/dist/src/mcp/cli.mjs
CHANGED
|
@@ -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(', ')}`);
|
package/dist/src/mcp/registry.js
CHANGED
|
@@ -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
|
-
|
|
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 */
|