@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,1372 @@
1
+ export const QUESTION_AUTO_DELEGATE_DELAY_MS = 30_000;
2
+
3
+ export const QUESTION_AUTO_DELEGATE_ANSWER =
4
+ '当前用户暂未响应,请根据任务目标和已有上下文自行判断,选择合理方案并继续执行。';
5
+
6
+ export const QUESTION_SUBMISSION_CLAIMED_CODE = 'question_submission_claimed';
7
+
8
+ /** Definitive client/upstream rejections that did not leave an ambiguous send. */
9
+ const DEFINITIVE_REJECT_STATUSES = new Set([400, 409, 422]);
10
+
11
+ const TERMINAL_SETTLED = 'settled';
12
+ const BLOCKING_STATES = new Set(['counting', 'paused', 'disabled', 'submitting', 'uncertain']);
13
+
14
+ const asTrimmedString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
15
+
16
+ export const buildAutoDelegateAnswers = (questionCount, answerText = QUESTION_AUTO_DELEGATE_ANSWER) => {
17
+ const count = Number.isFinite(questionCount) && questionCount > 0 ? Math.floor(questionCount) : 1;
18
+ const text = typeof answerText === 'string' && answerText ? answerText : QUESTION_AUTO_DELEGATE_ANSWER;
19
+ return Array.from({ length: count }, () => [text]);
20
+ };
21
+
22
+ const normalizeQuestion = (raw, directoryHint = '') => {
23
+ if (!raw || typeof raw !== 'object') return null;
24
+ const requestID = asTrimmedString(raw.id) || asTrimmedString(raw.requestID);
25
+ const sessionID = asTrimmedString(raw.sessionID);
26
+ if (!requestID || !sessionID) return null;
27
+ const directory = asTrimmedString(raw.directory) || asTrimmedString(directoryHint);
28
+ const questions = Array.isArray(raw.questions) ? raw.questions : [];
29
+ return {
30
+ requestID,
31
+ sessionID,
32
+ directory,
33
+ questionCount: questions.length > 0 ? questions.length : 1,
34
+ questions,
35
+ };
36
+ };
37
+
38
+ const publicRequest = (entry) => ({
39
+ requestID: entry.requestID,
40
+ sessionID: entry.sessionID,
41
+ directory: entry.directory,
42
+ questionCount: entry.questionCount,
43
+ state: entry.state,
44
+ deadlineAt: entry.deadlineAt,
45
+ pauseReason: entry.pauseReason,
46
+ submittedBy: entry.submittedBy,
47
+ resolution: entry.resolution,
48
+ });
49
+
50
+ const mergeDirectoryLists = (...lists) => {
51
+ const out = new Set();
52
+ for (const list of lists) {
53
+ if (!Array.isArray(list)) continue;
54
+ for (const item of list) {
55
+ const value = asTrimmedString(item);
56
+ if (value) out.add(value);
57
+ }
58
+ }
59
+ return Array.from(out);
60
+ };
61
+
62
+ export function createQuestionAutoDelegateCore({
63
+ io,
64
+ delayMs = QUESTION_AUTO_DELEGATE_DELAY_MS,
65
+ autoAnswer = QUESTION_AUTO_DELEGATE_ANSWER,
66
+ } = {}) {
67
+ if (!io || typeof io !== 'object') {
68
+ throw new TypeError('createQuestionAutoDelegateCore requires io');
69
+ }
70
+
71
+ const epoch = typeof io.createEpoch === 'function' ? io.createEpoch() : `qad-${Date.now()}`;
72
+ let revision = 0;
73
+ /** @type {'loading' | 'ready' | 'unavailable'} */
74
+ let settingsStatus = 'loading';
75
+ let enabled = false;
76
+ /** Bumped on every applyEnabled so a late start() read cannot overwrite newer settings. */
77
+ let settingsGeneration = 0;
78
+ let disposed = false;
79
+ let coverageState = 'ready';
80
+ /** @type {string[]} */
81
+ let failedDirectories = [];
82
+ /** @type {Map<string, any>} */
83
+ const requests = new Map();
84
+ /** @type {Map<string, { parentID: string | null, directory: string }>} */
85
+ const sessions = new Map();
86
+ /** Directories observed from events / requests (worktree child paths etc.). */
87
+ const observedDirectories = new Set();
88
+ let reconcilePromise = null;
89
+ /** @type {string[] | null} */
90
+ let trailingReconcileDirectories = null;
91
+ let started = false;
92
+ /** @type {null | (() => void)} */
93
+ let stopHub = null;
94
+
95
+ const noteDirectory = (directory) => {
96
+ const value = asTrimmedString(directory);
97
+ if (value) observedDirectories.add(value);
98
+ };
99
+
100
+ const bump = () => {
101
+ revision += 1;
102
+ try {
103
+ io.onChanged?.({ epoch, revision });
104
+ } catch {
105
+ // Tip broadcast must never break core transitions.
106
+ }
107
+ };
108
+
109
+ const snapshot = () => ({
110
+ epoch,
111
+ revision,
112
+ serverNow: io.now(),
113
+ enabled: settingsStatus === 'ready' ? enabled : false,
114
+ delayMs,
115
+ coverage: {
116
+ state: settingsStatus === 'unavailable' && coverageState === 'ready' ? 'partial' : coverageState,
117
+ failedDirectories: failedDirectories.slice(),
118
+ },
119
+ requests: Array.from(requests.values()).map(publicRequest),
120
+ });
121
+
122
+ const rememberSession = (info, directoryHint = '') => {
123
+ if (!info || typeof info !== 'object') return;
124
+ const id = asTrimmedString(info.id);
125
+ if (!id) return;
126
+ const existing = sessions.get(id);
127
+ const parentID = asTrimmedString(info.parentID) || existing?.parentID || null;
128
+ const directory = asTrimmedString(info.directory)
129
+ || asTrimmedString(directoryHint)
130
+ || existing?.directory
131
+ || '';
132
+ sessions.set(id, { parentID, directory });
133
+ noteDirectory(directory);
134
+ };
135
+
136
+ const clearTimer = (entry) => {
137
+ if (entry?.timer) {
138
+ try {
139
+ entry.timer.clear();
140
+ } catch {
141
+ // ignore
142
+ }
143
+ entry.timer = null;
144
+ }
145
+ };
146
+
147
+ const fireTakeover = (entry, reason) => {
148
+ try {
149
+ io.onUserTakeover?.({
150
+ requestID: entry.requestID,
151
+ sessionID: entry.sessionID,
152
+ directory: entry.directory,
153
+ reason,
154
+ });
155
+ } catch {
156
+ // ignore
157
+ }
158
+ };
159
+
160
+ const settingsAllowAuto = () => settingsStatus === 'ready' && enabled === true;
161
+
162
+ /** Auto-submit requires ready settings, enabled flag, and an authoritative directory binding. */
163
+ const canAutoSubmit = (entry) => (
164
+ settingsAllowAuto()
165
+ && entry
166
+ && entry.state === 'counting'
167
+ && Boolean(asTrimmedString(entry.directory))
168
+ );
169
+
170
+ const scheduleAuto = (entry) => {
171
+ clearTimer(entry);
172
+ if (disposed || !canAutoSubmit(entry)) return;
173
+ const now = io.now();
174
+ if (entry.deadlineAt == null) {
175
+ entry.deadlineAt = now + delayMs;
176
+ }
177
+ const remaining = Math.max(0, entry.deadlineAt - now);
178
+ entry.timer = io.createTimer(() => {
179
+ entry.timer = null;
180
+ if (disposed || !canAutoSubmit(entry)) return;
181
+ void submitInternal({
182
+ requestID: entry.requestID,
183
+ sessionID: entry.sessionID,
184
+ directory: entry.directory,
185
+ kind: 'reply',
186
+ authority: 'auto',
187
+ answers: buildAutoDelegateAnswers(entry.questionCount, autoAnswer),
188
+ });
189
+ }, remaining);
190
+ };
191
+
192
+ const ensureCounting = (entry, { resetDeadline } = { resetDeadline: true }) => {
193
+ if (entry.state === TERMINAL_SETTLED || entry.state === 'uncertain' || entry.state === 'submitting') {
194
+ return false;
195
+ }
196
+ if (!settingsAllowAuto()) {
197
+ // Settings loading/unavailable or feature off: never arm auto timers.
198
+ clearTimer(entry);
199
+ if (settingsStatus === 'ready' && !enabled) {
200
+ entry.state = 'disabled';
201
+ entry.deadlineAt = null;
202
+ entry.pauseReason = null;
203
+ } else if (entry.state !== 'paused') {
204
+ // Keep identity but do not count until settings + directory are ready.
205
+ entry.state = entry.state === 'paused' ? 'paused' : 'counting';
206
+ entry.deadlineAt = null;
207
+ }
208
+ return false;
209
+ }
210
+ if (entry.state === 'paused') return false;
211
+ entry.state = 'counting';
212
+ entry.pauseReason = null;
213
+ if (!asTrimmedString(entry.directory)) {
214
+ // Bound directory required before any auto timer.
215
+ clearTimer(entry);
216
+ entry.deadlineAt = null;
217
+ return false;
218
+ }
219
+ if (resetDeadline || entry.deadlineAt == null) {
220
+ entry.deadlineAt = io.now() + delayMs;
221
+ }
222
+ scheduleAuto(entry);
223
+ return true;
224
+ };
225
+
226
+ const bindDirectoryToEntry = (entry, directory, { restartTimer = true } = {}) => {
227
+ const next = asTrimmedString(directory);
228
+ if (!next) return false;
229
+ if (entry.directory && entry.directory !== next) return false;
230
+ const wasMissing = !entry.directory;
231
+ entry.directory = next;
232
+ noteDirectory(next);
233
+ if (wasMissing && restartTimer && entry.state !== 'paused' && entry.state !== 'disabled') {
234
+ ensureCounting(entry, { resetDeadline: true });
235
+ return true;
236
+ }
237
+ if (entry.state === 'counting' && !entry.timer && canAutoSubmit(entry)) {
238
+ scheduleAuto(entry);
239
+ return true;
240
+ }
241
+ return wasMissing;
242
+ };
243
+
244
+ const trackQuestion = (normalized, { resetIfDuplicate = false } = {}) => {
245
+ const existing = requests.get(normalized.requestID);
246
+ if (existing) {
247
+ if (existing.state === TERMINAL_SETTLED || existing.state === 'uncertain' || existing.state === 'submitting') {
248
+ return { entry: existing, changed: false };
249
+ }
250
+ let changed = false;
251
+ if (normalized.directory) {
252
+ if (!existing.directory) {
253
+ existing.directory = normalized.directory;
254
+ noteDirectory(normalized.directory);
255
+ changed = true;
256
+ }
257
+ // Authoritative same-directory refine only; never clobber a different binding here.
258
+ }
259
+ if (normalized.sessionID && !existing.sessionID) {
260
+ existing.sessionID = normalized.sessionID;
261
+ changed = true;
262
+ }
263
+ if (normalized.questionCount > 0 && normalized.questionCount !== existing.questionCount) {
264
+ existing.questionCount = normalized.questionCount;
265
+ changed = true;
266
+ }
267
+ if (resetIfDuplicate) {
268
+ const counting = ensureCounting(existing, { resetDeadline: true });
269
+ return { entry: existing, changed: counting || changed };
270
+ }
271
+ if (changed && existing.state === 'counting') {
272
+ ensureCounting(existing, { resetDeadline: false });
273
+ }
274
+ return { entry: existing, changed };
275
+ }
276
+
277
+ const entry = {
278
+ requestID: normalized.requestID,
279
+ sessionID: normalized.sessionID,
280
+ directory: normalized.directory || '',
281
+ questionCount: normalized.questionCount,
282
+ state: 'counting',
283
+ deadlineAt: null,
284
+ pauseReason: null,
285
+ submittedBy: null,
286
+ resolution: null,
287
+ claimAuthority: null,
288
+ /** True once an upstream POST body left this process (or uncertain result). */
289
+ upstreamDispatched: false,
290
+ timer: null,
291
+ };
292
+ noteDirectory(entry.directory);
293
+ requests.set(entry.requestID, entry);
294
+ ensureCounting(entry, { resetDeadline: true });
295
+ return { entry, changed: true };
296
+ };
297
+
298
+ const markSettled = (entry, resolution, submittedBy = entry.submittedBy) => {
299
+ clearTimer(entry);
300
+ entry.state = TERMINAL_SETTLED;
301
+ entry.deadlineAt = null;
302
+ entry.pauseReason = null;
303
+ entry.resolution = resolution;
304
+ entry.claimAuthority = entry.claimAuthority || 'done';
305
+ if (submittedBy) entry.submittedBy = submittedBy;
306
+ };
307
+
308
+ const markUncertain = (entry, submittedBy) => {
309
+ clearTimer(entry);
310
+ entry.state = 'uncertain';
311
+ entry.deadlineAt = null;
312
+ entry.pauseReason = null;
313
+ entry.resolution = null;
314
+ entry.upstreamDispatched = true;
315
+ // Keep claimAuthority tombstone so concurrent POSTs stay 409.
316
+ if (!entry.claimAuthority) entry.claimAuthority = 'uncertain';
317
+ if (submittedBy) entry.submittedBy = submittedBy;
318
+ };
319
+
320
+ /** Release claim after a definitive pre-send / clear reject so a human can retry. */
321
+ const releaseClaimToPaused = (entry, pauseReason = 'user') => {
322
+ clearTimer(entry);
323
+ entry.claimAuthority = null;
324
+ entry.upstreamDispatched = false;
325
+ entry.state = 'paused';
326
+ entry.deadlineAt = null;
327
+ entry.pauseReason = pauseReason;
328
+ entry.submittedBy = null;
329
+ entry.resolution = null;
330
+ };
331
+
332
+ const resolveRootSession = (sessionID) => {
333
+ const id = asTrimmedString(sessionID);
334
+ if (!id) return { sessionID: '', directory: '' };
335
+ const seen = new Set();
336
+ let current = id;
337
+ let directory = sessions.get(id)?.directory || '';
338
+ while (current && !seen.has(current)) {
339
+ seen.add(current);
340
+ const info = sessions.get(current);
341
+ if (!info?.parentID) {
342
+ return { sessionID: current, directory: info?.directory || directory };
343
+ }
344
+ directory = info.directory || directory;
345
+ current = info.parentID;
346
+ }
347
+ return { sessionID: current || id, directory };
348
+ };
349
+
350
+ /**
351
+ * Recover a pending question identity via scoped list (never invent questionCount=1).
352
+ * @returns {Promise<ReturnType<typeof normalizeQuestion> | null>}
353
+ */
354
+ const recoverPendingIdentity = async ({ requestID, sessionID, directoryHint }) => {
355
+ const scopes = mergeDirectoryLists(
356
+ [directoryHint],
357
+ sessionID ? [sessions.get(sessionID)?.directory] : [],
358
+ Array.from(observedDirectories),
359
+ );
360
+ if (scopes.length === 0) {
361
+ try {
362
+ const listed = await io.listDirectories();
363
+ scopes.push(...mergeDirectoryLists(listed));
364
+ } catch {
365
+ // ignore
366
+ }
367
+ }
368
+ // Prefer scoped directories; unscoped last and never treated as authoritative alone for identity.
369
+ for (const directory of scopes) {
370
+ let listed;
371
+ try {
372
+ listed = await io.listQuestions(directory);
373
+ } catch {
374
+ listed = null;
375
+ }
376
+ if (!listed) continue;
377
+ for (const item of listed) {
378
+ const normalized = normalizeQuestion(item, directory);
379
+ if (!normalized || normalized.requestID !== requestID) continue;
380
+ if (sessionID && normalized.sessionID && normalized.sessionID !== sessionID) continue;
381
+ if (!normalized.directory) normalized.directory = directory;
382
+ return normalized;
383
+ }
384
+ }
385
+ return null;
386
+ };
387
+
388
+ /** Best-effort parent walk + write directory write-back. */
389
+ const ensureSessionLineage = async (sessionID, directoryHint = '', requestID = '') => {
390
+ const id = asTrimmedString(sessionID);
391
+ if (!id || typeof io.getSession !== 'function') return;
392
+ const seen = new Set();
393
+ let current = id;
394
+ let directory = asTrimmedString(directoryHint);
395
+ let writebackDirectory = '';
396
+ while (current && !seen.has(current)) {
397
+ seen.add(current);
398
+ const cached = sessions.get(current);
399
+ if (cached?.parentID) {
400
+ directory = cached.directory || directory;
401
+ if (current === id && cached.directory) writebackDirectory = cached.directory;
402
+ current = cached.parentID;
403
+ continue;
404
+ }
405
+ let info = null;
406
+ try {
407
+ info = await io.getSession(current, directory || undefined);
408
+ } catch {
409
+ break;
410
+ }
411
+ if (!info) break;
412
+ rememberSession(info, directory);
413
+ directory = asTrimmedString(info.directory) || directory;
414
+ if (current === id && directory) writebackDirectory = directory;
415
+ const parentID = asTrimmedString(info.parentID) || null;
416
+ if (!parentID) break;
417
+ current = parentID;
418
+ }
419
+
420
+ if (writebackDirectory && requestID) {
421
+ const entry = requests.get(requestID);
422
+ if (entry && bindDirectoryToEntry(entry, writebackDirectory, { restartTimer: true })) {
423
+ bump();
424
+ }
425
+ } else if (writebackDirectory) {
426
+ // Still refresh any counting entries for this session missing a directory.
427
+ let changed = false;
428
+ for (const entry of requests.values()) {
429
+ if (entry.sessionID !== id) continue;
430
+ if (bindDirectoryToEntry(entry, writebackDirectory, { restartTimer: true })) changed = true;
431
+ }
432
+ if (changed) bump();
433
+ }
434
+ };
435
+
436
+ /**
437
+ * Synchronous claim. Client session/directory hints never override bound identity.
438
+ * @returns {{ ok: true, entry: any } | { ok: false, reason: string, entry?: any }}
439
+ */
440
+ const claim = (requestID, authority, meta = {}) => {
441
+ let entry = requests.get(requestID);
442
+ const hintSession = asTrimmedString(meta.sessionID);
443
+ const hintDirectory = asTrimmedString(meta.directory);
444
+
445
+ if (!entry) {
446
+ entry = {
447
+ requestID,
448
+ sessionID: hintSession,
449
+ directory: hintDirectory,
450
+ questionCount: Number.isFinite(meta.questionCount) && meta.questionCount > 0
451
+ ? Math.floor(meta.questionCount)
452
+ : 0,
453
+ state: 'counting',
454
+ deadlineAt: null,
455
+ pauseReason: null,
456
+ submittedBy: null,
457
+ resolution: null,
458
+ claimAuthority: null,
459
+ upstreamDispatched: false,
460
+ timer: null,
461
+ };
462
+ noteDirectory(entry.directory);
463
+ requests.set(requestID, entry);
464
+ } else {
465
+ if (entry.state === TERMINAL_SETTLED) {
466
+ return { ok: false, reason: 'settled', entry };
467
+ }
468
+ if (entry.state === 'uncertain' || entry.claimAuthority || entry.state === 'submitting') {
469
+ return { ok: false, reason: 'claimed', entry };
470
+ }
471
+ // Bound identity wins — conflicting client hints are rejected.
472
+ if (entry.sessionID && hintSession && entry.sessionID !== hintSession) {
473
+ return { ok: false, reason: 'conflict', entry };
474
+ }
475
+ if (entry.directory && hintDirectory && entry.directory !== hintDirectory) {
476
+ return { ok: false, reason: 'conflict', entry };
477
+ }
478
+ if (!entry.sessionID && hintSession) entry.sessionID = hintSession;
479
+ if (!entry.directory && hintDirectory) {
480
+ entry.directory = hintDirectory;
481
+ noteDirectory(hintDirectory);
482
+ }
483
+ if (
484
+ Number.isFinite(meta.questionCount)
485
+ && meta.questionCount > 0
486
+ && (!entry.questionCount || entry.questionCount < 1)
487
+ ) {
488
+ entry.questionCount = Math.floor(meta.questionCount);
489
+ }
490
+ }
491
+
492
+ clearTimer(entry);
493
+ entry.claimAuthority = authority;
494
+ entry.state = 'submitting';
495
+ entry.deadlineAt = null;
496
+ entry.pauseReason = null;
497
+ entry.submittedBy = authority === 'manual' ? 'manual' : 'auto';
498
+ return { ok: true, entry };
499
+ };
500
+
501
+ const submitInternal = async (input) => {
502
+ if (disposed) {
503
+ return { outcome: 'error', snapshot: snapshot(), error: 'disposed', status: 500 };
504
+ }
505
+ const requestID = asTrimmedString(input?.requestID);
506
+ if (!requestID) {
507
+ return { outcome: 'error', snapshot: snapshot(), error: 'requestID is required', status: 400 };
508
+ }
509
+ const authority = input.authority === 'manual' || input.authority === 'delegate' || input.authority === 'auto'
510
+ ? input.authority
511
+ : 'auto';
512
+ const kind = input.kind === 'reject' ? 'reject' : 'reply';
513
+
514
+ // Manual reply: never substitute the auto-delegate fixed text. Require an array
515
+ // (including empty) and forward it as-is.
516
+ if (authority === 'manual' && kind === 'reply') {
517
+ if (!Array.isArray(input.answers)) {
518
+ return {
519
+ outcome: 'error',
520
+ snapshot: snapshot(),
521
+ error: 'answers must be an array',
522
+ status: 400,
523
+ };
524
+ }
525
+ }
526
+
527
+ // Auto path needs settings ready + directory; refuse without claiming when not eligible.
528
+ if (authority === 'auto') {
529
+ if (!settingsAllowAuto()) {
530
+ return {
531
+ outcome: 'disabled',
532
+ snapshot: snapshot(),
533
+ status: 409,
534
+ error: settingsStatus === 'unavailable'
535
+ ? 'Question auto-delegate settings unavailable'
536
+ : 'Question auto-delegate is not ready',
537
+ };
538
+ }
539
+ const existing = requests.get(requestID);
540
+ if (existing && !asTrimmedString(existing.directory) && !asTrimmedString(input.directory)) {
541
+ return {
542
+ outcome: 'error',
543
+ snapshot: snapshot(),
544
+ error: 'Authoritative directory required before auto-submit',
545
+ status: 409,
546
+ };
547
+ }
548
+ }
549
+
550
+ const claimed = claim(requestID, authority, {
551
+ sessionID: input.sessionID,
552
+ directory: input.directory,
553
+ questionCount: Array.isArray(input.answers) && input.answers.length > 0
554
+ ? input.answers.length
555
+ : undefined,
556
+ });
557
+
558
+ if (!claimed.ok) {
559
+ if (claimed.reason === 'conflict') {
560
+ return {
561
+ outcome: 'error',
562
+ snapshot: snapshot(),
563
+ status: 409,
564
+ error: 'sessionID/directory conflicts with bound request identity',
565
+ };
566
+ }
567
+ if (claimed.reason === 'settled') {
568
+ return {
569
+ outcome: 'settled',
570
+ snapshot: snapshot(),
571
+ upstreamStatus: 409,
572
+ error: 'Question already settled',
573
+ };
574
+ }
575
+ return {
576
+ outcome: 'claimed',
577
+ code: QUESTION_SUBMISSION_CLAIMED_CODE,
578
+ snapshot: snapshot(),
579
+ status: 409,
580
+ error: 'Question submission already claimed',
581
+ };
582
+ }
583
+
584
+ const entry = claimed.entry;
585
+ // Manual claim is a user takeover for goal linkage.
586
+ if (authority === 'manual') {
587
+ fireTakeover(entry, 'interaction');
588
+ }
589
+ bump();
590
+
591
+ const directory = asTrimmedString(entry.directory) || asTrimmedString(input.directory) || undefined;
592
+
593
+ // Auto still needs directory at POST time.
594
+ if (authority === 'auto' && !directory) {
595
+ releaseClaimToPaused(entry, null);
596
+ entry.state = 'counting';
597
+ entry.pauseReason = null;
598
+ bump();
599
+ return {
600
+ outcome: 'error',
601
+ snapshot: snapshot(),
602
+ error: 'Authoritative directory required before auto-submit',
603
+ status: 409,
604
+ };
605
+ }
606
+
607
+ let upstream;
608
+ try {
609
+ if (kind === 'reject') {
610
+ entry.upstreamDispatched = true;
611
+ upstream = await io.postReject(requestID, directory, input.body);
612
+ } else if (authority === 'manual') {
613
+ entry.upstreamDispatched = true;
614
+ // Forward the caller payload exactly (answers may be empty).
615
+ upstream = await io.postReply(requestID, directory, input.answers);
616
+ } else {
617
+ const count = entry.questionCount > 0 ? entry.questionCount : 1;
618
+ const answers = Array.isArray(input.answers) && input.answers.length > 0
619
+ ? input.answers
620
+ : buildAutoDelegateAnswers(count, autoAnswer);
621
+ entry.upstreamDispatched = true;
622
+ upstream = await io.postReply(requestID, directory, answers);
623
+ }
624
+ } catch (error) {
625
+ // Throw before a classified upstream result: treat as pre-send failure → release for retry.
626
+ if (entry.state === TERMINAL_SETTLED) {
627
+ return { outcome: 'settled', snapshot: snapshot() };
628
+ }
629
+ releaseClaimToPaused(entry, authority === 'manual' ? 'interaction' : 'user');
630
+ bump();
631
+ return {
632
+ outcome: 'error',
633
+ snapshot: snapshot(),
634
+ error: error?.message ?? 'Upstream submission failed before confirmation',
635
+ status: 502,
636
+ };
637
+ }
638
+
639
+ // Late authoritative settle must not be downgraded by a slow POST failure.
640
+ if (entry.state === TERMINAL_SETTLED) {
641
+ return {
642
+ outcome: 'settled',
643
+ snapshot: snapshot(),
644
+ upstreamStatus: upstream?.status,
645
+ upstreamBody: upstream?.body,
646
+ };
647
+ }
648
+
649
+ // Compatible extension: only an explicit notSent===true means the request
650
+ // never left the process. Release claim for human retry. Unknown status:0
651
+ // without notSent stays uncertain (may have been sent).
652
+ if (upstream?.notSent === true) {
653
+ releaseClaimToPaused(entry, authority === 'manual' ? 'interaction' : 'user');
654
+ bump();
655
+ return {
656
+ outcome: 'error',
657
+ snapshot: snapshot(),
658
+ upstreamStatus: upstream.status ?? 0,
659
+ upstreamBody: upstream.body,
660
+ error: 'Upstream submission was not sent',
661
+ status: 502,
662
+ };
663
+ }
664
+
665
+ if (upstream?.uncertain) {
666
+ markUncertain(entry, entry.submittedBy);
667
+ bump();
668
+ return {
669
+ outcome: 'uncertain',
670
+ snapshot: snapshot(),
671
+ upstreamStatus: upstream.status,
672
+ upstreamBody: upstream.body,
673
+ };
674
+ }
675
+
676
+ if (upstream?.ok) {
677
+ markSettled(entry, kind === 'reject' ? 'rejected' : 'replied', entry.submittedBy);
678
+ bump();
679
+ return {
680
+ outcome: 'submitted',
681
+ snapshot: snapshot(),
682
+ upstreamStatus: upstream.status,
683
+ upstreamBody: upstream.body,
684
+ };
685
+ }
686
+
687
+ if (upstream?.status === 404) {
688
+ markSettled(entry, 'external', entry.submittedBy);
689
+ bump();
690
+ return {
691
+ outcome: 'settled',
692
+ snapshot: snapshot(),
693
+ upstreamStatus: upstream.status,
694
+ upstreamBody: upstream.body,
695
+ };
696
+ }
697
+
698
+ // Clear reject (400/409/422): release claim and pause so a human can retry.
699
+ // Never auto-retry immediately.
700
+ if (DEFINITIVE_REJECT_STATUSES.has(upstream?.status)) {
701
+ releaseClaimToPaused(entry, authority === 'manual' ? 'interaction' : 'user');
702
+ bump();
703
+ return {
704
+ outcome: 'error',
705
+ snapshot: snapshot(),
706
+ upstreamStatus: upstream.status,
707
+ upstreamBody: upstream.body,
708
+ error: 'Upstream rejected the submission',
709
+ status: upstream.status,
710
+ };
711
+ }
712
+
713
+ // Ambiguous non-ok after dispatch (incl. bare status:0) → uncertain tombstone.
714
+ markUncertain(entry, entry.submittedBy);
715
+ bump();
716
+ return {
717
+ outcome: 'uncertain',
718
+ snapshot: snapshot(),
719
+ upstreamStatus: upstream?.status ?? 502,
720
+ upstreamBody: upstream?.body,
721
+ error: 'Upstream submission did not confirm success',
722
+ };
723
+ };
724
+
725
+ const applyEnabled = (nextEnabled) => {
726
+ if (disposed) return snapshot();
727
+ if (typeof nextEnabled !== 'boolean') {
728
+ // Invalid input must not change runtime state.
729
+ return snapshot();
730
+ }
731
+ settingsGeneration += 1;
732
+ const wasReady = settingsStatus === 'ready';
733
+ const wasEnabled = enabled;
734
+ settingsStatus = 'ready';
735
+
736
+ // Repeat true/false after ready: no deadline reset; true may arm eligible missing timers.
737
+ if (wasReady && wasEnabled === nextEnabled) {
738
+ if (nextEnabled) {
739
+ for (const entry of requests.values()) {
740
+ if (entry.state === 'counting' && !entry.timer && canAutoSubmit(entry)) {
741
+ scheduleAuto(entry);
742
+ }
743
+ }
744
+ }
745
+ bump();
746
+ return snapshot();
747
+ }
748
+
749
+ enabled = nextEnabled;
750
+
751
+ if (!enabled) {
752
+ for (const entry of requests.values()) {
753
+ if (entry.state === TERMINAL_SETTLED || entry.state === 'uncertain' || entry.state === 'submitting') continue;
754
+ clearTimer(entry);
755
+ entry.state = 'disabled';
756
+ entry.deadlineAt = null;
757
+ entry.pauseReason = null;
758
+ fireTakeover(entry, 'disabled');
759
+ }
760
+ bump();
761
+ return snapshot();
762
+ }
763
+
764
+ // false → true (or loading → true): still-pending restart a fresh 30s when directory-bound.
765
+ for (const entry of requests.values()) {
766
+ if (entry.state === TERMINAL_SETTLED || entry.state === 'uncertain' || entry.state === 'submitting') continue;
767
+ if (entry.state === 'paused') continue;
768
+ ensureCounting(entry, { resetDeadline: true });
769
+ }
770
+ bump();
771
+ return snapshot();
772
+ };
773
+
774
+ const pause = async (input) => {
775
+ if (disposed) {
776
+ return { outcome: 'error', snapshot: snapshot(), error: 'disposed', status: 500 };
777
+ }
778
+ const requestID = asTrimmedString(input?.requestID);
779
+ const sessionID = asTrimmedString(input?.sessionID);
780
+ const reason = input?.reason === 'interaction' || input?.reason === 'user' || input?.reason === 'goal'
781
+ ? input.reason
782
+ : null;
783
+ if (!requestID || !sessionID || !reason) {
784
+ return {
785
+ outcome: 'error',
786
+ snapshot: snapshot(),
787
+ error: 'requestID, sessionID, and reason (interaction|user|goal) are required',
788
+ status: 400,
789
+ };
790
+ }
791
+ const hintDirectory = asTrimmedString(input?.directory);
792
+
793
+ let entry = requests.get(requestID);
794
+ if (!entry) {
795
+ const recovered = await recoverPendingIdentity({
796
+ requestID,
797
+ sessionID,
798
+ directoryHint: hintDirectory,
799
+ });
800
+ if (!recovered) {
801
+ return {
802
+ outcome: 'not_found',
803
+ snapshot: snapshot(),
804
+ status: 404,
805
+ error: 'Question request not found',
806
+ };
807
+ }
808
+ if (recovered.sessionID && recovered.sessionID !== sessionID) {
809
+ return {
810
+ outcome: 'error',
811
+ snapshot: snapshot(),
812
+ status: 409,
813
+ error: 'sessionID conflicts with pending question identity',
814
+ };
815
+ }
816
+ if (hintDirectory && recovered.directory && hintDirectory !== recovered.directory) {
817
+ return {
818
+ outcome: 'error',
819
+ snapshot: snapshot(),
820
+ status: 409,
821
+ error: 'directory conflicts with pending question identity',
822
+ };
823
+ }
824
+ const tracked = trackQuestion(recovered);
825
+ entry = tracked.entry;
826
+ } else {
827
+ if (entry.state === TERMINAL_SETTLED) {
828
+ return { outcome: 'settled', snapshot: snapshot() };
829
+ }
830
+ if (entry.state === 'submitting' || entry.state === 'uncertain') {
831
+ return {
832
+ outcome: 'claimed',
833
+ code: QUESTION_SUBMISSION_CLAIMED_CODE,
834
+ status: 409,
835
+ snapshot: snapshot(),
836
+ };
837
+ }
838
+ if (entry.sessionID && entry.sessionID !== sessionID) {
839
+ return {
840
+ outcome: 'error',
841
+ snapshot: snapshot(),
842
+ status: 409,
843
+ error: 'sessionID conflicts with bound request identity',
844
+ };
845
+ }
846
+ if (entry.directory && hintDirectory && entry.directory !== hintDirectory) {
847
+ return {
848
+ outcome: 'error',
849
+ snapshot: snapshot(),
850
+ status: 409,
851
+ error: 'directory conflicts with bound request identity',
852
+ };
853
+ }
854
+ if (!entry.sessionID) entry.sessionID = sessionID;
855
+ if (!entry.directory && hintDirectory) {
856
+ entry.directory = hintDirectory;
857
+ noteDirectory(hintDirectory);
858
+ }
859
+ }
860
+
861
+ clearTimer(entry);
862
+ entry.state = 'paused';
863
+ entry.deadlineAt = null;
864
+ entry.pauseReason = reason === 'goal' ? 'goal' : reason;
865
+ if (reason !== 'goal') {
866
+ fireTakeover(entry, reason);
867
+ }
868
+ bump();
869
+ return { outcome: 'paused', snapshot: snapshot() };
870
+ };
871
+
872
+ const delegate = async (input) => {
873
+ const requestID = asTrimmedString(input?.requestID);
874
+ const sessionID = asTrimmedString(input?.sessionID);
875
+ if (!requestID || !sessionID) {
876
+ return {
877
+ outcome: 'error',
878
+ snapshot: snapshot(),
879
+ error: 'requestID and sessionID are required',
880
+ status: 400,
881
+ };
882
+ }
883
+ // Explicit single-shot delegate is allowed even when the feature toggle is off.
884
+ if (settingsStatus === 'unavailable') {
885
+ return {
886
+ outcome: 'error',
887
+ snapshot: snapshot(),
888
+ status: 503,
889
+ error: 'Question auto-delegate settings unavailable',
890
+ };
891
+ }
892
+
893
+ const hintDirectory = asTrimmedString(input?.directory);
894
+ let entry = requests.get(requestID);
895
+ if (!entry || !entry.questionCount || entry.questionCount < 1 || !entry.directory) {
896
+ const recovered = await recoverPendingIdentity({
897
+ requestID,
898
+ sessionID,
899
+ directoryHint: hintDirectory || entry?.directory,
900
+ });
901
+ if (recovered) {
902
+ if (recovered.sessionID && recovered.sessionID !== sessionID) {
903
+ return {
904
+ outcome: 'error',
905
+ snapshot: snapshot(),
906
+ status: 409,
907
+ error: 'sessionID conflicts with pending question identity',
908
+ };
909
+ }
910
+ if (hintDirectory && recovered.directory && hintDirectory !== recovered.directory) {
911
+ return {
912
+ outcome: 'error',
913
+ snapshot: snapshot(),
914
+ status: 409,
915
+ error: 'directory conflicts with pending question identity',
916
+ };
917
+ }
918
+ const tracked = trackQuestion(recovered);
919
+ entry = tracked.entry;
920
+ }
921
+ } else {
922
+ if (entry.sessionID && entry.sessionID !== sessionID) {
923
+ return {
924
+ outcome: 'error',
925
+ snapshot: snapshot(),
926
+ status: 409,
927
+ error: 'sessionID conflicts with bound request identity',
928
+ };
929
+ }
930
+ if (entry.directory && hintDirectory && entry.directory !== hintDirectory) {
931
+ return {
932
+ outcome: 'error',
933
+ snapshot: snapshot(),
934
+ status: 409,
935
+ error: 'directory conflicts with bound request identity',
936
+ };
937
+ }
938
+ }
939
+
940
+ if (!entry || !entry.questionCount || entry.questionCount < 1) {
941
+ return {
942
+ outcome: 'not_found',
943
+ snapshot: snapshot(),
944
+ status: 404,
945
+ error: 'Question request not found or question count unknown',
946
+ };
947
+ }
948
+
949
+ const directory = entry.directory || hintDirectory;
950
+ if (!directory) {
951
+ return {
952
+ outcome: 'error',
953
+ snapshot: snapshot(),
954
+ status: 409,
955
+ error: 'Authoritative directory required before delegate',
956
+ };
957
+ }
958
+
959
+ const result = await submitInternal({
960
+ requestID,
961
+ sessionID: entry.sessionID || sessionID,
962
+ directory,
963
+ kind: 'reply',
964
+ authority: 'delegate',
965
+ answers: buildAutoDelegateAnswers(entry.questionCount, autoAnswer),
966
+ });
967
+ if (result.outcome === 'submitted') {
968
+ return { ...result, outcome: 'delegated' };
969
+ }
970
+ return result;
971
+ };
972
+
973
+ const collectInventoryDirectories = async (explicit) => {
974
+ const fromExplicit = Array.isArray(explicit) ? explicit : null;
975
+ let fromIo = [];
976
+ let inventoryFailed = false;
977
+ if (!fromExplicit || fromExplicit.length === 0) {
978
+ try {
979
+ const listed = await io.listDirectories();
980
+ if (!Array.isArray(listed)) {
981
+ inventoryFailed = true;
982
+ } else {
983
+ fromIo = listed;
984
+ }
985
+ } catch {
986
+ inventoryFailed = true;
987
+ fromIo = [];
988
+ }
989
+ }
990
+ const fromRequests = Array.from(requests.values()).map((entry) => entry.directory);
991
+ const scopes = mergeDirectoryLists(
992
+ fromExplicit,
993
+ fromIo,
994
+ fromRequests,
995
+ Array.from(observedDirectories),
996
+ );
997
+ return { scopes, inventoryFailed };
998
+ };
999
+
1000
+ const runReconcileOnce = async (directories) => {
1001
+ coverageState = 'reconciling';
1002
+ bump();
1003
+
1004
+ const knownAtStart = new Set(requests.keys());
1005
+ const { scopes, inventoryFailed } = await collectInventoryDirectories(directories);
1006
+
1007
+ const nextFailed = [];
1008
+ if (inventoryFailed && (!Array.isArray(directories) || directories.length === 0)) {
1009
+ // Full inventory failure — do not settle anything; surface partial.
1010
+ failedDirectories = mergeDirectoryLists(failedDirectories, scopes, Array.from(observedDirectories));
1011
+ if (failedDirectories.length === 0) failedDirectories = ['_inventory'];
1012
+ coverageState = 'partial';
1013
+ bump();
1014
+ return snapshot();
1015
+ }
1016
+
1017
+ /** @type {Set<string>} directories whose list call returned authoritative data */
1018
+ const successfulDirectories = new Set();
1019
+ /** @type {Map<string, ReturnType<typeof normalizeQuestion>>} */
1020
+ const pendingById = new Map();
1021
+
1022
+ for (const directory of scopes) {
1023
+ let listed;
1024
+ try {
1025
+ listed = await io.listQuestions(directory);
1026
+ } catch {
1027
+ listed = null;
1028
+ }
1029
+ if (listed == null) {
1030
+ nextFailed.push(directory);
1031
+ continue;
1032
+ }
1033
+ successfulDirectories.add(directory);
1034
+ for (const item of listed) {
1035
+ const normalized = normalizeQuestion(item, directory);
1036
+ if (!normalized) continue;
1037
+ if (!normalized.directory) normalized.directory = directory;
1038
+ pendingById.set(normalized.requestID, normalized);
1039
+ rememberSession({ id: normalized.sessionID, directory: normalized.directory }, normalized.directory);
1040
+ }
1041
+ }
1042
+
1043
+ // Optional unscoped pass only discovers unknowns; never settles whole catalog.
1044
+ let unscopedListed = null;
1045
+ try {
1046
+ unscopedListed = await io.listQuestions(undefined);
1047
+ } catch {
1048
+ unscopedListed = null;
1049
+ }
1050
+ if (Array.isArray(unscopedListed)) {
1051
+ for (const item of unscopedListed) {
1052
+ const normalized = normalizeQuestion(item, '');
1053
+ if (!normalized) continue;
1054
+ // Prefer directory already known from scoped success.
1055
+ if (!normalized.directory) {
1056
+ const existing = pendingById.get(normalized.requestID);
1057
+ if (existing?.directory) normalized.directory = existing.directory;
1058
+ }
1059
+ if (!pendingById.has(normalized.requestID)) {
1060
+ pendingById.set(normalized.requestID, normalized);
1061
+ }
1062
+ if (normalized.directory) {
1063
+ rememberSession({ id: normalized.sessionID, directory: normalized.directory }, normalized.directory);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ failedDirectories = Array.from(new Set(nextFailed));
1069
+ let changed = false;
1070
+ /** @type {Array<Promise<void>>} */
1071
+ const lineageWork = [];
1072
+
1073
+ for (const normalized of pendingById.values()) {
1074
+ const result = trackQuestion(normalized, { resetIfDuplicate: false });
1075
+ if (result.changed) changed = true;
1076
+ if (settingsAllowAuto() && result.entry.state === 'disabled') {
1077
+ ensureCounting(result.entry, { resetDeadline: true });
1078
+ changed = true;
1079
+ } else if (settingsAllowAuto() && result.entry.state === 'counting' && !result.entry.timer) {
1080
+ scheduleAuto(result.entry);
1081
+ }
1082
+ // Unscoped (or otherwise unbound) pending must resolve real directory via
1083
+ // session lineage before any auto timer can arm.
1084
+ if (
1085
+ result.entry
1086
+ && !asTrimmedString(result.entry.directory)
1087
+ && result.entry.state === 'counting'
1088
+ ) {
1089
+ lineageWork.push(
1090
+ ensureSessionLineage(result.entry.sessionID, '', result.entry.requestID),
1091
+ );
1092
+ }
1093
+ }
1094
+ if (lineageWork.length > 0) {
1095
+ await Promise.all(lineageWork);
1096
+ // Lineage write-back may have armed timers; recount change signal.
1097
+ changed = true;
1098
+ }
1099
+
1100
+ // Settle only entries whose authoritative directory was successfully listed empty/missing.
1101
+ for (const entry of requests.values()) {
1102
+ if (!knownAtStart.has(entry.requestID)) continue;
1103
+ if (pendingById.has(entry.requestID)) continue;
1104
+ const dir = asTrimmedString(entry.directory);
1105
+ if (!dir || !successfulDirectories.has(dir)) continue;
1106
+ if (entry.state === 'submitting') continue;
1107
+ // counting/paused/disabled/uncertain with scoped authoritative absence → external.
1108
+ if (
1109
+ entry.state === 'counting'
1110
+ || entry.state === 'paused'
1111
+ || entry.state === 'disabled'
1112
+ || entry.state === 'uncertain'
1113
+ ) {
1114
+ markSettled(entry, 'external', entry.submittedBy);
1115
+ changed = true;
1116
+ }
1117
+ }
1118
+
1119
+ coverageState = failedDirectories.length > 0 || inventoryFailed ? 'partial' : 'ready';
1120
+ if (changed || true) bump();
1121
+ return snapshot();
1122
+ };
1123
+
1124
+ const reconcile = async ({ directories } = {}) => {
1125
+ if (disposed) return snapshot();
1126
+
1127
+ const requested = Array.isArray(directories)
1128
+ ? directories.map(asTrimmedString).filter(Boolean)
1129
+ : null;
1130
+
1131
+ if (reconcilePromise) {
1132
+ trailingReconcileDirectories = mergeDirectoryLists(
1133
+ trailingReconcileDirectories,
1134
+ requested,
1135
+ );
1136
+ return reconcilePromise.then(async () => {
1137
+ if (!trailingReconcileDirectories) return snapshot();
1138
+ const next = trailingReconcileDirectories;
1139
+ trailingReconcileDirectories = null;
1140
+ return reconcile({ directories: next });
1141
+ });
1142
+ }
1143
+
1144
+ reconcilePromise = runReconcileOnce(requested)
1145
+ .catch((error) => {
1146
+ coverageState = 'partial';
1147
+ console.warn('[question-auto-delegate] reconcile failed:', error?.message ?? error);
1148
+ return snapshot();
1149
+ })
1150
+ .finally(() => {
1151
+ reconcilePromise = null;
1152
+ });
1153
+
1154
+ return reconcilePromise;
1155
+ };
1156
+
1157
+ const processEvent = (payload, directoryHint = '') => {
1158
+ if (disposed || !payload || typeof payload !== 'object') return;
1159
+ const type = typeof payload.type === 'string' ? payload.type : '';
1160
+ const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
1161
+ const hint = asTrimmedString(directoryHint);
1162
+
1163
+ if (type === 'session.created' || type === 'session.updated') {
1164
+ rememberSession(properties.info, hint);
1165
+ const info = properties.info;
1166
+ const sessionId = asTrimmedString(info?.id);
1167
+ const directory = asTrimmedString(info?.directory) || hint;
1168
+ if (sessionId && directory) {
1169
+ let changed = false;
1170
+ for (const entry of requests.values()) {
1171
+ if (entry.sessionID !== sessionId) continue;
1172
+ if (bindDirectoryToEntry(entry, directory, { restartTimer: true })) changed = true;
1173
+ }
1174
+ if (changed) bump();
1175
+ }
1176
+ return;
1177
+ }
1178
+
1179
+ if (type === 'question.asked') {
1180
+ const normalized = normalizeQuestion(properties, hint);
1181
+ if (!normalized) return;
1182
+ rememberSession({ id: normalized.sessionID, directory: normalized.directory }, normalized.directory || hint);
1183
+ if (!normalized.directory) {
1184
+ const cached = sessions.get(normalized.sessionID);
1185
+ if (cached?.directory) normalized.directory = cached.directory;
1186
+ }
1187
+ const { changed } = trackQuestion(normalized, { resetIfDuplicate: false });
1188
+ if (changed) bump();
1189
+ // Lineage + directory write-back; timer only arms after authoritative directory binds.
1190
+ void ensureSessionLineage(normalized.sessionID, normalized.directory || hint, normalized.requestID);
1191
+ return;
1192
+ }
1193
+
1194
+ if (type === 'question.replied' || type === 'question.rejected') {
1195
+ const requestID = asTrimmedString(properties.id) || asTrimmedString(properties.requestID);
1196
+ if (!requestID) return;
1197
+ const entry = requests.get(requestID);
1198
+ if (!entry) return;
1199
+ if (entry.state === TERMINAL_SETTLED) return;
1200
+ const resolution = type === 'question.rejected' ? 'rejected' : 'replied';
1201
+ if (entry.state === 'uncertain' || entry.state === 'submitting') {
1202
+ markSettled(entry, resolution, entry.submittedBy ?? null);
1203
+ bump();
1204
+ return;
1205
+ }
1206
+ markSettled(entry, 'external', entry.submittedBy);
1207
+ bump();
1208
+ }
1209
+ };
1210
+
1211
+ const collectDescendants = (rootId) => {
1212
+ const out = new Set([rootId]);
1213
+ let grew = true;
1214
+ while (grew) {
1215
+ grew = false;
1216
+ for (const [id, info] of sessions.entries()) {
1217
+ if (out.has(id)) continue;
1218
+ if (info.parentID && out.has(info.parentID)) {
1219
+ out.add(id);
1220
+ grew = true;
1221
+ }
1222
+ }
1223
+ }
1224
+ return out;
1225
+ };
1226
+
1227
+ const isBlockingSession = (sessionID) => {
1228
+ const id = asTrimmedString(sessionID);
1229
+ if (!id) return false;
1230
+ const scope = collectDescendants(id);
1231
+ for (const entry of requests.values()) {
1232
+ if (!BLOCKING_STATES.has(entry.state)) continue;
1233
+ if (scope.has(entry.sessionID)) return true;
1234
+ }
1235
+ return false;
1236
+ };
1237
+
1238
+ const isAutoHandling = (sessionID, requestID) => {
1239
+ if (!settingsAllowAuto()) return false;
1240
+ if (requestID) {
1241
+ const entry = requests.get(asTrimmedString(requestID));
1242
+ if (!entry) return settingsAllowAuto();
1243
+ return entry.state === 'counting' || entry.state === 'submitting';
1244
+ }
1245
+ const id = asTrimmedString(sessionID);
1246
+ if (!id) return settingsAllowAuto();
1247
+ for (const entry of requests.values()) {
1248
+ if (entry.sessionID !== id) continue;
1249
+ if (entry.state === 'counting' || entry.state === 'submitting') return true;
1250
+ }
1251
+ return settingsAllowAuto();
1252
+ };
1253
+
1254
+ /**
1255
+ * Goal pause/abort reverse path: pause all related question timers for a session tree.
1256
+ * Uses owner/root directory from lineage when available.
1257
+ */
1258
+ const pauseForSessionTree = async (sessionID, directoryHint = '') => {
1259
+ const id = asTrimmedString(sessionID);
1260
+ if (!id) return snapshot();
1261
+ const root = resolveRootSession(id);
1262
+ const scope = collectDescendants(root.sessionID || id);
1263
+ // Also include the provided session and its descendants if root resolution was incomplete.
1264
+ for (const child of collectDescendants(id)) scope.add(child);
1265
+
1266
+ let changed = false;
1267
+ for (const entry of requests.values()) {
1268
+ if (!scope.has(entry.sessionID)) continue;
1269
+ if (entry.state === TERMINAL_SETTLED || entry.state === 'uncertain' || entry.state === 'submitting') continue;
1270
+ clearTimer(entry);
1271
+ if (entry.state !== 'paused' || entry.pauseReason !== 'goal') {
1272
+ entry.state = 'paused';
1273
+ entry.deadlineAt = null;
1274
+ entry.pauseReason = 'goal';
1275
+ changed = true;
1276
+ }
1277
+ if (!entry.directory && (root.directory || directoryHint)) {
1278
+ entry.directory = root.directory || asTrimmedString(directoryHint);
1279
+ noteDirectory(entry.directory);
1280
+ }
1281
+ }
1282
+ if (changed) bump();
1283
+ return snapshot();
1284
+ };
1285
+
1286
+ const start = () => {
1287
+ if (started) return () => {};
1288
+ started = true;
1289
+ const bootGeneration = settingsGeneration;
1290
+ void (async () => {
1291
+ let readResult;
1292
+ try {
1293
+ readResult = await io.readEnabled();
1294
+ } catch {
1295
+ readResult = null;
1296
+ }
1297
+ // applyEnabled (before or during boot) always wins over a stale disk read.
1298
+ if (settingsGeneration !== bootGeneration || settingsStatus === 'ready') {
1299
+ await reconcile();
1300
+ return;
1301
+ }
1302
+ if (readResult === null || readResult === undefined) {
1303
+ settingsStatus = 'unavailable';
1304
+ enabled = false;
1305
+ coverageState = 'partial';
1306
+ for (const entry of requests.values()) {
1307
+ clearTimer(entry);
1308
+ entry.deadlineAt = null;
1309
+ }
1310
+ bump();
1311
+ await reconcile();
1312
+ return;
1313
+ }
1314
+ settingsStatus = 'ready';
1315
+ enabled = readResult !== false;
1316
+ bump();
1317
+ if (enabled) {
1318
+ for (const entry of requests.values()) {
1319
+ if (entry.state === 'counting') ensureCounting(entry, { resetDeadline: false });
1320
+ }
1321
+ } else {
1322
+ for (const entry of requests.values()) {
1323
+ if (entry.state === TERMINAL_SETTLED || entry.state === 'uncertain' || entry.state === 'submitting') continue;
1324
+ clearTimer(entry);
1325
+ entry.state = 'disabled';
1326
+ entry.deadlineAt = null;
1327
+ }
1328
+ }
1329
+ await reconcile();
1330
+ })();
1331
+
1332
+ return () => {
1333
+ dispose();
1334
+ };
1335
+ };
1336
+
1337
+ const dispose = () => {
1338
+ if (disposed) return;
1339
+ disposed = true;
1340
+ for (const entry of requests.values()) clearTimer(entry);
1341
+ if (typeof stopHub === 'function') {
1342
+ try {
1343
+ stopHub();
1344
+ } catch {
1345
+ // ignore
1346
+ }
1347
+ stopHub = null;
1348
+ }
1349
+ };
1350
+
1351
+ const attachStop = (fn) => {
1352
+ stopHub = typeof fn === 'function' ? fn : null;
1353
+ };
1354
+
1355
+ return {
1356
+ start,
1357
+ dispose,
1358
+ snapshot,
1359
+ applyEnabled,
1360
+ pause,
1361
+ delegate,
1362
+ submit: submitInternal,
1363
+ reconcile,
1364
+ processEvent,
1365
+ isBlockingSession,
1366
+ isAutoHandling,
1367
+ pauseForSessionTree,
1368
+ resolveRootSession,
1369
+ /** @internal */
1370
+ _attachStop: attachStop,
1371
+ };
1372
+ }