@parallel-cli/parallel 0.4.3 → 0.4.5
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.
- package/CHANGELOG.md +60 -2
- package/README.md +112 -99
- package/dist/agents/agent.js +2 -2
- package/dist/agents/tools.js +105 -7
- package/dist/commands.js +104 -18
- package/dist/config.js +31 -1
- package/dist/controller.js +68 -14
- package/dist/coordination/blackboard.js +75 -0
- package/dist/i18n.js +140 -32
- package/dist/index.js +3 -3
- package/dist/server.js +10 -0
- package/dist/ui/AgentPanel.js +22 -7
- package/dist/ui/App.js +86 -62
- package/dist/ui/AttachApp.js +13 -3
- package/dist/ui/CommandInput.js +60 -9
- package/dist/ui/SettingsPanel.js +86 -18
- package/dist/ui/Timeline.js +11 -9
- package/dist/ui/views.js +39 -15
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
1
4
|
import { Controller, normalizeShellApprovalMode } from './controller.js';
|
|
2
5
|
import { createSkillTemplate, createSpecialistTemplate } from './skills.js';
|
|
3
|
-
import {
|
|
6
|
+
import { isLocalProvider, isPlaceholderModel, providerNeedsApiKey } from './config.js';
|
|
4
7
|
import { t } from './i18n.js';
|
|
5
8
|
// Grouped by intent so /help reads as a story: create agents → steer them →
|
|
6
9
|
// inspect the session → git safety net → session & config → exit.
|
|
@@ -66,6 +69,72 @@ export function matchCommands(input, opts = {}) {
|
|
|
66
69
|
function agentList(ctl) {
|
|
67
70
|
return [...ctl.board.agents.values()].map((a) => a.name).join(', ') || t('m.none');
|
|
68
71
|
}
|
|
72
|
+
function commandExists(name) {
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('which', [name], { stdio: 'ignore' });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function localEndpointReachable(baseUrl) {
|
|
82
|
+
let timeout;
|
|
83
|
+
try {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
timeout = setTimeout(() => controller.abort(), 1500);
|
|
86
|
+
const resp = await fetch(baseUrl.replace(/\/+$/, '') + '/models', { signal: controller.signal });
|
|
87
|
+
return resp.ok;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (timeout)
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function doctorReport(ctl, ui) {
|
|
98
|
+
const p = ctl.sessionProvider();
|
|
99
|
+
const lines = [];
|
|
100
|
+
let level = 'ok';
|
|
101
|
+
if (!p) {
|
|
102
|
+
ui.system(t('m.doctorReport', { lines: t('m.doctorNoProvider') }), 'error');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const activeModel = ctl.session.model || p.defaultModel || p.models[0] || '';
|
|
106
|
+
lines.push(t('m.doctorProvider', { provider: p.name, model: activeModel || '—' }));
|
|
107
|
+
if (providerNeedsApiKey(p) && !p.apiKey) {
|
|
108
|
+
level = 'error';
|
|
109
|
+
lines.push(t('m.doctorKeyMissing'));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
lines.push(providerNeedsApiKey(p) ? t('m.doctorKeyOk') : t('m.doctorKeySkipped'));
|
|
113
|
+
}
|
|
114
|
+
if (isPlaceholderModel(activeModel)) {
|
|
115
|
+
level = 'error';
|
|
116
|
+
lines.push(t('m.doctorModelMissing'));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(t('m.doctorModelOk', { model: activeModel }));
|
|
120
|
+
}
|
|
121
|
+
if (isLocalProvider(p)) {
|
|
122
|
+
const reachable = await localEndpointReachable(p.baseUrl);
|
|
123
|
+
if (reachable) {
|
|
124
|
+
lines.push(t('m.doctorEndpointOk', { url: p.baseUrl }));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (level !== 'error')
|
|
128
|
+
level = 'warn';
|
|
129
|
+
lines.push(t('m.doctorEndpointFail', { url: p.baseUrl }));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const sock = path.join(ctl.projectRoot, '.parallel', 'session.sock');
|
|
133
|
+
lines.push(fs.existsSync(sock) ? t('m.doctorAttachOk') : t('m.doctorAttachMissing'));
|
|
134
|
+
lines.push(commandExists('git') ? t('m.doctorGitOk') : t('m.doctorGitMissing'));
|
|
135
|
+
lines.push(commandExists('gh') ? t('m.doctorGhOk') : t('m.doctorGhMissing'));
|
|
136
|
+
ui.system(t('m.doctorReport', { lines: lines.join('\n') }), level);
|
|
137
|
+
}
|
|
69
138
|
/**
|
|
70
139
|
* Single-agent ergonomics: when the session has exactly ONE agent, commands
|
|
71
140
|
* that target an agent (/undo, /focus, /pause…) work without naming it.
|
|
@@ -78,10 +147,12 @@ function spawnFrom(arg, ctl, ui, images, specialist, mode = 'task') {
|
|
|
78
147
|
const p = ctl.sessionProvider();
|
|
79
148
|
if (!p)
|
|
80
149
|
return ui.system(t('m.missingProvider'), 'error');
|
|
81
|
-
if (
|
|
150
|
+
if (providerNeedsApiKey(p) && !p.apiKey)
|
|
82
151
|
return ui.system(t('m.missingKey', { name: p.name }), 'error');
|
|
83
|
-
|
|
152
|
+
const activeModel = ctl.session.model || p.defaultModel || p.models[0] || '';
|
|
153
|
+
if (isPlaceholderModel(activeModel)) {
|
|
84
154
|
return ui.system(t('m.missingModel', { name: p.name }), 'error');
|
|
155
|
+
}
|
|
85
156
|
// optional --model=xxx flag
|
|
86
157
|
let model;
|
|
87
158
|
let task = arg;
|
|
@@ -129,8 +200,8 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
129
200
|
return ui.system(t('m.usageAt'), 'warn');
|
|
130
201
|
const [, target, content] = m;
|
|
131
202
|
if (target.toLowerCase() === 'all') {
|
|
132
|
-
ctl.broadcast(content);
|
|
133
|
-
ui.system(t('m.broadcast'), 'ok');
|
|
203
|
+
const n = ctl.broadcast(content);
|
|
204
|
+
ui.system(t('m.broadcast', { n }), n > 0 ? 'ok' : 'warn');
|
|
134
205
|
}
|
|
135
206
|
else if (ctl.sendToAgent(target, content)) {
|
|
136
207
|
ui.system(t('m.sent', { target }), 'ok');
|
|
@@ -217,10 +288,18 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
217
288
|
case '/commit': {
|
|
218
289
|
// Commit the files touched by one agent (or all) — staged by explicit path.
|
|
219
290
|
const [target0, ...msg] = rest;
|
|
220
|
-
const
|
|
291
|
+
const solo = soloAgent(ctl);
|
|
292
|
+
const target = target0 && (target0.toLowerCase() === 'all' || ctl.board.getAgentByName(target0))
|
|
293
|
+
? target0
|
|
294
|
+
: target0 && solo
|
|
295
|
+
? solo
|
|
296
|
+
: target0 || solo;
|
|
297
|
+
const message = target0 && target === solo && target0.toLowerCase() !== solo?.toLowerCase() && !ctl.board.getAgentByName(target0)
|
|
298
|
+
? rest.join(' ').trim()
|
|
299
|
+
: msg.join(' ').trim();
|
|
221
300
|
if (!target)
|
|
222
301
|
return ui.system(t('m.usageCommit'), 'warn');
|
|
223
|
-
const r = ctl.commitFor(target,
|
|
302
|
+
const r = ctl.commitFor(target, message || undefined);
|
|
224
303
|
if (r.ok)
|
|
225
304
|
return ui.system(t('m.committed', { name: target, files: String(r.files) }), 'ok');
|
|
226
305
|
if (r.reason === 'not-found')
|
|
@@ -249,15 +328,19 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
249
328
|
ui.setView('sessions');
|
|
250
329
|
return;
|
|
251
330
|
}
|
|
331
|
+
const force = rest.includes('--force');
|
|
332
|
+
const selector = rest.filter((part) => part !== '--force').join(' ').trim();
|
|
252
333
|
const sessions = Controller.listSessions(ctl.projectRoot);
|
|
253
334
|
if (sessions.length === 0)
|
|
254
335
|
return ui.system(t('m.usageSession'), 'warn');
|
|
255
|
-
const idx =
|
|
336
|
+
const idx = selector.toLowerCase() === 'latest' ? 0 : Number.parseInt(selector, 10) - 1;
|
|
256
337
|
const session = sessions[idx];
|
|
257
338
|
if (!session)
|
|
258
339
|
return ui.system(t('m.usageSession'), 'warn');
|
|
340
|
+
if (ctl.hasRunningAgents() && !force)
|
|
341
|
+
return ui.system(t('m.sessionActive'), 'warn');
|
|
259
342
|
ctl.loadSession(session.data);
|
|
260
|
-
ui.system(t('m.sessionLoaded', { date: new Date(session.data.savedAt).toLocaleString() }), 'ok');
|
|
343
|
+
ui.system(t('m.sessionLoaded', { date: new Date(session.data.savedAt).toLocaleString() }) + '\n' + t('m.sessionRestoreHint'), 'ok');
|
|
261
344
|
return;
|
|
262
345
|
}
|
|
263
346
|
case '/restore': {
|
|
@@ -267,6 +350,8 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
267
350
|
if (!ctl.loadedSession)
|
|
268
351
|
return ui.system(t('m.usageSession'), 'warn');
|
|
269
352
|
const res = ctl.respawnAgent(arg);
|
|
353
|
+
if (res === 'no-agent')
|
|
354
|
+
return ui.system(t('m.noRestoredAgent', { name: arg }), 'error');
|
|
270
355
|
if (res === 'no-conversation')
|
|
271
356
|
return ui.system(t('m.noConversation', { name: arg }), 'error');
|
|
272
357
|
if (!res)
|
|
@@ -315,14 +400,7 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
315
400
|
return;
|
|
316
401
|
}
|
|
317
402
|
case '/doctor': {
|
|
318
|
-
|
|
319
|
-
if (!p)
|
|
320
|
-
return ui.system(t('m.missingProvider'), 'error');
|
|
321
|
-
if (!providerReady(p))
|
|
322
|
-
return ui.system(t('m.missingKey', { name: p.name }), 'error');
|
|
323
|
-
if (!ctl.session.model && !p.defaultModel && !p.models[0])
|
|
324
|
-
return ui.system(t('m.missingModel', { name: p.name }), 'error');
|
|
325
|
-
ui.system(t('m.doctorOk', { pm: `${p.name}:${ctl.session.model || p.defaultModel || p.models[0]}` }), 'ok');
|
|
403
|
+
void doctorReport(ctl, ui);
|
|
326
404
|
return;
|
|
327
405
|
}
|
|
328
406
|
case '/cost':
|
|
@@ -476,9 +554,17 @@ export function executeInput(raw, ctl, ui, images) {
|
|
|
476
554
|
ui.setView('settings-session');
|
|
477
555
|
return;
|
|
478
556
|
case '/project':
|
|
479
|
-
|
|
557
|
+
{
|
|
558
|
+
const force = rest.includes('--force');
|
|
559
|
+
const folderArg = rest.filter((part) => part !== '--force').join(' ').trim();
|
|
560
|
+
if (ctl.hasRunningAgents() && !force)
|
|
561
|
+
return ui.system(t('m.projectActive'), 'warn');
|
|
562
|
+
ui.openProject?.(folderArg || undefined);
|
|
563
|
+
}
|
|
480
564
|
return;
|
|
481
565
|
case '/wizard':
|
|
566
|
+
if (ctl.hasRunningAgents() && arg !== '--force')
|
|
567
|
+
return ui.system(t('m.wizardActive'), 'warn');
|
|
482
568
|
ui.openWizard?.();
|
|
483
569
|
return;
|
|
484
570
|
// SESSION-only approvals & sound (global defaults editable in /settings).
|
package/dist/config.js
CHANGED
|
@@ -280,8 +280,38 @@ export function isLocalProvider(p) {
|
|
|
280
280
|
export function providerNeedsApiKey(p) {
|
|
281
281
|
return p.requiresApiKey !== false && !isLocalProvider(p);
|
|
282
282
|
}
|
|
283
|
+
export function isPlaceholderModel(model) {
|
|
284
|
+
return !model.trim() || /^your-model-here$/i.test(model.trim());
|
|
285
|
+
}
|
|
286
|
+
export function providerHasUsableModel(p) {
|
|
287
|
+
return !isPlaceholderModel(p.defaultModel || p.models[0] || '');
|
|
288
|
+
}
|
|
283
289
|
export function providerReady(p) {
|
|
284
|
-
return !providerNeedsApiKey(p) || p.apiKey.trim().length > 0;
|
|
290
|
+
return (!providerNeedsApiKey(p) || p.apiKey.trim().length > 0) && providerHasUsableModel(p);
|
|
291
|
+
}
|
|
292
|
+
export async function detectProviderModels(provider, timeoutMs = 2000) {
|
|
293
|
+
let timeout;
|
|
294
|
+
try {
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
297
|
+
const resp = await fetch(provider.baseUrl.replace(/\/+$/, '') + '/models', { signal: controller.signal });
|
|
298
|
+
if (!resp.ok)
|
|
299
|
+
return null;
|
|
300
|
+
const data = (await resp.json());
|
|
301
|
+
const models = [
|
|
302
|
+
...(data.data?.map((m) => m.id || m.name).filter(Boolean) ?? []),
|
|
303
|
+
...(data.models?.map((m) => m.name).filter(Boolean) ?? []),
|
|
304
|
+
];
|
|
305
|
+
const unique = [...new Set(models.filter((m) => !isPlaceholderModel(m)))];
|
|
306
|
+
return unique.length > 0 ? { models: unique, defaultModel: unique[0] } : null;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (timeout)
|
|
313
|
+
clearTimeout(timeout);
|
|
314
|
+
}
|
|
285
315
|
}
|
|
286
316
|
export function getProvider(cfg, name) {
|
|
287
317
|
const n = (name ?? cfg.defaultProvider).toLowerCase();
|
package/dist/controller.js
CHANGED
|
@@ -72,6 +72,7 @@ export class Controller extends EventEmitter {
|
|
|
72
72
|
conversationFiles = new Map();
|
|
73
73
|
/** The session restored at startup (source of /restore conversations). */
|
|
74
74
|
loadedSession = null;
|
|
75
|
+
sessionOnlyProvider = null;
|
|
75
76
|
constructor(config, projectRoot) {
|
|
76
77
|
super();
|
|
77
78
|
this.config = config;
|
|
@@ -108,6 +109,10 @@ export class Controller extends EventEmitter {
|
|
|
108
109
|
// ---------- providers / models ----------
|
|
109
110
|
/** Provider used by the current session (falls back to the global default). */
|
|
110
111
|
sessionProvider() {
|
|
112
|
+
if (this.sessionOnlyProvider &&
|
|
113
|
+
this.session.providerName.toLowerCase() === this.sessionOnlyProvider.name.toLowerCase()) {
|
|
114
|
+
return this.sessionOnlyProvider;
|
|
115
|
+
}
|
|
111
116
|
return getProvider(this.config, this.session.providerName || undefined);
|
|
112
117
|
}
|
|
113
118
|
/** Resolve "model" or "provider:model" against the configured providers. */
|
|
@@ -115,7 +120,10 @@ export class Controller extends EventEmitter {
|
|
|
115
120
|
const trimmed = spec.trim();
|
|
116
121
|
const sep = trimmed.indexOf(':');
|
|
117
122
|
if (sep > 0) {
|
|
118
|
-
const
|
|
123
|
+
const left = trimmed.slice(0, sep).trim();
|
|
124
|
+
const provider = this.sessionOnlyProvider && this.sessionOnlyProvider.name.toLowerCase() === left.toLowerCase()
|
|
125
|
+
? this.sessionOnlyProvider
|
|
126
|
+
: getProvider(this.config, left);
|
|
119
127
|
if (provider)
|
|
120
128
|
return { provider, model: trimmed.slice(sep + 1).trim() };
|
|
121
129
|
}
|
|
@@ -231,7 +239,7 @@ export class Controller extends EventEmitter {
|
|
|
231
239
|
return;
|
|
232
240
|
const [q] = this.questions.splice(idx, 1);
|
|
233
241
|
this.board.log('', 'system', auto ? t('m.qAuto', { name: q.agentName, answer }) : t('m.qAnswered', { name: q.agentName, answer }));
|
|
234
|
-
q.resolve(answer);
|
|
242
|
+
q.resolve({ answer, auto });
|
|
235
243
|
this.emit('update');
|
|
236
244
|
}
|
|
237
245
|
// ---------- agents ----------
|
|
@@ -380,9 +388,10 @@ export class Controller extends EventEmitter {
|
|
|
380
388
|
* memory intact instead of starting from scratch.
|
|
381
389
|
*/
|
|
382
390
|
respawnAgent(name) {
|
|
383
|
-
const
|
|
391
|
+
const ref = name.toLowerCase();
|
|
392
|
+
const sa = this.loadedSession?.agents.find((a) => a.name.toLowerCase() === ref || a.alias?.toLowerCase() === ref || a.id?.toLowerCase() === ref);
|
|
384
393
|
if (!sa)
|
|
385
|
-
return 'no-
|
|
394
|
+
return 'no-agent';
|
|
386
395
|
if (!sa.conversation || !fs.existsSync(sa.conversation))
|
|
387
396
|
return 'no-conversation';
|
|
388
397
|
let history;
|
|
@@ -398,7 +407,8 @@ export class Controller extends EventEmitter {
|
|
|
398
407
|
}
|
|
399
408
|
if (history.length === 0)
|
|
400
409
|
return 'no-conversation';
|
|
401
|
-
|
|
410
|
+
const modelSpec = sa.model ? (sa.providerName ? `${sa.providerName}:${sa.model}` : sa.model) : undefined;
|
|
411
|
+
return this.spawnAgent(sa.task, sa.name, modelSpec, undefined, sa.specialist, history, sa.mode ?? 'task');
|
|
402
412
|
}
|
|
403
413
|
pauseAgent(name) {
|
|
404
414
|
const a = this.findAgent(name);
|
|
@@ -427,7 +437,7 @@ export class Controller extends EventEmitter {
|
|
|
427
437
|
for (const req of this.approvals.splice(0))
|
|
428
438
|
req.resolve(false);
|
|
429
439
|
for (const q of this.questions.splice(0))
|
|
430
|
-
q.resolve(q.options[q.recommended] ?? '');
|
|
440
|
+
q.resolve({ answer: q.options[q.recommended] ?? '', auto: true });
|
|
431
441
|
}
|
|
432
442
|
sendToAgent(name, content) {
|
|
433
443
|
const a = this.findAgent(name);
|
|
@@ -437,7 +447,21 @@ export class Controller extends EventEmitter {
|
|
|
437
447
|
return true;
|
|
438
448
|
}
|
|
439
449
|
broadcast(content) {
|
|
440
|
-
|
|
450
|
+
const stamp = content.trim();
|
|
451
|
+
const recent = [...this.board.notes]
|
|
452
|
+
.reverse()
|
|
453
|
+
.find((n) => n.from === 'user' && n.to === 'all' && Date.now() - n.ts < 1500);
|
|
454
|
+
if (stamp && recent?.content !== stamp)
|
|
455
|
+
this.board.addNote('user', 'all', stamp);
|
|
456
|
+
let n = 0;
|
|
457
|
+
for (const [id, agent] of this.agents.entries()) {
|
|
458
|
+
const info = this.board.agents.get(id);
|
|
459
|
+
if (!info || ['done', 'error', 'stopped'].includes(info.state))
|
|
460
|
+
continue;
|
|
461
|
+
agent.instruct(content);
|
|
462
|
+
n++;
|
|
463
|
+
}
|
|
464
|
+
return n;
|
|
441
465
|
}
|
|
442
466
|
hasRunningAgents() {
|
|
443
467
|
return [...this.board.agents.values()].some((a) => ['working', 'thinking', 'listening', 'waiting', 'paused', 'idle'].includes(a.state));
|
|
@@ -464,8 +488,10 @@ export class Controller extends EventEmitter {
|
|
|
464
488
|
continue;
|
|
465
489
|
const conflict = this.board.changes.some((c2) => c2.id > c.id && c2.path === c.path && c2.agentId !== info.id);
|
|
466
490
|
try {
|
|
467
|
-
const
|
|
468
|
-
|
|
491
|
+
const root = path.resolve(this.projectRoot);
|
|
492
|
+
const abs = path.resolve(root, c.path);
|
|
493
|
+
const rel = path.relative(root, abs);
|
|
494
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
469
495
|
return 'none';
|
|
470
496
|
fs.writeFileSync(abs, c.before);
|
|
471
497
|
}
|
|
@@ -589,12 +615,20 @@ export class Controller extends EventEmitter {
|
|
|
589
615
|
try {
|
|
590
616
|
const dir = this.sessionsDir();
|
|
591
617
|
fs.mkdirSync(dir, { recursive: true });
|
|
618
|
+
const providerName = this.sessionProvider()?.name ?? this.session.providerName;
|
|
619
|
+
const trimChange = (c) => ({
|
|
620
|
+
...c,
|
|
621
|
+
before: c.before.length > 40_000 ? `${c.before.slice(0, 40_000)}\n/* truncated */` : c.before,
|
|
622
|
+
after: c.after.length > 40_000 ? `${c.after.slice(0, 40_000)}\n/* truncated */` : c.after,
|
|
623
|
+
});
|
|
592
624
|
const data = {
|
|
593
625
|
savedAt: new Date().toISOString(),
|
|
594
626
|
name: this.sessionName,
|
|
595
627
|
projectRoot: this.projectRoot,
|
|
596
628
|
agents: [...this.board.agents.values()].map((a) => ({
|
|
629
|
+
id: a.id,
|
|
597
630
|
name: a.name,
|
|
631
|
+
alias: a.alias,
|
|
598
632
|
task: a.task,
|
|
599
633
|
mode: a.mode,
|
|
600
634
|
state: a.state,
|
|
@@ -603,10 +637,17 @@ export class Controller extends EventEmitter {
|
|
|
603
637
|
tokensIn: a.tokensIn,
|
|
604
638
|
tokensOut: a.tokensOut,
|
|
605
639
|
cost: a.cost,
|
|
640
|
+
providerName,
|
|
606
641
|
model: a.model,
|
|
642
|
+
specialist: a.specialist,
|
|
643
|
+
claims: a.claims,
|
|
644
|
+
ctxPct: a.ctxPct,
|
|
607
645
|
conversation: this.conversationFiles.get(a.id),
|
|
608
646
|
})),
|
|
609
647
|
notes: this.board.notes.slice(-200),
|
|
648
|
+
changes: this.board.changes.slice(-80).map(trimChange),
|
|
649
|
+
fileActivity: [...this.board.fileActivity.values()].slice(-100),
|
|
650
|
+
workMapWarnings: this.board.workMapWarnings.slice(-80),
|
|
610
651
|
changedFiles: [...new Set(this.board.changes.map((c) => c.path))],
|
|
611
652
|
};
|
|
612
653
|
const file = path.join(dir, `session-${this.sessionStamp}.json`);
|
|
@@ -630,7 +671,7 @@ export class Controller extends EventEmitter {
|
|
|
630
671
|
return { file, data: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
631
672
|
})
|
|
632
673
|
.sort((a, b) => (a.data.savedAt < b.data.savedAt ? 1 : -1))
|
|
633
|
-
.slice(0,
|
|
674
|
+
.slice(0, 20);
|
|
634
675
|
}
|
|
635
676
|
catch {
|
|
636
677
|
return [];
|
|
@@ -642,10 +683,13 @@ export class Controller extends EventEmitter {
|
|
|
642
683
|
if (data.name)
|
|
643
684
|
this.sessionName = data.name;
|
|
644
685
|
const tasks = data.agents.map((a) => `${a.name} [${a.state}] : ${a.task}${a.lastResult ? ` → ${a.lastResult}` : ''}`);
|
|
645
|
-
this.board.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
686
|
+
this.board.hydrate({
|
|
687
|
+
notes: data.notes ?? [],
|
|
688
|
+
changes: data.changes ?? [],
|
|
689
|
+
fileActivity: data.fileActivity ?? [],
|
|
690
|
+
workMapWarnings: data.workMapWarnings ?? [],
|
|
691
|
+
});
|
|
692
|
+
this.board.addNote('system', 'all', `Previous session restored (${data.savedAt}). Past work:\n${tasks.join('\n')}\nFiles changed then: ${(data.changedFiles ?? []).join(', ') || '(none)'}`);
|
|
649
693
|
this.board.log('', 'system', t('m.sessionRestored', { date: new Date(data.savedAt).toLocaleString() }));
|
|
650
694
|
// Financial history: per-agent cost/steps/tokens of the restored session.
|
|
651
695
|
const withCost = data.agents.filter((a) => a.cost !== undefined || a.tokensIn !== undefined);
|
|
@@ -671,11 +715,19 @@ export class Controller extends EventEmitter {
|
|
|
671
715
|
const p = getProvider(this.config, name);
|
|
672
716
|
if (!p)
|
|
673
717
|
return false;
|
|
718
|
+
this.sessionOnlyProvider = null;
|
|
674
719
|
this.session.providerName = p.name;
|
|
675
720
|
this.session.model = p.defaultModel || p.models[0] || '';
|
|
676
721
|
this.emit('update');
|
|
677
722
|
return true;
|
|
678
723
|
}
|
|
724
|
+
setSessionProviderConfig(p) {
|
|
725
|
+
this.sessionOnlyProvider = p;
|
|
726
|
+
this.session.providerName = p.name;
|
|
727
|
+
this.session.model = p.defaultModel || p.models[0] || '';
|
|
728
|
+
this.llmCache.clear();
|
|
729
|
+
this.emit('update');
|
|
730
|
+
}
|
|
679
731
|
setSessionApprovalMode(mode) {
|
|
680
732
|
this.session.approvalMode = mode;
|
|
681
733
|
this.emit('update');
|
|
@@ -687,6 +739,8 @@ export class Controller extends EventEmitter {
|
|
|
687
739
|
// ---------- GLOBAL settings (/settings) — persisted ----------
|
|
688
740
|
saveProvider(p) {
|
|
689
741
|
upsertProvider(this.config, p);
|
|
742
|
+
if (this.sessionOnlyProvider?.name.toLowerCase() === p.name.toLowerCase())
|
|
743
|
+
this.sessionOnlyProvider = null;
|
|
690
744
|
this.llmCache.clear();
|
|
691
745
|
// if the session points at this provider, refresh its view
|
|
692
746
|
if (this.session.providerName.toLowerCase() === p.name.toLowerCase()) {
|
|
@@ -17,6 +17,7 @@ export class Blackboard extends EventEmitter {
|
|
|
17
17
|
notes = [];
|
|
18
18
|
changes = [];
|
|
19
19
|
logs = [];
|
|
20
|
+
workMapWarnings = [];
|
|
20
21
|
noteSeq = 0;
|
|
21
22
|
changeSeq = 0;
|
|
22
23
|
logSeq = 0;
|
|
@@ -42,6 +43,8 @@ export class Blackboard extends EventEmitter {
|
|
|
42
43
|
if (!a)
|
|
43
44
|
return;
|
|
44
45
|
Object.assign(a, patch);
|
|
46
|
+
if ('claims' in patch)
|
|
47
|
+
this.recomputeWorkMap();
|
|
45
48
|
this.touch();
|
|
46
49
|
}
|
|
47
50
|
setAgentState(id, state, action) {
|
|
@@ -142,11 +145,65 @@ export class Blackboard extends EventEmitter {
|
|
|
142
145
|
this.conflictCounts.set(relPath, n);
|
|
143
146
|
if (n === 3)
|
|
144
147
|
this.emit('agent-event', { type: 'conflict', path: relPath });
|
|
148
|
+
this.upsertWorkWarning({
|
|
149
|
+
id: `conflict:${relPath}`,
|
|
150
|
+
level: n >= 3 ? 'conflict' : 'warn',
|
|
151
|
+
title: 'Repeated edit conflict',
|
|
152
|
+
detail: `${relPath} has ${n} recorded co-edit collision${n === 1 ? '' : 's'}. Coordinate before touching it again.`,
|
|
153
|
+
paths: [relPath],
|
|
154
|
+
agentNames: [],
|
|
155
|
+
ts: Date.now(),
|
|
156
|
+
count: n,
|
|
157
|
+
});
|
|
145
158
|
return n;
|
|
146
159
|
}
|
|
147
160
|
lastChangeId() {
|
|
148
161
|
return this.changes.length > 0 ? this.changes[this.changes.length - 1].id : 0;
|
|
149
162
|
}
|
|
163
|
+
static normClaim(p) {
|
|
164
|
+
return p.trim().replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
165
|
+
}
|
|
166
|
+
static overlaps(a, b) {
|
|
167
|
+
const x = Blackboard.normClaim(a);
|
|
168
|
+
const y = Blackboard.normClaim(b);
|
|
169
|
+
if (!x || !y)
|
|
170
|
+
return false;
|
|
171
|
+
return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
|
|
172
|
+
}
|
|
173
|
+
upsertWorkWarning(warning) {
|
|
174
|
+
const idx = this.workMapWarnings.findIndex((w) => w.id === warning.id);
|
|
175
|
+
if (idx >= 0)
|
|
176
|
+
this.workMapWarnings[idx] = warning;
|
|
177
|
+
else
|
|
178
|
+
this.workMapWarnings.push(warning);
|
|
179
|
+
if (this.workMapWarnings.length > 100)
|
|
180
|
+
this.workMapWarnings.splice(0, this.workMapWarnings.length - 100);
|
|
181
|
+
}
|
|
182
|
+
recomputeWorkMap() {
|
|
183
|
+
const agents = [...this.agents.values()].filter((a) => a.claims && a.claims.length > 0);
|
|
184
|
+
const warnings = [];
|
|
185
|
+
for (let i = 0; i < agents.length; i++) {
|
|
186
|
+
for (let j = i + 1; j < agents.length; j++) {
|
|
187
|
+
const a = agents[i];
|
|
188
|
+
const b = agents[j];
|
|
189
|
+
const paths = (a.claims ?? []).filter((left) => (b.claims ?? []).some((right) => Blackboard.overlaps(left, right)));
|
|
190
|
+
if (paths.length === 0)
|
|
191
|
+
continue;
|
|
192
|
+
warnings.push({
|
|
193
|
+
id: `overlap:${[a.id, b.id].sort().join(':')}`,
|
|
194
|
+
level: 'warn',
|
|
195
|
+
title: 'Overlapping work areas',
|
|
196
|
+
detail: `${a.name} and ${b.name} both declared ${paths.join(', ')}.`,
|
|
197
|
+
paths,
|
|
198
|
+
agentNames: [a.name, b.name],
|
|
199
|
+
ts: Date.now(),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const conflictWarnings = this.workMapWarnings.filter((w) => w.id.startsWith('conflict:')).slice(-20);
|
|
204
|
+
this.workMapWarnings = [...conflictWarnings, ...warnings].slice(-100);
|
|
205
|
+
return this.workMapWarnings;
|
|
206
|
+
}
|
|
150
207
|
// ---------- logs ----------
|
|
151
208
|
log(agentId, kind, text) {
|
|
152
209
|
this.logs.push({ agentId, kind, text, ts: Date.now(), seq: ++this.logSeq });
|
|
@@ -189,6 +246,13 @@ export class Blackboard extends EventEmitter {
|
|
|
189
246
|
lines.push(` • ${act.path} — ${mine ? 'you' : act.agentName} (${act.op}, ${age}s ago)`);
|
|
190
247
|
}
|
|
191
248
|
}
|
|
249
|
+
const warnings = this.workMapWarnings.filter((w) => w.level !== 'info').slice(-5);
|
|
250
|
+
if (warnings.length > 0) {
|
|
251
|
+
lines.push('Work map warnings (advisory, do not block):');
|
|
252
|
+
for (const w of warnings) {
|
|
253
|
+
lines.push(` • ${w.title}: ${w.detail}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
192
256
|
if (me)
|
|
193
257
|
lines.push(`Reminder — your task: ${me.task}`);
|
|
194
258
|
lines.push('=== END OF REAL-TIME STATE ===');
|
|
@@ -214,6 +278,8 @@ export class Blackboard extends EventEmitter {
|
|
|
214
278
|
})),
|
|
215
279
|
fileActivity: [...this.fileActivity.values()],
|
|
216
280
|
notes: this.notes.slice(-100),
|
|
281
|
+
changes: this.changes.slice(-50),
|
|
282
|
+
workMapWarnings: this.workMapWarnings.slice(-50),
|
|
217
283
|
};
|
|
218
284
|
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2));
|
|
219
285
|
}
|
|
@@ -222,4 +288,13 @@ export class Blackboard extends EventEmitter {
|
|
|
222
288
|
}
|
|
223
289
|
}, 500);
|
|
224
290
|
}
|
|
291
|
+
hydrate(data) {
|
|
292
|
+
this.notes = [...(data.notes ?? [])].sort((a, b) => a.id - b.id);
|
|
293
|
+
this.changes = [...(data.changes ?? [])].sort((a, b) => a.id - b.id);
|
|
294
|
+
this.fileActivity = new Map((data.fileActivity ?? []).map((a) => [a.path, a]));
|
|
295
|
+
this.workMapWarnings = [...(data.workMapWarnings ?? [])].sort((a, b) => a.ts - b.ts);
|
|
296
|
+
this.noteSeq = this.notes.reduce((max, n) => Math.max(max, n.id), 0);
|
|
297
|
+
this.changeSeq = this.changes.reduce((max, c) => Math.max(max, c.id), 0);
|
|
298
|
+
this.touch();
|
|
299
|
+
}
|
|
225
300
|
}
|