@aiwg/cli 2026.10.10 → 2026.10.11

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 (34) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +78 -14
  2. package/dist/src/cli/handlers/sessions.js +198 -15
  3. package/dist/src/cli/handlers/use.js +12 -4
  4. package/dist/src/config/worker-context.js +337 -0
  5. package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
  6. package/dist/src/models/model-capabilities.v1.json +78 -14
  7. package/dist/src/models/provider-policy.js +7 -0
  8. package/dist/src/policy/authorization.js +29 -1
  9. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  10. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  11. package/dist/src/sessions/adapters/grokbot.js +7 -0
  12. package/dist/src/sessions/batch-import.js +34 -6
  13. package/dist/src/sessions/contracts.js +19 -0
  14. package/dist/src/sessions/coverage-preflight.js +7 -1
  15. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  16. package/dist/src/sessions/host-catalog.js +157 -0
  17. package/dist/src/sessions/image-blobs.js +141 -0
  18. package/dist/src/sessions/importer.js +23 -3
  19. package/dist/src/sessions/index.js +2 -0
  20. package/dist/src/sessions/refresh.js +25 -2
  21. package/dist/src/sessions/repository.js +164 -22
  22. package/dist/src/sessions/workspace-discovery.js +5 -2
  23. package/package.json +7 -1
  24. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  25. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  26. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  27. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  28. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  29. package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
  30. package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
  31. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  32. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  33. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  34. package/tools/ralph-external/session-launcher.mjs +51 -96
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Worker AIWG context providers and launch-time bootstrap ensure (#3685).
3
+ *
4
+ * Orchestration must automatically `aiwg use` the providers configured for
5
+ * it, so any new worker process starts with the AIWG bootstrap in place.
6
+ * This module owns the config-side half: resolving WHICH providers
7
+ * (config `providers` plus every CLI configured under
8
+ * `parallelism.clis` / `parallelism.groups.*.members`, mapped through the
9
+ * provider registry) and checking/ensuring that bootstrap inside exactly
10
+ * one worker workdir (never outside it).
11
+ *
12
+ * The tools/ half (`tools/ralph-external/lib/worker-context.mjs`) loads the
13
+ * config through `readAiwgConfig` and injects it here; direct callers may
14
+ * pass an already-loaded config object instead.
15
+ */
16
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
17
+ import { spawnSync } from 'node:child_process';
18
+ import path from 'node:path';
19
+ import { getProviderArtifactPathStrings, getProviderDefinition, normalizeProviderDefinitionId, } from '../providers/provider-definitions.js';
20
+ import { emptyConfig, readAiwgConfig, writeAiwgConfig, } from './aiwg-config.js';
21
+ /**
22
+ * Typed refusal when a worker's AIWG bootstrap cannot be ensured.
23
+ * `remedy` names the operator action that unblocks the launch.
24
+ */
25
+ export class WorkerContextBootstrapError extends Error {
26
+ code = 'OG_WORKER_CONTEXT_UNAVAILABLE';
27
+ remedy;
28
+ provider;
29
+ workdir;
30
+ constructor(message, remedy, opts) {
31
+ super(message);
32
+ this.name = 'WorkerContextBootstrapError';
33
+ this.remedy = remedy;
34
+ if (opts?.provider !== undefined)
35
+ this.provider = opts.provider;
36
+ if (opts?.workdir !== undefined)
37
+ this.workdir = opts.workdir;
38
+ }
39
+ }
40
+ /** Explicit provider lists must be non-empty arrays of deployable ids. */
41
+ export function validateExplicitWorkerContextProviders(providers) {
42
+ if (!Array.isArray(providers) || providers.length === 0
43
+ || providers.some(p => typeof p !== 'string' || p.length === 0 || p.includes('\0') || p.includes(','))) {
44
+ throw new WorkerContextBootstrapError('worker context providers must be a non-empty array of provider ids', 'pass an explicit non-empty provider list, or configure providers plus orchestration CLIs in .aiwg/aiwg.config');
45
+ }
46
+ return [...new Set(providers)];
47
+ }
48
+ const ADAPTER_REFERENCE = /^(providers|workerAdapters):([a-z][a-z0-9-]{0,62})$/;
49
+ /**
50
+ * Map one orchestration CLI id to its AIWG provider id.
51
+ * A CLI without an explicit `parallelism.clis` entry defaults to
52
+ * `providers:<cli>` (the same default `resolveWorkerGroupInstallation`
53
+ * applies). Non-provider adapters (e.g. `workerAdapters:local-http`) and
54
+ * ids the registry does not know are reported as skipped, never guessed.
55
+ */
56
+ export function mapCliToProviderId(cli, parallelism) {
57
+ if (typeof cli !== 'string' || cli.length === 0) {
58
+ return { skipped: `empty CLI reference` };
59
+ }
60
+ const raw = typeof parallelism?.clis?.[cli]?.adapter === 'string'
61
+ && parallelism.clis[cli].adapter.length > 0
62
+ ? parallelism.clis[cli].adapter
63
+ : `providers:${cli}`;
64
+ const match = ADAPTER_REFERENCE.exec(raw);
65
+ if (!match || match[1] !== 'providers') {
66
+ return { skipped: `clis/${cli}: ${raw}` };
67
+ }
68
+ const provider = normalizeProviderDefinitionId(match[2]);
69
+ if (!provider)
70
+ return { skipped: `clis/${cli}: ${raw} (unknown provider)` };
71
+ return { provider };
72
+ }
73
+ /**
74
+ * Resolve the full worker-context provider set: the union of the config's
75
+ * own `providers` and every CLI configured for orchestration
76
+ * (`parallelism.clis` keys/adapters and the `cli` of every
77
+ * `parallelism.groups.*.members[]`), mapped to AIWG provider ids through
78
+ * the provider registry. Non-provider adapters are skipped and reported.
79
+ * Fails closed when the union is empty.
80
+ */
81
+ export function resolveWorkerContextProvidersFromConfig(config) {
82
+ const providers = [];
83
+ const skipped = [];
84
+ const add = (id) => {
85
+ if (!providers.includes(id))
86
+ providers.push(id);
87
+ };
88
+ const skip = (entry) => {
89
+ if (!skipped.includes(entry))
90
+ skipped.push(entry);
91
+ };
92
+ const configured = config?.providers;
93
+ if (Array.isArray(configured)) {
94
+ for (const entry of configured) {
95
+ if (typeof entry !== 'string' || entry.length === 0)
96
+ continue;
97
+ // Configured providers deploy verbatim when the registry does not
98
+ // know them: project-local provider adapters resolve at deploy time
99
+ // (see the `use` handler's project-local resolution), so an unknown
100
+ // id here is the deployer's decision, not this resolver's.
101
+ add(normalizeProviderDefinitionId(entry) ?? entry);
102
+ }
103
+ }
104
+ const parallelism = config?.parallelism;
105
+ for (const cli of Object.keys(parallelism?.clis ?? {})) {
106
+ const mapped = mapCliToProviderId(cli, parallelism);
107
+ if ('provider' in mapped)
108
+ add(mapped.provider);
109
+ else
110
+ skip(mapped.skipped);
111
+ }
112
+ for (const [groupId, group] of Object.entries(parallelism?.groups ?? {})) {
113
+ for (const member of group?.members ?? []) {
114
+ const cli = member?.cli;
115
+ const mapped = mapCliToProviderId(cli, parallelism);
116
+ if ('provider' in mapped)
117
+ add(mapped.provider);
118
+ else
119
+ skip(typeof cli === 'string' && cli ? `groups/${groupId}/members cli '${cli}': ${mapped.skipped}` : `groups/${groupId}/members: ${mapped.skipped}`);
120
+ }
121
+ }
122
+ if (providers.length === 0) {
123
+ throw new WorkerContextBootstrapError('worker context deployment needs configured providers: the union of config providers and orchestration CLIs is empty'
124
+ + (skipped.length > 0 ? ` (skipped: ${skipped.join('; ')})` : ''), 'configure providers and/or parallelism clis/groups in .aiwg/aiwg.config, or pass an explicit provider list');
125
+ }
126
+ return { providers, skipped };
127
+ }
128
+ /** AIWG-managed rule-section markers written into a deployed AGENTS.md. */
129
+ const WORKER_CONTEXT_RULE_MARKER = /<!--\s*AIWG:[A-Za-z0-9_-]+:(start|end)\s*-->/;
130
+ /**
131
+ * Check that a worker workdir already carries one provider's bootstrap:
132
+ * the kernel artifact dirs (`use all` writes kernel skills, rules,
133
+ * behaviors — never the full agent/command surface) and, for providers
134
+ * whose contract targets AGENTS.md, the AIWG rule sections.
135
+ */
136
+ export function workerContextBootstrapStatus({ workdir, provider }) {
137
+ const missing = [];
138
+ const definition = getProviderDefinition(provider);
139
+ if (!definition) {
140
+ return { present: false, missing: [`unknown provider '${provider}'`] };
141
+ }
142
+ const paths = getProviderArtifactPathStrings(provider);
143
+ const kernelDirs = [
144
+ definition.paths.kernelSkills,
145
+ paths?.rules ?? null,
146
+ paths?.behaviors ?? null,
147
+ ].filter((dir) => typeof dir === 'string' && dir.length > 0);
148
+ const requiredDirs = kernelDirs.length > 0
149
+ ? kernelDirs
150
+ : Object.values(paths ?? {}).filter((dir) => typeof dir === 'string' && dir.length > 0);
151
+ for (const dir of [...new Set(requiredDirs)]) {
152
+ const absolute = path.isAbsolute(dir) ? dir : path.join(workdir, dir);
153
+ let entries = null;
154
+ try {
155
+ entries = readdirSync(absolute);
156
+ }
157
+ catch {
158
+ entries = null;
159
+ }
160
+ if (!entries || entries.length === 0)
161
+ missing.push(`provider dir '${dir}'`);
162
+ }
163
+ if ((definition.context?.bootstrapTargets ?? []).includes('AGENTS.md')) {
164
+ let content = null;
165
+ try {
166
+ content = readFileSync(path.join(workdir, 'AGENTS.md'), 'utf8');
167
+ }
168
+ catch {
169
+ content = null;
170
+ }
171
+ if (!content || !WORKER_CONTEXT_RULE_MARKER.test(content)) {
172
+ missing.push('AGENTS.md rule sections');
173
+ }
174
+ }
175
+ return { present: missing.length === 0, missing };
176
+ }
177
+ /**
178
+ * Materialize the union provider set into the worker workdir's own config
179
+ * through the existing loader/writer (never ad-hoc JSON). Existing entries
180
+ * keep their order; union members absent from the worktree base are
181
+ * appended. A missing config is created minimal. An already-current config
182
+ * is left untouched so the deploy below stays the only writer.
183
+ */
184
+ export async function syncWorkerContextProviders(options) {
185
+ const { workdir } = options;
186
+ if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) {
187
+ throw new WorkerContextBootstrapError(`worker context sync needs an absolute workdir, got ${JSON.stringify(workdir)}`, 'sync worker context providers only into the worker workdir', { workdir: typeof workdir === 'string' ? workdir : undefined });
188
+ }
189
+ const providers = validateExplicitWorkerContextProviders(options.providers);
190
+ const read = options.readConfig ?? ((dir) => readAiwgConfig(dir));
191
+ const existing = await read(workdir);
192
+ const current = Array.isArray(existing?.providers)
193
+ ? existing.providers.filter((entry) => typeof entry === 'string')
194
+ : [];
195
+ const merged = [...current];
196
+ for (const provider of providers) {
197
+ if (!merged.includes(provider))
198
+ merged.push(provider);
199
+ }
200
+ const unchanged = merged.length === current.length && merged.every((entry, index) => entry === current[index]);
201
+ if (unchanged)
202
+ return { providers: merged, updated: false };
203
+ const base = existing !== null && typeof existing === 'object' && 'version' in existing
204
+ ? existing
205
+ : {};
206
+ const next = { ...emptyConfig(merged), ...base, providers: merged };
207
+ const write = options.writeConfig ?? ((dir, config) => writeAiwgConfig(dir, config));
208
+ await write(workdir, next);
209
+ return { providers: merged, updated: true };
210
+ }
211
+ /**
212
+ * Canonical one-pass deploy argv: a single `aiwg use all` invocation
213
+ * covering every listed provider. Never one deploy per provider:
214
+ * per-provider invocations rewrite AGENTS.md piecemeal and clobber each
215
+ * other's rule sections, while one pass keeps them all. No `--providers`
216
+ * flag: it feeds only the global bootstrap and the orchestrated
217
+ * verification report — `executeCore` resolves project-scope providers
218
+ * from config (`src/cli/handlers/use.ts`), ignoring both the flag and any
219
+ * explicitly resolved list. The supported project-scope mechanism is the
220
+ * configured-provider path: `syncWorkerContextProviders` first, then this
221
+ * single pass reads the union back from the worktree config.
222
+ */
223
+ export function buildWorkerContextDeployArgs({ providers, target }) {
224
+ // Fail fast on a hostile list; the synced worktree config carries the set.
225
+ validateExplicitWorkerContextProviders(providers);
226
+ return ['use', 'all', '--target', target, '--yes'];
227
+ }
228
+ /**
229
+ * Ensure a worker workdir carries its CLI's AIWG bootstrap before launch.
230
+ * Present: no-op. Missing: sync the union set into the workdir config,
231
+ * then exactly one `aiwg use all` pass into the workdir (never anywhere
232
+ * else), then re-verify. Impossible (no binary, failed deploy, still
233
+ * missing afterwards): throw `WorkerContextBootstrapError` naming the
234
+ * remedy — the caller refuses the launch rather than starting a
235
+ * context-free worker.
236
+ */
237
+ export async function ensureWorkerContextBootstrap(options) {
238
+ const { workdir, provider, aiwgBin = process.env.AIWG_BIN ?? 'aiwg', spawn = spawnSync, timeoutMs = 300000, statusFn = workerContextBootstrapStatus, } = options;
239
+ if (typeof workdir !== 'string' || !path.isAbsolute(workdir) || !existsSync(workdir)) {
240
+ throw new WorkerContextBootstrapError(`worker context bootstrap needs an existing absolute workdir, got ${JSON.stringify(workdir)}`, 'launch the worker with its resolved absolute workdir', { provider, workdir: typeof workdir === 'string' ? workdir : undefined });
241
+ }
242
+ const status = statusFn({ workdir, provider });
243
+ if (status.present)
244
+ return { ensured: false, providers: [provider], skippedBridges: [] };
245
+ const providers = options.providers && options.providers.length > 0
246
+ ? validateExplicitWorkerContextProviders(options.providers)
247
+ : [provider];
248
+ // The configured-provider path: the single pass below resolves its set
249
+ // from the workdir config, so the union must be materialized there
250
+ // first — `--providers` never reaches project-scope deploys.
251
+ const sync = options.syncFn ?? syncWorkerContextProviders;
252
+ let synced;
253
+ try {
254
+ synced = await sync({ workdir, providers });
255
+ }
256
+ catch (error) {
257
+ if (error instanceof WorkerContextBootstrapError)
258
+ throw error;
259
+ throw new WorkerContextBootstrapError(`worker context sync failed: ${error instanceof Error ? error.message : String(error)}`, `write the union providers into ${path.join(workdir, '.aiwg', 'aiwg.config')} (or run 'aiwg use all --target ${workdir} --yes' manually) before launch`, { provider, workdir });
260
+ }
261
+ const argv = buildWorkerContextDeployArgs({ providers: synced.providers, target: workdir });
262
+ const deploy = options.deployFn ?? (({ worktreePath, providers: deployProviders, aiwgBin: bin }) => {
263
+ // worktreePath IS the worker workdir by construction: the only
264
+ // `--target` ever passed is `workdir` above, and the spawn cwd is the
265
+ // same directory, so no deploy write can land outside it.
266
+ if (path.resolve(worktreePath) !== path.resolve(workdir)) {
267
+ throw new WorkerContextBootstrapError(`refusing worker context deploy outside the worker workdir (${worktreePath} !== ${workdir})`, 'deploy worker context only into the worker workdir', { provider, workdir });
268
+ }
269
+ const result = spawn(bin, argv, { cwd: workdir, encoding: 'utf8', timeout: timeoutMs });
270
+ if (result.error) {
271
+ throw new WorkerContextBootstrapError(`worker context deploy failed to run (${result.error?.message});`
272
+ + ` missing in ${workdir}: ${status.missing.join('; ')}`, `install the aiwg CLI on PATH or set AIWG_BIN to its location (tried '${bin}'),`
273
+ + ` or run 'aiwg ${argv.join(' ')}' manually before launch`, { provider, workdir });
274
+ }
275
+ if (result.status !== 0) {
276
+ throw new WorkerContextBootstrapError(`worker context deploy exited ${result.status}: ${String(result.stderr ?? '').trim().slice(0, 2000)};`
277
+ + ` missing in ${workdir}: ${status.missing.join('; ')}`, `run 'aiwg ${argv.join(' ')}' manually and resolve the reported failure before launch`, { provider, workdir });
278
+ }
279
+ return { providers: [...deployProviders] };
280
+ });
281
+ let deployed;
282
+ try {
283
+ deployed = await deploy({ worktreePath: workdir, providers: synced.providers, aiwgBin });
284
+ }
285
+ catch (error) {
286
+ if (error instanceof WorkerContextBootstrapError)
287
+ throw error;
288
+ throw new WorkerContextBootstrapError(`worker context deploy failed: ${error instanceof Error ? error.message : String(error)};`
289
+ + ` missing in ${workdir}: ${status.missing.join('; ')}`, `run 'aiwg ${argv.join(' ')}' manually and resolve the reported failure before launch`, { provider, workdir });
290
+ }
291
+ const after = statusFn({ workdir, provider });
292
+ if (!after.present) {
293
+ throw new WorkerContextBootstrapError(`worker context deploy did not produce the bootstrap: still missing in ${workdir}: ${after.missing.join('; ')}`, `run 'aiwg ${argv.join(' ')}' manually and verify the provider bootstrap before launch`, { provider, workdir });
294
+ }
295
+ // Keep deploy-touched tracked bridges out of the worker diff. A workdir
296
+ // outside any git repository has no tracked files; skip silently there.
297
+ let skippedBridges = [];
298
+ if (options.skipWorktreeFn) {
299
+ skippedBridges = options.skipWorktreeFn({ workdir });
300
+ }
301
+ else {
302
+ try {
303
+ skippedBridges = flagDeployDirtiedSkipWorktree({ workdir, spawn, timeoutMs });
304
+ }
305
+ catch {
306
+ skippedBridges = [];
307
+ }
308
+ }
309
+ return { ensured: true, providers: [...deployed.providers], skippedBridges };
310
+ }
311
+ /** Tracked files the deploy modified, hidden from the worker diff. */
312
+ export function flagDeployDirtiedSkipWorktree({ workdir, spawn = spawnSync, timeoutMs = 30000 }) {
313
+ const status = spawn('git', ['status', '--porcelain=v1', '--untracked-files=no'], { cwd: workdir, encoding: 'utf8', timeout: timeoutMs });
314
+ if (status.error)
315
+ throw new Error(`git status failed: ${status.error?.message}`);
316
+ if (status.status !== 0)
317
+ throw new Error(`git status exited ${status.status}`);
318
+ const files = [];
319
+ for (const line of String(status.stdout ?? '').split('\n')) {
320
+ if (!line.trim())
321
+ continue;
322
+ const raw = line.slice(3);
323
+ const arrow = raw.indexOf(' -> ');
324
+ const rel = arrow >= 0 ? raw.slice(arrow + 4) : raw;
325
+ if (!rel || rel.startsWith('"'))
326
+ throw new Error(`worker context deploy dirtied an unflaggeable path: ${line}`);
327
+ files.push(rel);
328
+ }
329
+ if (files.length > 0) {
330
+ const flag = spawn('git', ['update-index', '--skip-worktree', '--', ...files], { cwd: workdir, encoding: 'utf8', timeout: timeoutMs });
331
+ if (flag.error || flag.status !== 0) {
332
+ throw new Error(`git update-index --skip-worktree failed: ${flag.error?.message ?? String(flag.stderr ?? '').trim()}`);
333
+ }
334
+ }
335
+ return files;
336
+ }
337
+ //# sourceMappingURL=worker-context.js.map
@@ -178,6 +178,13 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
178
178
  import { mcpError, mcpJson } from '../helpers.mjs';
179
179
  import { buildShellStopControl, waitForUnitTermination } from './worker-shell-control.mjs';
180
180
  import { launchPreparedShellWorker, sweepWorkerStdin } from './worker-stdin-runtime.mjs';
181
+ // NOTE: no static import of ../../config/worker-context.js here. That
182
+ // module is TypeScript: a literal relative import resolves under vitest
183
+ // but throws ERR_MODULE_NOT_FOUND under raw node (e.g. `node
184
+ // src/mcp/cli.mjs`, which test/unit/mcp exercises directly). The
185
+ // request-time loader below resolves it through importImpl (dist when
186
+ // built, source otherwise), so merely loading this bridge never touches
187
+ // it. See tools/_resolve-impl.mjs (#3062).
181
188
 
182
189
  const execFileAsync = promisify(execFile);
183
190
 
@@ -642,6 +649,64 @@ function describeGateFailures(failures) {
642
649
  return `worker dispatch gate blocked: ${failures.map(f => `${f.signal} (${f.reason})`).join('; ')}`;
643
650
  }
644
651
 
652
+ /** Request-time loader for the TypeScript worker-context module (see the
653
+ * import note above): resolved through importImpl so production uses the
654
+ * compiled `dist` tree while a checkout falls back to source. Cached for
655
+ * the process lifetime; only request paths that actually ensure worker
656
+ * context ever trigger the load. */
657
+ let cachedWorkerContextModule = null;
658
+ async function loadWorkerContextModule() {
659
+ if (!cachedWorkerContextModule) cachedWorkerContextModule = await importImpl('config/worker-context.js');
660
+ return cachedWorkerContextModule;
661
+ }
662
+
663
+ /** Map the worker CLI to its provider and ensure its bootstrap is present
664
+ * in the worker workdir (#3685 round 2). Deploys the resolved union set
665
+ * (config providers plus orchestration CLIs) in one pass when the worker
666
+ * provider's bootstrap is missing; an unmappable CLI refuses with the
667
+ * typed diagnostic, since no deploy could target it. */
668
+ async function ensureWorkerContextForInvocation({ invocation, config }) {
669
+ const { ensureWorkerContextBootstrap, mapCliToProviderId, resolveWorkerContextProvidersFromConfig, WorkerContextBootstrapError } =
670
+ await loadWorkerContextModule();
671
+ const cli = invocation?.selected?.cli;
672
+ const mapped = mapCliToProviderId(cli, config?.parallelism);
673
+ if (!('provider' in mapped) || !mapped.provider) {
674
+ throw new WorkerContextBootstrapError(
675
+ `cannot ensure worker context: CLI '${cli}' maps to no AIWG provider (${mapped.skipped ?? 'unmapped'})`,
676
+ `configure parallelism.clis.${cli} with a providers:<id> adapter, or dispatch a CLI whose adapter maps to a known provider`,
677
+ { workdir: invocation?.cwd },
678
+ );
679
+ }
680
+ let providers = [mapped.provider];
681
+ try {
682
+ providers = [...new Set([...resolveWorkerContextProvidersFromConfig(config).providers, mapped.provider])];
683
+ } catch {
684
+ providers = [mapped.provider];
685
+ }
686
+ return ensureWorkerContextBootstrap({ workdir: invocation?.cwd, provider: mapped.provider, providers });
687
+ }
688
+
689
+ /** Refuse a failed bootstrap ensure as a typed blocked diagnostic that
690
+ * names the remedy — never a bare string the operator cannot act on.
691
+ * `code`/`remedy`/`provider`/`workdir` survive on any error object that
692
+ * carries them (the tools-side ensure throws a duck-typed refusal, not
693
+ * necessarily this module's class instance); only genuinely bare failures
694
+ * fall back to the generic code and remedy. */
695
+ function toWorkerContextDiagnostic(error) {
696
+ const fields = (error !== null && typeof error === 'object') ? error : {};
697
+ // Duck-typed on purpose: the worker-context module loads lazily (see
698
+ // above), so no class identity is available at module scope. Every
699
+ // refusal path sets `code`/`remedy` on the thrown object instead.
700
+ const code = (typeof fields.code === 'string' && fields.code ? fields.code : 'OG_WORKER_CONTEXT_UNAVAILABLE');
701
+ const remedy = (typeof fields.remedy === 'string' && fields.remedy ? fields.remedy
702
+ : 'ensure the worker CLI maps to a known AIWG provider and the aiwg CLI is installed (AIWG_BIN), then retry the run');
703
+ return { code,
704
+ message: error instanceof Error ? error.message : String(error?.message ?? error),
705
+ remedy,
706
+ ...(fields.provider !== undefined ? { provider: fields.provider } : {}),
707
+ ...(fields.workdir !== undefined ? { workdir: fields.workdir } : {}) };
708
+ }
709
+
645
710
  // Audited observer; refresh the historical baseline graph after rebase.
646
711
  const waitForUnitTerminationBaseline = waitForUnitTermination;
647
712
  export { waitForUnitTerminationBaseline as __testOnlyWaitForUnitTerminationBaseline };
@@ -758,6 +823,7 @@ export class ChildProcessTransport {
758
823
  // cap since a long-lived connection could otherwise grow it unbounded.
759
824
  this._rawResponses = new Map();
760
825
  this._buffer = undefined;
826
+ this._bufferBytes = 0;
761
827
  }
762
828
 
763
829
  async start() {
@@ -769,7 +835,7 @@ export class ChildProcessTransport {
769
835
  try {
770
836
  // Check each frame BEFORE concatenation or UTF-8 decoding.
771
837
  let offset = 0;
772
- let frameBytes = this._buffer?.length ?? 0;
838
+ let frameBytes = this._bufferBytes;
773
839
  while (offset < chunk.length) {
774
840
  const newline = chunk.indexOf(10, offset);
775
841
  const end = newline === -1 ? chunk.length : newline;
@@ -782,8 +848,7 @@ export class ChildProcessTransport {
782
848
  frameBytes = 0;
783
849
  offset = newline + 1;
784
850
  }
785
- this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
786
- this._drain();
851
+ this._drain(chunk);
787
852
  } catch (error) { this.onerror?.(error); }
788
853
  });
789
854
  child.stdout?.on('error', error => this.onerror?.(error));
@@ -799,6 +864,7 @@ export class ChildProcessTransport {
799
864
  if (this._closed) return;
800
865
  this._closed = true;
801
866
  this._buffer = undefined;
867
+ this._bufferBytes = 0;
802
868
  this._rawResponses.clear();
803
869
  this.onclose?.();
804
870
  }
@@ -807,6 +873,7 @@ export class ChildProcessTransport {
807
873
  if (this._failure) return;
808
874
  this._failure = Object.assign(new Error(code), { code });
809
875
  this._buffer = undefined;
876
+ this._bufferBytes = 0;
810
877
  this._rawResponses.clear();
811
878
  // Start stopping before pipe closure can exit the leader; onclose rejects
812
879
  // pending SDK requests immediately while termination proof is awaited.
@@ -825,14 +892,26 @@ export class ChildProcessTransport {
825
892
  // (own-property-preserving, retained in `_rawResponses` — see
826
893
  // constructor doc) and once through the SDK's own `JSONRPCMessageSchema`
827
894
  // (routed to `onmessage`, unchanged behavior for the Client/Protocol).
828
- _drain() {
829
- while (this._buffer) {
830
- const index = this._buffer.indexOf('\n');
831
- if (index === -1) return;
832
- const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
833
- this._buffer = this._buffer.subarray(index + 1);
895
+ _drain(chunk) {
896
+ // Retain fragments without copying or rescanning the growing partial
897
+ // frame on every pipe read. Join once, only when a newline arrives.
898
+ let offset = 0;
899
+ while (offset < chunk.length) {
900
+ const newline = chunk.indexOf(10, offset);
901
+ const end = newline === -1 ? chunk.length : newline;
902
+ if (end > offset) {
903
+ (this._buffer ??= []).push(chunk.subarray(offset, end));
904
+ this._bufferBytes += end - offset;
905
+ }
906
+ if (newline === -1) return;
907
+ const frame = this._buffer?.length === 1
908
+ ? this._buffer[0] : Buffer.concat(this._buffer ?? [], this._bufferBytes);
909
+ const line = frame.toString('utf8').replace(/\r$/, '');
910
+ this._buffer = undefined;
911
+ this._bufferBytes = 0;
834
912
  this._handleLine(line);
835
- if (this._buffer?.length === 0) this._buffer = undefined;
913
+ if (this._closed || this._failure) return;
914
+ offset = newline + 1;
836
915
  }
837
916
  }
838
917
 
@@ -905,6 +984,7 @@ export class ChildProcessTransport {
905
984
  try { signalChild(child, 'SIGKILL'); } catch { /* already gone */ }
906
985
  }
907
986
  this._buffer = undefined;
987
+ this._bufferBytes = 0;
908
988
  }
909
989
  }
910
990
 
@@ -1745,6 +1825,24 @@ export async function registerWorkerInvokeToolset(server, options = {}) {
1745
1825
  }
1746
1826
  const { root, snapshot, drivers, invocation } = prefix;
1747
1827
 
1828
+ // #3685 round 2: the worker CLI's AIWG bootstrap must be present in
1829
+ // the worker workdir before dispatch — a context-free worker would run
1830
+ // with no project rules/skills/hooks. Present: no-op. Missing: exactly
1831
+ // one `aiwg use all` pass into the workdir (never elsewhere). A failed
1832
+ // ensure refuses as `blocked` with the typed diagnostic (never a
1833
+ // launch). Default on; `ensureWorkerContext: false` skips (synthetic
1834
+ // tests), a function value replaces the ensure.
1835
+ if (options.ensureWorkerContext !== false) {
1836
+ try {
1837
+ const ensure = typeof options.ensureWorkerContext === 'function'
1838
+ ? options.ensureWorkerContext
1839
+ : ensureWorkerContextForInvocation;
1840
+ await ensure({ invocation, config: snapshot.config });
1841
+ } catch (error) {
1842
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: [toWorkerContextDiagnostic(error)] });
1843
+ }
1844
+ }
1845
+
1748
1846
  // =========================================================================
1749
1847
  // #2915 round 8 (D6): ONE decision, immediately after the prefix, from
1750
1848
  // the launcher's own pure eligibility predicate,