@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.
Files changed (59) hide show
  1. package/changelog/0.2.0.md +15 -0
  2. package/changelog/0.3.0.md +20 -0
  3. package/changelog/0.3.1.md +22 -0
  4. package/changelog/0.3.2.md +15 -0
  5. package/changelog/0.3.3.md +19 -0
  6. package/changelog/0.3.4.md +20 -0
  7. package/changelog/0.3.5.md +9 -0
  8. package/changelog/0.3.6.md +9 -0
  9. package/changelog/0.3.7.md +20 -0
  10. package/changelog/0.3.8.md +30 -0
  11. package/changelog/README.md +41 -0
  12. package/dist/agent/agent.d.ts +22 -0
  13. package/dist/agent/agent.js +259 -82
  14. package/dist/agent/toolCallRecovery.d.ts +57 -0
  15. package/dist/agent/toolCallRecovery.js +130 -0
  16. package/dist/agent/toolSafety.d.ts +17 -0
  17. package/dist/agent/toolSafety.js +102 -0
  18. package/dist/cli/banner.js +2 -2
  19. package/dist/cli/cliPrompt.js +65 -0
  20. package/dist/cli/commands/config.js +1 -1
  21. package/dist/cli/commands/mcp.d.ts +1 -1
  22. package/dist/cli/commands/mcp.js +29 -7
  23. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  24. package/dist/cli/commands/mcpInstall.js +87 -0
  25. package/dist/cli/commands/orchestration.js +33 -0
  26. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  27. package/dist/cli/commands/releaseNotes.js +109 -0
  28. package/dist/cli/commands/schedule.d.ts +18 -0
  29. package/dist/cli/commands/schedule.js +189 -0
  30. package/dist/cli/commands/ui.js +2 -2
  31. package/dist/cli/ink/Picker.d.ts +6 -0
  32. package/dist/cli/ink/Picker.js +41 -6
  33. package/dist/cli/ink/runChat.js +112 -1
  34. package/dist/cli/ink/toolFormat.d.ts +11 -9
  35. package/dist/cli/ink/toolFormat.js +42 -16
  36. package/dist/cli/repl.d.ts +1 -1
  37. package/dist/cli/repl.js +9 -2
  38. package/dist/config/config.d.ts +1 -1
  39. package/dist/index.js +10 -1
  40. package/dist/memory/briefing.js +4 -4
  41. package/dist/orchestration/tools.d.ts +95 -2
  42. package/dist/orchestration/tools.js +119 -4
  43. package/dist/prompt/systemPrompt.js +5 -4
  44. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  45. package/dist/runtime/anthropicAdapter.js +293 -0
  46. package/dist/runtime/cronParser.d.ts +23 -0
  47. package/dist/runtime/cronParser.js +122 -0
  48. package/dist/runtime/mcpClient.js +1 -1
  49. package/dist/runtime/mcpPool.d.ts +8 -0
  50. package/dist/runtime/mcpPool.js +19 -0
  51. package/dist/runtime/mcpUtils.d.ts +14 -0
  52. package/dist/runtime/mcpUtils.js +23 -0
  53. package/dist/runtime/scheduleTicker.d.ts +33 -0
  54. package/dist/runtime/scheduleTicker.js +99 -0
  55. package/dist/runtime/vendorSnippets.d.ts +45 -0
  56. package/dist/runtime/vendorSnippets.js +153 -0
  57. package/dist/state/scheduleStore.d.ts +37 -0
  58. package/dist/state/scheduleStore.js +64 -0
  59. 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
+ }
@@ -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.has(name);
119
+ const hasTool = hasMcpTool(toolNames, name);
120
120
  console.log(` ${name}: ${hasTool ? chalk.green('available') : chalk.yellow('not exposed')}`);
121
121
  }
122
122
  }
@@ -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;
@@ -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
- : '↑/↓ navigate · ↵ confirm · esc / q cancel');
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
  }
@@ -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
- controller.push.setStatus(`${status} ${elapsed}s${tokens}`);
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 `mcp__<server>__` namespace prefix that
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('mcp__brainrouter__memory_search', { q: 'auth' })
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 `mcp__<server>__` or `mcp_<server>_` namespace prefix from MCP tool
39
- * names. Server ids may contain underscores (e.g. `my_server`), so the
40
- * double-underscore form uses a lazy match. Both prefix conventions are in use
41
- * across the multi-MCP codepaths until naming is unified.
42
- * `mcp__brainrouter__memory_search` → `memory_search`
43
- * `mcp__my_server__memory_search` → `memory_search`
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
  /**