ikarmic-forge 0.0.0-stage → 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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/dist/acp/server.d.ts +55 -0
  4. package/dist/acp/server.d.ts.map +1 -0
  5. package/dist/acp/server.js +276 -0
  6. package/dist/acp/server.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +159 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/bin/forge.d.ts +3 -0
  12. package/dist/bin/forge.d.ts.map +1 -0
  13. package/dist/bin/forge.js +786 -0
  14. package/dist/bin/forge.js.map +1 -0
  15. package/dist/chat-files.d.ts +45 -0
  16. package/dist/chat-files.d.ts.map +1 -0
  17. package/dist/chat-files.js +189 -0
  18. package/dist/chat-files.js.map +1 -0
  19. package/dist/clipboard.d.ts +38 -0
  20. package/dist/clipboard.d.ts.map +1 -0
  21. package/dist/clipboard.js +63 -0
  22. package/dist/clipboard.js.map +1 -0
  23. package/dist/commands/acp.d.ts +4 -0
  24. package/dist/commands/acp.d.ts.map +1 -0
  25. package/dist/commands/acp.js +36 -0
  26. package/dist/commands/acp.js.map +1 -0
  27. package/dist/commands/ai-comments.d.ts +9 -0
  28. package/dist/commands/ai-comments.d.ts.map +1 -0
  29. package/dist/commands/ai-comments.js +43 -0
  30. package/dist/commands/ai-comments.js.map +1 -0
  31. package/dist/commands/architect.d.ts +9 -0
  32. package/dist/commands/architect.d.ts.map +1 -0
  33. package/dist/commands/architect.js +27 -0
  34. package/dist/commands/architect.js.map +1 -0
  35. package/dist/commands/checkpoint.d.ts +10 -0
  36. package/dist/commands/checkpoint.d.ts.map +1 -0
  37. package/dist/commands/checkpoint.js +168 -0
  38. package/dist/commands/checkpoint.js.map +1 -0
  39. package/dist/commands/config.d.ts +15 -0
  40. package/dist/commands/config.d.ts.map +1 -0
  41. package/dist/commands/config.js +180 -0
  42. package/dist/commands/config.js.map +1 -0
  43. package/dist/commands/diff.d.ts +29 -0
  44. package/dist/commands/diff.d.ts.map +1 -0
  45. package/dist/commands/diff.js +90 -0
  46. package/dist/commands/diff.js.map +1 -0
  47. package/dist/commands/hook.d.ts +10 -0
  48. package/dist/commands/hook.d.ts.map +1 -0
  49. package/dist/commands/hook.js +121 -0
  50. package/dist/commands/hook.js.map +1 -0
  51. package/dist/commands/mcp.d.ts +10 -0
  52. package/dist/commands/mcp.d.ts.map +1 -0
  53. package/dist/commands/mcp.js +110 -0
  54. package/dist/commands/mcp.js.map +1 -0
  55. package/dist/commands/memory.d.ts +10 -0
  56. package/dist/commands/memory.d.ts.map +1 -0
  57. package/dist/commands/memory.js +77 -0
  58. package/dist/commands/memory.js.map +1 -0
  59. package/dist/commands/orchestrator.d.ts +22 -0
  60. package/dist/commands/orchestrator.d.ts.map +1 -0
  61. package/dist/commands/orchestrator.js +113 -0
  62. package/dist/commands/orchestrator.js.map +1 -0
  63. package/dist/commands/plugin.d.ts +3 -0
  64. package/dist/commands/plugin.d.ts.map +1 -0
  65. package/dist/commands/plugin.js +62 -0
  66. package/dist/commands/plugin.js.map +1 -0
  67. package/dist/commands/policy.d.ts +2 -0
  68. package/dist/commands/policy.d.ts.map +1 -0
  69. package/dist/commands/policy.js +21 -0
  70. package/dist/commands/policy.js.map +1 -0
  71. package/dist/commands/skill.d.ts +13 -0
  72. package/dist/commands/skill.d.ts.map +1 -0
  73. package/dist/commands/skill.js +194 -0
  74. package/dist/commands/skill.js.map +1 -0
  75. package/dist/commands/spec.d.ts +23 -0
  76. package/dist/commands/spec.d.ts.map +1 -0
  77. package/dist/commands/spec.js +147 -0
  78. package/dist/commands/spec.js.map +1 -0
  79. package/dist/commands/watch.d.ts +15 -0
  80. package/dist/commands/watch.d.ts.map +1 -0
  81. package/dist/commands/watch.js +83 -0
  82. package/dist/commands/watch.js.map +1 -0
  83. package/dist/commands/worktree.d.ts +22 -0
  84. package/dist/commands/worktree.d.ts.map +1 -0
  85. package/dist/commands/worktree.js +187 -0
  86. package/dist/commands/worktree.js.map +1 -0
  87. package/dist/components/Approval.d.ts +9 -0
  88. package/dist/components/Approval.d.ts.map +1 -0
  89. package/dist/components/Approval.js +39 -0
  90. package/dist/components/Approval.js.map +1 -0
  91. package/dist/components/Footer.d.ts +22 -0
  92. package/dist/components/Footer.d.ts.map +1 -0
  93. package/dist/components/Footer.js +59 -0
  94. package/dist/components/Footer.js.map +1 -0
  95. package/dist/components/Header.d.ts +11 -0
  96. package/dist/components/Header.d.ts.map +1 -0
  97. package/dist/components/Header.js +23 -0
  98. package/dist/components/Header.js.map +1 -0
  99. package/dist/components/Spinner.d.ts +3 -0
  100. package/dist/components/Spinner.d.ts.map +1 -0
  101. package/dist/components/Spinner.js +8 -0
  102. package/dist/components/Spinner.js.map +1 -0
  103. package/dist/components/Stream.d.ts +10 -0
  104. package/dist/components/Stream.d.ts.map +1 -0
  105. package/dist/components/Stream.js +13 -0
  106. package/dist/components/Stream.js.map +1 -0
  107. package/dist/components/ToolEvent.d.ts +13 -0
  108. package/dist/components/ToolEvent.d.ts.map +1 -0
  109. package/dist/components/ToolEvent.js +54 -0
  110. package/dist/components/ToolEvent.js.map +1 -0
  111. package/dist/credential-store.d.ts +8 -0
  112. package/dist/credential-store.d.ts.map +1 -0
  113. package/dist/credential-store.js +37 -0
  114. package/dist/credential-store.js.map +1 -0
  115. package/dist/git-workflow.d.ts +16 -0
  116. package/dist/git-workflow.d.ts.map +1 -0
  117. package/dist/git-workflow.js +38 -0
  118. package/dist/git-workflow.js.map +1 -0
  119. package/dist/mcp-loader.d.ts +25 -0
  120. package/dist/mcp-loader.d.ts.map +1 -0
  121. package/dist/mcp-loader.js +99 -0
  122. package/dist/mcp-loader.js.map +1 -0
  123. package/dist/notify.d.ts +12 -0
  124. package/dist/notify.d.ts.map +1 -0
  125. package/dist/notify.js +47 -0
  126. package/dist/notify.js.map +1 -0
  127. package/dist/options.d.ts +449 -0
  128. package/dist/options.d.ts.map +1 -0
  129. package/dist/options.js +871 -0
  130. package/dist/options.js.map +1 -0
  131. package/dist/repl-commands.d.ts +92 -0
  132. package/dist/repl-commands.d.ts.map +1 -0
  133. package/dist/repl-commands.js +228 -0
  134. package/dist/repl-commands.js.map +1 -0
  135. package/dist/repl-extras.d.ts +39 -0
  136. package/dist/repl-extras.d.ts.map +1 -0
  137. package/dist/repl-extras.js +120 -0
  138. package/dist/repl-extras.js.map +1 -0
  139. package/dist/repl.d.ts +76 -0
  140. package/dist/repl.d.ts.map +1 -0
  141. package/dist/repl.js +1507 -0
  142. package/dist/repl.js.map +1 -0
  143. package/dist/runner.d.ts +64 -0
  144. package/dist/runner.d.ts.map +1 -0
  145. package/dist/runner.js +61 -0
  146. package/dist/runner.js.map +1 -0
  147. package/dist/session-checkpoints.d.ts +6 -0
  148. package/dist/session-checkpoints.d.ts.map +1 -0
  149. package/dist/session-checkpoints.js +25 -0
  150. package/dist/session-checkpoints.js.map +1 -0
  151. package/dist/specialists-loader.d.ts +22 -0
  152. package/dist/specialists-loader.d.ts.map +1 -0
  153. package/dist/specialists-loader.js +38 -0
  154. package/dist/specialists-loader.js.map +1 -0
  155. package/dist/tool-display.d.ts +19 -0
  156. package/dist/tool-display.d.ts.map +1 -0
  157. package/dist/tool-display.js +57 -0
  158. package/dist/tool-display.js.map +1 -0
  159. package/package.json +70 -3
@@ -0,0 +1,786 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import process from 'node:process';
4
+ import { isatty } from 'node:tty';
5
+ import React from 'react';
6
+ import { render } from 'ink';
7
+ import { Agent, SPEC_READONLY_TOOLS, architectPhaseConfig, defaultSessionDir, editorLimits, editorPhaseConfig, editorTask, formatCost, isUsableArchitectPlan, resolveModelAlias, runArchitect, findLatestSession, formatAliasTable, generateSessionId, loadForgeConfigFile, loadSchemaFile, loadSession, WorktreeManager, } from '@ikarmic/forge-core';
8
+ import { App } from '../app.js';
9
+ import { loadMcpTools } from '../mcp-loader.js';
10
+ import { applyModelAliases, parseOptions, toAgentConfig } from '../options.js';
11
+ import { Repl } from '../repl.js';
12
+ import { isStructuredOutputError, startAgent, startStructuredAgent } from '../runner.js';
13
+ import { handlePluginCommand } from '../commands/plugin.js';
14
+ import { handleSkillCommand } from '../commands/skill.js';
15
+ import { handleHookCommand } from '../commands/hook.js';
16
+ import { handleMemoryCommand } from '../commands/memory.js';
17
+ import { handleMcpCommand } from '../commands/mcp.js';
18
+ import { handleCheckpointCommand } from '../commands/checkpoint.js';
19
+ import { handleSpecCommand } from '../commands/spec.js';
20
+ import { handleWatchCommand } from '../commands/watch.js';
21
+ import { handleDiffCommand } from '../commands/diff.js';
22
+ import { handleOrchestratorCommand } from '../commands/orchestrator.js';
23
+ import { handleWorktreeCommand } from '../commands/worktree.js';
24
+ import { loadCustomSpecialists } from '../specialists-loader.js';
25
+ import { confirmPlan } from '../commands/architect.js';
26
+ import { createNotifier } from '../notify.js';
27
+ import { createGitWorkflow, formatCommitLine, resolveCommitModel } from '../git-workflow.js';
28
+ const require = createRequire(import.meta.url);
29
+ async function main() {
30
+ let parsed;
31
+ try {
32
+ parsed = parseOptions(process.argv);
33
+ }
34
+ catch (err) {
35
+ process.stderr.write(`forge: ${err.message}\n`);
36
+ process.exit(2);
37
+ }
38
+ const { options, resolveModel, listModels, sessionCommand, skillCommand, pluginCommand, hookCommand, memoryCommand, mcpCommand, checkpointCommand, specCommand, watchCommand, diffCommand, orchestratorCommand, worktreeCommand, configCommand, acpCommand, policyCommand, version } = parsed;
39
+ // Model aliases (`~/.forge/config.json` "aliases") rewrite --model before any
40
+ // command resolves it; resolveModel() reads the same mutated options object.
41
+ const { config: startupConfig, warnings: configWarnings } = await loadForgeConfigFile();
42
+ for (const w of configWarnings)
43
+ process.stderr.write(`forge: ${w}\n`);
44
+ try {
45
+ applyModelAliases(options, startupConfig.aliases);
46
+ }
47
+ catch (err) {
48
+ process.stderr.write(`forge: ${err.message}\n`);
49
+ process.exit(2);
50
+ }
51
+ // Bootstrap custom specialists from ~/.forge/specialists.json so
52
+ // they're available to every orchestrator command without each
53
+ // command knowing about the file. Silently no-ops when the file is
54
+ // missing; logs warnings + errors to stderr when it's malformed.
55
+ // Done early so `forge orchestrator list` can show custom entries.
56
+ loadCustomSpecialists();
57
+ // v2.11: auto-prune worktree leftovers from prior crashed sessions.
58
+ // Best-effort: if anything goes wrong (not a git repo, etc.) we
59
+ // silently skip. The user sees a one-line summary in stderr when
60
+ // anything was actually cleaned up.
61
+ await pruneWorktreesOnStartup(options.cwd ?? process.cwd());
62
+ if (listModels) {
63
+ try {
64
+ const model = resolveModel();
65
+ process.stdout.write(`${model.provider}/${model.model}\n`);
66
+ }
67
+ catch (err) {
68
+ process.stderr.write(`forge: ${err.message}\n`);
69
+ process.exit(2);
70
+ }
71
+ return;
72
+ }
73
+ // Handle the session subcommand (list / show / resume / delete) before
74
+ // anything else: it doesn't need an AI provider configured.
75
+ const resumeFlag = sessionCommand?.resume ?? null;
76
+ if (sessionCommand) {
77
+ await handleSessionCommand(sessionCommand);
78
+ return;
79
+ }
80
+ // Skill subcommand: `forge skill list|show|install|browse`.
81
+ if (skillCommand) {
82
+ const code = await handleSkillCommand(skillCommand, options.cwd ?? process.cwd());
83
+ process.exit(code);
84
+ }
85
+ // Plugin subcommand: `forge plugin list|show|install|remove`.
86
+ if (pluginCommand) {
87
+ const code = await handlePluginCommand(pluginCommand, options.cwd ?? process.cwd());
88
+ process.exit(code);
89
+ }
90
+ // Hook subcommand: `forge hook list|test|log`.
91
+ if (hookCommand) {
92
+ const code = await handleHookCommand(hookCommand, options.cwd ?? process.cwd());
93
+ process.exit(code);
94
+ }
95
+ // Memory subcommand: `forge memory list|check|forget`.
96
+ if (memoryCommand) {
97
+ const code = await handleMemoryCommand(memoryCommand, options.cwd ?? process.cwd());
98
+ process.exit(code);
99
+ }
100
+ // MCP subcommand: `forge mcp list|install|remove|browse`.
101
+ if (mcpCommand) {
102
+ const code = await handleMcpCommand(mcpCommand, options.cwd ?? process.cwd());
103
+ process.exit(code);
104
+ }
105
+ // Organization policy + corporate proxy. The policy file is enforced by every Agent, so a
106
+ // broken one must stop the run here with a clear message rather than fail open.
107
+ try {
108
+ const { getOrgPolicy, installProxy, proxyFromEnv } = await import('@ikarmic/forge-core');
109
+ const policy = getOrgPolicy()?.policy;
110
+ await installProxy({ ...proxyFromEnv(), ...(policy?.proxy ?? {}) });
111
+ }
112
+ catch (err) {
113
+ if (!policyCommand) {
114
+ process.stderr.write(`forge: ${err.message}\n`);
115
+ process.exit(2);
116
+ }
117
+ }
118
+ // `forge policy` shows the active organization policy.
119
+ if (policyCommand) {
120
+ const { handlePolicyCommand } = await import('../commands/policy.js');
121
+ process.exit(handlePolicyCommand());
122
+ }
123
+ // `forge acp` speaks the Agent Client Protocol on stdio (JetBrains AI Chat, Zed, ...).
124
+ if (acpCommand) {
125
+ const { handleAcpCommand } = await import('../commands/acp.js');
126
+ process.exit(await handleAcpCommand(options, resolveModel, version));
127
+ }
128
+ // Config subcommand: `forge config` (interactive settings wizard).
129
+ if (configCommand) {
130
+ const { createKeyringCredentialStore } = await import('../credential-store.js');
131
+ const { runConfigWizard } = await import('../commands/config.js');
132
+ const aliasTable = formatAliasTable(startupConfig.aliases);
133
+ if (aliasTable)
134
+ process.stdout.write(`${aliasTable}\n\n`);
135
+ await runConfigWizard({ credentialStore: createKeyringCredentialStore() });
136
+ process.exit(0);
137
+ }
138
+ // Checkpoint subcommand: `forge checkpoint list|show|restore|prune`.
139
+ if (checkpointCommand) {
140
+ const code = await handleCheckpointCommand(checkpointCommand, options.cwd ?? process.cwd());
141
+ process.exit(code);
142
+ }
143
+ // Spec subcommand: `forge spec list|show|cancel`.
144
+ if (specCommand) {
145
+ const cwd = options.cwd ?? process.cwd();
146
+ const code = await handleSpecCommand(specCommand, cwd, {
147
+ // Only `spec sync` calls this: a read-only spec-mode run, like `--spec`.
148
+ runSpecAgent: async (task) => {
149
+ const agent = new Agent({
150
+ model: resolveModel(),
151
+ cwd,
152
+ auto: true,
153
+ specMode: true,
154
+ toolSandbox: { allowTools: [...SPEC_READONLY_TOOLS] },
155
+ });
156
+ return agent.run({ task });
157
+ },
158
+ });
159
+ process.exit(code);
160
+ }
161
+ // Watch subcommand: `forge watch`. Runs until Ctrl+C; no AI provider needed.
162
+ if (watchCommand) {
163
+ const code = await handleWatchCommand(options.cwd ?? process.cwd(), resolveModel, { aiComments: options.aiComments });
164
+ process.exit(code);
165
+ }
166
+ // Diff subcommand: `forge diff <fileA> <fileB>` or `--from-stdin`.
167
+ if (diffCommand) {
168
+ const code = handleDiffCommand({
169
+ fileA: diffCommand.fileA,
170
+ ...(diffCommand.fileB ? { fileB: diffCommand.fileB } : {}),
171
+ ...(diffCommand.contextLines !== undefined ? { contextLines: diffCommand.contextLines } : {}),
172
+ ...(diffCommand.fromStdin ? { fromStdin: true } : {}),
173
+ ...(diffCommand.summaryOnly ? { summaryOnly: true } : {}),
174
+ cwd: options.cwd ?? process.cwd(),
175
+ });
176
+ process.exit(code);
177
+ }
178
+ // Worktree subcommand: `forge worktree list | cleanup`. Doesn't need
179
+ // an AI provider. Mirrors the soft-prefix pattern of `session` / `skill`
180
+ // / `checkpoint`.
181
+ if (worktreeCommand) {
182
+ const code = await handleWorktreeCommand(worktreeCommand, options.cwd ?? process.cwd());
183
+ process.exit(code);
184
+ }
185
+ // Orchestrator subcommand: needs a model resolved before dispatch.
186
+ if (orchestratorCommand && (orchestratorCommand.run || orchestratorCommand.plan)) {
187
+ let model;
188
+ try {
189
+ model = resolveModel();
190
+ }
191
+ catch (err) {
192
+ process.stderr.write(`forge: ${err.message}\n`);
193
+ process.exit(2);
194
+ }
195
+ const code = await handleOrchestratorCommand(orchestratorCommand, options.cwd ?? process.cwd(), { provider: model.provider, model: model.model }, {
196
+ worktrees: options.orchestratorWorktrees,
197
+ cleanupBranches: options.orchestratorCleanupBranches,
198
+ commitChanges: options.orchestratorCommit,
199
+ });
200
+ process.exit(code);
201
+ }
202
+ if (orchestratorCommand) {
203
+ // list-only path: no model needed
204
+ const code = await handleOrchestratorCommand(orchestratorCommand, options.cwd ?? process.cwd(), { provider: 'openai', model: 'noop' } // unused for list
205
+ );
206
+ process.exit(code);
207
+ }
208
+ // From here on we need a real model: resolve it now and surface any
209
+ // missing-key error in the right place.
210
+ let model;
211
+ try {
212
+ model = resolveModel();
213
+ }
214
+ catch (err) {
215
+ process.stderr.write(`forge: ${err.message}\n`);
216
+ process.exit(2);
217
+ }
218
+ // Load MCP tools (if configured) once per process. The cleanup runs on exit.
219
+ const session = options.mcpConfig
220
+ ? await loadMcpTools(options.mcpConfig)
221
+ : { tools: [], cleanup: async () => { } };
222
+ // Every exit path (onResult/onError/onExit/SIGINT handlers below) already
223
+ // awaits `session.cleanup()` explicitly before calling `process.exit(...)`.
224
+ // Guard it so the `process.on('exit', ...)` fallback below becomes a no-op
225
+ // in that (common) case instead of re-running teardown a second time:
226
+ // it stays as a genuine fallback for any exit path that doesn't go
227
+ // through explicit cleanup.
228
+ const rawCleanup = session.cleanup;
229
+ let cleaned = false;
230
+ session.cleanup = async () => {
231
+ if (cleaned)
232
+ return;
233
+ cleaned = true;
234
+ await rawCleanup();
235
+ };
236
+ // Ensure MCP servers always get a chance to clean up.
237
+ process.on('exit', () => {
238
+ // Synchronous best-effort: async cleanup happens on 'beforeExit' below.
239
+ void session.cleanup();
240
+ });
241
+ process.on('beforeExit', async () => {
242
+ await session.cleanup();
243
+ });
244
+ if (resumeFlag && resumeFlag.kind !== 'none' && !options.task) {
245
+ if (!isatty(1) || !isatty(0)) {
246
+ process.stderr.write('forge: --resume requires a TTY. Run inside a terminal or use `forge session show <id>` to inspect offline.\n');
247
+ process.exit(2);
248
+ }
249
+ await runReplMode(options, model, session, resumeFlag, version);
250
+ return;
251
+ }
252
+ // No task → interactive REPL (requires a TTY).
253
+ if (!options.task) {
254
+ if (!isatty(1) || !isatty(0)) {
255
+ process.stderr.write('forge: REPL mode requires a TTY. Pass a task as an argument or run inside a terminal.\n');
256
+ process.exit(2);
257
+ }
258
+ await runReplMode(options, model, session, null, version);
259
+ return;
260
+ }
261
+ if (options.json) {
262
+ await runJsonMode(options, model, session);
263
+ return;
264
+ }
265
+ await runInkMode(options, model, session);
266
+ }
267
+ async function handleSessionCommand(cmd) {
268
+ const dir = defaultSessionDir();
269
+ if (cmd.list) {
270
+ const entries = await import('@ikarmic/forge-core').then((m) => m.listSessions(dir));
271
+ const rows = entries.slice(0, cmd.list.limit);
272
+ if (rows.length === 0) {
273
+ process.stdout.write(`(no saved sessions in ${dir})\n`);
274
+ return;
275
+ }
276
+ for (const e of rows) {
277
+ const label = e.label ? ` [${e.label}]` : '';
278
+ const tok = e.totalInputTokens !== undefined ? ` · ${e.totalInputTokens + (e.totalOutputTokens ?? 0)} tok` : '';
279
+ const cost = e.totalCostUsd !== undefined && e.totalCostUsd > 0
280
+ ? ` · $${e.totalCostUsd < 0.01 ? e.totalCostUsd.toFixed(4) : e.totalCostUsd.toFixed(3)}`
281
+ : '';
282
+ process.stdout.write(`${e.id}${label} ${e.updatedAt} ${e.model.provider}/${e.model.model}${tok}${cost}\n` +
283
+ ` cwd: ${e.cwd}\n` +
284
+ ` ${e.preview ?? '(no preview)'}\n`);
285
+ }
286
+ return;
287
+ }
288
+ if (cmd.show) {
289
+ const session = await loadSession(cmd.show.id, dir);
290
+ process.stdout.write(JSON.stringify(session, null, 2) + '\n');
291
+ return;
292
+ }
293
+ if (cmd.delete) {
294
+ await import('@ikarmic/forge-core').then((m) => m.deleteSession(cmd.delete, dir));
295
+ process.stdout.write(`deleted ${cmd.delete}\n`);
296
+ return;
297
+ }
298
+ if (cmd.export) {
299
+ await handleSessionExport(cmd.export, dir);
300
+ return;
301
+ }
302
+ if (cmd.tail) {
303
+ await handleSessionTail(cmd.tail.id, dir);
304
+ return;
305
+ }
306
+ if (cmd.search) {
307
+ if (!cmd.search.query.trim()) {
308
+ process.stderr.write('usage: forge session search <words…>\n');
309
+ process.exitCode = 2;
310
+ return;
311
+ }
312
+ const found = await import('@ikarmic/forge-core').then((m) => m.searchSessions(dir, cmd.search.query, { limit: cmd.search.limit }));
313
+ if (found.length === 0) {
314
+ process.stdout.write(`(no session mentions "${cmd.search.query}")\n`);
315
+ return;
316
+ }
317
+ for (const f of found) {
318
+ const label = f.entry.label ? ` [${f.entry.label}]` : '';
319
+ process.stdout.write(`${f.entry.id}${label} ${f.entry.updatedAt} ${f.hits} hit${f.hits === 1 ? '' : 's'}\n cwd: ${f.entry.cwd}\n ${f.snippet}\n`);
320
+ }
321
+ return;
322
+ }
323
+ }
324
+ /** Print the session so far, then follow it until Ctrl-C (e.g. while the VS Code chat continues it). */
325
+ async function handleSessionTail(idArg, dir) {
326
+ const core = await import('@ikarmic/forge-core');
327
+ const id = idArg === 'latest' ? (await core.findLatestSession(dir))?.id : idArg;
328
+ if (!id) {
329
+ process.stderr.write(`forge session tail: no saved sessions in ${dir}\n`);
330
+ process.exitCode = 2;
331
+ return;
332
+ }
333
+ const session = await loadSession(id, dir);
334
+ const print = (messages) => {
335
+ for (const m of messages)
336
+ for (const line of core.summarizeMessage(m))
337
+ process.stdout.write(`${line}\n`);
338
+ };
339
+ process.stderr.write(`following ${id} (Ctrl-C to stop)\n`);
340
+ print(session.messages);
341
+ await new Promise((resolve) => {
342
+ const stop = core.watchSession(id, dir, ({ session: next, appended }) => {
343
+ if (appended === null)
344
+ process.stdout.write('--- history rewritten (compaction or another branch) ---\n');
345
+ print(appended ?? next.messages);
346
+ }, { known: session.messages, onError: () => undefined });
347
+ process.once('SIGINT', () => {
348
+ stop();
349
+ resolve();
350
+ });
351
+ });
352
+ }
353
+ async function handleSessionExport(cmd, dir) {
354
+ const session = await loadSession(cmd.id, dir);
355
+ const core = await import('@ikarmic/forge-core');
356
+ const rendered = cmd.format === 'json'
357
+ ? core.formatSessionJson(session)
358
+ : core.formatSessionMarkdown(session);
359
+ if (cmd.output) {
360
+ const fs = await import('node:fs/promises');
361
+ await fs.writeFile(cmd.output, rendered, 'utf8');
362
+ process.stderr.write(`wrote ${rendered.length} bytes (${cmd.format}) to ${cmd.output}\n`);
363
+ return;
364
+ }
365
+ process.stdout.write(rendered);
366
+ }
367
+ /**
368
+ * Resolve the schema path passed via `--schema <path>`. Loads the file
369
+ * via {@link loadSchemaFile} which auto-detects `.ts`/`.js` (Zod default
370
+ * export) vs `.json` (JSON Schema → Zod). Throws on missing file / bad
371
+ * format so the caller can surface a clean CLI error.
372
+ */
373
+ async function resolveStructuredSchema(path) {
374
+ // loadSchemaFile handles both Zod modules (default export) and JSON
375
+ // Schema documents (auto-converted). For .ts files, users running
376
+ // `forge` directly need to either compile the schema or use tsx;
377
+ // loadSchemaFile surfaces a friendly error in that case.
378
+ return await loadSchemaFile(path);
379
+ }
380
+ /**
381
+ * v2.11: On every CLI startup, prune worktrees left behind by
382
+ * crashed sessions. The current process's `WorktreeManager` instance
383
+ * doesn't own any worktrees yet (none have been created), so
384
+ * `pruneStale()` removes everything under `<repo>/.forge/worktrees/`
385
+ * that wasn't created in *this* process. Silently no-ops when the
386
+ * cwd isn't in a git repo.
387
+ */
388
+ async function pruneWorktreesOnStartup(cwd) {
389
+ try {
390
+ const repoRoot = await WorktreeManager.resolveRepoRoot(cwd);
391
+ if (!repoRoot)
392
+ return;
393
+ const manager = new WorktreeManager(repoRoot);
394
+ const pruned = await manager.pruneStale();
395
+ if (pruned > 0) {
396
+ process.stderr.write(`forge: pruned ${pruned} stale worktree${pruned === 1 ? '' : 's'} from a previous session\n`);
397
+ }
398
+ }
399
+ catch {
400
+ // Best-effort. Never let startup hygiene block the user's command.
401
+ }
402
+ }
403
+ async function runJsonMode(options, model, session) {
404
+ const baseConfig = await toAgentConfig(options, model, async () => false, session.tools);
405
+ if (options.architect) {
406
+ await runArchitectJsonMode(options, model, session, baseConfig);
407
+ return;
408
+ }
409
+ // Structured-output mode takes a separate path so we can wrap the
410
+ // agent loop with schema validation + retry. We emit the same event
411
+ // stream (so existing tooling keeps working) plus a final `data`
412
+ // event carrying the validated payload.
413
+ if (options.schema) {
414
+ await runStructuredJsonMode(options, model, session, baseConfig);
415
+ return;
416
+ }
417
+ const workflow = options.plan || options.spec ? null : await createGitWorkflow(options, baseConfig.cwd ?? process.cwd(), model);
418
+ const savedDirty = await workflow?.before();
419
+ if (savedDirty)
420
+ process.stdout.write(`${JSON.stringify({ type: 'commit', kind: 'dirty', ...savedDirty })}\n`);
421
+ const handle = startAgent({
422
+ baseConfig,
423
+ task: options.task,
424
+ hooks: {
425
+ onEvent: (event) => {
426
+ process.stdout.write(`${JSON.stringify(event)}\n`);
427
+ },
428
+ // In JSON mode we never prompt: default-deny. Pass --auto to bypass.
429
+ requestApproval: async () => options.auto,
430
+ },
431
+ });
432
+ try {
433
+ const result = await handle.promise;
434
+ if (workflow && result.reason === 'completed') {
435
+ const commit = await workflow.after(options.task).catch(() => null);
436
+ if (commit)
437
+ process.stdout.write(`${JSON.stringify({ type: 'commit', kind: 'run', ...commit })}\n`);
438
+ }
439
+ if (options.plan && result.reason === 'plan') {
440
+ // For --plan --json, emit the plan as a single top-level field so
441
+ // scripts can parse without digging through events.
442
+ process.stdout.write(`${JSON.stringify({ type: 'plan', plan: result.finalText })}\n`);
443
+ }
444
+ else if (options.spec && result.reason === 'spec') {
445
+ // For --spec --json, emit the raw (un-split) spec text as a single
446
+ // top-level field: same "scripts don't dig through events" reason
447
+ // as --plan --json above.
448
+ process.stdout.write(`${JSON.stringify({ type: 'spec', spec: result.finalText })}\n`);
449
+ }
450
+ else {
451
+ process.stdout.write(`${JSON.stringify({ type: 'final', ...result })}\n`);
452
+ }
453
+ }
454
+ finally {
455
+ await session.cleanup();
456
+ }
457
+ }
458
+ async function runStructuredJsonMode(options, _model, session, baseConfig) {
459
+ const schema = await resolveStructuredSchema(options.schema);
460
+ const handle = startStructuredAgent({
461
+ baseConfig,
462
+ task: options.task,
463
+ schema,
464
+ maxRetries: options.schemaRetries,
465
+ hooks: {
466
+ onEvent: (event) => {
467
+ process.stdout.write(`${JSON.stringify(event)}\n`);
468
+ },
469
+ // Same default-deny policy as the plain JSON mode.
470
+ requestApproval: async () => options.auto,
471
+ },
472
+ });
473
+ try {
474
+ const result = await handle.promise;
475
+ process.stdout.write(`${JSON.stringify({
476
+ type: 'structured',
477
+ data: result.data,
478
+ retries: result.retries,
479
+ iterations: result.result.iterations,
480
+ inputTokens: result.result.inputTokens,
481
+ outputTokens: result.result.outputTokens,
482
+ costUsd: result.result.costUsd,
483
+ durationMs: result.result.durationMs,
484
+ })}\n`);
485
+ }
486
+ catch (err) {
487
+ if (isStructuredOutputError(err)) {
488
+ // Distinct exit code (3) so scripts can tell "schema validation
489
+ // failed after N retries" apart from a generic crash (1).
490
+ process.stdout.write(`${JSON.stringify({
491
+ type: 'structured_error',
492
+ error: err.message,
493
+ attempts: err.attempts,
494
+ lastError: err.lastError,
495
+ lastText: err.lastText,
496
+ })}\n`);
497
+ await session.cleanup();
498
+ process.exit(3);
499
+ }
500
+ throw err;
501
+ }
502
+ finally {
503
+ await session.cleanup();
504
+ }
505
+ }
506
+ /** The `--editor-model`, carrying `--editor-reasoning` when given (the main model's configured effort never reaches it). */
507
+ function resolveEditorModel(options, aliases, model) {
508
+ const editor = resolveModelAlias(options.editorModel, aliases, model.provider);
509
+ return options.editorReasoning ? { ...editor, reasoningEffort: options.editorReasoning } : editor;
510
+ }
511
+ async function runArchitectJsonMode(options, model, session, baseConfig) {
512
+ const { config: shared } = await loadForgeConfigFile();
513
+ const editorModel = resolveEditorModel(options, shared.aliases, model);
514
+ try {
515
+ const result = await runArchitect({
516
+ task: options.task,
517
+ baseConfig: { ...baseConfig, approve: async () => options.auto },
518
+ editorModel,
519
+ onEvent: (event) => {
520
+ process.stdout.write(`${JSON.stringify(event)}\n`);
521
+ },
522
+ });
523
+ process.stdout.write(`${JSON.stringify({ type: 'final', ...result })}\n`);
524
+ process.exitCode = result.reason === 'completed' ? 0 : 1;
525
+ }
526
+ finally {
527
+ await session.cleanup();
528
+ }
529
+ }
530
+ async function runInkMode(options, model, session) {
531
+ if (!isatty(1)) {
532
+ process.stderr.write('forge: refusing to render the TUI without a TTY. Pass --json or run inside a terminal.\n');
533
+ process.exit(2);
534
+ }
535
+ const baseConfig = await toAgentConfig(options, model, async () => false, session.tools);
536
+ const { config: sharedConfig } = await loadForgeConfigFile();
537
+ if (options.architect) {
538
+ await runArchitectInkMode(options, model, session, baseConfig, sharedConfig);
539
+ return;
540
+ }
541
+ // Structured-output mode in the TUI runs the agent normally, but
542
+ // validates the final response against the schema and prints the
543
+ // validated payload to stdout (for piping) before exiting. The TUI
544
+ // still shows the full agent run so the user can see the work.
545
+ if (options.schema) {
546
+ await runStructuredInkMode(options, baseConfig, session);
547
+ return;
548
+ }
549
+ const workflow = options.plan || options.spec ? null : await createGitWorkflow(options, baseConfig.cwd ?? process.cwd(), model);
550
+ const savedDirty = await workflow?.before();
551
+ if (savedDirty)
552
+ process.stdout.write(`${formatCommitLine(savedDirty)} (your earlier changes)\n`);
553
+ const app = render(React.createElement(App, {
554
+ task: options.task,
555
+ baseConfig,
556
+ model,
557
+ display: sharedConfig.display,
558
+ onResult: (result) => {
559
+ app.unmount();
560
+ if (options.notifications)
561
+ createNotifier({ command: options.notificationsCommand })(`Forge finished (${result.reason})`);
562
+ setImmediate(async () => {
563
+ await session.cleanup();
564
+ if (workflow && result.reason === 'completed') {
565
+ const commit = await workflow.after(options.task).catch(() => null);
566
+ if (commit)
567
+ process.stdout.write(`\n${formatCommitLine(commit)}\n`);
568
+ }
569
+ if (options.plan) {
570
+ // Plan mode: print the plan to stdout for easy piping, then
571
+ // exit 0 (the user can pipe it into `$EDITOR`, a file, etc).
572
+ process.stdout.write(`\n# Forge plan\n\n${result.finalText}\n`);
573
+ process.exit(0);
574
+ return;
575
+ }
576
+ if (options.spec) {
577
+ // Spec mode, one-shot: print the raw three-section text and
578
+ // exit 0. Unlike `/spec` in the REPL, one-shot mode has no
579
+ // approve/edit/cancel step: nothing is written to
580
+ // `.forge/specs/`. Point the user at the REPL for that.
581
+ process.stdout.write(`\n# Forge spec draft\n\n${result.finalText}\n\n` +
582
+ `(nothing written to disk: run \`forge\` and use \`/spec\` in the REPL to review and persist this to .forge/specs/)\n`);
583
+ process.exit(0);
584
+ return;
585
+ }
586
+ process.exit(result.reason === 'completed' ? 0 : 1);
587
+ });
588
+ },
589
+ onError: (err) => {
590
+ app.unmount();
591
+ setImmediate(async () => {
592
+ await session.cleanup();
593
+ process.stderr.write(`forge: ${err.message}\n`);
594
+ process.exit(1);
595
+ });
596
+ },
597
+ }));
598
+ process.on('SIGINT', () => {
599
+ app.unmount();
600
+ setImmediate(async () => {
601
+ await session.cleanup();
602
+ process.exit(130);
603
+ });
604
+ });
605
+ }
606
+ async function runArchitectInkMode(options, model, session, baseConfig, sharedConfig) {
607
+ const editorModel = resolveEditorModel(options, sharedConfig.aliases, model);
608
+ let currentApp = null;
609
+ process.on('SIGINT', () => {
610
+ currentApp?.unmount();
611
+ setImmediate(async () => {
612
+ await session.cleanup();
613
+ process.exit(130);
614
+ });
615
+ });
616
+ const renderPhase = (task, config, phaseModel) => new Promise((resolve, reject) => {
617
+ const app = render(React.createElement(App, {
618
+ task,
619
+ baseConfig: config,
620
+ model: phaseModel,
621
+ display: sharedConfig.display,
622
+ onResult: (result) => {
623
+ app.unmount();
624
+ setImmediate(() => resolve(result));
625
+ },
626
+ onError: (err) => {
627
+ app.unmount();
628
+ setImmediate(() => reject(err));
629
+ },
630
+ }));
631
+ currentApp = app;
632
+ });
633
+ const finish = async (code, message) => {
634
+ await session.cleanup();
635
+ if (message)
636
+ process.stderr.write(`forge: ${message}\n`);
637
+ process.exit(code);
638
+ };
639
+ try {
640
+ process.stdout.write(`architect: ${model.provider}/${model.model}\n`);
641
+ const architect = await renderPhase(options.task, architectPhaseConfig(baseConfig), model);
642
+ if (!isUsableArchitectPlan(architect)) {
643
+ return await finish(1, architect.reason === 'spec' ? 'architect produced no usable plan' : `architect phase ended: ${architect.reason}`);
644
+ }
645
+ process.stdout.write(`\n# Architect plan\n\n${architect.finalText}\n\n`);
646
+ if (!options.auto && !(await confirmPlan(`${editorModel.provider}/${editorModel.model}`))) {
647
+ return await finish(1, 'plan rejected');
648
+ }
649
+ const editorConfig = {
650
+ ...editorPhaseConfig(baseConfig, editorModel),
651
+ ...editorLimits(baseConfig, { costUsd: architect.costUsd ?? 0, durationMs: architect.durationMs ?? 0 }),
652
+ };
653
+ process.stdout.write(`editor: ${editorModel.provider}/${editorModel.model}\n`);
654
+ const editor = await renderPhase(editorTask(options.task, architect.finalText), editorConfig, editorModel);
655
+ const known = (architect.costKnown ?? false) && (editor.costKnown ?? false);
656
+ process.stdout.write(`architect + editor: ${formatCost((architect.costUsd ?? 0) + (editor.costUsd ?? 0), known)}\n`);
657
+ return await finish(editor.reason === 'completed' ? 0 : 1);
658
+ }
659
+ catch (err) {
660
+ return await finish(1, err.message);
661
+ }
662
+ }
663
+ /**
664
+ * TUI wrapper for structured mode. Renders the App, but on completion
665
+ * the final response is validated against the schema (with retry) and
666
+ * the validated object is pretty-printed to stdout before exiting 0.
667
+ */
668
+ async function runStructuredInkMode(options, baseConfig, session) {
669
+ const schema = await resolveStructuredSchema(options.schema);
670
+ const handle = startStructuredAgent({
671
+ baseConfig,
672
+ task: options.task,
673
+ schema,
674
+ maxRetries: options.schemaRetries,
675
+ hooks: {
676
+ onEvent: (event) => {
677
+ // In the TUI the App owns the live event stream: we just
678
+ // forward here by re-emitting through the same path App uses.
679
+ // For v2.8.0 we keep it simple: TUI runs structured mode
680
+ // through startStructuredAgent without rendering; the validated
681
+ // payload is printed on completion.
682
+ void event;
683
+ },
684
+ // Approval is disabled in structured mode by default: these
685
+ // runs are typically scripted / CI. Pass --auto to override.
686
+ requestApproval: async () => options.auto,
687
+ },
688
+ });
689
+ try {
690
+ const result = await handle.promise;
691
+ process.stdout.write('\n# Forge structured output\n\n');
692
+ process.stdout.write(`${JSON.stringify(result.data, null, 2)}\n`);
693
+ process.exit(0);
694
+ }
695
+ catch (err) {
696
+ if (isStructuredOutputError(err)) {
697
+ process.stderr.write(`forge: structured output failed after ${err.attempts} attempt(s): ${err.lastError}\n`);
698
+ await session.cleanup();
699
+ process.exit(3);
700
+ }
701
+ process.stderr.write(`forge: ${err.message}\n`);
702
+ await session.cleanup();
703
+ process.exit(1);
704
+ }
705
+ finally {
706
+ // Best-effort cleanup if we didn't exit above.
707
+ }
708
+ }
709
+ async function runReplMode(options, model, session, resumeFlag, version) {
710
+ const baseConfig = await toAgentConfig(options, model, async () => false, session.tools);
711
+ const { config: sharedConfig } = await loadForgeConfigFile();
712
+ // Resolve which session id we're using: fresh, by-id, or --latest.
713
+ const dir = defaultSessionDir();
714
+ const { sessionId, initialHistory, initialLabel } = await resolveResume(resumeFlag, model, dir);
715
+ const gitWorkflow = await createGitWorkflow(options, baseConfig.cwd ?? process.cwd(), model);
716
+ const commitModel = await resolveCommitModel(options, model);
717
+ const app = render(React.createElement(Repl, {
718
+ baseConfig,
719
+ model,
720
+ display: sharedConfig.display,
721
+ gitWorkflow,
722
+ gitOptions: { noAttribute: options.noAttribute, gitCommitVerify: options.gitCommitVerify },
723
+ commitModel,
724
+ ...(options.notifications ? { notify: createNotifier({ command: options.notificationsCommand }) } : {}),
725
+ aliases: sharedConfig.aliases,
726
+ version,
727
+ lintCmds: options.lintCmds,
728
+ testCmds: options.testCmds,
729
+ onExit: () => {
730
+ app.unmount();
731
+ setImmediate(async () => {
732
+ await session.cleanup();
733
+ process.exit(0);
734
+ });
735
+ },
736
+ sessionId,
737
+ initialHistory,
738
+ sessionDir: dir,
739
+ ...(initialLabel ? { initialLabel } : {}),
740
+ }));
741
+ process.on('SIGINT', () => {
742
+ app.unmount();
743
+ setImmediate(async () => {
744
+ await session.cleanup();
745
+ process.exit(130);
746
+ });
747
+ });
748
+ }
749
+ /**
750
+ * Decide what session id / history to start the REPL with.
751
+ */
752
+ async function resolveResume(flag, _model, dir) {
753
+ if (!flag || flag.kind === 'none') {
754
+ return { sessionId: generateSessionId(), initialHistory: undefined, initialLabel: undefined };
755
+ }
756
+ let id;
757
+ if (flag.kind === 'latest') {
758
+ const latest = await findLatestSession(dir);
759
+ if (!latest) {
760
+ process.stderr.write(`forge: no saved sessions in ${dir}; starting a fresh one\n`);
761
+ return { sessionId: generateSessionId(), initialHistory: undefined, initialLabel: undefined };
762
+ }
763
+ id = latest.id;
764
+ }
765
+ else {
766
+ id = flag.id;
767
+ }
768
+ try {
769
+ const persisted = await loadSession(id, dir);
770
+ return {
771
+ sessionId: persisted.id,
772
+ initialHistory: persisted.messages,
773
+ initialLabel: persisted.label,
774
+ };
775
+ }
776
+ catch (err) {
777
+ process.stderr.write(`forge: ${err.message}; starting a fresh session\n`);
778
+ return { sessionId: generateSessionId(), initialHistory: undefined, initialLabel: undefined };
779
+ }
780
+ }
781
+ main().catch((err) => {
782
+ process.stderr.write(`forge: ${err.message}\n`);
783
+ process.exit(1);
784
+ });
785
+ void require;
786
+ //# sourceMappingURL=forge.js.map