@aiwg/cli 2026.8.16 → 2026.8.18

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 (55) hide show
  1. package/bin/aiwg.mjs +24 -2
  2. package/dist/src/a2a/agent-card.js +4 -1
  3. package/dist/src/a2a/client.js +148 -68
  4. package/dist/src/a2a/codecs.js +480 -0
  5. package/dist/src/a2a/events.js +226 -0
  6. package/dist/src/a2a/hitl-driver.js +8 -6
  7. package/dist/src/a2a/hitl.js +2 -1
  8. package/dist/src/a2a/http.js +85 -5
  9. package/dist/src/a2a/protocol.js +136 -0
  10. package/dist/src/a2a/types.js +4 -14
  11. package/dist/src/a2a/webhook.js +101 -4
  12. package/dist/src/artifacts/browser-export.js +1 -0
  13. package/dist/src/artifacts/cli.js +1 -1
  14. package/dist/src/artifacts/fortemi-core-query-adapter.js +6 -0
  15. package/dist/src/artifacts/types.js +73 -1
  16. package/dist/src/audit/operator-decision.js +15 -1
  17. package/dist/src/channel/manager.mjs +89 -17
  18. package/dist/src/cli/agent-spawn.js +4 -2
  19. package/dist/src/cli/handlers/cockpit.js +41 -0
  20. package/dist/src/cli/handlers/help.js +1 -1
  21. package/dist/src/cli/handlers/index.js +3 -1
  22. package/dist/src/cli/handlers/installation.js +79 -0
  23. package/dist/src/cli/handlers/ralph.js +2 -1
  24. package/dist/src/cli/handlers/refresh.js +4 -2
  25. package/dist/src/cli/handlers/runtime-info.js +9 -1
  26. package/dist/src/cli/handlers/sdlc-accelerate.js +2 -1
  27. package/dist/src/cli/handlers/serve.js +107 -4
  28. package/dist/src/cli/handlers/session.js +12 -26
  29. package/dist/src/cli/handlers/use.js +117 -12
  30. package/dist/src/cli/handlers/utilities.js +4 -0
  31. package/dist/src/cli/handlers/version.js +4 -0
  32. package/dist/src/cli/handlers/workspace.js +24 -2
  33. package/dist/src/cli/services/deployment-verification.js +9 -2
  34. package/dist/src/cockpit/doctor.js +257 -0
  35. package/dist/src/config/aiwg-config.js +36 -3
  36. package/dist/src/config/user-config-dir.mjs +29 -0
  37. package/dist/src/config/user-config.js +4 -22
  38. package/dist/src/extensions/commands/definitions.js +20 -1
  39. package/dist/src/features/catalog.js +2 -1
  40. package/dist/src/flow/graph-metadata.js +56 -0
  41. package/dist/src/installation/manager.mjs +243 -0
  42. package/dist/src/providers/transformation-receipt-integration.js +130 -3
  43. package/dist/src/security/artifact-verifier.js +7 -1
  44. package/dist/src/serve/a2a-terminal-observer.js +28 -5
  45. package/dist/src/serve/dispatch-router.js +32 -4
  46. package/dist/src/serve/executor-registry.js +29 -0
  47. package/dist/src/serve/mission-conductor.js +15 -1
  48. package/dist/src/serve/pty-bridge.js +6 -11
  49. package/dist/src/serve/stack-adapters.js +2 -1
  50. package/dist/src/serve/telemetry.js +5 -1
  51. package/dist/src/skills/run.js +15 -6
  52. package/dist/src/update/checker.mjs +16 -15
  53. package/dist/src/update/notifier.mjs +8 -3
  54. package/dist/src/update/service.mjs +49 -5
  55. package/package.json +2 -1
@@ -15,6 +15,7 @@
15
15
  //
16
16
  // @issue #1256
17
17
  import { createHmac, timingSafeEqual } from 'node:crypto';
18
+ import { decodeStreamResponse, A2AEventReconciler } from './events.js';
18
19
  /** Header name (case-insensitive). */
19
20
  export const SIGNATURE_HEADER = 'x-aiwg-signature';
20
21
  /** Five minutes — RFC 8941 timestamp tolerance. */
@@ -111,6 +112,7 @@ function constantTimeHexEqual(a, b) {
111
112
  export class IdempotencyCache {
112
113
  capacity;
113
114
  seen = new Set();
115
+ pending = new Set();
114
116
  order = [];
115
117
  constructor(capacity = DEFAULT_IDEMPOTENCY_CAPACITY) {
116
118
  this.capacity = Math.max(16, capacity);
@@ -119,6 +121,23 @@ export class IdempotencyCache {
119
121
  markFresh(id) {
120
122
  if (this.seen.has(id))
121
123
  return false;
124
+ this.commit(id);
125
+ return true;
126
+ }
127
+ /** Reserve an event before parsing/routing so concurrent deliveries cannot race. */
128
+ begin(id) {
129
+ if (this.seen.has(id))
130
+ return 'duplicate';
131
+ if (this.pending.has(id))
132
+ return 'pending';
133
+ this.pending.add(id);
134
+ return 'fresh';
135
+ }
136
+ /** Mark a successfully routed reservation as completed. */
137
+ commit(id) {
138
+ this.pending.delete(id);
139
+ if (this.seen.has(id))
140
+ return;
122
141
  this.seen.add(id);
123
142
  this.order.push(id);
124
143
  while (this.order.length > this.capacity) {
@@ -126,7 +145,10 @@ export class IdempotencyCache {
126
145
  if (evicted !== undefined)
127
146
  this.seen.delete(evicted);
128
147
  }
129
- return true;
148
+ }
149
+ /** Release a failed reservation so a later retry can be processed. */
150
+ release(id) {
151
+ this.pending.delete(id);
130
152
  }
131
153
  size() {
132
154
  return this.seen.size;
@@ -134,15 +156,35 @@ export class IdempotencyCache {
134
156
  }
135
157
  export class PushSecretRegistry {
136
158
  entries = new Map();
159
+ reconcilers = new Map();
137
160
  register(entry) {
161
+ if (entry.protocolVersion === '1.0' && !entry.taskId) {
162
+ throw new Error('A2A 1.0 push config registration requires taskId ownership scope');
163
+ }
138
164
  this.entries.set(entry.configId, entry);
165
+ if (entry.taskId) {
166
+ this.reconcilers.set(entry.configId, new A2AEventReconciler({
167
+ taskId: entry.taskId,
168
+ ...(entry.contextId ? { contextId: entry.contextId } : {}),
169
+ }));
170
+ }
139
171
  }
140
172
  lookup(configId) {
141
173
  return this.entries.get(configId) ?? null;
142
174
  }
143
175
  unregister(configId) {
176
+ this.reconcilers.delete(configId);
144
177
  return this.entries.delete(configId);
145
178
  }
179
+ reconcile(configId, event) {
180
+ const entry = this.entries.get(configId);
181
+ const eventOwner = ownerOf(event);
182
+ if (entry?.taskOwner && eventOwner && entry.taskOwner !== eventOwner) {
183
+ throw new Error(`A2A event belongs to owner ${eventOwner}, expected ${entry.taskOwner}`);
184
+ }
185
+ const reconciler = this.reconcilers.get(configId);
186
+ return reconciler ? reconciler.accept(event) : event;
187
+ }
146
188
  /** Test/debug helper. */
147
189
  size() {
148
190
  return this.entries.size;
@@ -193,40 +235,83 @@ export async function handleWebhook(configId, body, signature, eventId, opts) {
193
235
  // Idempotency check — duplicate event-ids are accepted with 200 but
194
236
  // not re-routed. The executor's retry logic depends on a 2xx response
195
237
  // to mark delivery complete; failing here would cause infinite retry.
196
- const fresh = opts.idempotency.markFresh(eventId);
197
- if (!fresh) {
238
+ const entryForScope = opts.registry.lookup(configId);
239
+ const protocolVersion = entryForScope?.protocolVersion ?? '0.3';
240
+ const scopedEventId = [
241
+ configId,
242
+ protocolVersion,
243
+ entryForScope?.taskOwner ?? '',
244
+ entryForScope?.taskId ?? '',
245
+ eventId,
246
+ ].join('|');
247
+ const reservation = opts.idempotency.begin(scopedEventId);
248
+ if (reservation === 'duplicate') {
198
249
  return { status: 200, body: { ok: true, deduped: true } };
199
250
  }
251
+ if (reservation === 'pending') {
252
+ return {
253
+ status: 409,
254
+ body: errorBody('aiwg.webhook_event_in_progress', 'a concurrent delivery is still being processed'),
255
+ };
256
+ }
200
257
  // Route the verified payload. Errors thrown here become 500 so the
201
258
  // executor will retry — pick the abstraction carefully on the
202
259
  // mission-state side.
203
260
  const entry = opts.registry.lookup(configId);
204
261
  if (!entry) {
205
262
  // Edge case: secret was unregistered between verify and route.
263
+ opts.idempotency.release(scopedEventId);
206
264
  return {
207
265
  status: 404,
208
266
  body: errorBody('aiwg.webhook_secret_unknown', `configId='${configId}' no longer registered`),
209
267
  };
210
268
  }
269
+ if (protocolVersion === '1.0' && opts.contentType?.split(';')[0]?.trim().toLowerCase() !== 'application/a2a+json') {
270
+ opts.idempotency.release(scopedEventId);
271
+ return {
272
+ status: 415,
273
+ body: errorBody('aiwg.webhook_content_type_invalid', 'A2A 1.0 push requires application/a2a+json'),
274
+ };
275
+ }
211
276
  let parsed;
212
277
  try {
213
278
  parsed = JSON.parse(body.toString('utf8'));
214
279
  }
215
280
  catch (e) {
281
+ opts.idempotency.release(scopedEventId);
216
282
  return {
217
283
  status: 400,
218
284
  body: errorBody('aiwg.webhook_body_not_json', e.message),
219
285
  };
220
286
  }
287
+ let event;
221
288
  try {
222
- await opts.route(entry, parsed);
289
+ event = decodeStreamResponse(protocolVersion, parsed, { eventId });
290
+ const accepted = opts.registry.reconcile(configId, event);
291
+ if (!accepted) {
292
+ opts.idempotency.commit(scopedEventId);
293
+ return { status: 200, body: { ok: true, deduped: true } };
294
+ }
295
+ event = accepted;
223
296
  }
224
297
  catch (e) {
298
+ opts.idempotency.release(scopedEventId);
299
+ return {
300
+ status: 400,
301
+ body: errorBody('aiwg.webhook_event_invalid', e.message),
302
+ };
303
+ }
304
+ try {
305
+ await opts.route(entry, event);
306
+ }
307
+ catch (e) {
308
+ opts.idempotency.release(scopedEventId);
225
309
  return {
226
310
  status: 500,
227
311
  body: errorBody('aiwg.webhook_route_failed', e.message),
228
312
  };
229
313
  }
314
+ opts.idempotency.commit(scopedEventId);
230
315
  return { status: 200, body: { ok: true } };
231
316
  }
232
317
  function errorBody(code, detail) {
@@ -237,4 +322,16 @@ function errorBody(code, detail) {
237
322
  detail,
238
323
  };
239
324
  }
325
+ function ownerOf(event) {
326
+ const metadata = event.type === 'task'
327
+ ? event.task.metadata
328
+ : event.type === 'message'
329
+ ? event.message.metadata
330
+ : event.metadata;
331
+ const owner = metadata?.['task_owner']
332
+ ?? metadata?.['taskOwner']
333
+ ?? metadata?.['tenant_id']
334
+ ?? metadata?.['tenantId'];
335
+ return typeof owner === 'string' && owner ? owner : undefined;
336
+ }
240
337
  //# sourceMappingURL=webhook.js.map
@@ -454,6 +454,7 @@ function recordForEntry(cwd, entry, graphName, dependencyGraph, privacy, schemaV
454
454
  ...(entry.searchTerms?.length
455
455
  ? { aiwg_search_terms: uniqueSorted(entry.searchTerms) }
456
456
  : {}),
457
+ ...(entry.script ? { aiwg_script: entry.script } : {}),
457
458
  },
458
459
  },
459
460
  }
@@ -494,7 +494,7 @@ async function handleBuild(args) {
494
494
  console.log('');
495
495
  console.log('Default behavior (no --graph): builds all graphs with defaultBuild: true');
496
496
  console.log('Multi-graph builds run by buildOrder/buildTier (refs → citations → bibliography before heavy graphs)');
497
- console.log(' Built-in defaults: project (always), codebase (skipped if src/test/tools absent)');
497
+ console.log(' Built-in defaults: project (always), codebase (auto-detects JavaScript/TypeScript and Python layouts)');
498
498
  console.log('');
499
499
  console.log('Examples:');
500
500
  console.log(' aiwg index build');
@@ -210,6 +210,7 @@ function artifactTypeFromRecord(record) {
210
210
  function entryFromRecord(record) {
211
211
  const indexedFrontmatter = record.search?.frontmatter ?? {};
212
212
  const indexedSearchTerms = indexedFrontmatter.aiwg_search_terms;
213
+ const indexedScript = indexedFrontmatter.aiwg_script;
213
214
  return {
214
215
  path: record.source.path,
215
216
  type: artifactTypeFromRecord(record),
@@ -245,6 +246,11 @@ function entryFromRecord(record) {
245
246
  kernel: typeof record.search?.frontmatter?.kernel === "boolean"
246
247
  ? record.search.frontmatter.kernel
247
248
  : undefined,
249
+ script: indexedScript && typeof indexedScript === "object"
250
+ && typeof indexedScript.entrypoint === "string"
251
+ && typeof indexedScript.runtime === "string"
252
+ ? indexedScript
253
+ : undefined,
248
254
  operationalState: record.operational_state,
249
255
  };
250
256
  }
@@ -195,6 +195,60 @@ export const BUILTIN_GRAPH_CONFIGS = {
195
195
  * @implements #426
196
196
  */
197
197
  export const GRAPH_CONFIGS = { ...BUILTIN_GRAPH_CONFIGS };
198
+ function freshBuiltinGraphConfig(name) {
199
+ const config = BUILTIN_GRAPH_CONFIGS[name];
200
+ return {
201
+ ...config,
202
+ scanDirs: [...config.scanDirs],
203
+ extensions: [...config.extensions],
204
+ };
205
+ }
206
+ /**
207
+ * Detect conventional Python layouts without treating every top-level folder
208
+ * as source. A Python project manifest activates `.py`/`.pyi` support; package
209
+ * roots are immediate directories containing `__init__.py`, plus the common
210
+ * `tests/` and `scripts/` roots when present.
211
+ */
212
+ function detectPythonCodebaseConfig(cwd, base) {
213
+ const hasPythonManifest = ['pyproject.toml', 'setup.py', 'setup.cfg']
214
+ .some((manifest) => fs.existsSync(path.join(cwd, manifest)));
215
+ if (!hasPythonManifest)
216
+ return base;
217
+ const detectedRoots = [];
218
+ for (const root of ['tests', 'scripts']) {
219
+ if (fs.existsSync(path.join(cwd, root)))
220
+ detectedRoots.push(root);
221
+ }
222
+ const excluded = new Set([
223
+ '.aiwg', '.git', '.github', '.venv', 'venv', 'node_modules',
224
+ 'src', 'test', 'tests', 'tools', 'scripts', 'docs', 'documentation',
225
+ ]);
226
+ try {
227
+ for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
228
+ if (!entry.isDirectory() || excluded.has(entry.name) || entry.name.startsWith('.'))
229
+ continue;
230
+ if (fs.existsSync(path.join(cwd, entry.name, '__init__.py')))
231
+ detectedRoots.push(entry.name);
232
+ }
233
+ }
234
+ catch {
235
+ // Layout detection is best-effort; the immutable defaults still apply.
236
+ }
237
+ return {
238
+ ...base,
239
+ scanDirs: [...new Set([...base.scanDirs, ...detectedRoots])],
240
+ extensions: [...new Set([...base.extensions, '.py', '.pyi'])],
241
+ };
242
+ }
243
+ function applyBuiltinGraphOverride(base, override) {
244
+ if (!override)
245
+ return base;
246
+ return {
247
+ ...base,
248
+ scanDirs: override.scanDirs ? [...override.scanDirs] : base.scanDirs,
249
+ extensions: override.extensions ? [...override.extensions] : base.extensions,
250
+ };
251
+ }
198
252
  /**
199
253
  * Normalize metadataSupplements entries.
200
254
  *
@@ -400,6 +454,11 @@ export function loadModuleGraphConfigs(cwd, diagnostics) {
400
454
  * @implements #426 #726
401
455
  */
402
456
  export function loadUserGraphConfigs(cwd, diagnostics) {
457
+ // Built-ins are immutable in index.graphs, but codebase roots/extensions may
458
+ // be adapted through the explicitly bounded graphOverrides contract (#2123).
459
+ // Reset on every project load so a prior cwd cannot leak its override or
460
+ // detected Python package roots into a later build in the same process.
461
+ GRAPH_CONFIGS.codebase = detectPythonCodebaseConfig(cwd, freshBuiltinGraphConfig('codebase'));
403
462
  // Load module-declared graphs first (frameworks/addons)
404
463
  const moduleLoaded = loadModuleGraphConfigs(cwd, diagnostics);
405
464
  const loaded = [...moduleLoaded];
@@ -408,6 +467,7 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
408
467
  // config.yaml is a
409
468
  // deprecated fallback so un-migrated corpora keep working.
410
469
  let graphs;
470
+ let graphOverrides;
411
471
  let fromDeprecatedYaml = false;
412
472
  // (a) Canonical: .aiwg/aiwg.config (JSON).
413
473
  try {
@@ -418,6 +478,9 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
418
478
  const g = idx?.graphs;
419
479
  if (g && typeof g === 'object')
420
480
  graphs = g;
481
+ const overrides = idx?.graphOverrides;
482
+ if (overrides && typeof overrides === 'object')
483
+ graphOverrides = overrides;
421
484
  }
422
485
  }
423
486
  catch {
@@ -430,7 +493,7 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
430
493
  });
431
494
  }
432
495
  // (b) Fallback: legacy .aiwg/config.yaml.
433
- if (!graphs) {
496
+ if (!graphs && !graphOverrides) {
434
497
  try {
435
498
  const configPath = projectAiwgPath(cwd, 'config.yaml');
436
499
  if (fs.existsSync(configPath)) {
@@ -441,12 +504,21 @@ export function loadUserGraphConfigs(cwd, diagnostics) {
441
504
  graphs = g;
442
505
  fromDeprecatedYaml = true;
443
506
  }
507
+ const overrides = idx?.graphOverrides;
508
+ if (overrides && typeof overrides === 'object') {
509
+ graphOverrides = overrides;
510
+ fromDeprecatedYaml = true;
511
+ }
444
512
  }
445
513
  }
446
514
  catch {
447
515
  // best-effort
448
516
  }
449
517
  }
518
+ const codebaseOverride = graphOverrides?.codebase;
519
+ if (codebaseOverride && typeof codebaseOverride === 'object' && !Array.isArray(codebaseOverride)) {
520
+ GRAPH_CONFIGS.codebase = applyBuiltinGraphOverride(GRAPH_CONFIGS.codebase, codebaseOverride);
521
+ }
450
522
  if (!graphs)
451
523
  return loaded;
452
524
  if (fromDeprecatedYaml && !yamlIndexDeprecationWarned) {
@@ -22,8 +22,9 @@ export function createDecisionRecord(input, previousHash) {
22
22
  const actor = redact(input.actor);
23
23
  const correlation = redact(input.correlation);
24
24
  const runtime = input.runtime ? redact(input.runtime) : undefined;
25
+ const graph = input.graph ? redact(input.graph) : undefined;
25
26
  const reason = redact(input.reason);
26
- const detected = [...actor.paths, ...correlation.paths, ...(runtime?.paths ?? []), ...reason.paths];
27
+ const detected = [...actor.paths, ...correlation.paths, ...(runtime?.paths ?? []), ...(graph?.paths ?? []), ...reason.paths];
27
28
  const unsigned = {
28
29
  schema_version: OPERATOR_DECISION_SCHEMA,
29
30
  event_id: input.event_id ?? randomUUID(),
@@ -36,6 +37,7 @@ export function createDecisionRecord(input, previousHash) {
36
37
  classification: input.classification,
37
38
  correlation: correlation.value,
38
39
  ...(runtime ? { runtime: runtime.value } : {}),
40
+ ...(graph ? { graph: graph.value } : {}),
39
41
  ...(input.policy_ref ? { policy_ref: input.policy_ref } : {}),
40
42
  redacted_fields: [...new Set([...(input.redacted_fields ?? []), ...detected])].sort(),
41
43
  previous_hash: previousHash,
@@ -74,6 +76,15 @@ export function toOpenTelemetryLog(record) {
74
76
  'aiwg.provider.id': record.correlation.provider_id,
75
77
  'aiwg.sandbox.task_id': record.correlation.sandbox_task_id,
76
78
  'aiwg.prompt.id': record.correlation.prompt_id,
79
+ 'aiwg.graph.id': record.graph?.graph_id,
80
+ 'aiwg.graph.version': record.graph?.graph_version,
81
+ 'aiwg.graph.run_id': record.graph?.run_id,
82
+ 'aiwg.graph.node_id': record.graph?.node_id,
83
+ 'aiwg.graph.node_run_id': record.graph?.node_run_id,
84
+ 'aiwg.graph.edge_id': record.graph?.edge_id,
85
+ 'aiwg.graph.route_name': record.graph?.route_name,
86
+ 'aiwg.graph.checkpoint_id': record.graph?.checkpoint_id,
87
+ 'aiwg.graph.replay_parent_run_id': record.graph?.replay_parent_run_id,
77
88
  }).filter(([, value]) => value !== undefined).map(([key, value]) => ({
78
89
  key,
79
90
  value: { stringValue: String(value) },
@@ -135,6 +146,9 @@ function validateInput(input) {
135
146
  throw new Error('a non-empty operator reason is required');
136
147
  if (!Object.values(input.correlation).some(Boolean))
137
148
  throw new Error('at least one correlation identifier is required');
149
+ if (input.graph && ![input.graph.graph_id, input.graph.graph_version, input.graph.run_id, input.graph.node_id, input.graph.node_run_id].every(value => typeof value === 'string' && value.length > 0)) {
150
+ throw new Error('graph decision context requires graph, run, node, and node-run identity');
151
+ }
138
152
  if (input.timestamp && !Number.isFinite(Date.parse(input.timestamp)))
139
153
  throw new Error('timestamp must be valid ISO time');
140
154
  }
@@ -13,8 +13,17 @@ import fs from 'fs/promises';
13
13
  import { readFileSync, existsSync } from 'fs';
14
14
  import path from 'path';
15
15
  import { fileURLToPath } from 'url';
16
- import { execSync, spawn } from 'child_process';
16
+ import { execFileSync, execSync, spawn } from 'child_process';
17
17
  import os from 'os';
18
+ import { resolveUserConfigDir } from '../config/user-config-dir.mjs';
19
+ import {
20
+ assertCanonicalInstallation,
21
+ createInstallationIdentity,
22
+ inferInstallationMethod,
23
+ inspectInstallation,
24
+ loadInstallationIdentity,
25
+ saveInstallationIdentity,
26
+ } from '../installation/manager.mjs';
18
27
 
19
28
  /**
20
29
  * Run a command with inherited stdio, applying a wall-clock timeout so a
@@ -80,8 +89,6 @@ const __filename = fileURLToPath(import.meta.url);
80
89
  const __dirname = path.dirname(__filename);
81
90
 
82
91
  // Configuration paths
83
- const CONFIG_DIR = path.join(os.homedir(), '.aiwg');
84
- const CONFIG_FILE = path.join(CONFIG_DIR, 'channel.json');
85
92
  const EDGE_INSTALL_PATH = path.join(os.homedir(), '.local', 'share', 'ai-writing-guide');
86
93
  const REPO_URL = 'https://github.com/jmagly/aiwg.git';
87
94
 
@@ -132,22 +139,65 @@ export function getPackageRoot() {
132
139
  * Load channel configuration
133
140
  * @returns {Promise<object>} Channel configuration
134
141
  */
135
- export async function loadConfig() {
142
+ export async function loadConfig(options = {}) {
143
+ const configDir = resolveUserConfigDir(options);
144
+ const configFile = path.join(configDir, 'channel.json');
145
+ let legacy = {};
136
146
  try {
137
- const data = await fs.readFile(CONFIG_FILE, 'utf8');
138
- return { ...DEFAULT_CONFIG, ...JSON.parse(data) };
147
+ const data = await fs.readFile(configFile, 'utf8');
148
+ legacy = JSON.parse(data);
139
149
  } catch {
140
- return { ...DEFAULT_CONFIG };
150
+ // Legacy state is optional. The canonical identity below is authoritative.
141
151
  }
152
+ const actualRoot = options.actualRoot ?? getPackageRoot();
153
+ const identity = loadInstallationIdentity({ ...options, actualRoot, legacyConfig: legacy });
154
+ if (!identity) return { ...DEFAULT_CONFIG, ...legacy };
155
+ return {
156
+ ...DEFAULT_CONFIG,
157
+ ...legacy,
158
+ channel: identity.channel,
159
+ edgePath: identity.edgePath ?? legacy.edgePath ?? EDGE_INSTALL_PATH,
160
+ devMode: identity.runMode === 'development',
161
+ lastUpdateCheck: identity.lastUpdateCheck,
162
+ updateCheckInterval: identity.updateCheckInterval,
163
+ checkOnStartup: identity.checkOnStartup,
164
+ installation: identity,
165
+ };
142
166
  }
143
167
 
144
168
  /**
145
169
  * Save channel configuration
146
170
  * @param {object} config - Configuration to save
147
171
  */
148
- export async function saveConfig(config) {
149
- await fs.mkdir(CONFIG_DIR, { recursive: true });
150
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
172
+ export async function saveConfig(config, options = {}) {
173
+ const configDir = resolveUserConfigDir(options);
174
+ const configFile = path.join(configDir, 'channel.json');
175
+ await fs.mkdir(configDir, { recursive: true });
176
+ const { installation: _installation, ...legacyConfig } = config;
177
+ await fs.writeFile(configFile, `${JSON.stringify(legacyConfig, null, 2)}\n`);
178
+
179
+ const actualRoot = options.actualRoot ?? getPackageRoot();
180
+ const previous = loadInstallationIdentity({ ...options, actualRoot, legacyConfig: config });
181
+ if (previous) {
182
+ const development = config.devMode === true;
183
+ const root = development && config.edgePath ? config.edgePath : previous.root;
184
+ const method = development ? 'source' : previous.method;
185
+ saveInstallationIdentity(createInstallationIdentity({
186
+ ...options,
187
+ actualRoot,
188
+ root,
189
+ method,
190
+ runMode: development ? 'development' : 'normal',
191
+ channel: config.channel ?? previous.channel,
192
+ edgePath: config.edgePath ?? previous.edgePath,
193
+ managerExecutable: development ? undefined : previous.managerExecutable,
194
+ updateStrategy: development ? 'source-git' : previous.updateStrategy,
195
+ lastUpdateCheck: config.lastUpdateCheck ?? previous.lastUpdateCheck,
196
+ updateCheckInterval: config.updateCheckInterval ?? previous.updateCheckInterval,
197
+ checkOnStartup: config.checkOnStartup ?? previous.checkOnStartup,
198
+ recordedAt: previous.recordedAt,
199
+ }), options);
200
+ }
151
201
  }
152
202
 
153
203
  /**
@@ -178,7 +228,7 @@ export async function getFrameworkRoot() {
178
228
  }
179
229
  }
180
230
 
181
- return getPackageRoot();
231
+ return config.installation?.root ?? getPackageRoot();
182
232
  }
183
233
 
184
234
  /**
@@ -316,12 +366,16 @@ export async function switchToDev(devPath) {
316
366
  */
317
367
  export async function switchToNext() {
318
368
  const config = await loadConfig();
369
+ const status = assertCanonicalInstallation({ actualRoot: getPackageRoot() });
370
+ if (status.identity.method !== 'npm' || !status.identity.managerExecutable) {
371
+ throw new Error('The next channel requires a canonical npm installation with a recorded package-manager executable.');
372
+ }
319
373
 
320
374
  console.log('Switching to next channel (alpha/beta/RC — latest pre-release)...');
321
375
  console.log('');
322
376
 
323
377
  try {
324
- execSync('npm install -g aiwg@next', { stdio: 'inherit' });
378
+ execFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@next'], { stdio: 'inherit' });
325
379
  } catch (error) {
326
380
  console.error('Failed to install aiwg@next:', error.message);
327
381
  console.error('Check that npm is available and you have write access to the global prefix.');
@@ -346,12 +400,16 @@ export async function switchToNext() {
346
400
  */
347
401
  export async function switchToNightly() {
348
402
  const config = await loadConfig();
403
+ const status = assertCanonicalInstallation({ actualRoot: getPackageRoot() });
404
+ if (status.identity.method !== 'npm' || !status.identity.managerExecutable) {
405
+ throw new Error('The nightly channel requires a canonical npm installation with a recorded package-manager executable.');
406
+ }
349
407
 
350
408
  console.log('Switching to nightly channel (latest automated snapshot)...');
351
409
  console.log('');
352
410
 
353
411
  try {
354
- execSync('npm install -g aiwg@nightly', { stdio: 'inherit' });
412
+ execFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@nightly'], { stdio: 'inherit' });
355
413
  } catch (error) {
356
414
  console.error('Failed to install aiwg@nightly:', error.message);
357
415
  console.error('Check that npm is available and you have write access to the global prefix.');
@@ -376,17 +434,29 @@ export async function switchToNightly() {
376
434
  export async function switchToStable() {
377
435
  const config = await loadConfig();
378
436
 
379
- console.log('Switching to stable channel (npm package)...');
437
+ console.log('Switching to the stable channel...');
380
438
  console.log('');
381
439
 
382
440
  config.channel = 'stable';
383
441
  config.devMode = false;
384
442
  await saveConfig(config);
443
+ const actualRoot = getPackageRoot();
444
+ saveInstallationIdentity(createInstallationIdentity({
445
+ actualRoot,
446
+ root: actualRoot,
447
+ method: inferInstallationMethod(actualRoot),
448
+ runMode: 'normal',
449
+ channel: 'stable',
450
+ edgePath: config.edgePath,
451
+ lastUpdateCheck: config.lastUpdateCheck,
452
+ updateCheckInterval: config.updateCheckInterval,
453
+ checkOnStartup: config.checkOnStartup,
454
+ }));
385
455
 
386
456
  console.log('Switched to stable channel.');
387
- console.log('You are now using the npm-installed package.');
457
+ console.log('You are now using the canonical installed package.');
388
458
  console.log('');
389
- console.log('To update: npm install -g aiwg@latest');
459
+ console.log('To update: aiwg refresh --channel latest');
390
460
  console.log('To switch to edge: aiwg --use-main');
391
461
  }
392
462
 
@@ -415,6 +485,7 @@ function normalizeRepoUrl(repository) {
415
485
  export async function getVersionInfo() {
416
486
  const config = await loadConfig();
417
487
  const packageRoot = getPackageRoot();
488
+ const installation = inspectInstallation({ actualRoot: packageRoot, identity: config.installation });
418
489
 
419
490
  // Read package.json version
420
491
  const packageJsonPath = path.join(packageRoot, 'package.json');
@@ -441,6 +512,7 @@ export async function getVersionInfo() {
441
512
  version,
442
513
  channel,
443
514
  packageRoot,
515
+ installation,
444
516
  devMode: config.devMode || false,
445
517
  // Public-facing URLs — single source of truth is package.json, so user-visible
446
518
  // stamps/links never hardcode the internal build origin. The published package
@@ -480,7 +552,7 @@ export async function updateEdge() {
480
552
  const config = await loadConfig();
481
553
 
482
554
  if (config.channel !== 'edge') {
483
- console.log('Not in edge channel. Use npm install -g aiwg@latest for stable channel.');
555
+ console.log('Not in edge channel. Use `aiwg update` for the canonical installed channel.');
484
556
  return;
485
557
  }
486
558
 
@@ -27,8 +27,10 @@ export const PROVIDER_CONFIGS = {
27
27
  },
28
28
  codex: {
29
29
  binary: 'codex',
30
- // Codex supports --full-auto (no approval prompts) and --approval-mode full-auto
31
- dangerousFlag: '--full-auto',
30
+ // Current Codex releases use the explicit bypass flag for unrestricted,
31
+ // non-interactive execution. Keep this centralized so every launcher maps
32
+ // AIWG's --dangerous option consistently.
33
+ dangerousFlag: '--dangerously-bypass-approvals-and-sandbox',
32
34
  name: 'OpenAI Codex',
33
35
  },
34
36
  hermes: {
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import path from 'node:path';
6
+ import { formatCockpitDoctor, runCockpitDoctor, } from '../../cockpit/doctor.js';
6
7
  export const COCKPIT_PACKAGE_NAME = '@aiwg/cockpit';
7
8
  export function cockpitHome() {
8
9
  return process.env.AIWG_COCKPIT_HOME || path.join(homedir(), '.aiwg', 'cockpit', 'package');
@@ -22,6 +23,16 @@ async function coreVersion(frameworkRoot) {
22
23
  function packageRoot(home = cockpitHome()) {
23
24
  return path.join(home, 'node_modules', '@aiwg', 'cockpit');
24
25
  }
26
+ function valueAfter(args, flag) {
27
+ const index = args.indexOf(flag);
28
+ return index >= 0 ? args[index + 1] : undefined;
29
+ }
30
+ function doctorFormat(args) {
31
+ if (args.includes('--json'))
32
+ return 'json';
33
+ const value = valueAfter(args, '--format');
34
+ return value === 'json' || value === 'markdown' ? value : 'text';
35
+ }
25
36
  export async function resolveCockpitInstall(home = cockpitHome()) {
26
37
  const root = packageRoot(home);
27
38
  const pkg = await readJson(path.join(root, 'package.json'));
@@ -95,6 +106,36 @@ export const cockpitHandler = {
95
106
  const install = await resolveCockpitInstall();
96
107
  const version = await coreVersion(ctx.frameworkRoot);
97
108
  const autoInstall = ctx.args.includes('--install') || ctx.args.includes('--yes') || ctx.args.includes('-y');
109
+ if (ctx.args[0] === 'doctor' || ctx.args.includes('--doctor')) {
110
+ const sourcePackageRoot = path.join(ctx.frameworkRoot, 'apps', 'cockpit');
111
+ const sourcePackage = !install.installed ? await readJson(path.join(sourcePackageRoot, 'package.json')) : null;
112
+ const doctorInstall = install.installed ? install : {
113
+ installed: Boolean(sourcePackage),
114
+ version: typeof sourcePackage?.version === 'string' ? sourcePackage.version : undefined,
115
+ packageRoot: sourcePackageRoot,
116
+ };
117
+ const topologyValue = valueAfter(ctx.args, '--topology');
118
+ const topology = topologyValue === 'ssh-local' || topologyValue === 'ssh-reverse'
119
+ ? topologyValue
120
+ : 'same-host';
121
+ const report = await runCockpitDoctor({
122
+ coreVersion: version,
123
+ cockpitInstalled: doctorInstall.installed,
124
+ cockpitVersion: doctorInstall.version,
125
+ cockpitPackageRoot: doctorInstall.packageRoot,
126
+ topology,
127
+ cockpitHost: valueAfter(ctx.args, '--cockpit-host'),
128
+ executorHost: valueAfter(ctx.args, '--executor-host'),
129
+ expectedExecutorVersion: valueAfter(ctx.args, '--executor-version'),
130
+ forwardEndpoint: valueAfter(ctx.args, '--forward-endpoint'),
131
+ runtimeFile: valueAfter(ctx.args, '--runtime-file'),
132
+ });
133
+ return {
134
+ exitCode: report.status === 'blocked' ? 1 : 0,
135
+ message: formatCockpitDoctor(report, doctorFormat(ctx.args)),
136
+ rawOutput: true,
137
+ };
138
+ }
98
139
  if (ctx.args.includes('--status')) {
99
140
  return {
100
141
  exitCode: 0,