@sanqianx/claude-ai-workbench 0.13.3 → 0.13.5

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.
@@ -331,6 +331,22 @@ body.light .perm .btn-allow { color:#fff; }
331
331
  .perm .btn-deny:hover { color:var(--bad); }
332
332
  .perm .p-state { color:var(--muted); font-size:12.5px; }
333
333
  .perm .p-state.no { color:var(--bad); }
334
+ .perm.question-card { display:block; padding:16px; color:var(--text); }
335
+ .question-title { font-size:14px; font-weight:600; margin-bottom:12px; }
336
+ .question-fields { max-height: min(52vh, 460px); overflow:auto; overscroll-behavior:contain; }
337
+ .question-field { min-width:0; margin:0 0 16px; padding:0; border:0; }
338
+ .question-field legend { font-size:14px; line-height:1.6; margin:0 0 10px; padding:0; overflow-wrap:anywhere; }
339
+ .question-option { display:flex; gap:10px; align-items:flex-start; padding:10px; margin:6px 0; border:1px solid var(--line); border-radius:10px; cursor:pointer; }
340
+ .question-option:has(input:checked) { border-color:var(--accent); background:var(--surface2); }
341
+ .question-option input { flex:0 0 auto; width:16px; height:16px; margin:3px 0; accent-color:var(--accent); }
342
+ .question-option-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
343
+ .question-option-label { font-size:14px; line-height:1.5; }
344
+ .question-option-description { font-size:12px; color:var(--muted); line-height:1.5; }
345
+ .question-custom { box-sizing:border-box; width:100%; margin-top:6px; padding:10px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:inherit; font:inherit; font-size:14px; }
346
+ .question-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; font-size:12px; color:var(--muted); }
347
+ .question-error { font-size:12px; color:var(--bad); margin-bottom:8px; }
348
+ .question-submit:disabled { opacity:.45; cursor:default; }
349
+ .question-settled .question-title { margin:0; font-size:12px; font-weight:400; color:var(--muted); }
334
350
 
335
351
  /* 附件托盘:缩略图统一 48px,移除按钮悬浮于角标 */
336
352
  .attach-tray { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; margin:-8px 0 12px; }
@@ -991,6 +991,10 @@ async function openSession(sessionId, options = {}) {
991
991
  try {
992
992
  for (const event of historyView.events) { renderEvent(event); if (!page) last = Math.max(last, event.sequence || 0); }
993
993
  } finally { state.replaying = false; }
994
+ if (snapshot.permission?.kind === 'question') renderEvent({ type: 'claude/permission-request', ...snapshot.permission });
995
+ for (const card of state.questionCards?.values() || []) {
996
+ if (!card.classList.contains('question-settled') && card.dataset.permId !== snapshot.permission?.requestId) card.settle({ cancelled: true });
997
+ }
994
998
  renderBackgroundTasks();
995
999
  rememberTitleFromEvents(historyView.events);
996
1000
  // The SSE replay window can overlap the snapshot (a slow turn raced the
@@ -1171,6 +1175,7 @@ function rememberTitleFromEvents(events) {
1171
1175
  }
1172
1176
 
1173
1177
  function resetStream() {
1178
+ state.questionCards?.clear();
1174
1179
  endTurnVisuals();
1175
1180
  const col = $('stream-col');
1176
1181
  col.textContent = '';
@@ -1586,6 +1591,16 @@ function renderEvent(event) {
1586
1591
  break;
1587
1592
  }
1588
1593
  case 'claude/permission-request': {
1594
+ if (col.querySelector(`[data-perm-id="${cssEscape(event.requestId || '')}"]`)) break;
1595
+ if (event.kind === 'question' && Array.isArray(event.questions)) {
1596
+ const sessionId = state.activeSessionId;
1597
+ const card = window.createUserQuestionCard({ document, event,
1598
+ storageKey: `kb.workbench.questions:${apiBase}:${sessionId}:${event.requestId}`,
1599
+ onSubmit: answers => client.resolvePermission({ sessionId, requestId: event.requestId, allow: true, answers }),
1600
+ });
1601
+ state.questionCards ||= new Map(); state.questionCards.set(event.requestId, card);
1602
+ col.append(card); setBusyState('pending-permission'); break;
1603
+ }
1589
1604
  const card = el('div', 'perm');
1590
1605
  card.dataset.permId = event.requestId || '';
1591
1606
  const pIcon = el('span', 'p-icon');
@@ -1608,7 +1623,8 @@ function renderEvent(event) {
1608
1623
  }
1609
1624
  case 'claude/permission-resolved': {
1610
1625
  const card = col.querySelector(`.perm[data-perm-id="${cssEscape(event.requestId || '')}"]`);
1611
- if (card) settlePermissionCard(card, event.allow === true);
1626
+ if (card?.settle) card.settle(event);
1627
+ else if (card) settlePermissionCard(card, event.allow === true);
1612
1628
  setBusyState('running');
1613
1629
  break;
1614
1630
  }
@@ -2217,7 +2233,7 @@ function renderSessionStatus(activity) {
2217
2233
  status.hidden = false;
2218
2234
  text.textContent = ({
2219
2235
  running: '工作中', thinking: '思考中', 'tool-running': '执行工具', spawning: '启动中',
2220
- 'pending-permission': '等待授权', aborted: '已停止', failed: '失败',
2236
+ 'pending-permission': '等待你确认', aborted: '已停止', failed: '失败',
2221
2237
  }[sessionState] || sessionState) + (background > 0 ? ' · 后台运行中' : '');
2222
2238
  return;
2223
2239
  }
@@ -165,6 +165,7 @@
165
165
  <script src="../packages/client/index.js"></script>
166
166
  <script src="./right-dock-panel-registry.js"></script>
167
167
  <script src="./conversation-composer.js"></script>
168
+ <script src="./user-question-card.js"></script>
168
169
  <script src="./right-workspace-dock.js"></script>
169
170
  <script src="./app.js"></script>
170
171
  </body>
@@ -182,6 +182,8 @@
182
182
  .rd-composer-wrap .send { margin-left:auto; }
183
183
  .rd-composer-wrap .composer textarea { font-size:14px; min-height:48px; }
184
184
  .rd-continue { align-self:center; border:1px solid var(--line); border-radius:8px; background:var(--page); color:var(--text); font-size:13px; padding:9px 16px; margin:12px 20px 20px; }
185
- .rd-permissions { padding:0 20px; }
185
+ .rd-permissions { padding:0; }
186
+ .rd-permissions:empty { display:none; }
187
+ .rd-permissions .question-fields { max-height:min(38vh,360px); }
186
188
  .rd-pane[hidden], .rd-host-pane[hidden] { display:none; }
187
189
  @media (max-width:640px) { .rd-head,.rd-scroll { padding-left:16px; padding-right:16px; } .rd-composer-wrap { padding:12px; } }
@@ -701,7 +701,7 @@
701
701
  try {
702
702
  await client.send(view.childSessionId, text, { permissionMode: 'default' });
703
703
  if (epoch !== scopeEpoch || !tabs.includes(tab)) return;
704
- tab.draft = ''; saveTabDraft(tab); tab.pane.composer.input.value = '';
704
+ tab.draft = ''; saveTabDraft(tab); tab.pane.composer.input.value = ''; tab.pane.composer.input.style.height = 'auto';
705
705
  void loadDelegationFirstPage(tab); void validateScopeDelegations();
706
706
  } catch { if (epoch === scopeEpoch && tabs.includes(tab)) tab.error = '消息暂未发送成功,草稿已保留。请查看对话结果后再决定是否重试。'; }
707
707
  finally { if (epoch === scopeEpoch && tabs.includes(tab)) { tab.sending = false; render(); flushPersist(); } }
@@ -733,6 +733,8 @@
733
733
  void loadDelegationFirstPage(tab); return;
734
734
  }
735
735
  panel.events = mergeDelegationEvents(panel.events, [event]); panel.rendered = false;
736
+ if (event.type === 'claude/permission-request') { panel.currentPermission = event; panel.permissionRevision = (panel.permissionRevision || 0) + 1; }
737
+ if (event.type === 'claude/permission-resolved') { if (panel.currentPermission?.requestId === event.requestId) panel.currentPermission = null; panel.permissionRevision = (panel.permissionRevision || 0) + 1; }
736
738
  panel.highWater = Math.max(panel.highWater || 0, Number(event.sequence) || 0);
737
739
  if (event.type === 'claude/state') { currentView.child.state = event.state; currentView.child.active = ['created', 'spawning', 'running', 'thinking', 'tool-running', 'pending-permission'].includes(event.state); }
738
740
  if (event.type === 'claude/result') { currentView.child.active = false; currentView.child.turns = (currentView.child.turns || 0) + 1; }
@@ -780,13 +782,36 @@
780
782
  const pane = tab.pane, view = delegationIndex.get(tab.delegationId);
781
783
  if (!pane.permissions || !view) return;
782
784
  pane.permissions.textContent = '';
783
- if (!view.child.active) return;
785
+ if (!view.child.active) {
786
+ for (const card of pane.questionCards?.values() || []) card.settle({ cancelled: true });
787
+ pane.questionCards?.clear(); return;
788
+ }
784
789
  const pending = new Map();
785
790
  for (const event of tab.panel.events) {
786
791
  if (event.type === 'claude/permission-request') pending.set(event.requestId, event);
787
792
  if (event.type === 'claude/permission-resolved') pending.delete(event.requestId);
788
793
  }
794
+ if (tab.panel.currentPermission !== undefined) {
795
+ for (const [id, event] of pending) if (event.kind === 'question' && id !== tab.panel.currentPermission?.requestId) pending.delete(id);
796
+ if (tab.panel.currentPermission?.kind === 'question') pending.set(tab.panel.currentPermission.requestId, tab.panel.currentPermission);
797
+ }
798
+ for (const [id, card] of pane.questionCards || []) if (!pending.has(id)) { card.settle({ cancelled: true }); pane.questionCards.delete(id); }
789
799
  for (const [requestId, event] of pending) {
800
+ if (event.kind === 'question' && typeof global.createUserQuestionCard === 'function') {
801
+ pane.questionCards ||= new Map();
802
+ let card = pane.questionCards.get(requestId);
803
+ if (!card) {
804
+ const childId = view.childSessionId;
805
+ card = global.createUserQuestionCard({ document: doc, event,
806
+ storageKey: `kb.workbench.questions:${deps.apiBase || ''}:${childId}:${requestId}`,
807
+ onSubmit: async answers => {
808
+ if (tab.invalid || !tabs.includes(tab)) throw new Error('任务已关闭');
809
+ await client.resolvePermission({ sessionId: childId, requestId, allow: true, answers });
810
+ },
811
+ }); pane.questionCards.set(requestId, card);
812
+ }
813
+ pane.permissions.append(card); continue;
814
+ }
790
815
  const card = el('div', 'perm'); card.append(el('span', 'p-title', (event.toolName || '子代理') + ' 请求执行操作'));
791
816
  for (const allow of [true, false]) {
792
817
  const button = el('button', allow ? 'btn-allow' : 'btn-deny', allow ? '允许' : '拒绝');
@@ -799,6 +824,8 @@
799
824
  }
800
825
  pane.permissions.append(card);
801
826
  }
827
+ pane.scroll.append(pane.permissions);
828
+ if (pane.follow !== false) pane.scroll.scrollTop = pane.scroll.scrollHeight;
802
829
  }
803
830
 
804
831
  function openDelegation(delegationId) {
@@ -863,9 +890,14 @@
863
890
  panel.loading = true; panel.error = null; panel.loaded = false; panel.rendered = false;
864
891
  render();
865
892
  let page = null, error = null;
866
- try { page = await client.loadHistory(view.childSessionId, { limit: DOCK_PAGE_LIMIT }); }
867
- catch (err) { error = err; }
893
+ const permissionRevision = panel.permissionRevision || 0;
894
+ const [historyResult, snapshotResult] = await Promise.allSettled([
895
+ client.loadHistory(view.childSessionId, { limit: DOCK_PAGE_LIMIT }),
896
+ typeof client.loadSession === 'function' ? client.loadSession(view.childSessionId) : Promise.resolve(null),
897
+ ]);
898
+ if (historyResult.status === 'fulfilled') page = historyResult.value; else error = historyResult.reason;
868
899
  if (epoch !== scopeEpoch || !tabs.includes(tab) || panel.revision !== revision) return;
900
+ if (snapshotResult.status === 'fulfilled' && snapshotResult.value && permissionRevision === (panel.permissionRevision || 0)) panel.currentPermission = snapshotResult.value.permission || null;
869
901
  panel.loading = false;
870
902
  if (error) {
871
903
  const code = (error && error.code) || String((error && error.status) || '');
@@ -1107,7 +1139,7 @@
1107
1139
  if (!child.present) return { status: 'failed', label: '任务暂不可用' };
1108
1140
  if (child.state === 'failed') return { status: 'failed', label: '失败' };
1109
1141
  if (child.state === 'aborted') return { status: 'stopped', label: '已停止' };
1110
- if (child.state === 'pending-permission') return { status: 'pending', label: '等待授权' };
1142
+ if (child.state === 'pending-permission') return { status: 'pending', label: '等待你确认' };
1111
1143
  if (child.active) return { status: 'running', label: '运行中' };
1112
1144
  if (['dispatched', 'completed'].includes(view.phase) && child.state === 'idle' && child.turns > 0) return { status: 'completed', label: '已完成' };
1113
1145
  return { status: 'pending', label: '待执行' };
@@ -1118,7 +1150,7 @@
1118
1150
  tab.pane.node.classList.add('rd-delegation-pane');
1119
1151
  tab.pane.composer = buildComposer(tab);
1120
1152
  tab.pane.permissions = el('div', 'rd-permissions');
1121
- tab.pane.node.append(tab.pane.permissions, tab.pane.composer.node);
1153
+ tab.pane.node.append(tab.pane.composer.node);
1122
1154
  }
1123
1155
 
1124
1156
  function refreshDelegationPane(tab) {
@@ -0,0 +1,68 @@
1
+ (function (global) {
2
+ 'use strict';
3
+ let serial = 0;
4
+ function createUserQuestionCard({ document: doc, event, storageKey, onSubmit }) {
5
+ const make = (tag, cls, text) => { const node = doc.createElement(tag); if (cls) node.className = cls; if (text) node.textContent = text; return node; };
6
+ const node = make('section', 'perm question-card'); node.dataset.permId = event.requestId;
7
+ node.setAttribute('aria-label', '等待你的选择');
8
+ const title = make('div', 'question-title', '等待你的选择');
9
+ const form = make('form', 'question-form');
10
+ const fields = make('div', 'question-fields');
11
+ const error = make('div', 'question-error'); error.setAttribute('role', 'alert');
12
+ const submit = make('button', 'btn-allow question-submit', '提交答案'); submit.type = 'submit'; submit.disabled = true;
13
+ const footer = make('div', 'question-footer'); footer.append(make('span', '', '提交后继续'), submit);
14
+ form.append(fields, error, footer); node.append(title, form);
15
+ const readers = [], questions = event.questions || [], group = `question-${++serial}`;
16
+ let saved = {}, sending = false, settled = false;
17
+ try { saved = JSON.parse(global.localStorage?.getItem(storageKey) || '{}'); } catch {}
18
+ const read = () => Object.fromEntries(readers.map(reader => [reader.id, reader.read()]));
19
+ const update = () => {
20
+ const answers = read();
21
+ submit.disabled = sending || settled || readers.some(reader => !answers[reader.id].length || reader.incomplete());
22
+ const draft = Object.fromEntries(questions.filter(question => !question.isSecret).map(question => [question.id, answers[question.id]]));
23
+ try { if (storageKey) global.localStorage?.setItem(storageKey, JSON.stringify(draft)); } catch {}
24
+ };
25
+ questions.forEach((question, index) => {
26
+ const field = make('fieldset', 'question-field');
27
+ field.append(make('legend', '', question.question));
28
+ const options = [], custom = question.allowCustom ? make('input', 'question-custom') : null;
29
+ const previous = question.isSecret ? [] : Array.isArray(saved[question.id]) ? saved[question.id] : [];
30
+ let other = null;
31
+ const addOption = (text, description, value, checked) => {
32
+ const label = make('label', 'question-option');
33
+ const input = make('input'); input.type = question.multiSelect ? 'checkbox' : 'radio'; input.name = `${group}-${index}`; input.value = value; input.checked = checked;
34
+ const copy = make('span', 'question-option-copy'); copy.append(make('span', 'question-option-label', text));
35
+ if (description) copy.append(make('span', 'question-option-description', description));
36
+ label.append(input, copy); field.append(label); input.addEventListener('change', update); return input;
37
+ };
38
+ for (const option of question.options || []) options.push(addOption(option.label, option.description, option.label, previous.includes(option.label)));
39
+ if (custom) {
40
+ const own = previous.filter(value => !question.options.some(option => option.label === value));
41
+ if (question.options.length) other = addOption('其他', '', '__custom__', own.length > 0);
42
+ custom.type = question.isSecret ? 'password' : 'text'; custom.placeholder = question.options.length ? '输入你的选择或要求' : '输入答案';
43
+ custom.setAttribute('aria-label', question.question + ':自定义答案'); custom.value = own.join(', '); custom.autocomplete = 'off';
44
+ custom.addEventListener('input', () => { if (other) { if (!question.multiSelect) options.forEach(option => { option.checked = false; }); other.checked = true; } update(); });
45
+ field.append(custom);
46
+ }
47
+ readers.push({ id: question.id,
48
+ read: () => [...options.filter(option => option.checked).map(option => option.value), ...(custom && (!other || other.checked) && custom.value.trim() ? [custom.value.trim()] : [])],
49
+ incomplete: () => Boolean(custom && (!other || other.checked) && !custom.value.trim()),
50
+ }); fields.append(field);
51
+ });
52
+ node.settle = result => {
53
+ settled = true; form.hidden = true; title.textContent = result?.cancelled || result?.allow === false ? '问题已取消,未提交选择' : '答案已提交';
54
+ node.classList.add('question-settled');
55
+ try { if (storageKey) global.localStorage?.removeItem(storageKey); } catch {}
56
+ };
57
+ form.addEventListener('submit', async action => {
58
+ action.preventDefault(); update(); if (submit.disabled) return;
59
+ sending = true; error.textContent = ''; update();
60
+ try { await onSubmit(read()); node.settle({ allow: true }); }
61
+ catch { error.textContent = '答案未提交,请重试。'; }
62
+ finally { sending = false; if (!settled) update(); }
63
+ });
64
+ update(); return node;
65
+ }
66
+ global.createUserQuestionCard = createUserQuestionCard;
67
+ if (typeof module !== 'undefined' && module.exports) module.exports = { createUserQuestionCard };
68
+ })(typeof window === 'undefined' ? globalThis : window);
@@ -1,5 +1,7 @@
1
1
  # Session activity subscriptions
2
2
 
3
+ Structured user questions use `claude/permission-request` with `kind: "question"` and a `questions` array. Each question has `id`, `question`, `options`, `multiSelect`, and `allowCustom`. Render a choice form rather than an allow/deny prompt. Nothing is selected or submitted automatically. Use `client.resolvePermission({sessionId, requestId, allow: true, answers: { [questionId]: [selectedLabel] }})`; custom text is an answer value. Missing, empty, incomplete, or foreign answers return 400 and leave the request pending. Read the current `permission` from `loadSession` when restoring a page, because the original request may be outside the latest history page. Closing a tab or refreshing does not answer a question. Stopping the turn emits a cancelled resolution without inventing answers. Claude SDK and Codex app-server native reply formats are translated by their owning drivers; UI callers never construct native payloads.
4
+
3
5
  `GET /sessions/changes?contextId=<id>&after=<revision>` streams SSE session summaries. The client exposes `subscribeSessionChanges(contextId, callback, onError, { afterRevision })`. Embedded hosts may supply their existing EventSource-compatible WebSocket transport; standalone pages use native SSE.
4
6
 
5
7
  An initial `workbench/sessions-snapshot` contains `sessions` (including archived records) and `revision`. Subsequent `workbench/session-changed` messages contain `session`, `contextId`, and `revision`; `deleted: true` removes that session. Cursors outside the retained revision window produce `workbench/resync`. Re-read one full snapshot after that marker. Reconnection does not resend input.
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Claude AI Workbench API
4
- version: 0.13.3
4
+ version: 0.13.5
5
5
  description: Versioned local API for AI Profile, credential and Claude Code session management.
6
6
  servers:
7
7
  - url: http://127.0.0.1:5760/api/claude-workbench/v1
@@ -613,8 +613,16 @@ paths:
613
613
  properties:
614
614
  allow: { type: boolean }
615
615
  message: { type: string }
616
+ answers:
617
+ type: object
618
+ description: Explicit answers to a pending structured question, keyed by its question IDs.
619
+ additionalProperties:
620
+ type: array
621
+ minItems: 1
622
+ items: { type: string, minLength: 1 }
616
623
  responses:
617
624
  "200": { description: Permission resolved }
625
+ "400": { description: Incomplete or invalid question answers; the question remains pending }
618
626
  /sessions/{sessionId}/abort:
619
627
  parameters:
620
628
  - $ref: "#/components/parameters/SessionId"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/claude-ai-workbench",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "Shared Claude Code terminal and Anthropic-compatible AI profile modules for local developer tools.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -108,7 +108,7 @@ export interface ClaudeWorkbenchClient {
108
108
  send(sessionId: string, text: string, options?: SendOptions): Promise<unknown>;
109
109
  send(args: {sessionId: string; text?: string; attachments?: AttachmentInput[]; options?: SendOptions}): Promise<unknown>;
110
110
  resolvePermission(sessionId: string, requestId: string, allow: boolean, message?: string): Promise<unknown>;
111
- resolvePermission(args: {sessionId: string; requestId: string; allow: boolean; message?: string}): Promise<unknown>;
111
+ resolvePermission(args: {sessionId: string; requestId: string; allow: boolean; message?: string; answers?: Record<string, string[]>}): Promise<unknown>;
112
112
  abort(sessionId: string | {sessionId: string}): Promise<unknown>;
113
113
  setPermissionMode(sessionId: string, mode: PermissionMode): Promise<unknown>;
114
114
  setPermissionMode(args: {sessionId: string; mode: PermissionMode}): Promise<unknown>;
@@ -216,7 +216,7 @@
216
216
  resolvePermission(sessionOrArgs, requestId, allow, message) {
217
217
  const args = typeof sessionOrArgs === 'object' ? sessionOrArgs : { sessionId: sessionOrArgs, requestId, allow, message };
218
218
  return request(`/sessions/${encodeURIComponent(args.sessionId)}/permissions/${encodeURIComponent(args.requestId)}/resolve`, {
219
- method: 'POST', body: JSON.stringify({ allow: args.allow === true, message: args.message || '' }),
219
+ method: 'POST', body: JSON.stringify({ allow: args.allow === true, message: args.message || '', ...(args.answers ? { answers: args.answers } : {}) }),
220
220
  });
221
221
  },
222
222
  abort(sessionOrArgs) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/client",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.3"
23
+ "@claude-ai-workbench/contracts": "0.13.5"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/contracts",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/core",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.3"
23
+ "@claude-ai-workbench/contracts": "0.13.5"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -7,6 +7,7 @@ const crypto = require('crypto');
7
7
  const { writeJson } = require('../json-file');
8
8
  const { createCliJsonRunner, resolveCliInvocation } = require('./cli-json-runner');
9
9
  const { createAppServerBridge } = require('../bridges/app-server-bridge');
10
+ const { normalizeQuestions, codexAnswers } = require('../user-questions');
10
11
  const { stampEvent, resequenceRestoredEvents } = require('../event-stream');
11
12
  const {
12
13
  appendTextAttachments, normalizeAttachments, resolveOpenAIModel, resolveOpenAIBaseUrl,
@@ -14,7 +15,7 @@ const {
14
15
 
15
16
  const PROVIDER_ID = 'agent_terminal';
16
17
  const PROVIDER_KEY_ENV = 'AGENT_TERMINAL_PROVIDER_API_KEY';
17
- const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
18
+ const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running', 'pending-permission']);
18
19
 
19
20
  // Codex driver, two transports:
20
21
  // app-server (preferred) — one persistent `codex app-server` child speaking
@@ -666,6 +667,8 @@ function createAppServerCodexDriver(options = {}) {
666
667
  }
667
668
  const allow = decision && decision.allow === true;
668
669
  const approval = session.pendingApproval;
670
+ if (approval.questions && !allow) throw Object.assign(new Error('请提交问题答案,或停止当前任务。'), { status: 400 });
671
+ const questionResponse = approval.questions ? codexAnswers(approval.questions, decision?.answers) : null;
669
672
  session.pendingApproval = null;
670
673
  session.pendingPermission = null;
671
674
  const rpcDecision = approvalDecisionFor(approval.method, allow);
@@ -674,10 +677,11 @@ function createAppServerCodexDriver(options = {}) {
674
677
  requestId,
675
678
  allow,
676
679
  decision: rpcDecision,
680
+ ...(questionResponse ? { kind: 'question' } : {}),
677
681
  resolvedAt: new Date().toISOString(),
678
682
  });
679
683
  setState(session, 'running');
680
- approval.resolve({ decision: rpcDecision });
684
+ approval.resolve(questionResponse || { decision: rpcDecision });
681
685
  return { ok: true, started: true, decision: rpcDecision };
682
686
  },
683
687
 
@@ -1141,6 +1145,19 @@ function createAppServerCodexDriver(options = {}) {
1141
1145
  // -- server-initiated requests (approvals) --------------------------------
1142
1146
 
1143
1147
  async function handleServerRequest(method, params) {
1148
+ if (method === 'item/tool/requestUserInput') {
1149
+ const session = params && threads.get(params.threadId) && sessions.get(threads.get(params.threadId));
1150
+ if (!session || !ACTIVE_STATES.has(session.state) || session.currentTurn && params.turnId !== session.currentTurn.turnId) throw Object.assign(new Error('question is not part of an active turn'), { status: 409 });
1151
+ if (session.pendingApproval) throw Object.assign(new Error('another user interaction is pending'), { status: 409 });
1152
+ const questions = normalizeQuestions(params, true);
1153
+ return await new Promise((resolve, reject) => {
1154
+ const requestId = `codex-question-${nextPermissionId++}`;
1155
+ session.pendingApproval = { requestId, method, params, questions, resolve, reject };
1156
+ session.pendingPermission = { requestId, kind: 'question', toolName: 'request_user_input', questions, status: 'pending', createdAt: new Date().toISOString() };
1157
+ setState(session, 'pending-permission');
1158
+ emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
1159
+ });
1160
+ }
1144
1161
  if (isApprovalMethod(method)) {
1145
1162
  const session = params && threads.get(params.threadId) && sessions.get(threads.get(params.threadId));
1146
1163
  if (!session) throw Object.assign(new Error('codex approval for an unknown thread'), { status: 404 });
@@ -1204,11 +1221,13 @@ function createAppServerCodexDriver(options = {}) {
1204
1221
  type: 'claude/permission-resolved',
1205
1222
  requestId: approval.requestId,
1206
1223
  allow: false,
1224
+ ...(approval.questions ? { kind: 'question', cancelled: true } : {}),
1207
1225
  decision: approvalDecisionFor(approval.method, false),
1208
1226
  message: reason,
1209
1227
  resolvedAt: new Date().toISOString(),
1210
1228
  });
1211
- approval.resolve({ decision: approvalDecisionFor(approval.method, false) });
1229
+ if (approval.questions) approval.reject(Object.assign(new Error('Question cancelled without a user answer: ' + reason), { rpcCode: -32800 }));
1230
+ else approval.resolve({ decision: approvalDecisionFor(approval.method, false) });
1212
1231
  }
1213
1232
 
1214
1233
  // -- CLI fallback plumbing ------------------------------------------------
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ function invalid(message) { return Object.assign(new Error(message), { status: 400, code: 'QUESTION_ANSWER_INVALID' }); }
4
+ function normalizeQuestions(input, codex = false) {
5
+ if (!Array.isArray(input?.questions) || !input.questions.length || input.questions.length > 8) throw invalid('问题列表无效,请重新提问。');
6
+ const ids = new Set();
7
+ return input.questions.map((question, index) => {
8
+ const id = codex ? question.id : `question-${index + 1}`;
9
+ if (typeof id !== 'string' || !id || ids.has(id) || typeof question.question !== 'string' || !question.question.trim()) throw invalid('问题标识或内容无效。');
10
+ ids.add(id);
11
+ const options = Array.isArray(question.options) ? question.options.map(option => {
12
+ if (typeof option?.label !== 'string' || !option.label.trim()) throw invalid('问题选项无效。');
13
+ return { label: option.label, description: String(option.description || '') };
14
+ }) : [];
15
+ return { id, header: String(question.header || ''), question: question.question, options,
16
+ multiSelect: !codex && question.multiSelect === true, isSecret: question.isSecret === true,
17
+ allowCustom: !codex || question.isOther === true || !options.length };
18
+ });
19
+ }
20
+ function validateAnswers(questions, answers) {
21
+ if (!answers || typeof answers !== 'object' || Array.isArray(answers)) throw invalid('请填写答案后再提交。');
22
+ if (Object.keys(answers).some(id => !questions.some(question => question.id === id))) throw invalid('答案不属于本次问题。');
23
+ const result = Object.create(null);
24
+ for (const question of questions) {
25
+ const value = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined;
26
+ const selected = typeof value === 'string' ? [value] : value;
27
+ if (!Array.isArray(selected) || !selected.length || !question.multiSelect && selected.length !== 1
28
+ || selected.some(item => typeof item !== 'string' || !item.trim() || item.length > 8192)) throw invalid('请回答全部问题后再提交。');
29
+ if (!question.allowCustom && selected.some(item => !question.options.some(option => option.label === item))) throw invalid('请选择本次问题提供的选项。');
30
+ result[question.id] = [...new Set(selected.map(item => question.options.some(option => option.label === item) ? item : item.trim()))];
31
+ }
32
+ return result;
33
+ }
34
+ function sdkAnswers(input, questions, answers) {
35
+ const checked = validateAnswers(questions, answers);
36
+ return { ...input, answers: Object.fromEntries(questions.map(question => [question.question, checked[question.id].join(', ')])) };
37
+ }
38
+ function codexAnswers(questions, answers) {
39
+ const checked = validateAnswers(questions, answers);
40
+ return { answers: Object.fromEntries(questions.map(question => [question.id, { answers: checked[question.id] }])) };
41
+ }
42
+ function waitForSdkQuestion({ session, input, context = {}, requestId, emit, setState }) {
43
+ const questions = normalizeQuestions(input);
44
+ if (session.pendingToolApproval) throw invalid('已有待回答的问题或授权,请先完成当前操作。');
45
+ return new Promise(resolve => {
46
+ const cleanup = () => context.signal?.removeEventListener('abort', cancel);
47
+ const cancel = () => {
48
+ if (session.pendingToolApproval?.requestId !== requestId) return;
49
+ session.pendingToolApproval = null; session.pendingPermission = null; cleanup();
50
+ emit({ type: 'claude/permission-resolved', requestId, kind: 'question', allow: false, cancelled: true });
51
+ resolve({ behavior: 'deny', interrupt: true, message: 'Question cancelled without a user answer. No option was selected.', toolUseID: context.toolUseID });
52
+ };
53
+ session.pendingToolApproval = { requestId, input, questions, toolUseID: context.toolUseID,
54
+ resolve: result => { cleanup(); resolve(result); }, cancel };
55
+ session.pendingPermission = { requestId, kind: 'question', toolName: 'AskUserQuestion', questions,
56
+ status: 'pending', createdAt: new Date().toISOString() };
57
+ if (context.signal?.aborted) { cancel(); return; }
58
+ context.signal?.addEventListener('abort', cancel, { once: true });
59
+ setState('pending-permission');
60
+ emit({ type: 'claude/permission-request', ...session.pendingPermission });
61
+ });
62
+ }
63
+ async function questionPromptHook(input) {
64
+ if (input.tool_name !== 'AskUserQuestion') return {};
65
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: '等待用户明确回答问题。' } };
66
+ }
67
+ module.exports = { normalizeQuestions, validateAnswers, sdkAnswers, codexAnswers, waitForSdkQuestion, questionPromptHook };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/server",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
28
- "@claude-ai-workbench/contracts": "0.13.3"
28
+ "@claude-ai-workbench/contracts": "0.13.5"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/ui",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "type": "commonjs",
5
5
  "main": "ai-profile-settings.js",
6
6
  "browser": "ai-profile-settings.js",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@claude-ai-workbench/core": "0.13.3"
36
+ "@claude-ai-workbench/core": "0.13.5"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18"
@@ -20,6 +20,7 @@ const crypto = require('crypto');
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { writeJson } = require('../../../packages/server/lib/json-file');
23
+ const { sdkAnswers, waitForSdkQuestion, questionPromptHook } = require('../../../packages/server/lib/user-questions');
23
24
  const { renderPrompt } = require('./prompt-registry');
24
25
  const aiWorkspace = require('./ai-workspace');
25
26
  const {
@@ -980,11 +981,15 @@ function resolvePermission(sessionId, requestId, decision) {
980
981
  throw new Error('permission request not found or already resolved');
981
982
  }
982
983
  const allow = decision && decision.allow === true;
984
+ const questionApproval = session.pendingToolApproval?.questions ? session.pendingToolApproval : null;
985
+ if (questionApproval && !allow) throw Object.assign(new Error('请提交问题答案,或停止当前任务。'), { status: 400 });
986
+ const questionInput = questionApproval ? sdkAnswers(questionApproval.input, questionApproval.questions, decision.answers) : null;
983
987
  const resolvedAt = new Date().toISOString();
984
988
  emit(session, {
985
989
  type: 'claude/permission-resolved',
986
990
  requestId,
987
991
  allow,
992
+ ...(questionApproval ? { kind: 'question' } : {}),
988
993
  message: decision && decision.message || '',
989
994
  resolvedAt,
990
995
  });
@@ -997,7 +1002,7 @@ function resolvePermission(sessionId, requestId, decision) {
997
1002
  session.pendingPermission = null;
998
1003
  setState(session, 'running', { message: allow ? 'tool permission approved' : 'tool permission denied', requestId });
999
1004
  approval.resolve(allow
1000
- ? { behavior: 'allow', updatedInput: approval.input, toolUseID: approval.toolUseID }
1005
+ ? { behavior: 'allow', updatedInput: questionInput || approval.input, toolUseID: approval.toolUseID }
1001
1006
  : { behavior: 'deny', message: decision && decision.message || 'User denied tool use', toolUseID: approval.toolUseID });
1002
1007
  persistSession(session);
1003
1008
  return { ok: true, started: true, toolPermission: true };
@@ -1780,6 +1785,10 @@ async function runSdkTurn(session, opts, isResume, generation) {
1780
1785
  }
1781
1786
  return { behavior: 'deny', message: decision.reason || 'Denied by automation safety policy', toolUseID };
1782
1787
  }
1788
+ if (toolName === 'AskUserQuestion') {
1789
+ return waitForSdkQuestion({ session, input, context, requestId: newPermissionId(),
1790
+ emit: event => emit(session, event), setState: state => setState(session, state) });
1791
+ }
1783
1792
  if (sdkPermissionMode === 'bypassPermissions') {
1784
1793
  return { behavior: 'allow', updatedInput: input, toolUseID };
1785
1794
  }
@@ -1859,6 +1868,10 @@ async function runSdkTurn(session, opts, isResume, generation) {
1859
1868
  isCurrent,
1860
1869
  getSecrets: () => sdkOptions.env,
1861
1870
  });
1871
+ // A question always needs a human answer, even when tool execution is
1872
+ // otherwise auto-approved. The hook only routes to canUseTool; it never
1873
+ // chooses an answer or waits inside a hook timeout.
1874
+ sdkOptions.hooks.PreToolUse.push({ matcher: '^AskUserQuestion$', hooks: [questionPromptHook] });
1862
1875
 
1863
1876
  // Native transcript collection rides on the same query. One collector per
1864
1877
  // session keeps the published-uuid cursors across inputs, while each
@@ -2020,6 +2033,7 @@ async function runSdkTurn(session, opts, isResume, generation) {
2020
2033
  if (session.sdkTurnGeneration === generation) {
2021
2034
  session.subprocess = null;
2022
2035
  session.sdkTransport = null;
2036
+ session.pendingToolApproval?.cancel?.();
2023
2037
  session.pendingToolApproval = null;
2024
2038
  session.pendingPermission = null;
2025
2039
  persistSession(session);
@@ -2047,6 +2061,7 @@ function abort(sessionId, reason) {
2047
2061
  const session = getSession(sessionId);
2048
2062
  if (!session) throw new Error(`session not found: ${sessionId}`);
2049
2063
  const cause = reason || 'user-abort';
2064
+ session.pendingToolApproval?.cancel?.();
2050
2065
  finishBackgroundTasks(session, cause);
2051
2066
  if (session.subprocess) {
2052
2067
  try {
@@ -2211,6 +2226,7 @@ function isStaleActive(record, thresholdMs, now) {
2211
2226
  // automation queue (post-commit dispatcher) treats `failed` as a release
2212
2227
  // signal for the next queued run.
2213
2228
  function demoteSessionToFailed(session, reason, source) {
2229
+ session.pendingToolApproval?.cancel?.();
2214
2230
  session.state = 'failed';
2215
2231
  session.endedAt = new Date().toISOString();
2216
2232
  session.error = reason;