@aion0/forge 0.10.22 → 0.10.25

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,11 @@
1
- # Forge v0.10.22
1
+ # Forge v0.10.25
2
2
 
3
- Released: 2026-05-31
3
+ Released: 2026-06-01
4
4
 
5
- ## Changes since v0.10.20
5
+ ## Changes since v0.10.24
6
6
 
7
+ ### Other
8
+ - refactor(settings): drop unused taskModel / pipelineModel / telegramModel
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.20...v0.10.22
10
+
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.24...v0.10.25
@@ -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
+ }
@@ -200,9 +200,6 @@ interface Settings {
200
200
  notifyOnFailure: boolean;
201
201
  tunnelAutoStart: boolean;
202
202
  telegramTunnelPassword: string;
203
- taskModel: string;
204
- pipelineModel: string;
205
- telegramModel: string;
206
203
  skipPermissions: boolean;
207
204
  notificationRetentionDays: number;
208
205
  maxConcurrentPipelines: number;
@@ -228,9 +225,6 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
228
225
  notifyOnFailure: true,
229
226
  tunnelAutoStart: false,
230
227
  telegramTunnelPassword: '',
231
- taskModel: 'sonnet',
232
- pipelineModel: 'sonnet',
233
- telegramModel: 'sonnet',
234
228
  skipPermissions: false,
235
229
  notificationRetentionDays: 30,
236
230
  maxConcurrentPipelines: 5,
@@ -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
+ }
@@ -1,6 +1,12 @@
1
1
  # Forge — Long-Task Watch(轻量独立后台轮询 + 回调编排)设计方案
2
2
 
3
- > 状态:**待审,未实现**。给 zliu 决策用。
3
+ > 状态:**后端已实现(分支 `feat/watch`,commit ad374ec,未 merge)**。
4
+ > 已建:types(AsyncWatchSpec)、lib/watch/{watch-store,watch-runner,register,template}、
5
+ > tool-dispatcher 注册钩子、chat-standalone 启动 runner(进度→watch_status 事件 /
6
+ > 完成→runTurn 回灌)、app/api/watches(list/cancel/delete)。tsc 通过。
7
+ > **待做**:① UI——extension + /chat 订阅 `watch_status` 渲染状态条 chip、Settings→Monitor
8
+ > 的 watch 列表+cancel/delete;② 给 nac.upgrade 等连接器加 `async:` 块(消费者);
9
+ > ③ 实测 + merge。
4
10
  > 一句话定性(zliu):watch = **在 chat 里支持的一个轻量化异步回调机制**。
5
11
  > 消费者:TP 升级、**NAC 直连升级(nac.upgrade → 轮询 nac.get_version 直到
6
12
  > build 匹配)**、pytest 跑——凡是"发起后要等、完成再回 chat"的都用它。
@@ -102,6 +108,13 @@ upgrade_lab:
102
108
  线程。`progress.show:false` 则全程静默,只留终态。
103
109
  - 实现:进度走 `bridgePush('watch_status', …)`(extension/web 各加一个轻量
104
110
  status-bar 组件订阅);结果走已有的 session resume 回灌(§1)。
111
+ - **终态清 chip(必须显式发,别靠超时)**:watch 到终态时,`on_done`/`on_fail`
112
+ 只走结果通道(发完成消息),**不会再发常规进度**——若不补一条信号,chip 会
113
+ 一直挂到 prune 超时(150s)才消失,出现"结果已给、进度条还在转"的割裂。
114
+ 所以 `finish()` 额外补发一条**终态 `watch_status`**:`{watch_id, state, done:true, text}`。
115
+ 前端收到 `done:true`(或 `state≠active`)就**立刻删除该 chip**;否则才更新。
116
+ 即:进度通道既负责"更新",也负责"收尾删除"。(prune 150s 仅作兜底,正常
117
+ 路径靠这条终态信号即时清除。)
105
118
 
106
119
  - **完成回灌 chat**:默认 `mode=chat`——把最后一次 poll 结果 + `message` 作为
107
120
  tool-result 喂回发起的 session,助手据此吐一条消息(还能继续调用下一步)。
@@ -165,7 +178,7 @@ upgrade: # nac connector
165
178
  |---|---|---|
166
179
  | 类型 | `lib/connectors/types.ts`:`ConnectorTool.async?: AsyncWatchSpec`(poll/poll_args/done_path/done_match/fail_path/interval/timeout/max_polls/on_done/on_fail/**progress**) | 小 |
167
180
  | 存储 | `lib/watch/watch-store.ts`(新):SQLite 表 + CRUD + 单实例守卫 | ~100 行 |
168
- | 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick `bridgePush('watch_status',…)`** | ~170 行 |
181
+ | 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick + 终态各发一条 `watch_status`**(终态带 `done:true` 让前端立即清 chip) | ~170 行 |
169
182
  | 派发 | `lib/chat/tool-dispatcher.ts`:跑完带 `async` 的工具 → `registerWatch`,返回附 `watch_id` | 小 |
170
183
  | API | `app/api/watches/route.ts` + `[id]`:list / cancel / delete | 小 |
171
184
  | UI(状态条) | extension + /chat 各加一个轻量组件,订阅 `watch_status` push → 渲染就地更新的 chip(不进消息流) | 小 |