@aion0/forge 0.10.78 → 0.10.80

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 (38) hide show
  1. package/RELEASE_NOTES.md +9 -8
  2. package/app/api/code/route.ts +171 -54
  3. package/app/api/onboarding/route.ts +32 -0
  4. package/app/api/skills/local/route.ts +5 -4
  5. package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
  6. package/app/api/tasks/route.ts +2 -1
  7. package/cli/mw.mjs +7 -5
  8. package/cli/mw.ts +8 -6
  9. package/components/CodeViewer.tsx +127 -41
  10. package/components/Dashboard.tsx +6 -2
  11. package/components/DocsViewer.tsx +34 -22
  12. package/components/HelpTerminal.tsx +9 -5
  13. package/components/OnboardingWizard.tsx +65 -1
  14. package/components/ProjectDetail.tsx +33 -7
  15. package/components/TaskDetail.tsx +28 -1
  16. package/components/TmuxTaskTerminal.tsx +105 -0
  17. package/components/WebTerminal.tsx +26 -8
  18. package/components/WorkspaceView.tsx +68 -47
  19. package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
  20. package/docs/design_automation_records/README.md +232 -0
  21. package/lib/agents/index.ts +9 -0
  22. package/lib/chat/agent-loop.ts +6 -0
  23. package/lib/chat/tool-dispatcher.ts +110 -9
  24. package/lib/fileTree.ts +28 -0
  25. package/lib/help-docs/01-settings.md +11 -0
  26. package/lib/help-docs/05-pipelines.md +31 -0
  27. package/lib/help-docs/07-projects.md +3 -1
  28. package/lib/help-docs/25-chat-tools.md +23 -0
  29. package/lib/pipeline.ts +27 -3
  30. package/lib/session-utils.ts +19 -0
  31. package/lib/task-manager.ts +73 -3
  32. package/lib/task-tmux-backend.ts +625 -0
  33. package/lib/terminal-standalone.ts +17 -0
  34. package/lib/workspace/skill-installer.ts +18 -8
  35. package/package.json +1 -1
  36. package/proxy.ts +5 -4
  37. package/src/core/db/database.ts +1 -0
  38. package/src/types/index.ts +3 -0
package/lib/pipeline.ts CHANGED
@@ -59,6 +59,10 @@ export interface WorkflowNode {
59
59
  /** Milliseconds to wait before each retry. Default 0 (immediate).
60
60
  * Use 5000+ for downstream rate-limit recovery. */
61
61
  retryDelayMs?: number;
62
+ /** Execution backend for this node's task. Overrides the workflow-level
63
+ * default. 'tmux' = interactive claude in a dedicated tmux session
64
+ * (subscription billing); 'headless' / omitted = default `claude -p`. */
65
+ backend?: 'tmux' | 'headless';
62
66
  }
63
67
 
64
68
  // ─── Conversation Mode Types ──────────────────────────────
@@ -168,6 +172,10 @@ export interface Workflow {
168
172
  /** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
169
173
  * so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
170
174
  git_push?: boolean;
175
+ /** Default execution backend for every node's task. 'tmux' runs interactive
176
+ * claude in a per-node tmux session (subscription billing); 'headless' /
177
+ * omitted uses default `claude -p`. Per-node `backend:` overrides this. */
178
+ backend?: 'tmux' | 'headless';
171
179
  // Conversation mode fields (only when type === 'conversation')
172
180
  conversation?: ConversationConfig;
173
181
  }
@@ -247,6 +255,13 @@ export interface Pipeline {
247
255
  * recovery use the same set as the original run.
248
256
  */
249
257
  skills?: string[];
258
+ /**
259
+ * Resolved execution backend for this run, frozen from the workflow's
260
+ * `backend:` at start so retries / recovery reuse it. Each node's task
261
+ * inherits this unless the node declares its own `backend:`. Omitted =
262
+ * default headless.
263
+ */
264
+ backend?: 'tmux' | 'headless';
250
265
  /**
251
266
  * Absolute path to this run's scratch dir, served to YAML nodes as
252
267
  * `{{run.tmp_dir}}`. Layout: `<project_dir>/.forge/worktrees/pipeline-<id>/`.
@@ -460,6 +475,7 @@ export function parseWorkflow(raw: string): Workflow {
460
475
  retryDelayMs: Number.isFinite(Number(n.retry_delay_ms ?? n.retryDelayMs))
461
476
  ? Math.max(0, Math.trunc(Number(n.retry_delay_ms ?? n.retryDelayMs)))
462
477
  : 0,
478
+ backend: n.backend === 'tmux' || n.backend === 'headless' ? n.backend : undefined,
463
479
  };
464
480
  }
465
481
 
@@ -500,6 +516,7 @@ export function parseWorkflow(raw: string): Workflow {
500
516
  nodes,
501
517
  for_each,
502
518
  conversation,
519
+ backend: parsed.backend === 'tmux' || parsed.backend === 'headless' ? parsed.backend : undefined,
503
520
  };
504
521
  }
505
522
 
@@ -975,7 +992,7 @@ function renderSkillsAppendPrompt(skills: string[] | undefined): string {
975
992
  export function startPipeline(
976
993
  workflowName: string,
977
994
  input: Record<string, string>,
978
- opts: { skills?: string[] } = {},
995
+ opts: { skills?: string[]; backend?: 'tmux' | 'headless' } = {},
979
996
  ): Pipeline {
980
997
  const workflow = getWorkflow(workflowName);
981
998
  if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
@@ -1001,7 +1018,7 @@ export function startPipeline(
1001
1018
 
1002
1019
  // Conversation mode — separate execution path
1003
1020
  if (workflow.type === 'conversation' && workflow.conversation) {
1004
- return startConversationPipeline(workflow, input);
1021
+ return startConversationPipeline(workflow, input, opts.backend);
1005
1022
  }
1006
1023
 
1007
1024
  const id = randomUUID().slice(0, 8);
@@ -1059,6 +1076,8 @@ export function startPipeline(
1059
1076
  nodeOrder,
1060
1077
  createdAt: new Date().toISOString(),
1061
1078
  skills: opts.skills && opts.skills.length ? [...opts.skills] : undefined,
1079
+ // Runtime override (e.g. chat "use tmux") wins over the workflow's declared default.
1080
+ backend: opts.backend ?? workflow.backend,
1062
1081
  forEach: forEachState,
1063
1082
  };
1064
1083
 
@@ -1124,7 +1143,7 @@ type ConversationState = {
1124
1143
 
1125
1144
  // ─── Conversation Mode Execution ──────────────────────────
1126
1145
 
1127
- function startConversationPipeline(workflow: Workflow, input: Record<string, string>): Pipeline {
1146
+ function startConversationPipeline(workflow: Workflow, input: Record<string, string>, backendOverride?: 'tmux' | 'headless'): Pipeline {
1128
1147
  const conv = workflow.conversation!;
1129
1148
  const id = randomUUID().slice(0, 8);
1130
1149
 
@@ -1146,6 +1165,7 @@ function startConversationPipeline(workflow: Workflow, input: Record<string, str
1146
1165
  nodes: {},
1147
1166
  nodeOrder: [],
1148
1167
  createdAt: new Date().toISOString(),
1168
+ backend: backendOverride ?? workflow.backend,
1149
1169
  conversation: {
1150
1170
  config: {
1151
1171
  ...conv,
@@ -1206,6 +1226,7 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
1206
1226
  prompt: fullPrompt,
1207
1227
  mode: 'prompt',
1208
1228
  agent: agentDef.agent,
1229
+ backend: pipeline.backend === 'tmux' ? 'tmux' : undefined,
1209
1230
  conversationId: '', // fresh session — no resume for conversation mode
1210
1231
  });
1211
1232
  pipelineTaskIds.add(task.id);
@@ -2074,6 +2095,9 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
2074
2095
  prompt: effectivePrompt,
2075
2096
  mode: taskMode as any,
2076
2097
  agent: nodeDef.agent || undefined,
2098
+ // Backend: per-node override > pipeline default > headless. Only 'tmux'
2099
+ // is a real Task backend value; 'headless'/undefined → omit (default).
2100
+ backend: (nodeDef.backend || pipeline.backend) === 'tmux' ? 'tmux' : undefined,
2077
2101
  // Pipeline nodes always start a fresh Claude session. Without this,
2078
2102
  // createTask falls back to getProjectConversationId() which returns
2079
2103
  // the project's last interactive session id — but pipeline nodes
@@ -51,3 +51,22 @@ export async function getMcpFlag(projectPath: string): Promise<string> {
51
51
  return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
52
52
  }
53
53
 
54
+
55
+ /**
56
+ * tmux env injection — keep secret env (API keys) out of pane echo + shell
57
+ * history. The server stores values via `tmux set-environment` (the `setenv` WS
58
+ * message); this prefix pulls them back at launch time so only var NAMES appear
59
+ * in the typed command, never the values.
60
+ *
61
+ * Uses a single `eval "$(tmux show-environment -s | grep …)"` instead of one
62
+ * `export K="$(tmux show-environment K | sed …)"` per var — the latter form
63
+ * grows O(N) in length and hits tmux send-keys buffer limits with many vars.
64
+ * `tmux show-environment -s` outputs `KEY="val"; export KEY;` per line, so
65
+ * grepping `^(K1|K2)=` and eval-ing the matches is both short and safe.
66
+ */
67
+ export function tmuxEnvPrefix(keys: string[]): string {
68
+ const valid = keys.filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k));
69
+ if (!valid.length) return '';
70
+ const pattern = `^(${valid.join('|')})=`;
71
+ return `eval "$(tmux show-environment -s 2>/dev/null | grep -E '${pattern}')" && `;
72
+ }
@@ -17,6 +17,10 @@ import { recordUsage } from './usage-scanner';
17
17
  import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '../src/types';
18
18
 
19
19
  import { toIsoUTC } from './iso-time';
20
+ import { executeTmuxTask, killTmuxTaskSession } from './task-tmux-backend';
21
+ import { installForgeStopHook } from './workspace/skill-installer';
22
+
23
+ let _tmuxHookInstalled = false;
20
24
 
21
25
  /** Access pipeline.ts's pipelineTaskIds Set via the shared globalThis
22
26
  * Symbol it registers at module load. Avoids the circular static
@@ -77,6 +81,7 @@ export function createTask(opts: {
77
81
  scheduledAt?: string; // ISO timestamp — task won't run until this time
78
82
  watchConfig?: WatchConfig;
79
83
  agent?: string; // Agent ID (default: from settings)
84
+ backend?: 'tmux'; // 'tmux' = run in dedicated tmux session; omit = default headless
80
85
  }): Task {
81
86
  const id = randomUUID().slice(0, 8);
82
87
  const mode = opts.mode || 'prompt';
@@ -89,13 +94,14 @@ export function createTask(opts: {
89
94
  : (opts.conversationId || (mode === 'prompt' ? getProjectConversationId(opts.projectName) : null));
90
95
 
91
96
  db().prepare(`
92
- INSERT INTO tasks (id, project_name, project_path, prompt, mode, status, priority, conversation_id, log, scheduled_at, watch_config, agent)
93
- VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, '[]', ?, ?, ?)
97
+ INSERT INTO tasks (id, project_name, project_path, prompt, mode, status, priority, conversation_id, log, scheduled_at, watch_config, agent, backend)
98
+ VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, '[]', ?, ?, ?, ?)
94
99
  `).run(
95
100
  id, opts.projectName, opts.projectPath, opts.prompt, mode,
96
101
  opts.priority || 0, convId || null, opts.scheduledAt || null,
97
102
  opts.watchConfig ? JSON.stringify(opts.watchConfig) : null,
98
103
  agent || null,
104
+ opts.backend || null,
99
105
  );
100
106
 
101
107
  // Kick the runner
@@ -171,7 +177,7 @@ export function listTasks(status?: TaskStatus): Task[] {
171
177
  export function listTasksLite(status?: TaskStatus): Task[] {
172
178
  const SLIM_COLS = `
173
179
  id, project_name, project_path, prompt, mode, status, priority,
174
- conversation_id, watch_config, git_branch, cost_usd, error, agent,
180
+ conversation_id, watch_config, git_branch, cost_usd, error, agent, backend,
175
181
  created_at, started_at, completed_at, scheduled_at,
176
182
  length(log) AS log_size,
177
183
  CASE WHEN result_summary IS NULL THEN NULL ELSE substr(result_summary, 1, 1024) END AS result_summary,
@@ -270,6 +276,7 @@ function rowToLiteTask(row: any): Task {
270
276
  completedAt: toIsoUTC(row.completed_at) ?? undefined,
271
277
  scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
272
278
  agent: row.agent || undefined,
279
+ backend: row.backend || undefined,
273
280
  logSize: row.log_size || 0,
274
281
  hasGitDiff: !!row.has_git_diff,
275
282
  } as Task;
@@ -286,6 +293,11 @@ export function cancelTask(id: string): boolean {
286
293
  return true;
287
294
  }
288
295
 
296
+ // Kill tmux session for tmux-backend tasks
297
+ if ((task as any).backend === 'tmux') {
298
+ killTmuxTaskSession(id);
299
+ }
300
+
289
301
  updateTaskStatus(id, 'cancelled');
290
302
 
291
303
  // Clean up project lock if this was a running prompt task
@@ -300,6 +312,8 @@ export function deleteTask(id: string): boolean {
300
312
  const task = getTask(id);
301
313
  if (!task) return false;
302
314
  if (task.status === 'running') cancelTask(id);
315
+ // Always attempt cleanup for tmux tasks (session may be alive for debugging)
316
+ if ((task as any).backend === 'tmux') killTmuxTaskSession(id);
303
317
  db().prepare('DELETE FROM tasks WHERE id = ?').run(id);
304
318
  return true;
305
319
  }
@@ -372,6 +386,20 @@ export function retryTask(id: string): Task | null {
372
386
  });
373
387
  }
374
388
 
389
+ /**
390
+ * Complete a tmux task whose in-memory waiter was lost (e.g. server restart).
391
+ * Called by the hook endpoint fallback path in task-tmux-backend.
392
+ */
393
+ export function finishTmuxTask(id: string, paneCapture: string): void {
394
+ const summary = paneCapture.slice(0, 2048);
395
+ db().prepare("UPDATE tasks SET status = 'done', result_summary = ?, completed_at = datetime('now') WHERE id = ? AND status = 'running'").run(summary, id);
396
+ runningProjects.delete(getTask(id)?.projectName || '');
397
+ appendLog(id, { type: 'result', content: paneCapture, timestamp: new Date().toISOString() });
398
+ emit(id, 'status', 'done');
399
+ const doneTask = getTask(id);
400
+ if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
401
+ }
402
+
375
403
  // ─── Background Runner ───────────────────────────────────────
376
404
 
377
405
  export function ensureRunnerStarted() {
@@ -500,6 +528,46 @@ export function connectorEnv(): Record<string, string> {
500
528
  }
501
529
  }
502
530
 
531
+ function executeTmuxBackendTask(task: Task): Promise<void> {
532
+ // Ensure Stop hook is installed (idempotent; workspace daemon does this too,
533
+ // but tmux tasks may run without the workspace daemon active)
534
+ if (!_tmuxHookInstalled) {
535
+ _tmuxHookInstalled = true;
536
+ try { installForgeStopHook(Number(process.env.PORT) || 8403); } catch {}
537
+ }
538
+
539
+ updateTaskStatus(task.id, 'running');
540
+ db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
541
+ appendLog(task.id, { type: 'system', subtype: 'init', content: `Agent: ${(task as any).agent || 'claude'} | Backend: tmux`, timestamp: new Date().toISOString() });
542
+
543
+ return executeTmuxTask(task, {
544
+ appendLog: (entry) => appendLog(task.id, entry),
545
+ isCancelled: () => getTask(task.id)?.status === 'cancelled',
546
+ setStatus: (status, detail) => {
547
+ if (status === 'done') {
548
+ updateTaskStatus(task.id, 'done');
549
+ if (detail?.resultSummary) db().prepare('UPDATE tasks SET result_summary = ? WHERE id = ?').run(detail.resultSummary.slice(0, 2048), task.id);
550
+ if (detail?.costUSD) db().prepare('UPDATE tasks SET cost_usd = ? WHERE id = ?').run(detail.costUSD, task.id);
551
+ runningProjects.delete(task.projectName);
552
+ const doneTask = getTask(task.id); if (doneTask) notifyTaskComplete(doneTask).catch(() => {});
553
+ } else if (status === 'failed') {
554
+ updateTaskStatus(task.id, 'failed', detail?.error);
555
+ if (detail?.costUSD) db().prepare('UPDATE tasks SET cost_usd = ? WHERE id = ?').run(detail.costUSD, task.id);
556
+ runningProjects.delete(task.projectName);
557
+ const failedTask = getTask(task.id); if (failedTask) notifyTaskFailed(failedTask).catch(() => {});
558
+ } else {
559
+ updateTaskStatus(task.id, 'cancelled');
560
+ runningProjects.delete(task.projectName);
561
+ }
562
+ emit(task.id, 'status');
563
+ },
564
+ }).catch((err) => {
565
+ updateTaskStatus(task.id, 'failed', err?.message || String(err));
566
+ runningProjects.delete(task.projectName);
567
+ emit(task.id, 'status');
568
+ });
569
+ }
570
+
503
571
  function executeShellTask(task: Task): Promise<void> {
504
572
  return new Promise((resolve) => {
505
573
  updateTaskStatus(task.id, 'running');
@@ -565,6 +633,7 @@ function executeShellTask(task: Task): Promise<void> {
565
633
 
566
634
  function executeTask(task: Task): Promise<void> {
567
635
  if (task.mode === 'shell') return executeShellTask(task);
636
+ if ((task as any).backend === 'tmux') return executeTmuxBackendTask(task);
568
637
 
569
638
  return new Promise((resolve, reject) => {
570
639
  const settings = loadSettings();
@@ -1017,6 +1086,7 @@ function rowToTask(row: any): Task {
1017
1086
  completedAt: toIsoUTC(row.completed_at) ?? undefined,
1018
1087
  scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
1019
1088
  agent: row.agent || undefined,
1089
+ backend: row.backend || undefined,
1020
1090
  } as Task;
1021
1091
  }
1022
1092