ikarmic-forge 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +4 -0
  2. package/dist/acp/server.d.ts +3 -1
  3. package/dist/acp/server.d.ts.map +1 -1
  4. package/dist/acp/server.js +45 -4
  5. package/dist/acp/server.js.map +1 -1
  6. package/dist/bin/forge.js +45 -6
  7. package/dist/bin/forge.js.map +1 -1
  8. package/dist/chat-files.d.ts +8 -0
  9. package/dist/chat-files.d.ts.map +1 -1
  10. package/dist/chat-files.js +41 -4
  11. package/dist/chat-files.js.map +1 -1
  12. package/dist/clipboard.d.ts +38 -0
  13. package/dist/clipboard.d.ts.map +1 -0
  14. package/dist/clipboard.js +63 -0
  15. package/dist/clipboard.js.map +1 -0
  16. package/dist/commands/memory.d.ts +10 -0
  17. package/dist/commands/memory.d.ts.map +1 -0
  18. package/dist/commands/memory.js +77 -0
  19. package/dist/commands/memory.js.map +1 -0
  20. package/dist/commands/spec.d.ts +13 -3
  21. package/dist/commands/spec.d.ts.map +1 -1
  22. package/dist/commands/spec.js +76 -5
  23. package/dist/commands/spec.js.map +1 -1
  24. package/dist/components/Approval.d.ts.map +1 -1
  25. package/dist/components/Approval.js +2 -0
  26. package/dist/components/Approval.js.map +1 -1
  27. package/dist/options.d.ts +45 -3
  28. package/dist/options.d.ts.map +1 -1
  29. package/dist/options.js +90 -11
  30. package/dist/options.js.map +1 -1
  31. package/dist/repl-commands.d.ts +39 -0
  32. package/dist/repl-commands.d.ts.map +1 -1
  33. package/dist/repl-commands.js +91 -4
  34. package/dist/repl-commands.js.map +1 -1
  35. package/dist/repl-extras.d.ts +39 -0
  36. package/dist/repl-extras.d.ts.map +1 -0
  37. package/dist/repl-extras.js +120 -0
  38. package/dist/repl-extras.js.map +1 -0
  39. package/dist/repl.d.ts +25 -1
  40. package/dist/repl.d.ts.map +1 -1
  41. package/dist/repl.js +419 -86
  42. package/dist/repl.js.map +1 -1
  43. package/dist/session-checkpoints.js +1 -1
  44. package/dist/session-checkpoints.js.map +1 -1
  45. package/package.json +2 -2
package/dist/repl.js CHANGED
@@ -1,19 +1,49 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- import { Box, Text, useInput } from 'ink';
2
+ import { Box, Text, useInput, useStdin } from 'ink';
3
3
  import TextInput from 'ink-text-input';
4
- import { Agent, formatSteeringLine, defaultSessionDir, saveSession, SPEC_READONLY_TOOLS, SpecStore, splitSpecSections, slugifyTaskForSpecId, } from '@ikarmic/forge-core';
5
- import { rm } from 'node:fs/promises';
4
+ import { Agent, formatSteeringLine, defaultSessionDir, expandCustomCommand, formatCustomCommands, listSessions, loadCustomCommands, saveSession, searchSessions, SPEC_READONLY_TOOLS, SpecStore, splitSpecSections, slugifyTaskForSpecId, } from '@ikarmic/forge-core';
5
+ import { readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
- import { buildRepoMap, changedFiles, createExploreEditRouter, lintFile, commitFiles, diffForFiles, generateCommitMessage, isGitRepo, undoLastForgeCommit, } from '@ikarmic/forge-core';
7
+ import { buildRepoMap, changedFiles, createExploreEditRouter, fetchUrlTool, lintFile, redact, commitFiles, diffForFiles, generateCommitMessage, isGitRepo, undoLastForgeCommit, } from '@ikarmic/forge-core';
8
8
  import { Approval } from './components/Approval.js';
9
- import { ChatFiles, stripChatFiles } from './chat-files.js';
9
+ import { ChatFiles, stripAttachedFiles } from './chat-files.js';
10
+ import { copyToClipboard, readClipboard } from './clipboard.js';
11
+ import { buildProgram } from './options.js';
12
+ import { composeInEditor, conversationMarkdown, helpQuestionTask, lastAssistantText, reportUrl, } from './repl-extras.js';
10
13
  import { formatCommitLine, gitCommitOptions } from './git-workflow.js';
11
- import { OK_TASK, describeModel, estimateContextTokens, fixTask, formatModels, formatTokens, parseSlashCommand, runContextNote, runShell, switchModel, } from './repl-commands.js';
14
+ import { OK_TASK, describeModel, formatScript, parseReasoningEffort, parseThinkTokens, scriptLines, estimateContextTokens, fixTask, formatModels, formatTokens, parseSlashCommand, runContextNote, runShell, switchModel, } from './repl-commands.js';
12
15
  import { Header } from './components/Header.js';
13
16
  import { Spinner } from './components/Spinner.js';
14
17
  import { sessionCheckpointStore, sessionCheckpointsConfig } from './session-checkpoints.js';
15
18
  import { getUnifiedDiffText, summarizeResult as summarizeToolResult } from './tool-display.js';
16
- export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initialHistory, initialLabel, sessionDir, display = {}, gitWorkflow = null, gitOptions = { noAttribute: false, gitCommitVerify: false }, commitModel, notify, aliases, }) {
19
+ /** The text of `/help`. */
20
+ function replHelpText(sessionId) {
21
+ return [
22
+ 'Slash commands: /help [question] /clear /reset /save [label] /plan <task> /spec <task> /compact /rewind /ok /model /weak-model /models /add /read-only /drop /ls /run /test /lint /tokens /settings /commit /undo /diff /map /map-refresh /copy /copy-context /paste /web /editor /report /reasoning-effort /think-tokens /editor-model /architect /load /save-script /retry /edit-last /sessions /exit /quit.',
23
+ '/retry [new text] runs your last message again (or an edited one) in place of the last turn; /edit-last opens it in your editor first. Files the dropped turn changed stay changed: /undo or /rewind reverts them.',
24
+ '/sessions [words] lists recent sessions, or searches all of them; resume one with forge --resume <id>. Messages typed while a turn runs wait in a queue and run in order.',
25
+ 'Custom commands: a Markdown file in .forge/commands/<name>.md (project) or ~/.forge/commands/<name>.md becomes /<name> [args]; $ARGUMENTS and $1..$9 in it are filled in.',
26
+ '/commit commits all pending changes with a drafted message; /undo reverts the last Forge commit; /diff shows uncommitted changes.',
27
+ '/model [id|alias|provider:model] switches (or shows) the model; /weak-model sets the cheap model; /models [query] lists aliases and known models.',
28
+ '/add <files> and /read-only <files> attach files to every turn (images and PDFs go to the model as they are); /drop [files] detaches; /ls lists them. /run <cmd> (or !<cmd>) runs a command and shows the model its output on the next turn; /test [cmd] and /lint [files] send failures to the model to fix; with no argument they run your --test-cmd / --lint-cmd commands.',
29
+ '/ok says "go ahead"; /reset detaches files and clears the conversation; /tokens shows context use; /settings shows the effective settings.',
30
+ '/map shows the repo map (--map-tokens sets the size sent to the model); /map-refresh re-parses every file first.',
31
+ '/copy copies the last reply to the clipboard; /copy-context copies the whole conversation as Markdown; /paste adds the clipboard text to your next message; /web <url> adds a web page to it.',
32
+ '/editor [text] (or /edit) writes your message in $VISUAL / $EDITOR and sends it; /report prints a prefilled GitHub issue link with diagnostics only; /help <question> answers a question about Forge.',
33
+ '/reasoning-effort <level|default> sets how hard the model thinks; /think-tokens <n|8k|0> picks the nearest level for a thinking budget (Forge sets a level, not an exact count).',
34
+ '/editor-model <model> and /architect <task>: the main model plans without tools (like /plan), you approve, then the editor model applies it. Attach files (/add) and use /map to give the planner context.',
35
+ '/load <file> runs the commands and messages in a file, one per line (# for comments); /save-script <file> writes the model and attached files as such a script.',
36
+ 'Otherwise type a task and press Enter.',
37
+ `Session: ${sessionId} (auto-saved after each turn).`,
38
+ 'Plan review: when a plan is proposed, type a (approve), e (edit), or c (cancel).',
39
+ 'Spec review: same a/e/c keys: approve writes requirements.md/design.md/tasks.md to .forge/specs/<id>/.',
40
+ ].join('\n');
41
+ }
42
+ async function defaultFetchPage(url, cwd) {
43
+ const r = await fetchUrlTool.execute({ url }, { cwd, auto: false });
44
+ return { url: r.url, ...(r.title ? { title: r.title } : {}), status: r.status, excerpt: r.excerpt, truncated: r.truncated };
45
+ }
46
+ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initialHistory, initialLabel, sessionDir, display = {}, gitWorkflow = null, gitOptions = { noAttribute: false, gitCommitVerify: false }, commitModel, notify, aliases, version = 'unknown', services, lintCmds = [], testCmds = [], }) {
17
47
  const [messages, setMessages] = useState([]);
18
48
  const [input, setInput] = useState('');
19
49
  const [running, setRunning] = useState(false);
@@ -49,6 +79,34 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
49
79
  const pendingNotesRef = useRef([]);
50
80
  /** `/weak-model`: cheap model for read-only exploration steps and commit messages. */
51
81
  const [weakModel, setWeakModel] = useState(undefined);
82
+ /** `/editor-model`: the model that applies a plan made with `/architect`. */
83
+ const [editorModel, setEditorModel] = useState(undefined);
84
+ /** Set while an `/architect` plan awaits approval: the model to apply it with. */
85
+ const architectEditorRef = useRef(null);
86
+ /** True while a `/load` script runs, so a script cannot load another. */
87
+ const loadingRef = useRef(false);
88
+ /** The latest `handleSubmit`, for `/load` to replay lines through. */
89
+ const submitRef = useRef(null);
90
+ /** The last chat message and the history before it, for `/retry` and `/edit-last`. */
91
+ const lastTurnRef = useRef(null);
92
+ /** Messages typed while a submission is being handled; they run in order afterwards. */
93
+ const queueRef = useRef([]);
94
+ const inFlightRef = useRef(false);
95
+ const { setRawMode, isRawModeSupported } = useStdin();
96
+ /** Hand the terminal to the editor: leave raw mode while it runs, then take it back. */
97
+ const editInTerminal = async (initial) => {
98
+ if (services?.edit)
99
+ return services.edit(initial);
100
+ if (isRawModeSupported)
101
+ setRawMode(false);
102
+ try {
103
+ return await composeInEditor(initial);
104
+ }
105
+ finally {
106
+ if (isRawModeSupported)
107
+ setRawMode(true);
108
+ }
109
+ };
52
110
  // Per-turn AbortController so Escape can cancel a running agent turn (see
53
111
  // the useInput handler below). Stored in a ref so it survives re-renders
54
112
  // across the async run and stays reachable from the key handler.
@@ -60,7 +118,9 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
60
118
  const redactionsRef = useRef(0);
61
119
  const handleSubmit = useCallback(async (raw) => {
62
120
  const task = raw.trim();
63
- if (!task || running)
121
+ // No `running` check: submitFromUser queues what arrives mid-turn, and this callback's
122
+ // `running` can be a render behind when a queued message or a /load line comes in.
123
+ if (!task)
64
124
  return;
65
125
  setInput('');
66
126
  // Slash commands
@@ -73,7 +133,7 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
73
133
  historyRef.current = [];
74
134
  return;
75
135
  }
76
- if (task.startsWith('/save')) {
136
+ if (task === '/save' || task.startsWith('/save ')) {
77
137
  const label = task.replace(/^\/save\s*/, '').trim();
78
138
  if (label)
79
139
  setCurrentLabel(label);
@@ -103,23 +163,9 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
103
163
  return;
104
164
  }
105
165
  if (task === '/help') {
106
- setMessages((m) => [
107
- ...m,
108
- {
109
- kind: 'status',
110
- id: cryptoId(),
111
- text: 'Slash commands: /help /clear /reset /save [label] /plan <task> /spec <task> /compact /rewind /ok /model /weak-model /models /add /read-only /drop /ls /run /test /lint /tokens /settings /commit /undo /diff /map /map-refresh /exit /quit.\n' +
112
- '/commit commits all pending changes with a drafted message; /undo reverts the last Forge commit; /diff shows uncommitted changes.\n' +
113
- '/model [id|alias|provider:model] switches (or shows) the model; /weak-model sets the cheap model; /models [query] lists aliases and known models.\n' +
114
- '/add <files> and /read-only <files> attach files to every turn; /drop [files] detaches; /ls lists them. /run <cmd> (or !<cmd>) runs a command and shows the model its output on the next turn; /test [cmd] and /lint [files] send failures to the model to fix.\n' +
115
- '/ok says "go ahead"; /reset detaches files and clears the conversation; /tokens shows context use; /settings shows the effective settings.\n' +
116
- '/map shows the repo map (--map-tokens sets the size sent to the model); /map-refresh re-parses every file first.\n' +
117
- `Otherwise type a task and press Enter.\n` +
118
- `Session: ${currentSessionId} (auto-saved after each turn).\n` +
119
- 'Plan review: when a plan is proposed, type a (approve), e (edit), or c (cancel).\n' +
120
- 'Spec review: same a/e/c keys: approve writes requirements.md/design.md/tasks.md to .forge/specs/<id>/.',
121
- },
122
- ]);
166
+ const custom = await loadCustomCommands(agentConfigRef.current.cwd ?? process.cwd()).catch(() => new Map());
167
+ const extra = custom.size > 0 ? `\nYour commands:\n${formatCustomCommands(custom)}` : '';
168
+ setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: replHelpText(currentSessionId) + extra }]);
123
169
  return;
124
170
  }
125
171
  if (task === '/id') {
@@ -204,12 +250,15 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
204
250
  if (action === 'a' || action === 'approve') {
205
251
  const approvedPlan = pendingPlan;
206
252
  const approvedTask = pendingPlanTask ?? task;
253
+ const applyWith = architectEditorRef.current;
254
+ architectEditorRef.current = null;
207
255
  setPendingPlan(null);
208
256
  setPendingPlanTask(null);
209
- await runWithPlan(approvedTask, approvedPlan);
257
+ await runWithPlan(approvedTask, approvedPlan, applyWith ?? undefined);
210
258
  return;
211
259
  }
212
260
  if (action === 'c' || action === 'cancel') {
261
+ architectEditorRef.current = null;
213
262
  setPendingPlan(null);
214
263
  setPendingPlanTask(null);
215
264
  setMessages((m) => [
@@ -331,7 +380,7 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
331
380
  ]);
332
381
  return;
333
382
  }
334
- if (task.startsWith('/plan')) {
383
+ if (task === '/plan' || task.startsWith('/plan ')) {
335
384
  const planTask = task.replace(/^\/plan\s*/, '').trim();
336
385
  if (!planTask) {
337
386
  setMessages((m) => [
@@ -343,7 +392,7 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
343
392
  await runInPlanMode(planTask);
344
393
  return;
345
394
  }
346
- if (task.startsWith('/spec')) {
395
+ if (task === '/spec' || task.startsWith('/spec ')) {
347
396
  const specTask = task.replace(/^\/spec\s*/, '').trim();
348
397
  if (!specTask) {
349
398
  setMessages((m) => [
@@ -360,10 +409,39 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
360
409
  return;
361
410
  }
362
411
  const slash = parseSlashCommand(task);
363
- if (slash && ['model', 'weak-model', 'models', 'run', 'test', 'lint', 'ok', 'add', 'read-only', 'drop', 'ls', 'reset', 'tokens', 'settings'].includes(slash.name)) {
412
+ if (slash && ['model', 'weak-model', 'models', 'run', 'test', 'lint', 'ok', 'add', 'read-only', 'drop', 'ls', 'reset', 'tokens', 'settings', 'help', 'copy', 'copy-context', 'paste', 'web', 'editor', 'edit', 'report', 'reasoning-effort', 'think-tokens', 'editor-model', 'architect', 'load', 'save-script'].includes(slash.name)) {
364
413
  const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
365
414
  const cwd = agentConfigRef.current.cwd ?? process.cwd();
366
415
  const { name, args } = slash;
416
+ /** Run one command, showing the command and its output tail. */
417
+ const runShown = async (command) => {
418
+ say(`$ ${command}`);
419
+ setRunning(true);
420
+ setStatus('running');
421
+ let result;
422
+ try {
423
+ result = await runShell(command, cwd);
424
+ }
425
+ finally {
426
+ setRunning(false);
427
+ setStatus('idle');
428
+ }
429
+ const tail = result.output.trim().split('\n').slice(-40).join('\n');
430
+ say(`${tail || '(no output)'}\n[${result.timedOut ? 'timed out' : `exit ${result.exitCode ?? 'none'}`}]`, result.exitCode !== 0);
431
+ return result;
432
+ };
433
+ /** Run checks in order; the first failure goes to the model to fix, as in Aider. */
434
+ const runChecks = async (kind, commands) => {
435
+ for (const command of commands) {
436
+ const result = await runShown(command);
437
+ if (result.exitCode === 0)
438
+ continue;
439
+ const fix = fixTask(kind, command, result);
440
+ setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: fix }]);
441
+ await runAgent(fix, { planMode: false });
442
+ return;
443
+ }
444
+ };
367
445
  try {
368
446
  if (name === 'model' || name === 'weak-model') {
369
447
  const weak = name === 'weak-model';
@@ -394,6 +472,174 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
394
472
  say(formatModels(aliases, args));
395
473
  return;
396
474
  }
475
+ if (name === 'reasoning-effort' || name === 'think-tokens') {
476
+ if (!args) {
477
+ say(`reasoning effort: ${model.reasoningEffort ?? 'default (the provider decides)'}\n` +
478
+ (name === 'reasoning-effort' ? 'usage: /reasoning-effort <none|minimal|low|medium|high|xhigh|default>' : 'usage: /think-tokens <tokens, e.g. 8k | 0 | default>'));
479
+ return;
480
+ }
481
+ const change = name === 'reasoning-effort' ? parseReasoningEffort(args) : parseThinkTokens(args);
482
+ if (!change.ok) {
483
+ say(change.error, true);
484
+ return;
485
+ }
486
+ const { reasoningEffort: _dropped, ...rest } = model;
487
+ const next = change.effort ? { ...rest, reasoningEffort: change.effort } : rest;
488
+ setModel(next);
489
+ agentConfigRef.current = {
490
+ ...agentConfigRef.current,
491
+ model: next,
492
+ ...(weakModel ? { modelRouter: createExploreEditRouter({ strong: next, cheap: weakModel }) } : {}),
493
+ };
494
+ const note = 'note' in change && typeof change.note === 'string' ? change.note : undefined;
495
+ say(note ?? `reasoning effort → ${change.effort ?? 'default (the provider decides)'}`);
496
+ return;
497
+ }
498
+ if (name === 'editor-model') {
499
+ if (!args) {
500
+ say(`editor model: ${editorModel ? describeModel(editorModel) : '(none) set one with /editor-model <model>, then use /architect <task>'}`);
501
+ return;
502
+ }
503
+ if (args.toLowerCase() === 'off') {
504
+ setEditorModel(undefined);
505
+ say('editor model cleared');
506
+ return;
507
+ }
508
+ const res = switchModel(args, model, aliases);
509
+ if (!res.ok) {
510
+ say(res.error, true);
511
+ return;
512
+ }
513
+ setEditorModel(res.model);
514
+ say(`editor model → ${describeModel(res.model)} (applies plans made with /architect)`);
515
+ return;
516
+ }
517
+ if (name === 'architect') {
518
+ if (!editorModel) {
519
+ say('set the model that applies the plan first: /editor-model <model>', true);
520
+ return;
521
+ }
522
+ if (!args) {
523
+ say('usage: /architect <task> (the main model plans, you approve, then the editor model applies it)', true);
524
+ return;
525
+ }
526
+ architectEditorRef.current = editorModel;
527
+ setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: `/architect ${args}` }]);
528
+ await runAgent(args, { planMode: true });
529
+ return;
530
+ }
531
+ if (name === 'load') {
532
+ if (!args) {
533
+ say('usage: /load <file> (one command or message per line; blank lines and # comments are skipped)', true);
534
+ return;
535
+ }
536
+ if (loadingRef.current) {
537
+ say('a script cannot /load another script', true);
538
+ return;
539
+ }
540
+ const file = path.resolve(cwd, args);
541
+ let text;
542
+ try {
543
+ text = await readFile(file, 'utf8');
544
+ }
545
+ catch {
546
+ say(`cannot read ${args}`, true);
547
+ return;
548
+ }
549
+ const lines = scriptLines(text);
550
+ if (lines.length === 0) {
551
+ say(`${args} has no commands`, true);
552
+ return;
553
+ }
554
+ if (lines.length > 500) {
555
+ say(`${args} has ${lines.length} lines; scripts are limited to 500`, true);
556
+ return;
557
+ }
558
+ loadingRef.current = true;
559
+ try {
560
+ for (const line of lines) {
561
+ say(`> ${line}`);
562
+ await submitRef.current?.(line);
563
+ }
564
+ }
565
+ finally {
566
+ loadingRef.current = false;
567
+ }
568
+ return;
569
+ }
570
+ if (name === 'save-script') {
571
+ if (!args) {
572
+ say('usage: /save-script <file> (writes the model and attached files as commands for /load)', true);
573
+ return;
574
+ }
575
+ const target = path.resolve(cwd, args);
576
+ const rel = path.relative(cwd, target);
577
+ if (rel.startsWith('..') || path.isAbsolute(rel) || rel === '') {
578
+ say('the script must be inside the working directory', true);
579
+ return;
580
+ }
581
+ const script = formatScript({ model, weakModel, editorModel, files: chatFilesRef.current.list() });
582
+ await writeFile(target, script, 'utf8');
583
+ say(`wrote ${scriptLines(script).length} commands to ${rel.split(path.sep).join('/')}`);
584
+ return;
585
+ }
586
+ if (name === 'help') {
587
+ // /help <question>: answered from the command reference; read-only tools only.
588
+ const reference = `${replHelpText(currentSessionId)}\n\nCommand-line options:\n${buildProgram().helpInformation()}`;
589
+ const question = helpQuestionTask(args, reference);
590
+ setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: `/help ${args}` }]);
591
+ await runAgent(question, { planMode: false, readOnlyTools: true });
592
+ return;
593
+ }
594
+ if (name === 'copy' || name === 'copy-context') {
595
+ const text = name === 'copy' ? lastAssistantText(historyRef.current) : conversationMarkdown(historyRef.current);
596
+ if (!text) {
597
+ say(name === 'copy' ? 'nothing to copy yet: no reply from the model' : 'nothing to copy yet: the conversation is empty', true);
598
+ return;
599
+ }
600
+ const res = await (services?.copy ?? copyToClipboard)(text);
601
+ say(res.ok ? `copied ${text.length.toLocaleString('en-US')} characters to the clipboard (${res.tool})` : `copy failed: ${res.error}`, !res.ok);
602
+ return;
603
+ }
604
+ if (name === 'paste') {
605
+ const res = await (services?.read ?? readClipboard)();
606
+ if (!res.ok) {
607
+ say(`paste failed: ${res.error}`, true);
608
+ return;
609
+ }
610
+ if (!res.text.trim()) {
611
+ say('the clipboard is empty', true);
612
+ return;
613
+ }
614
+ pendingNotesRef.current.push(`Pasted from the clipboard:\n\`\`\`\n${redact(res.text.trimEnd()).text}\n\`\`\``);
615
+ say(`pasted ${res.text.length.toLocaleString('en-US')} characters (sent with your next message)`);
616
+ return;
617
+ }
618
+ if (name === 'web') {
619
+ if (!args) {
620
+ say('usage: /web <url>', true);
621
+ return;
622
+ }
623
+ say(`fetching ${args} ...`);
624
+ const page = await (services?.fetchPage ?? defaultFetchPage)(args, cwd);
625
+ pendingNotesRef.current.push(`Web page ${page.url}${page.title ? ` ("${page.title}")` : ''}${page.truncated ? ' (truncated)' : ''}:\n\`\`\`\n${page.excerpt}\n\`\`\``);
626
+ say(`fetched ${page.url} (HTTP ${page.status}, ${page.excerpt.length.toLocaleString('en-US')} characters${page.truncated ? ', truncated' : ''}); sent with your next message`);
627
+ return;
628
+ }
629
+ if (name === 'editor' || name === 'edit') {
630
+ const res = await editInTerminal(args);
631
+ if (!res.ok) {
632
+ say(`/${name}: ${res.error}`, true);
633
+ return;
634
+ }
635
+ await chatTurn(res.text, res.text);
636
+ return;
637
+ }
638
+ if (name === 'report') {
639
+ const url = reportUrl({ version, platform: `${process.platform} ${process.arch}`, node: process.version, model, sessionId: currentSessionId });
640
+ say(`Open this link to file an issue. It is prefilled with diagnostics only (version, platform, model): no keys, paths or prompts.\n${url}`);
641
+ return;
642
+ }
397
643
  if (name === 'ls') {
398
644
  const { editable, readOnly } = chatFilesRef.current.list();
399
645
  say(editable.length + readOnly.length === 0
@@ -454,6 +700,10 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
454
700
  await runAgent(OK_TASK, { planMode: false });
455
701
  return;
456
702
  }
703
+ if (name === 'lint' && !args && lintCmds.length > 0) {
704
+ await runChecks('lint', lintCmds);
705
+ return;
706
+ }
457
707
  if (name === 'lint') {
458
708
  const aut = agentConfigRef.current.autoLint ?? { syntax: true };
459
709
  const named = args ? args.split(/\s+/).filter(Boolean) : [];
@@ -486,35 +736,26 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
486
736
  await runAgent(fix, { planMode: false });
487
737
  return;
488
738
  }
489
- // /run and /test
490
- const command = name === 'test' && !args ? agentConfigRef.current.verify?.commands[0] ?? '' : args;
491
- if (!command) {
492
- say(name === 'test' ? 'usage: /test <command> (or start with --verify <command>)' : 'usage: /run <command> (or !<command>)', true);
739
+ // /run runs one command; /test with no argument runs every --test-cmd.
740
+ if (name === 'test' && !args) {
741
+ if (testCmds.length === 0) {
742
+ say('usage: /test <command> (or start forge with --test-cmd <command>)', true);
743
+ return;
744
+ }
745
+ await runChecks('test', testCmds);
493
746
  return;
494
747
  }
495
- say(`$ ${command}`);
496
- setRunning(true);
497
- setStatus('running');
498
- let result;
499
- try {
500
- result = await runShell(command, cwd);
501
- }
502
- finally {
503
- setRunning(false);
504
- setStatus('idle');
505
- }
506
- const tail = result.output.trim().split('\n').slice(-40).join('\n');
507
- say(`${tail || '(no output)'}\n[${result.timedOut ? 'timed out' : `exit ${result.exitCode ?? 'none'}`}]`, result.exitCode !== 0);
508
- if (name === 'run') {
509
- pendingNotesRef.current.push(runContextNote(command, result));
510
- say('(output will be sent to the model with your next message)');
748
+ if (!args) {
749
+ say(name === 'test' ? 'usage: /test <command> (or start forge with --test-cmd <command>)' : 'usage: /run <command> (or !<command>)', true);
511
750
  return;
512
751
  }
513
- if (result.exitCode === 0)
752
+ if (name === 'test') {
753
+ await runChecks('test', [args]);
514
754
  return;
515
- const fix = fixTask('test', command, result);
516
- setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: fix }]);
517
- await runAgent(fix, { planMode: false });
755
+ }
756
+ const result = await runShown(args);
757
+ pendingNotesRef.current.push(runContextNote(args, result));
758
+ say('(output will be sent to the model with your next message)');
518
759
  }
519
760
  catch (err) {
520
761
  setRunning(false);
@@ -538,17 +779,6 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
538
779
  }
539
780
  return;
540
781
  }
541
- if (task === '/tokens') {
542
- setMessages((m) => [
543
- ...m,
544
- {
545
- kind: 'status',
546
- id: cryptoId(),
547
- text: `tokens this session: ${inputTokens} in, ${outputTokens} out${costUsd !== undefined ? `, $${costUsd.toFixed(4)}` : ''}`,
548
- },
549
- ]);
550
- return;
551
- }
552
782
  if (task === '/commit' || task === '/undo' || task === '/diff') {
553
783
  const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
554
784
  const cwd = agentConfigRef.current.cwd ?? process.cwd();
@@ -583,9 +813,57 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
583
813
  }
584
814
  return;
585
815
  }
586
- const userMessageId = cryptoId();
587
- setMessages((m) => [...m, { kind: 'user', id: userMessageId, text: task }]);
588
- await runAgent(task, { planMode: false });
816
+ const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
817
+ if (task === '/retry' || task.startsWith('/retry ') || task === '/edit-last') {
818
+ const last = lastTurnRef.current;
819
+ if (!last) {
820
+ say('nothing to retry yet: send a message first', true);
821
+ return;
822
+ }
823
+ let next = task.replace(/^\/retry\s*/, '').trim() || last.task;
824
+ if (task === '/edit-last') {
825
+ const res = await editInTerminal(last.task);
826
+ if (!res.ok) {
827
+ say(`/edit-last: ${res.error}`, true);
828
+ return;
829
+ }
830
+ next = res.text;
831
+ }
832
+ // Drop the last turn from the conversation and run the message again in its place.
833
+ historyRef.current = last.historyBefore;
834
+ say(`retrying${next !== last.task ? ' with your edit' : ''}: the last turn is dropped from the conversation (files it changed stay changed; /undo or /rewind reverts them)`);
835
+ await chatTurn(next, next);
836
+ return;
837
+ }
838
+ if (task === '/sessions' || task.startsWith('/sessions ')) {
839
+ const query = task.replace(/^\/sessions\s*/, '').trim();
840
+ try {
841
+ if (!query) {
842
+ const recent = (await listSessions(sessionDir)).slice(0, 10);
843
+ say(recent.length === 0
844
+ ? 'no saved sessions yet'
845
+ : `recent sessions (resume with forge --resume <id>):\n${recent.map((e) => ` ${e.id}${e.label ? ` [${e.label}]` : ''} ${e.updatedAt.slice(0, 16).replace('T', ' ')} ${e.preview ?? ''}`).join('\n')}`);
846
+ return;
847
+ }
848
+ const found = await searchSessions(sessionDir, query, { limit: 10 });
849
+ say(found.length === 0
850
+ ? `no session mentions "${query}"`
851
+ : `sessions mentioning "${query}" (resume with forge --resume <id>):\n${found.map((f) => ` ${f.entry.id}${f.entry.label ? ` [${f.entry.label}]` : ''} ${f.entry.updatedAt.slice(0, 10)} ${f.snippet}`).join('\n')}`);
852
+ }
853
+ catch (err) {
854
+ say(`/sessions: ${err.message}`, true);
855
+ }
856
+ return;
857
+ }
858
+ const customSlash = parseSlashCommand(task);
859
+ if (customSlash && !task.startsWith('!')) {
860
+ const custom = (await loadCustomCommands(agentConfigRef.current.cwd ?? process.cwd()).catch(() => new Map())).get(customSlash.name);
861
+ if (custom) {
862
+ await chatTurn(task, expandCustomCommand(custom, customSlash.args));
863
+ return;
864
+ }
865
+ }
866
+ await chatTurn(task, task);
589
867
  }, [
590
868
  running,
591
869
  onExit,
@@ -597,13 +875,63 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
597
875
  sessionDir,
598
876
  costUsd,
599
877
  pendingPlan,
878
+ pendingPlanTask,
600
879
  pendingSpec,
880
+ pendingSpecTask,
881
+ // Everything the handler reads from props or state: a value missing here is stale after it changes.
882
+ weakModel,
883
+ editorModel,
884
+ aliases,
885
+ services,
886
+ version,
887
+ lintCmds,
888
+ testCmds,
889
+ gitWorkflow,
890
+ gitOptions,
891
+ commitModel,
601
892
  ]);
893
+ submitRef.current = handleSubmit;
894
+ /** One chat message: shown as `shown`, sent as `task`, and remembered for /retry. */
895
+ async function chatTurn(shown, task) {
896
+ lastTurnRef.current = { task, historyBefore: historyRef.current };
897
+ setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: shown }]);
898
+ await runAgent(task, { planMode: false });
899
+ }
900
+ /**
901
+ * What Enter does: handle the message now, or queue it while an earlier one is still being
902
+ * handled (a turn, a /test run, a script). Queued messages then run one by one, in order.
903
+ */
904
+ const submitFromUser = (raw) => {
905
+ const task = raw.trim();
906
+ if (!task)
907
+ return;
908
+ if (inFlightRef.current) {
909
+ queueRef.current.push(task);
910
+ setInput('');
911
+ setMessages((m) => [
912
+ ...m,
913
+ { kind: 'status', id: cryptoId(), text: `queued (${queueRef.current.length} waiting): ${task.length > 60 ? `${task.slice(0, 57)}...` : task}` },
914
+ ]);
915
+ return;
916
+ }
917
+ inFlightRef.current = true;
918
+ void (async () => {
919
+ try {
920
+ await submitRef.current?.(task);
921
+ }
922
+ finally {
923
+ inFlightRef.current = false;
924
+ const next = queueRef.current.shift();
925
+ if (next !== undefined)
926
+ submitFromUser(next);
927
+ }
928
+ })();
929
+ };
602
930
  /**
603
931
  * Run the agent for a single turn. `options.planMode: true` makes the
604
932
  * agent emit a plan instead of executing; the returned finalText holds
605
- * the plan. `options.planContext` prepends an `<approved-plan>` block
606
- * to the system prompt so the agent knows what to do.
933
+ * the plan. `options.planContext` puts an `<approved-plan>` block at the
934
+ * start of this turn's message so the agent knows what to do.
607
935
  *
608
936
  * Declared as a regular function (not `useCallback`) so it can be
609
937
  * referenced by `handleSubmit` below: function declarations are hoisted
@@ -616,19 +944,15 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
616
944
  abortControllerRef.current = controller;
617
945
  const approvals = new Map();
618
946
  const overrides = {
947
+ // An /architect plan is applied by the editor model; its cost router must not route back to the planner.
948
+ ...(options.modelOverride ? { model: options.modelOverride, modelRouter: undefined } : {}),
619
949
  planMode: options.planMode,
620
950
  specMode: options.specMode === true,
621
- ...(options.planContext
622
- ? {
623
- systemPrompt: (agentConfigRef.current.systemPrompt ?? '') +
624
- `\n\n<approved-plan>\n${options.planContext}\n</approved-plan>\nFollow the plan above. You may deviate if you find an issue, but explain why in your final summary.`,
625
- }
626
- : {}),
627
951
  // Spec mode always gets exactly the six read-only tools: a hard
628
952
  // override of whatever toolSandbox the user configured via
629
953
  // --allow-tools/--deny-tools, matching options.ts's toAgentConfig
630
954
  // wiring for one-shot `--spec`.
631
- ...(options.specMode === true ? { toolSandbox: { allowTools: [...SPEC_READONLY_TOOLS] } } : {}),
955
+ ...(options.specMode === true || options.readOnlyTools === true ? { toolSandbox: { allowTools: [...SPEC_READONLY_TOOLS] } } : {}),
632
956
  };
633
957
  const localConfig = {
634
958
  ...agentConfigRef.current,
@@ -644,7 +968,12 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
644
968
  const attached = await chatFilesRef.current.render();
645
969
  for (const note of attached.notes)
646
970
  setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: note }]);
647
- const turnContext = [...pendingNotesRef.current.splice(0), attached.block].filter((part) => part.length > 0).join('\n\n');
971
+ // The approved plan rides in this turn's message: a system-prompt override is ignored once the
972
+ // conversation has history (and it always has, right after the plan run), which also dropped edited plans.
973
+ const planBlock = options.planContext
974
+ ? `<approved-plan>\n${options.planContext}\n</approved-plan>\nFollow the plan above. You may deviate if you find an issue, but explain why in your final summary.`
975
+ : '';
976
+ const turnContext = [planBlock, ...pendingNotesRef.current.splice(0), attached.block].filter((part) => part.length > 0).join('\n\n');
648
977
  if (localConfig.repoMap && chatFilesRef.current.size > 0) {
649
978
  localConfig.repoMap = { ...localConfig.repoMap, focusFiles: chatFilesRef.current.focusFiles() };
650
979
  }
@@ -661,6 +990,7 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
661
990
  agentRunResult = await agent.run({
662
991
  task,
663
992
  ...(turnContext ? { context: turnContext } : {}),
993
+ ...(attached.attachments.length > 0 ? { attachments: attached.attachments } : {}),
664
994
  history: historyRef.current,
665
995
  abortSignal: controller.signal,
666
996
  onEvent: (event) => {
@@ -907,9 +1237,8 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
907
1237
  }
908
1238
  if (agentRunResult) {
909
1239
  // The attached files were for this turn; keep them out of the stored history.
910
- historyRef.current = attached.block
911
- ? agentRunResult.messages.map((m) => (m.role === 'user' && typeof m.content === 'string' ? { ...m, content: stripChatFiles(m.content) } : m))
912
- : agentRunResult.messages;
1240
+ historyRef.current =
1241
+ attached.block || attached.attachments.length > 0 ? agentRunResult.messages.map(stripAttachedFiles) : agentRunResult.messages;
913
1242
  if (historyRef.current.length > 0 && !options.planMode && !options.specMode) {
914
1243
  const savedPath = await persistSession({
915
1244
  id: currentSessionId,
@@ -940,17 +1269,18 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
940
1269
  }
941
1270
  /** Run the agent in plan mode for a task. */
942
1271
  function runInPlanMode(planTask) {
1272
+ architectEditorRef.current = null; // a plain /plan is applied by the main model
943
1273
  setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: `/plan ${planTask}` }]);
944
1274
  return runAgent(planTask, { planMode: true });
945
1275
  }
946
1276
  /** Run a normal (non-plan) agent turn with a previously-approved plan injected. */
947
- function runWithPlan(realTask, plan) {
1277
+ function runWithPlan(realTask, plan, applyWith) {
948
1278
  setMessages((m) => [
949
1279
  ...m,
950
- { kind: 'status', id: cryptoId(), text: '✓ plan approved: executing…' },
1280
+ { kind: 'status', id: cryptoId(), text: applyWith ? `✓ plan approved: ${describeModel(applyWith)} is applying it…` : '✓ plan approved: executing…' },
951
1281
  { kind: 'user', id: cryptoId(), text: realTask },
952
1282
  ]);
953
- return runAgent(realTask, { planMode: false, planContext: plan });
1283
+ return runAgent(realTask, { planMode: false, planContext: plan, ...(applyWith ? { modelOverride: applyWith } : {}) });
954
1284
  }
955
1285
  /**
956
1286
  * Run the agent in spec mode for a task. Unlike `runWithPlan`, there is
@@ -1043,9 +1373,12 @@ export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initi
1043
1373
  pendingApproval && (React.createElement(Approval, { request: pendingApproval.request, onResolve: handleApproval }))),
1044
1374
  React.createElement(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, flexDirection: "row" },
1045
1375
  React.createElement(Text, { color: "cyan", bold: true }, "forge \u276F"),
1046
- React.createElement(Box, { marginLeft: 1, flexGrow: 1 }, running ? (React.createElement(Box, null,
1376
+ React.createElement(Box, { marginLeft: 1, flexGrow: 1 }, pendingApproval ? (React.createElement(Box, null,
1377
+ React.createElement(Spinner, null),
1378
+ React.createElement(Text, { color: "gray" }, " waiting for your answer above"))) : running ? (React.createElement(Box, null,
1047
1379
  React.createElement(Spinner, null),
1048
- React.createElement(Text, { color: "gray" }, " running\u2026 (esc to cancel)"))) : (React.createElement(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "ask forge anything\u2026" })))),
1380
+ React.createElement(Text, { color: "gray" }, " running\u2026 (esc to cancel) "),
1381
+ React.createElement(TextInput, { value: input, onChange: setInput, onSubmit: submitFromUser, placeholder: "type to queue a message" }))) : (React.createElement(TextInput, { value: input, onChange: setInput, onSubmit: submitFromUser, placeholder: "ask forge anything\u2026" })))),
1049
1382
  React.createElement(Box, { marginTop: 1 },
1050
1383
  React.createElement(Text, { color: "gray" },
1051
1384
  "\u2191",