@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.
Files changed (38) hide show
  1. package/RELEASE_NOTES.md +9 -8
  2. package/app/api/code/route.ts +171 -54
  3. package/app/api/onboarding/route.ts +32 -0
  4. package/app/api/skills/local/route.ts +5 -4
  5. package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
  6. package/app/api/tasks/route.ts +2 -1
  7. package/cli/mw.mjs +7 -5
  8. package/cli/mw.ts +8 -6
  9. package/components/CodeViewer.tsx +127 -41
  10. package/components/Dashboard.tsx +6 -2
  11. package/components/DocsViewer.tsx +34 -22
  12. package/components/HelpTerminal.tsx +9 -5
  13. package/components/OnboardingWizard.tsx +65 -1
  14. package/components/ProjectDetail.tsx +33 -7
  15. package/components/TaskDetail.tsx +28 -1
  16. package/components/TmuxTaskTerminal.tsx +105 -0
  17. package/components/WebTerminal.tsx +26 -8
  18. package/components/WorkspaceView.tsx +68 -47
  19. package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
  20. package/docs/design_automation_records/README.md +232 -0
  21. package/lib/agents/index.ts +9 -0
  22. package/lib/chat/agent-loop.ts +6 -0
  23. package/lib/chat/tool-dispatcher.ts +110 -9
  24. package/lib/fileTree.ts +28 -0
  25. package/lib/help-docs/01-settings.md +11 -0
  26. package/lib/help-docs/05-pipelines.md +31 -0
  27. package/lib/help-docs/07-projects.md +3 -1
  28. package/lib/help-docs/25-chat-tools.md +23 -0
  29. package/lib/pipeline.ts +27 -3
  30. package/lib/session-utils.ts +19 -0
  31. package/lib/task-manager.ts +73 -3
  32. package/lib/task-tmux-backend.ts +625 -0
  33. package/lib/terminal-standalone.ts +17 -0
  34. package/lib/workspace/skill-installer.ts +18 -8
  35. package/package.json +1 -1
  36. package/proxy.ts +5 -4
  37. package/src/core/db/database.ts +1 -0
  38. package/src/types/index.ts +3 -0
package/RELEASE_NOTES.md CHANGED
@@ -1,14 +1,15 @@
1
- # Forge v0.10.78
1
+ # Forge v0.10.80
2
2
 
3
- Released: 2026-06-13
3
+ Released: 2026-06-14
4
4
 
5
- ## Changes since v0.10.77
5
+ ## Changes since v0.10.79
6
6
 
7
7
  ### Other
8
- - fix(projects): prefer ANY local checkout over clone when project is empty
9
- - feat(chat): live pipeline + task runtime chips in web chat
10
- - feat(mobile): default to Forge chat agent, toggle to terminal sessions
11
- - feat(telegram): full chat mode /chat picks session, all messages route to agent, /endchat exits
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.77...v0.10.78
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.79...v0.10.80
@@ -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 CODE_EXTS = new Set([
21
- '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
22
- '.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h',
23
- '.css', '.scss', '.html', '.json', '.yaml', '.yml', '.toml',
24
- '.md', '.txt', '.sh', '.bash', '.zsh', '.fish',
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
- const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
31
-
32
- function isCodeFile(name: string): boolean {
33
- if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
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, depth: number = 0): FileNode[] {
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
- const children = scanDir(fullPath, base, depth + 1);
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 => resolvedDir.startsWith(root) || resolvedDir === 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 = join(resolvedDir, diffFile);
124
- if (!fullPath.startsWith(resolvedDir)) {
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 = join(resolvedDir, filePath);
150
- if (!fullPath.startsWith(resolvedDir)) {
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
- // Return file tree
205
- const tree = scanDir(resolvedDir, resolvedDir);
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
- // Check if root is a git repo
241
- try {
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
- for (const entry of readdirSync(resolvedDir, { withFileTypes: true })) {
248
- if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
249
- const subDir = join(resolvedDir, entry.name);
250
- try {
251
- execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
252
- scanGitStatus(subDir, entry.name, entry.name);
253
- } catch {}
254
- }
255
- } catch {}
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({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges, gitRepos });
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 = join(resolvedDir, file);
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 (!fullPath.startsWith(resolvedDir)) {
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 => resolvedDir.startsWith(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
- files.push(...listFiles(fullPath, relPath));
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
- files.push(...listFiles(dirPath).map(f => ({ path: `${name}/${f.path}`, size: f.size })));
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
+ }
@@ -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 || "3000"}`;
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 filtered = args.filter((a) => a !== "--new");
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 Start a fresh session (ignore previous context)");
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 || '3000'}`;
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 flag to force a fresh session
152
+ // Parse --new and --tmux flags
153
153
  const newSession = args.includes('--new');
154
- const filtered = args.filter(a => a !== '--new');
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 Start a fresh session (ignore previous context)');
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}`);