@aion0/forge 0.10.18 → 0.10.22

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,8 @@
1
- # Forge v0.10.18
1
+ # Forge v0.10.22
2
2
 
3
- Released: 2026-05-30
3
+ Released: 2026-05-31
4
4
 
5
- ## Changes since v0.10.17
5
+ ## Changes since v0.10.20
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.17...v0.10.18
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.20...v0.10.22
@@ -6,9 +6,11 @@ export async function GET(req: Request) {
6
6
  const url = new URL(req.url);
7
7
  const resolve = url.searchParams.get('resolve');
8
8
 
9
- // GET /api/agents?resolve=claude → resolve terminal launch info for an agent
9
+ // GET /api/agents?resolve=claude[&scene=help] → resolve launch info for an
10
+ // agent in a given scene (terminal default; help/task/etc. pick models[scene]).
10
11
  if (resolve) {
11
- const info = resolveTerminalLaunch(resolve);
12
+ const scene = url.searchParams.get('scene') as 'terminal' | 'task' | 'telegram' | 'help' | 'mobile' | null;
13
+ const info = resolveTerminalLaunch(resolve, scene || undefined);
12
14
  return NextResponse.json(info);
13
15
  }
14
16
 
@@ -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
  }
@@ -27,7 +27,10 @@ export default function HelpTerminal() {
27
27
 
28
28
  let disposed = false;
29
29
  let dataDir = '~/.forge/data';
30
- let agentCmd = 'claude';
30
+ // Full launch command for the Help AI: resolved from the DEFAULT agent's
31
+ // `help` scene (binary path + --model models.help + env exports). Falls back
32
+ // to bare `claude` if resolution fails.
33
+ let launchCmd = 'claude';
31
34
 
32
35
  const cs = getComputedStyle(document.documentElement);
33
36
  const tv = (name: string) => cs.getPropertyValue(name).trim();
@@ -76,7 +79,7 @@ export default function HelpTerminal() {
76
79
  isNewSession = false;
77
80
  setTimeout(() => {
78
81
  if (socket.readyState === WebSocket.OPEN) {
79
- socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${agentCmd}\n` }));
82
+ socket.send(JSON.stringify({ type: 'input', data: `cd "${dataDir}" 2>/dev/null && ${launchCmd}\n` }));
80
83
  }
81
84
  }, 300);
82
85
  }
@@ -97,13 +100,27 @@ export default function HelpTerminal() {
97
100
  socket.onerror = () => {};
98
101
  }
99
102
 
100
- // Fetch data dir + default agent then connect
103
+ // Fetch data dir + default agent, then resolve that agent's `help`-scene
104
+ // launch info (path + model + env) so Help runs the configured binary/model.
101
105
  Promise.all([
102
106
  fetch('/api/help?action=status').then(r => r.json()).then(data => { if (data.dataDir) dataDir = data.dataDir; }).catch(() => {}),
103
- fetch('/api/agents').then(r => r.json()).then(data => {
107
+ fetch('/api/agents').then(r => r.json()).then(async (data) => {
104
108
  const defaultId = data.defaultAgent || 'claude';
105
- const agent = (data.agents || []).find((a: any) => a.id === defaultId);
106
- if (agent?.path) agentCmd = agent.path;
109
+ try {
110
+ const info = await fetch(`/api/agents?resolve=${encodeURIComponent(defaultId)}&scene=help`).then(r => r.json());
111
+ const bin = info?.cliCmd || 'claude';
112
+ const modelFlag = info?.model ? ` --model ${info.model}` : '';
113
+ const envPrefix = info?.env
114
+ ? Object.entries(info.env as Record<string, string>)
115
+ .map(([k, v]) => `export ${k}=${JSON.stringify(v)}`)
116
+ .join(' && ') + ' && '
117
+ : '';
118
+ launchCmd = `${envPrefix}${bin}${modelFlag}`;
119
+ } catch {
120
+ // Fallback to bare path from the agent list if resolve fails.
121
+ const agent = (data.agents || []).find((a: any) => a.id === defaultId);
122
+ if (agent?.path) launchCmd = agent.path;
123
+ }
107
124
  }).catch(() => {}),
108
125
  ]).finally(() => { if (!disposed) connect(); });
109
126
 
@@ -901,7 +901,6 @@ interface AgentEntry {
901
901
  enabled: boolean;
902
902
  type: string;
903
903
  taskFlags: string;
904
- interactiveCmd: string;
905
904
  resumeFlag: string;
906
905
  outputFormat: string;
907
906
  models: { terminal: string; task: string; telegram: string; help: string; mobile: string };
@@ -1334,7 +1333,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1334
1333
  'generic': { taskFlags: '', resumeFlag: '', outputFormat: 'text', skipPermissionsFlag: '' },
1335
1334
  };
1336
1335
  const makeNewAgent = (cliType = 'claude-code') => ({
1337
- id: '', name: '', path: '', interactiveCmd: '',
1336
+ id: '', name: '', path: '',
1338
1337
  models: { terminal: 'default', task: 'default', telegram: 'default', help: 'default', mobile: 'default' },
1339
1338
  requiresTTY: false, cliType,
1340
1339
  ...cliDefaults[cliType],
@@ -1369,7 +1368,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1369
1368
  enabled: cfg.enabled !== false,
1370
1369
  type: a.type || 'generic',
1371
1370
  taskFlags: cfg.taskFlags ?? (a.id === 'claude' ? '-p --verbose --output-format stream-json --dangerously-skip-permissions' : cfg.flags?.join(' ') ?? ''),
1372
- interactiveCmd: cfg.interactiveCmd ?? a.path,
1373
1371
  resumeFlag: cfg.resumeFlag ?? (a.capabilities?.supportsResume ? '-c' : ''),
1374
1372
  outputFormat: cfg.outputFormat ?? (a.capabilities?.supportsStreamJson ? 'stream-json' : 'text'),
1375
1373
  models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
@@ -1395,7 +1393,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1395
1393
  enabled: cfg.enabled !== false,
1396
1394
  type: 'generic',
1397
1395
  taskFlags: cfg.taskFlags ?? cfg.flags?.join(' ') ?? '',
1398
- interactiveCmd: cfg.interactiveCmd ?? cfg.path ?? '',
1399
1396
  resumeFlag: cfg.resumeFlag ?? '',
1400
1397
  outputFormat: cfg.outputFormat ?? 'text',
1401
1398
  models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
@@ -1434,7 +1431,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1434
1431
  path: a.path,
1435
1432
  enabled: a.enabled,
1436
1433
  taskFlags: a.taskFlags,
1437
- interactiveCmd: a.interactiveCmd,
1438
1434
  resumeFlag: a.resumeFlag,
1439
1435
  outputFormat: a.outputFormat,
1440
1436
  models: a.models,
@@ -1527,7 +1523,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1527
1523
  onClick={() => {
1528
1524
  // Auto-fill path from detected claude agent when opening Add form
1529
1525
  const claude = agents.find(a => a.id === 'claude');
1530
- if (claude?.path && !newAgent.path) setNewAgent((prev: any) => ({ ...prev, path: claude.path, interactiveCmd: claude.path }));
1526
+ if (claude?.path && !newAgent.path) setNewAgent((prev: any) => ({ ...prev, path: claude.path }));
1531
1527
  setShowAdd(v => !v);
1532
1528
  }}
1533
1529
  className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
@@ -1614,10 +1610,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1614
1610
  <label className="text-[9px] text-[var(--text-secondary)]">Task Flags <span className="text-[8px]">(non-interactive mode, e.g. -p --output-format json)</span></label>
1615
1611
  <input value={a.taskFlags} onChange={e => updateAgent(a.id, 'taskFlags', e.target.value)} placeholder="-p --verbose" className={inputClass} />
1616
1612
  </div>
1617
- <div>
1618
- <label className="text-[9px] text-[var(--text-secondary)]">Interactive Command <span className="text-[8px]">(terminal startup)</span></label>
1619
- <input value={a.interactiveCmd} onChange={e => updateAgent(a.id, 'interactiveCmd', e.target.value)} placeholder="claude" className={inputClass} />
1620
- </div>
1621
1613
  <div className="flex gap-3">
1622
1614
  <div className="flex-1">
1623
1615
  <label className="text-[9px] text-[var(--text-secondary)]">Resume Flag <span className="text-[8px]">(empty = no resume)</span></label>
@@ -1770,7 +1762,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1770
1762
  // Auto-fill path from detected agent if available
1771
1763
  const baseId = ct === 'claude-code' ? 'claude' : ct;
1772
1764
  const detected = agents.find(a => a.id === baseId);
1773
- setNewAgent({ ...newAgent, cliType: ct, ...(cliDefaults[ct] || {}), path: detected?.path || newAgent.path, interactiveCmd: detected?.path || newAgent.interactiveCmd });
1765
+ setNewAgent({ ...newAgent, cliType: ct, ...(cliDefaults[ct] || {}), path: detected?.path || newAgent.path });
1774
1766
  }} className={inputClass}>
1775
1767
  <option value="claude-code">Claude Code</option>
1776
1768
  <option value="codex">Codex</option>
@@ -0,0 +1,210 @@
1
+ # Forge — Long-Task Watch(轻量独立后台轮询 + 回调编排)设计方案
2
+
3
+ > 状态:**待审,未实现**。给 zliu 决策用。
4
+ > 一句话定性(zliu):watch = **在 chat 里支持的一个轻量化异步回调机制**。
5
+ > 消费者:TP 升级、**NAC 直连升级(nac.upgrade → 轮询 nac.get_version 直到
6
+ > build 匹配)**、pytest 跑——凡是"发起后要等、完成再回 chat"的都用它。
7
+ > 起因:TP `upgrade_lab`(NAC 升级)同步阻塞 5-10 分钟。连接器已用
8
+ > `detach` 让它不卡死,但"发起后盯到完成"这步若靠 AI 在对话里轮询,
9
+ > **不可靠**。需要一个**轻量、独立、纯后台**的机制:发起后自动定期 poll,
10
+ > 完成时**回调一个工具**(而非通知用户),全程不依赖 AI / 不依赖对话存活。
11
+
12
+ ## 0. 目标 & 非目标(按 zliu 最新意见收敛)
13
+
14
+ **目标**
15
+ - 发起即返回,不 hold 对话/标签页。
16
+ - **轻量、独立**:自带一个小 ticker + 一张表,**不挂靠 Schedules**(那是用户
17
+ 预定任务,语义不同),也不新增 standalone 进程。
18
+ - 完成/失败 → **回灌到发起的那个 chat 会话**:把 poll 结果作为一条
19
+ tool-result/系统事件喂回该 session,**让助手吐一条消息**(并可顺势继续判断/
20
+ 调用下一步工具)。这天然复用 chat:若该会话来自 telegram,消息就自然回到
21
+ telegram;来自 /chat 就在 /chat 冒出来——**不需要单独的 telegram/email 通道**。
22
+ - (可选)纯编排:也支持声明式 `on_done` 直接调一个工具做链式(升级完→自动
23
+ run_pytest),不吐消息。两种模式按 manifest 选。
24
+ - **每次 poll 可给轻量进度反馈,但不抢主对话**(zliu):进度走**独立的
25
+ 状态通道**(`watch_status` push),在 chat 里渲染成一个**会就地更新的小状态条/
26
+ chip**——每 tick 替换、不追加,**不进消息历史、不触发 LLM**,完成即消失。
27
+ 只有最终 `on_done`/`on_fail` 的终态消息才真正落进对话线程。即:
28
+ **过程 = 环境状态(ambient),结果 = 一条真消息**。
29
+ - **防死循环是一等需求**:后台自动调度最怕失控,必须有硬上限。
30
+ - **极简管理面**:不做常规 UI,但要**一个能看 active watch 列表 + 取消/删除**
31
+ 的地方,避免 watch 堆积、占资源、跑飞。
32
+
33
+ **非目标**
34
+ - 不做"独立于 chat 之外"的新通知通道——完成消息走发起会话本身的 chat 流。
35
+ - 不做复杂表达式引擎(done 判定走安全的点路径)。
36
+ - 不进 Schedules UI、不算一种 Schedule 类型。
37
+
38
+ ## 1. 形态:独立 `watch` 模块
39
+
40
+ ```
41
+ AI(在 session S)─call─> upgrade_lab ─(detach 25s, 返回 fired_at)─┐
42
+ ├─ dispatcher 见 async: registerWatch(..., session_id=S)
43
+ <── 立即返回 {dispatched, watch_id} ──────────────────┘
44
+ …………(对话可结束)…………
45
+ watch ticker(独立, ~30-60s)
46
+ └ 对每条 active watch,到点:
47
+ poll_tool ── done_path? ──┬─ 是 → 完成动作 → 终态 done
48
+ ├─ fail_path? → 失败动作 → 终态 failed
49
+ └─ 否 → polls++;超 max_polls / 过 timeout → 终态 timed_out(走失败动作)
50
+
51
+ 完成/失败动作(二选一,按 manifest):
52
+ • mode=chat(默认):把结果作为 tool-result 回灌 session S → resume 该会话 →
53
+ 助手吐消息(走 chat-standalone 现有的 resume + bridgePush 流;telegram 会话则到 telegram)。
54
+ • mode=tool:派发声明的 on_done.tool(链式编排,不吐消息)。
55
+ ```
56
+
57
+ - ticker 是一个 `setInterval`(随 **chat-standalone** 起,单实例守卫),**不是新
58
+ 进程**,也**不复用 Schedules tick**——逻辑独立、好推理、好限流。放 chat-standalone
59
+ 里还顺手:回灌 session 用的就是它自己的 resume 能力。
60
+ - registerWatch 记下 `session_id`(发起工具的那个 chat 会话),完成时回灌。
61
+ - watch 是**短生命周期**:到达终态(done/failed/timed_out/cancelled)即停轮询。
62
+
63
+ ## 2. Manifest 声明(连接器侧,新增 `async` 块)
64
+
65
+ ```yaml
66
+ upgrade_lab:
67
+ destructive: true
68
+ async:
69
+ poll: check_lab_upgrade_status # 同连接器内的查询工具
70
+ poll_args: # 用触发工具的 result/args 拼 poll 入参
71
+ deployinfo: "{args.deployinfo}"
72
+ lab: "{args.lab}"
73
+ since: "{result.fired_at}" # {result.*}=触发工具返回值;{args.*}=触发入参
74
+ # 完成判定:二选一。
75
+ done_path: done # (A) poll 结果该路径 truthy = 完成(TP:连接器自己比好返回 done:true)
76
+ # done_match: # (B) 值比较(NAC:get_version 返回 build 号,跟目标比)
77
+ # path: captured.build # poll 结果里的路径
78
+ # equals: "{args.target_build}" # equals / contains,期望值从触发 args/result 模板取
79
+ fail_path: any_failure # 可选:truthy = 失败
80
+ interval_sec: 60 # 轮询间隔(下限 30s)
81
+ timeout_sec: 1200 # 总时长上限(到点判 timed_out)
82
+ max_polls: 40 # 轮询次数硬上限(防失控)
83
+ on_done: # 完成动作
84
+ mode: chat # chat(默认,回灌发起会话让助手吐消息) | tool(链式) | none(仅落库)
85
+ message: "升级完成,核对结果。" # mode=chat 时注入会话的提示(可引用 {poll.*})
86
+ tool: "" # mode=tool 时要调的工具名
87
+ args: {} # mode=tool 入参,可引用 {poll.*}/{args.*}
88
+ on_fail: # 可选,同形(默认 mode=chat,把失败/超时也吐回会话)
89
+ mode: chat
90
+ message: "升级未在预期内完成,请手动核对。"
91
+ progress: # 每次 poll 的轻量反馈(不抢主对话)
92
+ show: true # 默认 true;false = 后台静默,只在终态出消息
93
+ message: "盯升级中… 第 {poll_count}/{max_polls} 次,build={poll.build}" # 可引用 {poll.*}/{poll_count}
94
+ ```
95
+
96
+ **两条输出通道(关键:进度不抢主对话)**
97
+ - **进度通道**:每 tick 通过独立 push topic `watch_status` 推一条
98
+ `{watch_id, state, poll_count, text}`。chat 端渲染成一个**就地更新的小状态条/
99
+ chip**(按 watch_id 去重替换,不追加),**不写入消息历史、不触发 LLM、不计入
100
+ 上下文**;watch 一到终态该状态条自动消失/收起。这样轮询再多也不污染对话。
101
+ - **结果通道**:只有 `on_done`/`on_fail`(mode=chat)产出**一条真消息**进对话
102
+ 线程。`progress.show:false` 则全程静默,只留终态。
103
+ - 实现:进度走 `bridgePush('watch_status', …)`(extension/web 各加一个轻量
104
+ status-bar 组件订阅);结果走已有的 session resume 回灌(§1)。
105
+
106
+ - **完成回灌 chat**:默认 `mode=chat`——把最后一次 poll 结果 + `message` 作为
107
+ tool-result 喂回发起的 session,助手据此吐一条消息(还能继续调用下一步)。
108
+ `mode=tool` 则纯链式派发工具、不吐消息;`mode=none` 只落库置终态(AI 下次
109
+ 需要时可查)。tool 模式走和 chat 同一条 dispatcher,可链式。
110
+ - **done 判定不用 eval**:`done_path`/`fail_path` 点路径取真值;`done_match`
111
+ 做 equals/contains 比较(期望值模板取),都不跑 eval、零注入面。
112
+
113
+ NAC 直连升级的 async 块(第二个消费者,用 done_match):
114
+ ```yaml
115
+ upgrade: # nac connector
116
+ async:
117
+ poll: get_version
118
+ poll_args: {} # get_version 无参,连 settings 里的 host 即可
119
+ done_match: { path: build, equals: "{args.target_build}" } # build6957 → "6957"
120
+ interval_sec: 60
121
+ timeout_sec: 900 # 含 reboot,放宽
122
+ on_done: { mode: chat, message: "NAC {settings.host} 已升级到 build {poll.build}。" }
123
+ ```
124
+ 注:`nac.upgrade` 目前参数没有 target_build——接 watch 时给它加一个(或从
125
+ 镜像文件名 `build(\d+)` 自动解析),传给 watch 做比较。
126
+
127
+ ## 3. 防死循环(纯后台调度的核心约束)
128
+
129
+ | 失控源 | 护栏 |
130
+ |---|---|
131
+ | 轮询永不停 | `max_polls`(默认 40)+ `timeout_sec`(默认 1200s),先到先终止 |
132
+ | 同一 watch 并发重入 | **单飞**:每条 watch 有 `running` 锁,上一次 poll 没回来不发下一次 |
133
+ | 回调再生回调(A→B→A) | 回调链 `chain_depth` 上限(默认 3)+ 已访问 watch 链路环检测 |
134
+ | 回调本身又是 async 工具 | 允许,但继承并递减 `chain_depth`;到 0 则只跑、不再登记新 watch |
135
+ | watch 越堆越多 | 全局 active watch 上限(默认 50);超了拒绝新登记并在触发工具结果里报错 |
136
+ | poll 报错刷屏 | 连续 N 次错误(默认 5)→ 终态 `errored`,不再重试 |
137
+ | 僵尸 watch | 任何 watch 硬性 `max_lifetime`(默认 2×timeout)兜底清理 |
138
+
139
+ 所有上限都有**默认值**且 manifest 可调小,不可调到无界。
140
+
141
+ ## 4. 重启不丢
142
+
143
+ - watch **持久化在 SQLite**(`connector_watches`),不是内存。
144
+ - Forge 重启/崩溃 → ticker 起来后扫 `state=active` 的 watch,凭存下的
145
+ `fired_at`/`next_poll_at`/`polls` 续轮询,**不靠 AI、不靠原对话**。
146
+ - 宕机超过 `max_lifetime` 的 watch → 标 `timed_out`、跑 `on_fail`(若有),
147
+ 绝不静默丢。
148
+
149
+ ## 5. 极简管理面(防资源占用 / 可取消)
150
+
151
+ 不做常规 UI,但提供**最小可观测+可控**:
152
+
153
+ - **API**:`GET /api/watches`(列 active+近期终态)、`POST /api/watches/:id/cancel`、
154
+ `DELETE /api/watches/:id`。
155
+ - **UI 落点**(二选一,倾向 A):
156
+ - **A. Settings → Monitor** 加一个 "Background Watches" 折叠区:每行显示
157
+ connector·poll_tool·polls/max·下次轮询·状态,带 Cancel/Delete。和已有
158
+ 进程监控同处,零新页面。
159
+ - **B.** /chat 侧栏一个折叠小列表。
160
+ - cancel = 立即置 `cancelled` 终态、停轮询;delete = 删行。
161
+
162
+ ## 6. 改动清单
163
+
164
+ | 在哪 | 改什么 | 量 |
165
+ |---|---|---|
166
+ | 类型 | `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
+ | 存储 | `lib/watch/watch-store.ts`(新):SQLite 表 + CRUD + 单实例守卫 | ~100 行 |
168
+ | 轮询 | `lib/watch/watch-runner.ts`(新):独立 ticker,单飞、护栏、终态机、调 poll/回调、**每 tick `bridgePush('watch_status',…)`** | ~170 行 |
169
+ | 派发 | `lib/chat/tool-dispatcher.ts`:跑完带 `async` 的工具 → `registerWatch`,返回附 `watch_id` | 小 |
170
+ | API | `app/api/watches/route.ts` + `[id]`:list / cancel / delete | 小 |
171
+ | UI(状态条) | extension + /chat 各加一个轻量组件,订阅 `watch_status` push → 渲染就地更新的 chip(不进消息流) | 小 |
172
+ | UI(管理面) | Settings Monitor 加 Background Watches 折叠区(只读+cancel/delete) | 小 |
173
+ | 连接器 | `upgrade_lab`/`upgrade_device` 加 `async:` 块 | 几行 |
174
+
175
+ **主体在 Forge**(类型+store+runner+API+UI);连接器只加声明块。**不复用
176
+ Schedules、不新增进程**——独立轻量,正合你要的形态。
177
+
178
+ ## 7. TP 落地体验(后台轮询 + 完成回灌 chat)
179
+
180
+ ```
181
+ 用户:升级 AT16_Combined_FSW 到 build6957
182
+ AI :upgrade_lab(command=…) → {dispatched, watch_id, fired_at}
183
+ AI :「已发起,后台盯到完成会在这里告诉你。」← 对话可结束/去忙别的
184
+ (后台 ticker 每 60s poll check_lab_upgrade_status(since=fired_at),无 AI 参与)
185
+ done:true → 回灌 session S → 助手在原会话吐:
186
+ 「✅ AT16_Combined_FSW 升级完成,FortiNAC(10.15.52.152)已到 build6957。」
187
+ (若该会话是 telegram,这条就到 telegram;是 /chat 就在 /chat 冒出来。
188
+ 若配了 on_done.mode=tool,则改为自动跑 run_pytest,不吐消息。)
189
+ ```
190
+
191
+ ## 8. 工作量
192
+
193
+ - Forge:store + runner + dispatcher 钩子 + API + Monitor 区 ≈ **1~1.5 天**
194
+ (护栏/单飞/重启续跑要写稳)。
195
+ - 连接器:几行。
196
+
197
+ ## 9. 待你决策
198
+
199
+ - [x] UI:不做常规 UI,但有**极简管理面**(列表+cancel/delete),落点倾向
200
+ Settings→Monitor 的折叠区。
201
+ - [x] 重启不丢:SQLite 持久化,续跑;超期回灌/回调,不静默丢。
202
+ - [x] 完成动作:默认 **mode=chat 回灌发起会话让助手吐消息**(telegram 会话→telegram,
203
+ /chat→/chat);可选 mode=tool 纯链式、mode=none 仅落库。**不**另起独立通知通道。
204
+ - [x] 进度反馈:每 tick 走独立 `watch_status` push,渲染成**就地更新的状态条/chip,
205
+ 不进消息流、不触发 LLM**;只有终态出一条真消息。`progress.show` 可关。
206
+ - [ ] **是否实现?**
207
+ - [ ] 管理面落点 A(Settings→Monitor)还是 B(/chat 侧栏)? 右上角菜单下有个 watch 按钮
208
+ - [ ] 回调链深度 / active 上限 / max_polls 默认值是否认可(3 / 50 / 40)?
209
+ - [ ] mode=chat 回灌时,助手是"只吐一条总结消息"还是"可继续自主调用工具"(后者更强但要纳入防循环上限)?
210
+ - 只回总结吧,终止,然后由 chat决定吧。不用做嵌套,因为本身就是异步跟踪了,就可以一直获取。如果真的需要嵌套就设置一个嵌套数量吧,