@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/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
- const byRepo = projects.filter((p) => p.repo && p.repo === want);
404
- if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
405
- const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
406
- if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
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
  }
@@ -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
+ }
@@ -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
- if (args.length === 0) {
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 '/chat_session':
311
- await handleChatStatus(chatId);
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 <msg> — chat with Forge agent\n` +
372
- `/chat_newstart a fresh chat session\n` +
373
- `/chat_session — show active session id\n\n` +
384
+ `💬 /chat — enter chat mode (pick/new session)\n` +
385
+ `/endchatleave 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
- async function handleChatReset(chatId: number) {
400
- const { clearTelegramSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
401
- clearTelegramSession(chatId);
402
- await send(chatId, '✓ Chat session cleared. The next /chat starts fresh.');
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
- async function handleChatStatus(chatId: number) {
406
- const { getTelegramSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
407
- const id = getTelegramSession(chatId);
408
- if (!id) {
409
- await send(chatId, 'No active chat session. /chat <message> creates one.');
410
- return;
411
- }
412
- await send(chatId, `Active session: ${id}\n/chat_new to start fresh`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.77",
3
+ "version": "0.10.79",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {