@openchambery/web 1.19.17-beta.2 → 1.19.17-beta.3

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 (128) hide show
  1. package/dist/assets/AgentAvatar-CUDJ3_nZ.js +1 -0
  2. package/dist/assets/AssistantView-CDXE0eTS.js +3 -0
  3. package/dist/assets/DiagramView-BrRV4o5T.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BLSnyG2Z.js +1 -0
  5. package/dist/assets/Icon-C9ZHbwqp.js +1 -0
  6. package/dist/assets/MarkdownRenderer-CAdfUgV1.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-BJd1Vjsc.js → MarkdownRendererImpl-D2dIlX0h.js} +2 -2
  8. package/dist/assets/MarkstreamRendererImpl-Drb2Tpv-.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-BgCoDqtp.js +1 -0
  10. package/dist/assets/MobileShareBridge-ZlVURYWR.js +4 -0
  11. package/dist/assets/MobileSurface-DhLOTW0-.js +2 -0
  12. package/dist/assets/MultiRunWindow-BCvcwItK.js +1 -0
  13. package/dist/assets/OnboardingScreen-DSyZtiFS.js +1 -0
  14. package/dist/assets/OverlayScrollbar-C7fAOAKT.js +2 -0
  15. package/dist/assets/SettingsView-D57amZJb.js +25 -0
  16. package/dist/assets/SettingsWindow-B5DqLCkD.js +1 -0
  17. package/dist/assets/TerminalView-2_RFPaVs.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-BQ_oXyS_.js → ToolOutputDialog-CQs_7KYT.js} +4 -4
  19. package/dist/assets/{appThemeRegistry-BDrIDWKH.js → appThemeRegistry-DpLe_jnW.js} +1 -1
  20. package/dist/assets/button-bhVnVYB_.js +1 -0
  21. package/dist/assets/collapsible-CoWJB_Zl.js +1 -0
  22. package/dist/assets/context-menu-gSO0b3sQ.js +1 -0
  23. package/dist/assets/diagram-Dx4UOZUn.js +1 -0
  24. package/dist/assets/dialog-C2Dw0yqA.js +1 -0
  25. package/dist/assets/{es-BQb6I8gl.js → es-Bxd4Lyu9.js} +2 -2
  26. package/dist/assets/{fileMentionTouchSelection-DzdgAvGS.js → fileMentionTouchSelection-CmQthb6Q.js} +2 -2
  27. package/dist/assets/{fr-BjGouC_l.js → fr-DiLDwVZs.js} +2 -2
  28. package/dist/assets/{gitApi-pLkl_IyQ.js → gitApi-8HxdR1zx.js} +3 -3
  29. package/dist/assets/input-8Te_vPwB.js +1 -0
  30. package/dist/assets/insertionBoundaries-Cdv9-1Bg.js +1 -0
  31. package/dist/assets/{ja-Cw9zliYY.js → ja-CWRlmEJM.js} +2 -2
  32. package/dist/assets/{ko-Dyz0Dk9b.js → ko-DmIugkgj.js} +2 -2
  33. package/dist/assets/main-CQY7JUX4.js +169 -0
  34. package/dist/assets/main-Cfru2-qr.js +2 -0
  35. package/dist/assets/{markdownHeightCache-BL8CUQ0M.js → markdownHeightCache-CT2d4yTV.js} +1 -1
  36. package/dist/assets/miniChat-BphFgAb_.js +2 -0
  37. package/dist/assets/mobile-BVRPTuLT.js +2 -0
  38. package/dist/assets/multirun-CYdieVzW.js +4 -0
  39. package/dist/assets/{pl-C7EY-dTH.js → pl-BU9AmdUM.js} +2 -2
  40. package/dist/assets/projectMeta-D8hiy3Dk.js +3 -0
  41. package/dist/assets/{pt-BR-B1uHpjFW.js → pt-BR-DQmEF9QB.js} +2 -2
  42. package/dist/assets/renderElectronMiniChatApp-CVqd3Nmc.js +2 -0
  43. package/dist/assets/renderMobileApp-DgtCGRKP.js +28 -0
  44. package/dist/assets/{runtimeConfig-BD1Fo5N5.js → runtimeConfig-DXXOBrK_.js} +1 -1
  45. package/dist/assets/runtimeEndpointReset-bSVOt7pi.js +147 -0
  46. package/dist/assets/sessionChangeSummary-CfnRZ0gS.js +2 -0
  47. package/dist/assets/sessionLookup-BZuujM6l.js +1 -0
  48. package/dist/assets/sortable-tabs-strip-BmvLnn6O.js +1 -0
  49. package/dist/assets/src-C_Yn9Ylz.css +2 -0
  50. package/dist/assets/{store-ClnJdJSj.js → store-BoixGrYS.js} +3 -3
  51. package/dist/assets/textarea-DRL916s6.js +1 -0
  52. package/dist/assets/tooltip-BLznq5KR.js +1 -0
  53. package/dist/assets/{uk-DgG8v2md.js → uk-Dax66sAC.js} +2 -2
  54. package/dist/assets/{useAppFontEffects-CN_3Jty7.js → useAppFontEffects-DhEfjuPp.js} +43 -43
  55. package/dist/assets/useDesktopWindowControlsLayout-BhNFU2Wd.js +1 -0
  56. package/dist/assets/{useEffectiveDirectory-C5Gg7EvP.js → useEffectiveDirectory-BhhzFJgX.js} +1 -1
  57. package/dist/assets/useMobileNavigationStore-1jdHeA49.js +413 -0
  58. package/dist/assets/useSessionAutoCleanup-C8u2-jrO.js +2 -0
  59. package/dist/assets/{useWorkerHighlightedLines-BSQQkZaO.js → useWorkerHighlightedLines-DFXK66lA.js} +2 -2
  60. package/dist/assets/vendor-zod-Dzk5kdUU.js +39 -0
  61. package/dist/assets/{zh-CN-C6OFKjeT.js → zh-CN-Du4VC8Z0.js} +2 -2
  62. package/dist/assets/{zh-TW-BVP99aMr.js → zh-TW-C0bY-Dso.js} +2 -2
  63. package/dist/index.html +3 -3
  64. package/dist/mini-chat.html +3 -3
  65. package/dist/mobile.html +3 -3
  66. package/package.json +1 -1
  67. package/server/index.js +51 -0
  68. package/server/lib/opencode/DOCUMENTATION.md +1 -0
  69. package/server/lib/opencode/core-routes.js +2 -0
  70. package/server/lib/opencode/feature-routes-runtime.js +20 -0
  71. package/server/lib/opencode/feature-routes-runtime.test.js +10 -0
  72. package/server/lib/opencode/routes.js +14 -1
  73. package/server/lib/opencode/settings-helpers.js +3 -0
  74. package/server/lib/opencode/settings-helpers.test.js +12 -0
  75. package/server/lib/opencode/shutdown-runtime.js +2 -0
  76. package/server/lib/question-auto-delegate/DOCUMENTATION.md +124 -0
  77. package/server/lib/question-auto-delegate/core.d.ts +255 -0
  78. package/server/lib/question-auto-delegate/core.js +1372 -0
  79. package/server/lib/question-auto-delegate/core.test.js +672 -0
  80. package/server/lib/question-auto-delegate/routes.js +223 -0
  81. package/server/lib/question-auto-delegate/routes.test.js +180 -0
  82. package/server/lib/question-auto-delegate/runtime.js +243 -0
  83. package/server/lib/session-goal/DOCUMENTATION.md +12 -2
  84. package/server/lib/session-goal/runtime.js +138 -35
  85. package/server/lib/session-goal/runtime.test.js +192 -0
  86. package/dist/assets/AgentAvatar-m1jNq4bX.js +0 -1
  87. package/dist/assets/AssistantView-DZMTfY1i.js +0 -3
  88. package/dist/assets/DiagramView-Byf2evGV.js +0 -1
  89. package/dist/assets/FadeInOnReveal-UGwClbL_.js +0 -1
  90. package/dist/assets/Icon-D1BcR8Tz.js +0 -1
  91. package/dist/assets/MarkdownRenderer-YMePGw0Z.js +0 -2
  92. package/dist/assets/MarkstreamRendererImpl-2oZKPfgd.js +0 -3
  93. package/dist/assets/MobileDetailNavigation-BeNUCxzK.js +0 -1
  94. package/dist/assets/MobileShareBridge-3tBtXXJJ.js +0 -4
  95. package/dist/assets/MobileSurface-ppn0zIpb.js +0 -2
  96. package/dist/assets/MultiRunWindow-CItY5bZ2.js +0 -1
  97. package/dist/assets/OnboardingScreen-bIWeMLVx.js +0 -1
  98. package/dist/assets/OverlayScrollbar-Bahg_P3r.js +0 -2
  99. package/dist/assets/SettingsView-0-Yqdzh-.js +0 -25
  100. package/dist/assets/SettingsWindow-1jOWzU2A.js +0 -1
  101. package/dist/assets/TerminalView-BLOCPFz9.js +0 -8
  102. package/dist/assets/button-9R-WgVMl.js +0 -1
  103. package/dist/assets/clipboard-GUUNlWIv.js +0 -1
  104. package/dist/assets/collapsible-Bbz9KiJf.js +0 -1
  105. package/dist/assets/context-menu-CSyOhRz8.js +0 -1
  106. package/dist/assets/diagram-NdsZih8P.js +0 -1
  107. package/dist/assets/dialog-DhMGbF_B.js +0 -1
  108. package/dist/assets/input-Cd_1HyQv.js +0 -1
  109. package/dist/assets/insertionBoundaries-DJvMDCJJ.js +0 -1
  110. package/dist/assets/main-B8fZfA3K.js +0 -169
  111. package/dist/assets/main-BGpPyUV2.js +0 -2
  112. package/dist/assets/miniChat-DHjphqgR.js +0 -2
  113. package/dist/assets/mobile-DNMX4u7x.js +0 -2
  114. package/dist/assets/multirun-BINJ2LCE.js +0 -4
  115. package/dist/assets/projectMeta-BMkGWqtz.js +0 -3
  116. package/dist/assets/renderElectronMiniChatApp-BonR3p7C.js +0 -2
  117. package/dist/assets/renderMobileApp-CZqNs2zK.js +0 -28
  118. package/dist/assets/runtimeEndpointReset-Dpx6GB_U.js +0 -147
  119. package/dist/assets/sessionChangeSummary-BG7l2heP.js +0 -2
  120. package/dist/assets/sessionLookup-BZuvc3Aa.js +0 -1
  121. package/dist/assets/sortable-tabs-strip-dYW6SH62.js +0 -1
  122. package/dist/assets/src-CO9q_82J.css +0 -2
  123. package/dist/assets/textarea-B9CmN13Z.js +0 -1
  124. package/dist/assets/tooltip-DSjr26A_.js +0 -1
  125. package/dist/assets/useDesktopWindowControlsLayout-C6kAkizT.js +0 -1
  126. package/dist/assets/useMobileNavigationStore-DDgESrzu.js +0 -413
  127. package/dist/assets/useSessionAutoCleanup-q2lauc7x.js +0 -2
  128. package/dist/assets/utils-2ZK8yboQ.js +0 -1
@@ -0,0 +1,223 @@
1
+ import { QUESTION_SUBMISSION_CLAIMED_CODE } from './core.js';
2
+
3
+ const asTrimmedString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
4
+
5
+ const readDirectory = (req) => {
6
+ const fromQuery = asTrimmedString(
7
+ Array.isArray(req.query?.directory) ? req.query.directory[0] : req.query?.directory,
8
+ );
9
+ if (fromQuery) return fromQuery;
10
+ const fromBody = asTrimmedString(req.body?.directory);
11
+ if (fromBody) return fromBody;
12
+ const header = req.headers?.['x-opencode-directory'];
13
+ if (typeof header === 'string' && header.trim()) {
14
+ if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
15
+ try {
16
+ return decodeURIComponent(header.trim());
17
+ } catch {
18
+ return header.trim();
19
+ }
20
+ }
21
+ return header.trim();
22
+ }
23
+ return '';
24
+ };
25
+
26
+ const sendMutation = (res, result) => {
27
+ if (result.outcome === 'claimed') {
28
+ return res.status(409).json({
29
+ error: result.error || 'Question submission already claimed',
30
+ code: result.code || QUESTION_SUBMISSION_CLAIMED_CODE,
31
+ outcome: result.outcome,
32
+ snapshot: result.snapshot,
33
+ });
34
+ }
35
+ if (result.outcome === 'not_found') {
36
+ return res.status(404).json({
37
+ error: result.error || 'Not found',
38
+ outcome: result.outcome,
39
+ snapshot: result.snapshot,
40
+ });
41
+ }
42
+ if (result.outcome === 'error') {
43
+ return res.status(result.status || 500).json({
44
+ error: result.error || 'Question auto-delegate error',
45
+ outcome: result.outcome,
46
+ snapshot: result.snapshot,
47
+ });
48
+ }
49
+ if (result.outcome === 'disabled') {
50
+ return res.status(409).json({
51
+ error: result.error || 'Question auto-delegate is disabled',
52
+ outcome: result.outcome,
53
+ snapshot: result.snapshot,
54
+ });
55
+ }
56
+ if (result.outcome === 'paused') {
57
+ return res.json({
58
+ outcome: result.outcome,
59
+ snapshot: result.snapshot,
60
+ });
61
+ }
62
+ return res.json({
63
+ outcome: result.outcome,
64
+ snapshot: result.snapshot,
65
+ ...(result.upstreamStatus != null ? { upstreamStatus: result.upstreamStatus } : {}),
66
+ });
67
+ };
68
+
69
+ /**
70
+ * OpenChamber-owned question auto-delegate routes + precise intercepts for
71
+ * `/api/question/:id/reply|reject` (must register before the generic OpenCode proxy).
72
+ * Manual reply/reject keep the upstream SDK response shape/body on success.
73
+ */
74
+ export function registerQuestionAutoDelegateRoutes(app, runtime) {
75
+ app.get('/api/question-auto-delegate', (_req, res) => {
76
+ try {
77
+ res.json(runtime.snapshot());
78
+ } catch (error) {
79
+ res.status(500).json({ error: error?.message ?? 'Failed to read question auto-delegate snapshot' });
80
+ }
81
+ });
82
+
83
+ app.post('/api/question-auto-delegate/requests/:requestID/pause', async (req, res) => {
84
+ try {
85
+ const result = await runtime.pause({
86
+ requestID: req.params.requestID,
87
+ sessionID: req.body?.sessionID,
88
+ directory: req.body?.directory,
89
+ reason: req.body?.reason,
90
+ });
91
+ return sendMutation(res, result);
92
+ } catch (error) {
93
+ return res.status(500).json({ error: error?.message ?? 'Failed to pause question auto-delegate' });
94
+ }
95
+ });
96
+
97
+ app.post('/api/question-auto-delegate/requests/:requestID/delegate', async (req, res) => {
98
+ try {
99
+ const result = await runtime.delegate({
100
+ requestID: req.params.requestID,
101
+ sessionID: req.body?.sessionID,
102
+ directory: req.body?.directory,
103
+ });
104
+ return sendMutation(res, result);
105
+ } catch (error) {
106
+ return res.status(500).json({ error: error?.message ?? 'Failed to delegate question' });
107
+ }
108
+ });
109
+
110
+ // Precise intercepts — claim then forward to upstream via runtime IO.
111
+ app.post('/api/question/:requestID/reply', async (req, res) => {
112
+ try {
113
+ const requestID = asTrimmedString(req.params.requestID);
114
+ const directory = readDirectory(req);
115
+ const sessionID = asTrimmedString(req.body?.sessionID);
116
+ // Manual path: answers must be present as an array (including []). Never
117
+ // substitute the auto-delegate fixed text.
118
+ if (!req.body || !Object.prototype.hasOwnProperty.call(req.body, 'answers')) {
119
+ return res.status(400).json({ error: 'answers is required' });
120
+ }
121
+ if (!Array.isArray(req.body.answers)) {
122
+ return res.status(400).json({ error: 'answers must be an array' });
123
+ }
124
+
125
+ const result = await runtime.submit({
126
+ requestID,
127
+ sessionID: sessionID || undefined,
128
+ directory: directory || undefined,
129
+ kind: 'reply',
130
+ authority: 'manual',
131
+ answers: req.body.answers,
132
+ body: req.body,
133
+ });
134
+
135
+ if (result.outcome === 'claimed') {
136
+ return res.status(409).json({
137
+ error: result.error || 'Question submission already claimed',
138
+ code: result.code || QUESTION_SUBMISSION_CLAIMED_CODE,
139
+ });
140
+ }
141
+ if (result.outcome === 'uncertain') {
142
+ return res.status(result.upstreamStatus && result.upstreamStatus >= 400 ? result.upstreamStatus : 502).json(
143
+ result.upstreamBody ?? { error: result.error || 'Question reply uncertain' },
144
+ );
145
+ }
146
+ if (result.outcome === 'not_found' || result.upstreamStatus === 404) {
147
+ // Preserve upstream 404 body shape when present.
148
+ if (result.upstreamBody != null) {
149
+ return res.status(404).json(result.upstreamBody);
150
+ }
151
+ return res.status(404).json({ error: 'Question not found' });
152
+ }
153
+ if (result.outcome === 'submitted' || result.outcome === 'settled') {
154
+ const status = result.upstreamStatus && result.upstreamStatus >= 200 ? result.upstreamStatus : 200;
155
+ if (result.upstreamBody === null || result.upstreamBody === undefined) {
156
+ // SDK optional unwrap expects boolean true on success for reply.
157
+ return res.status(status).json(true);
158
+ }
159
+ return res.status(status).json(result.upstreamBody);
160
+ }
161
+ if (result.outcome === 'error') {
162
+ // Definitive upstream reject (400/409/422) — preserve body when present.
163
+ if (result.upstreamBody != null && result.upstreamStatus) {
164
+ return res.status(result.upstreamStatus).json(result.upstreamBody);
165
+ }
166
+ return res.status(result.status || result.upstreamStatus || 500).json({
167
+ error: result.error || 'Question reply failed',
168
+ });
169
+ }
170
+ return res.status(result.upstreamStatus || 500).json(result.upstreamBody ?? { error: 'Question reply failed' });
171
+ } catch (error) {
172
+ return res.status(500).json({ error: error?.message ?? 'Question reply failed' });
173
+ }
174
+ });
175
+
176
+ app.post('/api/question/:requestID/reject', async (req, res) => {
177
+ try {
178
+ const requestID = asTrimmedString(req.params.requestID);
179
+ const directory = readDirectory(req);
180
+ const sessionID = asTrimmedString(req.body?.sessionID);
181
+
182
+ const result = await runtime.submit({
183
+ requestID,
184
+ sessionID: sessionID || undefined,
185
+ directory: directory || undefined,
186
+ kind: 'reject',
187
+ authority: 'manual',
188
+ body: req.body && typeof req.body === 'object' ? req.body : {},
189
+ });
190
+
191
+ if (result.outcome === 'claimed') {
192
+ return res.status(409).json({
193
+ error: result.error || 'Question submission already claimed',
194
+ code: result.code || QUESTION_SUBMISSION_CLAIMED_CODE,
195
+ });
196
+ }
197
+ if (result.outcome === 'uncertain') {
198
+ return res.status(result.upstreamStatus && result.upstreamStatus >= 400 ? result.upstreamStatus : 502).json(
199
+ result.upstreamBody ?? { error: result.error || 'Question reject uncertain' },
200
+ );
201
+ }
202
+ if (result.outcome === 'not_found' || result.upstreamStatus === 404) {
203
+ if (result.upstreamBody != null) {
204
+ return res.status(404).json(result.upstreamBody);
205
+ }
206
+ return res.status(404).json({ error: 'Question not found' });
207
+ }
208
+ if (result.outcome === 'submitted' || result.outcome === 'settled') {
209
+ const status = result.upstreamStatus && result.upstreamStatus >= 200 ? result.upstreamStatus : 200;
210
+ if (result.upstreamBody === null || result.upstreamBody === undefined) {
211
+ return res.status(status).json(true);
212
+ }
213
+ return res.status(status).json(result.upstreamBody);
214
+ }
215
+ if (result.outcome === 'error') {
216
+ return res.status(result.status || 500).json({ error: result.error || 'Question reject failed' });
217
+ }
218
+ return res.status(result.upstreamStatus || 500).json(result.upstreamBody ?? { error: 'Question reject failed' });
219
+ } catch (error) {
220
+ return res.status(500).json({ error: error?.message ?? 'Question reject failed' });
221
+ }
222
+ });
223
+ }
@@ -0,0 +1,180 @@
1
+ import express from 'express';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ QUESTION_AUTO_DELEGATE_ANSWER,
5
+ QUESTION_SUBMISSION_CLAIMED_CODE,
6
+ createQuestionAutoDelegateCore,
7
+ } from './core.js';
8
+ import { registerQuestionAutoDelegateRoutes } from './routes.js';
9
+
10
+ const listen = (app) => new Promise((resolve) => {
11
+ const server = app.listen(0, '127.0.0.1', () => {
12
+ const { port } = server.address();
13
+ resolve({
14
+ server,
15
+ base: `http://127.0.0.1:${port}`,
16
+ close: () => new Promise((r) => server.close(() => r())),
17
+ });
18
+ });
19
+ });
20
+
21
+ const createFakeTimers = () => {
22
+ const timers = [];
23
+ let now = 1_000_000;
24
+ let nextId = 1;
25
+ return {
26
+ now: () => now,
27
+ advance: async (ms) => {
28
+ now += ms;
29
+ const due = timers.filter((t) => !t.cleared && t.fireAt <= now);
30
+ for (const timer of due) {
31
+ timer.cleared = true;
32
+ timer.callback();
33
+ await Promise.resolve();
34
+ await Promise.resolve();
35
+ }
36
+ },
37
+ createTimer: (callback, delayMs) => {
38
+ const timer = { id: nextId++, fireAt: now + delayMs, callback, cleared: false };
39
+ timers.push(timer);
40
+ return { clear: () => { timer.cleared = true; } };
41
+ },
42
+ };
43
+ };
44
+
45
+ describe('question auto-delegate HTTP routes — claim arbitration', () => {
46
+ it('timer and manual POST share one upstream mutation (409 loser)', async () => {
47
+ const clock = createFakeTimers();
48
+ let release;
49
+ const gate = new Promise((resolve) => { release = resolve; });
50
+ let upstreamPosts = 0;
51
+ const posts = [];
52
+
53
+ const core = createQuestionAutoDelegateCore({
54
+ delayMs: 30_000,
55
+ io: {
56
+ now: clock.now,
57
+ createTimer: clock.createTimer,
58
+ createEpoch: () => 'epoch-http',
59
+ readEnabled: async () => true,
60
+ listDirectories: async () => ['/repo'],
61
+ listQuestions: async () => [],
62
+ getSession: async (id, directory) => ({ id, parentID: null, directory: directory || '/repo' }),
63
+ postReply: async (requestID, directory, answers) => {
64
+ upstreamPosts += 1;
65
+ posts.push({ requestID, directory, answers });
66
+ await gate;
67
+ return { ok: true, uncertain: false, status: 200, body: true };
68
+ },
69
+ postReject: async () => ({ ok: true, uncertain: false, status: 200, body: true }),
70
+ },
71
+ });
72
+ core.applyEnabled(true);
73
+ core.processEvent({
74
+ type: 'question.asked',
75
+ properties: {
76
+ id: 'q-race',
77
+ sessionID: 'ses-1',
78
+ directory: '/repo',
79
+ questions: [{ question: '?', header: 'h', options: [{ label: 'a', description: '' }] }],
80
+ },
81
+ });
82
+
83
+ const runtime = {
84
+ snapshot: () => core.snapshot(),
85
+ pause: (input) => core.pause(input),
86
+ delegate: (input) => core.delegate(input),
87
+ submit: (input) => core.submit(input),
88
+ };
89
+
90
+ const app = express();
91
+ app.use(express.json());
92
+ registerQuestionAutoDelegateRoutes(app, runtime);
93
+ const { base, close } = await listen(app);
94
+
95
+ try {
96
+ // Arm timer fire (auto claim starts and waits on gate).
97
+ const timerPromise = (async () => {
98
+ await clock.advance(30_000);
99
+ })();
100
+
101
+ // Give auto claim a tick to start.
102
+ await Promise.resolve();
103
+ await Promise.resolve();
104
+
105
+ const manualResponse = await fetch(`${base}/api/question/q-race/reply?directory=${encodeURIComponent('/repo')}`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ answers: [['human']] }),
109
+ });
110
+ expect(manualResponse.status).toBe(409);
111
+ const manualBody = await manualResponse.json();
112
+ expect(manualBody.code).toBe(QUESTION_SUBMISSION_CLAIMED_CODE);
113
+
114
+ release();
115
+ await timerPromise;
116
+ // Drain auto submit.
117
+ for (let i = 0; i < 20; i += 1) await Promise.resolve();
118
+
119
+ expect(upstreamPosts).toBe(1);
120
+ expect(posts[0].answers).toEqual([[QUESTION_AUTO_DELEGATE_ANSWER]]);
121
+ expect(core.snapshot().requests[0].state).toBe('settled');
122
+ expect(core.snapshot().requests[0].submittedBy).toBe('auto');
123
+ } finally {
124
+ core.dispose();
125
+ await close();
126
+ }
127
+ });
128
+
129
+ it('manual answers:[] are accepted and forwarded without auto text', async () => {
130
+ const posts = [];
131
+ const core = createQuestionAutoDelegateCore({
132
+ io: {
133
+ now: () => 1,
134
+ createTimer: () => ({ clear() {} }),
135
+ createEpoch: () => 'e',
136
+ readEnabled: async () => true,
137
+ listDirectories: async () => ['/repo'],
138
+ listQuestions: async () => [],
139
+ getSession: async (id) => ({ id, parentID: null, directory: '/repo' }),
140
+ postReply: async (requestID, directory, answers) => {
141
+ posts.push(answers);
142
+ return { ok: true, uncertain: false, status: 200, body: true };
143
+ },
144
+ postReject: async () => ({ ok: true, uncertain: false, status: 200, body: true }),
145
+ },
146
+ });
147
+ core.applyEnabled(true);
148
+ core.processEvent({
149
+ type: 'question.asked',
150
+ properties: {
151
+ id: 'q-empty',
152
+ sessionID: 'ses-1',
153
+ directory: '/repo',
154
+ questions: [{ question: '?', header: 'h', options: [] }],
155
+ },
156
+ });
157
+
158
+ const app = express();
159
+ app.use(express.json());
160
+ registerQuestionAutoDelegateRoutes(app, {
161
+ snapshot: () => core.snapshot(),
162
+ pause: (i) => core.pause(i),
163
+ delegate: (i) => core.delegate(i),
164
+ submit: (i) => core.submit(i),
165
+ });
166
+ const { base, close } = await listen(app);
167
+ try {
168
+ const response = await fetch(`${base}/api/question/q-empty/reply`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ answers: [], directory: '/repo' }),
172
+ });
173
+ expect(response.status).toBe(200);
174
+ expect(posts).toEqual([[]]);
175
+ } finally {
176
+ core.dispose();
177
+ await close();
178
+ }
179
+ });
180
+ });
@@ -0,0 +1,243 @@
1
+ import {
2
+ createQuestionAutoDelegateCore,
3
+ QUESTION_AUTO_DELEGATE_DELAY_MS,
4
+ } from './core.js';
5
+
6
+ const REQUEST_TIMEOUT_MS = 15_000;
7
+
8
+ const asTrimmedString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
9
+
10
+ const parseJsonSafe = async (response) => {
11
+ const text = await response.text().catch(() => '');
12
+ if (!text) return null;
13
+ try {
14
+ return JSON.parse(text);
15
+ } catch {
16
+ return text;
17
+ }
18
+ };
19
+
20
+ /**
21
+ * Web Host adapter: injects OpenCode upstream IO + event hub into the shared core.
22
+ * Does not loop through Host `/api/question/*` routes (avoids claim re-entry).
23
+ */
24
+ export function createQuestionAutoDelegateRuntime({
25
+ globalEventHub,
26
+ buildOpenCodeUrl,
27
+ getOpenCodeAuthHeaders,
28
+ readSettingsFromDiskMigrated,
29
+ sanitizeProjects,
30
+ /** Optional: session-index / worktree inventory directories. */
31
+ listIndexedDirectories,
32
+ broadcastGlobalUiEvent,
33
+ broadcastOpenChamberEvent,
34
+ fetchImpl = fetch,
35
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
36
+ delayMs = QUESTION_AUTO_DELEGATE_DELAY_MS,
37
+ now = () => Date.now(),
38
+ createTimer = (callback, ms) => {
39
+ const handle = setTimeout(callback, ms);
40
+ return { clear: () => clearTimeout(handle) };
41
+ },
42
+ createEpoch = () => `qad-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
43
+ onUserTakeover,
44
+ } = {}) {
45
+ const upstream = async (path, { directory, method = 'GET', body } = {}) => {
46
+ const url = new URL(buildOpenCodeUrl(path, ''));
47
+ if (directory) url.searchParams.set('directory', directory);
48
+ let response;
49
+ try {
50
+ response = await fetchImpl(url, {
51
+ method,
52
+ headers: {
53
+ Accept: 'application/json',
54
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
55
+ ...getOpenCodeAuthHeaders(),
56
+ },
57
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
58
+ signal: AbortSignal.timeout(requestTimeoutMs),
59
+ });
60
+ } catch (error) {
61
+ return {
62
+ ok: false,
63
+ uncertain: true,
64
+ status: 0,
65
+ body: { error: error?.message ?? 'Upstream request failed' },
66
+ };
67
+ }
68
+
69
+ const parsed = await parseJsonSafe(response);
70
+ if (response.ok) {
71
+ return { ok: true, uncertain: false, status: response.status, body: parsed };
72
+ }
73
+ // Definitive client/conflict rejects — core releases claim for manual retry.
74
+ if (response.status === 404 || response.status === 400 || response.status === 409 || response.status === 422) {
75
+ return { ok: false, uncertain: false, status: response.status, body: parsed };
76
+ }
77
+ if (response.status >= 500 || response.status === 0) {
78
+ return { ok: false, uncertain: true, status: response.status, body: parsed };
79
+ }
80
+ return { ok: false, uncertain: true, status: response.status, body: parsed };
81
+ };
82
+
83
+ const emitChanged = (tip) => {
84
+ const event = {
85
+ type: 'openchamber:question-auto-delegate-changed',
86
+ properties: {
87
+ epoch: tip.epoch,
88
+ revision: tip.revision,
89
+ },
90
+ };
91
+ try {
92
+ broadcastOpenChamberEvent?.(event);
93
+ } catch {
94
+ // ignore
95
+ }
96
+ try {
97
+ broadcastGlobalUiEvent?.(event);
98
+ } catch {
99
+ // ignore
100
+ }
101
+ };
102
+
103
+ const core = createQuestionAutoDelegateCore({
104
+ delayMs,
105
+ io: {
106
+ now,
107
+ createTimer,
108
+ createEpoch,
109
+ onChanged: emitChanged,
110
+ onUserTakeover,
111
+ /**
112
+ * @returns {Promise<boolean|null>} true/false when known; null when settings unreadable.
113
+ */
114
+ async readEnabled() {
115
+ try {
116
+ const settings = await readSettingsFromDiskMigrated();
117
+ if (settings?.questionAutoDelegateEnabled === false) return false;
118
+ return true;
119
+ } catch {
120
+ return null;
121
+ }
122
+ },
123
+ async listDirectories() {
124
+ const dirs = new Set();
125
+ try {
126
+ const settings = await readSettingsFromDiskMigrated();
127
+ const projects = typeof sanitizeProjects === 'function'
128
+ ? (sanitizeProjects(settings?.projects ?? []) || [])
129
+ : (Array.isArray(settings?.projects) ? settings.projects : []);
130
+ for (const project of projects) {
131
+ const path = asTrimmedString(project?.path);
132
+ if (path) dirs.add(path);
133
+ }
134
+ } catch {
135
+ // Project list failure is not fatal when index inventory exists.
136
+ }
137
+ if (typeof listIndexedDirectories === 'function') {
138
+ try {
139
+ const indexed = await listIndexedDirectories();
140
+ if (Array.isArray(indexed)) {
141
+ for (const directory of indexed) {
142
+ const path = asTrimmedString(directory);
143
+ if (path) dirs.add(path);
144
+ }
145
+ }
146
+ } catch {
147
+ // ignore index failures — core marks partial when inventory empty+failed
148
+ }
149
+ }
150
+ return Array.from(dirs);
151
+ },
152
+ async listQuestions(directory) {
153
+ const result = await upstream('/question', { directory });
154
+ if (!result.ok) return null;
155
+ const payload = result.body;
156
+ if (Array.isArray(payload)) return payload;
157
+ if (Array.isArray(payload?.data)) return payload.data;
158
+ return null;
159
+ },
160
+ async getSession(sessionID, directory) {
161
+ const result = await upstream(`/session/${encodeURIComponent(sessionID)}`, { directory });
162
+ if (!result.ok) return null;
163
+ const info = result.body?.data && typeof result.body.data === 'object'
164
+ ? result.body.data
165
+ : result.body;
166
+ if (!info || typeof info !== 'object') return null;
167
+ return {
168
+ id: asTrimmedString(info.id) || sessionID,
169
+ parentID: asTrimmedString(info.parentID) || null,
170
+ directory: asTrimmedString(info.directory) || asTrimmedString(directory) || null,
171
+ };
172
+ },
173
+ async postReply(requestID, directory, answers) {
174
+ return upstream(`/question/${encodeURIComponent(requestID)}/reply`, {
175
+ directory,
176
+ method: 'POST',
177
+ body: { answers },
178
+ });
179
+ },
180
+ async postReject(requestID, directory, body) {
181
+ return upstream(`/question/${encodeURIComponent(requestID)}/reject`, {
182
+ directory,
183
+ method: 'POST',
184
+ body: body && typeof body === 'object' ? body : {},
185
+ });
186
+ },
187
+ },
188
+ });
189
+
190
+ const processHubEvent = (event) => {
191
+ const raw = event?.payload;
192
+ const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw;
193
+ const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global'
194
+ ? event.directory
195
+ : '';
196
+ if (!payload || typeof payload !== 'object') return;
197
+ core.processEvent(payload, directory);
198
+ };
199
+
200
+ const start = () => {
201
+ const unsubscribers = [];
202
+ if (globalEventHub?.subscribeEvent) {
203
+ unsubscribers.push(globalEventHub.subscribeEvent(processHubEvent));
204
+ }
205
+ if (globalEventHub?.subscribeStatus) {
206
+ unsubscribers.push(globalEventHub.subscribeStatus((status) => {
207
+ if (status?.type === 'connect') {
208
+ void core.reconcile();
209
+ }
210
+ }));
211
+ }
212
+ const stopCore = core.start();
213
+ const stop = () => {
214
+ for (const unsubscribe of unsubscribers) {
215
+ try {
216
+ unsubscribe?.();
217
+ } catch {
218
+ // ignore
219
+ }
220
+ }
221
+ stopCore?.();
222
+ core.dispose();
223
+ };
224
+ core._attachStop?.(stop);
225
+ return stop;
226
+ };
227
+
228
+ return {
229
+ start,
230
+ dispose: () => core.dispose(),
231
+ snapshot: () => core.snapshot(),
232
+ applyEnabled: (enabled) => core.applyEnabled(enabled),
233
+ pause: (input) => core.pause(input),
234
+ delegate: (input) => core.delegate(input),
235
+ submit: (input) => core.submit(input),
236
+ reconcile: (options) => core.reconcile(options),
237
+ processEvent: (payload, directory) => core.processEvent(payload, directory),
238
+ isBlockingSession: (sessionID) => core.isBlockingSession(sessionID),
239
+ isAutoHandling: (sessionID, requestID) => core.isAutoHandling(sessionID, requestID),
240
+ pauseForSessionTree: (sessionID, directory) => core.pauseForSessionTree(sessionID, directory),
241
+ resolveRootSession: (sessionID) => core.resolveRootSession(sessionID),
242
+ };
243
+ }
@@ -104,11 +104,21 @@ before touching the filesystem). Rationale: metadata rides every
104
104
  stop), with a tick-side safety net. Messages sent while paused leave
105
105
  the goal alone; Resume re-arms the loop, and resuming over an aborted
106
106
  tail skips the audit and goes straight to a continuation nudge;
107
- - `question.asked` also pauses an active goal (including when a child /
107
+ - `question.asked` pauses an active goal (including when a child /
108
108
  sub-agent session asks — the parent session's goal is the one paused)
109
109
  but does NOT abort the current turn: aborting would kill the pending
110
110
  question. After the user answers, they Resume manually to re-arm
111
- the loop;
111
+ the loop. **Exception:** when question auto-delegate is handling the
112
+ ask (`shouldKeepGoalActiveForQuestion`), the goal stays `active` and
113
+ only the idle timer is cleared; `tick` also bails while
114
+ `isQuestionBlockingGoal` reports a pending auto-delegated question on
115
+ the session or a known descendant. User pause / disable of auto-delegate
116
+ still calls `pauseForQuestion`. See `lib/question-auto-delegate/`.
117
+ Explicit `session.updated` with `status: paused` notifies `onGoalPaused`
118
+ (cancels related question timers) **except** when `statusReason` is
119
+ `paused for question` (auto-delegate must keep counting). Abort after
120
+ metadata already paused still notifies for non-question pause reasons
121
+ (UI concurrent abort + metadata race).
112
122
  - terminal checks, cheapest first: assistant turn error → `blocked`;
113
123
  `tokensUsed >= tokenBudget` → `budgetLimited`;
114
124
  `turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;