@parallel-cli/parallel 0.4.8 → 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.
@@ -0,0 +1,58 @@
1
+ export const EXECUTION_BUDGETS = {
2
+ quick: {
3
+ profile: 'quick',
4
+ maxRounds: 6,
5
+ maxToolCalls: 12,
6
+ maxShellCommands: 2,
7
+ maxInputTokens: 150_000,
8
+ maxResultChars: 8_000,
9
+ maxRecentMessages: 24,
10
+ convergenceAt: 0.7,
11
+ },
12
+ standard: {
13
+ profile: 'standard',
14
+ maxRounds: 16,
15
+ maxToolCalls: 32,
16
+ maxShellCommands: 6,
17
+ maxInputTokens: 600_000,
18
+ maxResultChars: 16_000,
19
+ maxRecentMessages: 42,
20
+ convergenceAt: 0.75,
21
+ },
22
+ deep: {
23
+ profile: 'deep',
24
+ maxRounds: 60,
25
+ maxToolCalls: 120,
26
+ maxShellCommands: 30,
27
+ maxInputTokens: 3_000_000,
28
+ maxResultChars: 32_000,
29
+ maxRecentMessages: 80,
30
+ convergenceAt: 0.82,
31
+ },
32
+ };
33
+ const COMPLEX = /\b(migrat|refactor|architecture|redesign|rewrite|exhaustive|end[- ]to[- ]end|across|monorepo|multi[- ]service|security audit|performance audit|release|deploy)\b/i;
34
+ const SIMPLE = /\b(explain|find|locate|where|why|diagnos|inspect|verify|check|typo|rename|toggle|small|simple)\b/i;
35
+ export function classifyExecutionProfile(task, mode, forced) {
36
+ if (forced)
37
+ return forced;
38
+ if (mode === 'plan')
39
+ return 'deep';
40
+ if (mode === 'ask')
41
+ return COMPLEX.test(task) || task.length > 1_200 ? 'standard' : 'quick';
42
+ const pathMentions = task.match(/\b[\w./-]+\.(?:ts|tsx|js|mjs|json|md|py|rs|go|java)\b/g)?.length ?? 0;
43
+ if (COMPLEX.test(task) || pathMentions > 3 || task.length > 1_600)
44
+ return 'standard';
45
+ if (SIMPLE.test(task) || pathMentions <= 1 || task.length < 500)
46
+ return 'quick';
47
+ return 'standard';
48
+ }
49
+ export function nextExecutionProfile(profile) {
50
+ if (profile === 'quick')
51
+ return 'standard';
52
+ if (profile === 'standard')
53
+ return 'deep';
54
+ return null;
55
+ }
56
+ export function shouldEscalateExecution(task, inspectedFiles, changedFiles) {
57
+ return COMPLEX.test(task) || inspectedFiles > 3 || changedFiles > 3;
58
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { exec } from 'node:child_process';
4
4
  import * as Diff from 'diff';
5
+ import { appendFilePrivate, ensurePrivateDir, sanitizeTerminalText, writeFileAtomicPrivate } from '../security.js';
5
6
  const IGNORED = new Set(['node_modules', '.git', '.parallel', '.cursor', 'dist', '__pycache__', '.venv', 'venv']);
6
7
  const MAX_OUTPUT = 12_000;
7
8
  const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'claim_files', 'remember']);
@@ -15,6 +16,12 @@ function isMutatingShell(command) {
15
16
  return true;
16
17
  if (/[>|]\s*(sh|bash)\b/.test(c) || /\b(curl|wget)\b.*\|\s*(sh|bash)/.test(c))
17
18
  return true;
19
+ if (/\b(curl|wget)\b.*(&&|;)\s*(sh|bash|zsh|python|node)\b/.test(c))
20
+ return true;
21
+ if (/\b(nc|ncat|netcat|socat|telnet|ssh|scp|rsync)\b/.test(c))
22
+ return true;
23
+ if (/\b(bash|sh|zsh)\s+-c\b|\b(python|python3|node|perl|ruby)\s+(-c|-e)\b/.test(c))
24
+ return true;
18
25
  return false;
19
26
  }
20
27
  export const TOOL_DEFINITIONS = [
@@ -22,7 +29,7 @@ export const TOOL_DEFINITIONS = [
22
29
  type: 'function',
23
30
  function: {
24
31
  name: 'list_files',
25
- 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.',
26
33
  parameters: {
27
34
  type: 'object',
28
35
  properties: {
@@ -40,6 +47,8 @@ export const TOOL_DEFINITIONS = [
40
47
  type: 'object',
41
48
  properties: {
42
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' },
43
52
  },
44
53
  required: ['path'],
45
54
  },
@@ -63,6 +72,22 @@ export const TOOL_DEFINITIONS = [
63
72
  },
64
73
  },
65
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
+ },
66
91
  {
67
92
  type: 'function',
68
93
  function: {
@@ -113,7 +138,7 @@ export const TOOL_DEFINITIONS = [
113
138
  type: 'function',
114
139
  function: {
115
140
  name: 'inspect_project',
116
- 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.',
117
142
  parameters: {
118
143
  type: 'object',
119
144
  properties: {
@@ -178,7 +203,7 @@ export const TOOL_DEFINITIONS = [
178
203
  type: 'function',
179
204
  function: {
180
205
  name: 'update_steps',
181
- 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.',
182
207
  parameters: {
183
208
  type: 'object',
184
209
  properties: {
@@ -308,12 +333,15 @@ export class ToolExecutor {
308
333
  requestQuestion;
309
334
  skills;
310
335
  mode;
336
+ onInspect;
337
+ profile;
338
+ maxOutput;
311
339
  /** Last content this agent has seen for each file — basis of adaptive merging. */
312
340
  lastRead = new Map();
313
341
  /** Questions already asked — capped at 3 per task. */
314
342
  questionsAsked = 0;
315
343
  planApproved = false;
316
- 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) {
317
345
  this.board = board;
318
346
  this.agentId = agentId;
319
347
  this.agentName = agentName;
@@ -322,6 +350,9 @@ export class ToolExecutor {
322
350
  this.requestQuestion = requestQuestion;
323
351
  this.skills = skills;
324
352
  this.mode = mode;
353
+ this.onInspect = onInspect;
354
+ this.profile = profile;
355
+ this.maxOutput = maxOutput;
325
356
  }
326
357
  resolve(rel) {
327
358
  const root = path.resolve(this.projectRoot);
@@ -337,6 +368,7 @@ export class ToolExecutor {
337
368
  }
338
369
  rememberRead(relPath, content) {
339
370
  this.lastRead.set(relPath, { content, revision: this.board.fileRevision(relPath) });
371
+ this.onInspect?.(this.agentId, relPath, content);
340
372
  }
341
373
  adaptationMessage(relPath, seen, current, verb = 'was modified') {
342
374
  this.board.recordConflict(relPath);
@@ -414,9 +446,11 @@ export class ToolExecutor {
414
446
  case 'list_files':
415
447
  return this.listFiles(args?.path ?? '.');
416
448
  case 'read_file':
417
- return this.readFile(args.path);
449
+ return this.readFile(args.path, args?.startLine, args?.endLine);
418
450
  case 'read_many':
419
451
  return this.readMany(args.paths);
452
+ case 'read_artifact':
453
+ return this.readArtifact(args.id, args?.startLine, args?.lineCount);
420
454
  case 'write_file':
421
455
  return this.writeFile(args.path, args.content);
422
456
  case 'edit_file':
@@ -540,12 +574,12 @@ export class ToolExecutor {
540
574
  if (!f)
541
575
  return 'ERROR: remember needs a non-empty fact.';
542
576
  const file = path.join(this.projectRoot, '.parallel', 'memory.md');
543
- fs.mkdirSync(path.dirname(file), { recursive: true });
577
+ ensurePrivateDir(path.dirname(file));
544
578
  if (!fs.existsSync(file)) {
545
- fs.writeFileSync(file, '# Project memory\n\nDurable facts agents recorded. Injected into every agent\'s system prompt.\n\n');
579
+ writeFileAtomicPrivate(file, '# Project memory\n\nDurable facts agents recorded. Injected into every agent\'s system prompt.\n\n');
546
580
  }
547
581
  const line = `- ${f} _(${this.agentName}, ${new Date().toISOString().slice(0, 10)})_\n`;
548
- fs.appendFileSync(file, line);
582
+ appendFilePrivate(file, line);
549
583
  this.board.log(this.agentId, 'tool', `🧠 remember: ${f.slice(0, 80)}`);
550
584
  return 'Fact saved to the project memory. Every future agent will see it.';
551
585
  }
@@ -615,16 +649,18 @@ export class ToolExecutor {
615
649
  return '(empty folder)';
616
650
  return out.join('\n');
617
651
  }
618
- readFile(rel) {
652
+ readFile(rel, requestedStart, requestedEnd) {
619
653
  const abs = this.resolve(rel);
620
654
  const relPath = this.relOf(rel);
621
655
  const content = fs.readFileSync(abs, 'utf8');
622
656
  this.rememberRead(relPath, content);
623
657
  this.board.resolveConflict(relPath);
624
658
  const lines = content.split('\n');
625
- const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
626
- return numbered.length > MAX_OUTPUT
627
- ? 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)`
628
664
  : numbered;
629
665
  }
630
666
  readMany(paths) {
@@ -641,8 +677,9 @@ export class ToolExecutor {
641
677
  this.board.resolveConflict(relPath);
642
678
  const lines = content.split('\n');
643
679
  const numbered = lines.map((l, i) => `${String(i + 1).padStart(4)}|${l}`).join('\n');
644
- const body = numbered.length > Math.floor(MAX_OUTPUT / relPaths.length)
645
- ? 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)`
646
683
  : numbered;
647
684
  chunks.push(`--- ${relPath} (${lines.length} lines) ---\n${body}`);
648
685
  }
@@ -652,6 +689,19 @@ export class ToolExecutor {
652
689
  }
653
690
  return chunks.join('\n\n');
654
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
+ }
655
705
  /**
656
706
  * Adaptive co-editing: writing is NEVER blocked by another agent.
657
707
  * But if the file changed under you since your last read, you first get
@@ -730,7 +780,8 @@ export class ToolExecutor {
730
780
  }
731
781
  const results = [];
732
782
  const walk = (dir, depth) => {
733
- 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)
734
785
  return;
735
786
  let entries;
736
787
  try {
@@ -758,11 +809,15 @@ export class ToolExecutor {
758
809
  continue;
759
810
  }
760
811
  const lines = content.split('\n');
761
- 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++) {
762
814
  if (re.test(lines[i])) {
815
+ matched = true;
763
816
  results.push(`${path.relative(this.projectRoot, full)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
764
817
  }
765
818
  }
819
+ if (matched)
820
+ this.onInspect?.(this.agentId, path.relative(this.projectRoot, full), content);
766
821
  }
767
822
  }
768
823
  };
@@ -822,12 +877,17 @@ export class ToolExecutor {
822
877
  return;
823
878
  }
824
879
  const lines = content.split('\n');
880
+ let matched = false;
825
881
  for (let i = 0; i < lines.length && matches.length <= 200; i++) {
826
882
  for (const { raw, re } of regexes) {
827
- if (re.test(lines[i]))
883
+ if (re.test(lines[i])) {
884
+ matched = true;
828
885
  matches.push(`${raw} :: ${relPath}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
886
+ }
829
887
  }
830
888
  }
889
+ if (matched)
890
+ this.onInspect?.(this.agentId, relPath, content);
831
891
  };
832
892
  for (const rel of paths)
833
893
  visit(this.resolve(rel), 0);
@@ -849,18 +909,19 @@ export class ToolExecutor {
849
909
  exec(command, { cwd: this.projectRoot, timeout: 120_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
850
910
  let out = '';
851
911
  if (stdout)
852
- out += stdout;
912
+ out += sanitizeTerminalText(stdout);
853
913
  if (stderr)
854
- out += (out ? '\n--- stderr ---\n' : '') + stderr;
914
+ out += (out ? '\n--- stderr ---\n' : '') + sanitizeTerminalText(stderr);
855
915
  if (err && err.killed)
856
916
  out += '\n(process killed: 120s timeout)';
857
917
  else if (err)
858
918
  out += `\n(exit code: ${err.code ?? 1})`;
859
- if (out.length > MAX_OUTPUT)
860
- out = out.slice(0, MAX_OUTPUT) + '\n... (output truncated)';
861
919
  const result = out || '(no output, success)';
862
920
  const changed = this.recordShellMutations(before);
863
- 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);
864
925
  resolve(changed > 0 ? `${result}\n\nTracked shell mutations: ${changed} file${changed === 1 ? '' : 's'}.` : result);
865
926
  });
866
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' },
@@ -165,8 +166,17 @@ async function doctorReport(ctl, ui) {
165
166
  }
166
167
  const sock = path.join(ctl.projectRoot, '.parallel', 'session.sock');
167
168
  lines.push(fs.existsSync(sock) ? t('m.doctorAttachOk') : t('m.doctorAttachMissing'));
169
+ for (const line of ctl.securityDiagnostics()) {
170
+ if (line.startsWith('warn') && level !== 'error')
171
+ level = 'warn';
172
+ lines.push(`security ${line}`);
173
+ }
168
174
  lines.push(commandExists('git') ? t('m.doctorGitOk') : t('m.doctorGitMissing'));
169
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 ?? '—' }));
170
180
  ui.system(t('m.doctorReport', { lines: lines.join('\n') }), level);
171
181
  }
172
182
  /**
@@ -220,12 +230,21 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
220
230
  }
221
231
  // optional --model=xxx flag
222
232
  let model;
233
+ let profile;
223
234
  let task = arg;
224
235
  const mFlag = task.match(/\s--model=(\S+)/);
225
236
  if (mFlag) {
226
237
  model = mFlag[1];
227
238
  task = task.replace(mFlag[0], '').trim();
228
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
+ }
229
248
  // optional #skill tokens → force-load these skills at the start of the task
230
249
  const forced = [];
231
250
  const available = ctl.getSkills();
@@ -244,11 +263,12 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
244
263
  // optional "Name:" prefix
245
264
  const named = task.match(/^([\p{L}\p{N}_-]{1,16}):\s+(.+)$/su);
246
265
  const finalTask = named ? named[2] : task;
247
- 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);
248
267
  if (!agent)
249
268
  return ui.system(specialist ? t('m.noSpecialist', { name: specialist }) : t('m.spawnFail'), 'error');
250
269
  ui.system(t('m.spawned', { name: agent.name, model: model ? ` (${model})` : '' }) +
251
270
  ` /${mode}` +
271
+ ` [${ctl.board.agents.get(agent.id)?.profile ?? profile ?? 'auto'}]` +
252
272
  (specialist ? ` 🎓${specialist}` : '') +
253
273
  (forced.length > 0 ? ` 🧩${forced.join(',')}` : ''), 'info');
254
274
  }
@@ -490,6 +510,34 @@ export function executeInput(raw, ctl, ui, images) {
490
510
  case '/cost':
491
511
  ui.setView('cost');
492
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
+ }
493
541
  case '/status': {
494
542
  const p = ctl.sessionProvider();
495
543
  const agents = [...ctl.board.agents.values()];
@@ -497,8 +545,22 @@ export function executeInput(raw, ctl, ui, images) {
497
545
  const cost = agents.reduce((s, a) => s + (a.cost ?? 0), 0);
498
546
  const changed = new Set(ctl.board.changes.map((c) => c.path)).size;
499
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
+ }, {});
500
553
  // Multiline: each metric on its own line for readability.
501
- 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');
502
564
  return;
503
565
  }
504
566
  case '/raw':
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { chmodPrivateTree, ensurePrivateDir, writeJsonAtomicPrivate } from './security.js';
4
5
  let configHomeOverride;
5
6
  export function setConfigHome(dir) {
6
7
  configHomeOverride = path.resolve(dir.replace(/^~(?=$|\/)/, os.homedir()));
@@ -360,6 +361,8 @@ function normalizeConfig(cfg) {
360
361
  export function loadConfig() {
361
362
  let cfg = { ...DEFAULTS, providers: [] };
362
363
  try {
364
+ ensurePrivateDir(configDir());
365
+ chmodPrivateTree(configDir());
363
366
  const file = configFile();
364
367
  if (fs.existsSync(file)) {
365
368
  const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -414,8 +417,8 @@ export function loadConfig() {
414
417
  }
415
418
  export function saveConfig(cfg) {
416
419
  try {
417
- fs.mkdirSync(configDir(), { recursive: true });
418
- fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
420
+ ensurePrivateDir(configDir());
421
+ writeJsonAtomicPrivate(configFile(), cfg);
419
422
  }
420
423
  catch {
421
424
  // best effort