@aiwg/cli 2026.9.6 → 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.
Files changed (33) hide show
  1. package/dist/src/cli/handlers/help.js +7 -1
  2. package/dist/src/cli/handlers/installation.js +4 -0
  3. package/dist/src/cli/handlers/mc.js +13 -20
  4. package/dist/src/cli/handlers/ralph.js +14 -4
  5. package/dist/src/cli/handlers/refresh.js +298 -30
  6. package/dist/src/cli/handlers/runtime-info.js +3 -0
  7. package/dist/src/cli/handlers/serve.js +21 -3
  8. package/dist/src/cli/handlers/use.js +114 -8
  9. package/dist/src/cli/handlers/utilities.js +26 -10
  10. package/dist/src/cli/services/deployment-verification.js +117 -1
  11. package/dist/src/cli/watch-service.js +47 -4
  12. package/dist/src/config/project-artifacts-health.mjs +15 -2
  13. package/dist/src/cost/fleet-report.js +19 -5
  14. package/dist/src/extensions/project-local-doctor.js +40 -2
  15. package/dist/src/extensions/project-quickref.js +4 -0
  16. package/dist/src/installation/manager.mjs +38 -3
  17. package/dist/src/mcp/helpers.mjs +56 -22
  18. package/dist/src/mcp/registry.js +32 -22
  19. package/dist/src/mcp/registry.mjs +31 -26
  20. package/dist/src/mcp/toml-editor.mjs +117 -0
  21. package/dist/src/mcp/tools/orchestration.mjs +7 -7
  22. package/dist/src/mcp/tools/subsystems.mjs +7 -7
  23. package/dist/src/memory/context-pack.js +5 -1
  24. package/dist/src/plugin/skill-command-translator.js +70 -1
  25. package/dist/src/serve/a2a-terminal-observer.js +19 -1
  26. package/dist/src/serve/mission-hitl.js +91 -0
  27. package/dist/src/sessions/import-lease.js +5 -1
  28. package/dist/src/smiths/context-pipeline/workspace-context.js +81 -5
  29. package/dist/src/testing/fixtures/test-data-factory.js +3 -3
  30. package/dist/src/writing/pattern-library.js +29 -6
  31. package/package.json +2 -1
  32. package/tools/agents/deploy-agents.mjs +87 -5
  33. 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(_ctx) {
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
- const raw = parseFlag(args, flag);
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 value = Number(raw);
177
- if (!Number.isFinite(value) || value <= 0) {
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 maxIterationsRaw = parseFlag(ctx.args, '--max-iterations');
334
- let maxIterations = 10;
335
- if (maxIterationsRaw !== undefined) {
336
- const parsedIterations = parseInt(maxIterationsRaw, 10);
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 = integer ? parseInt(raw ?? '', 10) : parseFloat(raw ?? '');
26
- if (!Number.isFinite(value) || value <= 0) {
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' && ctx.args[i + 1]) {
435
- maxIterations = parseInt(ctx.args[++i], 10);
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
- export async function pruneStaleManagedAgentFiles(options) {
73
- const desired = await currentBundledAgentBasenames(options.frameworkRoot);
74
- const currentVersion = options.currentVersion ?? await readFrameworkVersion(options.frameworkRoot);
75
- const removals = [];
76
- for (const [provider, relDir] of Object.entries(PROVIDER_AGENT_DIRS)) {
77
- const dir = path.join(options.projectRoot, relDir);
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
- const artifactName = normalizeAgentArtifactName(entry.name);
100
- const missingFromCurrentPackage = !desired.has(artifactName);
101
- // Addons have independent manifest versions. Comparing their managed
102
- // marker to the top-level package version makes a successful refresh
103
- // delete freshly restored addon agents. Version-based cleanup remains
104
- // valid for other provider trees that were not refreshed, while the
105
- // active provider removes only artifacts absent from current sources.
106
- const fromOlderPackage = provider !== options.provider
107
- && currentVersion !== null
108
- && isOlderManagedVersion(marker.version, currentVersion);
109
- if (!missingFromCurrentPackage && !fromOlderPackage)
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
- const relFile = path.relative(options.projectRoot, file);
231
+ }
112
232
  if (!options.dryRun)
113
- await fs.rm(file, { force: true });
114
- let providerRemoval = removals.find((item) => item.provider === provider);
115
- if (!providerRemoval) {
116
- providerRemoval = { provider, paths: [] };
117
- removals.push(providerRemoval);
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
- providerRemoval.paths.push(relFile);
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
- for (const removal of removals)
123
- removal.paths.sort((a, b) => a.localeCompare(b));
124
- return removals;
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
- staleAgentRemovals = await pruneStaleManagedAgentFiles({
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.success(`Removed ${total} stale AIWG-managed agent file${total === 1 ? '' : 's'} ` +
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.assignMission(missionId, executor.executorId);
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 → 200; forwards to owning executor over WS
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.hitl_id || !payload.response) {
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',