@parallel-cli/parallel 0.4.9 → 0.5.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.
@@ -29,7 +29,7 @@ export const TOOL_DEFINITIONS = [
29
29
  type: 'function',
30
30
  function: {
31
31
  name: 'list_files',
32
- description: 'List project files (recursive, ignores node_modules/.git/dist). Use it first to understand the structure.',
32
+ description: 'List project files (recursive, ignores node_modules/.git/dist). Use only when shared project context does not already identify the relevant area.',
33
33
  parameters: {
34
34
  type: 'object',
35
35
  properties: {
@@ -47,6 +47,8 @@ export const TOOL_DEFINITIONS = [
47
47
  type: 'object',
48
48
  properties: {
49
49
  path: { type: 'string', description: 'Relative file path' },
50
+ startLine: { type: 'integer', description: 'Optional first line, 1-based' },
51
+ endLine: { type: 'integer', description: 'Optional last line, inclusive' },
50
52
  },
51
53
  required: ['path'],
52
54
  },
@@ -70,6 +72,22 @@ export const TOOL_DEFINITIONS = [
70
72
  },
71
73
  },
72
74
  },
75
+ {
76
+ type: 'function',
77
+ function: {
78
+ name: 'read_artifact',
79
+ description: 'Read a targeted line range from a long tool output previously stored as an artifact.',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ id: { type: 'string', description: 'Artifact id returned by a previous tool result' },
84
+ startLine: { type: 'integer', description: 'First line, 1-based' },
85
+ lineCount: { type: 'integer', description: 'Number of lines, max 200' },
86
+ },
87
+ required: ['id'],
88
+ },
89
+ },
90
+ },
73
91
  {
74
92
  type: 'function',
75
93
  function: {
@@ -120,7 +138,7 @@ export const TOOL_DEFINITIONS = [
120
138
  type: 'function',
121
139
  function: {
122
140
  name: 'inspect_project',
123
- description: 'Batch read-only inspection: list files under paths and/or run several regex searches in one call. Prefer this over cascades of grep/head/tail/wc/awk for exploratory checks.',
141
+ description: 'Batch targeted read-only inspection: list files under task-relevant paths and/or run several regex searches in one call. Prefer this over generic repository exploration or cascades of grep/head/tail/wc/awk.',
124
142
  parameters: {
125
143
  type: 'object',
126
144
  properties: {
@@ -185,7 +203,7 @@ export const TOOL_DEFINITIONS = [
185
203
  type: 'function',
186
204
  function: {
187
205
  name: 'update_steps',
188
- description: 'Update the visible Cursor-style task checklist. At task start create 3-6 concrete steps; during work keep exactly one active step and mark completed steps done.',
206
+ description: 'Update the visible Cursor-style task checklist. At task start create 3-6 outcome-oriented steps; do not add a generic project-exploration step when shared context already covers it. Keep exactly one active step.',
189
207
  parameters: {
190
208
  type: 'object',
191
209
  properties: {
@@ -315,12 +333,15 @@ export class ToolExecutor {
315
333
  requestQuestion;
316
334
  skills;
317
335
  mode;
336
+ onInspect;
337
+ profile;
338
+ maxOutput;
318
339
  /** Last content this agent has seen for each file — basis of adaptive merging. */
319
340
  lastRead = new Map();
320
341
  /** Questions already asked — capped at 3 per task. */
321
342
  questionsAsked = 0;
322
343
  planApproved = false;
323
- constructor(board, agentId, agentName, projectRoot, requestApproval, requestQuestion, skills, mode = 'task') {
344
+ constructor(board, agentId, agentName, projectRoot, requestApproval, requestQuestion, skills, mode = 'task', onInspect, profile = 'standard', maxOutput = MAX_OUTPUT) {
324
345
  this.board = board;
325
346
  this.agentId = agentId;
326
347
  this.agentName = agentName;
@@ -329,6 +350,9 @@ export class ToolExecutor {
329
350
  this.requestQuestion = requestQuestion;
330
351
  this.skills = skills;
331
352
  this.mode = mode;
353
+ this.onInspect = onInspect;
354
+ this.profile = profile;
355
+ this.maxOutput = maxOutput;
332
356
  }
333
357
  resolve(rel) {
334
358
  const root = path.resolve(this.projectRoot);
@@ -344,6 +368,7 @@ export class ToolExecutor {
344
368
  }
345
369
  rememberRead(relPath, content) {
346
370
  this.lastRead.set(relPath, { content, revision: this.board.fileRevision(relPath) });
371
+ this.onInspect?.(this.agentId, relPath, content);
347
372
  }
348
373
  adaptationMessage(relPath, seen, current, verb = 'was modified') {
349
374
  this.board.recordConflict(relPath);
@@ -421,9 +446,11 @@ export class ToolExecutor {
421
446
  case 'list_files':
422
447
  return this.listFiles(args?.path ?? '.');
423
448
  case 'read_file':
424
- return this.readFile(args.path);
449
+ return this.readFile(args.path, args?.startLine, args?.endLine);
425
450
  case 'read_many':
426
451
  return this.readMany(args.paths);
452
+ case 'read_artifact':
453
+ return this.readArtifact(args.id, args?.startLine, args?.lineCount);
427
454
  case 'write_file':
428
455
  return this.writeFile(args.path, args.content);
429
456
  case 'edit_file':
@@ -622,16 +649,18 @@ export class ToolExecutor {
622
649
  return '(empty folder)';
623
650
  return out.join('\n');
624
651
  }
625
- readFile(rel) {
652
+ readFile(rel, requestedStart, requestedEnd) {
626
653
  const abs = this.resolve(rel);
627
654
  const relPath = this.relOf(rel);
628
655
  const content = fs.readFileSync(abs, 'utf8');
629
656
  this.rememberRead(relPath, content);
630
657
  this.board.resolveConflict(relPath);
631
658
  const lines = content.split('\n');
632
- const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
633
- return numbered.length > MAX_OUTPUT
634
- ? numbered.slice(0, MAX_OUTPUT) + `\n... (truncated, ${lines.length} lines total)`
659
+ const start = Math.max(1, Math.floor(Number(requestedStart) || 1));
660
+ const end = Math.min(lines.length, Math.max(start, Math.floor(Number(requestedEnd) || lines.length)));
661
+ const numbered = lines.slice(start - 1, end).map((l, i) => `${String(start + i).padStart(4)}|${l}`).join('\n');
662
+ return numbered.length > this.maxOutput
663
+ ? numbered.slice(0, this.maxOutput) + `\n... (truncated; requested ${start}-${end}, ${lines.length} lines total)`
635
664
  : numbered;
636
665
  }
637
666
  readMany(paths) {
@@ -648,8 +677,9 @@ export class ToolExecutor {
648
677
  this.board.resolveConflict(relPath);
649
678
  const lines = content.split('\n');
650
679
  const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
651
- const body = numbered.length > Math.floor(MAX_OUTPUT / relPaths.length)
652
- ? numbered.slice(0, Math.floor(MAX_OUTPUT / relPaths.length)) + `\n... (truncated, ${lines.length} lines total)`
680
+ const perFile = Math.max(1_000, Math.floor(this.maxOutput / relPaths.length));
681
+ const body = numbered.length > perFile
682
+ ? numbered.slice(0, perFile) + `\n... (truncated, ${lines.length} lines total)`
653
683
  : numbered;
654
684
  chunks.push(`--- ${relPath} (${lines.length} lines) ---\n${body}`);
655
685
  }
@@ -659,6 +689,19 @@ export class ToolExecutor {
659
689
  }
660
690
  return chunks.join('\n\n');
661
691
  }
692
+ readArtifact(id, requestedStart, requestedCount) {
693
+ const safeId = String(id ?? '');
694
+ if (!/^artifact-\d+\.txt$/.test(safeId))
695
+ return 'ERROR: invalid artifact id.';
696
+ const file = path.join(this.projectRoot, '.parallel', 'runs', this.agentId, 'artifacts', safeId);
697
+ if (!fs.existsSync(file))
698
+ return `ERROR: artifact not found: ${safeId}`;
699
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
700
+ const start = Math.max(1, Math.floor(Number(requestedStart) || 1));
701
+ const count = Math.min(200, Math.max(1, Math.floor(Number(requestedCount) || 80)));
702
+ const end = Math.min(lines.length, start + count - 1);
703
+ return lines.slice(start - 1, end).map((line, index) => `${String(start + index).padStart(4)}|${line}`).join('\n');
704
+ }
662
705
  /**
663
706
  * Adaptive co-editing: writing is NEVER blocked by another agent.
664
707
  * But if the file changed under you since your last read, you first get
@@ -737,7 +780,8 @@ export class ToolExecutor {
737
780
  }
738
781
  const results = [];
739
782
  const walk = (dir, depth) => {
740
- if (depth > 6 || results.length > 100)
783
+ const resultLimit = this.profile === 'quick' ? 40 : this.profile === 'standard' ? 80 : 150;
784
+ if (depth > 6 || results.length >= resultLimit)
741
785
  return;
742
786
  let entries;
743
787
  try {
@@ -765,11 +809,15 @@ export class ToolExecutor {
765
809
  continue;
766
810
  }
767
811
  const lines = content.split('\n');
768
- for (let i = 0; i < lines.length && results.length <= 100; i++) {
812
+ let matched = false;
813
+ for (let i = 0; i < lines.length && results.length < resultLimit; i++) {
769
814
  if (re.test(lines[i])) {
815
+ matched = true;
770
816
  results.push(`${path.relative(this.projectRoot, full)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
771
817
  }
772
818
  }
819
+ if (matched)
820
+ this.onInspect?.(this.agentId, path.relative(this.projectRoot, full), content);
773
821
  }
774
822
  }
775
823
  };
@@ -829,12 +877,17 @@ export class ToolExecutor {
829
877
  return;
830
878
  }
831
879
  const lines = content.split('\n');
880
+ let matched = false;
832
881
  for (let i = 0; i < lines.length && matches.length <= 200; i++) {
833
882
  for (const { raw, re } of regexes) {
834
- if (re.test(lines[i]))
883
+ if (re.test(lines[i])) {
884
+ matched = true;
835
885
  matches.push(`${raw} :: ${relPath}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
886
+ }
836
887
  }
837
888
  }
889
+ if (matched)
890
+ this.onInspect?.(this.agentId, relPath, content);
838
891
  };
839
892
  for (const rel of paths)
840
893
  visit(this.resolve(rel), 0);
@@ -863,11 +916,12 @@ export class ToolExecutor {
863
916
  out += '\n(process killed: 120s timeout)';
864
917
  else if (err)
865
918
  out += `\n(exit code: ${err.code ?? 1})`;
866
- if (out.length > MAX_OUTPUT)
867
- out = out.slice(0, MAX_OUTPUT) + '\n... (output truncated)';
868
919
  const result = out || '(no output, success)';
869
920
  const changed = this.recordShellMutations(before);
870
- this.board.log(this.agentId, 'tool_result', result);
921
+ const logged = result.length > this.maxOutput
922
+ ? `${result.slice(0, this.maxOutput)}\n... (${result.length.toLocaleString()} characters; full output retained as an agent artifact)`
923
+ : result;
924
+ this.board.log(this.agentId, 'tool_result', logged);
871
925
  resolve(changed > 0 ? `${result}\n\nTracked shell mutations: ${changed} file${changed === 1 ? '' : 's'}.` : result);
872
926
  });
873
927
  });
package/dist/commands.js CHANGED
@@ -9,9 +9,9 @@ import { t } from './i18n.js';
9
9
  // inspect the session → git safety net → session & config → exit.
10
10
  export const COMMANDS = [
11
11
  // create agents
12
- { name: '/ask', args: '[Name:] <question> [--model=m]', descKey: 'cmd.ask', group: 'modes', aliases: ['/a'] },
13
- { name: '/task', args: '[Name:] <task> [--model=m] [#skill]', descKey: 'cmd.task', group: 'modes', aliases: ['/t'] },
14
- { name: '/plan', args: '[Name:] <task> [--model=m]', descKey: 'cmd.plan', group: 'modes', aliases: ['/p'] },
12
+ { name: '/ask', args: '[Name:] <question> [--quick|--standard|--deep] [--model=m]', descKey: 'cmd.ask', group: 'modes', aliases: ['/a'] },
13
+ { name: '/task', args: '[Name:] <task> [--quick|--standard|--deep] [--model=m] [#skill]', descKey: 'cmd.task', group: 'modes', aliases: ['/t'] },
14
+ { name: '/plan', args: '[Name:] <task> [--quick|--standard|--deep] [--model=m]', descKey: 'cmd.plan', group: 'modes', aliases: ['/p'] },
15
15
  { name: '/review', args: '[agent|all] [prompt]', descKey: 'cmd.review', group: 'modes' },
16
16
  { name: '/issue', args: '<n>', descKey: 'cmd.issue', group: 'git' },
17
17
  { name: '/specialist', args: '<name> <task> | new <name> [global]', descKey: 'cmd.specialist', group: 'modes' },
@@ -39,6 +39,7 @@ export const COMMANDS = [
39
39
  { name: '/diff', args: '', descKey: 'cmd.diff', group: 'views' },
40
40
  { name: '/cost', args: '', descKey: 'cmd.cost', group: 'views' },
41
41
  { name: '/status', args: '', descKey: 'cmd.status', group: 'views' },
42
+ { name: '/memory', args: '[refresh]', descKey: 'cmd.memory', group: 'views' },
42
43
  // sessions
43
44
  { name: '/save', args: '[name]', descKey: 'cmd.save', group: 'git' },
44
45
  { name: '/sessions', args: '', descKey: 'cmd.sessions', group: 'git' },
@@ -172,6 +173,10 @@ async function doctorReport(ctl, ui) {
172
173
  }
173
174
  lines.push(commandExists('git') ? t('m.doctorGitOk') : t('m.doctorGitMissing'));
174
175
  lines.push(commandExists('gh') ? t('m.doctorGhOk') : t('m.doctorGhMissing'));
176
+ const memory = ctl.projectContextStatus();
177
+ if (['idle', 'fallback', 'error'].includes(memory.status) && level !== 'error')
178
+ level = 'warn';
179
+ lines.push(t('m.doctorMemory', { status: memory.status, date: memory.generatedAt ?? '—' }));
175
180
  ui.system(t('m.doctorReport', { lines: lines.join('\n') }), level);
176
181
  }
177
182
  /**
@@ -225,12 +230,21 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
225
230
  }
226
231
  // optional --model=xxx flag
227
232
  let model;
233
+ let profile;
228
234
  let task = arg;
229
235
  const mFlag = task.match(/\s--model=(\S+)/);
230
236
  if (mFlag) {
231
237
  model = mFlag[1];
232
238
  task = task.replace(mFlag[0], '').trim();
233
239
  }
240
+ for (const candidate of ['quick', 'standard', 'deep']) {
241
+ const flag = `--${candidate}`;
242
+ if (new RegExp(`(^|\\s)${flag}(?=\\s|$)`).test(task)) {
243
+ profile = candidate;
244
+ task = task.replace(new RegExp(`(^|\\s)${flag}(?=\\s|$)`), ' ').trim();
245
+ break;
246
+ }
247
+ }
234
248
  // optional #skill tokens → force-load these skills at the start of the task
235
249
  const forced = [];
236
250
  const available = ctl.getSkills();
@@ -249,11 +263,12 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
249
263
  // optional "Name:" prefix
250
264
  const named = task.match(/^([\p{L}\p{N}_-]{1,16}):\s+(.+)$/su);
251
265
  const finalTask = named ? named[2] : task;
252
- const agent = ctl.spawnAgent(finalTask, named ? named[1] : undefined, model, images, specialist, undefined, mode);
266
+ const agent = ctl.spawnAgent(finalTask, named ? named[1] : undefined, model, images, specialist, undefined, mode, profile);
253
267
  if (!agent)
254
268
  return ui.system(specialist ? t('m.noSpecialist', { name: specialist }) : t('m.spawnFail'), 'error');
255
269
  ui.system(t('m.spawned', { name: agent.name, model: model ? ` (${model})` : '' }) +
256
270
  ` /${mode}` +
271
+ ` [${ctl.board.agents.get(agent.id)?.profile ?? profile ?? 'auto'}]` +
257
272
  (specialist ? ` 🎓${specialist}` : '') +
258
273
  (forced.length > 0 ? ` 🧩${forced.join(',')}` : ''), 'info');
259
274
  }
@@ -495,6 +510,34 @@ export function executeInput(raw, ctl, ui, images) {
495
510
  case '/cost':
496
511
  ui.setView('cost');
497
512
  return;
513
+ case '/memory': {
514
+ if (arg && arg !== 'refresh')
515
+ return ui.system(t('m.usageMemory'), 'warn');
516
+ if (arg === 'refresh') {
517
+ ui.system(t('memory.indexing'), 'info');
518
+ void ctl.refreshProjectContext().then(() => {
519
+ const memory = ctl.projectContextStatus();
520
+ ui.system(t('m.memoryStatus', {
521
+ status: memory.status,
522
+ date: memory.generatedAt ?? '—',
523
+ model: memory.model ?? '—',
524
+ tokens: memory.tokensIn + memory.tokensOut,
525
+ cost: memory.cost === null ? '—' : memory.cost.toFixed(4),
526
+ }), memory.status === 'ready' ? 'ok' : 'warn');
527
+ });
528
+ return;
529
+ }
530
+ const memory = ctl.projectContextStatus();
531
+ const index = ctl.projectIndexStatus();
532
+ ui.system(t('m.memoryStatus', {
533
+ status: memory.status,
534
+ date: memory.generatedAt ?? '—',
535
+ model: memory.model ?? '—',
536
+ tokens: memory.tokensIn + memory.tokensOut,
537
+ cost: memory.cost === null ? '—' : memory.cost.toFixed(4),
538
+ }) + ` · index ${index.files} files/${index.symbols} symbols`, memory.status === 'ready' ? 'ok' : memory.status === 'indexing' ? 'info' : 'warn');
539
+ return;
540
+ }
498
541
  case '/status': {
499
542
  const p = ctl.sessionProvider();
500
543
  const agents = [...ctl.board.agents.values()];
@@ -502,8 +545,22 @@ export function executeInput(raw, ctl, ui, images) {
502
545
  const cost = agents.reduce((s, a) => s + (a.cost ?? 0), 0);
503
546
  const changed = new Set(ctl.board.changes.map((c) => c.path)).size;
504
547
  const pm = p ? `${p.name}:${ctl.session.model}` : '-';
548
+ const memory = ctl.projectContextStatus();
549
+ const profiles = agents.reduce((counts, agent) => {
550
+ counts[agent.profile] = (counts[agent.profile] ?? 0) + 1;
551
+ return counts;
552
+ }, {});
505
553
  // Multiline: each metric on its own line for readability.
506
- ui.system(t('m.status', { pm, approval: ctl.session.approvalMode, total: agents.length, active, changed, cost: cost.toFixed(3) }), 'info');
554
+ ui.system(t('m.status', {
555
+ pm,
556
+ approval: ctl.session.approvalMode,
557
+ total: agents.length,
558
+ active,
559
+ changed,
560
+ cost: cost.toFixed(3),
561
+ memory: memory.status,
562
+ memoryCost: memory.cost === null ? '—' : memory.cost.toFixed(4),
563
+ }) + `\nProfiles: ${Object.entries(profiles).map(([profile, count]) => `${profile}=${count}`).join(', ') || 'none'}`, 'info');
507
564
  return;
508
565
  }
509
566
  case '/raw':
@@ -5,11 +5,14 @@ import { exec, execFileSync, spawn } from 'node:child_process';
5
5
  import { Blackboard } from './coordination/blackboard.js';
6
6
  import { LLMClient } from './llm/client.js';
7
7
  import { Agent } from './agents/agent.js';
8
- import { configFile, saveConfig, getProvider, upsertProvider } from './config.js';
9
- import { priceFor, fmtCost } from './pricing.js';
8
+ import { configFile, saveConfig, getProvider, providerReady, upsertProvider } from './config.js';
9
+ import { priceFor, fmtCost, costOf } from './pricing.js';
10
10
  import { loadSkills, loadSpecialists } from './skills.js';
11
11
  import { t } from './i18n.js';
12
12
  import { chmodPrivateTree, ensurePrivateDir, sanitizeForPersistence, writeFileAtomicPrivate } from './security.js';
13
+ import { ProjectContextStore, } from './project-context.js';
14
+ import { ProjectIndex } from './project-index.js';
15
+ import { classifyExecutionProfile, EXECUTION_BUDGETS } from './agents/execution-policy.js';
13
16
  const AGENT_COLORS = ['#f3e7c7', 'magenta', 'yellow', 'green', 'whiteBright', 'redBright', '#c8bfa6', 'magentaBright'];
14
17
  export function normalizeShellApprovalMode(mode) {
15
18
  if (mode === 'ask' || mode === 'auto-safe' || mode === 'yolo')
@@ -85,6 +88,8 @@ export class Controller extends EventEmitter {
85
88
  config;
86
89
  projectRoot;
87
90
  board;
91
+ projectContext;
92
+ projectIndex;
88
93
  agents = new Map();
89
94
  approvals = [];
90
95
  questions = [];
@@ -106,11 +111,25 @@ export class Controller extends EventEmitter {
106
111
  sessionOnlyProvider = null;
107
112
  sessionRetentionDays = 30;
108
113
  sessionRetentionMax = 30;
114
+ restoredSessionContext = '';
109
115
  constructor(config, projectRoot) {
110
116
  super();
111
117
  this.config = config;
112
118
  this.projectRoot = projectRoot;
113
119
  this.board = new Blackboard(projectRoot);
120
+ this.projectContext = new ProjectContextStore(projectRoot, (status, detail) => {
121
+ const text = status === 'indexing'
122
+ ? t('memory.indexing')
123
+ : status === 'ready'
124
+ ? t('memory.ready')
125
+ : status === 'fallback'
126
+ ? t('memory.fallback', { detail: detail ?? '' })
127
+ : '';
128
+ if (text)
129
+ this.board.log('', 'memory', text);
130
+ this.emit('update');
131
+ });
132
+ this.projectIndex = new ProjectIndex(projectRoot);
114
133
  const p = getProvider(config);
115
134
  this.session = {
116
135
  providerName: p?.name ?? '',
@@ -122,6 +141,10 @@ export class Controller extends EventEmitter {
122
141
  this.board.on('agent-event', (ev) => this.onAgentEvent(ev));
123
142
  this.board.on('note', (note) => this.nudgeFromNote(note));
124
143
  this.hardenPrivateState();
144
+ queueMicrotask(() => {
145
+ this.projectIndex.refresh();
146
+ void this.prewarmProjectContext();
147
+ });
125
148
  // Autosave: the session (+ conversations, written live by agents) survives a crash.
126
149
  const autosave = setInterval(() => this.saveSession(), 30_000);
127
150
  autosave.unref();
@@ -165,6 +188,69 @@ export class Controller extends EventEmitter {
165
188
  }
166
189
  return c;
167
190
  }
191
+ projectContextGenerator() {
192
+ const provider = this.sessionProvider();
193
+ const model = this.session.model || provider?.defaultModel || provider?.models[0] || '';
194
+ if (!provider || !model || !providerReady(provider))
195
+ return undefined;
196
+ const llm = this.llmFor(provider, model);
197
+ const price = priceFor(provider, model);
198
+ return async (messages) => {
199
+ const result = await llm.chat(messages);
200
+ return {
201
+ content: result.message.content ?? '',
202
+ tokensIn: result.tokensIn,
203
+ tokensOut: result.tokensOut,
204
+ model,
205
+ cost: price ? costOf(price, result.tokensIn, result.tokensOut) : null,
206
+ };
207
+ };
208
+ }
209
+ prewarmProjectContext(force = false) {
210
+ return this.projectContext.refresh(this.projectContextGenerator(), force);
211
+ }
212
+ refreshProjectContext() {
213
+ this.projectIndex.refresh();
214
+ return this.prewarmProjectContext(true);
215
+ }
216
+ projectContextStatus() {
217
+ return this.projectContext.statusSnapshot();
218
+ }
219
+ projectIndexStatus() {
220
+ return this.projectIndex.status();
221
+ }
222
+ async projectContextBootstrap(task = '') {
223
+ const agents = [...this.board.agents.values()]
224
+ .map((agent) => `- ${agent.name} [${agent.state}] ${agent.task}` +
225
+ (agent.currentAction ? ` | current: ${agent.currentAction}` : '') +
226
+ (agent.lastResult ? ` | result: ${agent.lastResult.slice(0, 600)}` : ''))
227
+ .join('\n');
228
+ const notes = this.board.notes
229
+ .slice(-20)
230
+ .map((note) => `- ${note.from} → ${note.to}: ${note.content.slice(0, 500)}`)
231
+ .join('\n');
232
+ const changes = this.board.changes
233
+ .slice(-20)
234
+ .map((change) => `- ${change.agentName}: ${change.path}`)
235
+ .join('\n');
236
+ const preSpawnContext = `PRE-SPAWN SESSION CONTEXT
237
+ Agents:
238
+ ${agents || '- none'}
239
+ Recent notes:
240
+ ${notes || '- none'}
241
+ Changes made before this agent started:
242
+ ${changes || '- none'}`;
243
+ const shared = this.projectContext.snapshot();
244
+ const targeted = this.projectIndex.retrieve(task);
245
+ return [
246
+ shared,
247
+ targeted,
248
+ preSpawnContext,
249
+ this.restoredSessionContext ? `RESTORED SESSION CONTEXT\n${this.restoredSessionContext}` : '',
250
+ ]
251
+ .filter(Boolean)
252
+ .join('\n\n');
253
+ }
168
254
  // ---------- sound cues (terminal bell) ----------
169
255
  onAgentEvent(ev) {
170
256
  if (ev.type === 'conflict' && ev.path) {
@@ -207,6 +293,10 @@ export class Controller extends EventEmitter {
207
293
  nudgeFromNote(note) {
208
294
  if (note.to === 'user' || note.from === 'system')
209
295
  return;
296
+ // User steering is urgent. Ordinary team notes are consumed as a batch on
297
+ // the next natural turn so they do not discard an in-flight model call.
298
+ if (note.from !== 'user' && !/\b(urgent|blocker|blocked|conflict|collision)\b/i.test(note.content))
299
+ return;
210
300
  const recipients = note.to === 'all'
211
301
  ? [...this.board.agents.values()].filter((a) => a.name.toLowerCase() !== note.from.toLowerCase())
212
302
  : [this.board.getAgentByName(note.to)].filter((a) => Boolean(a));
@@ -335,7 +425,7 @@ export class Controller extends EventEmitter {
335
425
  return out;
336
426
  }
337
427
  /** Launch agent N+1 — works at any time, even while others are running. */
338
- spawnAgent(task, name, modelSpec, images, specialistName, initialHistory, mode = 'task') {
428
+ spawnAgent(task, name, modelSpec, images, specialistName, initialHistory, mode = 'task', forcedProfile) {
339
429
  // Specialist persona: role appended to the system prompt, may pin a model.
340
430
  let specialist;
341
431
  if (specialistName) {
@@ -358,6 +448,8 @@ export class Controller extends EventEmitter {
358
448
  // A custom name keeps its alias, so the agent stays addressable both ways.
359
449
  const alias = `a${this.agentSeq}`;
360
450
  const agentName = name?.trim() || alias;
451
+ const profile = classifyExecutionProfile(task, mode, forcedProfile);
452
+ const budget = EXECUTION_BUDGETS[profile];
361
453
  const color = AGENT_COLORS[(this.agentSeq - 1) % AGENT_COLORS.length];
362
454
  // Conversation file (JSONL, appended live) — enables /restore after a save.
363
455
  let historyFile;
@@ -376,11 +468,13 @@ export class Controller extends EventEmitter {
376
468
  color,
377
469
  task,
378
470
  mode,
471
+ profile,
379
472
  model: resolved.model,
380
473
  llm: this.llmFor(resolved.provider, resolved.model),
381
474
  board: this.board,
382
475
  projectRoot: this.projectRoot,
383
476
  maxSteps: this.config.maxStepsPerAgent,
477
+ budget,
384
478
  requestApproval: this.requestApproval,
385
479
  requestQuestion: this.requestQuestion,
386
480
  images,
@@ -390,6 +484,27 @@ export class Controller extends EventEmitter {
390
484
  projectMemory: this.projectMemory(),
391
485
  historyFile,
392
486
  initialHistory,
487
+ projectContext: this.projectContextBootstrap(task),
488
+ onInspect: (agentId, relPath, content) => {
489
+ this.projectContext.recordInspection(agentId, relPath, content);
490
+ const current = this.board.agents.get(agentId);
491
+ if (current) {
492
+ current.inspectedFiles = this.projectContext.inspectedFiles(agentId);
493
+ }
494
+ },
495
+ onComplete: (agentId, summary) => {
496
+ const info = this.board.agents.get(agentId);
497
+ if (!info)
498
+ return;
499
+ const changedFiles = [...new Set(this.board.changes.filter((change) => change.agentId === agentId).map((change) => change.path))];
500
+ this.projectContext.recordOutcome(agentId, {
501
+ agentName: info.name,
502
+ task: info.task,
503
+ result: summary,
504
+ changedFiles,
505
+ });
506
+ this.projectIndex.refresh();
507
+ },
393
508
  });
394
509
  if (historyFile)
395
510
  this.conversationFiles.set(id, historyFile);
@@ -479,7 +594,7 @@ export class Controller extends EventEmitter {
479
594
  if (history.length === 0)
480
595
  return 'no-conversation';
481
596
  const modelSpec = sa.model ? (sa.providerName ? `${sa.providerName}:${sa.model}` : sa.model) : undefined;
482
- return this.spawnAgent(sa.task, sa.name, modelSpec, undefined, sa.specialist, history, sa.mode ?? 'task');
597
+ return this.spawnAgent(sa.task, sa.name, modelSpec, undefined, sa.specialist, history, sa.mode ?? 'task', sa.profile);
483
598
  }
484
599
  pauseAgent(name) {
485
600
  const a = this.findAgent(name);
@@ -728,8 +843,10 @@ export class Controller extends EventEmitter {
728
843
  alias: a.alias,
729
844
  task: a.task,
730
845
  mode: a.mode,
846
+ profile: a.profile,
731
847
  state: a.state,
732
848
  lastResult: a.lastResult,
849
+ startedAt: a.startedAt,
733
850
  steps: a.steps,
734
851
  tokensIn: a.tokensIn,
735
852
  tokensOut: a.tokensOut,
@@ -742,6 +859,7 @@ export class Controller extends EventEmitter {
742
859
  ctxPct: a.ctxPct,
743
860
  progressSteps: a.progressSteps,
744
861
  perf: a.perf,
862
+ inspectedFiles: a.inspectedFiles,
745
863
  conversation: this.conversationFiles.get(a.id),
746
864
  })),
747
865
  notes: this.board.notes.slice(-200),
@@ -749,6 +867,12 @@ export class Controller extends EventEmitter {
749
867
  fileActivity: [...this.board.fileActivity.values()].slice(-100),
750
868
  workMapWarnings: this.board.workMapWarnings.slice(-80),
751
869
  changedFiles: [...new Set(this.board.changes.map((c) => c.path))],
870
+ projectContext: {
871
+ schemaVersion: 1,
872
+ generatedAt: this.projectContextStatus().generatedAt,
873
+ fingerprint: this.projectContextStatus().fingerprint,
874
+ status: this.projectContextStatus().status,
875
+ },
752
876
  };
753
877
  const file = path.join(dir, `session-${this.sessionStamp}.json`);
754
878
  writeFileAtomicPrivate(file, sanitizeForPersistence(JSON.stringify(data, null, 2)));
@@ -783,13 +907,17 @@ export class Controller extends EventEmitter {
783
907
  if (data.name)
784
908
  this.sessionName = data.name;
785
909
  const tasks = data.agents.map((a) => `${a.name} [${a.state}] : ${a.task}${a.lastResult ? ` → ${a.lastResult}` : ''}`);
910
+ this.restoredSessionContext = `Previous session saved at ${data.savedAt}.
911
+ Past agents:
912
+ ${tasks.join('\n')}
913
+ Files changed then: ${(data.changedFiles ?? []).join(', ') || '(none)'}`;
786
914
  this.board.hydrate({
787
915
  notes: data.notes ?? [],
788
916
  changes: data.changes ?? [],
789
917
  fileActivity: data.fileActivity ?? [],
790
918
  workMapWarnings: data.workMapWarnings ?? [],
791
919
  });
792
- this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${(data.changedFiles ?? []).join(', ') || '(none)'}`);
920
+ this.board.addNote('system', 'all', this.restoredSessionContext);
793
921
  this.board.log('', 'system', t('m.sessionRestored', { date: new Date(data.savedAt).toLocaleString() }));
794
922
  // Financial history: per-agent cost/steps/tokens of the restored session.
795
923
  const withCost = data.agents.filter((a) => a.cost !== undefined || a.tokensIn !== undefined);
@@ -809,6 +937,7 @@ export class Controller extends EventEmitter {
809
937
  this.session.providerName = r.provider.name;
810
938
  this.session.model = r.model;
811
939
  this.emit('update');
940
+ void this.prewarmProjectContext();
812
941
  return { provider: r.provider.name, model: r.model };
813
942
  }
814
943
  setSessionProvider(name) {
@@ -819,6 +948,7 @@ export class Controller extends EventEmitter {
819
948
  this.session.providerName = p.name;
820
949
  this.session.model = p.defaultModel || p.models[0] || '';
821
950
  this.emit('update');
951
+ void this.prewarmProjectContext();
822
952
  return true;
823
953
  }
824
954
  setSessionProviderConfig(p) {
@@ -827,6 +957,7 @@ export class Controller extends EventEmitter {
827
957
  this.session.model = p.defaultModel || p.models[0] || '';
828
958
  this.llmCache.clear();
829
959
  this.emit('update');
960
+ void this.prewarmProjectContext();
830
961
  }
831
962
  setSessionApprovalMode(mode) {
832
963
  this.session.approvalMode = mode;