lazyclaw 5.0.9 → 5.2.0

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.
@@ -25,6 +25,7 @@ import * as tasksMod from '../tasks.mjs';
25
25
  import * as agentMemory from './agent_memory.mjs';
26
26
  import * as skillSynth from './skill_synth.mjs';
27
27
  import * as skills from '../skills.mjs';
28
+ import { composePromptStack } from './prompt_stack.mjs';
28
29
 
29
30
  export class MentionRouterError extends Error {
30
31
  constructor(message, code) {
@@ -97,9 +98,31 @@ export function buildTurnContext({ task, team, agent, agentRecord, teammates, co
97
98
  // The agent loads a full skill on demand with the skill_view tool —
98
99
  // progressive disclosure, so skill bodies don't bloat every prompt.
99
100
  const skillsBlock = buildSkillsBlock(configDir);
101
+ // v5 (canonical decision C5) — prepend the 8-layer prompt stack so the
102
+ // workspace SOUL / personality / USER.md / long-term memory layers land
103
+ // ahead of the agent's role. composePromptStack returns '' on a fresh
104
+ // install so the prompt is byte-identical to the pre-v5 shape when no
105
+ // layer source exists on disk. Wrapped in try/catch so a missing
106
+ // configDir file never crashes a live agent turn.
107
+ let promptStack = '';
108
+ try {
109
+ promptStack = composePromptStack({
110
+ cfgDir: configDir,
111
+ agent: agentRecord,
112
+ workspace: task?.workspace || agentRecord.workspace || '',
113
+ }) || '';
114
+ } catch { /* best-effort — see comment above */ }
115
+ // When composePromptStack emitted a Role layer (layer 3) we drop the
116
+ // bare `role` line below to avoid duplicating agent.role inside the
117
+ // system prompt. The stack helper builds the Role layer iff
118
+ // agentRecord.role is non-empty, so checking `promptStack` includes
119
+ // the marker `## Role (` is sufficient.
120
+ const stackHasRole = promptStack.includes('## Role (');
100
121
  const system = [
101
- role,
102
- role && '\n\n---\n',
122
+ promptStack || null,
123
+ promptStack && '\n\n---\n',
124
+ stackHasRole ? null : role,
125
+ !stackHasRole && role && '\n\n---\n',
103
126
  memBlock || null,
104
127
  skillsBlock || null,
105
128
  `You are *${agentRecord.displayName || agentRecord.name}* on team "${team.displayName || team.name}".`,
@@ -113,7 +136,56 @@ export function buildTurnContext({ task, team, agent, agentRecord, teammates, co
113
136
  renderTranscript(task.turns),
114
137
  `\n\n# Your turn (as ${agentRecord.name}):`,
115
138
  ];
116
- return { system, user: userParts.join('') };
139
+
140
+ // Group B / C10 — also emit history-as-messages. The legacy
141
+ // single-string `user` field is kept for callers that snapshot it
142
+ // (existing tests), but production code (runTaskTurn) now wires
143
+ // `history` into runAgentTurn so Anthropic's prompt cache + KV
144
+ // cache can lock onto the prior turns byte-identically across
145
+ // router iterations.
146
+ //
147
+ // Mapping:
148
+ // - turn.agent === 'user' → role:'user', plain content
149
+ // - turn.agent === speaker → role:'assistant' (the model's own
150
+ // prior turn — looks like an assistant's text from its POV)
151
+ // - turn.agent === any other teammate → role:'user' with a
152
+ // `[FROM x]` prefix so the model knows it's a peer speaking,
153
+ // not the human user
154
+ // We always prepend a single user-role kickoff message with the
155
+ // task spec so the first message in the history is anchored on the
156
+ // task; and we always append a final user-role "your turn" marker
157
+ // so the model knows when to speak.
158
+ const history = [];
159
+ const taskKickoff = [
160
+ `# Task: ${task.title}`,
161
+ task.description ? `\n${task.description}\n` : '',
162
+ ].join('').trim();
163
+ if (taskKickoff) history.push({ role: 'user', content: taskKickoff });
164
+ for (const t of (Array.isArray(task.turns) ? task.turns : [])) {
165
+ if (!t || !t.agent) continue;
166
+ const txt = String(t.text || '');
167
+ if (!txt) continue;
168
+ if (t.agent === 'user') {
169
+ history.push({ role: 'user', content: txt });
170
+ } else if (t.agent === agentRecord.name) {
171
+ history.push({ role: 'assistant', content: txt });
172
+ } else if (t.agent === 'system') {
173
+ // System pseudo-turns (kickoff seed) collapse into a user-role
174
+ // prefix-tagged note so message-role alternation stays clean.
175
+ history.push({ role: 'user', content: `[SYSTEM] ${txt}` });
176
+ } else {
177
+ history.push({ role: 'user', content: `[FROM ${t.agent}] ${txt}` });
178
+ }
179
+ }
180
+ // "Your turn" marker — always a user-role tail so the model is
181
+ // prompted to speak as the named agent. Combined with the speaker
182
+ // mapping above this gives Anthropic a stable cacheable prefix:
183
+ // the first N-1 messages are identical across router iterations
184
+ // for the same task, and only this final marker (plus a new prior
185
+ // turn) changes per router pass.
186
+ history.push({ role: 'user', content: `# Your turn (as ${agentRecord.name})` });
187
+
188
+ return { system, user: userParts.join(''), history };
117
189
  }
118
190
 
119
191
  // Build the system-prompt block listing the skills the agent can pull
@@ -238,7 +310,7 @@ async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, confi
238
310
  for (const name of collectParticipants(task)) {
239
311
  const agentRecord = agentsById[name];
240
312
  if (!agentRecord) continue;
241
- if (agentRecord.memoryWrite && agentRecord.memoryWrite !== 'auto') continue;
313
+ if ((agentRecord.memoryWrite ?? 'auto') !== 'auto') continue;
242
314
  try {
243
315
  const body = await agentMemory.reflectOnce({
244
316
  agent: agentRecord,
@@ -257,18 +329,19 @@ async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, confi
257
329
  }
258
330
  }
259
331
 
260
- // Phase 20 — fire one skill-synthesis LLM call per participating agent
261
- // whose skillWrite is 'auto', installing the resulting SKILL.md into
262
- // the shared skills dir. Default skillWrite is 'manual', so this is a
263
- // no-op unless the user opted an agent in. Best-effort: a failed
264
- // synthesis is logged, never thrown, so it can't poison a finished
265
- // task.
332
+ // Phase 20 / v5 Group A (M3) — fire one skill-synthesis LLM call per
333
+ // participating agent whose skillWrite is 'auto' (the default since
334
+ // v5). Installs the resulting SKILL.md into the shared skills dir.
335
+ // Legacy v4 agents (no explicit skillWrite field) inherit the new
336
+ // 'auto' default via the `?? 'auto'` guard below without a forced
337
+ // migration. Best-effort: a failed synthesis is logged, never thrown,
338
+ // so it can't poison a finished task.
266
339
  async function autoSynthSkills({ task, agentsById, apiKey, baseUrl, fetchImpl, configDir, logger = () => {} }) {
267
340
  if (!task || !Array.isArray(task.turns)) return;
268
341
  for (const name of collectParticipants(task)) {
269
342
  const agentRecord = agentsById[name];
270
343
  if (!agentRecord) continue;
271
- if (agentRecord.skillWrite !== 'auto') continue;
344
+ if ((agentRecord.skillWrite ?? 'auto') !== 'auto') continue;
272
345
  try {
273
346
  const result = await skillSynth.synthesizeSkill({ agent: agentRecord, task, apiKey, baseUrl, fetchImpl });
274
347
  if (result) {
@@ -370,10 +443,18 @@ export async function runTaskTurn({
370
443
  try {
371
444
  result = await agentTurn.runAgentTurn({
372
445
  agent: { ...agentRecord, role: ctx.system },
373
- userMessage: ctx.user,
374
- history: [],
446
+ // Group B / C10 — feed the transcript as a proper messages
447
+ // history. The kickoff + prior turns + "your turn" marker
448
+ // live in ctx.history; we leave userMessage empty so the
449
+ // adapter doesn't double-append a redundant user turn.
450
+ userMessage: '',
451
+ history: ctx.history,
375
452
  taskId: current.id,
376
453
  configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve,
454
+ // C9 — enable Anthropic prompt caching for the static system
455
+ // prefix + tool definitions. Non-anthropic adapters ignore
456
+ // the flag (it's a no-op for OpenAI/Gemini/claude-cli).
457
+ cache: true,
377
458
  });
378
459
  } catch (err) {
379
460
  await clearTypingPlaceholder(typing, logger);
@@ -0,0 +1,56 @@
1
+ // mas/orchestra.mjs — v5.0 spec §3 / canonical decision C2.
2
+ //
3
+ // "Orchestra" hub — the eventual home of the multi-agent dispatcher
4
+ // (planner + workers + synthesis). For v5.0.10 it acts as a thin
5
+ // coordinator layer:
6
+ //
7
+ // 1. Re-exports `makeOrchestratorProvider` from providers/orchestrator.mjs
8
+ // so cli.mjs / providers/registry.mjs can migrate the import path
9
+ // without breaking ~20 call sites in one commit.
10
+ // 2. Owns `POST_HOC_HOOK_NAMES` — the canonical list of triggers the
11
+ // orchestrator may fan into mas/learning.mjs. Mirrors the
12
+ // learning module's TRIGGERS so callers can `import { POST_HOC_HOOK_NAMES }
13
+ // from 'mas/orchestra.mjs'` without pulling the whole learning
14
+ // surface.
15
+ // 3. Provides `firePostTask({ ... })` — a microtask-safe wrapper around
16
+ // `learning.runLearning('post-task', …)` so the SYNTHESIS phase of
17
+ // providers/orchestrator.mjs can hook in via a single import line.
18
+ //
19
+ // Future (Phase 2): the dispatcher itself moves in here and
20
+ // providers/orchestrator.mjs becomes a thin shim that re-exports from
21
+ // mas/orchestra.mjs. Until then the boundary is one-way — orchestra
22
+ // imports from providers/orchestrator.mjs, never the other way around.
23
+
24
+ import { makeOrchestratorProvider as _make } from '../providers/orchestrator.mjs';
25
+ import { TRIGGERS, runLearning } from './learning.mjs';
26
+
27
+ // Re-export so callers can migrate gradually.
28
+ export const makeOrchestratorProvider = _make;
29
+
30
+ // Public alias mirroring the spec language ("post-hoc hooks"). Frozen
31
+ // so a typo on the consumer side surfaces at import time.
32
+ export const POST_HOC_HOOK_NAMES = TRIGGERS;
33
+
34
+ /**
35
+ * Fire a learning trigger without blocking the caller's stream. Wraps
36
+ * runLearning in queueMicrotask + a swallow-everything catch so a
37
+ * misconfigured trainer key (or a transient FS error) can never poison
38
+ * the user-facing orchestrator output.
39
+ *
40
+ * @param {(typeof TRIGGERS)[number]} trigger
41
+ * @param {Record<string, unknown>} ctx
42
+ */
43
+ export function fireLearning(trigger, ctx) {
44
+ queueMicrotask(() => {
45
+ runLearning(trigger, ctx).catch(() => { /* swallow */ });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Convenience for the common case — the orchestrator's SYNTHESIS phase
51
+ * just finished, fire the post-task hook. Equivalent to
52
+ * fireLearning('post-task', ctx).
53
+ */
54
+ export function firePostTask(ctx) {
55
+ fireLearning('post-task', ctx);
56
+ }
@@ -297,7 +297,25 @@ function buildAntiPatternPrompt(task, transcript) {
297
297
  // agent skill, which we then version-bump). Body/description are
298
298
  // re-sanitised here too so a direct caller (CLI/router) can't bypass
299
299
  // the redaction + cap. Returns { skill, path, version }.
300
- export function installSynthesized({ name, description = '', body = '', sourceTask = '', createdBy = 'agent' } = {}, configDir, ts = new Date()) {
300
+ export function installSynthesized({
301
+ name,
302
+ description = '',
303
+ body = '',
304
+ sourceTask = '',
305
+ createdBy = 'agent',
306
+ // v5 (Group A — C6 + M1): forward every frontmatter field
307
+ // synthesizeSkill / runLearning already computed. Without these
308
+ // forwards the resulting SKILL.md was missing trained_by, confidence,
309
+ // cross_cli_tested and the anti-pattern flag — exactly the metadata
310
+ // the canonical learning loop needs to rank skills cross-CLI.
311
+ trainedBy = null,
312
+ trainedOnModel = null,
313
+ trajectoryRef = null,
314
+ confidence = null,
315
+ crossCliTested = null,
316
+ outcome = 'done',
317
+ group = null,
318
+ } = {}, configDir, ts = new Date()) {
301
319
  // Slugify BEFORE reserving the name so a direct caller (CLI/router)
302
320
  // can't smuggle a newline/colon into the filename or inject a second
303
321
  // frontmatter key. parseSynthOutput already slugifies, but this path
@@ -313,6 +331,13 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
313
331
  body: sanitizeSkillBody(body),
314
332
  version,
315
333
  ts,
334
+ trainedBy,
335
+ trainedOnModel,
336
+ trajectoryRef,
337
+ confidence,
338
+ crossCliTested,
339
+ outcome,
340
+ group,
316
341
  });
317
342
  const p = skills.installSkill(finalName, doc, configDir);
318
343
  // Phase A: FTS5 mirror (spec §4.4). Group fallback per canonical C5.
@@ -320,9 +345,18 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
320
345
  const { meta, body: skillBody } = parseFrontmatter(doc);
321
346
  const group = meta.group
322
347
  || (finalName.includes('-') ? finalName.split('-')[0] : 'legacy');
348
+ // Operator-precedence fix (Group A — M5): the original expression
349
+ // meta.trained_by || createdBy === 'agent' ? 'agent' : 'user'
350
+ // parses as
351
+ // (meta.trained_by || (createdBy === 'agent')) ? 'agent' : 'user'
352
+ // so an agent-installed skill with frontmatter `trained_by: human`
353
+ // was being indexed as `trained_by: 'agent'` (the truthy `meta.trained_by`
354
+ // collapses to a bare boolean inside the ternary). The corrected
355
+ // parenthesisation honours frontmatter first, then falls back to
356
+ // createdBy-derived 'agent' / 'user'.
323
357
  _indexSkill({
324
358
  skill_name: finalName,
325
- trained_by: meta.trained_by || createdBy === 'agent' ? 'agent' : 'user',
359
+ trained_by: meta.trained_by || (createdBy === 'agent' ? 'agent' : 'user'),
326
360
  group_name: group,
327
361
  content: skillBody,
328
362
  }, configDir);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as registry from './tools/registry.mjs';
7
7
  import * as audit from './audit.mjs';
8
+ import { DEFAULT_TOOLS } from '../agents.mjs';
8
9
 
9
10
  export class ToolError extends Error {
10
11
  constructor(message, code) {
@@ -14,9 +15,15 @@ export class ToolError extends Error {
14
15
  }
15
16
  }
16
17
 
18
+ // Resolve the tool whitelist into JSON-Schema entries.
19
+ // - undefined → DEFAULT_TOOLS (the 5 safe defaults a fresh agent gets)
20
+ // - [] → advertise zero tools (matches the deny-check
21
+ // semantics in runTool — an agent with no whitelist
22
+ // is allowed to use NOTHING, not everything)
23
+ // - ['bash',…] → the explicit list, intersected with the registry
17
24
  export function listToolSchemas(names) {
18
25
  const out = [];
19
- const wanted = Array.isArray(names) && names.length ? names : registry.listNames();
26
+ const wanted = names === undefined ? DEFAULT_TOOLS : (Array.isArray(names) ? names : []);
20
27
  for (const name of wanted) {
21
28
  const t = registry.lookup(name);
22
29
  if (!t) continue;
package/mas/tools/git.mjs CHANGED
@@ -4,7 +4,24 @@
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
6
  function git(cwd, args, opts = {}) {
7
- const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
7
+ // M11 / C12 detect a missing git binary (Windows without
8
+ // Git-for-Windows, minimal Docker base images) up front and surface
9
+ // a clear remediation hint. The historical behaviour returned
10
+ // {ok:false} with a cryptic spawn error, which made the
11
+ // "lazyclaw doctor" path the only reliable signal. Now any agent
12
+ // touching this tool also gets a one-line diagnostic.
13
+ const exe = process.env.GIT_EXECUTABLE || 'git';
14
+ const r = spawnSync(exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
15
+ if (r.error && r.error.code === 'ENOENT') {
16
+ return {
17
+ ok: false,
18
+ stdout: '',
19
+ stderr: '',
20
+ exitCode: null,
21
+ error: 'GIT_NOT_INSTALLED',
22
+ hint: 'git binary not found on PATH. Install Git, or set GIT_EXECUTABLE. See `lazyclaw doctor`.',
23
+ };
24
+ }
8
25
  return { ok: r.status === 0, stdout: r.stdout, stderr: r.stderr, exitCode: r.status };
9
26
  }
10
27
 
@@ -13,7 +13,11 @@
13
13
  // filter: optional object of UNINDEXED column equality filters
14
14
  // (session_id, agent, outcome, trained_by, group_name, kind, since)
15
15
 
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
16
19
  import { openIndex, recall as indexRecall } from '../index_db.mjs';
20
+ import { sameFamily } from '../confidence.mjs';
17
21
 
18
22
  export const NAME = 'recall';
19
23
  export const DESCRIPTION =
@@ -26,6 +30,11 @@ export const PARAMETERS = {
26
30
  k: { type: 'integer', minimum: 1, maximum: 50 },
27
31
  summarize: { type: 'boolean' },
28
32
  filter: { type: 'object', additionalProperties: true },
33
+ // M10 — cross-CLI provider-aware ranking. When the caller is a
34
+ // worker on a specific provider (e.g. anthropic/openai/gemini),
35
+ // skills whose frontmatter cross_cli_tested[] includes that
36
+ // provider's family are boosted ahead of untested duplicates.
37
+ workerProvider: { type: 'string', description: 'Boost skills whose cross_cli_tested[] includes this provider family (e.g. "anthropic").' },
29
38
  },
30
39
  required: ['query'],
31
40
  };
@@ -33,6 +42,39 @@ export const PARAMETERS = {
33
42
  const DEFAULT_SCOPES = ['sessions', 'skills', 'trajectories', 'memories'];
34
43
  const MAX_K = 50;
35
44
 
45
+ function _defaultConfigDir() {
46
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
47
+ }
48
+
49
+ // Minimal frontmatter parser — read just the cross_cli_tested provider
50
+ // list for a skill by name. Returns [] when the file is missing or
51
+ // parse fails (best-effort ranking helper, not a strict loader).
52
+ function _readSkillCrossCli(skillName, configDir) {
53
+ if (!skillName) return [];
54
+ try {
55
+ const dir = configDir || _defaultConfigDir();
56
+ const filePath = path.join(dir, 'skills', `${skillName}.md`);
57
+ if (!fs.existsSync(filePath)) return [];
58
+ const raw = fs.readFileSync(filePath, 'utf8');
59
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
60
+ if (!m) return [];
61
+ const fm = m[1];
62
+ // Find cross_cli_tested block — YAML list form:
63
+ // cross_cli_tested:
64
+ // - provider: anthropic
65
+ // …
66
+ const idx = fm.indexOf('cross_cli_tested:');
67
+ if (idx < 0) return [];
68
+ const tail = fm.slice(idx);
69
+ const providers = [];
70
+ for (const line of tail.split('\n')) {
71
+ const pm = line.match(/^\s*-?\s*provider:\s*['"]?([\w.-]+)['"]?\s*$/);
72
+ if (pm) providers.push(pm[1]);
73
+ }
74
+ return providers;
75
+ } catch { return []; }
76
+ }
77
+
36
78
  let _stubRecall = null;
37
79
  export function __setRecall(fn) { _stubRecall = typeof fn === 'function' ? fn : null; }
38
80
 
@@ -83,12 +125,34 @@ export async function exec(args, { configDir } = {}) {
83
125
  });
84
126
  }
85
127
 
128
+ // M10 — cross-CLI provider-aware boost. For each `skills` hit, peek
129
+ // at the skill file's cross_cli_tested[] frontmatter. If any entry's
130
+ // provider is in the same family as workerProvider, promote the hit
131
+ // above untested siblings. We do this AFTER the FTS5 ranking so the
132
+ // base bm25 ordering still dominates — boosting is a tie-breaker /
133
+ // small re-rank, not a wholesale replacement.
134
+ const workerProvider = args.workerProvider ? String(args.workerProvider).trim() : '';
135
+ if (workerProvider) {
136
+ const boosted = hits.map((h, idx) => {
137
+ if (h.scope !== 'skills') return { h, boost: 0, idx };
138
+ const skillName = h.metadata?.skill_name || '';
139
+ const tested = _readSkillCrossCli(skillName, configDir);
140
+ const matched = tested.some((p) => sameFamily(p, workerProvider));
141
+ return { h, boost: matched ? 1 : 0, idx };
142
+ });
143
+ // Stable sort: matching skills first (boost desc), then preserve
144
+ // original FTS5 order via idx for ties.
145
+ boosted.sort((a, b) => (b.boost - a.boost) || (a.idx - b.idx));
146
+ hits = boosted.map((b) => ({ ...b.h, crossCliBoosted: b.boost > 0 }));
147
+ }
148
+
86
149
  return {
87
150
  ok: true,
88
151
  query,
89
152
  hits: hits.slice(0, k),
90
153
  summary: null, // v5.0: raw hits only; v5.1 wires trainer.
91
154
  summarizedBy: null,
155
+ workerProvider: workerProvider || null,
92
156
  latencyMs: Date.now() - t0,
93
157
  };
94
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.0.9",
3
+ "version": "5.2.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -50,6 +50,7 @@
50
50
  "config_features.mjs",
51
51
  "workspace.mjs",
52
52
  "browse.mjs",
53
+ "chat_window.mjs",
53
54
  "sandbox.mjs",
54
55
  "sandbox/",
55
56
  "skills_install.mjs",
@@ -145,16 +145,32 @@ export const anthropicProvider = {
145
145
  stream: true,
146
146
  messages: apiMessages,
147
147
  };
148
- const sys = opts.system || messages.find(m => m.role === 'system')?.content;
149
- if (sys) {
150
- // Prompt caching: when opts.cache is truthy, mark the system prompt
151
- // as ephemeral-cacheable so repeated calls with the same system
152
- // prefix only pay full input cost once. The Messages API expects
153
- // an array of text blocks here, so we lift the string into one.
154
- if (opts.cache) {
155
- body.system = [{ type: 'text', text: String(sys), cache_control: { type: 'ephemeral' } }];
156
- } else {
157
- body.system = sys;
148
+ // Multi-block system support (Group B / C8). When the caller has
149
+ // separated the STATIC prefix (workspace + skills index + agent.role
150
+ // + USER.md) from the VOLATILE per-turn content, we ship two text
151
+ // blocks so the prompt cache hits the long static prefix while the
152
+ // volatile suffix changes per turn. Static block carries
153
+ // cache_control:ephemeral; volatile does not.
154
+ const hasMultiBlock = (opts.systemStatic && String(opts.systemStatic).trim()) || (opts.systemVolatile && String(opts.systemVolatile).trim());
155
+ if (hasMultiBlock) {
156
+ const blocks = [];
157
+ const staticText = opts.systemStatic ? String(opts.systemStatic) : '';
158
+ const volatileText = opts.systemVolatile ? String(opts.systemVolatile) : '';
159
+ if (staticText) blocks.push({ type: 'text', text: staticText, cache_control: { type: 'ephemeral' } });
160
+ if (volatileText) blocks.push({ type: 'text', text: volatileText });
161
+ body.system = blocks;
162
+ } else {
163
+ const sys = opts.system || messages.find(m => m.role === 'system')?.content;
164
+ if (sys) {
165
+ // Prompt caching: when opts.cache is truthy, mark the system prompt
166
+ // as ephemeral-cacheable so repeated calls with the same system
167
+ // prefix only pay full input cost once. The Messages API expects
168
+ // an array of text blocks here, so we lift the string into one.
169
+ if (opts.cache) {
170
+ body.system = [{ type: 'text', text: String(sys), cache_control: { type: 'ephemeral' } }];
171
+ } else {
172
+ body.system = sys;
173
+ }
158
174
  }
159
175
  }
160
176
  // Extended thinking. opts.thinking: { enabled?: boolean, budgetTokens?: number }.
@@ -230,30 +230,85 @@ export function makeOrchestratorProvider(opts = {}) {
230
230
  yield `\n`;
231
231
 
232
232
  // ── Phase 2: EXECUTE ────────────────────────────────────────────
233
- yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}\n\n`;
233
+ // Concurrency policy (canonical spec §3 + C11 fix):
234
+ // concurrency <= 1 → sequential, streams each worker's chunks
235
+ // inline as they arrive (live-feedback UX).
236
+ // concurrency >= 2 → Promise.all-based parallel dispatch. Each
237
+ // worker buffers its own chunks; we flush
238
+ // them IN PLAN ORDER (subtask 1, then 2, …)
239
+ // so the user-facing output stays readable
240
+ // regardless of which worker finished first.
241
+ // The number is clamped to [1, workers.length] so a runaway value
242
+ // can't accidentally over-subscribe a single worker.
243
+ const rawConcurrency = Number.isFinite(o.concurrency) ? Math.floor(o.concurrency) : 1;
244
+ const concurrency = Math.max(1, Math.min(rawConcurrency, workers.length));
245
+ yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}${concurrency > 1 ? ` (concurrency=${concurrency}, parallel)` : ''}\n\n`;
234
246
  const results = [];
235
- for (let i = 0; i < trimmed.length; i++) {
236
- const sub = trimmed[i];
237
- const worker = workers[i % workers.length];
238
- yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
239
- let res = '';
240
- try {
241
- for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
242
- apiKey: keyResolver(cfg, worker.name),
243
- model: worker.model || undefined,
244
- signal: callerOpts.signal,
245
- })) {
246
- const s = String(chunk);
247
- res += s;
248
- yield s;
247
+ if (concurrency <= 1) {
248
+ // Sequential streaming path — historical default, preserved.
249
+ for (let i = 0; i < trimmed.length; i++) {
250
+ const sub = trimmed[i];
251
+ const worker = workers[i % workers.length];
252
+ yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
253
+ let res = '';
254
+ try {
255
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
256
+ apiKey: keyResolver(cfg, worker.name),
257
+ model: worker.model || undefined,
258
+ signal: callerOpts.signal,
259
+ })) {
260
+ const s = String(chunk);
261
+ res += s;
262
+ yield s;
263
+ }
264
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: res, error: null });
265
+ } catch (e) {
266
+ const msg = e?.message || String(e);
267
+ yield `\n⚠ worker error: ${msg}\n`;
268
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: '', error: msg });
249
269
  }
250
- results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: res, error: null });
251
- } catch (e) {
252
- const msg = e?.message || String(e);
253
- yield `\n⚠ worker error: ${msg}\n`;
254
- results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: '', error: msg });
270
+ yield `\n\n---\n\n`;
271
+ }
272
+ } else {
273
+ // Parallel dispatch buffer chunks per subtask, then flush in
274
+ // plan order. We start every subtask up-front (Promise.all over
275
+ // the slice) so wall-clock equals max-per-subtask, not sum.
276
+ // A single subtask failure does NOT block the others — the
277
+ // _runSubtask wrapper catches and records the error inline.
278
+ async function _runSubtask(sub, worker) {
279
+ const chunks = [];
280
+ let error = null;
281
+ try {
282
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
283
+ apiKey: keyResolver(cfg, worker.name),
284
+ model: worker.model || undefined,
285
+ signal: callerOpts.signal,
286
+ })) {
287
+ chunks.push(String(chunk));
288
+ }
289
+ } catch (e) {
290
+ error = e?.message || String(e);
291
+ }
292
+ return { sub, worker, chunks, error };
293
+ }
294
+ const settled = await Promise.all(
295
+ trimmed.map((sub, i) => _runSubtask(sub, workers[i % workers.length])),
296
+ );
297
+ // Flush in plan order so the synthesis prompt + user view see
298
+ // subtask 1, then 2, etc.
299
+ for (const { sub, worker, chunks, error } of settled) {
300
+ yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
301
+ const text = chunks.join('');
302
+ if (text) yield text;
303
+ if (error) yield `\n⚠ worker error: ${error}\n`;
304
+ results.push({
305
+ ...sub,
306
+ worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`,
307
+ result: text,
308
+ error,
309
+ });
310
+ yield `\n\n---\n\n`;
255
311
  }
256
- yield `\n\n---\n\n`;
257
312
  }
258
313
 
259
314
  // ── Phase 3: SYNTHESIS ──────────────────────────────────────────
@@ -276,6 +331,29 @@ export function makeOrchestratorProvider(opts = {}) {
276
331
  } catch (e) {
277
332
  yield `⚠ synthesis error: ${e?.message || String(e)}. Worker outputs above are the final material — please review them directly.\n`;
278
333
  }
334
+
335
+ // v5 (canonical decision C2) — fire the canonical post-task
336
+ // learning hook in a microtask so the user's stream is not
337
+ // blocked. Failures are silent here; mas/learning.mjs already
338
+ // swallows each sub-routine's errors and the audit log captures
339
+ // observability on its own. We import lazily so the orchestrator
340
+ // module stays cheap to load when learning is unused.
341
+ queueMicrotask(() => {
342
+ import('../mas/orchestra.mjs')
343
+ .then(o => o.firePostTask({
344
+ cfg,
345
+ agent: { name: 'orchestrator', provider: planner.name, model: planner.model, role: SYNTHESIS_SYSTEM },
346
+ task: {
347
+ id: `orch-${Date.now()}`,
348
+ title: userText.slice(0, 80),
349
+ turns: [
350
+ { agent: 'user', text: userText },
351
+ ...results.map(r => ({ agent: r.worker, text: r.result || r.error || '' })),
352
+ ],
353
+ },
354
+ }))
355
+ .catch(() => { /* swallow — learning is best-effort */ });
356
+ });
279
357
  },
280
358
  };
281
359
  }
@@ -524,6 +524,12 @@ export function parseSlashProviderModel(s) {
524
524
  const RESERVED_PROVIDER_NAMES = new Set([
525
525
  'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
526
526
  'orchestrator',
527
+ // M12 — `test` is reserved because the daemon intercepts
528
+ // GET /providers/test and POST /providers/test for batch provider
529
+ // probing. Allowing a custom provider literally named "test" would
530
+ // shadow those routes (GET/DELETE for the literal name become
531
+ // unreachable). Reject up-front in the POST validator.
532
+ 'test',
527
533
  '__add_custom__', '__custom_model__', '__fetch_models__',
528
534
  ]);
529
535