@aion0/forge 0.10.77 → 0.10.78

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 CHANGED
@@ -1,12 +1,14 @@
1
- # Forge v0.10.77
1
+ # Forge v0.10.78
2
2
 
3
- Released: 2026-06-12
3
+ Released: 2026-06-13
4
4
 
5
- ## Changes since v0.10.76
5
+ ## Changes since v0.10.77
6
6
 
7
7
  ### Other
8
- - fix(ui): route idpManualUrls open through openPortal too
9
- - feat(ui): Open-portal buttons route through container Chromium when present
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
10
12
 
11
13
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.76...v0.10.77
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.77...v0.10.78
package/app/chat/page.tsx CHANGED
@@ -65,6 +65,13 @@ export default function ChatPage() {
65
65
  // Background-watch progress chips (watch_id → {text, ts}). Ambient,
66
66
  // updated in place; pruned when stale. Not part of the message thread.
67
67
  const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
68
+ // Live pipeline + task runtime (polled from /api/activity/summary). Shows
69
+ // what the chat-triggered trigger_pipeline / dispatch_task — or anything
70
+ // else — has in flight, without leaving the chat.
71
+ const [running, setRunning] = useState<{
72
+ pipelines: Array<{ id: string; workflowName: string; currentNode: string | null; progress: { done: number; total: number } }>;
73
+ tasks: Array<{ id: string; project: string; prompt_preview: string; status: string }>;
74
+ }>({ pipelines: [], tasks: [] });
68
75
  const [input, setInput] = useState('');
69
76
  const [streaming, setStreaming] = useState(false);
70
77
  // True from Stop-click → next iteration boundary on the backend. Drives
@@ -258,6 +265,31 @@ export default function ChatPage() {
258
265
  return () => clearInterval(t);
259
266
  }, []);
260
267
 
268
+ // ─── Poll live pipeline + task runtime ────────────────────
269
+ useEffect(() => {
270
+ let alive = true;
271
+ const tick = async () => {
272
+ try {
273
+ const r = await fetch('/api/activity/summary');
274
+ if (!r.ok) return;
275
+ const j = await r.json();
276
+ if (!alive) return;
277
+ setRunning({
278
+ pipelines: (j.running || []).map((p: any) => ({
279
+ id: p.id, workflowName: p.workflowName, currentNode: p.currentNode,
280
+ progress: p.progress || { done: 0, total: 0 },
281
+ })),
282
+ tasks: (j.running_tasks || []).map((t: any) => ({
283
+ id: t.id, project: t.project, prompt_preview: t.prompt_preview, status: t.status,
284
+ })),
285
+ });
286
+ } catch { /* keep last */ }
287
+ };
288
+ tick();
289
+ const iv = setInterval(tick, 5000);
290
+ return () => { alive = false; clearInterval(iv); };
291
+ }, []);
292
+
261
293
  // ─── Auto-resize composer ─────────────────────────────────
262
294
  useEffect(() => {
263
295
  const el = composerRef.current;
@@ -740,7 +772,7 @@ export default function ChatPage() {
740
772
  )}
741
773
  </div>
742
774
 
743
- {Object.keys(watchChips).length > 0 && (
775
+ {(Object.keys(watchChips).length > 0 || running.pipelines.length > 0 || running.tasks.length > 0) && (
744
776
  <div className="px-6 pt-2">
745
777
  <div className="max-w-3xl mx-auto flex flex-wrap gap-2">
746
778
  {Object.entries(watchChips).map(([id, w]) => (
@@ -753,6 +785,26 @@ export default function ChatPage() {
753
785
  {w.text}
754
786
  </span>
755
787
  ))}
788
+ {running.pipelines.map((p) => (
789
+ <span
790
+ key={p.id}
791
+ title={`pipeline ${p.id}${p.currentNode ? ` — ${p.currentNode}` : ''}`}
792
+ className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
793
+ >
794
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
795
+ ⛓ {p.workflowName} {p.progress.total > 0 ? `${p.progress.done}/${p.progress.total}` : ''}{p.currentNode ? ` · ${p.currentNode}` : ''}
796
+ </span>
797
+ ))}
798
+ {running.tasks.map((t) => (
799
+ <span
800
+ key={t.id}
801
+ title={`task ${t.id} (${t.project})`}
802
+ className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
803
+ >
804
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
805
+ ⚙ {t.project}: {t.prompt_preview.slice(0, 30)}
806
+ </span>
807
+ ))}
756
808
  </div>
757
809
  </div>
758
810
  )}
@@ -0,0 +1,225 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Mobile chat view — talks to chat-standalone via /api/chat-proxy, the same
5
+ * backend as the web /chat and the Telegram chat bridge. Self-contained:
6
+ * session picker (+ new), message history, streaming SSE. Rendered by
7
+ * MobileView when viewMode === 'chat' (the default).
8
+ */
9
+
10
+ import { useState, useEffect, useRef, useCallback } from 'react';
11
+
12
+ const PROXY = '/api/chat-proxy';
13
+
14
+ interface ChatSession { id: string; title?: string; updated_at?: string }
15
+ interface Block { type: string; text?: string; name?: string }
16
+ interface ChatMsg { id?: string; role: string; blocks?: Block[]; content?: string; error?: string }
17
+
18
+ function msgText(m: ChatMsg): string {
19
+ if (typeof m.content === 'string' && m.content) return m.content;
20
+ if (Array.isArray(m.blocks)) {
21
+ return m.blocks
22
+ .map((b) => (b.type === 'text' ? (b.text || '') : b.type === 'tool_use' ? `⚙ ${b.name || 'tool'}` : ''))
23
+ .filter(Boolean)
24
+ .join('\n');
25
+ }
26
+ return '';
27
+ }
28
+
29
+ export default function MobileChat() {
30
+ const [sessions, setSessions] = useState<ChatSession[]>([]);
31
+ const [activeId, setActiveId] = useState<string>('');
32
+ const [messages, setMessages] = useState<ChatMsg[]>([]);
33
+ const [input, setInput] = useState('');
34
+ const [streaming, setStreaming] = useState(false);
35
+ const [partial, setPartial] = useState('');
36
+ const [showPicker, setShowPicker] = useState(false);
37
+ const scrollRef = useRef<HTMLDivElement>(null);
38
+ const srcRef = useRef<EventSource | null>(null);
39
+
40
+ const refreshSessions = useCallback(async () => {
41
+ try {
42
+ const r = await fetch(`${PROXY}/sessions?limit=50`);
43
+ const j = await r.json();
44
+ setSessions(Array.isArray(j?.sessions) ? j.sessions : []);
45
+ } catch { /* keep */ }
46
+ }, []);
47
+
48
+ const loadMessages = useCallback(async (id: string) => {
49
+ try {
50
+ const r = await fetch(`${PROXY}/sessions/${encodeURIComponent(id)}?limit=500`);
51
+ const j = await r.json();
52
+ setMessages(Array.isArray(j?.messages) ? j.messages : []);
53
+ } catch { /* keep */ }
54
+ }, []);
55
+
56
+ // First load: pick the main session (or the most recent / a fresh one).
57
+ useEffect(() => {
58
+ (async () => {
59
+ await refreshSessions();
60
+ try {
61
+ const r = await fetch(`${PROXY}/sessions/main`);
62
+ if (r.ok) {
63
+ const j = await r.json();
64
+ const id = j?.session?.id || j?.id;
65
+ if (id) { setActiveId(id); return; }
66
+ }
67
+ } catch { /* fall through */ }
68
+ try {
69
+ const r = await fetch(`${PROXY}/sessions?limit=1`);
70
+ const j = await r.json();
71
+ const id = j?.sessions?.[0]?.id;
72
+ if (id) setActiveId(id);
73
+ } catch { /* none */ }
74
+ })();
75
+ }, [refreshSessions]);
76
+
77
+ useEffect(() => { if (activeId) loadMessages(activeId); }, [activeId, loadMessages]);
78
+
79
+ // Auto-scroll
80
+ useEffect(() => {
81
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
82
+ }, [messages, partial]);
83
+
84
+ // SSE subscription per active session.
85
+ useEffect(() => {
86
+ if (!activeId) return;
87
+ srcRef.current?.close();
88
+ const src = new EventSource(`${PROXY}/sessions/${encodeURIComponent(activeId)}/events`);
89
+ srcRef.current = src;
90
+ src.onmessage = (ev) => {
91
+ let p: { type?: string; data?: any };
92
+ try { p = JSON.parse(ev.data); } catch { return; }
93
+ const data = p.data || {};
94
+ if (p.type === 'text_delta') {
95
+ setPartial((s) => s + (data.delta || ''));
96
+ } else if (p.type === 'message_saved') {
97
+ loadMessages(activeId);
98
+ setPartial('');
99
+ } else if (p.type === 'turn_done') {
100
+ setStreaming(false);
101
+ setPartial('');
102
+ loadMessages(activeId);
103
+ refreshSessions();
104
+ } else if (p.type === 'error') {
105
+ setStreaming(false);
106
+ setPartial('');
107
+ setMessages((m) => [...m, { role: 'system', content: `Error: ${data.error || 'unknown'}` }]);
108
+ }
109
+ };
110
+ return () => { src.close(); };
111
+ }, [activeId, loadMessages, refreshSessions]);
112
+
113
+ const send = async () => {
114
+ const text = input.trim();
115
+ if (!text || !activeId || streaming) return;
116
+ setInput('');
117
+ setStreaming(true);
118
+ // Optimistic user bubble; turn_done reloads the real thread from DB.
119
+ setMessages((m) => [...m, { role: 'user', content: text }]);
120
+ try {
121
+ await fetch(`${PROXY}/sessions/${encodeURIComponent(activeId)}/messages`, {
122
+ method: 'POST',
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify({ text }),
125
+ });
126
+ } catch (e) {
127
+ setStreaming(false);
128
+ setMessages((m) => [...m, { role: 'system', content: `Send failed: ${(e as Error).message}` }]);
129
+ }
130
+ };
131
+
132
+ const newSession = async () => {
133
+ try {
134
+ const r = await fetch(`${PROXY}/sessions`, {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json' },
137
+ body: JSON.stringify({ title: 'Mobile chat' }),
138
+ });
139
+ const j = await r.json();
140
+ const id = j?.session?.id || j?.id;
141
+ if (id) { setActiveId(id); setMessages([]); setShowPicker(false); await refreshSessions(); }
142
+ } catch { /* ignore */ }
143
+ };
144
+
145
+ const pickSession = (id: string) => { setActiveId(id); setMessages([]); setShowPicker(false); };
146
+
147
+ const activeTitle = sessions.find((s) => s.id === activeId)?.title || (activeId ? activeId.slice(0, 8) : 'No session');
148
+
149
+ return (
150
+ <div className="flex-1 flex flex-col min-h-0">
151
+ {/* Session bar */}
152
+ <div className="shrink-0 flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d]">
153
+ <button
154
+ onClick={() => { setShowPicker((v) => !v); if (!showPicker) refreshSessions(); }}
155
+ className="flex-1 text-left text-xs text-[#e6edf3] truncate active:opacity-70"
156
+ >💬 {activeTitle} ▾</button>
157
+ <button onClick={newSession} className="text-xs px-2 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]">+ New</button>
158
+ </div>
159
+
160
+ {showPicker && (
161
+ <div className="shrink-0 max-h-[40vh] overflow-y-auto bg-[#161b22] border-b border-[#30363d]">
162
+ {sessions.length === 0 ? (
163
+ <div className="px-3 py-4 text-xs text-[#8b949e] text-center">No sessions</div>
164
+ ) : sessions.map((s) => (
165
+ <button
166
+ key={s.id}
167
+ onClick={() => pickSession(s.id)}
168
+ className={`w-full text-left px-3 py-2 border-b border-[#30363d]/50 text-xs active:bg-[#1c2128] ${s.id === activeId ? 'text-[#7c5bf0]' : 'text-[#e6edf3]'}`}
169
+ >
170
+ <span className="truncate">{s.title || s.id.slice(0, 12)}</span>
171
+ </button>
172
+ ))}
173
+ </div>
174
+ )}
175
+
176
+ {/* Messages */}
177
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 min-h-0 space-y-3">
178
+ {messages.length === 0 && !partial ? (
179
+ <div className="h-full flex items-center justify-center text-sm text-[#8b949e]">Send a message to start.</div>
180
+ ) : messages.map((m, i) => {
181
+ const t = msgText(m);
182
+ if (!t) return null;
183
+ return (
184
+ <div key={m.id || i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
185
+ <div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
186
+ m.role === 'user' ? 'bg-[#7c5bf0] text-white rounded-br-sm'
187
+ : m.role === 'system' ? 'bg-red-900/30 text-red-300 rounded-bl-sm'
188
+ : 'bg-[#1c2128] text-[#e6edf3] rounded-bl-sm'
189
+ }`}>{t}</div>
190
+ </div>
191
+ );
192
+ })}
193
+ {partial && (
194
+ <div className="flex justify-start">
195
+ <div className="max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-sm whitespace-pre-wrap break-words bg-[#1c2128] text-[#e6edf3]">{partial}</div>
196
+ </div>
197
+ )}
198
+ {streaming && !partial && (
199
+ <div className="flex justify-start">
200
+ <div className="bg-[#1c2128] rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-[#8b949e]">Thinking…</div>
201
+ </div>
202
+ )}
203
+ </div>
204
+
205
+ {/* Input */}
206
+ <div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#161b22] border-t border-[#30363d]">
207
+ <input
208
+ type="text"
209
+ value={input}
210
+ onChange={(e) => setInput(e.target.value)}
211
+ onKeyDown={(e) => { if (e.key === 'Enter' && !streaming) send(); }}
212
+ placeholder={activeId ? 'Type a message…' : 'No session'}
213
+ disabled={!activeId}
214
+ className="flex-1 bg-[#0d1117] border border-[#30363d] rounded-lg px-3 py-2 text-sm text-[#e6edf3] focus:outline-none focus:border-[#7c5bf0] disabled:opacity-50 min-w-0"
215
+ autoComplete="off" autoCorrect="off"
216
+ />
217
+ <button
218
+ onClick={send}
219
+ disabled={!activeId || !input.trim() || streaming}
220
+ className="px-4 py-2 bg-[#7c5bf0] text-white rounded-lg text-sm font-medium disabled:opacity-50 shrink-0"
221
+ >Send</button>
222
+ </div>
223
+ </div>
224
+ );
225
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import MobileChat from './MobileChat';
4
5
 
5
6
  interface Project { name: string; path: string }
6
7
  interface SessionInfo { sessionId: string; summary?: string; firstPrompt?: string; modified?: string }
@@ -24,6 +25,9 @@ export default function MobileView() {
24
25
  const scrollRef = useRef<HTMLDivElement>(null);
25
26
  const inputRef = useRef<HTMLInputElement>(null);
26
27
  const abortRef = useRef<AbortController | null>(null);
28
+ // Default to the Forge chat agent (same backend as web /chat + Telegram);
29
+ // Terminal tab keeps the original claude-session browse/continue view.
30
+ const [viewMode, setViewMode] = useState<'chat' | 'terminal'>('chat');
27
31
 
28
32
  // Fetch projects
29
33
  useEffect(() => {
@@ -225,9 +229,24 @@ export default function MobileView() {
225
229
 
226
230
  return (
227
231
  <div className="h-[100dvh] flex flex-col bg-[#0d1117] text-[#e6edf3]">
232
+ {/* Mode toggle — Chat (Forge agent) vs Terminal (claude sessions) */}
233
+ <div className="shrink-0 flex items-center gap-1.5 px-2 py-1.5 bg-[#161b22] border-b border-[#30363d]">
234
+ <span className="text-xs font-bold text-[#7c5bf0] mr-1">Forge</span>
235
+ <button
236
+ onClick={() => setViewMode('chat')}
237
+ className={`text-xs px-2.5 py-1 rounded ${viewMode === 'chat' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
238
+ >💬 Chat</button>
239
+ <button
240
+ onClick={() => setViewMode('terminal')}
241
+ className={`text-xs px-2.5 py-1 rounded ${viewMode === 'terminal' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
242
+ >⌨ Terminal</button>
243
+ <a href="/?force=desktop" className="ml-auto text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
244
+ </div>
245
+
246
+ {viewMode === 'chat' ? <MobileChat /> : (
247
+ <>
228
248
  {/* Header */}
229
249
  <header className="shrink-0 flex items-center gap-1.5 px-2 py-2 bg-[#161b22] border-b border-[#30363d]">
230
- <span className="text-xs font-bold text-[#7c5bf0]">Forge</span>
231
250
  <select
232
251
  value={selectedProject?.path || ''}
233
252
  onChange={e => {
@@ -273,7 +292,6 @@ export default function MobileView() {
273
292
  {tunnelUrl && (
274
293
  <button onClick={closeTunnel} className="text-xs px-1.5 py-1 border border-green-700 rounded text-green-400" title={tunnelUrl}>●</button>
275
294
  )}
276
- <a href="/?force=desktop" className="text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
277
295
  </header>
278
296
 
279
297
  {/* Session list */}
@@ -380,6 +398,8 @@ export default function MobileView() {
380
398
  </div>
381
399
  )}
382
400
  </div>
401
+ </>
402
+ )}
383
403
  </div>
384
404
  );
385
405
  }
@@ -104,6 +104,21 @@ async function ensureSession(telegramChatId: number): Promise<string> {
104
104
  return id;
105
105
  }
106
106
 
107
+ /** List recent chat sessions (for the /chat picker). Best-effort: [] on error. */
108
+ export async function listChatSessions(limit = 20): Promise<Array<{ id: string; title: string; updated_at?: string }>> {
109
+ try {
110
+ const r = await fetch(`${BASE}/api/sessions?limit=${limit}`);
111
+ if (!r.ok) return [];
112
+ const j = await r.json();
113
+ return Array.isArray(j?.sessions) ? j.sessions : [];
114
+ } catch { return []; }
115
+ }
116
+
117
+ /** Create a fresh chat session, returns its id (null on failure). */
118
+ export async function newChatSession(title: string): Promise<string | null> {
119
+ try { return await createSession(title); } catch { return null; }
120
+ }
121
+
107
122
  async function openSse(sessionId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
108
123
  const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
109
124
  headers: { accept: 'text/event-stream' },
@@ -83,3 +83,5 @@ Next to the **Automation** tab in the left-side nav sits a small **Activity** su
83
83
  - **User menu (▾)** — `⚙ Settings` + `💬 Chat (web) ↗` at the top (Chat opens in a new tab so the dashboard isn't replaced); then a divider, then the periodic-check screens `📊 Monitor` (background watches, processes, queues), `🔐 Login Status` (connector creds), `💰 Usage` (token/cost analytics), `📜 Logs`, `📱 Mobile View ↗`; then `⏻ Logout`.
84
84
 
85
85
  Periodic-check screens (Monitor / Login Status / Usage) live inside the user menu so the top bar only shows things worth glancing at.
86
+
87
+ **Mobile View** (`/mobile`, opened from the user menu or auto on phones) defaults to a **💬 Chat** tab — the Forge chat agent, same backend as the web `/chat` and the Telegram chat. Pick or start a session and talk; replies stream live. Toggle to the **⌨ Terminal** tab to browse/continue Claude Code sessions per project (the original mobile view). `PC` switches to the desktop layout.
@@ -24,6 +24,8 @@
24
24
  | `/peek <project>` | Preview running session |
25
25
  | `/cancel <id>` | Cancel a task |
26
26
  | `/retry <id>` | Retry a failed task |
27
+ | `/chat` | Enter chat mode — pick an existing chat session or start a new one, then every message goes to the Forge chat agent (same as the web `/chat`) |
28
+ | `/endchat` | Leave chat mode |
27
29
  | `/tunnel_start <password>` | Start Cloudflare Tunnel (returns URL + code) |
28
30
  | `/tunnel_stop` | Stop tunnel |
29
31
  | `/tunnel_code <password>` | Get session code for remote login |
@@ -33,6 +35,7 @@
33
35
  - Reply to a task message to interact with it
34
36
  - Send `"project: instructions"` to quick-create a task
35
37
  - Numbered lists — reply with a number to select
38
+ - **Chat mode**: `/chat` → reply with a number to pick a session (`0` = new) → talk normally; the agent streams replies just like the web `/chat`. `/endchat` exits. Other slash-commands still work while in chat mode.
36
39
 
37
40
  ## Troubleshooting
38
41
 
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
  }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.77",
3
+ "version": "0.10.78",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {