@aion0/forge 0.10.20 → 0.10.23

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,8 +1,26 @@
1
- # Forge v0.10.20
1
+ # Forge v0.10.23
2
2
 
3
- Released: 2026-05-31
3
+ Released: 2026-06-01
4
4
 
5
- ## Changes since v0.10.19
5
+ ## Changes since v0.10.22
6
6
 
7
+ ### Features
8
+ - feat: long-task watch — lightweight async background tool-call primitive for chat
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.19...v0.10.20
10
+ ### Documentation
11
+ - docs: mark watch backend implemented on feat/watch
12
+
13
+ ### Other
14
+ - fix(chat): cap tool_result at 16k chars — oversized result (e.g. full test tree) no longer blows the history budget → orphan strip → empty messages → provider 'messages must not be empty'; + clear-error guard if history is ever empty
15
+ - feat(dashboard): expandable Alerts/notifications — click a row to see full title+body (was truncated, no way to read in full); auto-marks read on expand
16
+ - docs(help): add 24-watch.md (background watches: async block + start_watch, where to see/cancel) + index/routing
17
+ - feat(watch): start_watch builtin — LLM-driven dynamic watch (poll/done/interval), coexists with declarative async blocks
18
+ - feat(chat): show per-message timestamp in /chat (time-of-day, or date+time if older)
19
+ - docs(watch): document terminal watch_status → immediate chip removal (prune is fallback)
20
+ - fix(watch): emit terminal watch_status on finish so the progress chip clears immediately (was lingering until 150s prune)
21
+ - feat(watch): Background Watches section in MonitorPanel (list + cancel/delete)
22
+ - fix(watch): pre-resolve {args.*}/{result.*}/{settings.*} in watch messages at register time
23
+ - feat(watch): /chat UI — ambient progress chip (watch_status) + Background watches panel (list/cancel/delete); help doc for async block
24
+
25
+
26
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.22...v0.10.23
@@ -65,7 +65,7 @@ function deriveModeLabel(entries: ConnectorEntry[]): 'server-side' | 'browser-si
65
65
  }
66
66
  }
67
67
  if (ps.size === 0) return 'browser-side';
68
- const allServer = [...ps].every((p) => p === 'http' || p === 'shell');
68
+ const allServer = [...ps].every((p) => p === 'http' || p === 'shell' || p === 'ssh');
69
69
  const allBrowser = [...ps].every((p) => p === 'browser');
70
70
  return allServer ? 'server-side' : allBrowser ? 'browser-side' : 'mixed';
71
71
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * POST /api/watches/:id { action: "cancel" } — stop an active watch.
3
+ * DELETE /api/watches/:id — remove the row.
4
+ *
5
+ * Writes go to workflow.db; the runner (chat-standalone) reads state each
6
+ * tick, so a cancel here takes effect within one tick without IPC.
7
+ */
8
+ import { NextResponse } from 'next/server';
9
+ import { cancelWatch, deleteWatch, getWatch } from '@/lib/watch/watch-store';
10
+
11
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
12
+ const { id } = await params;
13
+ const body = await req.json().catch(() => ({}));
14
+ const action = String(body?.action || 'cancel');
15
+ if (action !== 'cancel') return NextResponse.json({ error: `unknown action "${action}"` }, { status: 400 });
16
+ if (!getWatch(id)) return NextResponse.json({ error: 'watch not found' }, { status: 404 });
17
+ const ok = cancelWatch(id, Date.now());
18
+ return NextResponse.json({ ok, watch: getWatch(id) });
19
+ }
20
+
21
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
22
+ const { id } = await params;
23
+ const ok = deleteWatch(id);
24
+ return NextResponse.json({ ok });
25
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * GET /api/watches — list background long-task watches (active first,
3
+ * then recent terminal). Read-only management surface for the watch
4
+ * runner that lives in chat-standalone; both share workflow.db.
5
+ */
6
+ import { NextResponse } from 'next/server';
7
+ import { listWatches } from '@/lib/watch/watch-store';
8
+
9
+ export async function GET(req: Request) {
10
+ const url = new URL(req.url);
11
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));
12
+ try {
13
+ return NextResponse.json({ watches: listWatches(limit) });
14
+ } catch (e) {
15
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
16
+ }
17
+ }
package/app/chat/page.tsx CHANGED
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
23
  import MarkdownContent from '@/components/MarkdownContent';
24
+ import WatchesPanel from '@/components/WatchesPanel';
24
25
  import type { ContentBlock, Message, Session } from '@/lib/chat/types';
25
26
 
26
27
  const PROXY = '/api/chat-proxy';
@@ -46,6 +47,9 @@ export default function ChatPage() {
46
47
  const [sessions, setSessions] = useState<ChatSession[]>([]);
47
48
  const [activeId, setActiveId] = useState<string>('');
48
49
  const [messages, setMessages] = useState<Message[]>([]);
50
+ // Background-watch progress chips (watch_id → {text, ts}). Ambient,
51
+ // updated in place; pruned when stale. Not part of the message thread.
52
+ const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
49
53
  const [input, setInput] = useState('');
50
54
  const [streaming, setStreaming] = useState(false);
51
55
  const [partial, setPartial] = useState('');
@@ -129,6 +133,18 @@ export default function ChatPage() {
129
133
  setPartial('');
130
134
  loadMessages(activeId);
131
135
  refreshSessions();
136
+ } else if (type === 'watch_status') {
137
+ // Ambient background-watch progress — a chip that updates in
138
+ // place, NOT a chat message (doesn't touch the thread / LLM).
139
+ // A terminal status (done/non-active) drops the chip at once.
140
+ const wid = String(data.watch_id || '');
141
+ if (wid) {
142
+ const terminal = !!data.done || (data.state && data.state !== 'active');
143
+ setWatchChips((c) => {
144
+ if (terminal) { const n = { ...c }; delete n[wid]; return n; }
145
+ return { ...c, [wid]: { text: String(data.text || ''), ts: Date.now() } };
146
+ });
147
+ }
132
148
  } else if (type === 'error') {
133
149
  setStreaming(false);
134
150
  setError(String(data.error || 'unknown error'));
@@ -145,6 +161,22 @@ export default function ChatPage() {
145
161
  if (el) el.scrollTop = el.scrollHeight;
146
162
  }, [messages, partial]);
147
163
 
164
+ // ─── Prune stale watch chips (no update in >150s = done/gone) ──
165
+ useEffect(() => {
166
+ const t = setInterval(() => {
167
+ setWatchChips((c) => {
168
+ const now = Date.now();
169
+ const next: typeof c = {};
170
+ let changed = false;
171
+ for (const [k, v] of Object.entries(c)) {
172
+ if (now - v.ts < 150_000) next[k] = v; else changed = true;
173
+ }
174
+ return changed ? next : c;
175
+ });
176
+ }, 30_000);
177
+ return () => clearInterval(t);
178
+ }, []);
179
+
148
180
  // ─── Auto-resize composer ─────────────────────────────────
149
181
  useEffect(() => {
150
182
  const el = composerRef.current;
@@ -282,6 +314,8 @@ export default function ChatPage() {
282
314
  })}
283
315
  </div>
284
316
 
317
+ <WatchesPanel />
318
+
285
319
  <div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
286
320
  <button
287
321
  type="button"
@@ -369,6 +403,23 @@ export default function ChatPage() {
369
403
  </div>
370
404
  </div>
371
405
 
406
+ {Object.keys(watchChips).length > 0 && (
407
+ <div className="px-6 pt-2">
408
+ <div className="max-w-3xl mx-auto flex flex-wrap gap-2">
409
+ {Object.entries(watchChips).map(([id, w]) => (
410
+ <span
411
+ key={id}
412
+ title={id}
413
+ 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)]"
414
+ >
415
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
416
+ {w.text}
417
+ </span>
418
+ ))}
419
+ </div>
420
+ </div>
421
+ )}
422
+
372
423
  <form
373
424
  className="border-t border-[var(--border)] px-6 py-4"
374
425
  onSubmit={(e) => { e.preventDefault(); send(); }}
@@ -411,7 +462,17 @@ export default function ChatPage() {
411
462
 
412
463
  // ─── Message renderers ────────────────────────────────────
413
464
 
414
- function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
465
+ function fmtTs(ts?: number): string {
466
+ if (!ts) return '';
467
+ const d = new Date(ts);
468
+ if (Number.isNaN(d.getTime())) return '';
469
+ const now = new Date();
470
+ const sameDay = d.toDateString() === now.toDateString();
471
+ const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
472
+ return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
473
+ }
474
+
475
+ function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: number; children: React.ReactNode }) {
415
476
  const isUser = role === 'user';
416
477
  return (
417
478
  <div className="flex gap-4">
@@ -426,8 +487,9 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
426
487
  {isUser ? 'U' : 'AI'}
427
488
  </div>
428
489
  <div className="flex-1 min-w-0">
429
- <div className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
430
- {role}
490
+ <div className="flex items-baseline gap-2 mb-1">
491
+ <span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">{role}</span>
492
+ {ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
431
493
  </div>
432
494
  <div className="space-y-2">{children}</div>
433
495
  </div>
@@ -437,7 +499,7 @@ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: R
437
499
 
438
500
  function MessageView({ m }: { m: Message }) {
439
501
  return (
440
- <RoleBlock role={m.role}>
502
+ <RoleBlock role={m.role} ts={m.ts}>
441
503
  {m.blocks.map((b, i) => (
442
504
  <BlockView key={i} b={b} />
443
505
  ))}
@@ -141,6 +141,7 @@ export default function Dashboard({ user }: { user: any }) {
141
141
  const [notifications, setNotifications] = useState<any[]>([]);
142
142
  const [unreadCount, setUnreadCount] = useState(0);
143
143
  const [showNotifications, setShowNotifications] = useState(false);
144
+ const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
144
145
  const [showUserMenu, setShowUserMenu] = useState(false);
145
146
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
146
147
  const [displayName, setDisplayName] = useState(user?.name || 'Forge');
@@ -511,10 +512,23 @@ export default function Dashboard({ user }: { user: any }) {
511
512
  {notifications.length === 0 ? (
512
513
  <div className="p-6 text-center text-xs text-[var(--text-secondary)]">No notifications</div>
513
514
  ) : (
514
- notifications.map((n: any) => (
515
+ notifications.map((n: any) => {
516
+ const expanded = expandedNotif === n.id;
517
+ return (
515
518
  <div
516
519
  key={n.id}
517
- className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
520
+ onClick={async () => {
521
+ setExpandedNotif(expanded ? null : n.id);
522
+ if (!expanded && !n.read) {
523
+ await fetch('/api/notifications', {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/json' },
526
+ body: JSON.stringify({ action: 'markRead', id: n.id }),
527
+ });
528
+ fetchNotifications();
529
+ }
530
+ }}
531
+ className={`group px-3 py-2 border-b border-[var(--border)]/50 hover:bg-[var(--bg-tertiary)] cursor-pointer ${!n.read ? 'bg-[var(--accent)]/5' : ''}`}
518
532
  >
519
533
  <div className="flex items-start gap-2">
520
534
  <span className="text-[10px] mt-0.5 shrink-0">
@@ -522,16 +536,17 @@ export default function Dashboard({ user }: { user: any }) {
522
536
  </span>
523
537
  <div className="flex-1 min-w-0">
524
538
  <div className="flex items-center gap-1">
525
- <span className={`text-[11px] truncate ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
539
+ <span className={`text-[11px] ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'} ${!n.read ? 'font-semibold text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
526
540
  {n.title}
527
541
  </span>
528
542
  {!n.read && <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />}
529
543
  </div>
530
544
  {n.body && (
531
- <p className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5">{n.body}</p>
545
+ <p className={`text-[9px] text-[var(--text-secondary)] mt-0.5 ${expanded ? 'break-words whitespace-pre-wrap' : 'truncate'}`}>{n.body}</p>
532
546
  )}
533
547
  <span className="text-[8px] text-[var(--text-secondary)]">
534
548
  {new Date(n.createdAt).toLocaleString()}
549
+ <span className="ml-1 opacity-50">· {expanded ? 'click to collapse' : 'click for detail'}</span>
535
550
  </span>
536
551
  </div>
537
552
  <div className="hidden group-hover:flex items-center gap-1 shrink-0">
@@ -568,7 +583,8 @@ export default function Dashboard({ user }: { user: any }) {
568
583
  </div>
569
584
  </div>
570
585
  </div>
571
- ))
586
+ );
587
+ })
572
588
  )}
573
589
  </div>
574
590
  </div>
@@ -157,6 +157,9 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
157
157
  </div>
158
158
  )}
159
159
  </div>
160
+
161
+ {/* Background watches — long-task pollers (nac upgrade etc.) */}
162
+ <MonitorWatches />
160
163
  </div>
161
164
  ) : (
162
165
  <div className="p-8 text-center text-xs text-[var(--text-secondary)]">Loading...</div>
@@ -165,3 +168,88 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
165
168
  </div>
166
169
  );
167
170
  }
171
+
172
+ // ─── Background watches ────────────────────────────────────
173
+ // Long-task pollers registered by `async` connector tools (e.g. nac
174
+ // upgrade → poll get_version until the build matches). Same data as the
175
+ // /chat panel; lives here so it's visible alongside the other running
176
+ // background resources. Reads /api/watches; cancel/delete per [id].
177
+
178
+ interface WatchRow {
179
+ id: string;
180
+ label: string;
181
+ state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
182
+ polls: number;
183
+ max_polls: number;
184
+ last_text: string | null;
185
+ }
186
+
187
+ const WATCH_STATE_COLOR: Record<string, string> = {
188
+ active: 'text-amber-400',
189
+ done: 'text-green-400',
190
+ failed: 'text-red-400',
191
+ timed_out: 'text-red-400',
192
+ errored: 'text-red-400',
193
+ cancelled: 'text-[var(--text-secondary)]',
194
+ };
195
+
196
+ function MonitorWatches() {
197
+ const [watches, setWatches] = useState<WatchRow[]>([]);
198
+ const load = useCallback(() => {
199
+ fetch('/api/watches?limit=30')
200
+ .then(r => r.json())
201
+ .then(j => setWatches(Array.isArray(j.watches) ? j.watches : []))
202
+ .catch(() => {});
203
+ }, []);
204
+ useEffect(() => {
205
+ load();
206
+ const t = setInterval(load, 10_000);
207
+ return () => clearInterval(t);
208
+ }, [load]);
209
+
210
+ const act = async (id: string, method: 'POST' | 'DELETE') => {
211
+ try {
212
+ await fetch(`/api/watches/${id}`, {
213
+ method,
214
+ ...(method === 'POST'
215
+ ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) }
216
+ : {}),
217
+ });
218
+ } catch { /* ignore */ }
219
+ load();
220
+ };
221
+
222
+ const activeCount = watches.filter(w => w.state === 'active').length;
223
+
224
+ return (
225
+ <div>
226
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
227
+ Background Watches ({activeCount} active)
228
+ </h3>
229
+ {watches.length === 0 ? (
230
+ <div className="text-[10px] text-[var(--text-secondary)]">No watches</div>
231
+ ) : (
232
+ <div className="space-y-1.5">
233
+ {watches.map(w => (
234
+ <div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
235
+ <div className="flex items-center gap-2 text-[11px]">
236
+ <span className={`text-[9px] uppercase tracking-wide ${WATCH_STATE_COLOR[w.state] || ''}`}>{w.state}</span>
237
+ <span className="font-mono text-[var(--text-primary)] truncate flex-1" title={w.label}>{w.label}</span>
238
+ <span className="text-[9px] text-[var(--text-secondary)]">{w.polls}/{w.max_polls}</span>
239
+ </div>
240
+ {w.last_text && (
241
+ <div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={w.last_text}>{w.last_text}</div>
242
+ )}
243
+ <div className="flex gap-2 mt-1">
244
+ {w.state === 'active' && (
245
+ <button type="button" onClick={() => act(w.id, 'POST')} className="text-[9px] text-amber-400 hover:underline">cancel</button>
246
+ )}
247
+ <button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[9px] text-[var(--text-secondary)] hover:underline">delete</button>
248
+ </div>
249
+ </div>
250
+ ))}
251
+ </div>
252
+ )}
253
+ </div>
254
+ );
255
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Background-watch management — a compact list of long-task watches with
5
+ * cancel/delete. Reads GET /api/watches; cancel/delete via the [id] route.
6
+ * Lives in the /chat sidebar so users can see and rein in background
7
+ * pollers (avoid runaway / resource hogging). Pure backend otherwise —
8
+ * the watch runner lives in chat-standalone.
9
+ */
10
+
11
+ import { useCallback, useEffect, useState } from 'react';
12
+
13
+ interface Watch {
14
+ id: string;
15
+ label: string;
16
+ state: 'active' | 'done' | 'failed' | 'timed_out' | 'cancelled' | 'errored';
17
+ polls: number;
18
+ max_polls: number;
19
+ last_text: string | null;
20
+ updated_at: number;
21
+ }
22
+
23
+ const STATE_COLOR: Record<string, string> = {
24
+ active: 'text-amber-400',
25
+ done: 'text-green-400',
26
+ failed: 'text-red-400',
27
+ timed_out: 'text-red-400',
28
+ errored: 'text-red-400',
29
+ cancelled: 'text-[var(--text-secondary)]',
30
+ };
31
+
32
+ export default function WatchesPanel() {
33
+ const [watches, setWatches] = useState<Watch[]>([]);
34
+ const [open, setOpen] = useState(false);
35
+
36
+ const load = useCallback(() => {
37
+ fetch('/api/watches?limit=30')
38
+ .then((r) => r.json())
39
+ .then((j) => setWatches(Array.isArray(j.watches) ? j.watches : []))
40
+ .catch(() => {});
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ load();
45
+ const t = setInterval(load, 10_000);
46
+ return () => clearInterval(t);
47
+ }, [load]);
48
+
49
+ const act = async (id: string, method: 'POST' | 'DELETE') => {
50
+ try {
51
+ await fetch(`/api/watches/${id}`, {
52
+ method,
53
+ ...(method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'cancel' }) } : {}),
54
+ });
55
+ } catch { /* ignore */ }
56
+ load();
57
+ };
58
+
59
+ const activeCount = watches.filter((w) => w.state === 'active').length;
60
+ if (watches.length === 0) return null;
61
+
62
+ return (
63
+ <div className="px-4 py-2 border-t border-[var(--border)] text-xs text-[var(--text-secondary)]">
64
+ <button
65
+ type="button"
66
+ onClick={() => setOpen((o) => !o)}
67
+ className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
68
+ >
69
+ <span>Background watches</span>
70
+ {activeCount > 0 && (
71
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
72
+ )}
73
+ <span className="ml-auto text-[10px] opacity-60">{activeCount} active · {open ? '▾' : '▸'}</span>
74
+ </button>
75
+ {open && (
76
+ <div className="mt-1.5 space-y-1.5 max-h-56 overflow-y-auto">
77
+ {watches.map((w) => (
78
+ <div key={w.id} className="rounded border border-[var(--border)] px-2 py-1.5">
79
+ <div className="flex items-center gap-2">
80
+ <span className={`text-[10px] uppercase tracking-wide ${STATE_COLOR[w.state] || ''}`}>{w.state}</span>
81
+ <span className="truncate font-medium text-[var(--text-primary)]" title={w.label}>{w.label}</span>
82
+ <span className="ml-auto text-[10px] opacity-60">{w.polls}/{w.max_polls}</span>
83
+ </div>
84
+ {w.last_text && <div className="text-[10px] opacity-70 truncate mt-0.5" title={w.last_text}>{w.last_text}</div>}
85
+ <div className="flex gap-2 mt-1">
86
+ {w.state === 'active' && (
87
+ <button type="button" onClick={() => act(w.id, 'POST')} className="text-[10px] text-amber-400 hover:underline">cancel</button>
88
+ )}
89
+ <button type="button" onClick={() => act(w.id, 'DELETE')} className="text-[10px] opacity-60 hover:opacity-100 hover:underline">delete</button>
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )}
95
+ </div>
96
+ );
97
+ }