@aiwg/cli 2026.9.5 → 2026.9.7
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/cli/handlers/help.js +7 -1
- package/dist/src/cli/handlers/installation.js +4 -0
- package/dist/src/cli/handlers/mc.js +13 -20
- package/dist/src/cli/handlers/ralph.js +14 -4
- package/dist/src/cli/handlers/refresh.js +298 -30
- package/dist/src/cli/handlers/runtime-info.js +3 -0
- package/dist/src/cli/handlers/serve.js +21 -3
- package/dist/src/cli/handlers/use.js +114 -8
- package/dist/src/cli/handlers/utilities.js +26 -10
- package/dist/src/cli/services/deployment-verification.js +117 -1
- package/dist/src/cli/watch-service.js +47 -4
- package/dist/src/config/project-artifacts-health.mjs +15 -2
- package/dist/src/cost/fleet-report.js +19 -5
- package/dist/src/extensions/project-local-doctor.js +40 -2
- package/dist/src/extensions/project-quickref.js +4 -0
- package/dist/src/installation/manager.mjs +38 -3
- package/dist/src/mcp/helpers.mjs +56 -22
- package/dist/src/mcp/registry.js +32 -22
- package/dist/src/mcp/registry.mjs +31 -26
- package/dist/src/mcp/toml-editor.mjs +117 -0
- package/dist/src/mcp/tools/orchestration.mjs +7 -7
- package/dist/src/mcp/tools/subsystems.mjs +7 -7
- package/dist/src/memory/context-pack.js +5 -1
- package/dist/src/plugin/skill-command-translator.js +70 -1
- package/dist/src/serve/a2a-terminal-observer.js +19 -1
- package/dist/src/serve/mission-hitl.js +91 -0
- package/dist/src/sessions/import-lease.js +5 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +81 -5
- package/dist/src/testing/fixtures/test-data-factory.js +3 -3
- package/dist/src/writing/pattern-library.js +29 -6
- package/package.json +3 -1
- package/tools/agents/deploy-agents.mjs +87 -5
- package/tools/agents/providers/base.mjs +61 -2
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as ui from '../ui.js';
|
|
11
11
|
import { maybePrintCommunityFooter } from '../../community/footer.js';
|
|
12
12
|
import { listProviderDefinitions } from '../../providers/provider-definitions.js';
|
|
13
|
+
import { getCommandIds } from '../../extensions/commands/definitions.js';
|
|
13
14
|
/**
|
|
14
15
|
* Help command handler
|
|
15
16
|
*/
|
|
@@ -19,7 +20,12 @@ export const helpHandler = {
|
|
|
19
20
|
description: 'Show CLI help message',
|
|
20
21
|
category: 'maintenance',
|
|
21
22
|
aliases: ['-h', '-help', '--help'],
|
|
22
|
-
async execute(
|
|
23
|
+
async execute(ctx) {
|
|
24
|
+
if (ctx.args.includes('--json')) {
|
|
25
|
+
// Canonical IDs only: aliases and example prose are not registry entries.
|
|
26
|
+
console.log(JSON.stringify({ schema: 'aiwg.command-registry.v1', commandIds: getCommandIds() }));
|
|
27
|
+
return { exitCode: 0 };
|
|
28
|
+
}
|
|
23
29
|
displayHelp();
|
|
24
30
|
return { exitCode: 0 };
|
|
25
31
|
},
|
|
@@ -20,6 +20,10 @@ function display(status, json) {
|
|
|
20
20
|
console.log(`Release channel: ${status.identity?.channel ?? '(unrecorded)'}`);
|
|
21
21
|
console.log(`Actual method: ${status.actualMethod}`);
|
|
22
22
|
console.log(`Actual root: ${status.actualRoot}`);
|
|
23
|
+
console.log(`Framework root: ${status.frameworkRoot}`);
|
|
24
|
+
if (status.launcher) {
|
|
25
|
+
console.log(`Launcher: ${status.launcher.method} at ${status.launcher.root} (edge redirect — expected)`);
|
|
26
|
+
}
|
|
23
27
|
if (status.drift.length > 0) {
|
|
24
28
|
console.log('Drift:');
|
|
25
29
|
for (const item of status.drift)
|
|
@@ -169,12 +169,15 @@ function hasFlag(args, flag) {
|
|
|
169
169
|
* dispatch missions with no ceiling while the operator believed one applied
|
|
170
170
|
* (#1770).
|
|
171
171
|
*/
|
|
172
|
-
function parseNumberFlag(args, flag, invalidSink) {
|
|
173
|
-
|
|
174
|
-
if (raw === undefined)
|
|
172
|
+
function parseNumberFlag(args, flag, invalidSink, integer = false) {
|
|
173
|
+
if (!args.some(arg => arg === flag || arg.startsWith(`${flag}=`)))
|
|
175
174
|
return undefined;
|
|
176
|
-
const
|
|
177
|
-
|
|
175
|
+
const raw = parseFlag(args, flag);
|
|
176
|
+
// Presence and validity are distinct: a trailing flag must not silently
|
|
177
|
+
// disappear, and counter limits must not accept fractional/unsafe values.
|
|
178
|
+
const decimal = typeof raw === 'string' && /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw);
|
|
179
|
+
const value = decimal ? Number(raw) : NaN;
|
|
180
|
+
if (!Number.isFinite(value) || value <= 0 || (integer && !Number.isSafeInteger(value))) {
|
|
178
181
|
invalidSink?.push(`${flag} (got '${raw}')`);
|
|
179
182
|
return undefined;
|
|
180
183
|
}
|
|
@@ -330,23 +333,13 @@ async function mcDispatch(ctx) {
|
|
|
330
333
|
const completion = parseFlag(ctx.args, '--completion');
|
|
331
334
|
const priority = parseFlag(ctx.args, '--priority') || 'normal';
|
|
332
335
|
const invalidFlags = [];
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (!Number.isFinite(parsedIterations) || parsedIterations <= 0) {
|
|
338
|
-
invalidFlags.push(`--max-iterations (got '${maxIterationsRaw}')`);
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
maxIterations = parsedIterations;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
const maxTotalTokens = parseNumberFlag(ctx.args, '--max-total-tokens', invalidFlags);
|
|
345
|
-
const maxOutputTokens = parseNumberFlag(ctx.args, '--max-output-tokens', invalidFlags);
|
|
346
|
-
const maxToolCalls = parseNumberFlag(ctx.args, '--max-tool-calls', invalidFlags);
|
|
336
|
+
const maxIterations = parseNumberFlag(ctx.args, '--max-iterations', invalidFlags, true) ?? 10;
|
|
337
|
+
const maxTotalTokens = parseNumberFlag(ctx.args, '--max-total-tokens', invalidFlags, true);
|
|
338
|
+
const maxOutputTokens = parseNumberFlag(ctx.args, '--max-output-tokens', invalidFlags, true);
|
|
339
|
+
const maxToolCalls = parseNumberFlag(ctx.args, '--max-tool-calls', invalidFlags, true);
|
|
347
340
|
const maxTotalCost = parseNumberFlag(ctx.args, '--max-total-cost', invalidFlags);
|
|
348
341
|
const maxWallClockMinutes = parseNumberFlag(ctx.args, '--max-wall-clock-minutes', invalidFlags);
|
|
349
|
-
const explorationQuota = parseNumberFlag(ctx.args, '--exploration-quota', invalidFlags);
|
|
342
|
+
const explorationQuota = parseNumberFlag(ctx.args, '--exploration-quota', invalidFlags, true);
|
|
350
343
|
const budgetStopPolicyRaw = parseFlag(ctx.args, '--budget-stop-policy');
|
|
351
344
|
let budgetStopPolicy;
|
|
352
345
|
if (budgetStopPolicyRaw !== undefined) {
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
import { createScriptRunner } from './script-runner.js';
|
|
14
14
|
import { launchExternalRalph, getLoopStatuses, abortLoop, resumeLoop, attachToLoopOutput, } from './ralph-launcher.js';
|
|
15
15
|
import { handlerResultFromError } from '../errors.js';
|
|
16
|
+
/** Parse a complete positive decimal value without truncating counter limits. */
|
|
17
|
+
function parsePositiveLimit(raw, integer = false) {
|
|
18
|
+
if (typeof raw !== 'string' || !/^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
|
|
19
|
+
return undefined;
|
|
20
|
+
const value = Number(raw);
|
|
21
|
+
return Number.isFinite(value) && value > 0 && (!integer || Number.isSafeInteger(value)) ? value : undefined;
|
|
22
|
+
}
|
|
16
23
|
/**
|
|
17
24
|
* Parse Ralph command arguments
|
|
18
25
|
*/
|
|
@@ -22,8 +29,8 @@ function parseRalphArgs(args) {
|
|
|
22
29
|
// Present-but-invalid numeric values are a hard usage error: an operator who
|
|
23
30
|
// typed --max-total-cost expects a ceiling to exist (#1770).
|
|
24
31
|
const positiveNumber = (flag, raw, integer = false) => {
|
|
25
|
-
const value =
|
|
26
|
-
if (
|
|
32
|
+
const value = parsePositiveLimit(raw, integer);
|
|
33
|
+
if (value === undefined) {
|
|
27
34
|
result.invalidFlags.push(`${flag} (got '${raw ?? ''}')`);
|
|
28
35
|
return undefined;
|
|
29
36
|
}
|
|
@@ -431,8 +438,11 @@ export class RalphResumeHandler {
|
|
|
431
438
|
if (arg === '--loop-id' && ctx.args[i + 1]) {
|
|
432
439
|
loopId = ctx.args[++i];
|
|
433
440
|
}
|
|
434
|
-
else if (arg === '--max-iterations'
|
|
435
|
-
maxIterations =
|
|
441
|
+
else if (arg === '--max-iterations') {
|
|
442
|
+
maxIterations = parsePositiveLimit(ctx.args[++i], true);
|
|
443
|
+
if (maxIterations === undefined) {
|
|
444
|
+
return { exitCode: 1, message: 'Error: --max-iterations requires a positive safe integer. Loop not resumed.' };
|
|
445
|
+
}
|
|
436
446
|
}
|
|
437
447
|
}
|
|
438
448
|
try {
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* @issue #173, #174, #482, #557, #694
|
|
14
14
|
*/
|
|
15
15
|
import { promises as fs } from 'fs';
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
16
17
|
import path from 'path';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
17
20
|
import { createScriptRunner } from './script-runner.js';
|
|
18
21
|
import { createUseHandler } from './use.js';
|
|
19
22
|
import { getFrameworkRoot } from '../../channel/manager.mjs';
|
|
@@ -21,6 +24,7 @@ import { refreshAllPackages } from '../../packages/registry.js';
|
|
|
21
24
|
import { resolveActiveProvider } from '../provider-resolution.js';
|
|
22
25
|
import { readAiwgConfig, writeAiwgConfig, hashManifest, getProviderParallelismDefaults, } from '../../config/aiwg-config.js';
|
|
23
26
|
import { discoverProjectLocalBundles } from '../../extensions/project-local-discovery.js';
|
|
27
|
+
import { getProviderArtifactPathStrings } from '../../providers/provider-definitions.js';
|
|
24
28
|
import { collectPackagedAgentInventory, normalizeAgentArtifactName, parseManagedArtifactMarker, } from '../../agents/packaged-agent-inventory.js';
|
|
25
29
|
import * as ui from '../ui.js';
|
|
26
30
|
const PROVIDER_AGENT_DIRS = {
|
|
@@ -69,12 +73,33 @@ function isOlderManagedVersion(deployedVersion, currentVersion) {
|
|
|
69
73
|
return true;
|
|
70
74
|
return deployed.prerelease.localeCompare(current.prerelease, undefined, { numeric: true }) < 0;
|
|
71
75
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const PRUNABLE_ARTIFACT_KINDS = ['agents', 'commands', 'rules'];
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the on-disk artifact directories for a provider.
|
|
79
|
+
*
|
|
80
|
+
* `PROVIDER_AGENT_DIRS` remains the enumeration of providers AIWG can prune;
|
|
81
|
+
* the command/rule directories come from the provider definitions so the
|
|
82
|
+
* unit-prune path never drifts from what `aiwg use` actually wrote.
|
|
83
|
+
*/
|
|
84
|
+
function providerArtifactDirs(provider) {
|
|
85
|
+
const agents = PROVIDER_AGENT_DIRS[provider] ?? null;
|
|
86
|
+
const declared = getProviderArtifactPathStrings(provider);
|
|
87
|
+
const usable = (value) => typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) ? value : null;
|
|
88
|
+
return {
|
|
89
|
+
agents,
|
|
90
|
+
commands: usable(declared?.commands),
|
|
91
|
+
rules: usable(declared?.rules),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Collect every AIWG-managed (`source: bundled`) artifact in one provider tree. */
|
|
95
|
+
async function collectManagedProviderArtifacts(projectRoot, provider, kinds) {
|
|
96
|
+
const dirs = providerArtifactDirs(provider);
|
|
97
|
+
const hits = [];
|
|
98
|
+
for (const kind of kinds) {
|
|
99
|
+
const relDir = dirs[kind];
|
|
100
|
+
if (!relDir)
|
|
101
|
+
continue;
|
|
102
|
+
const dir = path.join(projectRoot, relDir);
|
|
78
103
|
let entries;
|
|
79
104
|
try {
|
|
80
105
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -85,6 +110,9 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
85
110
|
for (const entry of entries) {
|
|
86
111
|
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
87
112
|
continue;
|
|
113
|
+
// Generated indexes are rewritten on every deploy, never orphaned.
|
|
114
|
+
if (entry.name === 'RULES-INDEX.md' || entry.name === 'RULES-ONDEMAND.md')
|
|
115
|
+
continue;
|
|
88
116
|
const file = path.join(dir, entry.name);
|
|
89
117
|
let content;
|
|
90
118
|
try {
|
|
@@ -96,32 +124,192 @@ export async function pruneStaleManagedAgentFiles(options) {
|
|
|
96
124
|
const marker = parseManagedArtifactMarker(content);
|
|
97
125
|
if (marker?.source !== 'bundled')
|
|
98
126
|
continue;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
hits.push({
|
|
128
|
+
kind,
|
|
129
|
+
absolutePath: file,
|
|
130
|
+
relativePath: path.relative(projectRoot, file),
|
|
131
|
+
version: marker.version,
|
|
132
|
+
artifactName: normalizeAgentArtifactName(entry.name),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return hits;
|
|
137
|
+
}
|
|
138
|
+
export async function detectStaleProviderTrees(options) {
|
|
139
|
+
const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
|
|
140
|
+
if (currentVersion === null)
|
|
141
|
+
return [];
|
|
142
|
+
const trees = [];
|
|
143
|
+
for (const provider of Object.keys(PROVIDER_AGENT_DIRS)) {
|
|
144
|
+
if (provider === options.provider)
|
|
145
|
+
continue;
|
|
146
|
+
const hits = (await collectManagedProviderArtifacts(options.projectRoot, provider, PRUNABLE_ARTIFACT_KINDS))
|
|
147
|
+
.filter((hit) => isOlderManagedVersion(hit.version, currentVersion));
|
|
148
|
+
if (hits.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
const counts = { agents: 0, commands: 0, rules: 0 };
|
|
151
|
+
for (const hit of hits)
|
|
152
|
+
counts[hit.kind] += 1;
|
|
153
|
+
const oldest = hits
|
|
154
|
+
.map((hit) => hit.version)
|
|
155
|
+
.reduce((a, b) => (isOlderManagedVersion(a, b) ? a : b));
|
|
156
|
+
trees.push({ provider, version: oldest, counts, total: hits.length });
|
|
157
|
+
}
|
|
158
|
+
trees.sort((a, b) => a.provider.localeCompare(b.provider));
|
|
159
|
+
return trees;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Split project-relative paths into the ones git tracks and the ones it does not (#2509).
|
|
163
|
+
*
|
|
164
|
+
* Deleting an ignored, regenerable artifact and deleting a committed file are
|
|
165
|
+
* not the same act. A project that is not a git repo — or a machine without
|
|
166
|
+
* git — reports everything as untracked, so the caller behaves as before.
|
|
167
|
+
*/
|
|
168
|
+
export async function partitionTrackedPaths(projectRoot, relativePaths) {
|
|
169
|
+
if (relativePaths.length === 0)
|
|
170
|
+
return { tracked: [], untracked: [] };
|
|
171
|
+
let trackedSet;
|
|
172
|
+
try {
|
|
173
|
+
const { stdout } = await execFileAsync('git', ['ls-files', '-z', '--', ...relativePaths], { cwd: projectRoot, maxBuffer: 32 * 1024 * 1024 });
|
|
174
|
+
trackedSet = new Set(stdout.split('\0').filter(Boolean));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { tracked: [], untracked: [...relativePaths] };
|
|
178
|
+
}
|
|
179
|
+
const tracked = [];
|
|
180
|
+
const untracked = [];
|
|
181
|
+
for (const relativePath of relativePaths) {
|
|
182
|
+
// git reports POSIX separators regardless of platform.
|
|
183
|
+
(trackedSet.has(relativePath.split(path.sep).join('/')) ? tracked : untracked).push(relativePath);
|
|
184
|
+
}
|
|
185
|
+
return { tracked, untracked };
|
|
186
|
+
}
|
|
187
|
+
export async function pruneStaleManagedAgentFiles(options) {
|
|
188
|
+
const desired = await currentBundledAgentBasenames(options.frameworkRoot);
|
|
189
|
+
const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
|
|
190
|
+
const crossProvider = options.crossProvider ?? 'skip';
|
|
191
|
+
const removals = [];
|
|
192
|
+
const trackedSkipped = [];
|
|
193
|
+
const record = (list, provider, relativePath) => {
|
|
194
|
+
let entry = list.find((item) => item.provider === provider);
|
|
195
|
+
if (!entry) {
|
|
196
|
+
entry = { provider, paths: [] };
|
|
197
|
+
list.push(entry);
|
|
198
|
+
}
|
|
199
|
+
entry.paths.push(relativePath);
|
|
200
|
+
};
|
|
201
|
+
for (const provider of Object.keys(PROVIDER_AGENT_DIRS)) {
|
|
202
|
+
const isTargetProvider = provider === options.provider;
|
|
203
|
+
// Non-target trees are pruned as a unit or not at all; the refreshed
|
|
204
|
+
// provider only drops agents whose source no longer ships them.
|
|
205
|
+
if (!isTargetProvider && crossProvider === 'skip')
|
|
206
|
+
continue;
|
|
207
|
+
const kinds = isTargetProvider ? ['agents'] : PRUNABLE_ARTIFACT_KINDS;
|
|
208
|
+
const hits = await collectManagedProviderArtifacts(options.projectRoot, provider, kinds);
|
|
209
|
+
const eligible = hits.filter((hit) => {
|
|
210
|
+
if (isTargetProvider) {
|
|
211
|
+
// Addons have independent manifest versions. Comparing their managed
|
|
212
|
+
// marker to the top-level package version makes a successful refresh
|
|
213
|
+
// delete freshly restored addon agents, so the active provider removes
|
|
214
|
+
// only artifacts absent from current sources.
|
|
215
|
+
return !desired.has(hit.artifactName);
|
|
216
|
+
}
|
|
217
|
+
return currentVersion !== null && isOlderManagedVersion(hit.version, currentVersion);
|
|
218
|
+
});
|
|
219
|
+
if (eligible.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
// Only the cross-provider unit prune defers to VCS state.
|
|
222
|
+
let protectedPaths = new Set();
|
|
223
|
+
if (!isTargetProvider && !options.allowTrackedDeletes) {
|
|
224
|
+
const { tracked } = await partitionTrackedPaths(options.projectRoot, eligible.map((hit) => hit.relativePath));
|
|
225
|
+
protectedPaths = new Set(tracked);
|
|
226
|
+
}
|
|
227
|
+
for (const hit of eligible) {
|
|
228
|
+
if (protectedPaths.has(hit.relativePath)) {
|
|
229
|
+
record(trackedSkipped, provider, hit.relativePath);
|
|
110
230
|
continue;
|
|
111
|
-
|
|
231
|
+
}
|
|
112
232
|
if (!options.dryRun)
|
|
113
|
-
await fs.rm(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
233
|
+
await fs.rm(hit.absolutePath, { force: true });
|
|
234
|
+
record(removals, provider, hit.relativePath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const list of [removals, trackedSkipped]) {
|
|
238
|
+
for (const entry of list)
|
|
239
|
+
entry.paths.sort((a, b) => a.localeCompare(b));
|
|
240
|
+
}
|
|
241
|
+
return { removals, trackedSkipped };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Reconcile `installed.deployedTo` with the artifacts a prune pass left behind (#2506).
|
|
245
|
+
*
|
|
246
|
+
* Recorded counts are written by `aiwg use` before the prune runs, so a run
|
|
247
|
+
* that deletes artifacts otherwise leaves the config permanently claiming
|
|
248
|
+
* files that are gone. Two cases:
|
|
249
|
+
*
|
|
250
|
+
* - **Unit-pruned provider tree** (a non-refreshed provider removed wholesale):
|
|
251
|
+
* every managed agent/command/rule for that provider is gone, so those
|
|
252
|
+
* counts drop to zero across all installed bundles. Skills are not part of
|
|
253
|
+
* the prune pass and keep their recorded values.
|
|
254
|
+
* - **Refreshed provider** (agent orphans only): subtract the removed count
|
|
255
|
+
* from the recorded agent totals, largest contributor first, clamped at 0.
|
|
256
|
+
*/
|
|
257
|
+
async function reconcileDeployedToAfterPrune(projectRoot, removals, refreshedProvider) {
|
|
258
|
+
if (removals.length === 0)
|
|
259
|
+
return;
|
|
260
|
+
const config = await readAiwgConfig(projectRoot);
|
|
261
|
+
if (!config)
|
|
262
|
+
return;
|
|
263
|
+
let changed = false;
|
|
264
|
+
for (const removal of removals) {
|
|
265
|
+
const unitPruned = removal.provider !== refreshedProvider;
|
|
266
|
+
const entries = Object.values(config.installed)
|
|
267
|
+
.filter((entry) => entry.deployedTo?.[removal.provider]);
|
|
268
|
+
if (entries.length === 0)
|
|
269
|
+
continue;
|
|
270
|
+
if (unitPruned) {
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
const counts = entry.deployedTo[removal.provider];
|
|
273
|
+
if (counts.agents === 0 && counts.commands === 0 && counts.rules === 0)
|
|
274
|
+
continue;
|
|
275
|
+
counts.agents = 0;
|
|
276
|
+
counts.commands = 0;
|
|
277
|
+
counts.rules = 0;
|
|
278
|
+
changed = true;
|
|
118
279
|
}
|
|
119
|
-
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
let outstanding = removal.paths.length;
|
|
283
|
+
const byAgentsDesc = entries
|
|
284
|
+
.slice()
|
|
285
|
+
.sort((a, b) => b.deployedTo[removal.provider].agents - a.deployedTo[removal.provider].agents);
|
|
286
|
+
for (const entry of byAgentsDesc) {
|
|
287
|
+
if (outstanding <= 0)
|
|
288
|
+
break;
|
|
289
|
+
const counts = entry.deployedTo[removal.provider];
|
|
290
|
+
const deduct = Math.min(counts.agents, outstanding);
|
|
291
|
+
if (deduct <= 0)
|
|
292
|
+
continue;
|
|
293
|
+
counts.agents -= deduct;
|
|
294
|
+
outstanding -= deduct;
|
|
295
|
+
changed = true;
|
|
120
296
|
}
|
|
121
297
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
298
|
+
if (changed)
|
|
299
|
+
await writeAiwgConfig(projectRoot, config);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether this run may delete git-tracked artifacts during a cross-provider
|
|
303
|
+
* prune (#2514).
|
|
304
|
+
*
|
|
305
|
+
* Deliberately not derived from `--force`. `--force` governs what gets
|
|
306
|
+
* *written* — it replaces artifacts AIWG does not currently manage. Deleting
|
|
307
|
+
* files someone committed, in a provider tree this run was not asked to touch,
|
|
308
|
+
* is a different decision and gets its own switch, so habitual `--force` use
|
|
309
|
+
* can never authorise it.
|
|
310
|
+
*/
|
|
311
|
+
export function allowsTrackedDeletes(args) {
|
|
312
|
+
return args.includes('--prune-tracked');
|
|
125
313
|
}
|
|
126
314
|
/**
|
|
127
315
|
* Parse --flag value pairs from args
|
|
@@ -161,6 +349,17 @@ Options:
|
|
|
161
349
|
--skip-update Skip the installation update
|
|
162
350
|
--packages-only Refresh remote packages only
|
|
163
351
|
--provider <name> Override provider auto-detection
|
|
352
|
+
--prune-other-providers Remove stale AIWG-managed trees belonging to
|
|
353
|
+
providers this run did not refresh. Off by default:
|
|
354
|
+
a provider-scoped refresh never mutates another
|
|
355
|
+
provider's deployed surface. Git-tracked files are
|
|
356
|
+
always left in place unless --prune-tracked is given.
|
|
357
|
+
--prune-tracked Allow --prune-other-providers to delete git-tracked
|
|
358
|
+
files. Deleting committed files is a separate
|
|
359
|
+
decision from --force, which only governs writes.
|
|
360
|
+
--force Re-write every deployed artifact, replacing files
|
|
361
|
+
AIWG does not currently manage. Never authorises
|
|
362
|
+
deleting tracked files.
|
|
164
363
|
--channel <name> Select the update channel (stable or main)
|
|
165
364
|
--frameworks <list> Re-deploy a comma-separated installed subset
|
|
166
365
|
--model <name> Override all deployed agent model tiers
|
|
@@ -192,6 +391,14 @@ export const refreshHandler = {
|
|
|
192
391
|
const quiet = hasFlag(ctx.args, '--quiet');
|
|
193
392
|
const skipUpdate = hasFlag(ctx.args, '--skip-update');
|
|
194
393
|
const packagesOnly = hasFlag(ctx.args, '--packages-only');
|
|
394
|
+
// #2506: cross-provider pruning is opt-in. A provider-scoped refresh must
|
|
395
|
+
// not silently delete another provider's deployed surface.
|
|
396
|
+
const pruneOtherProviders = hasFlag(ctx.args, '--prune-other-providers');
|
|
397
|
+
const forceDeploy = hasFlag(ctx.args, '--force');
|
|
398
|
+
// #2514: deleting committed files in a tree this run was not asked to touch
|
|
399
|
+
// is its own decision, not a consequence of asking for a forceful re-write.
|
|
400
|
+
// `--force` governs what gets written; this governs what gets destroyed.
|
|
401
|
+
const pruneTracked = allowsTrackedDeletes(ctx.args);
|
|
195
402
|
const provider = parseFlag(ctx.args, '--provider');
|
|
196
403
|
const channel = parseFlag(ctx.args, '--channel');
|
|
197
404
|
const frameworksArg = parseFlag(ctx.args, '--frameworks');
|
|
@@ -313,6 +520,9 @@ export const refreshHandler = {
|
|
|
313
520
|
'--target', ctx.cwd,
|
|
314
521
|
'--yes',
|
|
315
522
|
'--json',
|
|
523
|
+
// #2507: give operators a reclaim path for artifacts an older
|
|
524
|
+
// AIWG left behind without a managed marker.
|
|
525
|
+
...(forceDeploy ? ['--force'] : []),
|
|
316
526
|
...modelDeployArgs,
|
|
317
527
|
],
|
|
318
528
|
rawArgs: ['use', fw],
|
|
@@ -364,32 +574,83 @@ export const refreshHandler = {
|
|
|
364
574
|
catch {
|
|
365
575
|
// Non-fatal — refresh continues
|
|
366
576
|
}
|
|
367
|
-
// Step 4.5: Stale deployment check (#621, #1460, #1799)
|
|
577
|
+
// Step 4.5: Stale deployment check (#621, #1460, #1799, #2506)
|
|
368
578
|
if (!quiet)
|
|
369
579
|
ui.info('Checking for stale deployments...');
|
|
580
|
+
// A modifier with nothing to modify is almost always a mistyped intent.
|
|
581
|
+
if (pruneTracked && !pruneOtherProviders && !quiet) {
|
|
582
|
+
ui.warn('--prune-tracked has no effect without --prune-other-providers; nothing was removed.');
|
|
583
|
+
}
|
|
370
584
|
let staleAgentRemovals = [];
|
|
585
|
+
let trackedSkipped = [];
|
|
371
586
|
if (!dryRun && deploymentFailures.length === 0) {
|
|
372
587
|
try {
|
|
373
|
-
|
|
588
|
+
const pruneResult = await pruneStaleManagedAgentFiles({
|
|
374
589
|
projectRoot: ctx.cwd,
|
|
375
590
|
frameworkRoot,
|
|
376
591
|
provider: detectedProvider,
|
|
592
|
+
crossProvider: pruneOtherProviders ? 'prune' : 'skip',
|
|
593
|
+
allowTrackedDeletes: pruneTracked,
|
|
377
594
|
});
|
|
595
|
+
staleAgentRemovals = pruneResult.removals;
|
|
596
|
+
trackedSkipped = pruneResult.trackedSkipped;
|
|
597
|
+
if (trackedSkipped.length > 0 && !quiet) {
|
|
598
|
+
const total = trackedSkipped.reduce((sum, item) => sum + item.paths.length, 0);
|
|
599
|
+
ui.warn(`Left ${total} git-tracked AIWG-managed file${total === 1 ? '' : 's'} in place ` +
|
|
600
|
+
`across ${trackedSkipped.length} provider${trackedSkipped.length === 1 ? '' : 's'}`);
|
|
601
|
+
for (const skipped of trackedSkipped) {
|
|
602
|
+
const shown = skipped.paths.slice(0, 3).join(', ');
|
|
603
|
+
const remainder = skipped.paths.length - 3;
|
|
604
|
+
ui.dim(` ${skipped.provider}: ${skipped.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
|
|
605
|
+
}
|
|
606
|
+
ui.dim(" These are committed files. Add --prune-tracked to remove them as well.");
|
|
607
|
+
}
|
|
378
608
|
if (staleAgentRemovals.length > 0 && !quiet) {
|
|
379
609
|
const total = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
380
|
-
ui.
|
|
610
|
+
ui.warn(`Removed ${total} stale AIWG-managed file${total === 1 ? '' : 's'} ` +
|
|
381
611
|
`across ${staleAgentRemovals.length} provider${staleAgentRemovals.length === 1 ? '' : 's'}`);
|
|
382
612
|
for (const removal of staleAgentRemovals) {
|
|
383
613
|
const shown = removal.paths.slice(0, 3).join(', ');
|
|
384
614
|
const remainder = removal.paths.length - 3;
|
|
385
615
|
ui.dim(` ${removal.provider}: ${removal.paths.length} (${shown}${remainder > 0 ? `, ...and ${remainder} more` : ''})`);
|
|
386
616
|
}
|
|
617
|
+
ui.dim(' Review `git status` before committing — deployed artifacts may be tracked.');
|
|
387
618
|
}
|
|
619
|
+
// #2506: keep the recorded deployment state consistent with what the
|
|
620
|
+
// prune actually left on disk, so a later run does not trust counts
|
|
621
|
+
// for artifacts that no longer exist.
|
|
622
|
+
await reconcileDeployedToAfterPrune(ctx.cwd, staleAgentRemovals, pruneOtherProviders ? detectedProvider : null);
|
|
388
623
|
}
|
|
389
624
|
catch {
|
|
390
625
|
if (!quiet)
|
|
391
626
|
ui.dim(' Agent orphan cleanup skipped (non-critical)');
|
|
392
627
|
}
|
|
628
|
+
// #2506: a provider-scoped refresh reports other providers' stale trees
|
|
629
|
+
// instead of mutating them. Silent cross-provider deletion destroyed
|
|
630
|
+
// git-tracked artifacts and left half-deployed surfaces behind.
|
|
631
|
+
if (!pruneOtherProviders) {
|
|
632
|
+
try {
|
|
633
|
+
const staleTrees = await detectStaleProviderTrees({
|
|
634
|
+
projectRoot: ctx.cwd,
|
|
635
|
+
frameworkRoot,
|
|
636
|
+
provider: detectedProvider,
|
|
637
|
+
});
|
|
638
|
+
for (const tree of staleTrees) {
|
|
639
|
+
const breakdown = ['agents', 'commands', 'rules']
|
|
640
|
+
.filter((kind) => tree.counts[kind] > 0)
|
|
641
|
+
.map((kind) => `${tree.counts[kind]} ${kind}`)
|
|
642
|
+
.join(', ');
|
|
643
|
+
ui.warn(`Stale ${tree.provider} deployment: ${tree.total} AIWG-managed file(s) from v${tree.version} ` +
|
|
644
|
+
`(${breakdown}) — this run refreshed ${detectedProvider ?? 'the active provider'} only`);
|
|
645
|
+
ui.dim(` Refresh it with 'aiwg refresh --provider ${tree.provider}', ` +
|
|
646
|
+
`or remove it with 'aiwg refresh --prune-other-providers'`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
if (!quiet)
|
|
651
|
+
ui.dim(' Stale provider tree check skipped (non-critical)');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
393
654
|
try {
|
|
394
655
|
const { getFrameworkRoot } = await import('../../channel/manager.mjs');
|
|
395
656
|
const { join } = await import('path');
|
|
@@ -420,6 +681,13 @@ export const refreshHandler = {
|
|
|
420
681
|
ui.warn(`Stale deployment: ${name} — run 'aiwg use ${name}' to redeploy`);
|
|
421
682
|
}
|
|
422
683
|
}
|
|
684
|
+
else if (staleAgentRemovals.length > 0) {
|
|
685
|
+
// #2506: a run that deleted artifacts is not an "up to date" run.
|
|
686
|
+
const removed = staleAgentRemovals.reduce((sum, item) => sum + item.paths.length, 0);
|
|
687
|
+
if (!quiet) {
|
|
688
|
+
ui.warn(`Deployments current, but ${removed} stale artifact(s) were removed this run — review the list above`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
423
691
|
else {
|
|
424
692
|
if (!quiet)
|
|
425
693
|
ui.success('All deployments up to date');
|
|
@@ -298,6 +298,9 @@ async function handleRuntimeInfo(args, cwd = process.cwd()) {
|
|
|
298
298
|
console.log(`\nAIWG Installation:`);
|
|
299
299
|
console.log(` Canonical: ${installation.identity?.method ?? 'unrecorded'} at ${installation.identity?.root ?? '(unrecorded)'}`);
|
|
300
300
|
console.log(` Actual: ${installation.actualMethod} at ${installation.actualRoot}`);
|
|
301
|
+
if (installation.launcher) {
|
|
302
|
+
console.log(` Launcher: ${installation.launcher.method} at ${installation.launcher.root} (edge redirect)`);
|
|
303
|
+
}
|
|
301
304
|
console.log(` Run mode: ${installation.identity?.runMode ?? '(unrecorded)'}`);
|
|
302
305
|
console.log(` State: ${installation.state}`);
|
|
303
306
|
// Scheduler backend detection
|
|
@@ -16,6 +16,8 @@ import { sandboxRegistry, normalizeSandboxEvent, } from '../../serve/sandbox-reg
|
|
|
16
16
|
import { routeTask } from '../../serve/agent-router.js';
|
|
17
17
|
import { routeDispatch } from '../../serve/dispatch-router.js';
|
|
18
18
|
import { observeA2ATerminalState } from '../../serve/a2a-terminal-observer.js';
|
|
19
|
+
import { respondToA2AMission } from '../../serve/mission-hitl.js';
|
|
20
|
+
import { A2A_HITL_PROMPT_V1 } from '../../a2a/client.js';
|
|
19
21
|
import { executorRegistry, validateRegisterPayload, validateDispatchPayload, validateEventEnvelope, } from '../../serve/executor-registry.js';
|
|
20
22
|
import { handleWebhook, IdempotencyCache, PushSecretRegistry, } from '../../a2a/webhook.js';
|
|
21
23
|
import { AiwgError, EXIT_CODES } from '../errors.js';
|
|
@@ -807,6 +809,7 @@ export async function startServer(opts) {
|
|
|
807
809
|
const a2aProtocolPolicy = configuredA2AProtocolPolicy;
|
|
808
810
|
try {
|
|
809
811
|
const result = await routeDispatch(executor, payload, {
|
|
812
|
+
optionalExtensions: [A2A_HITL_PROMPT_V1],
|
|
810
813
|
a2aProtocolPolicy,
|
|
811
814
|
allowA2AProtocolFallback: configuredA2AProtocolFallback,
|
|
812
815
|
allowLegacyExecutorFallback: configuredLegacyExecutorFallback,
|
|
@@ -872,7 +875,14 @@ export async function startServer(opts) {
|
|
|
872
875
|
...(a2aFallbackReason ? { fallbackReason: a2aFallbackReason } : {}),
|
|
873
876
|
});
|
|
874
877
|
}
|
|
875
|
-
executorRegistry.
|
|
878
|
+
const existingMission = executorRegistry.getMission(missionId);
|
|
879
|
+
// An idempotent replay must not discard in-flight/accepted approvals.
|
|
880
|
+
if (!(dispatchPath === 'v2' && a2aTask && a2aInstanceId
|
|
881
|
+
&& existingMission?.executorId === executor.executorId
|
|
882
|
+
&& existingMission.a2a?.taskId === a2aTask.id
|
|
883
|
+
&& existingMission.a2a.instanceId === a2aInstanceId)) {
|
|
884
|
+
executorRegistry.assignMission(missionId, executor.executorId);
|
|
885
|
+
}
|
|
876
886
|
if (dispatchPath === 'v2' && a2aTask && a2aInstanceId) {
|
|
877
887
|
void observeA2ATerminalState(executorRegistry, executor, missionId, a2aInstanceId, a2aTask, {
|
|
878
888
|
onError: (err) => {
|
|
@@ -929,7 +939,7 @@ export async function startServer(opts) {
|
|
|
929
939
|
error: mission.error,
|
|
930
940
|
});
|
|
931
941
|
});
|
|
932
|
-
// POST /api/v1/missions/:id/hitl_response →
|
|
942
|
+
// POST /api/v1/missions/:id/hitl_response → owning task's negotiated transport
|
|
933
943
|
app.post('/api/v1/missions/:id/hitl_response', async (c) => {
|
|
934
944
|
const missionId = c.req.param('id');
|
|
935
945
|
const mission = executorRegistry.getMission(missionId);
|
|
@@ -944,9 +954,17 @@ export async function startServer(opts) {
|
|
|
944
954
|
}
|
|
945
955
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
946
956
|
const payload = body;
|
|
947
|
-
if (!payload
|
|
957
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
|
958
|
+
|| typeof payload.hitl_id !== 'string' || !payload.hitl_id || !Object.hasOwn(payload, 'response')) {
|
|
948
959
|
return c.json({ error: 'hitl_id and response are required' }, 400);
|
|
949
960
|
}
|
|
961
|
+
if (mission.a2a) {
|
|
962
|
+
const result = await respondToA2AMission(executorRegistry, missionId, payload.hitl_id, payload.response);
|
|
963
|
+
return c.json(result.body, result.status);
|
|
964
|
+
}
|
|
965
|
+
if (typeof payload.response !== 'string' || !payload.response) {
|
|
966
|
+
return c.json({ error: 'Legacy HITL response must be a non-empty string' }, 400);
|
|
967
|
+
}
|
|
950
968
|
// Push hitl_responded event to the executor over WS
|
|
951
969
|
const envelope = {
|
|
952
970
|
event: 'mission.hitl_responded',
|