tmux-team 2.0.0-alpha.1 → 2.0.0-alpha.4
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/package.json +4 -2
- package/src/cli.ts +25 -3
- package/src/commands/config.ts +186 -0
- package/src/commands/help.ts +44 -12
- package/src/commands/preamble.ts +153 -0
- package/src/commands/talk.test.ts +160 -5
- package/src/commands/talk.ts +359 -22
- package/src/config.test.ts +1 -1
- package/src/config.ts +70 -6
- package/src/pm/commands.test.ts +1061 -91
- package/src/pm/commands.ts +77 -8
- package/src/pm/manager.ts +12 -6
- package/src/pm/permissions.test.ts +332 -0
- package/src/pm/permissions.ts +279 -0
- package/src/pm/storage/fs.ts +3 -2
- package/src/pm/storage/github.ts +47 -35
- package/src/pm/types.ts +6 -0
- package/src/types.ts +11 -0
- package/src/ui.ts +13 -4
package/src/commands/talk.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// talk command - send message to agent(s)
|
|
3
3
|
// ─────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
import type { Context } from '../types.js';
|
|
5
|
+
import type { Context, PaneEntry } from '../types.js';
|
|
6
6
|
import type { WaitResult } from '../types.js';
|
|
7
7
|
import { ExitCodes } from '../exits.js';
|
|
8
8
|
import { colors } from '../ui.js';
|
|
9
9
|
import crypto from 'crypto';
|
|
10
10
|
import { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
|
|
11
|
+
import { resolveActor } from '../pm/permissions.js';
|
|
11
12
|
|
|
12
13
|
function sleepMs(ms: number): Promise<void> {
|
|
13
14
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -26,6 +27,38 @@ function renderWaitLine(agent: string, elapsedSeconds: number): string {
|
|
|
26
27
|
return `⏳ Waiting for ${agent}... (${s}s)`;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// ─────────────────────────────────────────────────────────────
|
|
31
|
+
// Types for broadcast wait mode
|
|
32
|
+
// ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface AgentWaitState {
|
|
35
|
+
agent: string;
|
|
36
|
+
pane: string;
|
|
37
|
+
requestId: string;
|
|
38
|
+
nonce: string;
|
|
39
|
+
marker: string;
|
|
40
|
+
baseline: string;
|
|
41
|
+
status: 'pending' | 'completed' | 'timeout' | 'error';
|
|
42
|
+
response?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
elapsedMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BroadcastWaitResult {
|
|
48
|
+
target: 'all';
|
|
49
|
+
mode: 'wait';
|
|
50
|
+
self?: string;
|
|
51
|
+
identityWarning?: string;
|
|
52
|
+
summary: {
|
|
53
|
+
total: number;
|
|
54
|
+
completed: number;
|
|
55
|
+
timeout: number;
|
|
56
|
+
error: number;
|
|
57
|
+
skipped: number;
|
|
58
|
+
};
|
|
59
|
+
results: AgentWaitState[];
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
/**
|
|
30
63
|
* Build the final message with optional preamble.
|
|
31
64
|
* Format: [SYSTEM: <preamble>]\n\n<message>
|
|
@@ -53,11 +86,6 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
53
86
|
const { ui, config, tmux, flags, exit } = ctx;
|
|
54
87
|
const waitEnabled = Boolean(flags.wait) || config.mode === 'wait';
|
|
55
88
|
|
|
56
|
-
if (waitEnabled && target === 'all') {
|
|
57
|
-
ui.error("Wait mode is not supported with 'all' yet. Send to one agent at a time.");
|
|
58
|
-
exit(ExitCodes.ERROR);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
89
|
if (target === 'all') {
|
|
62
90
|
const agents = Object.entries(config.paneRegistry);
|
|
63
91
|
if (agents.length === 0) {
|
|
@@ -65,33 +93,59 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
65
93
|
exit(ExitCodes.CONFIG_MISSING);
|
|
66
94
|
}
|
|
67
95
|
|
|
96
|
+
// Determine current agent to skip self
|
|
97
|
+
const { actor: self, warning: identityWarning } = resolveActor(config.paneRegistry);
|
|
98
|
+
|
|
99
|
+
// Surface identity warnings (mismatch, unregistered pane, etc.)
|
|
100
|
+
if (identityWarning && !flags.json) {
|
|
101
|
+
ui.warn(identityWarning);
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
if (flags.delay && flags.delay > 0) {
|
|
69
105
|
await sleepMs(flags.delay * 1000);
|
|
70
106
|
}
|
|
71
107
|
|
|
72
|
-
|
|
108
|
+
// Filter out self
|
|
109
|
+
const targetAgents = agents.filter(([name]) => name !== self);
|
|
110
|
+
const skippedSelf = agents.length !== targetAgents.length;
|
|
73
111
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
results.push({ agent:
|
|
112
|
+
if (!waitEnabled) {
|
|
113
|
+
// Non-wait mode: fire and forget
|
|
114
|
+
const results: { agent: string; pane: string; status: string }[] = [];
|
|
115
|
+
|
|
116
|
+
if (skippedSelf) {
|
|
117
|
+
const selfData = config.paneRegistry[self];
|
|
118
|
+
results.push({ agent: self, pane: selfData?.pane || '', status: 'skipped (self)' });
|
|
81
119
|
if (!flags.json) {
|
|
82
|
-
console.log(`${colors.
|
|
120
|
+
console.log(`${colors.dim('○')} Skipped ${colors.cyan(self)} (self)`);
|
|
83
121
|
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [name, data] of targetAgents) {
|
|
125
|
+
try {
|
|
126
|
+
let msg = buildMessage(message, name, ctx);
|
|
127
|
+
if (name === 'gemini') msg = msg.replace(/!/g, '');
|
|
128
|
+
tmux.send(data.pane, msg);
|
|
129
|
+
results.push({ agent: name, pane: data.pane, status: 'sent' });
|
|
130
|
+
if (!flags.json) {
|
|
131
|
+
console.log(`${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
results.push({ agent: name, pane: data.pane, status: 'failed' });
|
|
135
|
+
if (!flags.json) {
|
|
136
|
+
ui.warn(`Failed to send to ${name}`);
|
|
137
|
+
}
|
|
88
138
|
}
|
|
89
139
|
}
|
|
90
|
-
}
|
|
91
140
|
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
if (flags.json) {
|
|
142
|
+
ui.json({ target: 'all', self, identityWarning, results });
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
94
145
|
}
|
|
146
|
+
|
|
147
|
+
// Wait mode: parallel polling
|
|
148
|
+
await cmdTalkAllWait(ctx, targetAgents, message, self, identityWarning, skippedSelf);
|
|
95
149
|
return;
|
|
96
150
|
}
|
|
97
151
|
|
|
@@ -259,3 +313,286 @@ export async function cmdTalk(ctx: Context, target: string, message: string): Pr
|
|
|
259
313
|
clearActiveRequest(ctx.paths, target, requestId);
|
|
260
314
|
}
|
|
261
315
|
}
|
|
316
|
+
|
|
317
|
+
// ─────────────────────────────────────────────────────────────
|
|
318
|
+
// Broadcast wait mode: parallel polling for all agents
|
|
319
|
+
// ─────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
async function cmdTalkAllWait(
|
|
322
|
+
ctx: Context,
|
|
323
|
+
targetAgents: [string, PaneEntry][],
|
|
324
|
+
message: string,
|
|
325
|
+
self: string,
|
|
326
|
+
identityWarning: string | undefined,
|
|
327
|
+
skippedSelf: boolean
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
const { ui, config, tmux, flags, exit, paths } = ctx;
|
|
330
|
+
const timeoutSeconds = flags.timeout ?? config.defaults.timeout;
|
|
331
|
+
const pollIntervalSeconds = Math.max(0.1, config.defaults.pollInterval);
|
|
332
|
+
const captureLines = config.defaults.captureLines;
|
|
333
|
+
|
|
334
|
+
// Best-effort state cleanup
|
|
335
|
+
cleanupState(paths, 60 * 60);
|
|
336
|
+
|
|
337
|
+
// Initialize wait state for each agent with unique nonces
|
|
338
|
+
const agentStates: AgentWaitState[] = [];
|
|
339
|
+
|
|
340
|
+
if (!flags.json) {
|
|
341
|
+
console.log(
|
|
342
|
+
`${colors.cyan('→')} Broadcasting to ${targetAgents.length} agent(s) (wait mode)...`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Phase 1: Send messages to all agents and capture baselines
|
|
347
|
+
for (const [name, data] of targetAgents) {
|
|
348
|
+
const requestId = makeRequestId();
|
|
349
|
+
const nonce = makeNonce(); // Unique nonce per agent (#19)
|
|
350
|
+
const marker = `{tmux-team-end:${nonce}}`;
|
|
351
|
+
|
|
352
|
+
let baseline = '';
|
|
353
|
+
try {
|
|
354
|
+
baseline = tmux.capture(data.pane, captureLines);
|
|
355
|
+
} catch {
|
|
356
|
+
agentStates.push({
|
|
357
|
+
agent: name,
|
|
358
|
+
pane: data.pane,
|
|
359
|
+
requestId,
|
|
360
|
+
nonce,
|
|
361
|
+
marker,
|
|
362
|
+
baseline: '',
|
|
363
|
+
status: 'error',
|
|
364
|
+
error: `Failed to capture pane ${data.pane}`,
|
|
365
|
+
});
|
|
366
|
+
if (!flags.json) {
|
|
367
|
+
ui.warn(`Failed to capture ${name} (${data.pane})`);
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build and send message
|
|
373
|
+
const messageWithPreamble = buildMessage(message, name, ctx);
|
|
374
|
+
const fullMessage = `${messageWithPreamble}\n\n[IMPORTANT: When your response is complete, print exactly: ${marker}]`;
|
|
375
|
+
const msg = name === 'gemini' ? fullMessage.replace(/!/g, '') : fullMessage;
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
tmux.send(data.pane, msg);
|
|
379
|
+
setActiveRequest(paths, name, {
|
|
380
|
+
id: requestId,
|
|
381
|
+
nonce,
|
|
382
|
+
pane: data.pane,
|
|
383
|
+
startedAtMs: Date.now(),
|
|
384
|
+
});
|
|
385
|
+
agentStates.push({
|
|
386
|
+
agent: name,
|
|
387
|
+
pane: data.pane,
|
|
388
|
+
requestId,
|
|
389
|
+
nonce,
|
|
390
|
+
marker,
|
|
391
|
+
baseline,
|
|
392
|
+
status: 'pending',
|
|
393
|
+
});
|
|
394
|
+
if (!flags.json) {
|
|
395
|
+
console.log(` ${colors.green('→')} Sent to ${colors.cyan(name)} (${data.pane})`);
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
agentStates.push({
|
|
399
|
+
agent: name,
|
|
400
|
+
pane: data.pane,
|
|
401
|
+
requestId,
|
|
402
|
+
nonce,
|
|
403
|
+
marker,
|
|
404
|
+
baseline,
|
|
405
|
+
status: 'error',
|
|
406
|
+
error: `Failed to send to pane ${data.pane}`,
|
|
407
|
+
});
|
|
408
|
+
if (!flags.json) {
|
|
409
|
+
ui.warn(`Failed to send to ${name}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Track pending agents
|
|
415
|
+
const pendingAgents = () => agentStates.filter((s) => s.status === 'pending');
|
|
416
|
+
|
|
417
|
+
if (pendingAgents().length === 0) {
|
|
418
|
+
// All failed to send, output results and exit with error
|
|
419
|
+
outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
|
|
420
|
+
exit(ExitCodes.ERROR);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const startedAt = Date.now();
|
|
425
|
+
let lastLogAt = 0;
|
|
426
|
+
const isTTY = process.stdout.isTTY && !flags.json;
|
|
427
|
+
|
|
428
|
+
// SIGINT handler: cleanup ALL active requests (#18)
|
|
429
|
+
const onSigint = (): void => {
|
|
430
|
+
for (const state of agentStates) {
|
|
431
|
+
clearActiveRequest(paths, state.agent, state.requestId);
|
|
432
|
+
}
|
|
433
|
+
if (!flags.json) {
|
|
434
|
+
process.stdout.write('\n');
|
|
435
|
+
ui.error('Interrupted.');
|
|
436
|
+
}
|
|
437
|
+
// Output partial results
|
|
438
|
+
outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
|
|
439
|
+
exit(ExitCodes.ERROR);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
process.once('SIGINT', onSigint);
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
// Phase 2: Poll all agents in parallel until all complete or timeout
|
|
446
|
+
while (pendingAgents().length > 0) {
|
|
447
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
448
|
+
|
|
449
|
+
// Check timeout for each pending agent (#17)
|
|
450
|
+
for (const state of pendingAgents()) {
|
|
451
|
+
if (elapsedSeconds >= timeoutSeconds) {
|
|
452
|
+
state.status = 'timeout';
|
|
453
|
+
state.error = `Timed out after ${Math.floor(timeoutSeconds)}s`;
|
|
454
|
+
state.elapsedMs = Math.floor(elapsedSeconds * 1000);
|
|
455
|
+
clearActiveRequest(paths, state.agent, state.requestId);
|
|
456
|
+
if (!flags.json) {
|
|
457
|
+
console.log(
|
|
458
|
+
` ${colors.red('✗')} ${colors.cyan(state.agent)} timed out (${Math.floor(elapsedSeconds)}s)`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// All done?
|
|
465
|
+
if (pendingAgents().length === 0) break;
|
|
466
|
+
|
|
467
|
+
// Progress logging (non-TTY)
|
|
468
|
+
if (!flags.json && !isTTY) {
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
if (now - lastLogAt >= 5000) {
|
|
471
|
+
lastLogAt = now;
|
|
472
|
+
const pending = pendingAgents()
|
|
473
|
+
.map((s) => s.agent)
|
|
474
|
+
.join(', ');
|
|
475
|
+
console.error(
|
|
476
|
+
`[tmux-team] Waiting for: ${pending} (${Math.floor(elapsedSeconds)}s elapsed)`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
await sleepMs(pollIntervalSeconds * 1000);
|
|
482
|
+
|
|
483
|
+
// Poll each pending agent
|
|
484
|
+
for (const state of pendingAgents()) {
|
|
485
|
+
let output = '';
|
|
486
|
+
try {
|
|
487
|
+
output = tmux.capture(state.pane, captureLines);
|
|
488
|
+
} catch {
|
|
489
|
+
state.status = 'error';
|
|
490
|
+
state.error = `Failed to capture pane ${state.pane}`;
|
|
491
|
+
state.elapsedMs = Date.now() - startedAt;
|
|
492
|
+
clearActiveRequest(paths, state.agent, state.requestId);
|
|
493
|
+
if (!flags.json) {
|
|
494
|
+
ui.warn(`Failed to capture ${state.agent}`);
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const markerIndex = output.indexOf(state.marker);
|
|
500
|
+
if (markerIndex === -1) continue;
|
|
501
|
+
|
|
502
|
+
// Found marker - extract response
|
|
503
|
+
let startIndex = 0;
|
|
504
|
+
const baselineIndex = state.baseline ? output.lastIndexOf(state.baseline) : -1;
|
|
505
|
+
if (baselineIndex !== -1) {
|
|
506
|
+
startIndex = baselineIndex + state.baseline.length;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
state.response = output.slice(startIndex, markerIndex).trim();
|
|
510
|
+
state.status = 'completed';
|
|
511
|
+
state.elapsedMs = Date.now() - startedAt;
|
|
512
|
+
clearActiveRequest(paths, state.agent, state.requestId);
|
|
513
|
+
|
|
514
|
+
if (!flags.json) {
|
|
515
|
+
console.log(
|
|
516
|
+
` ${colors.green('✓')} ${colors.cyan(state.agent)} completed (${Math.floor(state.elapsedMs / 1000)}s)`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
} finally {
|
|
522
|
+
process.removeListener('SIGINT', onSigint);
|
|
523
|
+
// Cleanup any remaining active requests
|
|
524
|
+
for (const state of agentStates) {
|
|
525
|
+
clearActiveRequest(paths, state.agent, state.requestId);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Output results
|
|
530
|
+
outputBroadcastResults(ctx, agentStates, self, identityWarning, skippedSelf);
|
|
531
|
+
|
|
532
|
+
// Exit with appropriate code
|
|
533
|
+
const hasTimeout = agentStates.some((s) => s.status === 'timeout');
|
|
534
|
+
const hasError = agentStates.some((s) => s.status === 'error');
|
|
535
|
+
if (hasTimeout) {
|
|
536
|
+
exit(ExitCodes.TIMEOUT);
|
|
537
|
+
} else if (hasError) {
|
|
538
|
+
exit(ExitCodes.ERROR);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function outputBroadcastResults(
|
|
543
|
+
ctx: Context,
|
|
544
|
+
agentStates: AgentWaitState[],
|
|
545
|
+
self: string,
|
|
546
|
+
identityWarning: string | undefined,
|
|
547
|
+
skippedSelf: boolean
|
|
548
|
+
): void {
|
|
549
|
+
const { ui, flags } = ctx;
|
|
550
|
+
|
|
551
|
+
const summary = {
|
|
552
|
+
total: agentStates.length + (skippedSelf ? 1 : 0),
|
|
553
|
+
completed: agentStates.filter((s) => s.status === 'completed').length,
|
|
554
|
+
timeout: agentStates.filter((s) => s.status === 'timeout').length,
|
|
555
|
+
error: agentStates.filter((s) => s.status === 'error').length,
|
|
556
|
+
skipped: skippedSelf ? 1 : 0,
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
if (flags.json) {
|
|
560
|
+
const result: BroadcastWaitResult = {
|
|
561
|
+
target: 'all',
|
|
562
|
+
mode: 'wait',
|
|
563
|
+
self,
|
|
564
|
+
identityWarning,
|
|
565
|
+
summary,
|
|
566
|
+
results: agentStates.map((s) => ({
|
|
567
|
+
agent: s.agent,
|
|
568
|
+
pane: s.pane,
|
|
569
|
+
requestId: s.requestId,
|
|
570
|
+
nonce: s.nonce,
|
|
571
|
+
marker: s.marker,
|
|
572
|
+
baseline: '', // Don't include baseline in output
|
|
573
|
+
status: s.status,
|
|
574
|
+
response: s.response,
|
|
575
|
+
error: s.error,
|
|
576
|
+
elapsedMs: s.elapsedMs,
|
|
577
|
+
})),
|
|
578
|
+
};
|
|
579
|
+
ui.json(result);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Human-readable output
|
|
584
|
+
console.log();
|
|
585
|
+
console.log(
|
|
586
|
+
`${colors.cyan('Summary:')} ${summary.completed} completed, ${summary.timeout} timeout, ${summary.error} error, ${summary.skipped} skipped`
|
|
587
|
+
);
|
|
588
|
+
console.log();
|
|
589
|
+
|
|
590
|
+
// Print responses
|
|
591
|
+
for (const state of agentStates) {
|
|
592
|
+
if (state.status === 'completed' && state.response) {
|
|
593
|
+
console.log(colors.cyan(`─── Response from ${state.agent} (${state.pane}) ───`));
|
|
594
|
+
console.log(state.response);
|
|
595
|
+
console.log();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -158,7 +158,7 @@ describe('loadConfig', () => {
|
|
|
158
158
|
|
|
159
159
|
expect(config.mode).toBe('polling');
|
|
160
160
|
expect(config.preambleMode).toBe('always');
|
|
161
|
-
expect(config.defaults.timeout).toBe(
|
|
161
|
+
expect(config.defaults.timeout).toBe(180);
|
|
162
162
|
expect(config.defaults.pollInterval).toBe(1);
|
|
163
163
|
expect(config.defaults.captureLines).toBe(100);
|
|
164
164
|
expect(config.agents).toEqual({});
|
package/src/config.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import os from 'os';
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
GlobalConfig,
|
|
10
|
+
LocalConfig,
|
|
11
|
+
LocalConfigFile,
|
|
12
|
+
LocalSettings,
|
|
13
|
+
ResolvedConfig,
|
|
14
|
+
Paths,
|
|
15
|
+
} from './types.js';
|
|
9
16
|
|
|
10
17
|
const CONFIG_FILENAME = 'config.json';
|
|
11
18
|
const LOCAL_CONFIG_FILENAME = 'tmux-team.json';
|
|
@@ -16,7 +23,7 @@ const DEFAULT_CONFIG: Omit<GlobalConfig, 'agents'> & { agents: Record<string, ne
|
|
|
16
23
|
mode: 'polling',
|
|
17
24
|
preambleMode: 'always',
|
|
18
25
|
defaults: {
|
|
19
|
-
timeout:
|
|
26
|
+
timeout: 180,
|
|
20
27
|
pollInterval: 1,
|
|
21
28
|
captureLines: 100,
|
|
22
29
|
},
|
|
@@ -136,10 +143,20 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
// Load local config (pane registry)
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
146
|
+
// Load local config (pane registry + optional settings)
|
|
147
|
+
const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
|
|
148
|
+
if (localConfigFile) {
|
|
149
|
+
// Extract local settings if present
|
|
150
|
+
const { $config: localSettings, ...paneEntries } = localConfigFile;
|
|
151
|
+
|
|
152
|
+
// Merge local settings (override global)
|
|
153
|
+
if (localSettings) {
|
|
154
|
+
if (localSettings.mode) config.mode = localSettings.mode;
|
|
155
|
+
if (localSettings.preambleMode) config.preambleMode = localSettings.preambleMode;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Set pane registry (filter out $config)
|
|
159
|
+
config.paneRegistry = paneEntries as LocalConfig;
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
return config;
|
|
@@ -157,3 +174,50 @@ export function ensureGlobalDir(paths: Paths): void {
|
|
|
157
174
|
fs.mkdirSync(paths.globalDir, { recursive: true });
|
|
158
175
|
}
|
|
159
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Load raw global config file (for editing).
|
|
180
|
+
*/
|
|
181
|
+
export function loadGlobalConfig(paths: Paths): Partial<GlobalConfig> {
|
|
182
|
+
return loadJsonFile<Partial<GlobalConfig>>(paths.globalConfig) ?? {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Save global config file.
|
|
187
|
+
*/
|
|
188
|
+
export function saveGlobalConfig(paths: Paths, config: Partial<GlobalConfig>): void {
|
|
189
|
+
ensureGlobalDir(paths);
|
|
190
|
+
fs.writeFileSync(paths.globalConfig, JSON.stringify(config, null, 2) + '\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Load raw local config file (for editing).
|
|
195
|
+
*/
|
|
196
|
+
export function loadLocalConfigFile(paths: Paths): LocalConfigFile {
|
|
197
|
+
return loadJsonFile<LocalConfigFile>(paths.localConfig) ?? {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Save local config file (preserves both $config and pane entries).
|
|
202
|
+
*/
|
|
203
|
+
export function saveLocalConfigFile(paths: Paths, configFile: LocalConfigFile): void {
|
|
204
|
+
fs.writeFileSync(paths.localConfig, JSON.stringify(configFile, null, 2) + '\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Update local settings (creates $config if needed).
|
|
209
|
+
*/
|
|
210
|
+
export function updateLocalSettings(paths: Paths, settings: LocalSettings): void {
|
|
211
|
+
const configFile = loadLocalConfigFile(paths);
|
|
212
|
+
configFile.$config = { ...configFile.$config, ...settings };
|
|
213
|
+
saveLocalConfigFile(paths, configFile);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Clear local settings.
|
|
218
|
+
*/
|
|
219
|
+
export function clearLocalSettings(paths: Paths): void {
|
|
220
|
+
const configFile = loadLocalConfigFile(paths);
|
|
221
|
+
delete configFile.$config;
|
|
222
|
+
saveLocalConfigFile(paths, configFile);
|
|
223
|
+
}
|