@sanqianx/claude-ai-workbench 0.13.3 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/agent-terminal/app.css +16 -0
- package/apps/agent-terminal/app.js +18 -2
- package/apps/agent-terminal/index.html +1 -0
- package/apps/agent-terminal/right-workspace-dock.css +3 -1
- package/apps/agent-terminal/right-workspace-dock.js +30 -3
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/docs/sdk/SESSION_EVENTS.md +2 -0
- package/docs/sdk/openapi.yaml +9 -1
- package/package.json +1 -1
- package/packages/client/index.d.ts +1 -1
- package/packages/client/index.js +1 -1
- package/packages/client/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/core/package.json +2 -2
- package/packages/server/lib/drivers/codex-driver.js +22 -3
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/package.json +2 -2
- package/packages/ui/package.json +2 -2
- package/src/backend/lib/claude-cli-runner.js +17 -1
|
@@ -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)
|
|
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': '
|
|
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 20px; flex:0 1 auto; min-height:0; overflow-y:auto; }
|
|
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; } }
|
|
@@ -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; }
|
|
@@ -786,7 +788,27 @@
|
|
|
786
788
|
if (event.type === 'claude/permission-request') pending.set(event.requestId, event);
|
|
787
789
|
if (event.type === 'claude/permission-resolved') pending.delete(event.requestId);
|
|
788
790
|
}
|
|
791
|
+
if (tab.panel.currentPermission !== undefined) {
|
|
792
|
+
for (const [id, event] of pending) if (event.kind === 'question' && id !== tab.panel.currentPermission?.requestId) pending.delete(id);
|
|
793
|
+
if (tab.panel.currentPermission?.kind === 'question') pending.set(tab.panel.currentPermission.requestId, tab.panel.currentPermission);
|
|
794
|
+
}
|
|
795
|
+
for (const [id, card] of pane.questionCards || []) if (!pending.has(id)) { card.settle({ cancelled: true }); pane.questionCards.delete(id); }
|
|
789
796
|
for (const [requestId, event] of pending) {
|
|
797
|
+
if (event.kind === 'question' && typeof global.createUserQuestionCard === 'function') {
|
|
798
|
+
pane.questionCards ||= new Map();
|
|
799
|
+
let card = pane.questionCards.get(requestId);
|
|
800
|
+
if (!card) {
|
|
801
|
+
const childId = view.childSessionId;
|
|
802
|
+
card = global.createUserQuestionCard({ document: doc, event,
|
|
803
|
+
storageKey: `kb.workbench.questions:${deps.apiBase || ''}:${childId}:${requestId}`,
|
|
804
|
+
onSubmit: async answers => {
|
|
805
|
+
if (tab.invalid || !tabs.includes(tab)) throw new Error('任务已关闭');
|
|
806
|
+
await client.resolvePermission({ sessionId: childId, requestId, allow: true, answers });
|
|
807
|
+
},
|
|
808
|
+
}); pane.questionCards.set(requestId, card);
|
|
809
|
+
}
|
|
810
|
+
pane.permissions.append(card); continue;
|
|
811
|
+
}
|
|
790
812
|
const card = el('div', 'perm'); card.append(el('span', 'p-title', (event.toolName || '子代理') + ' 请求执行操作'));
|
|
791
813
|
for (const allow of [true, false]) {
|
|
792
814
|
const button = el('button', allow ? 'btn-allow' : 'btn-deny', allow ? '允许' : '拒绝');
|
|
@@ -863,9 +885,14 @@
|
|
|
863
885
|
panel.loading = true; panel.error = null; panel.loaded = false; panel.rendered = false;
|
|
864
886
|
render();
|
|
865
887
|
let page = null, error = null;
|
|
866
|
-
|
|
867
|
-
|
|
888
|
+
const permissionRevision = panel.permissionRevision || 0;
|
|
889
|
+
const [historyResult, snapshotResult] = await Promise.allSettled([
|
|
890
|
+
client.loadHistory(view.childSessionId, { limit: DOCK_PAGE_LIMIT }),
|
|
891
|
+
typeof client.loadSession === 'function' ? client.loadSession(view.childSessionId) : Promise.resolve(null),
|
|
892
|
+
]);
|
|
893
|
+
if (historyResult.status === 'fulfilled') page = historyResult.value; else error = historyResult.reason;
|
|
868
894
|
if (epoch !== scopeEpoch || !tabs.includes(tab) || panel.revision !== revision) return;
|
|
895
|
+
if (snapshotResult.status === 'fulfilled' && snapshotResult.value && permissionRevision === (panel.permissionRevision || 0)) panel.currentPermission = snapshotResult.value.permission || null;
|
|
869
896
|
panel.loading = false;
|
|
870
897
|
if (error) {
|
|
871
898
|
const code = (error && error.code) || String((error && error.status) || '');
|
|
@@ -1107,7 +1134,7 @@
|
|
|
1107
1134
|
if (!child.present) return { status: 'failed', label: '任务暂不可用' };
|
|
1108
1135
|
if (child.state === 'failed') return { status: 'failed', label: '失败' };
|
|
1109
1136
|
if (child.state === 'aborted') return { status: 'stopped', label: '已停止' };
|
|
1110
|
-
if (child.state === 'pending-permission') return { status: 'pending', label: '
|
|
1137
|
+
if (child.state === 'pending-permission') return { status: 'pending', label: '等待你确认' };
|
|
1111
1138
|
if (child.active) return { status: 'running', label: '运行中' };
|
|
1112
1139
|
if (['dispatched', 'completed'].includes(view.phase) && child.state === 'idle' && child.turns > 0) return { status: 'completed', label: '已完成' };
|
|
1113
1140
|
return { status: 'pending', label: '待执行' };
|
|
@@ -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.
|
package/docs/sdk/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: Claude AI Workbench API
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.4
|
|
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
|
@@ -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>;
|
package/packages/client/index.js
CHANGED
|
@@ -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
|
+
"version": "0.13.4",
|
|
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.
|
|
23
|
+
"@claude-ai-workbench/contracts": "0.13.4"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": ">=18"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-ai-workbench/core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
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.
|
|
23
|
+
"@claude-ai-workbench/contracts": "0.13.4"
|
|
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.
|
|
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
|
+
"version": "0.13.4",
|
|
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.
|
|
28
|
+
"@claude-ai-workbench/contracts": "0.13.4"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
package/packages/ui/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-ai-workbench/ui",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
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.
|
|
36
|
+
"@claude-ai-workbench/core": "0.13.4"
|
|
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;
|