@sanqianx/claude-ai-workbench 0.13.2-rc.21 → 0.13.2-rc.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.
Files changed (45) hide show
  1. package/apps/agent-terminal/app.js +289 -23
  2. package/apps/agent-terminal/index.html +6 -0
  3. package/apps/agent-terminal/panel-host-proof.html +152 -0
  4. package/apps/agent-terminal/panel-proof-content.html +41 -0
  5. package/apps/agent-terminal/right-dock-panel-registry.js +216 -0
  6. package/apps/agent-terminal/right-workspace-dock.css +159 -0
  7. package/apps/agent-terminal/right-workspace-dock.js +1929 -0
  8. package/docs/sdk/SESSION_EVENTS.md +2 -0
  9. package/docs/sdk/openapi.yaml +343 -1
  10. package/package.json +2 -2
  11. package/packages/client/README.md +18 -0
  12. package/packages/client/index.d.ts +33 -0
  13. package/packages/client/index.js +36 -1
  14. package/packages/client/package.json +2 -2
  15. package/packages/contracts/index.d.ts +219 -0
  16. package/packages/contracts/index.js +12 -0
  17. package/packages/contracts/package.json +1 -1
  18. package/packages/core/package.json +2 -2
  19. package/packages/server/bin/claude-workbench-server.js +5 -2
  20. package/packages/server/index.d.ts +23 -1
  21. package/packages/server/index.js +30 -7
  22. package/packages/server/lib/agent-registry.js +42 -0
  23. package/packages/server/lib/context-resolver.js +15 -0
  24. package/packages/server/lib/delegation-phase-store.js +357 -0
  25. package/packages/server/lib/delegation-store.js +468 -0
  26. package/packages/server/lib/event-stream.js +10 -1
  27. package/packages/server/lib/http-handler.js +36 -1
  28. package/packages/server/lib/runtime-loader.js +120 -0
  29. package/packages/server/lib/session-history.js +446 -18
  30. package/packages/server/lib/workbench-service.js +856 -8
  31. package/packages/server/package.json +2 -2
  32. package/packages/ui/package.json +2 -2
  33. package/src/backend/lib/claude-cli-runner.js +472 -16
  34. package/src/backend/lib/native-task-control.js +217 -0
  35. package/src/backend/lib/native-task-history-index.js +475 -0
  36. package/src/backend/lib/native-task-history-reader.js +526 -0
  37. package/src/backend/lib/native-task-messages.js +457 -0
  38. package/src/backend/lib/native-task-registry.js +325 -0
  39. package/src/backend/lib/native-task-runtime.js +242 -0
  40. package/src/backend/lib/native-task-trace.js +440 -0
  41. package/src/backend/lib/native-task-transcript.js +393 -0
  42. package/src/backend/lib/sdk-session-stream.js +33 -13
  43. package/src/backend/lib/store-scope.js +131 -0
  44. package/src/backend/lib/task-identity.js +104 -0
  45. package/src/backend/lib/task-state-projection.js +58 -0
@@ -103,6 +103,23 @@ function embeddedTransport() {
103
103
  const client = window.createClaudeWorkbenchClient
104
104
  ? window.createClaudeWorkbenchClient({ apiBase, ...embeddedTransport() })
105
105
  : null;
106
+ // The dock is a feature-detected add-on: sandboxes and older hosts without
107
+ // the module keep working with dock === null. W3 adds the panel registry the
108
+ // same way - absent module means native-only behavior, never a broken page.
109
+ const panelRegistry = window.createRightDockPanelRegistry
110
+ ? window.createRightDockPanelRegistry({ document, baseUrl: typeof location !== 'undefined' && location.href ? location.href : null })
111
+ : null;
112
+ const hostPanelOwner = panelRegistry ? panelRegistry.createOwner({ role: 'embed-host' }) : null;
113
+ const dock = window.createRightWorkspaceDock && client
114
+ ? window.createRightWorkspaceDock({
115
+ client, apiBase, document,
116
+ storage: typeof localStorage !== 'undefined' ? localStorage : null,
117
+ renderMarkdown, toast,
118
+ registry: panelRegistry,
119
+ onPanelsChanged: reason => postPanelState(reason),
120
+ onBackgroundActivityChanged: activity => renderSessionStatus(activity),
121
+ })
122
+ : null;
106
123
 
107
124
  const PERMISSION_LABELS = { default: '默认防护', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: '完全访问' };
108
125
  const EFFORT_LABELS = { low: '低', medium: '中', high: '高', max: '最高' };
@@ -203,6 +220,15 @@ function savePreferences(partial) {
203
220
  }, 300);
204
221
  }
205
222
 
223
+ // Health can resolve after a session is already attached; the dock then holds
224
+ // a null storeScope and every read answers TASK_IDENTITY_UNAVAILABLE. Re-bind
225
+ // the live session's scope to the now-known identity.
226
+ function rebindDockIdentity() {
227
+ if (!dock || !state.activeSessionId) return;
228
+ const identity = state.taskIdentity && state.taskIdentity.storeScope ? state.taskIdentity.storeScope : null;
229
+ dock.refreshIdentity(identity);
230
+ }
231
+
206
232
  /* ===================== init ===================== */
207
233
 
208
234
  async function init() {
@@ -216,19 +242,24 @@ async function init() {
216
242
  const healthJob = client.health().then(health => {
217
243
  $('app-version').textContent = `v${health.version}`;
218
244
  $('about-text').textContent = `Agent Terminal v${health.version} · API ${health.apiVersion} · ${health.sessions} 个会话`;
245
+ state.taskIdentity = health.taskIdentity || null;
246
+ rebindDockIdentity();
247
+ if (dock && state.activeSessionId) dock.handleResync();
219
248
  }).catch(() => {});
220
249
  const preferences = loadPreferences();
221
250
  const projects = preferences.then(() => refreshProjects()).catch(error => toast(`项目读取失败:${error.message}`, true));
222
251
  await Promise.allSettled([healthJob, refreshAgents(), refreshProfiles(), projects]);
223
252
  window.addEventListener('beforeunload', () => {
224
253
  saveCurrentDraft();
254
+ if (dock) dock.flushPersist();
225
255
  for (const stop of summaryStreams.values()) stop();
226
256
  if (state.unsub) state.unsub();
227
257
  });
228
258
  if (!state.activeSessionId) $('session-title').textContent = '选择或创建一个会话';
229
259
  // Ready is the usable-page boundary: the host may immediately select a
230
260
  // project or open settings, so data and handlers must both be available.
231
- if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*');
261
+ // Panel registrations before this point are late arrivals and stay inert.
262
+ if (window.parent !== window) { embedPanelsReady = true; window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*'); }
232
263
  }
233
264
 
234
265
  /* ===================== agents & capabilities ===================== */
@@ -754,6 +785,7 @@ function receiveSummary(id, event) {
754
785
  sessionListDirty.delete(id);
755
786
  }
756
787
  else if (event.type === 'workbench/session-changed') {
788
+ if (dock && typeof dock.notifySessionChanged === 'function') dock.notifySessionChanged(event.session);
757
789
  sessionListJobs.get(id)?.changes.set(event.session.sessionId, event);
758
790
  if (!sessionLists.has(id)) sessionListDirty.add(id);
759
791
  rows = [...(sessionLists.get(id) || [])].filter(s => s.sessionId !== event.session.sessionId);
@@ -911,6 +943,11 @@ async function openSession(sessionId, options = {}) {
911
943
  $('chat-input').disabled = false;
912
944
  state.sessionSummary = summary;
913
945
  restoreBackgroundTasks(summary.tasks || snapshot.tasks || []);
946
+ if (dock) dock.attachSession({
947
+ storeScope: state.taskIdentity?.storeScope || null,
948
+ contextId: summary.contextId || state.activeProjectId || null,
949
+ parentSessionId: sessionId,
950
+ });
914
951
  if (summary.contextId && summary.contextId !== state.activeProjectId) {
915
952
  state.activeProjectId = summary.contextId;
916
953
  restoreDraft(state.activeProjectId, sessionId);
@@ -945,6 +982,7 @@ async function openSession(sessionId, options = {}) {
945
982
  state.unsub = client.subscribe(sessionId, event => {
946
983
  if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
947
984
  if (event.type === 'workbench/resync') {
985
+ if (dock) dock.handleResync(event);
948
986
  if (historyView?.supported && !historyView.error) {
949
987
  last = Number(event.highWater) || 0;
950
988
  // The replay cache is not conversation truth. Re-read durable history
@@ -967,6 +1005,7 @@ async function openSession(sessionId, options = {}) {
967
1005
  if (state.replaying && !(Number.isFinite(sequence) && sequence > 0 && sequence <= replayThrough)) state.replaying = false;
968
1006
  if (historyView?.sessionId === sessionId) historyView.events.push(event);
969
1007
  state.liveRevision = (state.liveRevision || 0) + 1;
1008
+ if (dock) dock.handleSessionEvent(event);
970
1009
  renderEvent(event);
971
1010
  }, () => {}, { afterSequence: last, onOpen: () => {
972
1011
  if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
@@ -1051,7 +1090,6 @@ async function reloadHistory(view) {
1051
1090
  const oldest = view.events.find(event => event.type === 'claude/user-prompt') || view.events[0];
1052
1091
  const oldestKey = oldest && historyEventKey(oldest);
1053
1092
  const buffered = view.events;
1054
- const liveStart = buffered.length;
1055
1093
  try {
1056
1094
  let page = await client.loadHistory(view.sessionId);
1057
1095
  let events = page.events, nextCursor = page.nextCursor;
@@ -1066,7 +1104,12 @@ async function reloadHistory(view) {
1066
1104
  if (historyView !== view || revision !== view.revision) return;
1067
1105
  }
1068
1106
  if (historyView !== view || view.epoch !== sessionEpoch || revision !== view.revision) return;
1069
- const queued = buffered.slice(liveStart).filter(event => Number(event.sequence) > page.highWater);
1107
+ // Union by row key: live rows received before the first page returned
1108
+ // (including before this reload even started) survive unless the fetched
1109
+ // history already contains them. The fetched page must never overwrite
1110
+ // the live tail into silence.
1111
+ const fetchedKeys = new Set(events.map(historyEventKey));
1112
+ const queued = buffered.filter(event => !fetchedKeys.has(historyEventKey(event)));
1070
1113
  view.nextCursor = nextCursor; view.incomplete = page.incomplete; view.error = false;
1071
1114
  rebuildHistory(view, [...events, ...queued], false);
1072
1115
  } catch { if (historyView === view) view.error = true; }
@@ -1085,6 +1128,7 @@ function detachSession(options = {}) {
1085
1128
  state.sessionSummary = null;
1086
1129
  state.loadingSession = false;
1087
1130
  $('chat-input').disabled = false;
1131
+ if (dock) dock.detachSession();
1088
1132
  restoreBackgroundTasks([]);
1089
1133
  setBusyState('idle');
1090
1134
  historyView = null;
@@ -1346,9 +1390,24 @@ function createPacer(render) {
1346
1390
  }
1347
1391
 
1348
1392
  const backgroundTasks = new Map();
1393
+ function trustedTaskRef(ref) {
1394
+ if (!ref || typeof ref !== 'object' || ref.kind !== 'native-task') return null;
1395
+ for (const field of ['storeScope', 'contextId', 'parentSessionId', 'ownerRunId', 'taskId']) {
1396
+ if (typeof ref[field] !== 'string' || ref[field].trim() === '') return null;
1397
+ }
1398
+ return { kind: 'native-task', storeScope: ref.storeScope, contextId: ref.contextId, parentSessionId: ref.parentSessionId, ownerRunId: ref.ownerRunId, taskId: ref.taskId };
1399
+ }
1400
+ function taskRowKey(ref) { return JSON.stringify([ref.storeScope, ref.contextId, ref.parentSessionId, ref.ownerRunId, ref.taskId]); }
1349
1401
  function restoreBackgroundTasks(tasks) {
1350
1402
  backgroundTasks.clear();
1351
- for (const task of tasks) backgroundTasks.set(task.taskId, task);
1403
+ // Only a trusted native-task ref anchors a row; bare id rows remain
1404
+ // status echoes that can never grow into an interactive identity.
1405
+ for (const task of Array.isArray(tasks) ? tasks : []) {
1406
+ if (!task || typeof task !== 'object') continue;
1407
+ const ref = trustedTaskRef(task.taskRef);
1408
+ if (!ref && !task.taskId) continue;
1409
+ backgroundTasks.set(ref ? taskRowKey(ref) : 'bare|' + task.taskId, { ...task, taskRef: ref });
1410
+ }
1352
1411
  renderBackgroundTasks();
1353
1412
  }
1354
1413
  function renderBackgroundTasks() {
@@ -1363,11 +1422,8 @@ function renderBackgroundTasks() {
1363
1422
  if (unsupported) { panel.append(el('p', 'task-detail', '当前 Agent 未提供后台任务状态;文字声明不能证明任务已启动。')); return; }
1364
1423
  const running = [...backgroundTasks.values()].filter(t => ['running', 'pending'].includes(t.status)).length;
1365
1424
  panel.append(el('strong', null, `后台任务 · ${running} 个运行中`));
1366
- if (running) {
1367
- const stop = el('button', 'linklike', '停止本会话任务');
1368
- stop.addEventListener('click', stopCurrent);
1369
- panel.append(stop);
1370
- }
1425
+ // 单个后台任务的精确停止 API 未交付:本切片没有任何停止入口,也不以
1426
+ // 主会话 abort 冒充(stopCurrent 仅属于前台发送按钮)。
1371
1427
  for (const task of backgroundTasks.values()) {
1372
1428
  const card = el('article', 'task-card');
1373
1429
  card.dataset.taskId = task.taskId;
@@ -1376,15 +1432,44 @@ function renderBackgroundTasks() {
1376
1432
  const detail = [task.lastToolName && `最近工具:${task.lastToolName}`, task.summary,
1377
1433
  task.usage && `工具调用 ${task.usage.tool_uses ?? '未知'} · 用时 ${task.usage.duration_ms ?? '未知'}ms`].filter(Boolean).join('\n');
1378
1434
  if (detail) card.append(el('div', 'task-detail', detail));
1435
+ if (task.taskRef && dock) {
1436
+ const open = el('button', 'linklike', '在右侧任务面板查看');
1437
+ open.addEventListener('click', () => { dock.openTask(task.taskRef); });
1438
+ card.append(open);
1439
+ }
1379
1440
  panel.append(card);
1380
1441
  }
1381
1442
  }
1382
1443
  function renderEvent(event) {
1444
+ if (event.type === 'claude/native-task-state' || event.type === 'claude/native-task-transcript') {
1445
+ // Native facts render in the dock; the strip only mirrors a status line
1446
+ // when the event carries a trusted taskRef, and terminal states stick.
1447
+ const ref = trustedTaskRef(event.taskRef);
1448
+ if (ref) {
1449
+ const key = taskRowKey(ref);
1450
+ const old = backgroundTasks.get(key) || { taskRef: ref, taskId: ref.taskId };
1451
+ if (!(['completed', 'failed', 'stopped'].includes(old.status) && ['running', 'pending'].includes(event.status))) {
1452
+ backgroundTasks.set(key, { ...old, taskRef: ref, taskId: ref.taskId,
1453
+ taskKind: event.taskKind || old.taskKind, taskType: event.taskType || old.taskType,
1454
+ status: event.status || old.status, summary: event.summary || old.summary });
1455
+ }
1456
+ renderBackgroundTasks();
1457
+ }
1458
+ return;
1459
+ }
1383
1460
  if (/^claude\/task-(started|progress|updated|notification)$/.test(event.type)) {
1384
- const old = backgroundTasks.get(event.taskId) || {};
1385
- if (['completed', 'failed', 'stopped'].includes(old.status) && ['running', 'pending'].includes(event.status)) return;
1386
- backgroundTasks.set(event.taskId, { ...old, ...event });
1387
- renderBackgroundTasks();
1461
+ // Bare echoes cannot create identity: they merge only into rows that
1462
+ // already exist with the same taskId, and terminal states stick.
1463
+ if (event.taskId) {
1464
+ let touched = false;
1465
+ for (const [key, old] of backgroundTasks) {
1466
+ if (old.taskId !== event.taskId) continue;
1467
+ if (['completed', 'failed', 'stopped'].includes(old.status) && ['running', 'pending'].includes(event.status)) continue;
1468
+ backgroundTasks.set(key, { ...old, ...event, taskRef: old.taskRef });
1469
+ touched = true;
1470
+ }
1471
+ if (touched) renderBackgroundTasks();
1472
+ }
1388
1473
  return;
1389
1474
  }
1390
1475
  // Subagent text/tool streams belong to task cards, never the foreground turn.
@@ -2052,21 +2137,40 @@ function fmtTokens(value) {
2052
2137
  function setBusyState(sessionState) {
2053
2138
  state.sessionState = sessionState;
2054
2139
  state.busy = ['running', 'thinking', 'tool-running', 'spawning', 'pending-permission'].includes(sessionState);
2055
- const status = $('session-status');
2056
2140
  const send = $('btn-send');
2141
+ send.innerHTML = '';
2142
+ if (state.busy) send.append(el('span', 'sq'));
2143
+ else { const up = el('span', 'up'); up.append(icon('send', 16)); send.append(up); }
2144
+ send.title = state.busy ? '停止' : '发送';
2145
+ renderSessionStatus();
2146
+ }
2147
+
2148
+ // The status chip follows the main session's own state machine; background
2149
+ // work (native or delegated) only ever appends a suffix and never revives a
2150
+ // terminal main state. Composer availability stays bound to the main session
2151
+ // alone - a busy delegated child never locks the main input.
2152
+ function renderSessionStatus(activity) {
2153
+ const status = $('session-status');
2154
+ const text = $('session-status-text');
2155
+ if (!status || !text) return;
2156
+ const snapshot = activity && typeof activity === 'object' ? activity
2157
+ : (dock && typeof dock.backgroundActivity === 'function' ? dock.backgroundActivity() : null);
2158
+ const background = snapshot && Number.isFinite(Number(snapshot.total)) ? Number(snapshot.total) : 0;
2159
+ const sessionState = state.sessionState;
2057
2160
  if (sessionState && sessionState !== 'idle') {
2058
2161
  status.hidden = false;
2059
- $('session-status-text').textContent = {
2162
+ text.textContent = ({
2060
2163
  running: '工作中', thinking: '思考中', 'tool-running': '执行工具', spawning: '启动中',
2061
2164
  'pending-permission': '等待授权', aborted: '已停止', failed: '失败',
2062
- }[sessionState] || sessionState;
2063
- } else {
2064
- status.hidden = true;
2165
+ }[sessionState] || sessionState) + (background > 0 ? ' · 后台运行中' : '');
2166
+ return;
2065
2167
  }
2066
- send.innerHTML = '';
2067
- if (state.busy) send.append(el('span', 'sq'));
2068
- else { const up = el('span', 'up'); up.append(icon('send', 16)); send.append(up); }
2069
- send.title = state.busy ? '停止' : '发送';
2168
+ if (background > 0) {
2169
+ status.hidden = false;
2170
+ text.textContent = '主回复已结束 · 后台运行中';
2171
+ return;
2172
+ }
2173
+ status.hidden = true;
2070
2174
  }
2071
2175
 
2072
2176
  async function refreshContextUsage() {
@@ -2889,6 +2993,96 @@ function embedAllowedOrigin(origin) {
2889
2993
  } catch { return false; }
2890
2994
  }
2891
2995
  let visibleRefreshJob = null, lastVisibleRefresh = 0, embedVisible = false;
2996
+ // Panel registrations are only honored once the workbench is ready; earlier
2997
+ // messages are late arrivals from a previous page lifecycle.
2998
+ let embedPanelsReady = false;
2999
+ function panelScopeMatches(scope) {
3000
+ if (!scope || typeof scope !== 'object') return true;
3001
+ const project = scope.projectId === undefined ? null : String(scope.projectId == null ? '' : scope.projectId);
3002
+ const session = scope.sessionId === undefined ? null : String(scope.sessionId == null ? '' : scope.sessionId);
3003
+ if (project !== null && project !== String(state.activeProjectId == null ? '' : state.activeProjectId)) return false;
3004
+ if (session !== null && session !== String(state.activeSessionId == null ? '' : state.activeSessionId)) return false;
3005
+ return true;
3006
+ }
3007
+ // K1 host→panel content: a plain JSON object only. Limits: ≤1MiB serialized,
3008
+ // nesting depth ≤5, ≤500 items per array. Functions, cycles and other
3009
+ // non-cloneable values fail structuredClone here with an explicit receipt -
3010
+ // the workbench never interprets, evaluates or stringifies business HTML.
3011
+ const PANEL_CONTENT_MAX_CHARS = 1048576;
3012
+ const PANEL_CONTENT_MAX_DEPTH = 5;
3013
+ const PANEL_CONTENT_MAX_ARRAY = 500;
3014
+ function panelContentCheck(content) {
3015
+ if (content === null || typeof content !== 'object' || Array.isArray(content)) {
3016
+ return { ok: false, code: 'INVALID_CONTENT', message: 'content must be a plain JSON object' };
3017
+ }
3018
+ if (typeof structuredClone !== 'function') {
3019
+ return { ok: false, code: 'CONTENT_NOT_CLONEABLE', message: 'structured clone is unavailable' };
3020
+ }
3021
+ let value;
3022
+ try { value = structuredClone(content); } catch (error) {
3023
+ return { ok: false, code: 'CONTENT_NOT_CLONEABLE', message: 'content rejected by structured clone: ' + (error && error.message ? error.message : String(error)) };
3024
+ }
3025
+ let text;
3026
+ try { text = JSON.stringify(value); } catch {
3027
+ return { ok: false, code: 'INVALID_CONTENT', message: 'content must be JSON-serializable' };
3028
+ }
3029
+ if (text === null || text.length > PANEL_CONTENT_MAX_CHARS) {
3030
+ return { ok: false, code: 'CONTENT_TOO_LARGE', message: 'serialized content exceeds ' + PANEL_CONTENT_MAX_CHARS + ' characters' };
3031
+ }
3032
+ let violation = null;
3033
+ const walk = (node, depth) => {
3034
+ if (violation) return;
3035
+ if (node === null || typeof node !== 'object') {
3036
+ if (typeof node === 'number' && !Number.isFinite(node)) {
3037
+ violation = { code: 'INVALID_CONTENT', message: 'content must contain finite JSON numbers only' };
3038
+ }
3039
+ return;
3040
+ }
3041
+ if (!Array.isArray(node)) {
3042
+ // Structured clones can be cross-realm in embedding tests; classify by
3043
+ // the JSON object tag rather than comparing realm-specific prototypes.
3044
+ // Date, Map, Set, and other exotic boxed values still fail this test.
3045
+ if (Object.prototype.toString.call(node) !== '[object Object]') {
3046
+ violation = { code: 'INVALID_CONTENT', message: 'content must contain plain JSON objects only' };
3047
+ return;
3048
+ }
3049
+ if (Object.getOwnPropertySymbols(node).length) {
3050
+ violation = { code: 'INVALID_CONTENT', message: 'content must not contain symbol properties' };
3051
+ return;
3052
+ }
3053
+ }
3054
+ if (Array.isArray(node)) {
3055
+ if (depth > PANEL_CONTENT_MAX_DEPTH) { violation = { code: 'CONTENT_TOO_DEEP', message: 'content nests deeper than ' + PANEL_CONTENT_MAX_DEPTH + ' levels' }; return; }
3056
+ if (node.length > PANEL_CONTENT_MAX_ARRAY) { violation = { code: 'CONTENT_ARRAY_TOO_LONG', message: 'arrays are limited to ' + PANEL_CONTENT_MAX_ARRAY + ' items' }; return; }
3057
+ for (const item of node) walk(item, depth + 1);
3058
+ } else if (node !== null && typeof node === 'object') {
3059
+ if (depth > PANEL_CONTENT_MAX_DEPTH) { violation = { code: 'CONTENT_TOO_DEEP', message: 'content nests deeper than ' + PANEL_CONTENT_MAX_DEPTH + ' levels' }; return; }
3060
+ for (const key of Object.keys(node)) walk(node[key], depth + 1);
3061
+ }
3062
+ };
3063
+ walk(value, 1);
3064
+ if (violation) return { ok: false, code: violation.code, message: violation.message };
3065
+ return { ok: true, value };
3066
+ }
3067
+ function panelStatePayload(reason) {
3068
+ const snap = dock ? dock.snapshot() : null;
3069
+ const panels = snap ? snap.tabs.filter(tab => tab.kind === 'host').map(tab => ({
3070
+ panelId: tab.id,
3071
+ providerId: tab.providerId,
3072
+ panelType: tab.panelType,
3073
+ open: true,
3074
+ active: tab.id === snap.activeTabId,
3075
+ error: tab.hostError || null,
3076
+ content: tab.content || null,
3077
+ scope: snap.scope ? { projectId: snap.scope.contextId, sessionId: snap.scope.parentSessionId } : null,
3078
+ })) : [];
3079
+ const providers = panelRegistry ? panelRegistry.list().map(d => ({ providerId: d.providerId, panelType: d.panelType, title: d.title })) : [];
3080
+ return { type: 'kb:panel/state', app: 'agent-terminal', reason, panels, providers };
3081
+ }
3082
+ function postPanelState(reason) {
3083
+ if (!embedded || window.parent === window) return;
3084
+ try { window.parent.postMessage(panelStatePayload(reason), document.referrer ? new URL(document.referrer).origin : location.origin); } catch { /* a broken referrer never breaks the app */ }
3085
+ }
2892
3086
  function refreshVisibleState() {
2893
3087
  if (visibleRefreshJob) return visibleRefreshJob;
2894
3088
  if (Date.now() - lastVisibleRefresh < 1000) return Promise.resolve();
@@ -2946,10 +3140,82 @@ function embedSelectProject(ref) {
2946
3140
  return true;
2947
3141
  }
2948
3142
  window.addEventListener('message', async ev => {
2949
- if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
2950
3143
  const data = ev.data;
3144
+ // Panel receipts arrive from sandboxed panel iframes whose origin is the
3145
+ // opaque "null" - identity is the source window itself, never an origin
3146
+ // string. Only the ack verb exists; panels get no other channel in.
3147
+ if (data && typeof data === 'object' && data.type === 'kb:panel/content-ack') {
3148
+ if (embedded && dock && ev.source) dock.ackHostPanelContent(ev.source, data);
3149
+ return;
3150
+ }
3151
+ if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
2951
3152
  if (!data || typeof data !== 'object') return;
2952
3153
  const reply = payload => { if (ev.source) ev.source.postMessage(Object.assign({ app: 'agent-terminal', requestId: data.requestId }, payload), ev.origin); };
3154
+ if (typeof data.type === 'string' && data.type.startsWith('kb:panel/')) {
3155
+ // W3 trusted host panel protocol. Same gate as every embed message: only
3156
+ // the real parent on the allowed origin, and only an embedded workbench.
3157
+ if (!embedded) { reply({ type: data.type, ok: false, code: 'NOT_EMBEDDED', error: 'panel protocol requires embed mode' }); return; }
3158
+ // W0-CONTRACTS: a referrer alone never authorizes provider registration —
3159
+ // a malicious parent is its own referrer. Panel messages demand a strictly
3160
+ // same-origin host this round; future cross-origin hosts need an explicit
3161
+ // allowlist, never a widening of the referrer rule.
3162
+ if (ev.origin !== location.origin || !embedAllowedOrigin(ev.origin)) { reply({ type: data.type, ok: false, code: 'FORBIDDEN_HOST_ORIGIN', error: 'panel hosts must be same-origin this round' }); return; }
3163
+ if (!embedPanelsReady) { reply({ type: data.type, ok: false, code: 'NOT_READY', error: 'panel messages are ignored before kb:ready' }); return; }
3164
+ if (!panelRegistry || !dock || !hostPanelOwner) { reply({ type: data.type, ok: false, code: 'PANELS_UNAVAILABLE', error: 'panel registry is unavailable' }); return; }
3165
+ if (!panelScopeMatches(data.scope)) { reply({ type: data.type, ok: false, code: 'SCOPE_MISMATCH', error: 'message scope does not match the active project/session' }); return; }
3166
+ if (data.type === 'kb:panel/register') {
3167
+ // kind and origin are bound here, never taken from the message body:
3168
+ // hosts may only ever register same-origin iframe panels.
3169
+ const descriptor = Object.assign({}, data.panel, { kind: 'iframe', origin: ev.origin });
3170
+ const result = panelRegistry.register(descriptor, hostPanelOwner);
3171
+ if (result.ok) postPanelState('register');
3172
+ reply({ type: 'kb:panel/registered', ok: result.ok, code: result.code || null, error: result.message || null, providerId: result.ok ? result.providerId : null });
3173
+ return;
3174
+ }
3175
+ if (data.type === 'kb:panel/unregister') {
3176
+ const result = panelRegistry.unregister(String(data.providerId || ''), hostPanelOwner);
3177
+ if (result.ok) postPanelState('unregister');
3178
+ reply({ type: 'kb:panel/unregistered', ok: result.ok, code: result.code || null, error: result.message || null, providerId: result.ok ? result.providerId : null });
3179
+ return;
3180
+ }
3181
+ if (data.type === 'kb:panel/open') {
3182
+ const panelId = dock.openPanel(String(data.providerId || ''));
3183
+ reply({ type: 'kb:panel/opened', ok: Boolean(panelId), panelId: panelId || null, providerId: String(data.providerId || '') });
3184
+ return;
3185
+ }
3186
+ if (data.type === 'kb:panel/close') {
3187
+ const closed = dock.closeHostPanels({ panelId: data.panelId, providerId: data.providerId });
3188
+ reply({ type: 'kb:panel/closed', ok: true, closed });
3189
+ return;
3190
+ }
3191
+ if (data.type === 'kb:panel/activate') {
3192
+ const ok = dock.activateHostPanel(String(data.panelId || ''));
3193
+ reply({ type: 'kb:panel/activated', ok, panelId: String(data.panelId || '') });
3194
+ return;
3195
+ }
3196
+ if (data.type === 'kb:panel/content') {
3197
+ // Host→panel structured content. The panel is chosen by panelId AND
3198
+ // providerId, and the provider must be an iframe panel this exact host
3199
+ // origin registered - a same-origin neighbor cannot feed foreign panels.
3200
+ const panelId = typeof data.panelId === 'string' ? data.panelId.trim() : '';
3201
+ const providerId = typeof data.providerId === 'string' ? data.providerId.trim() : '';
3202
+ const refuse = (code, error) => reply({ type: 'kb:panel/content-relayed', ok: false, code, error, panelId, providerId, version: data.version === undefined ? null : data.version });
3203
+ if (!panelId || !providerId) { refuse('INVALID_PANEL_REF', 'panelId and providerId are required'); return; }
3204
+ const descriptor = panelRegistry.get(providerId);
3205
+ if (!descriptor || descriptor.kind !== 'iframe' || descriptor.origin !== ev.origin) { refuse('UNKNOWN_PROVIDER', 'providerId is not an iframe panel registered by this host origin'); return; }
3206
+ let version = null;
3207
+ if (typeof data.version === 'number' && Number.isFinite(data.version)) version = data.version;
3208
+ else if (typeof data.version === 'string' && data.version.trim() && data.version.trim().length <= 200) version = data.version.trim();
3209
+ if (version === null) { refuse('INVALID_VERSION', 'version must be a finite number or a short non-empty string'); return; }
3210
+ const check = panelContentCheck(data.content);
3211
+ if (!check.ok) { refuse(check.code, check.message); return; }
3212
+ const relayed = dock.relayHostPanelContent(panelId, providerId, version, check.value);
3213
+ reply({ type: 'kb:panel/content-relayed', ok: Boolean(relayed.ok), code: relayed.code || null, error: relayed.message || null, panelId, providerId, version });
3214
+ return;
3215
+ }
3216
+ reply({ type: 'kb:panel/error', ok: false, code: 'UNKNOWN_PANEL_MESSAGE', error: 'unsupported panel message' });
3217
+ return;
3218
+ }
2953
3219
  if (data.type === 'kb:theme') { document.body.classList.toggle('light', data.theme !== 'dark'); document.body.dataset.kbTheme = data.theme === 'dark' ? 'dark' : 'light'; reply({ type: 'kb:configured', theme: document.body.dataset.kbTheme }); return; }
2954
3220
  if (data.type === 'kb:visibility' || data.type === 'kb:refresh') {
2955
3221
  if (data.type === 'kb:visibility') {
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Agent Terminal</title>
7
7
  <link rel="stylesheet" href="./app.css">
8
+ <link rel="stylesheet" href="./right-workspace-dock.css">
8
9
  </head>
9
10
  <body>
10
11
 
@@ -37,6 +38,7 @@
37
38
  <span class="session-provider" id="session-provider"></span>
38
39
  <span class="st" id="session-status" hidden><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l1.9 5.8L20 12l-6.1 3.2L12 21l-1.9-5.8L4 12l6.1-3.2z"/></svg><span id="session-status-text"></span></span>
39
40
  <span style="flex:1"></span>
41
+ <button class="ghost" id="btn-dock-toggle" title="任务面板(原生后台任务)" aria-expanded="false" aria-controls="right-dock" hidden><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M15 5v14" stroke="currentColor" stroke-width="2"/></svg><span class="rd-toggle-count" hidden></span></button>
40
42
  <button class="ghost" id="btn-top-settings" title="设置(模型供应商)"><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
41
43
  </div>
42
44
 
@@ -86,6 +88,8 @@
86
88
  </div>
87
89
  </div>
88
90
  </div>
91
+
92
+ <aside class="right-dock" id="right-dock" hidden aria-label="原生后台任务面板"></aside>
89
93
  </div>
90
94
 
91
95
  <!-- ============ 设置视图 ============ -->
@@ -159,6 +163,8 @@
159
163
  <div class="toast-host" id="toast" aria-live="polite"></div>
160
164
 
161
165
  <script src="../packages/client/index.js"></script>
166
+ <script src="./right-dock-panel-registry.js"></script>
167
+ <script src="./right-workspace-dock.js"></script>
162
168
  <script src="./app.js"></script>
163
169
  </body>
164
170
  </html>
@@ -0,0 +1,152 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>W3 Panel Host Proof(最小可信宿主)</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ body { margin: 0; font: 13px/1.5 system-ui, sans-serif; background: #101016; color: #e8e8ee; display: flex; flex-direction: column; height: 100vh; }
10
+ header { padding: 8px 12px; border-bottom: 1px solid #2a2a33; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
11
+ header h1 { font-size: 14px; margin: 0 12px 0 0; }
12
+ header .note { color: #9a9aa5; font-size: 11px; }
13
+ button { font: inherit; padding: 3px 10px; border: 1px solid #2a2a33; border-radius: 6px; background: #1a1a22; color: inherit; cursor: pointer; }
14
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
15
+ main { flex: 1; display: flex; min-height: 0; }
16
+ #terminal-frame { flex: 1 1 62%; border: none; background: #14141a; }
17
+ aside { flex: 0 0 38%; border-left: 1px solid #2a2a33; display: flex; flex-direction: column; min-width: 0; }
18
+ .state-head { padding: 6px 10px; border-bottom: 1px solid #2a2a33; display: flex; gap: 8px; align-items: baseline; }
19
+ .state-head strong { font-size: 12px; }
20
+ #panel-status { font-size: 11px; color: #9a9aa5; }
21
+ #state-log { flex: 1; overflow-y: auto; margin: 0; padding: 6px 10px; font: 11px/1.6 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
22
+ #state-log .line { display: block; border-bottom: 1px dashed #22222b; padding: 2px 0; }
23
+ #state-log .dir-host { color: #7fa3e8; }
24
+ #state-log .dir-app { color: #2e9e5b; }
25
+ #state-log .dir-err { color: #d64545; }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <header>
30
+ <h1>W3 Panel Host Proof</h1>
31
+ <span class="note">同源最小宿主:以 embed 模式加载真实 Agent Terminal,注册同源 knowledge iframe 面板。独立 Workbench 不依赖本页代码。</span>
32
+ </header>
33
+ <header>
34
+ <button id="btn-register" disabled>注册知识面板</button>
35
+ <button id="btn-unregister" disabled>注销</button>
36
+ <button id="btn-open-1" disabled>打开一个</button>
37
+ <button id="btn-open-2" disabled>打开第二个</button>
38
+ <button id="btn-close-last" disabled>关闭最后一个</button>
39
+ <button id="btn-activate-first" disabled>切换激活到第一个</button>
40
+ </header>
41
+ <main>
42
+ <iframe id="terminal-frame" title="Agent Terminal(embed)" src="/agent-terminal/?embed=1"></iframe>
43
+ <aside>
44
+ <div class="state-head"><strong>panel state 事件日志</strong><span id="panel-status">等待 kb:ready…</span></div>
45
+ <pre id="state-log"></pre>
46
+ </aside>
47
+ </main>
48
+ <script>
49
+ (function () {
50
+ 'use strict';
51
+ var frame = document.getElementById('terminal-frame');
52
+ var log = document.getElementById('state-log');
53
+ var status = document.getElementById('panel-status');
54
+ var buttons = {
55
+ register: document.getElementById('btn-register'),
56
+ unregister: document.getElementById('btn-unregister'),
57
+ open1: document.getElementById('btn-open-1'),
58
+ open2: document.getElementById('btn-open-2'),
59
+ closeLast: document.getElementById('btn-close-last'),
60
+ activateFirst: document.getElementById('btn-activate-first'),
61
+ };
62
+ var ORIGIN = window.location.origin;
63
+ var PROVIDER_ID = 'kb.proof.knowledge';
64
+ var ready = false;
65
+ var registered = false;
66
+ var panels = [];
67
+ var requestSeq = 0;
68
+
69
+ function line(text, dir) {
70
+ var entry = document.createElement('span');
71
+ entry.className = 'line ' + (dir === 'host' ? 'dir-host' : dir === 'app' ? 'dir-app' : 'dir-err');
72
+ entry.textContent = new Date().toISOString().slice(11, 23) + ' ' + text;
73
+ log.append(entry);
74
+ log.scrollTop = log.scrollHeight;
75
+ }
76
+
77
+ function send(message) {
78
+ if (!ready) { line('宿主 → 终端:忽略(终端未 ready)', 'err'); return; }
79
+ message.requestId = 'proof-' + (++requestSeq);
80
+ frame.contentWindow.postMessage(message, ORIGIN);
81
+ line('宿主 → 终端 ' + message.type + ' ' + JSON.stringify(message), 'host');
82
+ }
83
+
84
+ function refreshButtons() {
85
+ var knowledgeOpen = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
86
+ buttons.register.disabled = !ready || registered;
87
+ buttons.unregister.disabled = !ready || !registered;
88
+ buttons.open1.disabled = !ready || !registered;
89
+ buttons.open2.disabled = !ready || !registered;
90
+ buttons.closeLast.disabled = knowledgeOpen.length === 0;
91
+ buttons.activateFirst.disabled = knowledgeOpen.length < 2;
92
+ status.textContent = ready
93
+ ? (registered ? '已注册 knowledge 面板;当前打开 ' + knowledgeOpen.length + ' 个实例' : '未注册 provider:knowledge 不显示为可用')
94
+ : '等待 kb:ready…';
95
+ }
96
+
97
+ window.addEventListener('message', function (ev) {
98
+ if (ev.source !== frame.contentWindow || ev.origin !== ORIGIN) return;
99
+ var data = ev.data;
100
+ if (!data || typeof data !== 'object') return;
101
+ if (data.type === 'kb:ready') {
102
+ ready = true;
103
+ line('终端 → 宿主 kb:ready(注册自此生效)', 'app');
104
+ refreshButtons();
105
+ return;
106
+ }
107
+ if (data.type === 'kb:panel/state') {
108
+ panels = Array.isArray(data.panels) ? data.panels : [];
109
+ var providers = (data.providers || []).map(function (p) { return p.providerId; });
110
+ line('终端 → 宿主 kb:panel/state reason=' + data.reason +
111
+ ' panels=' + JSON.stringify(panels.map(function (p) {
112
+ return { id: p.panelId, provider: p.providerId, active: p.active, error: p.error, scope: p.scope };
113
+ })) +
114
+ ' providers=' + JSON.stringify(providers), 'app');
115
+ refreshButtons();
116
+ return;
117
+ }
118
+ if (typeof data.type === 'string' && data.type.indexOf('kb:panel/') === 0) {
119
+ line('终端 → 宿主 ' + data.type + ' ' + JSON.stringify(data), 'app');
120
+ if (data.type === 'kb:panel/registered' && data.ok) registered = true;
121
+ if (data.type === 'kb:panel/unregistered' && data.ok) registered = false;
122
+ refreshButtons();
123
+ }
124
+ });
125
+
126
+ buttons.register.addEventListener('click', function () {
127
+ send({ type: 'kb:panel/register', panel: {
128
+ providerId: PROVIDER_ID,
129
+ panelType: 'knowledge',
130
+ title: '知识引用(proof)',
131
+ url: new URL('panel-proof-content.html?panel=knowledge&source=host-proof', window.location.href).href,
132
+ capabilities: ['read-only', 'proof'],
133
+ multiInstance: 'multiple',
134
+ appliesTo: { projects: '*' },
135
+ } });
136
+ });
137
+ buttons.unregister.addEventListener('click', function () { send({ type: 'kb:panel/unregister', providerId: PROVIDER_ID }); });
138
+ buttons.open1.addEventListener('click', function () { send({ type: 'kb:panel/open', providerId: PROVIDER_ID }); });
139
+ buttons.open2.addEventListener('click', function () { send({ type: 'kb:panel/open', providerId: PROVIDER_ID }); });
140
+ buttons.closeLast.addEventListener('click', function () {
141
+ var open = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
142
+ if (open.length) send({ type: 'kb:panel/close', panelId: open[open.length - 1].panelId });
143
+ });
144
+ buttons.activateFirst.addEventListener('click', function () {
145
+ var open = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
146
+ if (open.length >= 2) send({ type: 'kb:panel/activate', panelId: open[0].panelId });
147
+ });
148
+ refreshButtons();
149
+ })();
150
+ </script>
151
+ </body>
152
+ </html>