@kinqs/brainrouter-cli 0.3.7 → 0.3.8
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/0.2.0.md +15 -0
- package/changelog/0.3.0.md +20 -0
- package/changelog/0.3.1.md +22 -0
- package/changelog/0.3.2.md +15 -0
- package/changelog/0.3.3.md +19 -0
- package/changelog/0.3.4.md +20 -0
- package/changelog/0.3.5.md +9 -0
- package/changelog/0.3.6.md +9 -0
- package/changelog/0.3.7.md +20 -0
- package/changelog/0.3.8.md +30 -0
- package/changelog/README.md +41 -0
- package/dist/agent/agent.d.ts +22 -0
- package/dist/agent/agent.js +259 -82
- package/dist/agent/toolCallRecovery.d.ts +57 -0
- package/dist/agent/toolCallRecovery.js +130 -0
- package/dist/agent/toolSafety.d.ts +17 -0
- package/dist/agent/toolSafety.js +102 -0
- package/dist/cli/banner.js +2 -2
- package/dist/cli/cliPrompt.js +65 -0
- package/dist/cli/commands/config.js +1 -1
- package/dist/cli/commands/mcp.d.ts +1 -1
- package/dist/cli/commands/mcp.js +29 -7
- package/dist/cli/commands/mcpInstall.d.ts +20 -0
- package/dist/cli/commands/mcpInstall.js +87 -0
- package/dist/cli/commands/orchestration.js +33 -0
- package/dist/cli/commands/releaseNotes.d.ts +24 -0
- package/dist/cli/commands/releaseNotes.js +109 -0
- package/dist/cli/commands/schedule.d.ts +18 -0
- package/dist/cli/commands/schedule.js +189 -0
- package/dist/cli/commands/ui.js +2 -2
- package/dist/cli/ink/Picker.d.ts +6 -0
- package/dist/cli/ink/Picker.js +41 -6
- package/dist/cli/ink/runChat.js +112 -1
- package/dist/cli/ink/toolFormat.d.ts +11 -9
- package/dist/cli/ink/toolFormat.js +42 -16
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +9 -2
- package/dist/config/config.d.ts +1 -1
- package/dist/index.js +10 -1
- package/dist/memory/briefing.js +4 -4
- package/dist/orchestration/tools.d.ts +95 -2
- package/dist/orchestration/tools.js +119 -4
- package/dist/prompt/systemPrompt.js +5 -4
- package/dist/runtime/anthropicAdapter.d.ts +100 -0
- package/dist/runtime/anthropicAdapter.js +293 -0
- package/dist/runtime/cronParser.d.ts +23 -0
- package/dist/runtime/cronParser.js +122 -0
- package/dist/runtime/mcpClient.js +1 -1
- package/dist/runtime/mcpPool.d.ts +8 -0
- package/dist/runtime/mcpPool.js +19 -0
- package/dist/runtime/mcpUtils.d.ts +14 -0
- package/dist/runtime/mcpUtils.js +23 -0
- package/dist/runtime/scheduleTicker.d.ts +33 -0
- package/dist/runtime/scheduleTicker.js +99 -0
- package/dist/runtime/vendorSnippets.d.ts +45 -0
- package/dist/runtime/vendorSnippets.js +153 -0
- package/dist/state/scheduleStore.d.ts +37 -0
- package/dist/state/scheduleStore.js +64 -0
- package/package.json +7 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/release-notes` slash command — show the changelog for the running CLI version.
|
|
3
|
+
*
|
|
4
|
+
* /release-notes → current version's notes
|
|
5
|
+
* /release-notes <version> → specific version
|
|
6
|
+
* /release-notes list → every shipped version, sorted descending
|
|
7
|
+
*
|
|
8
|
+
* Changelog files ship inside the published package at `changelog/<version>.md`.
|
|
9
|
+
* The repo-root `brainrouter-changelog/` is copied into `brainrouter-cli/changelog/`
|
|
10
|
+
* by `prepublishOnly` so users who install via npm see them.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
const MAX_LINES = 200;
|
|
17
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
18
|
+
export async function tryHandleReleaseNotesCommand(ctx, deps = {}) {
|
|
19
|
+
if (ctx.command !== '/release-notes')
|
|
20
|
+
return false;
|
|
21
|
+
const out = runReleaseNotes(ctx.args, deps);
|
|
22
|
+
console.log(out);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pure handler — returns the rendered string. Split from `tryHandle*` so unit
|
|
27
|
+
* tests can assert on the output without capturing stdout.
|
|
28
|
+
*/
|
|
29
|
+
export function runReleaseNotes(args, deps = {}) {
|
|
30
|
+
const dir = deps.changelogDir ?? defaultChangelogDir();
|
|
31
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
32
|
+
if (sub === 'list')
|
|
33
|
+
return renderList(dir);
|
|
34
|
+
let version;
|
|
35
|
+
if (sub) {
|
|
36
|
+
if (!SEMVER_RE.test(sub)) {
|
|
37
|
+
return chalk.red(`Not a valid semver: "${args[0]}". Try /release-notes list.`);
|
|
38
|
+
}
|
|
39
|
+
version = sub;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const v = deps.currentVersion ?? readCurrentVersion();
|
|
43
|
+
if (!v)
|
|
44
|
+
return chalk.red('Could not determine current CLI version.');
|
|
45
|
+
version = v;
|
|
46
|
+
}
|
|
47
|
+
const filePath = path.join(dir, `${version}.md`);
|
|
48
|
+
let body;
|
|
49
|
+
try {
|
|
50
|
+
body = fs.readFileSync(filePath, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return chalk.yellow(`no notes shipped for ${version}`);
|
|
54
|
+
}
|
|
55
|
+
return truncate(body, version);
|
|
56
|
+
}
|
|
57
|
+
function renderList(dir) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(dir);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return chalk.yellow('No bundled changelog directory found.');
|
|
64
|
+
}
|
|
65
|
+
const versions = entries
|
|
66
|
+
.filter((f) => f.endsWith('.md'))
|
|
67
|
+
.map((f) => f.slice(0, -3))
|
|
68
|
+
.filter((v) => SEMVER_RE.test(v))
|
|
69
|
+
.sort(compareSemverDesc);
|
|
70
|
+
if (versions.length === 0)
|
|
71
|
+
return chalk.yellow('No changelog versions bundled.');
|
|
72
|
+
return versions.join('\n');
|
|
73
|
+
}
|
|
74
|
+
function truncate(body, version) {
|
|
75
|
+
const lines = body.split('\n');
|
|
76
|
+
if (lines.length <= MAX_LINES)
|
|
77
|
+
return body;
|
|
78
|
+
const head = lines.slice(0, MAX_LINES).join('\n');
|
|
79
|
+
return `${head}\n\n…truncated at ${MAX_LINES} lines. Run \`/release-notes ${version}\` on its own to scroll the full file in a fresh paginator.`;
|
|
80
|
+
}
|
|
81
|
+
function compareSemverDesc(a, b) {
|
|
82
|
+
const pa = a.split(/[-+]/)[0].split('.').map(Number);
|
|
83
|
+
const pb = b.split(/[-+]/)[0].split('.').map(Number);
|
|
84
|
+
for (let i = 0; i < 3; i++) {
|
|
85
|
+
if (pa[i] !== pb[i])
|
|
86
|
+
return pb[i] - pa[i];
|
|
87
|
+
}
|
|
88
|
+
// Identical core → keep pre-release sort stable by string compare (descending).
|
|
89
|
+
return b.localeCompare(a);
|
|
90
|
+
}
|
|
91
|
+
// --- Package-root resolution -------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* `brainrouter-cli/changelog/` — relative to this compiled file. The dist
|
|
94
|
+
* layout mirrors src, so both `src/cli/commands/releaseNotes.ts` (dev/tsx)
|
|
95
|
+
* and `dist/cli/commands/releaseNotes.js` (built) resolve to the same root.
|
|
96
|
+
*/
|
|
97
|
+
function defaultChangelogDir() {
|
|
98
|
+
return fileURLToPath(new URL('../../../changelog', import.meta.url));
|
|
99
|
+
}
|
|
100
|
+
function readCurrentVersion() {
|
|
101
|
+
try {
|
|
102
|
+
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
|
103
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
104
|
+
return pkg.version;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/schedule` slash command — recurring cron + one-shot dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Recurring : /schedule cron "*\/15 * * * *" /ci-status
|
|
5
|
+
* One-shot : /schedule in 30s /agents
|
|
6
|
+
* /schedule at 14:30 /agents
|
|
7
|
+
* Management : /schedule list
|
|
8
|
+
* /schedule remove <id>
|
|
9
|
+
* /schedule disable <id>
|
|
10
|
+
* /schedule enable <id>
|
|
11
|
+
*
|
|
12
|
+
* The dispatched command runs in the SAME session that registered the
|
|
13
|
+
* schedule (we use `agent.sessionKey` as the owner). The ticker filters
|
|
14
|
+
* by owner — if a different REPL is open against the same workspace,
|
|
15
|
+
* it won't fire someone else's jobs.
|
|
16
|
+
*/
|
|
17
|
+
import type { CommandContext } from './_context.js';
|
|
18
|
+
export declare function tryHandleScheduleCommand(ctx: CommandContext): Promise<boolean>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/schedule` slash command — recurring cron + one-shot dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Recurring : /schedule cron "*\/15 * * * *" /ci-status
|
|
5
|
+
* One-shot : /schedule in 30s /agents
|
|
6
|
+
* /schedule at 14:30 /agents
|
|
7
|
+
* Management : /schedule list
|
|
8
|
+
* /schedule remove <id>
|
|
9
|
+
* /schedule disable <id>
|
|
10
|
+
* /schedule enable <id>
|
|
11
|
+
*
|
|
12
|
+
* The dispatched command runs in the SAME session that registered the
|
|
13
|
+
* schedule (we use `agent.sessionKey` as the owner). The ticker filters
|
|
14
|
+
* by owner — if a different REPL is open against the same workspace,
|
|
15
|
+
* it won't fire someone else's jobs.
|
|
16
|
+
*/
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { parseInterval } from '../../runtime/loopRunner.js';
|
|
19
|
+
import { parseCron, nextCronFire } from '../../runtime/cronParser.js';
|
|
20
|
+
import { addSchedule, loadSchedules, removeSchedule, setScheduleEnabled, } from '../../state/scheduleStore.js';
|
|
21
|
+
export async function tryHandleScheduleCommand(ctx) {
|
|
22
|
+
if (ctx.command !== '/schedule')
|
|
23
|
+
return false;
|
|
24
|
+
const { args, agent } = ctx;
|
|
25
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
26
|
+
if (!sub || sub === 'list') {
|
|
27
|
+
renderList(agent.workspaceRoot, agent.sessionKey);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
31
|
+
const id = args[1];
|
|
32
|
+
if (!id) {
|
|
33
|
+
console.log(chalk.red('\nUsage: /schedule remove <id>\n'));
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const ok = removeSchedule(agent.workspaceRoot, id);
|
|
37
|
+
console.log(ok
|
|
38
|
+
? chalk.green(`\n✓ Removed ${id}.\n`)
|
|
39
|
+
: chalk.yellow(`\nNo schedule with id ${id}.\n`));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (sub === 'disable' || sub === 'enable') {
|
|
43
|
+
const id = args[1];
|
|
44
|
+
if (!id) {
|
|
45
|
+
console.log(chalk.red(`\nUsage: /schedule ${sub} <id>\n`));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const ok = setScheduleEnabled(agent.workspaceRoot, id, sub === 'enable');
|
|
49
|
+
console.log(ok
|
|
50
|
+
? chalk.green(`\n✓ ${sub === 'enable' ? 'Enabled' : 'Disabled'} ${id}.\n`)
|
|
51
|
+
: chalk.yellow(`\nNo schedule with id ${id}.\n`));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (sub === 'cron') {
|
|
55
|
+
// Need to re-join because the splitter cracked the quoted cron expr
|
|
56
|
+
// across tokens. Re-join args after the leading "cron".
|
|
57
|
+
const rest = args.slice(1).join(' ').trim();
|
|
58
|
+
const m = /^"([^"]+)"\s+(\/\S.*)$/.exec(rest);
|
|
59
|
+
if (!m) {
|
|
60
|
+
console.log(chalk.red('\nUsage: /schedule cron "<expr>" /command'));
|
|
61
|
+
console.log(chalk.gray(' e.g. /schedule cron "*/15 * * * *" /ci-status\n'));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const expr = m[1];
|
|
65
|
+
const command = m[2].trim();
|
|
66
|
+
if (!command.startsWith('/')) {
|
|
67
|
+
console.log(chalk.red('\nSchedule only dispatches slash commands (must start with `/`).\n'));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const cron = parseCron(expr);
|
|
71
|
+
if (!cron) {
|
|
72
|
+
console.log(chalk.red(`\nInvalid cron expression: "${expr}"`));
|
|
73
|
+
console.log(chalk.gray(' Expected 5 fields: minute hour dom month dow\n'));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
const nextRun = nextCronFire(cron, new Date());
|
|
77
|
+
const rec = addSchedule(agent.workspaceRoot, {
|
|
78
|
+
kind: 'cron',
|
|
79
|
+
expr,
|
|
80
|
+
command,
|
|
81
|
+
owner: agent.sessionKey,
|
|
82
|
+
nextRun: nextRun.toISOString(),
|
|
83
|
+
});
|
|
84
|
+
console.log(chalk.green(`\n✓ Registered ${rec.id}: cron "${expr}" → ${command}`));
|
|
85
|
+
console.log(chalk.gray(` Next fire: ${formatWhen(nextRun)}\n`));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (sub === 'in') {
|
|
89
|
+
const ms = parseInterval(args[1] ?? '');
|
|
90
|
+
const command = args.slice(2).join(' ').trim();
|
|
91
|
+
if (!ms || !command) {
|
|
92
|
+
console.log(chalk.red('\nUsage: /schedule in <duration> /command'));
|
|
93
|
+
console.log(chalk.gray(' e.g. /schedule in 5m /ci-status\n'));
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (!command.startsWith('/')) {
|
|
97
|
+
console.log(chalk.red('\nSchedule only dispatches slash commands.\n'));
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const nextRun = new Date(Date.now() + ms);
|
|
101
|
+
const rec = addSchedule(agent.workspaceRoot, {
|
|
102
|
+
kind: 'once',
|
|
103
|
+
expr: nextRun.toISOString(),
|
|
104
|
+
command,
|
|
105
|
+
owner: agent.sessionKey,
|
|
106
|
+
nextRun: nextRun.toISOString(),
|
|
107
|
+
});
|
|
108
|
+
console.log(chalk.green(`\n✓ Registered ${rec.id}: one-shot in ${args[1]} → ${command}`));
|
|
109
|
+
console.log(chalk.gray(` Fires at: ${formatWhen(nextRun)}\n`));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if (sub === 'at') {
|
|
113
|
+
const time = args[1] ?? '';
|
|
114
|
+
const command = args.slice(2).join(' ').trim();
|
|
115
|
+
const tm = /^(\d{1,2}):(\d{2})$/.exec(time);
|
|
116
|
+
if (!tm || !command) {
|
|
117
|
+
console.log(chalk.red('\nUsage: /schedule at HH:MM /command'));
|
|
118
|
+
console.log(chalk.gray(' e.g. /schedule at 14:30 /agents\n'));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (!command.startsWith('/')) {
|
|
122
|
+
console.log(chalk.red('\nSchedule only dispatches slash commands.\n'));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
const h = Number(tm[1]);
|
|
126
|
+
const min = Number(tm[2]);
|
|
127
|
+
if (h > 23 || min > 59) {
|
|
128
|
+
console.log(chalk.red('\nInvalid time. Hours 0-23, minutes 0-59.\n'));
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
const now = new Date();
|
|
132
|
+
const target = new Date(now);
|
|
133
|
+
target.setHours(h, min, 0, 0);
|
|
134
|
+
if (target.getTime() <= now.getTime())
|
|
135
|
+
target.setDate(target.getDate() + 1);
|
|
136
|
+
const rec = addSchedule(agent.workspaceRoot, {
|
|
137
|
+
kind: 'once',
|
|
138
|
+
expr: target.toISOString(),
|
|
139
|
+
command,
|
|
140
|
+
owner: agent.sessionKey,
|
|
141
|
+
nextRun: target.toISOString(),
|
|
142
|
+
});
|
|
143
|
+
console.log(chalk.green(`\n✓ Registered ${rec.id}: one-shot at ${time} → ${command}`));
|
|
144
|
+
console.log(chalk.gray(` Fires at: ${formatWhen(target)}\n`));
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
console.log(chalk.red(`\nUnknown subcommand: /schedule ${sub}`));
|
|
148
|
+
console.log(chalk.gray(' Try: list | cron "<expr>" /cmd | in <dur> /cmd | at HH:MM /cmd | remove <id> | disable <id> | enable <id>\n'));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
function renderList(workspaceRoot, sessionKey) {
|
|
152
|
+
const all = loadSchedules(workspaceRoot);
|
|
153
|
+
const mine = all.filter((s) => s.owner === sessionKey);
|
|
154
|
+
if (mine.length === 0) {
|
|
155
|
+
console.log(chalk.yellow('\nNo schedules registered for this session.'));
|
|
156
|
+
console.log(chalk.gray(' Add one with /schedule cron "<expr>" /command or /schedule in 5m /command\n'));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
console.log(chalk.bold('\nSchedules'));
|
|
160
|
+
for (const s of mine) {
|
|
161
|
+
const status = s.enabled ? chalk.green('●') : chalk.gray('○');
|
|
162
|
+
const kind = s.kind === 'cron' ? `cron "${s.expr}"` : `once`;
|
|
163
|
+
console.log(` ${status} ${chalk.cyan(s.id)} ${chalk.gray(kind.padEnd(28))} → ${s.command}`);
|
|
164
|
+
console.log(` ${chalk.gray(`next: ${formatWhen(new Date(s.nextRun))}${s.lastRun ? ` · last: ${formatWhen(new Date(s.lastRun))}` : ''}`)}`);
|
|
165
|
+
}
|
|
166
|
+
console.log();
|
|
167
|
+
}
|
|
168
|
+
function formatWhen(d) {
|
|
169
|
+
if (!Number.isFinite(d.getTime()))
|
|
170
|
+
return '(invalid)';
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const delta = d.getTime() - now;
|
|
173
|
+
const abs = Math.abs(delta);
|
|
174
|
+
const human = humanDelta(abs);
|
|
175
|
+
const rel = delta >= 0 ? `in ${human}` : `${human} ago`;
|
|
176
|
+
return `${d.toISOString().replace('T', ' ').slice(0, 16)} (${rel})`;
|
|
177
|
+
}
|
|
178
|
+
function humanDelta(ms) {
|
|
179
|
+
const s = Math.round(ms / 1000);
|
|
180
|
+
if (s < 60)
|
|
181
|
+
return `${s}s`;
|
|
182
|
+
const m = Math.round(s / 60);
|
|
183
|
+
if (m < 60)
|
|
184
|
+
return `${m}m`;
|
|
185
|
+
const h = Math.round(m / 60);
|
|
186
|
+
if (h < 48)
|
|
187
|
+
return `${h}h`;
|
|
188
|
+
return `${Math.round(h / 24)}d`;
|
|
189
|
+
}
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -8,7 +8,7 @@ import { execSync } from 'node:child_process';
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { spinner as makeSpinner } from '../spinner.js';
|
|
10
10
|
import { LOCAL_TOOLS } from '../../agent/agent.js';
|
|
11
|
-
import { callMcpTool } from '../../runtime/mcpUtils.js';
|
|
11
|
+
import { callMcpTool, hasMcpTool } from '../../runtime/mcpUtils.js';
|
|
12
12
|
import { listSessions, reconcileStale } from '../../orchestration/orchestrator.js';
|
|
13
13
|
import { readPreferences, resolveEffort, writePreferences } from '../../state/preferencesStore.js';
|
|
14
14
|
import { readPlan } from '../../state/taskStore.js';
|
|
@@ -116,7 +116,7 @@ export async function tryHandleUiCommand(ctx) {
|
|
|
116
116
|
const toolNames = new Set((res.tools || []).map((tool) => tool.name));
|
|
117
117
|
const memoryTools = ['memory_recall', 'memory_capture_turn', 'memory_working_offload'];
|
|
118
118
|
for (const name of memoryTools) {
|
|
119
|
-
const hasTool = toolNames
|
|
119
|
+
const hasTool = hasMcpTool(toolNames, name);
|
|
120
120
|
console.log(` ${name}: ${hasTool ? chalk.green('available') : chalk.yellow('not exposed')}`);
|
|
121
121
|
}
|
|
122
122
|
}
|
package/dist/cli/ink/Picker.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface PickerProps {
|
|
|
33
33
|
footer?: string;
|
|
34
34
|
rows: PickerRow[];
|
|
35
35
|
initialCursor?: number;
|
|
36
|
+
multiSelect?: boolean;
|
|
36
37
|
allowOther?: boolean;
|
|
37
38
|
otherLabel?: string;
|
|
38
39
|
otherDescription?: string;
|
|
@@ -56,6 +57,11 @@ export interface PickerProps {
|
|
|
56
57
|
export type PickerResult = {
|
|
57
58
|
kind: 'pick';
|
|
58
59
|
id: string;
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'multi';
|
|
62
|
+
id: string;
|
|
63
|
+
ids: string[];
|
|
64
|
+
otherText?: string;
|
|
59
65
|
} | {
|
|
60
66
|
kind: 'other';
|
|
61
67
|
text: string;
|
package/dist/cli/ink/Picker.js
CHANGED
|
@@ -27,6 +27,7 @@ export function Picker(props) {
|
|
|
27
27
|
];
|
|
28
28
|
}, [props.rows, props.allowOther, props.otherLabel, props.otherDescription]);
|
|
29
29
|
const [cursor, setCursor] = useState(() => Math.max(0, Math.min(props.initialCursor ?? 0, augmentedRows.length - 1)));
|
|
30
|
+
const [selected, setSelected] = useState(() => new Set());
|
|
30
31
|
const [phase, setPhase] = useState(props.prefilledOther !== undefined ? 'other' : 'pick');
|
|
31
32
|
const [otherText, setOtherText] = useState(props.prefilledOther ?? '');
|
|
32
33
|
const [preview, setPreview] = useState(undefined);
|
|
@@ -100,6 +101,17 @@ export function Picker(props) {
|
|
|
100
101
|
}
|
|
101
102
|
if (key.return) {
|
|
102
103
|
const row = augmentedRows[cursor];
|
|
104
|
+
if (props.multiSelect) {
|
|
105
|
+
if (selected.size === 0)
|
|
106
|
+
return;
|
|
107
|
+
if (selected.has(OTHER_ID)) {
|
|
108
|
+
setPhase('other');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const ids = augmentedRows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
|
112
|
+
finish({ kind: 'multi', id: ids[0] ?? '', ids });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
103
115
|
if (row.id === OTHER_ID) {
|
|
104
116
|
setPhase('other');
|
|
105
117
|
return;
|
|
@@ -107,6 +119,18 @@ export function Picker(props) {
|
|
|
107
119
|
finish({ kind: 'pick', id: row.id });
|
|
108
120
|
return;
|
|
109
121
|
}
|
|
122
|
+
if (input === ' ' && props.multiSelect) {
|
|
123
|
+
const row = augmentedRows[cursor];
|
|
124
|
+
setSelected((prev) => {
|
|
125
|
+
const next = new Set(prev);
|
|
126
|
+
if (next.has(row.id))
|
|
127
|
+
next.delete(row.id);
|
|
128
|
+
else
|
|
129
|
+
next.add(row.id);
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
110
134
|
if (key.escape || input === 'q') {
|
|
111
135
|
finish({ kind: 'cancelled' });
|
|
112
136
|
return;
|
|
@@ -114,20 +138,31 @@ export function Picker(props) {
|
|
|
114
138
|
});
|
|
115
139
|
const footer = props.footer ?? (phase === 'other'
|
|
116
140
|
? '↵ accept · esc back · ⌫ erase'
|
|
117
|
-
:
|
|
141
|
+
: props.multiSelect
|
|
142
|
+
? '↑/↓ navigate · space toggle · ↵ confirm · esc / q cancel'
|
|
143
|
+
: '↑/↓ navigate · ↵ confirm · esc / q cancel');
|
|
118
144
|
const accent = props.accentColor ?? themeToAccent(props.theme?.mode) ?? '#CC9166';
|
|
119
|
-
return (_jsxs(Frame, { title: props.title, subtitle: props.subtitle, badge: props.badge, footer: footer, accentColor: accent, children: [phase === 'pick' ? (_jsx(PickerRows, { rows: augmentedRows, cursor: cursor, accentColor: accent })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: accent, children: "\u203A Type your answer" }), _jsx(Text, { color: "gray", dimColor: true, children: props.otherDescription ?? 'Press ENTER to accept' }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(TextInput, { value: otherText, onChange: setOtherText, onSubmit: (value) => {
|
|
145
|
+
return (_jsxs(Frame, { title: props.title, subtitle: props.subtitle, badge: props.badge, footer: footer, accentColor: accent, children: [phase === 'pick' ? (_jsx(PickerRows, { rows: augmentedRows, cursor: cursor, accentColor: accent, multiSelect: !!props.multiSelect, selected: selected })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: accent, children: "\u203A Type your answer" }), _jsx(Text, { color: "gray", dimColor: true, children: props.otherDescription ?? 'Press ENTER to accept' }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(TextInput, { value: otherText, onChange: setOtherText, onSubmit: (value) => {
|
|
120
146
|
const trimmed = value.trim();
|
|
121
147
|
if (!trimmed)
|
|
122
148
|
return;
|
|
149
|
+
if (props.multiSelect) {
|
|
150
|
+
finish({
|
|
151
|
+
kind: 'multi',
|
|
152
|
+
id: augmentedRows.find((r) => selected.has(r.id) && r.id !== OTHER_ID)?.id ?? '',
|
|
153
|
+
ids: augmentedRows.filter((r) => selected.has(r.id) && r.id !== OTHER_ID).map((r) => r.id),
|
|
154
|
+
otherText: trimmed,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
123
158
|
finish({ kind: 'other', text: trimmed });
|
|
124
159
|
} })] })] })), preview && preview.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", borderTop: true, borderLeft: false, borderRight: false, borderBottom: false, children: preview.map((line, i) => _jsx(Text, { children: line }, i)) })) : null] }));
|
|
125
160
|
}
|
|
126
|
-
function PickerRows({ rows, cursor, accentColor }) {
|
|
127
|
-
return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsx(PickerRowView, { row: row, selected: i === cursor, accentColor: accentColor }, row.id))) }));
|
|
161
|
+
function PickerRows({ rows, cursor, accentColor, multiSelect, selected }) {
|
|
162
|
+
return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsx(PickerRowView, { row: row, selected: i === cursor, accentColor: accentColor, multiSelect: multiSelect, checked: selected.has(row.id) }, row.id))) }));
|
|
128
163
|
}
|
|
129
|
-
function PickerRowView({ row, selected, accentColor }) {
|
|
164
|
+
function PickerRowView({ row, selected, accentColor, multiSelect, checked }) {
|
|
130
165
|
// Selected glyph + bold label + right-aligned value, lifted from
|
|
131
166
|
// openSrc/grok-cli/src/ui/components/SuggestionOverlay.tsx
|
|
132
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: accentColor, children: selected ? ' › ' : ' ' }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { bold: selected, color: selected ? accentColor : undefined, children: row.label }) }), row.value ? _jsx(Text, { color: "gray", children: row.value }) : null] }), row.description ? (_jsx(Box, { paddingLeft: 5, children: _jsx(Text, { color: "gray", dimColor: true, children: row.description }) })) : null] }));
|
|
167
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: accentColor, children: selected ? ' › ' : ' ' }), multiSelect ? _jsx(Text, { color: checked ? accentColor : 'gray', children: checked ? '[x] ' : '[ ] ' }) : null, _jsx(Box, { flexGrow: 1, children: _jsx(Text, { bold: selected, color: selected ? accentColor : undefined, children: row.label }) }), row.value ? _jsx(Text, { color: "gray", children: row.value }) : null] }), row.description ? (_jsx(Box, { paddingLeft: 5, children: _jsx(Text, { color: "gray", dimColor: true, children: row.description }) })) : null] }));
|
|
133
168
|
}
|
package/dist/cli/ink/runChat.js
CHANGED
|
@@ -12,6 +12,7 @@ import { addGoalTokens, buildGoalContinuationPrompt, formatBudget, goalHasBudget
|
|
|
12
12
|
import { setActiveReadline } from '../cliPrompt.js';
|
|
13
13
|
import { ChatApp } from './ChatApp.js';
|
|
14
14
|
import { handleSlashCommand, lookupSlashDescription, SLASH_COMMANDS } from '../repl.js';
|
|
15
|
+
import { startScheduleTicker } from '../../runtime/scheduleTicker.js';
|
|
15
16
|
import { formatToolCall } from './toolFormat.js';
|
|
16
17
|
import { setAmbientChat } from './ambientChat.js';
|
|
17
18
|
import { captureConsoleOutput } from './consoleCapture.js';
|
|
@@ -288,7 +289,11 @@ export async function runChat(opts) {
|
|
|
288
289
|
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
289
290
|
const u = agent.lastTurnUsage;
|
|
290
291
|
const tokens = u.calls > 0 ? ` ${u.promptTokens.toLocaleString()}↑ ${u.completionTokens.toLocaleString()}↓` : '';
|
|
291
|
-
|
|
292
|
+
// When children are alive — typically because the parent is in a
|
|
293
|
+
// wait_agent / wait_agents / R1 guardrail auto-drain — append a
|
|
294
|
+
// compact "running children" row so the parent never looks frozen.
|
|
295
|
+
const childrenRow = runningChildren.size > 0 ? ` · ${formatRunningChildrenRow()}` : '';
|
|
296
|
+
controller.push.setStatus(`${status} ${elapsed}s${tokens}${childrenRow}`);
|
|
292
297
|
};
|
|
293
298
|
// Per-tool start time + args — agent.runTurn fires onToolStart with
|
|
294
299
|
// full args but onToolEnd only sees name + result, so we stash the
|
|
@@ -299,6 +304,25 @@ export async function runChat(opts) {
|
|
|
299
304
|
// start time wins, slightly under-counting concurrent invocations).
|
|
300
305
|
const toolStartTimes = new Map();
|
|
301
306
|
const toolArgsSnapshot = new Map();
|
|
307
|
+
// Stash child tool args between onChildToolStart and onChildToolEnd so the
|
|
308
|
+
// end row can render `Read(foo.ts)` instead of just `read_file`. Keyed by
|
|
309
|
+
// `${childId}:${tool}` so two children running the same tool don't collide.
|
|
310
|
+
const childToolArgs = new Map();
|
|
311
|
+
// Currently-running children for the compact "running children" status row.
|
|
312
|
+
// Maintained from onChildToolStart / onChildComplete (the only signals the
|
|
313
|
+
// REPL gets about child lifecycle that don't require re-reading sessions).
|
|
314
|
+
const runningChildren = new Map();
|
|
315
|
+
const formatRunningChildrenRow = () => {
|
|
316
|
+
if (runningChildren.size === 0)
|
|
317
|
+
return '';
|
|
318
|
+
const parts = [];
|
|
319
|
+
for (const [id, info] of runningChildren) {
|
|
320
|
+
const idShort = id.slice(0, 8);
|
|
321
|
+
const tail = info.tool ? ` ${info.tool}` : '';
|
|
322
|
+
parts.push(`${id.startsWith('agent-') ? id.slice(0, 14) : 'agent-' + idShort} (${info.role}${tail})`);
|
|
323
|
+
}
|
|
324
|
+
return `running children: ${parts.join(', ')}`;
|
|
325
|
+
};
|
|
302
326
|
try {
|
|
303
327
|
const answer = await agent.runTurn(expanded, {
|
|
304
328
|
onStatusUpdate: tickStatus,
|
|
@@ -337,7 +361,50 @@ export async function runChat(opts) {
|
|
|
337
361
|
controller.push.plan(items, explanation);
|
|
338
362
|
tickStatus('Thinking');
|
|
339
363
|
},
|
|
364
|
+
onChildToolStart: (event) => {
|
|
365
|
+
const key = `${event.childId}:${event.tool}`;
|
|
366
|
+
childToolArgs.set(key, event.args ?? {});
|
|
367
|
+
const prior = runningChildren.get(event.childId);
|
|
368
|
+
runningChildren.set(event.childId, { role: event.role, tool: event.tool });
|
|
369
|
+
// Live status row so the user sees WHICH children are alive while
|
|
370
|
+
// the parent is waiting. Quiet-mode rule: still surface long-running
|
|
371
|
+
// child state — it's the user's only signal that the parent isn't stuck.
|
|
372
|
+
const row = formatRunningChildrenRow();
|
|
373
|
+
if (row)
|
|
374
|
+
controller.push.setStatus(row);
|
|
375
|
+
// First-tool notice: emit a one-line "child started" row so the
|
|
376
|
+
// scrollback shows the child began before any tool finishes. Quiet
|
|
377
|
+
// mode suppresses this; the paired end row below is enough.
|
|
378
|
+
if (!prior && !isQuiet()) {
|
|
379
|
+
const idShort = event.childId.slice(0, 8);
|
|
380
|
+
const idLabel = event.childId.startsWith('agent-') ? event.childId.slice(0, 14) : 'agent-' + idShort;
|
|
381
|
+
controller.push.notice(`▶ ${idLabel} (${event.role}) running...`, 'info');
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
onChildToolEnd: (event) => {
|
|
385
|
+
const key = `${event.childId}:${event.tool}`;
|
|
386
|
+
const args = childToolArgs.get(key);
|
|
387
|
+
childToolArgs.delete(key);
|
|
388
|
+
// Tool finished — null out the tool field so the running-children
|
|
389
|
+
// status row stops showing a stale tool name.
|
|
390
|
+
const cur = runningChildren.get(event.childId);
|
|
391
|
+
if (cur)
|
|
392
|
+
runningChildren.set(event.childId, { role: cur.role, tool: undefined });
|
|
393
|
+
const idShort = event.childId.slice(0, 8);
|
|
394
|
+
const idLabel = event.childId.startsWith('agent-') ? event.childId.slice(0, 14) : 'agent-' + idShort;
|
|
395
|
+
const inner = formatToolCall(event.tool, args);
|
|
396
|
+
const header = `[${idLabel} ${event.role}] ${inner}`;
|
|
397
|
+
// Quiet-mode rule (carried from R1): hide noisy success previews,
|
|
398
|
+
// but still print the paired row so the user has a visible signal
|
|
399
|
+
// that the child made progress.
|
|
400
|
+
controller.push.tool(header, event.ok, {
|
|
401
|
+
preview: !isQuiet() ? event.preview : undefined,
|
|
402
|
+
durationMs: event.durationMs,
|
|
403
|
+
});
|
|
404
|
+
tickStatus('Thinking');
|
|
405
|
+
},
|
|
340
406
|
onChildComplete: (event) => {
|
|
407
|
+
runningChildren.delete(event.childId);
|
|
341
408
|
const ok = event.status === 'completed';
|
|
342
409
|
const head = ok
|
|
343
410
|
? `🏁 Agent ${event.childId} (${event.role}) completed`
|
|
@@ -427,6 +494,39 @@ export async function runChat(opts) {
|
|
|
427
494
|
armIdleHint();
|
|
428
495
|
}
|
|
429
496
|
};
|
|
497
|
+
// Background `/schedule` ticker. Single in-process timer; fires due
|
|
498
|
+
// cron/one-shot jobs by re-injecting their slash command through the
|
|
499
|
+
// same dispatcher the user uses. Filtered by sessionKey so a tick
|
|
500
|
+
// only fires jobs owned by THIS REPL — schedules registered in a
|
|
501
|
+
// different session sit idle until that session is open. Stops in
|
|
502
|
+
// the `waitUntilExit` handlers below so /exit and ^C clean up.
|
|
503
|
+
let scheduleTicker = null;
|
|
504
|
+
const startTicker = () => {
|
|
505
|
+
if (scheduleTicker)
|
|
506
|
+
return;
|
|
507
|
+
scheduleTicker = startScheduleTicker({
|
|
508
|
+
workspaceRoot: agent.workspaceRoot,
|
|
509
|
+
sessionKey: agent.sessionKey,
|
|
510
|
+
fire: (command, sched) => {
|
|
511
|
+
if (!controller)
|
|
512
|
+
return;
|
|
513
|
+
if (isProcessing) {
|
|
514
|
+
// Catch-up rule: only fire ONCE per missed window. The ticker
|
|
515
|
+
// has already advanced nextRun past `now`, so silently
|
|
516
|
+
// dropping a busy-session fire is correct — it won't refire
|
|
517
|
+
// for the same minute.
|
|
518
|
+
controller.push.notice(`(schedule ${sched.id} fired while a turn was in flight — skipped)`, 'warn');
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const parts = command.trim().split(/\s+/);
|
|
522
|
+
const cmd = parts[0].toLowerCase();
|
|
523
|
+
const args = parts.slice(1);
|
|
524
|
+
controller.push.notice(`⏰ Schedule ${sched.id} → ${command}`, 'info');
|
|
525
|
+
void dispatchSlash(cmd, args, shim);
|
|
526
|
+
},
|
|
527
|
+
onError: (msg) => controller?.push.notice(`[schedule] ${msg}`, 'warn'),
|
|
528
|
+
});
|
|
529
|
+
};
|
|
430
530
|
// Mount Ink. We DON'T set `patchConsole: false` — Ink's default
|
|
431
531
|
// (patchConsole enabled) is exactly what we want: legacy slash
|
|
432
532
|
// commands that still write via chalk + console.log have their
|
|
@@ -452,6 +552,7 @@ export async function runChat(opts) {
|
|
|
452
552
|
});
|
|
453
553
|
refreshFooter();
|
|
454
554
|
armIdleHint();
|
|
555
|
+
startTicker();
|
|
455
556
|
}, onAccessModeCycle: () => {
|
|
456
557
|
const cycle = ['read', 'write', 'shell'];
|
|
457
558
|
const current = agent.getAccessMode();
|
|
@@ -500,6 +601,11 @@ export async function runChat(opts) {
|
|
|
500
601
|
setAmbientChat(undefined);
|
|
501
602
|
cleanupResizeClear();
|
|
502
603
|
clearIdleHint();
|
|
604
|
+
try {
|
|
605
|
+
scheduleTicker?.stop();
|
|
606
|
+
}
|
|
607
|
+
catch { /* noop */ }
|
|
608
|
+
scheduleTicker = null;
|
|
503
609
|
try {
|
|
504
610
|
await mcpClient.close();
|
|
505
611
|
}
|
|
@@ -514,6 +620,11 @@ export async function runChat(opts) {
|
|
|
514
620
|
setAmbientChat(undefined);
|
|
515
621
|
cleanupResizeClear();
|
|
516
622
|
clearIdleHint();
|
|
623
|
+
try {
|
|
624
|
+
scheduleTicker?.stop();
|
|
625
|
+
}
|
|
626
|
+
catch { /* noop */ }
|
|
627
|
+
scheduleTicker = null;
|
|
517
628
|
try {
|
|
518
629
|
await mcpClient.close();
|
|
519
630
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* — one-line, identity-revealing, no JSON. These helpers do the same
|
|
13
13
|
* mapping for our built-in LOCAL_TOOLS (cli/../agent/agent.ts) + MCP
|
|
14
|
-
* tool names (which carry an `
|
|
14
|
+
* tool names (which carry an `mcp_<server>_` namespace prefix that
|
|
15
15
|
* the user doesn't care about).
|
|
16
16
|
*
|
|
17
17
|
* Reference for the convention: claude-code transcripts (see
|
|
@@ -28,20 +28,22 @@
|
|
|
28
28
|
* → "Bash(npm test)"
|
|
29
29
|
* formatToolCall('grep_search', { query: 'authenticate', path: '.' })
|
|
30
30
|
* → 'Grep("authenticate")'
|
|
31
|
-
* formatToolCall('
|
|
31
|
+
* formatToolCall('mcp_brainrouter_memory_search', { q: 'auth' })
|
|
32
32
|
* → 'MemorySearch("auth")'
|
|
33
33
|
* formatToolCall('spawn_agent', { role: 'researcher', prompt: '...' })
|
|
34
34
|
* → 'Spawn(researcher, "...")'
|
|
35
|
+
* formatToolCall('task_agent', { role: 'reviewer', prompt: '...' })
|
|
36
|
+
* → 'Task(reviewer, "...")'
|
|
35
37
|
*/
|
|
36
38
|
export declare function formatToolCall(name: string, args: Record<string, any> | undefined): string;
|
|
39
|
+
export declare function setKnownMcpServerIds(ids: ReadonlyArray<string>): void;
|
|
37
40
|
/**
|
|
38
|
-
* Strip the `
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
43
|
-
*
|
|
44
|
-
* `mcp_brainrouter_memory_search` → `memory_search`
|
|
41
|
+
* Strip the `mcp_<server>_` namespace prefix from MCP tool names. As of
|
|
42
|
+
* 0.3.8-R5 the pool normalises to single-underscore at the boundary, so
|
|
43
|
+
* downstream call-sites only ever see this shape.
|
|
44
|
+
* `mcp_brainrouter_memory_search` → `memory_search`
|
|
45
|
+
* `mcp_my_server_memory_search` → `memory_search` (when `my_server`
|
|
46
|
+
* is registered via `setKnownMcpServerIds`)
|
|
45
47
|
*/
|
|
46
48
|
export declare function stripMcpPrefix(name: string): string;
|
|
47
49
|
/**
|