@spzxg/cli-ui 0.6.2 → 0.6.6

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
package/src/core/store.ts CHANGED
@@ -10,12 +10,18 @@
10
10
  *
11
11
  * 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
12
12
  */
13
- import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
14
- import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
15
- import { formatAnswersText } from "../ui/format.js";
16
- import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks.js";
17
- import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace.js";
18
- import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
13
+ import { clientId } from "./id";
14
+ import { connectionDraftScope } from "./identity";
15
+ import { notifyCallback } from "./callbacks";
16
+ import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client";
17
+ import { addTurnsIfAbsent, applyItems, applyOlderPage, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation";
18
+ import { formatAnswersText } from "../ui/format";
19
+ import { backgroundBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "./blocks";
20
+ import { cloneBoardView, reportBoardOwner, activateBoardOwner, selectedBoardOwner, mergeBoardHistory, rememberBoardFile } from "../board/context";
21
+ import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceWatchOptions } from "../board/fileWatch";
22
+ import type { WorkspaceChangeEvent, WorkspaceWatchStatus } from "./types";
23
+ import { createWorkspace, type BoardView, type CliWorkspace } from "../board/workspace";
24
+ import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types";
19
25
 
20
26
  export interface JobEntry {
21
27
  job: CenterJob | null;
@@ -46,6 +52,8 @@ export interface CliState {
46
52
  viewing: Record<string, { ref: BoardRef; off: boolean }>;
47
53
  /** 看板现在的样子(按对话键):正在看的文件、开着的文件页签、当前页签。没有看板时没有。 */
48
54
  boards: Record<string, BoardView>;
55
+ /** 最近看板背景,收起/卸载后保留,供下一条消息被动参考。 */
56
+ boardContext: Record<string, BoardView>;
49
57
  /** 业务方补充的项目背景(按对话键;"" = 还没建的新对话),随下一条消息发出(见 appendContext)。 */
50
58
  context: Record<string, ContextItem[]>;
51
59
  }
@@ -100,12 +108,27 @@ export type CliEvent =
100
108
  | { type: "board_ref"; key: string; refs: BoardRef[] }
101
109
  /** 看板上切了页签、打开 / 关掉了文件(view 为 null = 看板关了)。 */
102
110
  | { type: "board_change"; key: string; view: BoardView | null }
111
+ | { type: "board_context"; jobId: string; view: BoardView }
112
+ | WorkspaceChangeEvent
113
+ | WorkspaceWatchStatus
103
114
  /**
104
115
  * 补充背景变了:追加 / 去掉(pending 是还没发出的),或随消息发出了(sent 是这次发出的)。
105
116
  * key 是对话键;新对话还没建时是 ""。
106
117
  */
107
118
  | { type: "context"; key: string; pending: ContextItem[]; sent?: ContextItem[] };
108
119
 
120
+ /** Public workspace metadata must not be shared between business listeners. */
121
+ export function cloneWorkspaceEvent(event:CliEvent):CliEvent {
122
+ if(event.type==='files_changed')return cloneWorkspaceChange(event);
123
+ if(event.type==='files_watch')return {...event};
124
+ if(event.type==='board_ref')return {...event,refs:event.refs.map(ref=>({...ref}))};
125
+ if(event.type==='context')return {...event,pending:event.pending.map(item=>({...item})),...(event.sent?{sent:event.sent.map(item=>({...item}))}:{})};
126
+ if(event.type==='open')return {...event,target:{...event.target}};
127
+ if(event.type==='board_context')return {...event,view:cloneBoardView(event.view)};
128
+ if(event.type==='board_change')return {...event,view:event.view?cloneBoardView(event.view):null};
129
+ return event;
130
+ }
131
+
109
132
  /** 看板要打开的东西。 */
110
133
  export type OpenTarget = { kind: "file"; path: string } | { kind: "browser" } | { kind: "changes" } | { kind: "files"; path?: string }
111
134
  /** 宿主用 extraTabs 加的页签。 */
@@ -199,7 +222,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
199
222
  // 列表只放宿主给的 jobIds 和本页里新建 / 接续出来的对话:不从 center 发现,也不从本地存储恢复——
200
223
  // 不传就不加载,免得把这个产品线令牌下别的业务、别的用户的对话列出来。
201
224
 
202
- let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, context: {} };
225
+ let state: CliState = { selected: null, jobIds: [], jobs: {}, convs: {}, listLoading: false, listError: null, refs: {}, viewing: {}, boards: {}, boardContext: {}, context: {} };
203
226
  const listeners = new Set<() => void>();
204
227
  const eventListeners = new Set<(event: CliEvent) => void>();
205
228
  const settledTurns = new Set<string>();
@@ -210,9 +233,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
210
233
 
211
234
  const set = (next: Partial<CliState>) => {
212
235
  state = { ...state, ...next };
213
- for (const fn of listeners) fn();
236
+ for (const fn of [...listeners]) notifyCallback(fn, undefined);
237
+ };
238
+ // Business callbacks may synchronously navigate or register more listeners.
239
+ // Preserve emission order and keep a fixed audience for each notification.
240
+ const eventQueue:Array<{event:CliEvent;listeners:Array<(event:CliEvent)=>void>}>=[];
241
+ let dispatching=false;
242
+ const emit = (event:CliEvent) => {
243
+ eventQueue.push({event,listeners:[...eventListeners]});if(dispatching)return;
244
+ dispatching=true;
245
+ try{while(eventQueue.length){const next=eventQueue.shift()!;for(const fn of next.listeners)notifyCallback(fn,cloneWorkspaceEvent(next.event));}}
246
+ finally{dispatching=false;}
214
247
  };
215
- const emit = (event: CliEvent) => { for (const fn of eventListeners) fn(event); };
216
248
  const patchConv = (key: string, fn: (conv: ConvState) => ConvState) => {
217
249
  const prev = state.convs[key] ?? emptyConv(key);
218
250
  const next = fn(prev);
@@ -286,7 +318,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
286
318
 
287
319
  function removeJob(jobId: string) {
288
320
  const ids = state.jobIds.filter((id) => id !== jobId);
289
- set({ jobIds: ids, selected: state.selected === jobId ? null : state.selected });
321
+ set({ jobIds: ids, selected: state.selected && parseConvKey(state.selected).jobId === jobId ? null : state.selected });
290
322
  }
291
323
 
292
324
  /** 对话接续:旧 job 换成新 job(列表位置不变,选中跟过去)。oldId / newId 可以是对话键。 */
@@ -355,24 +387,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
355
387
  function connect(key: string) {
356
388
  const entry = streams.get(key);
357
389
  if (!entry || entry.refs <= 0) return;
390
+ const previous = entry.stop;
391
+ entry.stop = null;
392
+ previous?.();
393
+ if (streams.get(key) !== entry || entry.refs <= 0) return;
358
394
  const { jobId, taskId } = parseConvKey(key);
359
- patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
360
- let ended = false;
361
- entry.stop = client.stream(jobId, {
362
- onOpen: () => { entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
363
- onSnapshot: (snapshot) => patchConv(key, (conv) => applySnapshot(conv, snapshot)),
364
- onChunk: (items) => patchConv(key, (conv) => applyItems(conv, items)),
365
- onEnd: (end) => {
366
- ended = true;
367
- // 接续的新对话:指定了对话的跟到接续它的那个对话(还没有就不跟,再发消息时 center 会开出来);只有 job_id 的照旧跟新 job。
368
- const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
369
- patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
370
- void refreshJob(jobId, true);
371
- },
372
- onClose: (error) => {
373
- entry.stop = null;
374
- const current = streams.get(key);
375
- if (!current || current.refs <= 0) return;
395
+ let ended = false, retired = false;
396
+ let cleanup: (() => void) | null = null;
397
+ const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
398
+ const active = () => !retired && owns();
399
+ const retire = () => {
400
+ if (retired) return;
401
+ retired = true;
402
+ if (entry.stop === retire) entry.stop = null;
403
+ const dispose = cleanup; cleanup = null;
404
+ notifyCallback(dispose ?? undefined, undefined);
405
+ };
406
+ const close = (error: CenterError | null) => {
407
+ if (!active()) return;
408
+ retire();
409
+ const current = streams.get(key);
410
+ if (current !== entry || current.refs <= 0 || current.stop) return;
376
411
  // 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
377
412
  if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
378
413
  // 404:对话不存在,重连也没用。
@@ -393,8 +428,32 @@ export function createCliStore(options: CliStoreOptions = {}) {
393
428
  current.attempt += 1;
394
429
  patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
395
430
  current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
396
- },
397
- }, undefined, taskId);
431
+ };
432
+ entry.stop = retire;
433
+ patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
434
+ if (!active()) return;
435
+ try {
436
+ cleanup = client.stream(jobId, {
437
+ onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
438
+ onSnapshot: (snapshot) => { if (active()) patchConv(key, (conv) => applySnapshot(conv, snapshot)); },
439
+ onChunk: (items) => { if (active()) patchConv(key, (conv) => applyItems(conv, items)); },
440
+ onEnd: (end) => {
441
+ if (!active()) return;
442
+ ended = true;
443
+ const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
444
+ patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
445
+ void refreshJob(jobId, true);
446
+ },
447
+ onClose: close,
448
+ }, undefined, taskId);
449
+ // Some custom transports close synchronously before returning cleanup.
450
+ if (retired || !owns()) {
451
+ const dispose = cleanup; cleanup = null;
452
+ notifyCallback(dispose ?? undefined, undefined);
453
+ }
454
+ } catch (error) {
455
+ close(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
456
+ }
398
457
  }
399
458
 
400
459
  // 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
@@ -443,16 +502,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
443
502
  void refreshJob(jobId);
444
503
  connect(key);
445
504
  }
505
+ const owned = entry;
506
+ let active = true;
446
507
  return () => {
508
+ if (!active) return;
509
+ active = false;
447
510
  const current = streams.get(key);
448
- if (!current) return;
511
+ if (current !== owned) return;
449
512
  current.refs -= 1;
450
513
  if (current.refs > 0) return;
451
- current.stop?.();
452
- if (current.timer) clearTimeout(current.timer);
514
+ // Retire the registry before calling host cleanup: it may synchronously
515
+ // acquire a new lease for the same task.
453
516
  streams.delete(key);
517
+ if (current.timer) { clearTimeout(current.timer); current.timer = null; }
454
518
  syncLive();
455
- patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
519
+ current.stop?.();
520
+ if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
456
521
  };
457
522
  }
458
523
 
@@ -523,6 +588,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
523
588
  return target;
524
589
  }
525
590
  if (!key || !ref) {
591
+ const selectedBeforeCreate = state.selected;
526
592
  let newId: string;
527
593
  if (createConversation) {
528
594
  newId = await createConversation(text);
@@ -534,7 +600,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
534
600
  }
535
601
  addJob(newId);
536
602
  // 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
537
- if (overrides.select !== false) set({ selected: newId });
603
+ if (overrides.select !== false && state.selected === selectedBeforeCreate) set({ selected: newId });
538
604
  emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
539
605
  void refreshJob(newId, true);
540
606
  return newId;
@@ -666,7 +732,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
666
732
 
667
733
  function setRefs(key: string, refs: BoardRef[]) {
668
734
  const next = { ...state.refs };
669
- if (refs.length) next[key] = refs; else delete next[key];
735
+ if (refs.length) next[key] = refs.map(ref => ({ ...ref })); else delete next[key];
670
736
  set({ refs: next });
671
737
  emit({ type: "board_ref", key, refs });
672
738
  }
@@ -723,15 +789,44 @@ export function createCliStore(options: CliStoreOptions = {}) {
723
789
  }
724
790
 
725
791
  // ── 看板现在的样子(给业务方:当前打开的文件等)────────
726
- function reportBoard(key: string, view: BoardView | null, owner = "default") {
727
- const top = latestReport(boardSources, key, owner, view ? { ...view, files: [...view.files] } : null);
728
- const cur = state.boards[key];
729
- if (!top && !cur) return;
730
- if (top && cur && cur.tab === top.tab && cur.dir === top.dir && cur.file === top.file && cur.files.join("\n") === top.files.join("\n")) return;
731
- const next = { ...state.boards };
732
- if (top) next[key] = top; else delete next[key];
733
- set({ boards: next });
734
- emit({ type: "board_change", key, view: top ? { ...top, files: [...top.files] } : null });
792
+ function applyBoardSelection(key:string,top:BoardView|null,history:import("../board/workspace").BoardFile[]) {
793
+ const current=state.boards[key],previous=state.boardContext[key];
794
+ const boardChanged=JSON.stringify(top)!==JSON.stringify(current??null);
795
+ const retained=top?{...cloneBoardView(top),history,visible:top.visible!==false}:previous?{...cloneBoardView(previous),history,visible:false}:null;
796
+ const contextChanged=!!retained&&JSON.stringify(previous)!==JSON.stringify(retained);
797
+ if(!boardChanged&&!contextChanged)return;
798
+ const boards={...state.boards};if(top)boards[key]=cloneBoardView(top);else delete boards[key];
799
+ // Commit both public views before subscribers can read them or navigate again.
800
+ set({...(boardChanged?{boards}:{}),...(contextChanged?{boardContext:{...state.boardContext,[key]:retained!}}:{})});
801
+ if(boardChanged&&state.boards[key]===boards[key])emit({type:"board_change",key,view:top?cloneBoardView(top):null});
802
+ // A reentrant callback may already have selected a newer file. Do not emit
803
+ // or restore the older automatic context over that newer selection.
804
+ if(contextChanged&&state.boardContext[key]===retained)emit({type:"board_context",jobId:key,view:cloneBoardView(retained!)});
805
+ }
806
+ function reportBoard(key:string,view:BoardView|null,owner="default") {
807
+ const sources=boardSources.get(key)??new Map<string,BoardView>();
808
+ let history=state.boardContext[key]?.history??[];
809
+ if(view){history=mergeBoardHistory(history,sources.get(owner),view);reportBoardOwner(sources,owner,cloneBoardView(view));}
810
+ else sources.delete(owner);
811
+ if(sources.size)boardSources.set(key,sources);else boardSources.delete(key);
812
+ const top=selectedBoardOwner(sources)?.[1]??null;applyBoardSelection(key,top,history);
813
+ }
814
+ function activateBoard(key:string,owner:string) {
815
+ const sources=boardSources.get(key);if(!sources)return;
816
+ const top=activateBoardOwner(sources,owner)?.[1];if(!top)return;
817
+ const file=top.current??(top.file?{path:top.file,sourceId:key}:null);
818
+ const history=file?rememberBoardFile(state.boardContext[key]?.history??[],file):state.boardContext[key]?.history??[];
819
+ applyBoardSelection(key,top,history);
820
+ }
821
+ const fileSubscriptions = new Map<string,{listeners:Set<{change?:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']}>;stop:()=>void;status?:WorkspaceWatchStatus}>();
822
+ function watchWorkspace(key:string,listener?:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}) {
823
+ if(!client.board) throw new Error("当前后端不支持工作区文件订阅");
824
+ const item={change:listener,status:options.onStatus};let subscription=fileSubscriptions.get(key);
825
+ if(!subscription){
826
+ subscription={listeners:new Set([item]),stop:()=>{}};fileSubscriptions.set(key,subscription);const owned=subscription;
827
+ owned.stop=subscribeWorkspaceFiles(client.board,key,event=>{emit(event);for(const next of [...owned.listeners])notifyCallback(next.change,cloneWorkspaceChange(event));},{...options,onStatus:status=>{owned.status=status;emit(status);for(const next of [...owned.listeners])notifyCallback(next.status,{...status});}});
828
+ }else{subscription.listeners.add(item);if(subscription.status)notifyCallback(item.status,{...subscription.status});retryWorkspaceFiles(client.board,key);}
829
+ const owned=subscription;let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(item);if(!owned.listeners.size){if(fileSubscriptions.get(key)===owned)fileSubscriptions.delete(key);owned.stop();}};
735
830
  }
736
831
 
737
832
  // ── 补充背景:业务方随时追加,随下一条消息发给智能体 ────────
@@ -764,7 +859,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
764
859
  if (next.length !== list.length) setContext(k, next);
765
860
  }
766
861
  /** 还没发出的补充背景。 */
767
- const pendingContext = (key: string | null): ContextItem[] => [...(state.context[ctxKey(key)] ?? [])];
862
+ const pendingContext = (key: string | null): ContextItem[] => (state.context[ctxKey(key)] ?? []).map(item => ({ ...item }));
768
863
 
769
864
  function workspace(key?: string | null): CliWorkspace {
770
865
  const k = key ?? state.selected;
@@ -773,6 +868,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
773
868
  backend: client.board,
774
869
  key: k,
775
870
  view: () => state.boards[k],
871
+ context: () => state.boardContext[k] ? cloneBoardView(state.boardContext[k]) : null,
872
+ watch: (listener, options) => watchWorkspace(k,listener,options),
776
873
  open: (path) => openInBoard(k, { kind: "file", path }),
777
874
  });
778
875
  }
@@ -795,13 +892,18 @@ export function createCliStore(options: CliStoreOptions = {}) {
795
892
 
796
893
  function openInBoard(jobId: string, target: OpenTarget): boolean {
797
894
  let handled = false;
798
- for (let i = surfaces.length - 1; i >= 0 && !handled; i--) handled = surfaces[i](jobId, target);
895
+ for (const surface of [...surfaces].reverse()) {
896
+ try { handled = surface(jobId, { ...target }); }
897
+ catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
898
+ if (handled) break;
899
+ }
799
900
  emit({ type: "open", jobId, target, handled });
800
901
  return handled;
801
902
  }
802
903
 
803
904
  return {
804
905
  client,
906
+ draftScope: connectionDraftScope(options),
805
907
  getState: () => state,
806
908
  /** 在看板里打开文件 / 浏览器 / 改动。返回有没有看板接住。 */
807
909
  openInBoard,
@@ -821,11 +923,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
821
923
  addRef, removeRef, takeRefs, restoreRefs,
822
924
  setViewing, dismissViewing, takeViewing, restoreViewing,
823
925
  /** 看板报告自己的样子(看板组件用)。 */
824
- reportBoard,
926
+ reportBoard, activateBoard,
825
927
  /** 业务方追加项目背景:随下一条消息发给智能体。 */
826
928
  appendContext, removeContext, pendingContext,
827
929
  /** 工作区文件接口(不显示看板也能用):列文件、读文件、下载、打包。不传 = 当前选中的对话。 */
828
- workspace,
930
+ workspace, watchWorkspace,
829
931
  open, send, answerAsk, cancelTurn, close, loadOlder,
830
932
  };
831
933
  }
@@ -854,8 +956,4 @@ async function retryOnce<T>(fn: () => Promise<T>): Promise<T> {
854
956
  }
855
957
  }
856
958
 
857
- function uid() {
858
- const cryptoObj = globalThis.crypto as Crypto | undefined;
859
- if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
860
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
861
- }
959
+ const uid = clientId;
package/src/core/types.ts CHANGED
@@ -43,7 +43,13 @@ export interface CenterTurn {
43
43
  queue_position?: number | null;
44
44
  }
45
45
 
46
- export interface CenterTask {
46
+ /** Node-observed human wait state. Missing/null means unavailable, not confirmed idle. */
47
+ export interface AwaitingInputState {
48
+ awaiting_input?: boolean | null;
49
+ awaiting_input_observed_at?: string | null;
50
+ }
51
+
52
+ export interface CenterTask extends AwaitingInputState {
47
53
  id: string;
48
54
  job_id: string;
49
55
  seq?: number;
@@ -63,7 +69,7 @@ export interface CenterTask {
63
69
  }
64
70
 
65
71
  /** 对话任务里的一个对话(一个子任务、一个 Agent 会话)。conversation.conversations 的一项。 */
66
- export interface ConversationInfo {
72
+ export interface ConversationInfo extends AwaitingInputState {
67
73
  task_id: string;
68
74
  seq: number;
69
75
  title: string;
@@ -79,7 +85,7 @@ export interface ConversationInfo {
79
85
  created_at: string | null;
80
86
  }
81
87
 
82
- export interface ConversationSummary {
88
+ export interface ConversationSummary extends AwaitingInputState {
83
89
  /** 里面的每个对话(按开的先后)。旧版 center 没有这个字段:当作只有一个对话。 */
84
90
  conversations?: ConversationInfo[];
85
91
  turns: number;
@@ -117,6 +123,12 @@ export interface SendResult {
117
123
  turn: CenterTurn;
118
124
  }
119
125
 
126
+ /** 每次浏览器操作前的随机等待范围;合法范围由任务中心与节点共同校验。 */
127
+ export interface BrowserPacing {
128
+ min_seconds: number;
129
+ max_seconds: number;
130
+ }
131
+
120
132
  /** 新建对话时可带的参数(POST /api/jobs,mode=conversation)。 */
121
133
  export interface NewConversationOptions {
122
134
  title?: string;
@@ -127,6 +139,10 @@ export interface NewConversationOptions {
127
139
  agent?: string;
128
140
  timeoutSeconds?: number;
129
141
  browser?: boolean;
142
+ /** Ego 操作手册的 YAML 正文(不是文件名或 URL);必须显式 browser:true。 */
143
+ browserPlaybook?: string;
144
+ /** 转发至节点执行器和浏览器脚本;必须显式 browser:true。 */
145
+ browserPacing?: BrowserPacing;
130
146
  }
131
147
 
132
148
  // ── Operator:节点转录 ─────────────────────────────────────────────
@@ -213,8 +229,8 @@ export type NodeEvent =
213
229
  | { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
214
230
  | { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
215
231
  | { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
216
- | { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string }
217
- | { type: "input"; input: InputRequest; msgId?: string; generation?: number }
232
+ | { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
233
+ | { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
218
234
  | { type: "usage"; usage: TurnUsage }
219
235
  | { type: "notice"; content: string; msgId?: string; generation?: number }
220
236
  | { type: "error"; content: string; msgId?: string; generation?: number }
@@ -233,7 +249,7 @@ export type StreamItem =
233
249
  | { type: "task"; task: StreamTask | BatchStreamTask };
234
250
 
235
251
  /** 实时流看的那个对话(GET /api/jobs/:id/stream?task=)。 */
236
- export interface StreamTask { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
252
+ export interface StreamTask extends AwaitingInputState { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
237
253
 
238
254
  export interface StreamSnapshot {
239
255
  job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null; mode: string; title: string; conversation: ConversationSummary };
@@ -246,7 +262,7 @@ export interface StreamSnapshot {
246
262
  * 一次性任务(含续派)的子任务,实时流里的样子。cli-chat 只读查看这类任务时用。
247
263
  * prompt / opener_prompt:指定了 ?task= 时看的那个子任务才有(opener_prompt = 开出它所在节点对话的那个子任务的原文)。
248
264
  */
249
- export interface BatchStreamTask {
265
+ export interface BatchStreamTask extends AwaitingInputState {
250
266
  id: string;
251
267
  seq: number;
252
268
  task_key: string | null;
@@ -380,6 +396,8 @@ export interface FileEntry {
380
396
  type: "dir" | "file";
381
397
  size: number;
382
398
  mtime: number;
399
+ /** Opaque metadata fingerprint for reconnect reconciliation, not an immutable content version. */
400
+ changeToken?: string;
383
401
  }
384
402
 
385
403
  export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
@@ -424,15 +442,52 @@ export interface BrowserStatus {
424
442
  }
425
443
 
426
444
  /** 浏览器画面流里的一条消息(节点原样转发)。 */
427
- export type BrowserMessage =
445
+ export interface BrowserTab { id: string; title: string; url: string }
446
+ /** 新节点用页面身份隔离输入;旧节点可以省略这两个字段。 */
447
+ export interface BrowserTarget { targetId?: string; targetEpoch?: number }
448
+ export type BrowserMessage = BrowserTarget & (
428
449
  | { type: "frame"; data: string; metadata?: { deviceWidth: number; deviceHeight: number } }
429
450
  | { type: "page"; url?: string; title?: string }
451
+ /** 只包含当前任务内的网页;只有明确声明 canSelect 才显示可切换标签。 */
452
+ | { type: "tabs"; tabs: BrowserTab[]; activeId: string | null; canSelect?: boolean }
430
453
  | { type: "error"; message?: string }
431
454
  /** 按了复制 / 剪切:选中的文字(只发给按键的人),写进本机剪贴板。 */
432
455
  | { type: "clipboard"; text: string }
433
456
  /** 网页弹出了 alert / confirm / prompt(画面里看不到,单独显示)。 */
434
457
  | { type: "dialog"; dialogType: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
435
- | { type: "dialog_closed" };
458
+ | { type: "dialog_closed" });
436
459
 
437
460
  /** 浏览器实时连接(GET /browser/live)的画面连接状况。 */
438
461
  export type BrowserFramesState = { state: "idle" | "connecting" | "live" | "retrying"; detail?: string };
462
+
463
+
464
+ export interface WorkspaceFileChange {
465
+ kind: "created" | "modified" | "deleted";
466
+ path: string;
467
+ file?: FileEntry;
468
+ previous?: FileEntry;
469
+ }
470
+ export type WorkspaceFileBatch =
471
+ | { type: "snapshot"; files: FileEntry[]; revision?: string; complete: boolean }
472
+ | { type: "changes"; changes: WorkspaceFileChange[]; revision?: string; complete: boolean }
473
+ | { type: "resync"; reason: string; complete: false; revision?: string };
474
+ export interface WorkspaceChangeEvent {
475
+ type: "files_changed";
476
+ /** 对话键;节点适配下是 task.id。 */
477
+ jobId: string;
478
+ mode: "events" | "poll";
479
+ coverage: "workspace" | "listed-files";
480
+ complete: boolean;
481
+ changes: WorkspaceFileChange[];
482
+ revision?: string;
483
+ resync?: boolean;
484
+ }
485
+ export interface WorkspaceWatchStatus {
486
+ type: "files_watch";
487
+ jobId: string;
488
+ state: "connecting" | "ready" | "reconnecting" | "error";
489
+ mode: "events" | "poll";
490
+ coverage: "workspace" | "listed-files";
491
+ complete: boolean;
492
+ error?: string;
493
+ }