@aion0/forge 0.10.78 → 0.10.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +9 -8
- package/app/api/code/route.ts +171 -54
- package/app/api/onboarding/route.ts +32 -0
- package/app/api/skills/local/route.ts +5 -4
- 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/CodeViewer.tsx +127 -41
- package/components/Dashboard.tsx +6 -2
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/TaskDetail.tsx +28 -1
- package/components/TmuxTaskTerminal.tsx +105 -0
- package/components/WebTerminal.tsx +26 -8
- package/components/WorkspaceView.tsx +68 -47
- package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
- package/docs/design_automation_records/README.md +232 -0
- package/lib/agents/index.ts +9 -0
- package/lib/chat/agent-loop.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +110 -9
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/05-pipelines.md +31 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/help-docs/25-chat-tools.md +23 -0
- package/lib/pipeline.ts +27 -3
- package/lib/session-utils.ts +19 -0
- package/lib/task-manager.ts +73 -3
- package/lib/task-tmux-backend.ts +625 -0
- package/lib/terminal-standalone.ts +17 -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/RELEASE_NOTES.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.80
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-14
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.79
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
- feat(
|
|
10
|
-
- feat(
|
|
11
|
-
- feat(
|
|
8
|
+
- feat(chat): read_project_file + full task output + anti-fabrication guidance
|
|
9
|
+
- feat(pipeline): per-run tmux/headless backend selection
|
|
10
|
+
- feat(tasks): tmux backend — reliable completion detection + interactive session view
|
|
11
|
+
- feat(tasks): show backend + agent badges in TaskDetail header
|
|
12
|
+
- feat(tasks): tmux backend — interactive claude via Stop hook completion detection
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
15
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.79...v0.10.80
|
package/app/api/code/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join, relative, extname } from 'node:path';
|
|
3
|
+
import { join, relative, extname, resolve, sep } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { loadSettings } from '@/lib/settings';
|
|
@@ -10,6 +10,13 @@ interface FileNode {
|
|
|
10
10
|
path: string;
|
|
11
11
|
type: 'file' | 'dir';
|
|
12
12
|
children?: FileNode[];
|
|
13
|
+
hasChildren?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TreeListing {
|
|
17
|
+
tree: FileNode[];
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
count: number;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
const IGNORE = new Set([
|
|
@@ -17,27 +24,19 @@ const IGNORE = new Set([
|
|
|
17
24
|
'.DS_Store', 'coverage', '__pycache__', '.cache', '.output', 'target',
|
|
18
25
|
]);
|
|
19
26
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'.sql', '.graphql', '.proto', '.env', '.gitignore',
|
|
26
|
-
'.xml', '.csv', '.lock', '.properties', '.gradle', '.groovy',
|
|
27
|
-
'.scala', '.clj', '.cljs', '.jsp', '.erb', '.vue', '.svelte',
|
|
28
|
-
]);
|
|
27
|
+
const MAX_TREE_ENTRIES = 1000;
|
|
28
|
+
const MAX_FILE_INDEX_ENTRIES = 10000;
|
|
29
|
+
// Per-directory file cap for the search index, so one huge directory can't
|
|
30
|
+
// consume the whole budget and starve files in sibling directories.
|
|
31
|
+
const MAX_INDEX_FILES_PER_DIR = 1000;
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const ext = extname(name);
|
|
35
|
-
if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
|
|
36
|
-
return CODE_EXTS.has(ext) || IMAGE_EXTS.has(ext);
|
|
33
|
+
function isInside(parent: string, child: string): boolean {
|
|
34
|
+
const normalizedParent = resolve(parent);
|
|
35
|
+
const normalizedChild = resolve(child);
|
|
36
|
+
return normalizedChild === normalizedParent || normalizedChild.startsWith(normalizedParent + sep);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function scanDir(dir: string, base: string,
|
|
40
|
-
if (depth > 10) return [];
|
|
39
|
+
function scanDir(dir: string, base: string, maxEntries = MAX_TREE_ENTRIES): TreeListing {
|
|
41
40
|
try {
|
|
42
41
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
43
42
|
const nodes: FileNode[] = [];
|
|
@@ -50,23 +49,92 @@ function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
|
|
|
50
49
|
return a.name.localeCompare(b.name);
|
|
51
50
|
});
|
|
52
51
|
|
|
53
|
-
for (const entry of sorted) {
|
|
52
|
+
for (const entry of sorted.slice(0, maxEntries)) {
|
|
54
53
|
const fullPath = join(dir, entry.name);
|
|
55
54
|
const relPath = relative(base, fullPath);
|
|
56
55
|
|
|
57
56
|
if (entry.isDirectory()) {
|
|
58
|
-
|
|
59
|
-
nodes.push({ name: entry.name, path: relPath, type: 'dir', children });
|
|
57
|
+
nodes.push({ name: entry.name, path: relPath, type: 'dir', hasChildren: true });
|
|
60
58
|
} else {
|
|
61
59
|
nodes.push({ name: entry.name, path: relPath, type: 'file' });
|
|
62
60
|
}
|
|
63
61
|
}
|
|
64
|
-
return nodes;
|
|
62
|
+
return { tree: nodes, truncated: sorted.length > maxEntries, count: sorted.length };
|
|
65
63
|
} catch {
|
|
66
|
-
return [];
|
|
64
|
+
return { tree: [], truncated: false, count: 0 };
|
|
67
65
|
}
|
|
68
66
|
}
|
|
69
67
|
|
|
68
|
+
interface PathIndex {
|
|
69
|
+
files: FileNode[];
|
|
70
|
+
dirs: FileNode[];
|
|
71
|
+
truncated: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Flat index of the whole tree, computed once server-side so clients can search
|
|
75
|
+
// without ever holding the recursive structure.
|
|
76
|
+
//
|
|
77
|
+
// Breadth-first with a per-directory file cap: BFS so shallow source dirs are
|
|
78
|
+
// indexed before deep generated/vendor noise, and the per-dir cap so a single
|
|
79
|
+
// huge directory (e.g. a 150k-file build output) can't consume the whole budget
|
|
80
|
+
// and starve real files. `truncated` is set when either cap drops entries, so
|
|
81
|
+
// the client can tell the user search is partial.
|
|
82
|
+
function scanIndex(root: string, base: string, maxEntries = MAX_FILE_INDEX_ENTRIES, perDir = MAX_INDEX_FILES_PER_DIR): PathIndex {
|
|
83
|
+
const files: FileNode[] = [];
|
|
84
|
+
const dirs: FileNode[] = [];
|
|
85
|
+
let truncated = false;
|
|
86
|
+
const queue: string[] = [root];
|
|
87
|
+
|
|
88
|
+
while (queue.length > 0 && !(files.length >= maxEntries && dirs.length >= maxEntries)) {
|
|
89
|
+
const dir = queue.shift()!;
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
93
|
+
.filter(e => !IGNORE.has(e.name))
|
|
94
|
+
.sort((a, b) => {
|
|
95
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
96
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
97
|
+
return a.name.localeCompare(b.name);
|
|
98
|
+
});
|
|
99
|
+
} catch { continue; }
|
|
100
|
+
|
|
101
|
+
let dirFiles = 0;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const fullPath = join(dir, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
if (dirs.length < maxEntries) dirs.push({ name: entry.name, path: relative(base, fullPath), type: 'dir' });
|
|
106
|
+
else truncated = true;
|
|
107
|
+
queue.push(fullPath);
|
|
108
|
+
} else if (files.length >= maxEntries) {
|
|
109
|
+
truncated = true;
|
|
110
|
+
} else if (dirFiles >= perDir) {
|
|
111
|
+
truncated = true; // this directory has more files than we index per dir
|
|
112
|
+
} else {
|
|
113
|
+
files.push({ name: entry.name, path: relative(base, fullPath), type: 'file' });
|
|
114
|
+
dirFiles++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { files, dirs, truncated };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Return one directory level, but recurse into the directories named in
|
|
123
|
+
// `expandSet` (the ancestor chain of a target file). Lets the client reveal a
|
|
124
|
+
// deep file in one request. Bounded: at most one directory per level is
|
|
125
|
+
// expanded, each level capped at maxEntries.
|
|
126
|
+
function scanDirExpand(dir: string, base: string, expandSet: Set<string>, maxEntries = MAX_TREE_ENTRIES): FileNode[] {
|
|
127
|
+
const { tree } = scanDir(dir, base, maxEntries);
|
|
128
|
+
for (const node of tree) {
|
|
129
|
+
if (node.type === 'dir' && expandSet.has(node.path)) {
|
|
130
|
+
const children = scanDirExpand(join(dir, node.name), base, expandSet, maxEntries);
|
|
131
|
+
node.children = children;
|
|
132
|
+
node.hasChildren = children.length > 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return tree;
|
|
136
|
+
}
|
|
137
|
+
|
|
70
138
|
// GET /api/code?dir=<absolute-path>&file=<relative-path>
|
|
71
139
|
// dir mode: returns file tree for the given directory
|
|
72
140
|
// file mode: returns file content
|
|
@@ -74,17 +142,18 @@ export async function GET(req: Request) {
|
|
|
74
142
|
const { searchParams } = new URL(req.url);
|
|
75
143
|
const dir = searchParams.get('dir');
|
|
76
144
|
const filePath = searchParams.get('file');
|
|
145
|
+
const treePath = searchParams.get('treePath') || '';
|
|
77
146
|
|
|
78
147
|
if (!dir) {
|
|
79
148
|
return NextResponse.json({ tree: [], dirName: '' });
|
|
80
149
|
}
|
|
81
150
|
|
|
82
|
-
const resolvedDir = dir.replace(/^~/, homedir());
|
|
151
|
+
const resolvedDir = resolve(dir.replace(/^~/, homedir()));
|
|
83
152
|
|
|
84
153
|
// Security: dir must be under a projectRoot
|
|
85
154
|
const settings = loadSettings();
|
|
86
|
-
const projectRoots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
87
|
-
const allowed = projectRoots.some(root =>
|
|
155
|
+
const projectRoots = (settings.projectRoots || []).map(r => resolve(r.replace(/^~/, homedir())));
|
|
156
|
+
const allowed = projectRoots.some(root => isInside(root, resolvedDir));
|
|
88
157
|
if (!allowed) {
|
|
89
158
|
return NextResponse.json({ error: 'Directory not under any project root' }, { status: 403 });
|
|
90
159
|
}
|
|
@@ -120,8 +189,8 @@ export async function GET(req: Request) {
|
|
|
120
189
|
// Git diff for a specific file
|
|
121
190
|
const diffFile = searchParams.get('diff');
|
|
122
191
|
if (diffFile) {
|
|
123
|
-
const fullPath =
|
|
124
|
-
if (!
|
|
192
|
+
const fullPath = resolve(resolvedDir, diffFile);
|
|
193
|
+
if (!isInside(resolvedDir, fullPath)) {
|
|
125
194
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
126
195
|
}
|
|
127
196
|
try {
|
|
@@ -146,8 +215,8 @@ export async function GET(req: Request) {
|
|
|
146
215
|
|
|
147
216
|
// Read file content
|
|
148
217
|
if (filePath) {
|
|
149
|
-
const fullPath =
|
|
150
|
-
if (!
|
|
218
|
+
const fullPath = resolve(resolvedDir, filePath);
|
|
219
|
+
if (!isInside(resolvedDir, fullPath)) {
|
|
151
220
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
152
221
|
}
|
|
153
222
|
try {
|
|
@@ -201,9 +270,40 @@ export async function GET(req: Request) {
|
|
|
201
270
|
}
|
|
202
271
|
}
|
|
203
272
|
|
|
204
|
-
//
|
|
205
|
-
|
|
273
|
+
// Expand-to-path: return the tree pre-expanded along a target's ancestor
|
|
274
|
+
// chain so the client can reveal a deep file in a single request. The walk is
|
|
275
|
+
// bounded by path depth × MAX_TREE_ENTRIES, so it never materializes the
|
|
276
|
+
// whole tree.
|
|
277
|
+
const expandTo = searchParams.get('expandTo');
|
|
278
|
+
if (expandTo) {
|
|
279
|
+
const target = resolve(resolvedDir, expandTo);
|
|
280
|
+
if (!isInside(resolvedDir, target)) {
|
|
281
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
282
|
+
}
|
|
283
|
+
const expandSet = new Set<string>();
|
|
284
|
+
let rel = relative(resolvedDir, target);
|
|
285
|
+
if (rel && rel !== '.') expandSet.add(rel); // expand the target itself if it is a dir
|
|
286
|
+
while (rel && rel !== '.') {
|
|
287
|
+
rel = rel.split(sep).slice(0, -1).join(sep); // parent
|
|
288
|
+
if (rel) expandSet.add(rel);
|
|
289
|
+
}
|
|
290
|
+
return NextResponse.json({ tree: scanDirExpand(resolvedDir, resolvedDir, expandSet), expandTo });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Return one directory level. Children are loaded on demand with treePath.
|
|
294
|
+
const listingDir = resolve(resolvedDir, treePath);
|
|
295
|
+
if (!isInside(resolvedDir, listingDir)) {
|
|
296
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
297
|
+
}
|
|
298
|
+
const { tree, truncated, count } = scanDir(listingDir, resolvedDir);
|
|
206
299
|
const dirName = resolvedDir.split('/').pop() || resolvedDir;
|
|
300
|
+
const includeGit = treePath === '';
|
|
301
|
+
// One traversal builds both the file index (search) and the directory index
|
|
302
|
+
// (watch-path picker), so the client never walks the tree itself.
|
|
303
|
+
const index = includeGit ? scanIndex(resolvedDir, resolvedDir) : undefined;
|
|
304
|
+
const fileIndex = index?.files;
|
|
305
|
+
const dirIndex = index?.dirs;
|
|
306
|
+
const indexTruncated = index?.truncated ?? false;
|
|
207
307
|
|
|
208
308
|
// Git status: scan for git repos (could be root dir or subdirectories)
|
|
209
309
|
interface GitRepo {
|
|
@@ -237,29 +337,46 @@ export async function GET(req: Request) {
|
|
|
237
337
|
} catch {}
|
|
238
338
|
}
|
|
239
339
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
243
|
-
scanGitStatus(resolvedDir, '.', '');
|
|
244
|
-
} catch {
|
|
245
|
-
// Root is not a git repo — scan subdirectories
|
|
340
|
+
if (includeGit) {
|
|
341
|
+
// Check if root is a git repo
|
|
246
342
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
343
|
+
execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
344
|
+
scanGitStatus(resolvedDir, '.', '');
|
|
345
|
+
} catch {
|
|
346
|
+
// Root is not a git repo — scan subdirectories
|
|
347
|
+
try {
|
|
348
|
+
for (const entry of readdirSync(resolvedDir, { withFileTypes: true })) {
|
|
349
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
|
|
350
|
+
const subDir = join(resolvedDir, entry.name);
|
|
351
|
+
try {
|
|
352
|
+
execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
353
|
+
scanGitStatus(subDir, entry.name, entry.name);
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
} catch {}
|
|
357
|
+
}
|
|
256
358
|
}
|
|
257
359
|
|
|
258
360
|
// Flatten for backward compat
|
|
259
361
|
const gitChanges = gitRepos.flatMap(r => r.changes);
|
|
260
362
|
const gitBranch = gitRepos.length === 1 ? gitRepos[0].branch : '';
|
|
261
363
|
|
|
262
|
-
return NextResponse.json({
|
|
364
|
+
return NextResponse.json({
|
|
365
|
+
tree,
|
|
366
|
+
dirName,
|
|
367
|
+
dirPath: resolvedDir,
|
|
368
|
+
treePath,
|
|
369
|
+
truncated,
|
|
370
|
+
count,
|
|
371
|
+
limit: MAX_TREE_ENTRIES,
|
|
372
|
+
fileIndex,
|
|
373
|
+
dirIndex,
|
|
374
|
+
indexTruncated,
|
|
375
|
+
fileIndexLimit: MAX_FILE_INDEX_ENTRIES,
|
|
376
|
+
gitBranch,
|
|
377
|
+
gitChanges,
|
|
378
|
+
gitRepos,
|
|
379
|
+
});
|
|
263
380
|
}
|
|
264
381
|
|
|
265
382
|
// PUT /api/code — save file content
|
|
@@ -274,18 +391,18 @@ export async function PUT(req: Request) {
|
|
|
274
391
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
|
275
392
|
}
|
|
276
393
|
|
|
277
|
-
const resolvedDir = dir.replace(/^~/, homedir());
|
|
278
|
-
const fullPath =
|
|
394
|
+
const resolvedDir = resolve(dir.replace(/^~/, homedir()));
|
|
395
|
+
const fullPath = resolve(resolvedDir, file);
|
|
279
396
|
|
|
280
397
|
// Security: ensure path is within the directory
|
|
281
|
-
if (!
|
|
398
|
+
if (!isInside(resolvedDir, fullPath)) {
|
|
282
399
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
283
400
|
}
|
|
284
401
|
|
|
285
402
|
// Verify dir is under a configured project root
|
|
286
403
|
const settings = loadSettings();
|
|
287
|
-
const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
288
|
-
const allowed = roots.some(r =>
|
|
404
|
+
const roots = (settings.projectRoots || []).map(r => resolve(r.replace(/^~/, homedir())));
|
|
405
|
+
const allowed = roots.some(r => isInside(r, resolvedDir));
|
|
289
406
|
if (!allowed) {
|
|
290
407
|
return NextResponse.json({ error: 'Directory not in project roots' }, { status: 403 });
|
|
291
408
|
}
|
|
@@ -34,6 +34,8 @@ import { installFromMarketplace, listMarketplace } from '@/lib/workflow-marketpl
|
|
|
34
34
|
import { installFromRegistry } from '@/lib/connectors/sync';
|
|
35
35
|
import { resolveWizardTemplate } from '@/lib/connectors/wizard-template';
|
|
36
36
|
import { decryptDeep } from '@/lib/enterprise-secret';
|
|
37
|
+
import { encryptSecret } from '@/lib/crypto';
|
|
38
|
+
import { existsSync, readFileSync } from 'fs';
|
|
37
39
|
import { provisionTemperUser } from '@/lib/memory/temper-provision';
|
|
38
40
|
import type { AgentEntry, ApiProfile } from '@/lib/settings';
|
|
39
41
|
import bundledTemplate from '@/templates/connector-config-template.json';
|
|
@@ -59,6 +61,10 @@ interface OnboardingPayload {
|
|
|
59
61
|
tool: 'claude' | 'codex' | 'aider' | 'opencode';
|
|
60
62
|
path?: string;
|
|
61
63
|
setAsDefault?: boolean;
|
|
64
|
+
/** Extra env merged into the agent (e.g. CLAUDE_CODE_OAUTH_TOKEN for
|
|
65
|
+
* container deployments where `claude login` can't reach the keychain).
|
|
66
|
+
* Values are encrypted at rest (enc:…). */
|
|
67
|
+
env?: Record<string, string>;
|
|
62
68
|
};
|
|
63
69
|
connectorValues?: Record<string, string>; // template ${key} → value
|
|
64
70
|
/** Subset of template connector ids to install. Missing/empty = install
|
|
@@ -294,18 +300,43 @@ function applyApiProfile(p: OnboardingPayload['apiProfile']): string | null {
|
|
|
294
300
|
return null;
|
|
295
301
|
}
|
|
296
302
|
|
|
303
|
+
/** Best-effort deployment-shape detection. Container deploys can't run
|
|
304
|
+
* `claude login` (no libsecret/keychain) so the wizard guides a pasted
|
|
305
|
+
* CLAUDE_CODE_OAUTH_TOKEN instead. Signals (any → container): explicit
|
|
306
|
+
* FORGE_CONTAINER env, Docker's /.dockerenv, or a containerized cgroup. */
|
|
307
|
+
function detectDeployment(): 'container' | 'host' {
|
|
308
|
+
if (process.env.FORGE_CONTAINER === '1') return 'container';
|
|
309
|
+
try { if (existsSync('/.dockerenv')) return 'container'; } catch {}
|
|
310
|
+
try {
|
|
311
|
+
const cg = readFileSync('/proc/1/cgroup', 'utf-8');
|
|
312
|
+
if (/docker|containerd|kubepods|lxc/i.test(cg)) return 'container';
|
|
313
|
+
} catch {}
|
|
314
|
+
return 'host';
|
|
315
|
+
}
|
|
316
|
+
|
|
297
317
|
function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
|
|
298
318
|
if (!a) return null;
|
|
299
319
|
if (!a.id || !a.tool) return 'cli agent id and tool required';
|
|
300
320
|
const settings = loadSettings();
|
|
301
321
|
settings.agents = settings.agents || {};
|
|
302
322
|
const existing = settings.agents[a.id] || {};
|
|
323
|
+
// Merge extra env (e.g. CLAUDE_CODE_OAUTH_TOKEN), encrypting each value at
|
|
324
|
+
// rest. resolveTerminalLaunch decrypts on the way out. Skip blank values so
|
|
325
|
+
// re-running the wizard without re-pasting keeps the existing token.
|
|
326
|
+
let mergedEnv = (existing as any).env as Record<string, string> | undefined;
|
|
327
|
+
if (a.env && Object.keys(a.env).length) {
|
|
328
|
+
mergedEnv = { ...(mergedEnv || {}) };
|
|
329
|
+
for (const [k, v] of Object.entries(a.env)) {
|
|
330
|
+
if (typeof v === 'string' && v.trim()) mergedEnv[k] = encryptSecret(v.trim());
|
|
331
|
+
}
|
|
332
|
+
}
|
|
303
333
|
settings.agents[a.id] = {
|
|
304
334
|
...existing,
|
|
305
335
|
tool: a.tool,
|
|
306
336
|
name: existing.name || a.id,
|
|
307
337
|
enabled: true,
|
|
308
338
|
...(a.path ? { path: a.path } : {}),
|
|
339
|
+
...(mergedEnv ? { env: mergedEnv } : {}),
|
|
309
340
|
};
|
|
310
341
|
if (a.setAsDefault) settings.defaultAgent = a.id;
|
|
311
342
|
// Terminal launch (lib/claude-process.ts) reads settings.claudePath,
|
|
@@ -1244,6 +1275,7 @@ export async function GET(req: Request) {
|
|
|
1244
1275
|
projectRoots: settings.projectRoots || [],
|
|
1245
1276
|
},
|
|
1246
1277
|
detected_cli: detected,
|
|
1278
|
+
deployment: detectDeployment(),
|
|
1247
1279
|
template_connectors: templateConnectors,
|
|
1248
1280
|
template_prompts: template._prompts || {},
|
|
1249
1281
|
template_agents_preview: agentsPreview.length ? agentsPreview : undefined,
|
|
@@ -13,8 +13,7 @@ function md5(content: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** Recursively list files in a directory */
|
|
16
|
-
function listFiles(dir: string, prefix = ''): { path: string; size: number }[] {
|
|
17
|
-
const files: { path: string; size: number }[] = [];
|
|
16
|
+
function listFiles(dir: string, prefix = '', files: { path: string; size: number }[] = []): { path: string; size: number }[] {
|
|
18
17
|
if (!existsSync(dir)) return files;
|
|
19
18
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
20
19
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
@@ -22,7 +21,7 @@ function listFiles(dir: string, prefix = ''): { path: string; size: number }[] {
|
|
|
22
21
|
if (entry.isFile()) {
|
|
23
22
|
files.push({ path: relPath, size: statSync(fullPath).size });
|
|
24
23
|
} else if (entry.isDirectory()) {
|
|
25
|
-
|
|
24
|
+
listFiles(fullPath, relPath, files);
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
27
|
return files;
|
|
@@ -148,7 +147,9 @@ export async function GET(req: Request) {
|
|
|
148
147
|
}
|
|
149
148
|
// Directory contents
|
|
150
149
|
if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
|
|
151
|
-
|
|
150
|
+
for (const file of listFiles(dirPath)) {
|
|
151
|
+
files.push({ path: `${name}/${file.path}`, size: file.size });
|
|
152
|
+
}
|
|
152
153
|
}
|
|
153
154
|
return NextResponse.json({ files });
|
|
154
155
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { fireTmuxHook, completeStaleTmuxTask } from '@/lib/task-tmux-backend';
|
|
3
|
+
|
|
4
|
+
// Called by the Claude Code Stop hook when a tmux-backend task turn completes.
|
|
5
|
+
// The hook script (installed in ~/.claude/settings.json) reads task-context.json
|
|
6
|
+
// from the project dir and POSTs here. We resolve the awaited promise in executeTmuxTask.
|
|
7
|
+
// If no waiter exists (server restart mid-task), fall back to directly completing the task.
|
|
8
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { id } = await params;
|
|
10
|
+
const handled = fireTmuxHook(id);
|
|
11
|
+
if (!handled) {
|
|
12
|
+
completeStaleTmuxTask(id);
|
|
13
|
+
}
|
|
14
|
+
return NextResponse.json({ ok: true });
|
|
15
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function GET(req: Request) {
|
|
|
14
14
|
|
|
15
15
|
// Create a new task
|
|
16
16
|
export async function POST(req: Request) {
|
|
17
|
-
const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent } = await req.json();
|
|
17
|
+
const { projectName, prompt, priority, newSession, conversationId, scheduledAt, mode, watchConfig, agent, backend } = await req.json();
|
|
18
18
|
|
|
19
19
|
if (!projectName || !prompt) {
|
|
20
20
|
return NextResponse.json({ error: 'projectName and prompt are required' }, { status: 400 });
|
|
@@ -38,6 +38,7 @@ export async function POST(req: Request) {
|
|
|
38
38
|
mode: mode || 'prompt',
|
|
39
39
|
watchConfig: watchConfig || undefined,
|
|
40
40
|
agent: agent || undefined,
|
|
41
|
+
backend: backend === 'tmux' ? 'tmux' : undefined,
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
return NextResponse.json(task);
|
package/cli/mw.mjs
CHANGED
|
@@ -1352,7 +1352,7 @@ var init_clean = __esm({
|
|
|
1352
1352
|
|
|
1353
1353
|
// cli/mw.ts
|
|
1354
1354
|
var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
|
|
1355
|
-
var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "
|
|
1355
|
+
var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "8403"}`;
|
|
1356
1356
|
var [, , cmd, ...args] = process.argv;
|
|
1357
1357
|
async function checkForUpdate() {
|
|
1358
1358
|
try {
|
|
@@ -1463,19 +1463,21 @@ async function main() {
|
|
|
1463
1463
|
case "task":
|
|
1464
1464
|
case "t": {
|
|
1465
1465
|
const newSession = args.includes("--new");
|
|
1466
|
-
const
|
|
1466
|
+
const useTmux = args.includes("--tmux");
|
|
1467
|
+
const filtered = args.filter((a) => a !== "--new" && a !== "--tmux");
|
|
1467
1468
|
const project = filtered[0];
|
|
1468
1469
|
const prompt = filtered.slice(1).join(" ");
|
|
1469
1470
|
if (!project || !prompt) {
|
|
1470
|
-
console.log("Usage: mw task <project> <prompt> [--new]");
|
|
1471
|
-
console.log(" --new
|
|
1471
|
+
console.log("Usage: mw task <project> <prompt> [--new] [--tmux]");
|
|
1472
|
+
console.log(" --new Start a fresh session (ignore previous context)");
|
|
1473
|
+
console.log(" --tmux Run via tmux backend (interactive mode, subscription billing)");
|
|
1472
1474
|
console.log('Example: mw task my-app "Fix the login bug"');
|
|
1473
1475
|
process.exit(1);
|
|
1474
1476
|
}
|
|
1475
1477
|
const task = await api3("/api/tasks", {
|
|
1476
1478
|
method: "POST",
|
|
1477
1479
|
headers: { "Content-Type": "application/json" },
|
|
1478
|
-
body: JSON.stringify({ projectName: project, prompt, newSession })
|
|
1480
|
+
body: JSON.stringify({ projectName: project, prompt, newSession, ...useTmux ? { backend: "tmux" } : {} })
|
|
1479
1481
|
});
|
|
1480
1482
|
const session = task.conversationId ? "(continuing session)" : "(new session)";
|
|
1481
1483
|
console.log(`\u2713 Task ${task.id} created ${session}`);
|
package/cli/mw.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === '--port');
|
|
19
|
-
const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '
|
|
19
|
+
const BASE = process.env.MW_URL || `http://localhost:${_cliPort || '8403'}`;
|
|
20
20
|
|
|
21
21
|
const [, , cmd, ...args] = process.argv;
|
|
22
22
|
|
|
@@ -149,21 +149,23 @@ async function main() {
|
|
|
149
149
|
|
|
150
150
|
case 'task':
|
|
151
151
|
case 't': {
|
|
152
|
-
// Parse --new
|
|
152
|
+
// Parse --new and --tmux flags
|
|
153
153
|
const newSession = args.includes('--new');
|
|
154
|
-
const
|
|
154
|
+
const useTmux = args.includes('--tmux');
|
|
155
|
+
const filtered = args.filter(a => a !== '--new' && a !== '--tmux');
|
|
155
156
|
const project = filtered[0];
|
|
156
157
|
const prompt = filtered.slice(1).join(' ');
|
|
157
158
|
if (!project || !prompt) {
|
|
158
|
-
console.log('Usage: mw task <project> <prompt> [--new]');
|
|
159
|
-
console.log(' --new
|
|
159
|
+
console.log('Usage: mw task <project> <prompt> [--new] [--tmux]');
|
|
160
|
+
console.log(' --new Start a fresh session (ignore previous context)');
|
|
161
|
+
console.log(' --tmux Run via tmux backend (interactive mode, subscription billing)');
|
|
160
162
|
console.log('Example: mw task my-app "Fix the login bug"');
|
|
161
163
|
process.exit(1);
|
|
162
164
|
}
|
|
163
165
|
const task = await api('/api/tasks', {
|
|
164
166
|
method: 'POST',
|
|
165
167
|
headers: { 'Content-Type': 'application/json' },
|
|
166
|
-
body: JSON.stringify({ projectName: project, prompt, newSession }),
|
|
168
|
+
body: JSON.stringify({ projectName: project, prompt, newSession, ...(useTmux ? { backend: 'tmux' } : {}) }),
|
|
167
169
|
});
|
|
168
170
|
const session = task.conversationId ? '(continuing session)' : '(new session)';
|
|
169
171
|
console.log(`✓ Task ${task.id} created ${session}`);
|