@aion0/forge 0.10.79 → 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.
- package/RELEASE_NOTES.md +8 -5
- package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
- package/app/api/tasks/route.ts +2 -1
- package/cli/mw.mjs +7 -5
- package/cli/mw.ts +8 -6
- package/components/Dashboard.tsx +6 -2
- package/components/TaskDetail.tsx +28 -1
- package/components/TmuxTaskTerminal.tsx +105 -0
- package/components/WebTerminal.tsx +7 -0
- package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
- package/docs/design_automation_records/README.md +232 -0
- package/lib/chat/agent-loop.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +110 -9
- package/lib/help-docs/05-pipelines.md +31 -0
- package/lib/help-docs/25-chat-tools.md +23 -0
- package/lib/pipeline.ts +27 -3
- package/lib/task-manager.ts +73 -3
- package/lib/task-tmux-backend.ts +625 -0
- package/lib/workspace/skill-installer.ts +18 -8
- package/package.json +1 -1
- package/proxy.ts +5 -4
- package/src/core/db/database.ts +1 -0
- 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
|
|
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
|
package/lib/task-manager.ts
CHANGED
|
@@ -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
|
|