@aion0/forge 0.10.77 → 0.10.79
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 +6 -6
- 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/chat/page.tsx +53 -1
- package/components/CodeViewer.tsx +127 -41
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/MobileChat.tsx +225 -0
- package/components/MobileView.tsx +22 -2
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/WebTerminal.tsx +19 -8
- package/components/WorkspaceView.tsx +68 -47
- package/lib/agents/index.ts +9 -0
- package/lib/chat/telegram-bridge.ts +15 -0
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/00-overview.md +2 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/02-telegram.md +3 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/projects.ts +15 -5
- package/lib/session-utils.ts +19 -0
- package/lib/telegram-bot.ts +74 -26
- package/lib/terminal-standalone.ts +17 -0
- package/package.json +1 -1
package/lib/projects.ts
CHANGED
|
@@ -400,11 +400,21 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
|
|
|
400
400
|
const projects = scanProjects();
|
|
401
401
|
const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
402
402
|
const wantBase = want.split('/').pop()!;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
403
|
+
// Prefer ANY local checkout — pipelines run in a worktree, so picking
|
|
404
|
+
// either of several same-repo checkouts (e.g. FortiNAC vs FortiNAC-v3,
|
|
405
|
+
// both origin fortinac/fortinac) is safe and never touches the user's
|
|
406
|
+
// branch. Order: exact origin path → origin basename → project name.
|
|
407
|
+
// Taking the first match (not requiring a unique one) is what keeps a
|
|
408
|
+
// clone — which fails when the GitLab host is behind a VPN the server
|
|
409
|
+
// can't reach — from being attempted at all when a local copy exists.
|
|
410
|
+
const localHit =
|
|
411
|
+
projects.find((p) => p.repo && p.repo === want) ||
|
|
412
|
+
projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
|
|
413
|
+
projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
|
|
414
|
+
if (localHit) return { project: localHit, source: 'existing' };
|
|
415
|
+
|
|
416
|
+
// No local checkout at all — clone (may fail if host unreachable, then
|
|
417
|
+
// scratch below).
|
|
408
418
|
const cloned = tryGitlabClone(targetPath);
|
|
409
419
|
if (cloned) return { project: cloned, source: 'gitlab-cloned', clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git` };
|
|
410
420
|
}
|
package/lib/session-utils.ts
CHANGED
|
@@ -51,3 +51,22 @@ export async function getMcpFlag(projectPath: string): Promise<string> {
|
|
|
51
51
|
return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* tmux env injection — keep secret env (API keys) out of pane echo + shell
|
|
57
|
+
* history. The server stores values via `tmux set-environment` (the `setenv` WS
|
|
58
|
+
* message); this prefix pulls them back at launch time so only var NAMES appear
|
|
59
|
+
* in the typed command, never the values.
|
|
60
|
+
*
|
|
61
|
+
* Uses a single `eval "$(tmux show-environment -s | grep …)"` instead of one
|
|
62
|
+
* `export K="$(tmux show-environment K | sed …)"` per var — the latter form
|
|
63
|
+
* grows O(N) in length and hits tmux send-keys buffer limits with many vars.
|
|
64
|
+
* `tmux show-environment -s` outputs `KEY="val"; export KEY;` per line, so
|
|
65
|
+
* grepping `^(K1|K2)=` and eval-ing the matches is both short and safe.
|
|
66
|
+
*/
|
|
67
|
+
export function tmuxEnvPrefix(keys: string[]): string {
|
|
68
|
+
const valid = keys.filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k));
|
|
69
|
+
if (!valid.length) return '';
|
|
70
|
+
const pattern = `^(${valid.join('|')})=`;
|
|
71
|
+
return `eval "$(tmux show-environment -s 2>/dev/null | grep -E '${pattern}')" && `;
|
|
72
|
+
}
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -32,7 +32,11 @@ const chatNumberedTasks = new Map<number, Map<number, string>>();
|
|
|
32
32
|
const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
|
|
33
33
|
const chatNumberedProjects = new Map<number, Map<number, string>>();
|
|
34
34
|
// Track what the last numbered list was for
|
|
35
|
-
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek' | 'inject-pick' | 'inject-typing'>();
|
|
35
|
+
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek' | 'inject-pick' | 'inject-typing' | 'chat-pick'>();
|
|
36
|
+
// Chat mode: once entered (/chat → pick session), every non-command message
|
|
37
|
+
// goes to the Forge chat agent — same as the local /chat — until /endchat.
|
|
38
|
+
const chatActiveMode = new Set<number>(); // chatIds currently in chat mode
|
|
39
|
+
const chatNumberedChatSessions = new Map<number, Map<number, string>>(); // num → chat session id (0 = new)
|
|
36
40
|
// Inject mode state — picked tmux session per chat
|
|
37
41
|
const chatNumberedTmux = new Map<number, Map<number, string>>(); // num → tmux session name
|
|
38
42
|
const chatInjectTarget = new Map<number, string>(); // chatId → tmux session name (currently selected)
|
|
@@ -147,11 +151,27 @@ async function handleMessage(msg: any) {
|
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Chat mode: every non-command message (including bare numbers) goes to the
|
|
155
|
+
// chat agent until /endchat. Must run BEFORE numbered selection so digits are
|
|
156
|
+
// treated as chat content, not list picks. Commands fall through.
|
|
157
|
+
if (chatActiveMode.has(chatId) && !text.startsWith('/')) {
|
|
158
|
+
await handleChat(chatId, text);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
150
162
|
// Quick number selection (1-10) → context-dependent
|
|
151
163
|
if (/^\d{1,2}$/.test(text)) {
|
|
152
164
|
const num = parseInt(text);
|
|
153
165
|
const mode = chatListMode.get(chatId);
|
|
154
166
|
|
|
167
|
+
if (mode === 'chat-pick') {
|
|
168
|
+
const sessMap = chatNumberedChatSessions.get(chatId);
|
|
169
|
+
if (num === 0 || sessMap?.has(num)) {
|
|
170
|
+
await pickChatSession(chatId, num);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
if (mode === 'task-create') {
|
|
156
176
|
const projMap = chatNumberedProjects.get(chatId);
|
|
157
177
|
if (projMap?.has(num)) {
|
|
@@ -298,17 +318,10 @@ async function handleMessage(msg: any) {
|
|
|
298
318
|
}
|
|
299
319
|
case '/chat':
|
|
300
320
|
case '/c':
|
|
301
|
-
|
|
302
|
-
await send(chatId, 'Usage: /chat <message>\n/chat_new to start a fresh session\n/chat_session to show the active session id');
|
|
303
|
-
} else {
|
|
304
|
-
await handleChat(chatId, args.join(' '));
|
|
305
|
-
}
|
|
306
|
-
break;
|
|
307
|
-
case '/chat_new':
|
|
308
|
-
await handleChatReset(chatId);
|
|
321
|
+
await enterChatMode(chatId);
|
|
309
322
|
break;
|
|
310
|
-
case '/
|
|
311
|
-
await
|
|
323
|
+
case '/endchat':
|
|
324
|
+
await exitChatMode(chatId);
|
|
312
325
|
break;
|
|
313
326
|
case '/tunnel':
|
|
314
327
|
await handleTunnelStatus(chatId);
|
|
@@ -368,9 +381,8 @@ async function sendHelp(chatId: number) {
|
|
|
368
381
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
369
382
|
`/projects — list projects\n` +
|
|
370
383
|
`🤖 /agents — list available agents\n\n` +
|
|
371
|
-
`💬 /chat
|
|
372
|
-
`/
|
|
373
|
-
`/chat_session — show active session id\n\n` +
|
|
384
|
+
`💬 /chat — enter chat mode (pick/new session)\n` +
|
|
385
|
+
`/endchat — leave chat mode\n\n` +
|
|
374
386
|
`🌐 /tunnel — status\n` +
|
|
375
387
|
`/tunnel_start / /tunnel_stop\n` +
|
|
376
388
|
`/tunnel_code <admin_pw> — get session code\n\n` +
|
|
@@ -396,20 +408,56 @@ async function handleChat(chatId: number, userText: string) {
|
|
|
396
408
|
}
|
|
397
409
|
}
|
|
398
410
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
411
|
+
// Enter chat mode: show a numbered session picker (0 = new). Once the user
|
|
412
|
+
// picks, every plain message goes to the chat agent until /endchat.
|
|
413
|
+
async function enterChatMode(chatId: number) {
|
|
414
|
+
// Leave any active chat first so digits in the picker are read as choices,
|
|
415
|
+
// not chat content.
|
|
416
|
+
chatActiveMode.delete(chatId);
|
|
417
|
+
try {
|
|
418
|
+
const { listChatSessions } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
419
|
+
const sessions = await listChatSessions(15);
|
|
420
|
+
const numMap = new Map<number, string>();
|
|
421
|
+
const lines = ['💬 Chat mode — pick a session:', '', '0. ➕ New session'];
|
|
422
|
+
sessions.forEach((s, i) => {
|
|
423
|
+
const n = i + 1;
|
|
424
|
+
numMap.set(n, s.id);
|
|
425
|
+
const title = (s.title || s.id.slice(0, 8)).slice(0, 40);
|
|
426
|
+
lines.push(`${n}. ${title}`);
|
|
427
|
+
});
|
|
428
|
+
chatNumberedChatSessions.set(chatId, numMap);
|
|
429
|
+
chatListMode.set(chatId, 'chat-pick');
|
|
430
|
+
lines.push('', 'Reply with a number. /endchat to cancel.');
|
|
431
|
+
await send(chatId, lines.join('\n'));
|
|
432
|
+
} catch (err) {
|
|
433
|
+
await send(chatId, `✗ chat error: ${(err as Error).message}`);
|
|
434
|
+
}
|
|
403
435
|
}
|
|
404
436
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
437
|
+
// Pick a session (0 = new) → bind it and switch to chat-active mode.
|
|
438
|
+
async function pickChatSession(chatId: number, num: number) {
|
|
439
|
+
const { setTelegramSession, newChatSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
440
|
+
let sessionId: string | null;
|
|
441
|
+
if (num === 0) {
|
|
442
|
+
sessionId = await newChatSession(`Telegram chat ${chatId}`);
|
|
443
|
+
if (!sessionId) { await send(chatId, '✗ could not create a new session'); return; }
|
|
444
|
+
} else {
|
|
445
|
+
sessionId = chatNumberedChatSessions.get(chatId)?.get(num) || null;
|
|
446
|
+
if (!sessionId) { await send(chatId, 'Invalid choice. /chat to list again.'); return; }
|
|
447
|
+
}
|
|
448
|
+
setTelegramSession(chatId, sessionId);
|
|
449
|
+
chatListMode.delete(chatId);
|
|
450
|
+
chatNumberedChatSessions.delete(chatId);
|
|
451
|
+
chatActiveMode.add(chatId);
|
|
452
|
+
await send(chatId, `✅ Chat started${num === 0 ? ' (new session)' : ''}. Send messages normally — every message goes to the Forge agent.\n\n/endchat to leave.`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Leave chat mode. Keeps the session binding so /chat can resume it later.
|
|
456
|
+
async function exitChatMode(chatId: number) {
|
|
457
|
+
const wasActive = chatActiveMode.delete(chatId);
|
|
458
|
+
chatListMode.delete(chatId);
|
|
459
|
+
chatNumberedChatSessions.delete(chatId);
|
|
460
|
+
await send(chatId, wasActive ? '👋 Left chat mode.' : 'Not in chat mode.');
|
|
413
461
|
}
|
|
414
462
|
|
|
415
463
|
async function sendAgentList(chatId: number) {
|
|
@@ -424,6 +424,23 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
424
424
|
break;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
case 'setenv': {
|
|
428
|
+
// Store secret env (API keys) into the tmux SESSION environment so the
|
|
429
|
+
// launch command can pull them via `tmux show-environment` instead of
|
|
430
|
+
// typing `export KEY=value` into the pane — keeps secrets out of the
|
|
431
|
+
// terminal echo and shell history. Values never touch the typed line.
|
|
432
|
+
const name = parsed.sessionName;
|
|
433
|
+
const env = parsed.env;
|
|
434
|
+
if (name && tmuxSessionExists(name) && env && typeof env === 'object') {
|
|
435
|
+
for (const [k, v] of Object.entries(env)) {
|
|
436
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
437
|
+
const val = `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
438
|
+
try { execSync(`${TMUX} set-environment -t "${name}" ${k} ${val}`, { timeout: 3000 }); } catch {}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
|
|
427
444
|
case 'load-state': {
|
|
428
445
|
const state = loadTerminalState();
|
|
429
446
|
ws.send(JSON.stringify({ type: 'terminal-state', data: state }));
|
package/package.json
CHANGED