@aion0/forge 0.3.3 → 0.3.5

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/lib/pipeline.ts CHANGED
@@ -30,8 +30,10 @@ export interface WorkflowNode {
30
30
  id: string;
31
31
  project: string;
32
32
  prompt: string;
33
+ mode?: 'claude' | 'shell'; // default: 'claude' (claude -p), 'shell' runs raw shell command
34
+ branch?: string; // auto checkout this branch before running (supports templates)
33
35
  dependsOn: string[];
34
- outputs: { name: string; extract: 'result' | 'git_diff' }[];
36
+ outputs: { name: string; extract: 'result' | 'git_diff' | 'stdout' }[];
35
37
  routes: { condition: string; next: string }[];
36
38
  maxIterations: number;
37
39
  }
@@ -70,21 +72,183 @@ export interface Pipeline {
70
72
 
71
73
  // ─── Workflow Loading ─────────────────────────────────────
72
74
 
73
- export function listWorkflows(): Workflow[] {
74
- if (!existsSync(WORKFLOWS_DIR)) return [];
75
- return readdirSync(WORKFLOWS_DIR)
76
- .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
77
- .map(f => {
75
+ // ─── Built-in workflows ──────────────────────────────────
76
+
77
+ export const BUILTIN_WORKFLOWS: Record<string, string> = {
78
+ 'issue-auto-fix': `
79
+ name: issue-auto-fix
80
+ description: "Fetch a GitHub issue → fix code on a new branch → create PR"
81
+ input:
82
+ issue_id: "GitHub issue number"
83
+ project: "Project name"
84
+ base_branch: "Base branch (default: auto-detect)"
85
+ extra_context: "Additional instructions for the fix (optional)"
86
+ nodes:
87
+ setup:
88
+ mode: shell
89
+ project: "{{input.project}}"
90
+ prompt: |
91
+ cd "$(git rev-parse --show-toplevel)" && \
92
+ if [ -n "$(git status --porcelain)" ]; then echo "ERROR: Working directory has uncommitted changes. Please commit or stash first." && exit 1; fi && \
93
+ ORIG_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) && \
94
+ REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
95
+ BASE={{input.base_branch}} && \
96
+ if [ -z "$BASE" ] || [ "$BASE" = "auto-detect" ]; then BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main); fi && \
97
+ git checkout "$BASE" 2>/dev/null || true && \
98
+ git pull origin "$BASE" 2>/dev/null || true && \
99
+ OLD_BRANCH=$(git branch --list "fix/{{input.issue_id}}-*" | head -1 | tr -d ' *') && \
100
+ if [ -n "$OLD_BRANCH" ]; then git branch -D "$OLD_BRANCH" 2>/dev/null || true; fi && \
101
+ echo "REPO=$REPO" && echo "BASE=$BASE" && echo "ORIG_BRANCH=$ORIG_BRANCH"
102
+ outputs:
103
+ - name: info
104
+ extract: stdout
105
+ fetch-issue:
106
+ mode: shell
107
+ project: "{{input.project}}"
108
+ depends_on: [setup]
109
+ prompt: |
110
+ REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
111
+ gh issue view {{input.issue_id}} --json title,body,labels,number -R "$REPO"
112
+ outputs:
113
+ - name: issue_json
114
+ extract: stdout
115
+ fix-code:
116
+ project: "{{input.project}}"
117
+ depends_on: [fetch-issue]
118
+ prompt: |
119
+ A GitHub issue needs to be fixed. Here is the issue data:
120
+
121
+ {{nodes.fetch-issue.outputs.issue_json}}
122
+
123
+ Steps:
124
+ 1. Create a new branch from the current branch (which is already on the base). Name format: fix/{{input.issue_id}}-<short-description> (e.g. fix/3-add-validation, fix/15-null-pointer). Any old branch for this issue has been cleaned up.
125
+ 2. Analyze the issue and fix the code.
126
+ 3. Stage and commit with a message referencing #{{input.issue_id}}.
127
+
128
+ Base branch info: {{nodes.setup.outputs.info}}
129
+
130
+ Additional context from user: {{input.extra_context}}
131
+ outputs:
132
+ - name: summary
133
+ extract: result
134
+ - name: diff
135
+ extract: git_diff
136
+ push-and-pr:
137
+ mode: shell
138
+ project: "{{input.project}}"
139
+ depends_on: [fix-code]
140
+ prompt: |
141
+ REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
142
+ BRANCH=$(git branch --show-current) && \
143
+ git push -u origin "$BRANCH" --force-with-lease 2>&1 && \
144
+ PR_URL=$(gh pr create --title 'Fix #{{input.issue_id}}' \
145
+ --body 'Auto-fix by Forge Pipeline for issue #{{input.issue_id}}.' -R "$REPO" 2>/dev/null || \
146
+ gh pr view "$BRANCH" --json url -q .url -R "$REPO" 2>/dev/null) && \
147
+ echo "$PR_URL"
148
+ outputs:
149
+ - name: pr_url
150
+ extract: stdout
151
+ notify:
152
+ mode: shell
153
+ project: "{{input.project}}"
154
+ depends_on: [push-and-pr]
155
+ prompt: |
156
+ ORIG=$(echo '{{nodes.setup.outputs.info}}' | grep ORIG_BRANCH= | cut -d= -f2) && \
157
+ if [ -n "$(git status --porcelain)" ]; then
158
+ echo "PR created for issue #{{input.issue_id}}: {{nodes.push-and-pr.outputs.pr_url}} (staying on $(git branch --show-current) - uncommitted changes)"
159
+ else
160
+ git checkout "$ORIG" 2>/dev/null || true
161
+ echo "PR created for issue #{{input.issue_id}}: {{nodes.push-and-pr.outputs.pr_url}} (switched back to $ORIG)"
162
+ fi
163
+ `,
164
+ 'pr-review': `
165
+ name: pr-review
166
+ description: "Review a PR → approve or request changes → notify"
167
+ input:
168
+ pr_number: "Pull request number"
169
+ project: "Project name"
170
+ nodes:
171
+ setup:
172
+ mode: shell
173
+ project: "{{input.project}}"
174
+ prompt: |
175
+ REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
176
+ echo "REPO=$REPO"
177
+ outputs:
178
+ - name: info
179
+ extract: stdout
180
+ fetch-pr:
181
+ mode: shell
182
+ project: "{{input.project}}"
183
+ depends_on: [setup]
184
+ prompt: |
185
+ REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
186
+ gh pr diff {{input.pr_number}} -R "$REPO"
187
+ outputs:
188
+ - name: diff
189
+ extract: stdout
190
+ review:
191
+ project: "{{input.project}}"
192
+ depends_on: [fetch-pr]
193
+ prompt: |
194
+ Review the following pull request diff carefully. Check for:
195
+ - Bugs and logic errors
196
+ - Security vulnerabilities
197
+ - Performance issues
198
+ - Code style and best practices
199
+
200
+ PR #{{input.pr_number}} diff:
201
+ {{nodes.fetch-pr.outputs.diff}}
202
+
203
+ Respond with:
204
+ 1. APPROVED or CHANGES_REQUESTED
205
+ 2. Detailed list of specific issues found with file paths and line numbers
206
+ 3. Suggestions for improvement
207
+ outputs:
208
+ - name: review_result
209
+ extract: result
210
+ post-review:
211
+ mode: shell
212
+ project: "{{input.project}}"
213
+ depends_on: [review]
214
+ prompt: "echo 'Review complete for PR #{{input.pr_number}}'"
215
+ outputs:
216
+ - name: status
217
+ extract: stdout
218
+ `,
219
+ };
220
+
221
+ export interface WorkflowWithMeta extends Workflow {
222
+ builtin?: boolean;
223
+ }
224
+
225
+ export function listWorkflows(): WorkflowWithMeta[] {
226
+ // User workflows
227
+ const userWorkflows: WorkflowWithMeta[] = [];
228
+ if (existsSync(WORKFLOWS_DIR)) {
229
+ for (const f of readdirSync(WORKFLOWS_DIR).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
78
230
  try {
79
- return parseWorkflow(readFileSync(join(WORKFLOWS_DIR, f), 'utf-8'));
80
- } catch {
81
- return null;
231
+ userWorkflows.push({ ...parseWorkflow(readFileSync(join(WORKFLOWS_DIR, f), 'utf-8')), builtin: false });
232
+ } catch {}
233
+ }
234
+ }
235
+
236
+ // Built-in workflows (don't override user ones with same name)
237
+ const userNames = new Set(userWorkflows.map(w => w.name));
238
+ const builtins: WorkflowWithMeta[] = [];
239
+ for (const [, yaml] of Object.entries(BUILTIN_WORKFLOWS)) {
240
+ try {
241
+ const w = parseWorkflow(yaml);
242
+ if (!userNames.has(w.name)) {
243
+ builtins.push({ ...w, builtin: true });
82
244
  }
83
- })
84
- .filter(Boolean) as Workflow[];
245
+ } catch {}
246
+ }
247
+
248
+ return [...builtins, ...userWorkflows];
85
249
  }
86
250
 
87
- export function getWorkflow(name: string): Workflow | null {
251
+ export function getWorkflow(name: string): WorkflowWithMeta | null {
88
252
  return listWorkflows().find(w => w.name === name) || null;
89
253
  }
90
254
 
@@ -98,6 +262,8 @@ function parseWorkflow(raw: string): Workflow {
98
262
  id,
99
263
  project: n.project || '',
100
264
  prompt: n.prompt || '',
265
+ mode: n.mode || 'claude',
266
+ branch: n.branch || undefined,
101
267
  dependsOn: n.depends_on || n.dependsOn || [],
102
268
  outputs: (n.outputs || []).map((o: any) => ({
103
269
  name: o.name,
@@ -167,31 +333,61 @@ export function listPipelines(): Pipeline[] {
167
333
 
168
334
  // ─── Template Resolution ──────────────────────────────────
169
335
 
336
+ /** Escape a string for safe embedding in shell commands (single-quote wrapping) */
337
+ function shellEscape(s: string): string {
338
+ // Replace single quotes with '\'' (end quote, escaped quote, start quote)
339
+ return s.replace(/'/g, "'\\''");
340
+ }
341
+
170
342
  function resolveTemplate(template: string, ctx: {
171
343
  input: Record<string, string>;
172
344
  vars: Record<string, string>;
173
345
  nodes: Record<string, PipelineNodeState>;
174
- }): string {
346
+ }, shellMode?: boolean): string {
175
347
  return template.replace(/\{\{(.*?)\}\}/g, (_, expr) => {
176
348
  const path = expr.trim();
349
+ let value = '';
177
350
 
178
351
  // {{input.xxx}}
179
- if (path.startsWith('input.')) return ctx.input[path.slice(6)] || '';
180
-
352
+ if (path.startsWith('input.')) value = ctx.input[path.slice(6)] || '';
181
353
  // {{vars.xxx}}
182
- if (path.startsWith('vars.')) return ctx.vars[path.slice(5)] || '';
183
-
354
+ else if (path.startsWith('vars.')) value = ctx.vars[path.slice(5)] || '';
184
355
  // {{nodes.xxx.outputs.yyy}}
185
- const nodeMatch = path.match(/^nodes\.(\w+)\.outputs\.(\w+)$/);
186
- if (nodeMatch) {
187
- const [, nodeId, outputName] = nodeMatch;
188
- return ctx.nodes[nodeId]?.outputs[outputName] || '';
356
+ else {
357
+ const nodeMatch = path.match(/^nodes\.([\w-]+)\.outputs\.([\w-]+)$/);
358
+ if (nodeMatch) {
359
+ const [, nodeId, outputName] = nodeMatch;
360
+ value = ctx.nodes[nodeId]?.outputs[outputName] || '';
361
+ } else {
362
+ return `{{${path}}}`;
363
+ }
189
364
  }
190
365
 
191
- return `{{${path}}}`;
366
+ return shellMode ? shellEscape(value) : value;
192
367
  });
193
368
  }
194
369
 
370
+ // ─── Project-level pipeline lock ─────────────────────────
371
+ const projectPipelineLocks = new Map<string, string>(); // projectPath → pipelineId
372
+
373
+ function acquireProjectLock(projectPath: string, pipelineId: string): boolean {
374
+ const existing = projectPipelineLocks.get(projectPath);
375
+ if (existing && existing !== pipelineId) {
376
+ // Check if the existing pipeline is still running
377
+ const p = getPipeline(existing);
378
+ if (p && p.status === 'running') return false;
379
+ // Stale lock, clear it
380
+ }
381
+ projectPipelineLocks.set(projectPath, pipelineId);
382
+ return true;
383
+ }
384
+
385
+ function releaseProjectLock(projectPath: string, pipelineId: string) {
386
+ if (projectPipelineLocks.get(projectPath) === pipelineId) {
387
+ projectPipelineLocks.delete(projectPath);
388
+ }
389
+ }
390
+
195
391
  // ─── Pipeline Execution ───────────────────────────────────
196
392
 
197
393
  export function startPipeline(workflowName: string, input: Record<string, string>): Pipeline {
@@ -254,7 +450,7 @@ function recoverStuckPipelines() {
254
450
  const nodeDef = workflow.nodes[nodeId];
255
451
  if (nodeDef) {
256
452
  for (const outputDef of nodeDef.outputs) {
257
- if (outputDef.extract === 'result') node.outputs[outputDef.name] = task.resultSummary || '';
453
+ if (outputDef.extract === 'result' || outputDef.extract === 'stdout') node.outputs[outputDef.name] = task.resultSummary || '';
258
454
  else if (outputDef.extract === 'git_diff') node.outputs[outputDef.name] = task.gitDiff || '';
259
455
  }
260
456
  }
@@ -337,8 +533,9 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
337
533
  if (!depsReady) continue;
338
534
 
339
535
  // Resolve templates
536
+ const isShell = nodeDef.mode === 'shell';
340
537
  const project = resolveTemplate(nodeDef.project, ctx);
341
- const prompt = resolveTemplate(nodeDef.prompt, ctx);
538
+ const prompt = resolveTemplate(nodeDef.prompt, ctx, isShell);
342
539
 
343
540
  const projectInfo = getProjectInfo(project);
344
541
  if (!projectInfo) {
@@ -349,16 +546,41 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
349
546
  continue;
350
547
  }
351
548
 
352
- // Create task with pipeline model
549
+ // Auto checkout branch if specified
550
+ if (nodeDef.branch) {
551
+ const branchName = resolveTemplate(nodeDef.branch, ctx);
552
+ try {
553
+ const { execSync } = require('node:child_process');
554
+ // Create branch if not exists, or switch to it
555
+ try {
556
+ execSync(`git checkout -b ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
557
+ } catch {
558
+ execSync(`git checkout ${branchName}`, { cwd: projectInfo.path, stdio: 'pipe' });
559
+ }
560
+ console.log(`[pipeline] Checked out branch: ${branchName}`);
561
+ } catch (e: any) {
562
+ nodeState.status = 'failed';
563
+ nodeState.error = `Branch checkout failed: ${e.message}`;
564
+ savePipeline(pipeline);
565
+ notifyStep(pipeline, nodeId, 'failed', nodeState.error);
566
+ continue;
567
+ }
568
+ }
569
+
570
+ // Create task — mode: 'shell' runs raw command, 'claude' runs claude -p
571
+ const taskMode = nodeDef.mode === 'shell' ? 'shell' : 'prompt';
353
572
  const task = createTask({
354
573
  projectName: projectInfo.name,
355
574
  projectPath: projectInfo.path,
356
575
  prompt,
576
+ mode: taskMode as any,
357
577
  });
358
578
  pipelineTaskIds.add(task.id);
359
- const pipelineModel = loadSettings().pipelineModel;
360
- if (pipelineModel && pipelineModel !== 'default') {
361
- taskModelOverrides.set(task.id, pipelineModel);
579
+ if (taskMode !== 'shell') {
580
+ const pipelineModel = loadSettings().pipelineModel;
581
+ if (pipelineModel && pipelineModel !== 'default') {
582
+ taskModelOverrides.set(task.id, pipelineModel);
583
+ }
362
584
  }
363
585
 
364
586
  nodeState.status = 'running';
@@ -384,6 +606,50 @@ function checkPipelineCompletion(pipeline: Pipeline) {
384
606
  pipeline.completedAt = new Date().toISOString();
385
607
  savePipeline(pipeline);
386
608
  notifyPipelineComplete(pipeline);
609
+
610
+ // Update issue_autofix_processed status
611
+ if (pipeline.workflowName === 'issue-auto-fix') {
612
+ try {
613
+ const { updateProcessedStatus } = require('./issue-scanner');
614
+ const issueId = parseInt(pipeline.input.issue_id);
615
+ const projectInfo = getProjectInfo(pipeline.input.project);
616
+ if (projectInfo && issueId) {
617
+ const prOutput = pipeline.nodes['push-and-pr']?.outputs?.pr_url || '';
618
+ const prMatch = prOutput.match(/\/pull\/(\d+)/);
619
+ const prNumber = prMatch ? parseInt(prMatch[1]) : undefined;
620
+ updateProcessedStatus(projectInfo.path, issueId, pipeline.status, prNumber);
621
+ }
622
+ } catch {}
623
+ }
624
+
625
+ // Auto-chain: issue-auto-fix → pr-review
626
+ if (pipeline.workflowName === 'issue-auto-fix' && pipeline.status === 'done') {
627
+ try {
628
+ // Extract PR number from push-and-pr output
629
+ const prOutput = pipeline.nodes['push-and-pr']?.outputs?.pr_url || '';
630
+ const prMatch = prOutput.match(/\/pull\/(\d+)/);
631
+ if (prMatch) {
632
+ const prNumber = prMatch[1];
633
+ console.log(`[pipeline] Auto-triggering pr-review for PR #${prNumber}`);
634
+ startPipeline('pr-review', {
635
+ pr_number: prNumber,
636
+ project: pipeline.input.project || '',
637
+ });
638
+ }
639
+ } catch (e) {
640
+ console.error('[pipeline] Failed to auto-trigger pr-review:', e);
641
+ }
642
+ }
643
+
644
+ // Release project lock
645
+ const workflow = getWorkflow(pipeline.workflowName);
646
+ if (workflow) {
647
+ const projectNames = new Set(Object.values(workflow.nodes).map(n => n.project));
648
+ for (const pName of projectNames) {
649
+ const pInfo = getProjectInfo(resolveTemplate(pName, { input: pipeline.input, vars: pipeline.vars, nodes: pipeline.nodes }));
650
+ if (pInfo) releaseProjectLock(pInfo.path, pipeline.id);
651
+ }
652
+ }
387
653
  }
388
654
  }
389
655
 
@@ -422,6 +688,8 @@ function setupTaskListener(pipelineId: string) {
422
688
  for (const outputDef of nodeDef.outputs) {
423
689
  if (outputDef.extract === 'result') {
424
690
  nodeState.outputs[outputDef.name] = task.resultSummary || '';
691
+ } else if (outputDef.extract === 'stdout') {
692
+ nodeState.outputs[outputDef.name] = task.resultSummary || '';
425
693
  } else if (outputDef.extract === 'git_diff') {
426
694
  nodeState.outputs[outputDef.name] = task.gitDiff || '';
427
695
  }
package/lib/settings.ts CHANGED
@@ -26,6 +26,7 @@ export interface Settings {
26
26
  skillsRepoUrl: string; // GitHub raw URL for skills registry
27
27
  displayName: string; // User display name (shown in header)
28
28
  displayEmail: string; // User email (for session/future integrations)
29
+ favoriteProjects: string[]; // Favorite project paths (shown at top of sidebar)
29
30
  }
30
31
 
31
32
  const defaults: Settings = {
@@ -47,6 +48,7 @@ const defaults: Settings = {
47
48
  skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
48
49
  displayName: 'Forge',
49
50
  displayEmail: '',
51
+ favoriteProjects: [],
50
52
  };
51
53
 
52
54
  /** Load settings with secrets decrypted (for internal use) */
package/lib/skills.ts CHANGED
@@ -34,6 +34,7 @@ export interface SkillItem {
34
34
  installedProjects: string[];
35
35
  installedVersion: string; // version currently installed (empty if not installed)
36
36
  hasUpdate: boolean; // true if registry version > installed version
37
+ deletedRemotely: boolean; // true if removed from remote registry but still installed locally
37
38
  }
38
39
 
39
40
  function db() {
@@ -182,13 +183,16 @@ export async function syncSkills(): Promise<{ synced: number; error?: string }>
182
183
  });
183
184
  tx();
184
185
 
185
- // Remove items no longer in registry (not installed locally)
186
+ // Handle items no longer in registry
186
187
  const registryNames = new Set(items.map(s => s.name));
187
188
  const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
188
189
  for (const row of dbItems) {
189
190
  if (!registryNames.has(row.name)) {
190
191
  const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
191
- if (!hasLocal) {
192
+ if (hasLocal) {
193
+ // Still installed locally — mark as deleted remotely so the user can decide
194
+ db().prepare('UPDATE skills SET deleted_remotely = 1 WHERE name = ?').run(row.name);
195
+ } else {
192
196
  db().prepare('DELETE FROM skills WHERE name = ?').run(row.name);
193
197
  }
194
198
  }
@@ -223,6 +227,7 @@ export function listSkills(): SkillItem[] {
223
227
  installedProjects: JSON.parse(r.installed_projects || '[]'),
224
228
  installedVersion,
225
229
  hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
230
+ deletedRemotely: !!r.deleted_remotely,
226
231
  };
227
232
  });
228
233
  }
@@ -529,6 +534,29 @@ export function installLocal(name: string, type: string, sourceProject: string |
529
534
  return { ok: true };
530
535
  }
531
536
 
537
+ /** Remove all local files for a skill deleted from the remote registry, then drop its DB record. */
538
+ export function purgeDeletedSkill(name: string): void {
539
+ const row = db().prepare('SELECT type, installed_projects FROM skills WHERE name = ?').get(name) as any;
540
+ if (!row) return;
541
+ const type: string = row.type || 'skill';
542
+ const projects: string[] = JSON.parse(row.installed_projects || '[]');
543
+
544
+ // Remove global files
545
+ try { rmSync(join(GLOBAL_SKILLS_DIR, name), { recursive: true }); } catch {}
546
+ try { unlinkSync(join(GLOBAL_COMMANDS_DIR, `${name}.md`)); } catch {}
547
+ try { rmSync(join(GLOBAL_COMMANDS_DIR, name), { recursive: true }); } catch {}
548
+
549
+ // Remove project-local files
550
+ for (const pp of projects) {
551
+ try { rmSync(join(pp, '.claude', 'skills', name), { recursive: true }); } catch {}
552
+ try { unlinkSync(join(pp, '.claude', 'commands', `${name}.md`)); } catch {}
553
+ try { rmSync(join(pp, '.claude', 'commands', name), { recursive: true }); } catch {}
554
+ }
555
+
556
+ // Remove DB record entirely
557
+ db().prepare('DELETE FROM skills WHERE name = ?').run(name);
558
+ }
559
+
532
560
  /** Delete a local skill/command from a specific project or global */
533
561
  export function deleteLocal(name: string, type: string, projectPath?: string): boolean {
534
562
  const base = projectPath ? join(projectPath, '.claude') : getClaudeDir();
@@ -235,7 +235,46 @@ async function processNextTask() {
235
235
  }
236
236
  }
237
237
 
238
+ function executeShellTask(task: Task): Promise<void> {
239
+ return new Promise((resolve) => {
240
+ updateTaskStatus(task.id, 'running');
241
+ db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
242
+ console.log(`[task:shell] ${task.projectName}: "${task.prompt.slice(0, 80)}"`);
243
+
244
+ const child = spawn('bash', ['-c', task.prompt], {
245
+ cwd: task.projectPath,
246
+ env: { ...process.env },
247
+ stdio: ['ignore', 'pipe', 'pipe'],
248
+ });
249
+
250
+ let stdout = '';
251
+ let stderr = '';
252
+ child.stdout.on('data', (chunk: Buffer) => {
253
+ const text = chunk.toString();
254
+ stdout += text;
255
+ appendLog(task.id, { type: 'system', subtype: 'text', content: text, timestamp: new Date().toISOString() });
256
+ });
257
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
258
+
259
+ child.on('exit', (code) => {
260
+ if (code === 0) {
261
+ db().prepare('UPDATE tasks SET status = ?, result_summary = ?, completed_at = datetime(\'now\') WHERE id = ?')
262
+ .run('done', stdout.trim(), task.id);
263
+ emit(task.id, 'status', 'done');
264
+ } else {
265
+ const errMsg = stderr.trim() || `Exit code ${code}`;
266
+ db().prepare('UPDATE tasks SET status = ?, error = ?, completed_at = datetime(\'now\') WHERE id = ?')
267
+ .run('failed', errMsg, task.id);
268
+ emit(task.id, 'status', 'failed');
269
+ }
270
+ resolve();
271
+ });
272
+ });
273
+ }
274
+
238
275
  function executeTask(task: Task): Promise<void> {
276
+ if (task.mode === 'shell') return executeShellTask(task);
277
+
239
278
  return new Promise((resolve, reject) => {
240
279
  const settings = loadSettings();
241
280
  const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
@@ -28,12 +28,16 @@ import { WebSocketServer, WebSocket } from 'ws';
28
28
  import * as pty from 'node-pty';
29
29
  import { execSync } from 'node:child_process';
30
30
  import { homedir } from 'node:os';
31
+ import { createHash } from 'node:crypto';
31
32
  import { getDataDir } from './dirs';
32
33
  import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
33
34
  import { join } from 'node:path';
34
35
 
35
36
  const PORT = Number(process.env.TERMINAL_PORT) || 3001;
36
- const SESSION_PREFIX = 'mw-';
37
+ // Session prefix based on DATA_DIR hash — default instance keeps 'mw-' for backward compat
38
+ const _dataDir = process.env.FORGE_DATA_DIR || '';
39
+ const _isDefault = !_dataDir || _dataDir.endsWith('/data') || _dataDir.endsWith('/.forge');
40
+ const SESSION_PREFIX = _isDefault ? 'mw-' : `mw${createHash('md5').update(_dataDir).digest('hex').slice(0, 6)}-`;
37
41
 
38
42
  // Remove CLAUDECODE env so Claude Code can run inside terminal sessions
39
43
  delete process.env.CLAUDECODE;
package/next.config.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { NextConfig } from 'next';
2
2
 
3
+ const terminalPort = parseInt(process.env.TERMINAL_PORT || '') || 3001;
4
+
3
5
  const nextConfig: NextConfig = {
4
6
  serverExternalPackages: ['better-sqlite3'],
5
7
  async rewrites() {
@@ -7,7 +9,7 @@ const nextConfig: NextConfig = {
7
9
  {
8
10
  // Proxy terminal WebSocket through Next.js so it works via Cloudflare Tunnel
9
11
  source: '/terminal-ws',
10
- destination: 'http://localhost:3001',
12
+ destination: `http://localhost:${terminalPort}`,
11
13
  },
12
14
  ];
13
15
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,6 +33,7 @@ function initSchema(db: Database.Database) {
33
33
  migrate('ALTER TABLE skills ADD COLUMN archive TEXT');
34
34
  migrate("ALTER TABLE skills ADD COLUMN installed_version TEXT NOT NULL DEFAULT ''");
35
35
  migrate('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
36
+ migrate('ALTER TABLE skills ADD COLUMN deleted_remotely INTEGER NOT NULL DEFAULT 0');
36
37
 
37
38
  db.exec(`
38
39
  CREATE TABLE IF NOT EXISTS sessions (
@@ -148,6 +149,18 @@ function initSchema(db: Database.Database) {
148
149
  synced_at TEXT NOT NULL DEFAULT (datetime('now'))
149
150
  );
150
151
 
152
+ -- Tab state (projects, docs, etc)
153
+ CREATE TABLE IF NOT EXISTS tab_state (
154
+ type TEXT PRIMARY KEY,
155
+ data TEXT NOT NULL DEFAULT '{}'
156
+ );
157
+
158
+ -- Project favorites
159
+ CREATE TABLE IF NOT EXISTS project_favorites (
160
+ project_path TEXT PRIMARY KEY,
161
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
162
+ );
163
+
151
164
  -- Session watchers — monitor sessions and notify via Telegram
152
165
  CREATE TABLE IF NOT EXISTS session_watchers (
153
166
  id TEXT PRIMARY KEY,
@@ -76,7 +76,7 @@ export interface UsageRecord {
76
76
  }
77
77
 
78
78
  export type TaskStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
79
- export type TaskMode = 'prompt' | 'monitor';
79
+ export type TaskMode = 'prompt' | 'monitor' | 'shell';
80
80
 
81
81
  export interface WatchConfig {
82
82
  condition: 'change' | 'idle' | 'complete' | 'error' | 'keyword';