@migor/cli 0.9.0 → 0.9.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.9.1] - 2026-09-22
11
+
12
+ ### 新增 · dev 面板的实时右栏(在飞就看得到调用树)
13
+
14
+ - **右栏从「收尾才有」变成「一个个长出来」**:runner 订阅框架的增量记账出口
15
+ (`onTraceEvent`,0.8.3 已落地)逐笔 `POST /ingest-event` → 父进程**原样**广播
16
+ (SSE 的 `dev` 命名事件多一类 `trace-event`)→ 面板按 `seq` 折回一棵**临时**的树。
17
+ 收尾那份整棵 trace 回来时**覆盖**它(缺的 span 由那份补齐)。纪律与框架同名出口一致:
18
+ **不保证送达**,所以刷新页面之后早先那些 span 不会回来(收尾那份会补)。
19
+ 计划 §D7 的评审补充点名要求的那条(P1b 面板侧工作)这轮落地。
20
+ 折回逻辑是**纯逻辑 + 单测**(`panel-logic.ts` 的 `applyTraceEvent`),e2e-dev 另有一条
21
+ 「帧必须先于 run-done 到达,且折回逐字等于收尾的整棵 trace」的真进程断言。
22
+
23
+ ### 变更 · 工作目录选择器:文件可选、隐藏目录可达、`浏览…` 不再是开关
24
+
25
+ - **文件也能选**:`GET /api/fs` 现在分三类回(`dirs` / `dotDirs` / `files`;文件有上限,
26
+ 超限时回 `filesTruncated`)。点文件名 = 工作目录取它**所在的**目录 + prompt 为空时填文件名
27
+ (你已经写好的 prompt **一个字不动**)。旧形态只列目录 ⇒「让 agent 看某个文件」只能靠自己打名字。
28
+ - **隐藏目录单列**(不再整批过滤):`.git` / `~/x/.y` 这类目录以前**根本点不进去**
29
+ (`..` 只能退到它上面,进不去)。
30
+ - **`浏览…` 不再当开关**:以前面板开着时再点一次只是把它关掉,于是「在输入框敲了路径 → 点浏览」
31
+ 这个最自然的动作是**把面板关掉**。现在点它**总是**按输入框里的路径打开,点面板外面收起。
32
+
33
+ ### 修复
34
+
35
+ - **跑完的回复被面板自己擦掉(端到端实测 17 ms)**:`run-done` 与「这条 trace 的收尾帧」
36
+ 几乎同毫秒到达,而 `open()`(收尾帧触发的自动打开)**无条件**清回复 ⇒ 刚写上去的那句立刻没了,
37
+ 表现是「run 完成了,但面板上没有回复」。现在按「这条 trace 是不是这段回复的主人」判
38
+ (`panel-logic.replyBelongsTo`):打开**别的** run 的 trace 照样清(防张冠李戴),
39
+ 打开**刚跑完那一轮**的不清。
40
+ - **在飞的树被收尾帧骗成「已完成」**:`playTrace` 以前对根 span 没有 `endedAt` 的树也调
41
+ `finish()`(根被标成 `●` + 耗时),而在飞时那是**假事实**。现在根没结束就不收尾(留 `◌`)——
42
+ 该分支只对「在飞 / 截断」的树生效,收尾的整棵 trace 行为逐字未变。
43
+
44
+ ### 文档
45
+
46
+ - `docs/usage-guide.md` §2.2:补「右栏是实时的」(含两条边界)与工作目录的三个点击语义。
47
+ - `docs/guards.md` §1.4:新增五条守卫登记(见该节)。
48
+ - `docs/plans/2026-09-22-dev-debug-loop.md`:§D7 那条评审补充标注 **P1b 已落地**。
49
+ - `docs/spec.md` §10 2026-09-22 ⑦:本轮三个缺陷的现场证据与修法(含「同一条事实两个表面」的第 N 个影子)。
50
+
8
51
  ## [0.9.0] - 2026-09-22
9
52
 
10
53
  > 本版主题(窗口 `0.8.3 → 0.9.0`):**dev 调试环落地** —— `agentia dev` 从「`tsx watch` 跑你的
@@ -1143,7 +1186,8 @@
1143
1186
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
1144
1187
  框架本体单包;CLI 独立成包(workspaces)。
1145
1188
 
1146
- [Unreleased]: https://github.com/retrychx/agentia/compare/v0.9.0...HEAD
1189
+ [Unreleased]: https://github.com/retrychx/agentia/compare/v0.9.1...HEAD
1190
+ [0.9.1]: https://github.com/retrychx/agentia/releases/tag/v0.9.1
1147
1191
  [0.9.0]: https://github.com/retrychx/agentia/releases/tag/v0.9.0
1148
1192
  [0.8.3]: https://github.com/retrychx/agentia/releases/tag/v0.8.3
1149
1193
  [0.8.2]: https://github.com/retrychx/agentia/releases/tag/v0.8.2
package/dist/AGENTS.md CHANGED
@@ -131,11 +131,20 @@ npx agentia --version # CLI 版本(= -v)
131
131
  |---|---|
132
132
  | **prompt** | 输入框 + `↑`/`↓` 调回历史(像 shell)。面板上「点一下 = 一次**真** run」 |
133
133
  | **能力** | 多选,缺省全选。收窄的是**菜单**(主 agent 仍在环里),不是「只可能调它」—— 别的能力 `tools` 里的显式引用仍能调到被排除的那个 |
134
- | **工作目录** | 目录选择器(`浏览…`)。**它是这个环的主控件**:任务型 agent 的 prompt 几乎不变,变的是目录 |
134
+ | **工作目录** | 目录选择器(`浏览…`)。**它是这个环的主控件**:任务型 agent 的 prompt 几乎不变,变的是目录。三个点击语义:点**目录名**进去 / 点 `..` 上一层 / **点文件名** = 把它**所在的**目录当工作目录(prompt 为空时顺手把文件名填进去,你写好的话一个字不动);`.git` 这类**隐藏目录**单列在选择器末尾(可达但靠后)。`浏览…` **总是**按输入框里的路径打开 —— 敲了路径再点它就是打开那个路径(不是「再点一次关掉」);点面板外面收起 |
135
135
  | **多轮** | 跟能力走:`src/dev.config.ts` 里 `multiTurn: [...]` 声明哪些能力该多轮,混选时取 **OR** 且面板**标出来源**(`多轮·trip-planner`) |
136
136
 
137
137
  `npm run dev -- "你的问题"` 可以直接把第一句 prompt 带上(等价于启动后在输入框里敲)。
138
138
 
139
+ **右栏是实时的(在飞就看得到)**:run 一开跑,右栏就从空树开始,**span 一个个长出来**
140
+ (`◌` = 还没结束,`●` = 已结束),收尾时用**整棵 trace** 覆盖一遍。实现走框架的增量记账出口
141
+ (`onTraceEvent`,0.8.3):runner 订阅 → 逐笔 `POST /ingest-event` → 父进程广播 → 面板按 `seq` 折回。
142
+ 两条边界(与框架同名出口一致,都写在文档里以免被当成缺陷):
143
+
144
+ - **不保证送达**:SSE 断了就断了,没有重放。所以「在飞的那棵树」是**临时的** —— 刷新页面之后
145
+ 早先那些 span 不会回来(收尾的整棵 trace 会补齐它们),**收尾那份永远覆盖在飞的这份**。
146
+ - 它只影响「此刻看到什么」,**不影响 run 本身**,也不影响落盘的 trace(那是另一条缝)。
147
+
139
148
  **为什么用 `agentia dev` 而不是 `tsx watch`**:`agentia dev` 自己 import 你的 `src/app.ts` 并驱动它,
140
149
  于是**它才是调用方**,四个输入才可能生效。它也**自己管文件监视**(不再叠一层 `tsx watch`):
141
150
 
@@ -19,7 +19,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
19
19
  import { createRequire } from 'node:module';
20
20
  import { join } from 'node:path';
21
21
  import { pathToFileURL } from 'node:url';
22
- import { createInspectSink } from './inspector-sink.js';
22
+ import { createInspectEventSink, createInspectSink, } from './inspector-sink.js';
23
23
  import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, } from './dev-protocol.js';
24
24
  const root = process.env.AGENTIA_DEV_ROOT || process.cwd();
25
25
  function send(msg) {
@@ -60,6 +60,28 @@ async function registerTraceSink() {
60
60
  console.warn(`[agentia] inspector 未挂载:${e.message}`);
61
61
  }
62
62
  }
63
+ /**
64
+ * ① 实时右栏:**增量**记账事件的出口(框架 `onTraceEvent` 的落点)。
65
+ *
66
+ * 与上面那个 trace sink 是**两条缝**,差别都是刻意的:
67
+ * - trace sink 走收尾的 `snapshot()`(一次、被 `flushSinks` await、保证送达);
68
+ * - 这里走运行期的 `onTraceEvent`(逐笔、同步派发、**不保证送达**)——「此刻看到」。
69
+ *
70
+ * 为什么必须有它:此前右栏唯一的画树点在 run **收尾之后**,一次十几秒的 run 期间
71
+ * 面板上的树是死的(只有一句「正在跑…」)。框架的增量出口本来就是为这类消费者准备的
72
+ * (`RunInvocationOptions.onTraceEvent`,0.8.3),计划 §D7 的评审补充也点名要求接上。
73
+ *
74
+ * 纪律(与框架同名出口一致):不 await、失败静默、由 sink 自己排 FIFO 链保序
75
+ * (见 `createInspectEventSink`)。**丢帧只会让树少长一会儿**:收尾那份整棵 trace
76
+ * 会把面板上折回的临时树覆盖掉,缺的 span 由它补齐 —— 不会让面板看到一棵错的树。
77
+ */
78
+ function registerEventSink() {
79
+ const port = Number(process.env.AGENTIA_INSPECT_PORT || 0);
80
+ if (port <= 0)
81
+ return;
82
+ const token = process.env.AGENTIA_INSPECT_TOKEN || undefined;
83
+ eventSink = createInspectEventSink({ port, ...(token ? { token } : {}) });
84
+ }
63
85
  // ---------- dev.config.ts(**数据**,不是逻辑) ----------
64
86
  /**
65
87
  * 读 `src/dev.config.ts`。
@@ -329,6 +351,8 @@ let currentAbort = null;
329
351
  let runInFlight = false;
330
352
  /** 窗口内到达的中止请求:建完 controller 立刻补一次 `abort()`(见 `runOnce` 的 await 顺序) */
331
353
  let abortRequested = false;
354
+ /** ① 增量记账事件的出口(`registerEventSink()` 建;端口没给就是 null —— 面板没开) */
355
+ let eventSink = null;
332
356
  async function runOnce(msg) {
333
357
  const { request } = msg;
334
358
  send({ type: 'run-start' });
@@ -350,6 +374,11 @@ async function runOnce(msg) {
350
374
  maxTotalTokens: budget.maxTotalTokens,
351
375
  signal: ac.signal,
352
376
  };
377
+ // ① 实时右栏:**在 run 之前**挂上(框架只在运行期派发 —— 晚一步就等于整轮都收不到)。
378
+ // 单次 run 的 `onTraceEvent` 与应用级缺省是**叠加**的(不是覆盖),所以这里挂上
379
+ // 不会把用户在 `createApp` 里配的那个挤掉。
380
+ if (eventSink)
381
+ opts.onTraceEvent = (e) => eventSink?.send(e);
353
382
  try {
354
383
  if (request.multiTurn) {
355
384
  const mod = await importAppModule();
@@ -393,6 +422,8 @@ async function runOnce(msg) {
393
422
  async function main() {
394
423
  // ⚠️ 顺序不能换:sink 必须早于用户 app 的 import(createApp 构造期对 defaultSinks 快照)
395
424
  await registerTraceSink();
425
+ // 增量出口只是个 HTTP 出口(不碰框架注册表),但同样在这里建:一个进程一次。
426
+ registerEventSink();
396
427
  let built;
397
428
  try {
398
429
  built = await build();
package/dist/dev.js CHANGED
@@ -916,6 +916,14 @@ export function devServer(argv = []) {
916
916
  session: readSession,
917
917
  abort: abortRun,
918
918
  clearSession,
919
+ /**
920
+ * ① 实时右栏:runner 逐笔 POST 上来的增量记账事件**原样广播**给面板。
921
+ *
922
+ * 父进程在这一层**不做任何解释**(不折回、不缓存、不裁剪):折回是面板的活
923
+ * (它有 `panel-logic.applyTraceEvent` 与单测),而父进程插一手就多一处会漂的口径。
924
+ * 这里只保证「按收到的顺序、同步转发」—— SSE 写入本身是有序的。
925
+ */
926
+ traceEvent: (e) => emit({ kind: 'trace-event', event: e }),
919
927
  },
920
928
  });
921
929
  console.log(`Inspector: http://127.0.0.1:${inspector.port}/?t=${token}\n` +
@@ -81,7 +81,14 @@ function eventFull(ev) {
81
81
  }
82
82
 
83
83
  /**
84
- * 把真实 Trace 线性回放给视图(同步、一次性;用于 run 结束后的静态展示)。
84
+ * 把真实 Trace 线性回放给视图(同步、一次性)。
85
+ *
86
+ * 两种用途共用本函数(这也是它必须呆在 trace-view 里、不进 CLI 的理由):
87
+ * - **收尾的整棵 trace**(run 结束后一次画完);
88
+ * - **在飞的那棵树**(CLI inspector 的实时右栏:每收到一条增量记账事件就整体重画一次,
89
+ * 见 `packages/cli/src/panel-logic.ts` 的 `applyTraceEvent`)。重画是幂等的 ——
90
+ * 同一棵 spans 交给它多少次,画出来的都是同一棵树。
91
+ *
85
92
  * 返回 false 表示 trace 空、未做任何渲染。
86
93
  */
87
94
  export function playTrace(view, trace) {
@@ -158,6 +165,9 @@ export function playTrace(view, trace) {
158
165
  }
159
166
  }
160
167
 
161
- view.finish(root.endedAt != null ? root.endedAt - root.startedAt : 0, root.status, root.error);
168
+ // 根 span 还没结束(`span.end` 尚未到达)⇒ **不收尾**:`finish()` 会把根标成已完成
169
+ // (● + 耗时),而这棵树其实还在长 —— 在飞时看到「已完成」是假事实。留 ◌ 才对。
170
+ // 收尾的整棵 trace 根必有 `endedAt`,所以这条分支只对「在飞 / 截断」的树生效。
171
+ if (root.endedAt != null) view.finish(root.endedAt - root.startedAt, root.status, root.error);
162
172
  return true;
163
173
  }
@@ -151,6 +151,11 @@
151
151
  .fs-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); }
152
152
  .fs-item { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--text); padding: 4px 6px; border-radius: 4px; font: inherit; font-family: var(--mono); font-size: 12px; cursor: pointer; }
153
153
  .fs-item:hover { background: #202024; }
154
+ /* 隐藏目录:可达但不可眼(旧实现直接过滤掉,于是 .y 这类目录根本点不进去) */
155
+ .fs-dot { color: var(--faint); }
156
+ /* 文件行:点它 = 选它所在的目录(不是选文件本身当工作目录 —— 工作目录只能是目录) */
157
+ .fs-file { color: var(--text); }
158
+ .fs-sep { padding: 8px 6px 2px; margin-top: 6px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
154
159
  .fs-foot { padding-top: 8px; border-top: 1px solid var(--border); margin-top: 6px; }
155
160
  .mini { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 5px 9px; font: inherit; font-size: 11.5px; cursor: pointer; }
156
161
  .mini:hover { border-color: var(--ice); }
@@ -227,7 +232,10 @@
227
232
  <span id="fs-path"></span>
228
233
  </div>
229
234
  <div id="fs-list"></div>
230
- <div class="fs-foot"><button class="mini" id="fs-use" type="button">选这个目录</button></div>
235
+ <div class="fs-foot">
236
+ <button class="mini" id="fs-use" type="button">选这个目录</button>
237
+ <span class="hint"> 点文件名 = 选它所在的目录(prompt 为空时填文件名)</span>
238
+ </div>
231
239
  </div>
232
240
  </footer>
233
241
 
@@ -235,14 +243,20 @@
235
243
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
236
244
  import {
237
245
  HISTORY_DRAFT,
246
+ applyTraceEvent,
247
+ browseTarget,
238
248
  capabilityBadge,
249
+ emptyTraceAccumulator,
239
250
  historyStep,
240
251
  historyText,
241
252
  mergeConversation,
242
253
  multiTurnDefault,
243
254
  multiTurnLabel,
244
255
  normalizeToolSources,
256
+ partialTrace,
257
+ promptAfterFilePick,
245
258
  pushPromptHistory,
259
+ replyBelongsTo,
246
260
  runDoneNotice,
247
261
  runIsFailure,
248
262
  shortenPath,
@@ -298,6 +312,17 @@
298
312
  list: [],
299
313
  current: null,
300
314
  reply: '',
315
+ /**
316
+ * 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
317
+ * 「现在打开的就是这条回复的主人」⇒ 不清。见 panel-logic 的 `replyBelongsTo`。
318
+ */
319
+ replyFor: null,
320
+ /**
321
+ * 在飞 run 的增量 trace 累加器(null = 没有在飞的 run)。
322
+ * 收到 `trace-event` 就折回并重画;收尾那份整棵 trace 回来时把它清掉
323
+ * (**收尾那份永远覆盖在飞的这份**,缺的 span 由它补齐)。
324
+ */
325
+ live: null,
301
326
  notice: '', // 面板自己发的操作反馈(已接受 / 重启了 runner / 出错)
302
327
  noticeErr: false,
303
328
  };
@@ -536,6 +561,52 @@
536
561
  renderChat();
537
562
  }
538
563
 
564
+ // ---------- 在飞 run 的实时右栏(①) ----------
565
+
566
+ /**
567
+ * run 开跑:把右栏切成「这一轮的空树」。
568
+ *
569
+ * 为什么必须在 run-start 就切(而不是等第一条 span):不切的话右栏留着**上一轮**那棵树,
570
+ * 一次十几秒的 run 期间它看起来就是「这次的结果」。同理清掉上一轮的回复 / 排行 / 用量 ——
571
+ * 它们都是**上一轮**的账,与在飞的这一轮无关(历史 run 在左栏列表里随时能点回来)。
572
+ */
573
+ function beginLiveRender() {
574
+ state.live = emptyTraceAccumulator();
575
+ state.reply = '';
576
+ state.replyFor = null;
577
+ replyEl.hidden = true;
578
+ replyEl.textContent = '';
579
+ summaryEl.replaceChildren();
580
+ usageEl.hidden = true;
581
+ ttlEl.textContent = '';
582
+ const ph = document.createElement('div');
583
+ ph.className = 'tr-empty';
584
+ ph.textContent = '// 这一轮在飞 —— span 会一个个长出来(收尾时用整棵 trace 覆盖)';
585
+ traceEl.replaceChildren(ph);
586
+ }
587
+
588
+ /**
589
+ * 增量事件 → 折回 → 重画整棵树。
590
+ *
591
+ * `playTrace` 是**幂等全量重画**(自己拆好树再画),所以这里不需要「局部更新」那套:
592
+ * 每次把当前已知的 spans 交回去就行。用 rAF 合帧是必要的 —— 一次 run 可能有几百条
593
+ * 记账事件,逐条重绘是白烧 CPU(而人眼只看得见一帧)。
594
+ */
595
+ let liveFrame = 0;
596
+ function renderLive() {
597
+ if (liveFrame) return;
598
+ liveFrame = requestAnimationFrame(() => {
599
+ liveFrame = 0;
600
+ if (!state.live) return;
601
+ const t = partialTrace(state.live);
602
+ // 空树不交给 playTrace(它对空 spans 直接返回 false,右栏会留着占位那行 —— 那正是我们要的)
603
+ if (t.spans.length === 0) return;
604
+ if (!playTrace(view, t)) return;
605
+ ttlEl.textContent = `${t.traceId}(在飞)`;
606
+ usageEl.hidden = false;
607
+ });
608
+ }
609
+
539
610
  // ---------- 拉数据 ----------
540
611
  async function refreshDev() {
541
612
  try {
@@ -546,6 +617,9 @@
546
617
  }
547
618
  // 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
548
619
  state.sessionId = state.dev?.sessionId ?? '';
620
+ // 页面**在 run 在飞时**才加载(刷新 / 新开标签)⇒ 补一个累加器,好接住后续的增量帧。
621
+ // 之前那些 span 收不到(SSE 不重放)—— 它们会随收尾那份整棵 trace 一起补齐。
622
+ if (state.dev?.running && !state.live) state.live = emptyTraceAccumulator();
549
623
  renderControls();
550
624
  renderChat();
551
625
  renderBanner();
@@ -590,10 +664,17 @@
590
664
  playTrace(view, trace);
591
665
  summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
592
666
  // 回复正文只对**刚跑完的那一轮**有意义(trace 上没有它 —— 它是 run-done 事件带的)
593
- // ⇒ 打开历史 run 必须清掉,否则它挂在别人的 trace 底下(张冠李戴)
594
- state.reply = '';
595
- replyEl.hidden = true;
596
- replyEl.textContent = '';
667
+ // ⇒ 打开**别的** run 时必须清掉,否则它挂在别人的 trace 底下(张冠李戴)。
668
+ //
669
+ // ⚠️ 但不能**无条件**清:`run-done` 与「这条 trace 的收尾帧」几乎同毫秒到达,
670
+ // 而这条 handler 的链更长(refresh → refreshSession → renderChat → open)——
671
+ // 无条件清会把刚写上去的那句回复擦掉(端到端实测 17 ms,现象是「跑完了但没回复」)。
672
+ if (!replyBelongsTo(id, state.replyFor)) {
673
+ state.reply = '';
674
+ state.replyFor = null;
675
+ replyEl.hidden = true;
676
+ replyEl.textContent = '';
677
+ }
597
678
  renderList();
598
679
  }
599
680
 
@@ -602,10 +683,20 @@
602
683
  if (state.list.length && !state.current) await open(state.list[0].traceId);
603
684
  }
604
685
 
605
- // ---------- 目录浏览 ----------
686
+ // ---------- 目录浏览 / 选文件(③) ----------
606
687
  // 浏览器拿不到用户选的文件夹的绝对路径(webkitdirectory 只给相对路径、
607
688
  // File System Access 只给 handle.name),而 dev 环要的是绝对路径 ⇒ 服务端列目录。
689
+ //
690
+ // 三个控件语义(2026-09-22 复核后定,前两条是实测出来的缺口):
691
+ // - 点**目录名** ⇒ 进去;点 `..` ⇒ 上一层;「选这个目录」⇒ 把当前位置当工作目录。
692
+ // - 点**文件名** ⇒ 工作目录 = 它**所在的**目录(就是当前这一层),prompt 为空时填文件名。
693
+ // 只列目录的旧形态下,「让 agent 看某个文件」只能靠用户自己把名字打进 prompt。
694
+ // - 隐藏目录(`.git` / `~/x/.y`)单列在正常目录之后 —— 旧实现整批过滤掉,
695
+ // 于是那些目录**根本点不进去**(`..` 只能退到它上面,进不去)。
608
696
  let fsPath = '';
697
+ function closeFs() {
698
+ fsEl.hidden = true;
699
+ }
609
700
  async function openFs(at) {
610
701
  const res = await fetch('/api/fs?path=' + encodeURIComponent(at));
611
702
  if (!res.ok) {
@@ -617,30 +708,67 @@
617
708
  fsPath = body.path;
618
709
  fsPathEl.textContent = body.path;
619
710
  fsListEl.replaceChildren();
620
- for (const name of body.dirs) {
711
+
712
+ const row = (text, cls, title, onClick) => {
621
713
  const b = document.createElement('button');
622
714
  b.type = 'button';
623
- b.className = 'fs-item';
624
- b.textContent = name + '/';
625
- b.addEventListener('click', () => openFs(fsPath + '/' + name));
715
+ b.className = cls;
716
+ b.textContent = text;
717
+ if (title) b.title = title;
718
+ b.addEventListener('click', onClick);
626
719
  fsListEl.appendChild(b);
720
+ };
721
+ const enterDir = (name) => () => void openFs(`${fsPath}/${name}`);
722
+ /** 选一个文件:工作目录取**当前这一层**(它所在的目录),prompt 只在空时被填 */
723
+ const useFile = (name) => () => {
724
+ workdirEl.value = fsPath;
725
+ state.workdir = fsPath;
726
+ const picked = promptAfterFilePick(promptEl.value, name);
727
+ if (picked.filled) promptEl.value = picked.prompt;
728
+ closeFs();
729
+ setNotice(
730
+ picked.filled
731
+ ? `工作目录:${short(fsPath)}(prompt 已填「${name}」)`
732
+ : `工作目录:${short(fsPath)}(prompt 里已有内容,一个字没动)`,
733
+ false,
734
+ );
735
+ };
736
+
737
+ for (const name of body.dirs) row(name + '/', 'fs-item', '', enterDir(name));
738
+ for (const name of body.dotDirs ?? []) {
739
+ row(name + '/', 'fs-item fs-dot', '隐藏目录', enterDir(name));
740
+ }
741
+ const files = body.files ?? [];
742
+ if (files.length > 0) {
743
+ const sep = document.createElement('div');
744
+ sep.className = 'fs-sep';
745
+ sep.textContent = '文件';
746
+ fsListEl.appendChild(sep);
747
+ for (const name of files) {
748
+ row(name, 'fs-item fs-file', '选这个文件:工作目录设成它所在的目录,prompt 为空时填文件名', useFile(name));
749
+ }
750
+ }
751
+ if (body.filesTruncated) {
752
+ const more = document.createElement('div');
753
+ more.className = 'hint';
754
+ more.textContent = `(文件只列了前 ${files.length} 个)`;
755
+ fsListEl.appendChild(more);
627
756
  }
628
757
  fsEl.hidden = false;
629
758
  fsEl.dataset.parent = body.parent;
630
759
  }
631
- $('fs-up').addEventListener('click', () => openFs(fsEl.dataset.parent || fsPath));
760
+ $('fs-up').addEventListener('click', () => void openFs(fsEl.dataset.parent || fsPath));
632
761
  $('fs-use').addEventListener('click', () => {
633
762
  workdirEl.value = fsPath;
634
763
  state.workdir = fsPath;
635
- fsEl.hidden = true;
764
+ closeFs();
636
765
  setNotice(`工作目录:${short(fsPath)}`, false);
637
766
  });
767
+ // ⚠️ 这里**不是**开关:旧实现「面板开着时再点一次把它关掉」,于是
768
+ // 「在输入框敲了目标路径 → 点浏览…」这个最自然的动作是把面板关掉,
769
+ // 用户就以为「选不了别的目录」。现在点它**总是**按当前输入打开。
638
770
  $('browse').addEventListener('click', () => {
639
- if (!fsEl.hidden) {
640
- fsEl.hidden = true;
641
- return;
642
- }
643
- void openFs(workdirEl.value.trim() || (state.dev ? state.dev.defaultWorkdir : '.'));
771
+ void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
644
772
  });
645
773
 
646
774
  // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
@@ -654,6 +782,9 @@
654
782
  });
655
783
  document.addEventListener('click', (e) => {
656
784
  if (capsEl.open && !capsEl.contains(e.target)) capsEl.open = false;
785
+ // 目录浏览器:点面板外面收起。`浏览…` 不再是开关之后,这是主要的收起方式
786
+ // (按钮自己那一支要排除,否则点它就会「开完立刻被这里收掉」)。
787
+ if (!fsEl.hidden && !fsEl.contains(e.target) && !$('browse').contains(e.target)) closeFs();
657
788
  });
658
789
 
659
790
  promptEl.addEventListener('keydown', (e) => {
@@ -758,6 +889,9 @@
758
889
  const nameEl = document.createElement('b');
759
890
  nameEl.textContent = s.name;
760
891
  liveEl.append(nameEl, ` · ${fmtMs(s.ms)}`);
892
+ // 收尾那份整棵 trace 到了 ⇒ 在飞的那份**作废**:`open()` 画的是权威版本,
893
+ // 而且此后不该再让迟到的增量帧把它重画成残缺的一棵(见 dev-protocol 的 DevEvent)
894
+ state.live = null;
761
895
  await refresh();
762
896
  await refreshSession();
763
897
  renderChat();
@@ -767,6 +901,8 @@
767
901
  const ev = JSON.parse(m.data);
768
902
  if (ev.kind === 'run-start') {
769
903
  liveEl.textContent = '正在跑…';
904
+ // 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
905
+ beginLiveRender();
770
906
  setNotice(
771
907
  `运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
772
908
  `${ev.multiTurn ? ' · 多轮' : ''}`,
@@ -774,8 +910,16 @@
774
910
  );
775
911
  // run-start 是「确实开跑了」的权威信号 ⇒ 中止按钮在这里出现(不看面板自己的乐观状态)
776
912
  await refreshDev();
913
+ } else if (ev.kind === 'trace-event') {
914
+ // 在飞的一笔记账(①):折回 + 合帧重画。`state.live` 为空说明收尾那份已经到了
915
+ // (或这一轮不是面板起的)⇒ 丢掉 —— 权威版本已经在屏幕上,别用残缺的覆盖它。
916
+ if (state.live && applyTraceEvent(state.live, ev.event)) renderLive();
777
917
  } else if (ev.kind === 'run-done') {
778
918
  state.reply = ev.finalText || '';
919
+ // 记下这条回复的**主人**:紧随其后的收尾帧会 open() 同一条 traceId,
920
+ // 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
921
+ state.replyFor = ev.traceId;
922
+ state.live = null;
779
923
  replyEl.hidden = state.reply.length === 0;
780
924
  replyEl.textContent = state.reply;
781
925
  // 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
@@ -789,6 +933,8 @@
789
933
  renderChat();
790
934
  } else if (ev.kind === 'runner-restart') {
791
935
  setNotice(`重启 runner:${ev.reason}`, false);
936
+ // 重启前的在飞 run 已经不存在了(它的 trace 会丢,面板在别处明说)⇒ 别留一棵半截树
937
+ state.live = null;
792
938
  // 重启期间菜单是过期的(旧 runner 的能力)—— 先刷一次,等 runner-ready 再刷一次。
793
939
  await refreshDev();
794
940
  } else if (ev.kind === 'runner-ready') {
@@ -797,6 +943,7 @@
797
943
  await refreshDev();
798
944
  } else if (ev.kind === 'runner-error') {
799
945
  setNotice(ev.message, true);
946
+ state.live = null;
800
947
  await refreshDev();
801
948
  }
802
949
  });
@@ -15,3 +15,22 @@ export function createInspectSink(opts) {
15
15
  },
16
16
  };
17
17
  }
18
+ export function createInspectEventSink(opts) {
19
+ const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest-event`;
20
+ const headers = { 'content-type': 'application/json' };
21
+ if (opts.token)
22
+ headers['x-agentia-token'] = opts.token;
23
+ let chain = Promise.resolve();
24
+ return {
25
+ send(event) {
26
+ chain = chain.then(async () => {
27
+ try {
28
+ await fetch(url, { method: 'POST', headers, body: JSON.stringify(event) });
29
+ }
30
+ catch {
31
+ /* 面板未开 / 已关 / 连接被拒:静默(观测失败不击穿 dev) */
32
+ }
33
+ });
34
+ },
35
+ };
36
+ }
package/dist/inspector.js CHANGED
@@ -60,6 +60,68 @@ function validateTrace(t) {
60
60
  }
61
61
  return null;
62
62
  }
63
+ /**
64
+ * 入站校验:**增量记账事件**的形状(`POST /ingest-event`,① 实时右栏)。
65
+ *
66
+ * 为什么卡得比 `validateTrace` 还细:面板拿这些字段**直接建树**,而坏帧的报错现场在
67
+ * 浏览器里(最难查的地方)。缺一个 `spanId` 的表现是「树上少一个节点」而不是任何报错 ——
68
+ * 所以宁可在这里响亮拒(400 会被 runner 的 event sink 静默吞掉,但至少不会污染面板)。
69
+ */
70
+ function validateTraceEvent(e) {
71
+ if (!e || typeof e !== 'object' || Array.isArray(e))
72
+ return '事件必须是对象';
73
+ const ev = e;
74
+ if (typeof ev.seq !== 'number' || !Number.isFinite(ev.seq))
75
+ return 'seq 必须是有穷 number';
76
+ switch (ev.type) {
77
+ case 'span.begin': {
78
+ const s = ev.span;
79
+ if (!s || typeof s !== 'object')
80
+ return 'span.begin 缺 span';
81
+ if (typeof s.spanId !== 'string' || s.spanId.length === 0)
82
+ return 'span.begin 的 span.spanId 缺失';
83
+ if (typeof s.traceId !== 'string' || s.traceId.length === 0) {
84
+ return 'span.begin 的 span.traceId 缺失';
85
+ }
86
+ if (typeof s.name !== 'string')
87
+ return 'span.begin 的 span.name 缺失';
88
+ if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
89
+ return 'span.begin 的 span.startedAt 非法';
90
+ }
91
+ return null;
92
+ }
93
+ case 'span.end': {
94
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
95
+ return 'span.end 缺 spanId';
96
+ if (typeof ev.endedAt !== 'number' || !Number.isFinite(ev.endedAt))
97
+ return 'span.end 的 endedAt 非法';
98
+ if (typeof ev.status !== 'string')
99
+ return 'span.end 的 status 缺失';
100
+ return null;
101
+ }
102
+ case 'span.event': {
103
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
104
+ return 'span.event 缺 spanId';
105
+ if (!ev.event || typeof ev.event.name !== 'string')
106
+ return 'span.event 缺 event.name';
107
+ return null;
108
+ }
109
+ case 'span.attribute': {
110
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
111
+ return 'span.attribute 缺 spanId';
112
+ if (typeof ev.key !== 'string' || ev.key.length === 0)
113
+ return 'span.attribute 缺 key';
114
+ return null;
115
+ }
116
+ case 'span.link': {
117
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
118
+ return 'span.link 缺 spanId';
119
+ return null;
120
+ }
121
+ default:
122
+ return `不认识的事件类型:${String(ev.type)}`;
123
+ }
124
+ }
63
125
  /** 防 DNS rebinding:面板只服务本机,Host 不是 localhost/127.0.0.1/[::1] 的一律拒 */
64
126
  function isLocalHostHeader(host) {
65
127
  if (!host)
@@ -142,6 +204,11 @@ const MAX_RUNS = 50;
142
204
  * (见 `rememberNote`),所以它有自己的一条淘汰线。
143
205
  */
144
206
  const MAX_NOTES = MAX_RUNS;
207
+ /**
208
+ * `GET /api/fs` 一次最多回多少个**文件**名(目录不设限:一个位置要列几百个目录,本身就
209
+ * 说明位置选错了)。超限回 `filesTruncated: true` —— 截断必须**明示**,不许静默少给。
210
+ */
211
+ const FILE_LIMIT = 200;
145
212
  /** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
146
213
  const HERE = dirname(fileURLToPath(import.meta.url));
147
214
  const ASSETS = join(HERE, 'inspector');
@@ -352,6 +419,42 @@ export function startInspector(opts = {}) {
352
419
  json(res, 200, { ok: true });
353
420
  return;
354
421
  }
422
+ /**
423
+ * **在飞 run 的增量记账事件**(① 实时右栏):`POST /ingest-event`。
424
+ *
425
+ * 与 `POST /ingest` 的分工是刻意的两条缝(框架侧同名区分):
426
+ * - 这条 = **此刻看到**:高频、逐笔、不落库、**不保证送达**;
427
+ * - 那条 = **最终账**:收尾一次、进 run 列表、被 `/api/runs` 读。
428
+ *
429
+ * 所以这里**只广播**、不建 run 记录:面板拿它把树「长」出来,收尾那份整棵 trace
430
+ * 回来时覆盖(`open()` 的渲染永远以收尾那份为准)。
431
+ *
432
+ * 202 而不是 200:已经广播出去了,但面板收没收到不归这里管(SSE 是单向流)。
433
+ * 没有 dev 钩子时回 503 —— 与 `/run` / `/run/abort` 同一口径(没有面板消费它)。
434
+ */
435
+ if (req.method === 'POST' && path === '/ingest-event') {
436
+ if (!opts.dev) {
437
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
438
+ return;
439
+ }
440
+ const body = await readBody(req);
441
+ let ev;
442
+ try {
443
+ ev = JSON.parse(body);
444
+ }
445
+ catch {
446
+ json(res, 400, { error: 'body 不是合法 JSON' });
447
+ return;
448
+ }
449
+ const problem = validateTraceEvent(ev);
450
+ if (problem) {
451
+ json(res, 400, { error: problem });
452
+ return;
453
+ }
454
+ opts.dev.traceEvent(ev);
455
+ json(res, 202, { ok: true });
456
+ return;
457
+ }
355
458
  if (req.method === 'GET' && path === '/api/runs') {
356
459
  json(res, 200, order
357
460
  .slice()
@@ -374,14 +477,25 @@ export function startInspector(opts = {}) {
374
477
  return;
375
478
  }
376
479
  /**
377
- * 目录浏览器:`GET /api/fs?path=<绝对路径>` → 该目录下的**子目录名**(不含文件)。
480
+ * 目录浏览器:`GET /api/fs?path=<绝对路径>` →
481
+ * `{ path, parent, dirs, dotDirs, files, filesTruncated }`。
378
482
  *
379
483
  * 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
380
484
  * 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
381
485
  * (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
382
486
  * 主控件」(D3/D7)该有的手感。
383
487
  *
384
- * 只列**目录**、不读文件内容;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
488
+ * 三条口径(2026-09-22 复核后改,前两条是实测出来的缺口):
489
+ * - **文件也列**(`files`,只给名字、不读内容):面板据此提供「选这个文件」——
490
+ * 工作目录取它所在目录、prompt 空时填文件名。只列目录时,「让 agent 看某个文件」
491
+ * 只能靠用户自己把文件名打进 prompt。
492
+ * - **隐藏目录单列**(`dotDirs`,不在 `dirs` 里):以前一批过滤掉,于是
493
+ * `~/xxx/.yyy` 这类目录**根本点不进去**(`..` 只能退到它上面,进不去)。
494
+ * 单列是为了不把 `.git` / `.cache` 混进正常浏览,同时保证**可达**。
495
+ * - `files` 有上限(`FILE_LIMIT`)并回 `filesTruncated`:一次列几千个文件的名
496
+ * 没有意义,但**必须明示**截断(本仓纪律:不许静默少给)。
497
+ *
498
+ * 只列名字、**不读文件内容**;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
385
499
  * 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
386
500
  * 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
387
501
  */
@@ -408,11 +522,26 @@ export function startInspector(opts = {}) {
408
522
  json(res, 400, { error: `读不了这个目录:${e.message}` });
409
523
  return;
410
524
  }
411
- const dirs = entries
412
- .filter((e) => !e.name.startsWith('.') && isDirLikeEntry(join(abs, e.name), e))
413
- .map((e) => e.name)
414
- .sort();
415
- json(res, 200, { path: abs, parent: dirname(abs), dirs });
525
+ const dirs = [];
526
+ const dotDirs = [];
527
+ const files = [];
528
+ for (const e of entries) {
529
+ if (isDirLikeEntry(join(abs, e.name), e)) {
530
+ (e.name.startsWith('.') ? dotDirs : dirs).push(e.name);
531
+ continue;
532
+ }
533
+ // 读不了内容的条目(管道 / socket / 权限)也当文件列出来 —— 列名字不需要读它
534
+ if (!e.name.startsWith('.'))
535
+ files.push(e.name);
536
+ }
537
+ json(res, 200, {
538
+ path: abs,
539
+ parent: dirname(abs),
540
+ dirs: dirs.sort(),
541
+ dotDirs: dotDirs.sort(),
542
+ files: files.sort().slice(0, FILE_LIMIT),
543
+ filesTruncated: files.length > FILE_LIMIT,
544
+ });
416
545
  return;
417
546
  }
418
547
  if (req.method === 'POST' && path === '/run') {
@@ -246,3 +246,138 @@ export function shortenPath(abs, opts = {}) {
246
246
  return `.${abs.slice(cwd.length)}`;
247
247
  return abs;
248
248
  }
249
+ /** 空累加器:`state.live` 的初值,也是 e2e 折回的起点 */
250
+ export function emptyTraceAccumulator() {
251
+ return { lastSeq: 0, traceId: '', spans: [] };
252
+ }
253
+ /**
254
+ * 应用一条增量记账事件。返回**它有没有被用上**(false = 丢了)。
255
+ *
256
+ * 丢的三种情形都是刻意的、**都不抛**:
257
+ * - `seq` 不前进(重复投递、迟到的旧帧)—— SSE 不保证送达,但送达的只能应用一次;
258
+ * - 指向**没见过的 `spanId`**(面板连上得晚,那个 `span.begin` 没收到)—— 绝不能凭空建
259
+ * 节点:那会造出一棵缺了上半截的假树。收尾那份会补齐;
260
+ * - 形状不认识(框架将来加了新事件类型)—— 静默放过,别让整个右栏停摆。
261
+ */
262
+ export function applyTraceEvent(acc, ev) {
263
+ // 先卡 seq:它同时挡住「重复投递」与「乱序的旧帧」
264
+ if (!ev || typeof ev.seq !== 'number' || ev.seq <= acc.lastSeq)
265
+ return false;
266
+ const find = (spanId) => acc.spans.find((s) => s.spanId === spanId);
267
+ switch (ev.type) {
268
+ case 'span.begin': {
269
+ const src = ev.span;
270
+ // 拷一份:折回不该持有(更不该改写)协议对象 —— 面板会重画很多次
271
+ const copy = {
272
+ ...src,
273
+ attributes: { ...src.attributes },
274
+ events: (src.events ?? []).map((e) => ({ ...e })),
275
+ };
276
+ const seen = find(src.spanId);
277
+ // 同一个 `spanId` 再来一次 ⇒ **原地刷新**(重放幂等),不再挂一个同 id 的节点
278
+ if (seen)
279
+ Object.assign(seen, copy);
280
+ else {
281
+ acc.spans.push(copy);
282
+ if (!acc.traceId)
283
+ acc.traceId = src.traceId;
284
+ }
285
+ break;
286
+ }
287
+ case 'span.end': {
288
+ const s = find(ev.spanId);
289
+ if (!s)
290
+ return false;
291
+ s.endedAt = ev.endedAt;
292
+ s.status = ev.status;
293
+ if (ev.error)
294
+ s.error = { ...ev.error };
295
+ if (ev.usage)
296
+ s.usage = { ...ev.usage };
297
+ break;
298
+ }
299
+ case 'span.event': {
300
+ const s = find(ev.spanId);
301
+ if (!s)
302
+ return false;
303
+ s.events.push({ ...ev.event });
304
+ break;
305
+ }
306
+ case 'span.attribute': {
307
+ const s = find(ev.spanId);
308
+ if (!s)
309
+ return false;
310
+ s.attributes = { ...s.attributes, [ev.key]: ev.value };
311
+ break;
312
+ }
313
+ case 'span.link': {
314
+ const s = find(ev.spanId);
315
+ if (!s)
316
+ return false;
317
+ s.links = [...(s.links ?? []), ev.link];
318
+ break;
319
+ }
320
+ default:
321
+ return false;
322
+ }
323
+ acc.lastSeq = ev.seq;
324
+ return true;
325
+ }
326
+ /**
327
+ * 当前已知的这棵树(交给 `playTrace` 画)。返回的是**副本** —— 渲染层拿它排序 / 展开,
328
+ * 不该回头改到累加器(下一次重画还要用同一份账)。
329
+ *
330
+ * `status` 只区分**在飞**与**已收尾**:根 span 还没有 `endedAt` 就是「在飞」,渲染层据此
331
+ * **不收尾**(否则根会被画成「已在某一刻完成」,那是个假事实)。收尾后取根自己的状态
332
+ * (`ok` / `error`),与框架口径一致。
333
+ */
334
+ export function partialTrace(acc) {
335
+ const spans = acc.spans.map((s) => ({
336
+ ...s,
337
+ attributes: { ...s.attributes },
338
+ events: s.events.map((e) => ({ ...e })),
339
+ }));
340
+ const root = spans.find((s) => s.parentSpanId === null);
341
+ const status = root && root.endedAt === undefined ? 'running' : (root?.status ?? 'running');
342
+ return { traceId: acc.traceId, spans, status };
343
+ }
344
+ // ---------- ② 回复正文的归属 ----------
345
+ /**
346
+ * 「`open(id)` 该不该**保留**屏幕上这条回复」。
347
+ *
348
+ * 背景:`run-done` 把回复正文写上去之后,面板会**自动 open 刚跑完的那一轮** ——
349
+ * 而 `open()` 原本无条件清掉回复区,于是那句回复在十几毫秒后被自己擦掉(看起来像「没回复」)。
350
+ *
351
+ * 口径与 `open()` 的原意图对齐:只有「现在打开的就是这条回复的主人」才保留;其余一律清
352
+ * (打开一条历史 run 时若还挂着**别人**的回复,那是张冠李戴 —— 比空白更糟)。
353
+ * `replyFor` 为 null(还没跑过 / 已清)**不等于**「谁都对」:默认清。
354
+ */
355
+ export function replyBelongsTo(openId, replyFor) {
356
+ if (!openId || !replyFor)
357
+ return false;
358
+ return openId === replyFor;
359
+ }
360
+ // ---------- ③ 目录浏览 / 选文件 ----------
361
+ /**
362
+ * 点「浏览…」时该打开哪个目录:**总是**按输入框的值。
363
+ *
364
+ * 旧实现把它当**开关**(面板开着时再点一次就关掉),于是「在输入框敲了目标路径 → 点浏览…」
365
+ * 这个最自然的动作反而是把面板关掉,用户就以为「选不了别的目录」。空值才回落到缺省工作目录;
366
+ * 首尾空白不该让路径读错。
367
+ */
368
+ export function browseTarget(value, defaultDir) {
369
+ const v = value.trim();
370
+ return v.length > 0 ? v : defaultDir;
371
+ }
372
+ /**
373
+ * 点一个**文件**之后的 prompt 该是什么。
374
+ *
375
+ * 口径:**只在 prompt 为空时**填文件名(省一次打字);用户已经写好的话**一个字都不动** ——
376
+ * 悄悄改写用户输入比少填一次糟得多。`filled` 是给面板回话用的(提示文案不同)。
377
+ * 工作目录的切换(取该文件**所在的**目录)由调用方做,不在这个纯判定里。
378
+ */
379
+ export function promptAfterFilePick(current, name) {
380
+ if (current.trim().length > 0)
381
+ return { prompt: current, filled: false };
382
+ return { prompt: name, filled: true };
383
+ }
@@ -9,10 +9,10 @@
9
9
  "typecheck": "tsc --noEmit -p tsconfig.json"
10
10
  },
11
11
  "dependencies": {
12
- "@migor/agentia": "^0.9.0"
12
+ "@migor/agentia": "^0.9.1"
13
13
  },
14
14
  "devDependencies": {
15
- "@migor/cli": "^0.9.0",
15
+ "@migor/cli": "^0.9.1",
16
16
  "tsx": "^4.19.0",
17
17
  "typescript": "^7.0.2",
18
18
  "@types/node": "^22.0.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@migor/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Agentia 框架命令行工具:create 脚手架 + g 能力生成 + dev 本地可观测面板(trace inspector)",
5
5
  "type": "module",
6
6
  "bin": {