@aion0/forge 0.2.5 ā 0.2.7
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/app/api/pipelines/route.ts +16 -0
- package/bin/forge-server.mjs +1 -1
- package/components/Dashboard.tsx +1 -1
- package/components/PipelineEditor.tsx +1 -1
- package/components/PipelineView.tsx +27 -7
- package/components/SettingsModal.tsx +57 -0
- package/install.sh +2 -1
- package/instrumentation.ts +0 -2
- package/lib/notify.ts +8 -0
- package/lib/password.ts +1 -1
- package/lib/pipeline.ts +66 -3
- package/lib/settings.ts +6 -0
- package/lib/task-manager.ts +20 -1
- package/lib/telegram-bot.ts +115 -99
- package/package.json +1 -1
|
@@ -16,6 +16,22 @@ export async function GET(req: Request) {
|
|
|
16
16
|
return NextResponse.json(listWorkflows());
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
if (type === 'workflow-yaml') {
|
|
20
|
+
const name = searchParams.get('name');
|
|
21
|
+
if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
|
|
22
|
+
try {
|
|
23
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
24
|
+
const filePath = join(FLOWS_DIR, `${name}.yaml`);
|
|
25
|
+
const altPath = join(FLOWS_DIR, `${name}.yml`);
|
|
26
|
+
const path = existsSync(filePath) ? filePath : existsSync(altPath) ? altPath : null;
|
|
27
|
+
if (!path) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
28
|
+
const yaml = readFileSync(path, 'utf-8');
|
|
29
|
+
return NextResponse.json({ yaml });
|
|
30
|
+
} catch {
|
|
31
|
+
return NextResponse.json({ error: 'Failed to read' }, { status: 500 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
|
20
36
|
}
|
|
21
37
|
|
package/bin/forge-server.mjs
CHANGED
|
@@ -180,7 +180,7 @@ if (isDev) {
|
|
|
180
180
|
});
|
|
181
181
|
child.on('exit', (code) => process.exit(code || 0));
|
|
182
182
|
} else {
|
|
183
|
-
if (!existsSync(join(ROOT, '.next'))) {
|
|
183
|
+
if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
|
|
184
184
|
console.log('[forge] Building...');
|
|
185
185
|
execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
|
|
186
186
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -324,7 +324,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
324
324
|
{/* Pipelines */}
|
|
325
325
|
{viewMode === 'pipelines' && (
|
|
326
326
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
327
|
-
<PipelineView />
|
|
327
|
+
<PipelineView onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
|
|
328
328
|
</Suspense>
|
|
329
329
|
)}
|
|
330
330
|
|
|
@@ -168,7 +168,7 @@ export default function PipelineEditor({ onSave, onClose, initialYaml }: {
|
|
|
168
168
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NodeData>>([]);
|
|
169
169
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
170
170
|
const [editingNode, setEditingNode] = useState<{ id: string; project: string; prompt: string; outputs: { name: string; extract: string }[] } | null>(null);
|
|
171
|
-
const [workflowName, setWorkflowName] = useState('
|
|
171
|
+
const [workflowName, setWorkflowName] = useState('');
|
|
172
172
|
const [workflowDesc, setWorkflowDesc] = useState('');
|
|
173
173
|
const [varsProject, setVarsProject] = useState('');
|
|
174
174
|
const [projects, setProjects] = useState<{ name: string; root: string }[]>([]);
|
|
@@ -60,7 +60,7 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
60
60
|
skipped: 'text-gray-500',
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
export default function PipelineView() {
|
|
63
|
+
export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
|
|
64
64
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
65
65
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
66
66
|
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
|
|
@@ -69,6 +69,7 @@ export default function PipelineView() {
|
|
|
69
69
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
70
70
|
const [creating, setCreating] = useState(false);
|
|
71
71
|
const [showEditor, setShowEditor] = useState(false);
|
|
72
|
+
const [editorYaml, setEditorYaml] = useState<string | undefined>(undefined);
|
|
72
73
|
|
|
73
74
|
const fetchData = useCallback(async () => {
|
|
74
75
|
const [pRes, wRes] = await Promise.all([
|
|
@@ -150,12 +151,25 @@ export default function PipelineView() {
|
|
|
150
151
|
<aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
151
152
|
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
|
152
153
|
<span className="text-[11px] font-semibold text-[var(--text-primary)]">Pipelines</span>
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
<select
|
|
155
|
+
onChange={async (e) => {
|
|
156
|
+
const name = e.target.value;
|
|
157
|
+
if (!name) { setEditorYaml(undefined); setShowEditor(true); return; }
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(name)}`);
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
setEditorYaml(data.yaml || undefined);
|
|
162
|
+
} catch { setEditorYaml(undefined); }
|
|
163
|
+
setShowEditor(true);
|
|
164
|
+
e.target.value = '';
|
|
165
|
+
}}
|
|
166
|
+
className="text-[10px] px-1 py-0.5 rounded text-green-400 bg-transparent hover:bg-green-400/10 cursor-pointer"
|
|
167
|
+
defaultValue=""
|
|
156
168
|
>
|
|
157
|
-
Editor
|
|
158
|
-
|
|
169
|
+
<option value="">Editor ā¾</option>
|
|
170
|
+
<option value="">+ New workflow</option>
|
|
171
|
+
{workflows.map(w => <option key={w.name} value={w.name}>{w.name}</option>)}
|
|
172
|
+
</select>
|
|
159
173
|
<button
|
|
160
174
|
onClick={() => setShowCreate(v => !v)}
|
|
161
175
|
className={`text-[10px] px-2 py-0.5 rounded ${showCreate ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
|
|
@@ -322,7 +336,12 @@ export default function PipelineView() {
|
|
|
322
336
|
<span className={STATUS_COLOR[node.status]}>{STATUS_ICON[node.status]}</span>
|
|
323
337
|
<span className="text-xs font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
324
338
|
{node.taskId && (
|
|
325
|
-
<
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => onViewTask?.(node.taskId!)}
|
|
341
|
+
className="text-[9px] text-[var(--accent)] font-mono hover:underline"
|
|
342
|
+
>
|
|
343
|
+
task:{node.taskId}
|
|
344
|
+
</button>
|
|
326
345
|
)}
|
|
327
346
|
{node.iterations > 1 && (
|
|
328
347
|
<span className="text-[9px] text-yellow-400">iter {node.iterations}</span>
|
|
@@ -416,6 +435,7 @@ nodes:
|
|
|
416
435
|
{showEditor && (
|
|
417
436
|
<Suspense fallback={null}>
|
|
418
437
|
<PipelineEditor
|
|
438
|
+
initialYaml={editorYaml}
|
|
419
439
|
onSave={async (yaml) => {
|
|
420
440
|
// Save YAML to ~/.forge/flows/
|
|
421
441
|
await fetch('/api/pipelines', {
|
|
@@ -12,6 +12,9 @@ interface Settings {
|
|
|
12
12
|
notifyOnFailure: boolean;
|
|
13
13
|
tunnelAutoStart: boolean;
|
|
14
14
|
telegramTunnelPassword: string;
|
|
15
|
+
taskModel: string;
|
|
16
|
+
pipelineModel: string;
|
|
17
|
+
telegramModel: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
interface TunnelStatus {
|
|
@@ -33,6 +36,9 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
33
36
|
notifyOnFailure: true,
|
|
34
37
|
tunnelAutoStart: false,
|
|
35
38
|
telegramTunnelPassword: '',
|
|
39
|
+
taskModel: 'sonnet',
|
|
40
|
+
pipelineModel: 'sonnet',
|
|
41
|
+
telegramModel: 'sonnet',
|
|
36
42
|
});
|
|
37
43
|
const [newRoot, setNewRoot] = useState('');
|
|
38
44
|
const [newDocRoot, setNewDocRoot] = useState('');
|
|
@@ -263,6 +269,57 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
263
269
|
</div>
|
|
264
270
|
</div>
|
|
265
271
|
|
|
272
|
+
{/* Model Settings */}
|
|
273
|
+
<div className="space-y-2">
|
|
274
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
275
|
+
Models
|
|
276
|
+
</label>
|
|
277
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
278
|
+
Claude model for each feature. Uses your Claude Code subscription. Options: sonnet, opus, haiku, or default (subscription default).
|
|
279
|
+
</p>
|
|
280
|
+
<div className="grid grid-cols-3 gap-2">
|
|
281
|
+
<div>
|
|
282
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Tasks</label>
|
|
283
|
+
<select
|
|
284
|
+
value={settings.taskModel || 'sonnet'}
|
|
285
|
+
onChange={e => setSettings({ ...settings, taskModel: e.target.value })}
|
|
286
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
287
|
+
>
|
|
288
|
+
<option value="default">Default</option>
|
|
289
|
+
<option value="sonnet">Sonnet</option>
|
|
290
|
+
<option value="opus">Opus</option>
|
|
291
|
+
<option value="haiku">Haiku</option>
|
|
292
|
+
</select>
|
|
293
|
+
</div>
|
|
294
|
+
<div>
|
|
295
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Pipelines</label>
|
|
296
|
+
<select
|
|
297
|
+
value={settings.pipelineModel || 'sonnet'}
|
|
298
|
+
onChange={e => setSettings({ ...settings, pipelineModel: e.target.value })}
|
|
299
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
300
|
+
>
|
|
301
|
+
<option value="default">Default</option>
|
|
302
|
+
<option value="sonnet">Sonnet</option>
|
|
303
|
+
<option value="opus">Opus</option>
|
|
304
|
+
<option value="haiku">Haiku</option>
|
|
305
|
+
</select>
|
|
306
|
+
</div>
|
|
307
|
+
<div>
|
|
308
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Telegram</label>
|
|
309
|
+
<select
|
|
310
|
+
value={settings.telegramModel || 'sonnet'}
|
|
311
|
+
onChange={e => setSettings({ ...settings, telegramModel: e.target.value })}
|
|
312
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
|
|
313
|
+
>
|
|
314
|
+
<option value="default">Default</option>
|
|
315
|
+
<option value="sonnet">Sonnet</option>
|
|
316
|
+
<option value="opus">Opus</option>
|
|
317
|
+
<option value="haiku">Haiku</option>
|
|
318
|
+
</select>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
266
323
|
{/* Remote Access (Cloudflare Tunnel) */}
|
|
267
324
|
<div className="space-y-2">
|
|
268
325
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
package/install.sh
CHANGED
|
@@ -17,7 +17,8 @@ else
|
|
|
17
17
|
echo "[forge] Installing from npm..."
|
|
18
18
|
rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
|
|
19
19
|
npm cache clean --force 2>/dev/null || true
|
|
20
|
-
|
|
20
|
+
# Install from /tmp to avoid pnpm node_modules conflict
|
|
21
|
+
(cd /tmp && npm install -g @aion0/forge)
|
|
21
22
|
echo "[forge] Building..."
|
|
22
23
|
cd "$(npm root -g)/@aion0/forge" && npx next build && cd -
|
|
23
24
|
fi
|
package/instrumentation.ts
CHANGED
|
@@ -26,7 +26,5 @@ export async function register() {
|
|
|
26
26
|
const { getPassword } = await import('./lib/password');
|
|
27
27
|
const password = getPassword();
|
|
28
28
|
process.env.MW_PASSWORD = password;
|
|
29
|
-
console.log(`[init] Login password: ${password}`);
|
|
30
|
-
console.log('[init] Forgot password? Run: forge password');
|
|
31
29
|
}
|
|
32
30
|
}
|
package/lib/notify.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { loadSettings } from './settings';
|
|
|
6
6
|
import type { Task } from '@/src/types';
|
|
7
7
|
|
|
8
8
|
export async function notifyTaskComplete(task: Task) {
|
|
9
|
+
// Skip pipeline tasks
|
|
10
|
+
try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
|
|
11
|
+
|
|
9
12
|
const settings = loadSettings();
|
|
10
13
|
if (!settings.notifyOnComplete) return;
|
|
11
14
|
|
|
@@ -13,11 +16,13 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
13
16
|
const duration = task.startedAt && task.completedAt
|
|
14
17
|
? formatDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime())
|
|
15
18
|
: 'unknown';
|
|
19
|
+
const model = task.log?.find(e => e.subtype === 'init' && e.content.startsWith('Model:'))?.content.replace('Model: ', '') || 'unknown';
|
|
16
20
|
|
|
17
21
|
await sendTelegram(
|
|
18
22
|
`ā
*Task Done*\n\n` +
|
|
19
23
|
`*Project:* ${esc(task.projectName)}\n` +
|
|
20
24
|
`*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
|
|
25
|
+
`*Model:* ${esc(model)}\n` +
|
|
21
26
|
`*Duration:* ${duration}\n` +
|
|
22
27
|
`*Cost:* ${cost}\n\n` +
|
|
23
28
|
`${task.resultSummary ? `*Result:*\n${esc(task.resultSummary.slice(0, 500))}` : '_No summary_'}`
|
|
@@ -25,6 +30,9 @@ export async function notifyTaskComplete(task: Task) {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export async function notifyTaskFailed(task: Task) {
|
|
33
|
+
// Skip pipeline tasks
|
|
34
|
+
try { const { pipelineTaskIds } = require('./pipeline'); if (pipelineTaskIds.has(task.id)) return; } catch {}
|
|
35
|
+
|
|
28
36
|
const settings = loadSettings();
|
|
29
37
|
if (!settings.notifyOnFailure) return;
|
|
30
38
|
|
package/lib/password.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auto-generated login password.
|
|
3
3
|
* Rotates daily. Saved to ~/.forge/password.json with date.
|
|
4
|
-
* CLI can read it via `
|
|
4
|
+
* CLI can read it via `forge password`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
package/lib/pipeline.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
|
-
import { createTask, getTask, onTaskEvent } from './task-manager';
|
|
13
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
14
14
|
import { getProjectInfo } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import type { Task } from '@/src/types';
|
|
@@ -18,6 +18,12 @@ import type { Task } from '@/src/types';
|
|
|
18
18
|
const PIPELINES_DIR = join(homedir(), '.forge', 'pipelines');
|
|
19
19
|
const WORKFLOWS_DIR = join(homedir(), '.forge', 'flows');
|
|
20
20
|
|
|
21
|
+
// Track pipeline task IDs so terminal notifications can skip them (persists across hot-reloads)
|
|
22
|
+
const pipelineTaskKey = Symbol.for('mw-pipeline-task-ids');
|
|
23
|
+
const gPipeline = globalThis as any;
|
|
24
|
+
if (!gPipeline[pipelineTaskKey]) gPipeline[pipelineTaskKey] = new Set<string>();
|
|
25
|
+
export const pipelineTaskIds: Set<string> = gPipeline[pipelineTaskKey];
|
|
26
|
+
|
|
21
27
|
// āāā Types āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
22
28
|
|
|
23
29
|
export interface WorkflowNode {
|
|
@@ -226,6 +232,58 @@ export function startPipeline(workflowName: string, input: Record<string, string
|
|
|
226
232
|
return pipeline;
|
|
227
233
|
}
|
|
228
234
|
|
|
235
|
+
// āāā Recovery: check for stuck pipelines āāāāāāāāāāāāāāāāāā
|
|
236
|
+
|
|
237
|
+
function recoverStuckPipelines() {
|
|
238
|
+
const pipelines = listPipelines().filter(p => p.status === 'running');
|
|
239
|
+
for (const pipeline of pipelines) {
|
|
240
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
241
|
+
if (!workflow) continue;
|
|
242
|
+
|
|
243
|
+
let changed = false;
|
|
244
|
+
for (const [nodeId, node] of Object.entries(pipeline.nodes)) {
|
|
245
|
+
if (node.status === 'running' && node.taskId) {
|
|
246
|
+
const task = getTask(node.taskId);
|
|
247
|
+
if (!task) {
|
|
248
|
+
// Task gone ā mark node as done (task completed and was cleaned up)
|
|
249
|
+
node.status = 'done';
|
|
250
|
+
node.completedAt = new Date().toISOString();
|
|
251
|
+
changed = true;
|
|
252
|
+
} else if (task.status === 'done') {
|
|
253
|
+
// Extract outputs
|
|
254
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
255
|
+
if (nodeDef) {
|
|
256
|
+
for (const outputDef of nodeDef.outputs) {
|
|
257
|
+
if (outputDef.extract === 'result') node.outputs[outputDef.name] = task.resultSummary || '';
|
|
258
|
+
else if (outputDef.extract === 'git_diff') node.outputs[outputDef.name] = task.gitDiff || '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
node.status = 'done';
|
|
262
|
+
node.completedAt = new Date().toISOString();
|
|
263
|
+
changed = true;
|
|
264
|
+
} else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
265
|
+
node.status = 'failed';
|
|
266
|
+
node.error = task.error || 'Task failed';
|
|
267
|
+
node.completedAt = new Date().toISOString();
|
|
268
|
+
changed = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (changed) {
|
|
274
|
+
savePipeline(pipeline);
|
|
275
|
+
// Re-setup listener and schedule next nodes
|
|
276
|
+
setupTaskListener(pipeline.id);
|
|
277
|
+
scheduleReadyNodes(pipeline, workflow);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Run recovery every 30 seconds
|
|
283
|
+
setInterval(recoverStuckPipelines, 30_000);
|
|
284
|
+
// Also run once on load
|
|
285
|
+
setTimeout(recoverStuckPipelines, 5000);
|
|
286
|
+
|
|
229
287
|
export function cancelPipeline(id: string): boolean {
|
|
230
288
|
const pipeline = getPipeline(id);
|
|
231
289
|
if (!pipeline || pipeline.status !== 'running') return false;
|
|
@@ -291,12 +349,17 @@ function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
291
349
|
continue;
|
|
292
350
|
}
|
|
293
351
|
|
|
294
|
-
// Create task
|
|
352
|
+
// Create task with pipeline model
|
|
295
353
|
const task = createTask({
|
|
296
354
|
projectName: projectInfo.name,
|
|
297
355
|
projectPath: projectInfo.path,
|
|
298
356
|
prompt,
|
|
299
357
|
});
|
|
358
|
+
pipelineTaskIds.add(task.id);
|
|
359
|
+
const pipelineModel = loadSettings().pipelineModel;
|
|
360
|
+
if (pipelineModel && pipelineModel !== 'default') {
|
|
361
|
+
taskModelOverrides.set(task.id, pipelineModel);
|
|
362
|
+
}
|
|
300
363
|
|
|
301
364
|
nodeState.status = 'running';
|
|
302
365
|
nodeState.taskId = task.id;
|
|
@@ -394,7 +457,7 @@ function setupTaskListener(pipelineId: string) {
|
|
|
394
457
|
}
|
|
395
458
|
|
|
396
459
|
savePipeline(pipeline);
|
|
397
|
-
|
|
460
|
+
// No per-step done notification ā only notify on start and failure
|
|
398
461
|
} else if (data === 'failed') {
|
|
399
462
|
nodeState.status = 'failed';
|
|
400
463
|
nodeState.error = task?.error || 'Task failed';
|
package/lib/settings.ts
CHANGED
|
@@ -16,6 +16,9 @@ export interface Settings {
|
|
|
16
16
|
notifyOnFailure: boolean; // Notify when task fails
|
|
17
17
|
tunnelAutoStart: boolean; // Auto-start Cloudflare Tunnel on startup
|
|
18
18
|
telegramTunnelPassword: string; // Password for getting login password via Telegram
|
|
19
|
+
taskModel: string; // Model for tasks (default: sonnet)
|
|
20
|
+
pipelineModel: string; // Model for pipelines (default: sonnet)
|
|
21
|
+
telegramModel: string; // Model for Telegram AI features (default: sonnet)
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const defaults: Settings = {
|
|
@@ -28,6 +31,9 @@ const defaults: Settings = {
|
|
|
28
31
|
notifyOnFailure: true,
|
|
29
32
|
tunnelAutoStart: false,
|
|
30
33
|
telegramTunnelPassword: '',
|
|
34
|
+
taskModel: 'default',
|
|
35
|
+
pipelineModel: 'default',
|
|
36
|
+
telegramModel: 'sonnet',
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
export function loadSettings(): Settings {
|
package/lib/task-manager.ts
CHANGED
|
@@ -39,6 +39,9 @@ function db() {
|
|
|
39
39
|
return getDb(getDbPath());
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Per-task model overrides (used by pipeline to set pipelineModel)
|
|
43
|
+
export const taskModelOverrides = new Map<string, string>();
|
|
44
|
+
|
|
42
45
|
// āāā CRUD āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
43
46
|
|
|
44
47
|
export function createTask(opts: {
|
|
@@ -239,6 +242,12 @@ function executeTask(task: Task): Promise<void> {
|
|
|
239
242
|
|
|
240
243
|
const args = ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'];
|
|
241
244
|
|
|
245
|
+
// Use model override if set, otherwise fall back to taskModel setting
|
|
246
|
+
const model = taskModelOverrides.get(task.id) || settings.taskModel;
|
|
247
|
+
if (model && model !== 'default') {
|
|
248
|
+
args.push('--model', model);
|
|
249
|
+
}
|
|
250
|
+
|
|
242
251
|
// Resume specific session to continue the conversation
|
|
243
252
|
if (task.conversationId) {
|
|
244
253
|
args.push('--resume', task.conversationId);
|
|
@@ -254,7 +263,7 @@ function executeTask(task: Task): Promise<void> {
|
|
|
254
263
|
|
|
255
264
|
// Resolve the actual claude CLI script path (claude is a symlink to a .js file)
|
|
256
265
|
const resolvedClaude = resolveClaudePath(claudePath);
|
|
257
|
-
console.log(`[task] ${task.projectName}: "${task.prompt.slice(0, 60)}..."`);
|
|
266
|
+
console.log(`[task] ${task.projectName} [${model || 'default'}]: "${task.prompt.slice(0, 60)}..."`);
|
|
258
267
|
|
|
259
268
|
const child = spawn(resolvedClaude.cmd, [...resolvedClaude.prefix, ...args], {
|
|
260
269
|
cwd: task.projectPath,
|
|
@@ -266,6 +275,7 @@ function executeTask(task: Task): Promise<void> {
|
|
|
266
275
|
let resultText = '';
|
|
267
276
|
let totalCost = 0;
|
|
268
277
|
let sessionId = '';
|
|
278
|
+
let modelUsed = '';
|
|
269
279
|
|
|
270
280
|
child.on('error', (err) => {
|
|
271
281
|
console.error(`[task-runner] Spawn error:`, err.message);
|
|
@@ -296,6 +306,9 @@ function executeTask(task: Task): Promise<void> {
|
|
|
296
306
|
}
|
|
297
307
|
|
|
298
308
|
if (parsed.session_id) sessionId = parsed.session_id;
|
|
309
|
+
if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.model) {
|
|
310
|
+
modelUsed = parsed.model;
|
|
311
|
+
}
|
|
299
312
|
if (parsed.type === 'result') {
|
|
300
313
|
resultText = typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result);
|
|
301
314
|
totalCost = parsed.total_cost_usd || 0;
|
|
@@ -381,6 +394,12 @@ function executeTask(task: Task): Promise<void> {
|
|
|
381
394
|
* Sends a visible bell character so the user knows to resume.
|
|
382
395
|
*/
|
|
383
396
|
function notifyTerminalSession(task: Task, status: 'done' | 'failed', sessionId?: string) {
|
|
397
|
+
// Skip pipeline tasks ā they have their own notification system
|
|
398
|
+
try {
|
|
399
|
+
const { pipelineTaskIds } = require('./pipeline');
|
|
400
|
+
if (pipelineTaskIds.has(task.id)) return;
|
|
401
|
+
} catch {}
|
|
402
|
+
|
|
384
403
|
try {
|
|
385
404
|
const out = execSync(
|
|
386
405
|
`tmux list-sessions -F "#{session_name}" 2>/dev/null`,
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram Bot ā remote interface for
|
|
2
|
+
* Telegram Bot ā remote interface for Forge.
|
|
3
3
|
*
|
|
4
4
|
* Optimized for mobile:
|
|
5
5
|
* - /tasks shows numbered list, reply with number to see details
|
|
@@ -19,8 +19,8 @@ import type { Task, TaskLogEntry } from '@/src/types';
|
|
|
19
19
|
// Prevent duplicate polling and state loss across hot-reloads
|
|
20
20
|
const globalKey = Symbol.for('mw-telegram-state');
|
|
21
21
|
const g = globalThis as any;
|
|
22
|
-
if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false };
|
|
23
|
-
const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean } = g[globalKey];
|
|
22
|
+
if (!g[globalKey]) g[globalKey] = { polling: false, pollTimer: null, lastUpdateId: 0, taskListenerAttached: false, pollActive: false, processedMsgIds: new Set<number>(), pollGeneration: 0 };
|
|
23
|
+
const botState: { polling: boolean; pollTimer: ReturnType<typeof setTimeout> | null; lastUpdateId: number; taskListenerAttached: boolean; pollActive: boolean; processedMsgIds: Set<number>; pollGeneration: number } = g[globalKey];
|
|
24
24
|
|
|
25
25
|
// Track which Telegram message maps to which task (for reply-based interaction)
|
|
26
26
|
const taskMessageMap = new Map<number, string>(); // messageId ā taskId
|
|
@@ -46,10 +46,16 @@ const logBuffers = new Map<string, { entries: string[]; timer: ReturnType<typeof
|
|
|
46
46
|
// āāā Start/Stop āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
47
47
|
|
|
48
48
|
export function startTelegramBot() {
|
|
49
|
-
if (botState.polling) return;
|
|
50
49
|
const settings = loadSettings();
|
|
51
50
|
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
52
51
|
|
|
52
|
+
// Kill any existing poll loop (handles hot-reload creating duplicates)
|
|
53
|
+
if (botState.polling) {
|
|
54
|
+
botState.pollGeneration++;
|
|
55
|
+
if (botState.pollTimer) { clearTimeout(botState.pollTimer); botState.pollTimer = null; }
|
|
56
|
+
botState.pollActive = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
botState.polling = true;
|
|
54
60
|
console.log('[telegram] Bot started');
|
|
55
61
|
|
|
@@ -62,6 +68,13 @@ export function startTelegramBot() {
|
|
|
62
68
|
onTaskEvent((taskId, event, data) => {
|
|
63
69
|
const settings = loadSettings();
|
|
64
70
|
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
71
|
+
|
|
72
|
+
// Skip pipeline tasks ā they have their own notification
|
|
73
|
+
try {
|
|
74
|
+
const { pipelineTaskIds } = require('./pipeline');
|
|
75
|
+
if (pipelineTaskIds.has(taskId)) return;
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
65
78
|
const chatId = Number(settings.telegramChatId.split(',')[0].trim());
|
|
66
79
|
|
|
67
80
|
if (event === 'log') {
|
|
@@ -102,7 +115,9 @@ function schedulePoll(delay: number = 1000) {
|
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
async function poll() {
|
|
105
|
-
|
|
118
|
+
const myGeneration = botState.pollGeneration;
|
|
119
|
+
|
|
120
|
+
// Prevent concurrent polls
|
|
106
121
|
if (!botState.polling || botState.pollActive) return;
|
|
107
122
|
botState.pollActive = true;
|
|
108
123
|
|
|
@@ -117,11 +132,13 @@ async function poll() {
|
|
|
117
132
|
|
|
118
133
|
const data = await res.json();
|
|
119
134
|
|
|
120
|
-
if (data.ok && data.result) {
|
|
135
|
+
if (data.ok && data.result && data.result.length > 0) {
|
|
136
|
+
console.log(`[telegram] Poll got ${data.result.length} updates, lastId=${botState.lastUpdateId}`);
|
|
121
137
|
for (const update of data.result) {
|
|
122
138
|
if (update.update_id <= botState.lastUpdateId) continue;
|
|
123
139
|
botState.lastUpdateId = update.update_id;
|
|
124
140
|
if (update.message?.text) {
|
|
141
|
+
console.log(`[telegram] Processing msg ${update.message.message_id}: ${update.message.text.slice(0, 30)}`);
|
|
125
142
|
await handleMessage(update.message);
|
|
126
143
|
}
|
|
127
144
|
}
|
|
@@ -131,7 +148,8 @@ async function poll() {
|
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
botState.pollActive = false;
|
|
134
|
-
|
|
151
|
+
// Only continue polling if this is still the current generation
|
|
152
|
+
if (botState.polling && myGeneration === botState.pollGeneration) schedulePoll(1000);
|
|
135
153
|
}
|
|
136
154
|
|
|
137
155
|
// āāā Message Handler āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
@@ -147,6 +165,16 @@ async function handleMessage(msg: any) {
|
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
// Message received (logged silently)
|
|
168
|
+
// Dedup: skip if we already processed this message
|
|
169
|
+
const msgId = msg.message_id;
|
|
170
|
+
if (botState.processedMsgIds.has(msgId)) return;
|
|
171
|
+
botState.processedMsgIds.add(msgId);
|
|
172
|
+
// Keep set size bounded
|
|
173
|
+
if (botState.processedMsgIds.size > 200) {
|
|
174
|
+
const oldest = [...botState.processedMsgIds].slice(0, 100);
|
|
175
|
+
oldest.forEach(id => botState.processedMsgIds.delete(id));
|
|
176
|
+
}
|
|
177
|
+
|
|
150
178
|
const text: string = msg.text.trim();
|
|
151
179
|
const replyTo = msg.reply_to_message?.message_id;
|
|
152
180
|
|
|
@@ -260,28 +288,30 @@ async function handleMessage(msg: any) {
|
|
|
260
288
|
await sendProjectList(chatId);
|
|
261
289
|
break;
|
|
262
290
|
case '/watch':
|
|
263
|
-
await handleWatch(chatId, args[0], args[1]);
|
|
264
|
-
break;
|
|
265
|
-
case '/watchers':
|
|
266
291
|
case '/w':
|
|
267
|
-
|
|
292
|
+
if (args.length > 0) {
|
|
293
|
+
await handleWatch(chatId, args[0], args[1]);
|
|
294
|
+
} else {
|
|
295
|
+
await sendWatcherList(chatId);
|
|
296
|
+
}
|
|
268
297
|
break;
|
|
269
298
|
case '/unwatch':
|
|
270
299
|
await handleUnwatch(chatId, args[0]);
|
|
271
300
|
break;
|
|
301
|
+
case '/docs':
|
|
302
|
+
case '/doc':
|
|
303
|
+
await handleDocs(chatId, args.join(' '));
|
|
304
|
+
break;
|
|
272
305
|
case '/peek':
|
|
306
|
+
case '/sessions':
|
|
307
|
+
case '/s':
|
|
273
308
|
if (args.length > 0) {
|
|
274
309
|
await handlePeek(chatId, args[0], args[1]);
|
|
275
310
|
} else {
|
|
276
311
|
await startPeekSelection(chatId);
|
|
277
312
|
}
|
|
278
313
|
break;
|
|
279
|
-
case '/docs':
|
|
280
|
-
case '/doc':
|
|
281
|
-
await handleDocs(chatId, args.join(' '));
|
|
282
|
-
break;
|
|
283
314
|
case '/note':
|
|
284
|
-
case '/docs_write':
|
|
285
315
|
await handleDocsWrite(chatId, args.join(' '));
|
|
286
316
|
break;
|
|
287
317
|
case '/cancel':
|
|
@@ -329,23 +359,19 @@ async function handleMessage(msg: any) {
|
|
|
329
359
|
async function sendHelp(chatId: number) {
|
|
330
360
|
await send(chatId,
|
|
331
361
|
`š¤ Forge\n\n` +
|
|
332
|
-
`š /
|
|
333
|
-
`/tasks
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
`/
|
|
339
|
-
|
|
340
|
-
`š /peek [project] [sessionId] ā session summary\n` +
|
|
341
|
-
`š /docs ā docs session summary\n` +
|
|
342
|
-
`/docs <filename> ā view doc file\n` +
|
|
343
|
-
`š /note ā quick note to docs claude\n\n` +
|
|
362
|
+
`š /task ā create task (interactive)\n` +
|
|
363
|
+
`/tasks ā task list\n\n` +
|
|
364
|
+
`š /sessions ā session summary (select project)\n` +
|
|
365
|
+
`š /docs ā docs summary / view file\n` +
|
|
366
|
+
`š /note ā quick note to docs\n\n` +
|
|
367
|
+
`š /watch <project> ā monitor session\n` +
|
|
368
|
+
`/watch ā list watchers\n` +
|
|
369
|
+
`/unwatch <id> ā stop\n\n` +
|
|
344
370
|
`š§ /cancel <id> /retry <id>\n` +
|
|
371
|
+
`/sessions ā browse sessions\n` +
|
|
345
372
|
`/projects ā list projects\n\n` +
|
|
346
|
-
`š /tunnel ā
|
|
347
|
-
`/tunnel_start
|
|
348
|
-
`/tunnel_stop ā stop tunnel\n` +
|
|
373
|
+
`š /tunnel ā status\n` +
|
|
374
|
+
`/tunnel_start / /tunnel_stop\n` +
|
|
349
375
|
`/tunnel_password <pw> ā get login password\n\n` +
|
|
350
376
|
`Reply number to select`
|
|
351
377
|
);
|
|
@@ -657,39 +683,16 @@ async function handlePeek(chatId: number, projectArg?: string, sessionArg?: stri
|
|
|
657
683
|
contextLen += line.length;
|
|
658
684
|
}
|
|
659
685
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (apiKey) {
|
|
665
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
666
|
-
method: 'POST',
|
|
667
|
-
headers: {
|
|
668
|
-
'Content-Type': 'application/json',
|
|
669
|
-
'x-api-key': apiKey,
|
|
670
|
-
'anthropic-version': '2023-06-01',
|
|
671
|
-
},
|
|
672
|
-
body: JSON.stringify({
|
|
673
|
-
model: 'claude-haiku-4-5-20251001',
|
|
674
|
-
max_tokens: 500,
|
|
675
|
-
messages: [{
|
|
676
|
-
role: 'user',
|
|
677
|
-
content: `Summarize this Claude Code session in 2-3 sentences. What was the user trying to do? What's the current status? Answer in the same language as the session content.\n\n${contextEntries.join('\n')}`,
|
|
678
|
-
}],
|
|
679
|
-
}),
|
|
680
|
-
});
|
|
681
|
-
if (res.ok) {
|
|
682
|
-
const data = await res.json();
|
|
683
|
-
summary = data.content?.[0]?.text || '';
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
} catch {}
|
|
686
|
+
const telegramModel = loadSettings().telegramModel || 'sonnet';
|
|
687
|
+
const summary = contextEntries.length > 3
|
|
688
|
+
? await aiSummarize(contextEntries.join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
|
|
689
|
+
: '';
|
|
687
690
|
|
|
688
691
|
// Format output
|
|
689
|
-
const header = `š ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` ⢠${session.gitBranch}` : ''}`;
|
|
692
|
+
const header = `š ${project.name} / ${session.sessionId.slice(0, 8)}\n${entries.length} entries${session.gitBranch ? ` ⢠${session.gitBranch}` : ''}${summary ? ` ⢠AI: ${telegramModel}` : ''}`;
|
|
690
693
|
|
|
691
694
|
const summaryBlock = summary
|
|
692
|
-
? `\n\nš Summary:\n${summary}`
|
|
695
|
+
? `\n\nš Summary (${telegramModel}):\n${summary}`
|
|
693
696
|
: '';
|
|
694
697
|
|
|
695
698
|
const rawBlock = `\n\n--- Recent ---\n${recentRaw.join('\n\n')}`;
|
|
@@ -1037,6 +1040,47 @@ async function handleTunnelPassword(chatId: number, password?: string, userMsgId
|
|
|
1037
1040
|
}
|
|
1038
1041
|
}
|
|
1039
1042
|
|
|
1043
|
+
// āāā AI Summarize (using Claude Code subscription) āāāāāāāāāāā
|
|
1044
|
+
|
|
1045
|
+
async function aiSummarize(content: string, instruction: string): Promise<string> {
|
|
1046
|
+
try {
|
|
1047
|
+
const settings = loadSettings();
|
|
1048
|
+
const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
|
|
1049
|
+
const model = settings.telegramModel || 'sonnet';
|
|
1050
|
+
const { execSync } = require('child_process');
|
|
1051
|
+
const { realpathSync } = require('fs');
|
|
1052
|
+
|
|
1053
|
+
// Resolve claude path
|
|
1054
|
+
let cmd = claudePath;
|
|
1055
|
+
try {
|
|
1056
|
+
const which = execSync(`which ${claudePath}`, { encoding: 'utf-8' }).trim();
|
|
1057
|
+
cmd = realpathSync(which);
|
|
1058
|
+
} catch {}
|
|
1059
|
+
|
|
1060
|
+
const args = ['-p', '--model', model, '--max-turns', '1'];
|
|
1061
|
+
const prompt = `${instruction}\n\nContent:\n${content.slice(0, 8000)}`;
|
|
1062
|
+
|
|
1063
|
+
let execCmd: string;
|
|
1064
|
+
if (cmd.endsWith('.js') || cmd.endsWith('.mjs')) {
|
|
1065
|
+
execCmd = `${process.execPath} ${cmd} ${args.join(' ')}`;
|
|
1066
|
+
} else {
|
|
1067
|
+
execCmd = `${cmd} ${args.join(' ')}`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const result = execSync(execCmd, {
|
|
1071
|
+
input: prompt,
|
|
1072
|
+
encoding: 'utf-8',
|
|
1073
|
+
timeout: 30000,
|
|
1074
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1075
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
1076
|
+
}).trim();
|
|
1077
|
+
|
|
1078
|
+
return result.slice(0, 1000);
|
|
1079
|
+
} catch {
|
|
1080
|
+
return '';
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1040
1084
|
// āāā Docs āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
1041
1085
|
|
|
1042
1086
|
async function handleDocs(chatId: number, input: string) {
|
|
@@ -1158,38 +1202,13 @@ async function handleDocs(chatId: number, input: string) {
|
|
|
1158
1202
|
} catch {}
|
|
1159
1203
|
|
|
1160
1204
|
const recent = entries.slice(-8).join('\n\n');
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
if (apiKey && entries.length > 2) {
|
|
1168
|
-
const contextText = entries.slice(-15).join('\n');
|
|
1169
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
1170
|
-
method: 'POST',
|
|
1171
|
-
headers: {
|
|
1172
|
-
'Content-Type': 'application/json',
|
|
1173
|
-
'x-api-key': apiKey,
|
|
1174
|
-
'anthropic-version': '2023-06-01',
|
|
1175
|
-
},
|
|
1176
|
-
body: JSON.stringify({
|
|
1177
|
-
model: 'claude-haiku-4-5-20251001',
|
|
1178
|
-
max_tokens: 300,
|
|
1179
|
-
messages: [{
|
|
1180
|
-
role: 'user',
|
|
1181
|
-
content: `Summarize this Claude Code session in 2-3 sentences. What was the user working on? What's the current status? Answer in the same language as the content.\n\n${contextText}`,
|
|
1182
|
-
}],
|
|
1183
|
-
}),
|
|
1184
|
-
});
|
|
1185
|
-
if (res.ok) {
|
|
1186
|
-
const data = await res.json();
|
|
1187
|
-
summary = data.content?.[0]?.text || '';
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
} catch {}
|
|
1205
|
+
const tModel = loadSettings().telegramModel || 'sonnet';
|
|
1206
|
+
const summary = entries.length > 3
|
|
1207
|
+
? await aiSummarize(entries.slice(-15).join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
|
|
1208
|
+
: '';
|
|
1209
|
+
const header = `š Docs: ${docRoot.split('/').pop()}\nš Session: ${sessionId.slice(0, 12)}${summary ? ` ⢠AI: ${tModel}` : ''}\n`;
|
|
1210
|
+
const summaryBlock = summary ? `\nš (${tModel}) ${summary}\n` : '';
|
|
1191
1211
|
|
|
1192
|
-
const summaryBlock = summary ? `\nš ${summary}\n` : '';
|
|
1193
1212
|
const fullText = header + summaryBlock + '\n--- Recent ---\n' + recent;
|
|
1194
1213
|
|
|
1195
1214
|
const chunks = splitMessage(fullText, 4000);
|
|
@@ -1393,19 +1412,16 @@ async function setBotCommands(token: string) {
|
|
|
1393
1412
|
headers: { 'Content-Type': 'application/json' },
|
|
1394
1413
|
body: JSON.stringify({
|
|
1395
1414
|
commands: [
|
|
1415
|
+
{ command: 'task', description: 'Create task' },
|
|
1396
1416
|
{ command: 'tasks', description: 'List tasks' },
|
|
1397
|
-
{ command: '
|
|
1398
|
-
{ command: '
|
|
1399
|
-
{ command: '
|
|
1417
|
+
{ command: 'sessions', description: 'Session summary (AI)' },
|
|
1418
|
+
{ command: 'docs', description: 'Docs summary / view file' },
|
|
1419
|
+
{ command: 'note', description: 'Quick note to docs' },
|
|
1420
|
+
{ command: 'watch', description: 'Monitor session / list watchers' },
|
|
1400
1421
|
{ command: 'tunnel', description: 'Tunnel status' },
|
|
1401
1422
|
{ command: 'tunnel_start', description: 'Start tunnel' },
|
|
1402
1423
|
{ command: 'tunnel_stop', description: 'Stop tunnel' },
|
|
1403
1424
|
{ command: 'tunnel_password', description: 'Get login password' },
|
|
1404
|
-
{ command: 'peek', description: 'Session summary (AI + recent)' },
|
|
1405
|
-
{ command: 'docs', description: 'Docs session summary / view file' },
|
|
1406
|
-
{ command: 'note', description: 'Quick note to docs Claude' },
|
|
1407
|
-
{ command: 'watch', description: 'Monitor session' },
|
|
1408
|
-
{ command: 'watchers', description: 'List watchers' },
|
|
1409
1425
|
{ command: 'help', description: 'Show help' },
|
|
1410
1426
|
],
|
|
1411
1427
|
}),
|
package/package.json
CHANGED