@aion0/forge 0.9.0 → 0.9.2

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 (70) hide show
  1. package/RELEASE_NOTES.md +60 -7
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +116 -7
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. package/lib/help-docs/22-recipes.md +0 -124
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Step-5 verification: snapshotIteration captures node states + correctly
3
+ * advances the iterations[] history. (Full checkPipelineCompletion has
4
+ * filesystem + scheduling side effects — exercised end-to-end in Step 7.)
5
+ *
6
+ * Run: npx tsx lib/__tests__/foreach-snapshot.test.ts
7
+ */
8
+
9
+ import { snapshotIteration } from '../pipeline';
10
+
11
+ let passed = 0, failed = 0;
12
+ function check(name: string, fn: () => void) {
13
+ try { fn(); console.log(` ✓ ${name}`); passed++; }
14
+ catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
15
+ }
16
+
17
+ function makePipeline(items: unknown[], currentIndex = 0): any {
18
+ return {
19
+ id: 'test123',
20
+ workflowName: 'test',
21
+ status: 'running',
22
+ input: {},
23
+ vars: {},
24
+ nodes: {
25
+ a: { status: 'done', outputs: { x: '1' } },
26
+ b: { status: 'done', outputs: { y: '2' }, error: undefined },
27
+ },
28
+ nodeOrder: ['a', 'b'],
29
+ createdAt: '2026-01-01T00:00:00.000Z',
30
+ forEach: {
31
+ items,
32
+ currentIndex,
33
+ total: items.length,
34
+ asName: 'item',
35
+ onFailure: 'continue',
36
+ iterations: [],
37
+ },
38
+ };
39
+ }
40
+
41
+ console.log('snapshotIteration tests');
42
+
43
+ check('snapshot captures all-done nodes with status="done"', () => {
44
+ const p = makePipeline([1, 2, 3]);
45
+ snapshotIteration(p, false);
46
+ if (p.forEach.iterations.length !== 1) throw new Error('iterations length wrong');
47
+ const iter = p.forEach.iterations[0];
48
+ if (iter.status !== 'done') throw new Error(`status wrong: ${iter.status}`);
49
+ if (iter.index !== 0) throw new Error('index wrong');
50
+ if (iter.nodes.a.status !== 'done') throw new Error('node a status wrong');
51
+ if (iter.nodes.a.outputs.x !== '1') throw new Error('node a output not preserved');
52
+ });
53
+
54
+ check('snapshot captures failed iter with status="failed"', () => {
55
+ const p = makePipeline([1, 2]);
56
+ p.nodes.b.status = 'failed';
57
+ p.nodes.b.error = 'boom';
58
+ snapshotIteration(p, true);
59
+ const iter = p.forEach.iterations[0];
60
+ if (iter.status !== 'failed') throw new Error(`status wrong: ${iter.status}`);
61
+ if (iter.nodes.b.error !== 'boom') throw new Error('error not preserved');
62
+ });
63
+
64
+ check('startedAt = createdAt for first iter', () => {
65
+ const p = makePipeline([1, 2]);
66
+ snapshotIteration(p, false);
67
+ if (p.forEach.iterations[0].startedAt !== p.createdAt) throw new Error('first startedAt should = createdAt');
68
+ });
69
+
70
+ check('startedAt = previous completedAt for subsequent iters', () => {
71
+ const p = makePipeline([1, 2]);
72
+ snapshotIteration(p, false);
73
+ const firstCompleted = p.forEach.iterations[0].completedAt;
74
+ p.forEach.currentIndex = 1;
75
+ snapshotIteration(p, false);
76
+ if (p.forEach.iterations[1].startedAt !== firstCompleted) {
77
+ throw new Error('second iter startedAt should chain from prev completedAt');
78
+ }
79
+ });
80
+
81
+ check('outputs are deep-copied (mutation safety)', () => {
82
+ const p = makePipeline([1]);
83
+ snapshotIteration(p, false);
84
+ const iter = p.forEach.iterations[0];
85
+ // Mutate live node — snapshot should NOT change
86
+ p.nodes.a.outputs.x = 'MUTATED';
87
+ if (iter.nodes.a.outputs.x !== '1') throw new Error('snapshot was mutated; need defensive copy');
88
+ });
89
+
90
+ check('multiple iterations accumulate', () => {
91
+ const p = makePipeline([1, 2, 3]);
92
+ snapshotIteration(p, false);
93
+ p.forEach.currentIndex = 1;
94
+ snapshotIteration(p, false);
95
+ p.forEach.currentIndex = 2;
96
+ snapshotIteration(p, true);
97
+ if (p.forEach.iterations.length !== 3) throw new Error('expected 3 iters');
98
+ if (p.forEach.iterations[2].status !== 'failed') throw new Error('iter 3 should be failed');
99
+ if (p.forEach.iterations[0].index !== 0) throw new Error('iter 0 index wrong');
100
+ if (p.forEach.iterations[1].index !== 1) throw new Error('iter 1 index wrong');
101
+ if (p.forEach.iterations[2].index !== 2) throw new Error('iter 2 index wrong');
102
+ });
103
+
104
+ check('no-op when pipeline.forEach undefined', () => {
105
+ const p: any = { id: 'x', nodes: {}, createdAt: '2026-01-01' };
106
+ snapshotIteration(p, false);
107
+ // Should not throw, should not add anything
108
+ if (p.forEach !== undefined) throw new Error('should leave forEach undefined');
109
+ });
110
+
111
+ console.log(`\n${passed} passed, ${failed} failed`);
112
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Step-4 verification: resolveForEachSource resolves + splits items.
3
+ * Run: npx tsx lib/__tests__/foreach-source.test.ts
4
+ */
5
+
6
+ import { resolveForEachSource } from '../pipeline';
7
+
8
+ let passed = 0, failed = 0;
9
+ function check(name: string, fn: () => void) {
10
+ try { fn(); console.log(` ✓ ${name}`); passed++; }
11
+ catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
12
+ }
13
+ function eqArr(a: unknown[], b: unknown[], label = '') {
14
+ if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
15
+ throw new Error(`${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
16
+ }
17
+ }
18
+
19
+ console.log('resolveForEachSource tests');
20
+
21
+ check('string source via {{input.x}} template, default split=","', () => {
22
+ const items = resolveForEachSource(
23
+ { source: '{{input.ids}}' },
24
+ { ids: '111,222,333' },
25
+ {},
26
+ );
27
+ eqArr(items, ['111', '222', '333']);
28
+ });
29
+
30
+ check('string source with whitespace around commas — trimmed', () => {
31
+ const items = resolveForEachSource(
32
+ { source: '{{input.ids}}' },
33
+ { ids: ' 111 ,222 , 333 ' },
34
+ {},
35
+ );
36
+ eqArr(items, ['111', '222', '333']);
37
+ });
38
+
39
+ check('string source with empty entries dropped', () => {
40
+ const items = resolveForEachSource(
41
+ { source: '{{input.ids}}' },
42
+ { ids: '111,,222, ,333,' },
43
+ {},
44
+ );
45
+ eqArr(items, ['111', '222', '333']);
46
+ });
47
+
48
+ check('empty input.ids → empty array', () => {
49
+ const items = resolveForEachSource({ source: '{{input.ids}}' }, { ids: '' }, {});
50
+ eqArr(items, []);
51
+ });
52
+
53
+ check('input missing → empty string template → empty array', () => {
54
+ const items = resolveForEachSource({ source: '{{input.missing}}' }, {}, {});
55
+ eqArr(items, []);
56
+ });
57
+
58
+ check('custom split: ";"', () => {
59
+ const items = resolveForEachSource(
60
+ { source: '{{input.ids}}', split: ';' },
61
+ { ids: '111;222;333' },
62
+ {},
63
+ );
64
+ eqArr(items, ['111', '222', '333']);
65
+ });
66
+
67
+ check('array literal source — used as-is', () => {
68
+ const items = resolveForEachSource(
69
+ { source: ['a', 'b', 'c'] },
70
+ {},
71
+ {},
72
+ );
73
+ eqArr(items, ['a', 'b', 'c']);
74
+ });
75
+
76
+ check('array literal with objects — preserved', () => {
77
+ const items = resolveForEachSource(
78
+ { source: [{ iid: 1 }, { iid: 2 }] },
79
+ {},
80
+ {},
81
+ );
82
+ if (items.length !== 2) throw new Error('length wrong');
83
+ if ((items[0] as any).iid !== 1) throw new Error('first item wrong');
84
+ });
85
+
86
+ check('vars source resolves', () => {
87
+ const items = resolveForEachSource(
88
+ { source: '{{vars.list}}' },
89
+ {},
90
+ { list: 'a,b,c' },
91
+ );
92
+ eqArr(items, ['a', 'b', 'c']);
93
+ });
94
+
95
+ check('literal string with no template, just split', () => {
96
+ const items = resolveForEachSource(
97
+ { source: 'foo,bar' },
98
+ {},
99
+ {},
100
+ );
101
+ eqArr(items, ['foo', 'bar']);
102
+ });
103
+
104
+ console.log(`\n${passed} passed, ${failed} failed`);
105
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Step-3 verification: resolveTemplate handles for_each context.
3
+ * Run: npx tsx lib/__tests__/foreach-template.test.ts
4
+ */
5
+
6
+ import { resolveTemplate } from '../pipeline';
7
+
8
+ let passed = 0;
9
+ let failed = 0;
10
+ function check(name: string, fn: () => void) {
11
+ try { fn(); console.log(` ✓ ${name}`); passed++; }
12
+ catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
13
+ }
14
+ function eq(actual: string, expected: string, label = '') {
15
+ if (actual !== expected) {
16
+ throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
17
+ }
18
+ }
19
+
20
+ const baseCtx = { input: {}, vars: {}, nodes: {} };
21
+
22
+ console.log('resolveTemplate + for_each tests');
23
+
24
+ check('no forEach in ctx — {{item}} is literal passthrough', () => {
25
+ eq(resolveTemplate('hello {{item}}', baseCtx), 'hello {{item}}');
26
+ });
27
+
28
+ check('scalar item: {{<as>}} substitutes', () => {
29
+ const ctx = { ...baseCtx, forEach: { asName: 'bug_id', item: 1213844, index: 0, total: 3 } };
30
+ eq(resolveTemplate('Fix bug {{bug_id}} now', ctx), 'Fix bug 1213844 now');
31
+ });
32
+
33
+ check('string item: {{<as>}} preserves string', () => {
34
+ const ctx = { ...baseCtx, forEach: { asName: 'name', item: 'alice', index: 0, total: 1 } };
35
+ eq(resolveTemplate('hi {{name}}', ctx), 'hi alice');
36
+ });
37
+
38
+ check('object item: {{<as>.field}} dotted access', () => {
39
+ const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, title: 'fix x' }, index: 0, total: 1 } };
40
+ eq(resolveTemplate('MR !{{mr.iid}}: {{mr.title}}', ctx), 'MR !42: fix x');
41
+ });
42
+
43
+ check('object item: missing field → empty string', () => {
44
+ const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42 }, index: 0, total: 1 } };
45
+ eq(resolveTemplate('author=@{{mr.author}}', ctx), 'author=@');
46
+ });
47
+
48
+ check('object item: {{<as>}} alone → JSON', () => {
49
+ const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, t: 'x' }, index: 0, total: 1 } };
50
+ eq(resolveTemplate('raw={{mr}}', ctx), 'raw={"iid":42,"t":"x"}');
51
+ });
52
+
53
+ check('{{loop.index}} + {{loop.total}}', () => {
54
+ const ctx = { ...baseCtx, forEach: { asName: 'item', item: 'x', index: 2, total: 5 } };
55
+ eq(resolveTemplate('Step {{loop.index}} of {{loop.total}}', ctx), 'Step 2 of 5');
56
+ });
57
+
58
+ check('shellMode escapes scalar item', () => {
59
+ const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
60
+ // shellEscapeAnsiC: ' → \'
61
+ const out = resolveTemplate("echo '{{name}}'", ctx, true);
62
+ if (!out.includes("o\\'brien")) throw new Error(`expected escaped apostrophe, got: ${out}`);
63
+ });
64
+
65
+ check('{{raw:<as>}} bypasses shell escape', () => {
66
+ const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
67
+ // raw: keeps verbatim
68
+ eq(resolveTemplate('{{raw:name}}', ctx, true), "o'brien");
69
+ });
70
+
71
+ check('input.x still works alongside forEach', () => {
72
+ const ctx = { input: { proj: 'FortiNAC' }, vars: {}, nodes: {}, forEach: { asName: 'bug_id', item: 1, index: 0, total: 1 } };
73
+ eq(resolveTemplate('{{input.proj}}/mantis-{{bug_id}}', ctx), 'FortiNAC/mantis-1');
74
+ });
75
+
76
+ check('nodes.X.outputs.Y still works alongside forEach', () => {
77
+ const ctx: any = {
78
+ input: {},
79
+ vars: {},
80
+ nodes: { ingest: { status: 'done', outputs: { ctx: 'hello' }, iterations: 1 } },
81
+ forEach: { asName: 'item', item: 'x', index: 0, total: 1 },
82
+ };
83
+ eq(resolveTemplate('{{nodes.ingest.outputs.ctx}}/{{item}}', ctx), 'hello/x');
84
+ });
85
+
86
+ check('run.tmp_dir still works alongside forEach', () => {
87
+ const ctx = {
88
+ ...baseCtx,
89
+ tmpDir: '/path/.forge/worktrees/pipeline-abc',
90
+ forEach: { asName: 'bug_id', item: 42, index: 0, total: 1 },
91
+ };
92
+ eq(resolveTemplate('{{run.tmp_dir}}/mantis-{{bug_id}}', ctx),
93
+ '/path/.forge/worktrees/pipeline-abc/mantis-42');
94
+ });
95
+
96
+ check('forEach.item = null → empty string', () => {
97
+ const ctx = { ...baseCtx, forEach: { asName: 'x', item: null, index: 0, total: 1 } };
98
+ eq(resolveTemplate('[{{x}}]', ctx), '[]');
99
+ });
100
+
101
+ check('forEach.item = false → "false"', () => {
102
+ const ctx = { ...baseCtx, forEach: { asName: 'x', item: false, index: 0, total: 1 } };
103
+ eq(resolveTemplate('[{{x}}]', ctx), '[false]');
104
+ });
105
+
106
+ check('loop.index works even when item is undefined-but-asName-set (defensive)', () => {
107
+ const ctx = { ...baseCtx, forEach: { asName: 'item', item: undefined, index: 7, total: 9 } };
108
+ eq(resolveTemplate('{{loop.index}}/{{loop.total}}', ctx), '7/9');
109
+ });
110
+
111
+ console.log(`\n${passed} passed, ${failed} failed`);
112
+ process.exit(failed === 0 ? 0 : 1);
@@ -72,7 +72,7 @@ interface ProviderResolution {
72
72
  * Anthropic's API is the only first-class non-OpenAI shape we ship; LiteLLM /
73
73
  * Azure / OpenAI proxies / Grok / Google-via-LiteLLM all speak OpenAI.
74
74
  */
75
- function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
75
+ export function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
76
76
  const p = (provider || '').toLowerCase();
77
77
  if (p === 'anthropic' || p === 'claude') return 'anthropic';
78
78
  return 'openai';
@@ -106,7 +106,7 @@ export function defaultBaseUrl(provider: string | undefined): string {
106
106
  }
107
107
  }
108
108
 
109
- function pickBaseUrl(
109
+ export function pickBaseUrl(
110
110
  profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
111
111
  adapter: 'anthropic' | 'openai',
112
112
  ): string {
@@ -119,7 +119,7 @@ function pickBaseUrl(
119
119
  return defaultBaseUrl(profile.provider);
120
120
  }
121
121
 
122
- function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
122
+ export function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
123
123
  if (profile.apiKey) return profile.apiKey;
124
124
  const env = profile.env || {};
125
125
  if (adapter === 'anthropic') return env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || '';
@@ -33,7 +33,7 @@
33
33
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
34
34
  import {
35
35
  createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
36
- clearSessionMessages, ensureMainSession, forkSession,
36
+ clearSessionMessages, ensureMainSession, forkSession, appendMessage,
37
37
  } from './chat/session-store';
38
38
  import { runTurn, type AgentEvent } from './chat/agent-loop';
39
39
  import { bridgePush } from './chat/bridge-client';
@@ -182,6 +182,28 @@ async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id:
182
182
  sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
183
183
  }
184
184
 
185
+ /**
186
+ * Inject a pre-formed message into a session WITHOUT running a turn —
187
+ * used by schedule actions (and similar server-side surfaces) to push
188
+ * a notification into an open chat. Going via this endpoint (instead
189
+ * of calling appendMessage in the caller's process) is what gives the
190
+ * UI a real-time `message_saved` SSE push; otherwise the message lands
191
+ * in SQLite but the open tab only sees it after a reload.
192
+ */
193
+ async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
194
+ const session = getSession(id);
195
+ if (!session) return sendJson(res, 404, { error: 'session not found' });
196
+ const body = await readJson(req);
197
+ const role: 'user' | 'assistant' = body?.role === 'user' ? 'user' : 'assistant';
198
+ const blocks = Array.isArray(body?.blocks) ? body.blocks : null;
199
+ if (!blocks || blocks.length === 0) return sendJson(res, 400, { error: 'blocks[] required' });
200
+ const saved = appendMessage({ session_id: id, role, blocks });
201
+ // Payload shape must match what agent-loop emits — extension's
202
+ // messageFromServer(event.data) reads .id/.role/.blocks/.ts directly.
203
+ fanoutEvent(id, { type: 'message_saved', message_id: saved.id, data: saved });
204
+ sendJson(res, 200, { ok: true, message_id: saved.id });
205
+ }
206
+
185
207
  function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
186
208
  // Verify the session exists before opening the stream.
187
209
  const session = getSession(id);
@@ -244,6 +266,9 @@ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
244
266
  if (messages && m === 'POST') return handleMessagePost(req, res, messages[1]!);
245
267
  if (messages && m === 'DELETE') return handleSessionClearMessages(req, res, messages[1]!);
246
268
 
269
+ const inject = /^\/api\/sessions\/([^/]+)\/inject$/.exec(url.pathname);
270
+ if (inject && m === 'POST') return handleInjectMessage(req, res, inject[1]!);
271
+
247
272
  const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
248
273
  if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);
249
274
 
@@ -5,7 +5,7 @@
5
5
  * Runs on your Claude Code subscription, not API key.
6
6
  */
7
7
 
8
- import { spawn, type ChildProcess } from 'node:child_process';
8
+ import { spawn, execSync, type ChildProcess } from 'node:child_process';
9
9
  import { loadSettings } from './settings';
10
10
 
11
11
  export interface ClaudeMessage {
@@ -93,13 +93,16 @@ export function sendToClaudeSession(
93
93
  const env = { ...process.env };
94
94
  delete env.CLAUDECODE;
95
95
 
96
- // Resolve full path so spawn works without shell for PATH lookup
96
+ // Resolve full path so spawn works without shell for PATH lookup.
97
+ // Was a lazy require() — fired ReferenceError on Claude task spawn
98
+ // under ESM concurrent loads. Now top-level imported.
97
99
  let resolvedClaude = claudePath;
98
100
  if (!claudePath.startsWith('/')) {
99
101
  try {
100
- const { execSync: execS } = require('node:child_process');
101
- resolvedClaude = execS(`which ${claudePath}`, { encoding: 'utf-8', env }).trim() || claudePath;
102
- } catch {}
102
+ resolvedClaude = execSync(`which ${claudePath}`, { encoding: 'utf-8', env }).trim() || claudePath;
103
+ } catch (e) {
104
+ console.warn(`[claude] which ${claudePath} failed: ${(e as Error).message}`);
105
+ }
103
106
  }
104
107
 
105
108
  const child = spawn(resolvedClaude, args, {
@@ -139,7 +139,9 @@ export interface SyncResult {
139
139
  * cache the registry row — the manifest is pulled on demand at install
140
140
  * time (see installFromRegistry).
141
141
  */
142
- export async function syncRegistry(opts: { refreshInstalled?: boolean } = {}): Promise<SyncResult> {
142
+ export async function syncRegistry(
143
+ opts: { refreshInstalled?: boolean; force?: boolean } = {},
144
+ ): Promise<SyncResult> {
143
145
  const base = baseUrl();
144
146
  console.log(`[connectors] Syncing registry from ${base}`);
145
147
  let registry: RegistryFile;
@@ -171,7 +173,11 @@ export async function syncRegistry(opts: { refreshInstalled?: boolean } = {}): P
171
173
  const local = getConnector(e.id);
172
174
  const isConfigOnly = configOnly.has(e.id);
173
175
  if (!local && !isConfigOnly) continue;
174
- if (local && local.version === e.version) continue;
176
+ // Normally skip when local matches registry — saves bandwidth.
177
+ // `force` overrides so the user can pull a fresh manifest even
178
+ // when the version string hasn't changed (e.g. param-description
179
+ // tweaks shipped under the same patch version).
180
+ if (!opts.force && local && local.version === e.version) continue;
175
181
  try {
176
182
  const yamlText = await fetchManifest(e.id, e.manifest);
177
183
  installConnector(e.id, yamlText);
package/lib/crypto.ts CHANGED
@@ -63,5 +63,5 @@ export function hashSecret(value: string): string {
63
63
  }
64
64
 
65
65
  /** Secret field names in settings */
66
- export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey'] as const;
66
+ export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey', 'smtpPassword'] as const;
67
67
  export type SecretField = typeof SECRET_FIELDS[number];
package/lib/dirs.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { homedir } from 'node:os';
11
11
  import { join } from 'node:path';
12
- import { existsSync, mkdirSync, renameSync, copyFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, renameSync, copyFileSync, readFileSync } from 'node:fs';
13
13
 
14
14
  /** Shared config directory — only binaries, fixed at ~/.forge/ */
15
15
  export function getConfigDir(): string {
@@ -85,15 +85,30 @@ export function migrateDataDir(): void {
85
85
  console.log('[forge] Migration complete. Old files kept as backup.');
86
86
  }
87
87
 
88
- /** Claude Code home directory — skills, commands, sessions */
88
+ /** Claude Code home directory — skills, commands, sessions.
89
+ *
90
+ * Circular-dep note: dirs ↔ settings (settings.ts imports getDataDir).
91
+ * Used to do `require('./settings')` here as a lazy hack — but that
92
+ * fires ReferenceError under ESM concurrent loads, and dirs is on
93
+ * the hot path for every task / connector / pipeline node.
94
+ *
95
+ * Cut the cycle by reading the YAML directly here. No parser needed:
96
+ * we only need one field (`claudeHome`), which is plain "key: value".
97
+ * Falls back to ~/.claude if anything goes sideways.
98
+ */
89
99
  export function getClaudeDir(): string {
90
- // Env var takes precedence
91
100
  if (process.env.CLAUDE_HOME) return process.env.CLAUDE_HOME;
92
- // Try to read from settings (lazy require to avoid circular dependency)
93
101
  try {
94
- const { loadSettings } = require('./settings');
95
- const settings = loadSettings();
96
- if (settings.claudeHome) return settings.claudeHome.replace(/^~/, homedir());
102
+ const settingsFile = join(getDataDir(), 'settings.yaml');
103
+ if (existsSync(settingsFile)) {
104
+ const raw = readFileSync(settingsFile, 'utf-8');
105
+ // Match a top-level `claudeHome: <value>` line. Tolerates quoted /
106
+ // unquoted strings and stray whitespace. Not a full YAML parser
107
+ // because we explicitly want to avoid loading the YAML module
108
+ // (and settings.ts) from this hot path.
109
+ const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
110
+ if (m?.[1]) return m[1].replace(/^~/, homedir());
111
+ }
97
112
  } catch {}
98
113
  return join(homedir(), '.claude');
99
114
  }