hammoc 1.0.4 → 1.1.0

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 (169) hide show
  1. package/README.md +46 -16
  2. package/bin/hammoc.js +22 -5
  3. package/package.json +2 -1
  4. package/packages/client/dist/assets/{index-Zkw0a1l9.js → index-CFWfpySn.js} +1 -1
  5. package/packages/client/dist/assets/index-D8ezrT4P.js +1446 -0
  6. package/packages/client/dist/assets/index-JvaBmdnx.css +32 -0
  7. package/packages/client/dist/index.html +2 -2
  8. package/packages/client/dist/sw.js +2 -1
  9. package/packages/server/dist/app.d.ts.map +1 -1
  10. package/packages/server/dist/app.js +58 -6
  11. package/packages/server/dist/app.js.map +1 -1
  12. package/packages/server/dist/config/index.d.ts +17 -9
  13. package/packages/server/dist/config/index.d.ts.map +1 -1
  14. package/packages/server/dist/config/index.js +17 -10
  15. package/packages/server/dist/config/index.js.map +1 -1
  16. package/packages/server/dist/controllers/boardController.d.ts +0 -1
  17. package/packages/server/dist/controllers/boardController.d.ts.map +1 -1
  18. package/packages/server/dist/controllers/boardController.js +62 -29
  19. package/packages/server/dist/controllers/boardController.js.map +1 -1
  20. package/packages/server/dist/controllers/cliController.d.ts.map +1 -1
  21. package/packages/server/dist/controllers/cliController.js +8 -0
  22. package/packages/server/dist/controllers/cliController.js.map +1 -1
  23. package/packages/server/dist/controllers/fileSystemController.d.ts +10 -0
  24. package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
  25. package/packages/server/dist/controllers/fileSystemController.js +156 -0
  26. package/packages/server/dist/controllers/fileSystemController.js.map +1 -1
  27. package/packages/server/dist/controllers/queueController.d.ts +0 -4
  28. package/packages/server/dist/controllers/queueController.d.ts.map +1 -1
  29. package/packages/server/dist/controllers/queueController.js +0 -88
  30. package/packages/server/dist/controllers/queueController.js.map +1 -1
  31. package/packages/server/dist/controllers/queueTemplateController.d.ts +4 -0
  32. package/packages/server/dist/controllers/queueTemplateController.d.ts.map +1 -1
  33. package/packages/server/dist/controllers/queueTemplateController.js +61 -4
  34. package/packages/server/dist/controllers/queueTemplateController.js.map +1 -1
  35. package/packages/server/dist/controllers/serverController.d.ts +3 -3
  36. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  37. package/packages/server/dist/controllers/serverController.js +42 -16
  38. package/packages/server/dist/controllers/serverController.js.map +1 -1
  39. package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
  40. package/packages/server/dist/controllers/sessionController.js +2 -1
  41. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  42. package/packages/server/dist/handlers/websocket.d.ts +12 -2
  43. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  44. package/packages/server/dist/handlers/websocket.js +775 -99
  45. package/packages/server/dist/handlers/websocket.js.map +1 -1
  46. package/packages/server/dist/index.js +5 -1
  47. package/packages/server/dist/index.js.map +1 -1
  48. package/packages/server/dist/locales/en/server.json +12 -3
  49. package/packages/server/dist/locales/es/server.json +3 -1
  50. package/packages/server/dist/locales/ja/server.json +3 -1
  51. package/packages/server/dist/locales/ko/server.json +12 -3
  52. package/packages/server/dist/locales/pt/server.json +3 -1
  53. package/packages/server/dist/locales/zh-CN/server.json +3 -1
  54. package/packages/server/dist/middleware/pathGuard.d.ts +1 -7
  55. package/packages/server/dist/middleware/pathGuard.d.ts.map +1 -1
  56. package/packages/server/dist/middleware/pathGuard.js +57 -4
  57. package/packages/server/dist/middleware/pathGuard.js.map +1 -1
  58. package/packages/server/dist/middleware/session.d.ts.map +1 -1
  59. package/packages/server/dist/middleware/session.js +3 -1
  60. package/packages/server/dist/middleware/session.js.map +1 -1
  61. package/packages/server/dist/routes/board.d.ts.map +1 -1
  62. package/packages/server/dist/routes/board.js +0 -1
  63. package/packages/server/dist/routes/board.js.map +1 -1
  64. package/packages/server/dist/routes/fileSystem.d.ts.map +1 -1
  65. package/packages/server/dist/routes/fileSystem.js +39 -0
  66. package/packages/server/dist/routes/fileSystem.js.map +1 -1
  67. package/packages/server/dist/routes/preferences.d.ts.map +1 -1
  68. package/packages/server/dist/routes/preferences.js +80 -2
  69. package/packages/server/dist/routes/preferences.js.map +1 -1
  70. package/packages/server/dist/routes/queue.d.ts.map +1 -1
  71. package/packages/server/dist/routes/queue.js +9 -8
  72. package/packages/server/dist/routes/queue.js.map +1 -1
  73. package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
  74. package/packages/server/dist/services/bmadStatusService.js +60 -0
  75. package/packages/server/dist/services/bmadStatusService.js.map +1 -1
  76. package/packages/server/dist/services/chatService.d.ts +4 -0
  77. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  78. package/packages/server/dist/services/chatService.js +7 -1
  79. package/packages/server/dist/services/chatService.js.map +1 -1
  80. package/packages/server/dist/services/cliService.d.ts.map +1 -1
  81. package/packages/server/dist/services/cliService.js +66 -15
  82. package/packages/server/dist/services/cliService.js.map +1 -1
  83. package/packages/server/dist/services/fileSystemService.d.ts +29 -1
  84. package/packages/server/dist/services/fileSystemService.d.ts.map +1 -1
  85. package/packages/server/dist/services/fileSystemService.js +240 -5
  86. package/packages/server/dist/services/fileSystemService.js.map +1 -1
  87. package/packages/server/dist/services/gitService.js +1 -1
  88. package/packages/server/dist/services/gitService.js.map +1 -1
  89. package/packages/server/dist/services/historyParser.d.ts +13 -0
  90. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  91. package/packages/server/dist/services/historyParser.js +72 -1
  92. package/packages/server/dist/services/historyParser.js.map +1 -1
  93. package/packages/server/dist/services/issueService.d.ts +3 -10
  94. package/packages/server/dist/services/issueService.d.ts.map +1 -1
  95. package/packages/server/dist/services/issueService.js +123 -152
  96. package/packages/server/dist/services/issueService.js.map +1 -1
  97. package/packages/server/dist/services/notificationService.d.ts +11 -6
  98. package/packages/server/dist/services/notificationService.d.ts.map +1 -1
  99. package/packages/server/dist/services/notificationService.js +75 -20
  100. package/packages/server/dist/services/notificationService.js.map +1 -1
  101. package/packages/server/dist/services/preferencesService.d.ts +2 -3
  102. package/packages/server/dist/services/preferencesService.d.ts.map +1 -1
  103. package/packages/server/dist/services/preferencesService.js +3 -10
  104. package/packages/server/dist/services/preferencesService.js.map +1 -1
  105. package/packages/server/dist/services/projectService.d.ts +26 -1
  106. package/packages/server/dist/services/projectService.d.ts.map +1 -1
  107. package/packages/server/dist/services/projectService.js +113 -0
  108. package/packages/server/dist/services/projectService.js.map +1 -1
  109. package/packages/server/dist/services/queueService.d.ts +1 -1
  110. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  111. package/packages/server/dist/services/queueService.js +6 -2
  112. package/packages/server/dist/services/queueService.js.map +1 -1
  113. package/packages/server/dist/services/queueTemplateService.d.ts +5 -0
  114. package/packages/server/dist/services/queueTemplateService.d.ts.map +1 -1
  115. package/packages/server/dist/services/queueTemplateService.js +71 -21
  116. package/packages/server/dist/services/queueTemplateService.js.map +1 -1
  117. package/packages/server/dist/services/sessionService.d.ts +12 -0
  118. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  119. package/packages/server/dist/services/sessionService.js +121 -107
  120. package/packages/server/dist/services/sessionService.js.map +1 -1
  121. package/packages/server/dist/services/streamHandler.d.ts +4 -2
  122. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  123. package/packages/server/dist/services/streamHandler.js +19 -4
  124. package/packages/server/dist/services/streamHandler.js.map +1 -1
  125. package/packages/server/dist/services/webPushService.d.ts +61 -0
  126. package/packages/server/dist/services/webPushService.d.ts.map +1 -0
  127. package/packages/server/dist/services/webPushService.js +258 -0
  128. package/packages/server/dist/services/webPushService.js.map +1 -0
  129. package/packages/server/dist/utils/networkUtils.d.ts +17 -2
  130. package/packages/server/dist/utils/networkUtils.d.ts.map +1 -1
  131. package/packages/server/dist/utils/networkUtils.js +121 -8
  132. package/packages/server/dist/utils/networkUtils.js.map +1 -1
  133. package/packages/server/package.json +4 -0
  134. package/packages/shared/dist/constants/errorCodes.d.ts +1 -0
  135. package/packages/shared/dist/constants/errorCodes.d.ts.map +1 -1
  136. package/packages/shared/dist/constants/errorCodes.js +2 -0
  137. package/packages/shared/dist/constants/errorCodes.js.map +1 -1
  138. package/packages/shared/dist/index.d.ts +1 -1
  139. package/packages/shared/dist/index.d.ts.map +1 -1
  140. package/packages/shared/dist/index.js.map +1 -1
  141. package/packages/shared/dist/types/bmadStatus.d.ts +2 -0
  142. package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
  143. package/packages/shared/dist/types/bmadStatus.js.map +1 -1
  144. package/packages/shared/dist/types/board.d.ts +6 -8
  145. package/packages/shared/dist/types/board.d.ts.map +1 -1
  146. package/packages/shared/dist/types/board.js +24 -39
  147. package/packages/shared/dist/types/board.js.map +1 -1
  148. package/packages/shared/dist/types/fileSystem.d.ts +36 -0
  149. package/packages/shared/dist/types/fileSystem.d.ts.map +1 -1
  150. package/packages/shared/dist/types/fileSystem.js +15 -0
  151. package/packages/shared/dist/types/fileSystem.js.map +1 -1
  152. package/packages/shared/dist/types/history.d.ts +3 -0
  153. package/packages/shared/dist/types/history.d.ts.map +1 -1
  154. package/packages/shared/dist/types/preferences.d.ts +24 -2
  155. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  156. package/packages/shared/dist/types/preferences.js.map +1 -1
  157. package/packages/shared/dist/types/sdk.d.ts +7 -0
  158. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  159. package/packages/shared/dist/types/sdk.js +25 -0
  160. package/packages/shared/dist/types/sdk.js.map +1 -1
  161. package/packages/shared/dist/types/websocket.d.ts +41 -3
  162. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  163. package/packages/shared/dist/utils/queueTemplateUtils.d.ts +2 -1
  164. package/packages/shared/dist/utils/queueTemplateUtils.d.ts.map +1 -1
  165. package/packages/shared/dist/utils/queueTemplateUtils.js +10 -3
  166. package/packages/shared/dist/utils/queueTemplateUtils.js.map +1 -1
  167. package/packages/client/dist/assets/index-DgULSwlr.js +0 -1320
  168. package/packages/client/dist/assets/index-Dto2jQe7.css +0 -32
  169. package/packages/client/dist/workbox-7a79b53c.js +0 -1
@@ -50,26 +50,12 @@ function triggerDashboardStatusChange(projectSlug) {
50
50
  }
51
51
  /**
52
52
  * Check terminal access for a socket connection.
53
- * Fail-closed: denies access on any error (e.g., preferences file read failure).
53
+ * Checks server config (TERMINAL_ENABLED env var) and local IP.
54
54
  * Story 17.5: Terminal Security
55
55
  */
56
- async function checkTerminalAccess(socket, lang) {
56
+ function checkTerminalAccess(socket, lang) {
57
57
  const t = i18next.getFixedT(lang);
58
- try {
59
- const terminalEnabled = await preferencesService.getTerminalEnabled();
60
- if (!terminalEnabled) {
61
- return {
62
- allowed: false,
63
- error: {
64
- code: TERMINAL_ERRORS.TERMINAL_DISABLED.code,
65
- message: t('ws.error.terminalDisabled'),
66
- },
67
- };
68
- }
69
- }
70
- catch (err) {
71
- // Fail-closed: deny access if preferences read fails
72
- log.error('Failed to check terminal enabled state, denying access:', err);
58
+ if (!preferencesService.getTerminalEnabled()) {
73
59
  return {
74
60
  allowed: false,
75
61
  error: {
@@ -95,6 +81,267 @@ let connectedClients = 0;
95
81
  // Primary maps: sessionId → ActiveStream, socketId → sessionId
96
82
  const activeStreams = new Map();
97
83
  const socketToSession = new Map();
84
+ // Story 24.3: Track which session room each socket joined (for session:leave room management)
85
+ const socketSessionRoom = new Map();
86
+ // Track which project room each socket joined (for leave on session switch)
87
+ const socketProjectRoom = new Map();
88
+ const chainState = new Map();
89
+ // Per-session drain generation counter for race guard
90
+ const chainDrainGeneration = new Map();
91
+ // Sessions that have completed at least one handleChatSend — safe to resume
92
+ const chainResumableSessions = new Set();
93
+ let chainItemCounter = 0;
94
+ const CHAIN_MAX_RETRIES = 3;
95
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
96
+ /** Generate a unique chain item ID */
97
+ function generateChainItemId() {
98
+ return `chain-${Date.now()}-${++chainItemCounter}`;
99
+ }
100
+ /** Map internal chain item to public PromptChainItem (allow-list of fields) */
101
+ function toPublicChainItem(item) {
102
+ const pub = { id: item.id, content: item.content, status: item.status, createdAt: item.createdAt };
103
+ if (item.retryCount !== undefined)
104
+ pub.retryCount = item.retryCount;
105
+ return pub;
106
+ }
107
+ /** Broadcast current chain state to all sockets in the session room (strips internal fields) */
108
+ function broadcastChainUpdate(sessionId) {
109
+ if (!io)
110
+ return;
111
+ const internalItems = chainState.get(sessionId) || [];
112
+ const items = internalItems.map(toPublicChainItem);
113
+ io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items });
114
+ }
115
+ /** Broadcast chain state including persisted failures from disk */
116
+ function broadcastChainUpdateWithFailures(sessionId) {
117
+ if (!io)
118
+ return;
119
+ withChainFailureLock(sessionId, () => projectService.readChainFailures(sessionId))
120
+ .then(failures => {
121
+ // Re-read in-memory state now (may have changed during async disk read)
122
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
123
+ io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items: [...freshItems, ...failures] });
124
+ })
125
+ .catch((err) => {
126
+ log.error(`Failed to read chain failures for broadcast (session ${sessionId}):`, err);
127
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
128
+ io.to(`session:${sessionId}`).emit('chain:update', { sessionId, items: freshItems });
129
+ });
130
+ }
131
+ /** Clean up chain state when no active work remains */
132
+ function cleanupChainIfIdle(sessionId) {
133
+ if (activeStreams.has(sessionId))
134
+ return;
135
+ const items = chainState.get(sessionId);
136
+ // Preserve if pending/sending items remain (drain will handle them)
137
+ // or failed items remain (disk persistence may have failed — keep in memory until dismissed)
138
+ if (items && items.some(item => item.status === 'pending' || item.status === 'sending' || item.status === 'failed')) {
139
+ return;
140
+ }
141
+ chainState.delete(sessionId);
142
+ chainResumableSessions.delete(sessionId);
143
+ // NOTE: chainDrainGeneration is intentionally NOT deleted here.
144
+ // Deleting would reset the counter to 0, allowing stale timers from before
145
+ // cleanup to match a new gen=1 value (ABA problem).
146
+ }
147
+ // Per-session mutex for failure file I/O to prevent read-modify-write races
148
+ const chainFailureLocks = new Map();
149
+ /** Execute a failure file operation under per-session lock */
150
+ function withChainFailureLock(sessionId, fn) {
151
+ const prev = chainFailureLocks.get(sessionId) || Promise.resolve();
152
+ const next = prev.then(fn, fn); // run even if previous rejected
153
+ chainFailureLocks.set(sessionId, next.then(() => { }, () => { }));
154
+ return next;
155
+ }
156
+ /** Persist a failed chain item to disk so it survives server restarts */
157
+ async function persistChainFailure(sessionId, item) {
158
+ return withChainFailureLock(sessionId, async () => {
159
+ const existing = await projectService.readChainFailures(sessionId);
160
+ existing.push(toPublicChainItem(item));
161
+ await projectService.writeChainFailures(sessionId, existing);
162
+ });
163
+ }
164
+ /** Remove a specific failure from disk */
165
+ async function removePersistedFailure(sessionId, itemId) {
166
+ return withChainFailureLock(sessionId, async () => {
167
+ const failures = await projectService.readChainFailures(sessionId);
168
+ if (failures.length === 0)
169
+ return;
170
+ const remaining = failures.filter(f => f.id !== itemId);
171
+ if (remaining.length !== failures.length) {
172
+ await projectService.writeChainFailures(sessionId, remaining);
173
+ }
174
+ });
175
+ }
176
+ /** Clear all persisted failures for a session */
177
+ async function clearPersistedFailures(sessionId) {
178
+ return withChainFailureLock(sessionId, async () => {
179
+ await projectService.writeChainFailures(sessionId, []);
180
+ });
181
+ }
182
+ /** Schedule chain drain after stream completion (1s delay) */
183
+ function scheduleChainDrain(sessionId, lang) {
184
+ // Increment generation counter to detect stale drains
185
+ const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
186
+ chainDrainGeneration.set(sessionId, gen);
187
+ log.info(`[CHAIN-DRAIN] scheduleChainDrain called: sessionId=${sessionId}, gen=${gen}, chainItems=${chainState.get(sessionId)?.length ?? 0}, activeStream=${activeStreams.has(sessionId)}`);
188
+ setTimeout(async () => {
189
+ // Race guard: if generation changed (manual chat:send started/finished), abort
190
+ if (chainDrainGeneration.get(sessionId) !== gen) {
191
+ log.info(`[CHAIN-DRAIN] timer aborted (generation mismatch): sessionId=${sessionId}, expected=${gen}, actual=${chainDrainGeneration.get(sessionId)}`);
192
+ return;
193
+ }
194
+ // Race guard: if another stream is currently active, abort drain
195
+ if (activeStreams.has(sessionId)) {
196
+ log.info(`[CHAIN-DRAIN] timer aborted (active stream exists): sessionId=${sessionId}, gen=${gen}`);
197
+ return;
198
+ }
199
+ const items = chainState.get(sessionId);
200
+ log.info(`[CHAIN-DRAIN] timer fired: sessionId=${sessionId}, gen=${gen}, items=${items?.length ?? 0}, statuses=${JSON.stringify(items?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
201
+ if (!items || items.length === 0) {
202
+ cleanupChainIfIdle(sessionId);
203
+ return;
204
+ }
205
+ const nextItem = items.find(item => item.status === 'pending');
206
+ if (!nextItem) {
207
+ log.info(`[CHAIN-DRAIN] no pending item found, cleaning up: sessionId=${sessionId}`);
208
+ cleanupChainIfIdle(sessionId);
209
+ return;
210
+ }
211
+ // Use per-item execution context
212
+ const { workingDirectory, permissionMode, model } = nextItem;
213
+ // Mark item as 'sending' and broadcast (must happen before any await to prevent duplicate execution)
214
+ nextItem.status = 'sending';
215
+ log.info(`[CHAIN-DRAIN] executing item: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}, content="${nextItem.content.slice(0, 80)}"`);
216
+ broadcastChainUpdate(sessionId);
217
+ const abortController = new AbortController();
218
+ let stream;
219
+ try {
220
+ // Create headless stream inside try — if this throws, sending status is recovered below
221
+ const headless = createHeadlessStream(sessionId, abortController);
222
+ stream = headless.stream;
223
+ log.info(`[CHAIN-DRAIN] headless stream created: sessionId=${sessionId}, socketsInRoom=${stream.sockets.size}`);
224
+ // Resolve projectSlug async (fire-and-forget) — same pattern as chat:send
225
+ projectService.findProjectByPath(workingDirectory).then((project) => {
226
+ if (project && stream.status === 'running' && activeStreams.get(stream.sessionId) === stream) {
227
+ sessionProjectMap.set(stream.sessionId, project.projectSlug);
228
+ triggerDashboardStatusChange(project.projectSlug);
229
+ }
230
+ }).catch((err) => {
231
+ log.warn(`[CHAIN-DRAIN] failed to resolve projectSlug for dashboard: sessionId=${sessionId}, dir=${workingDirectory}`, err);
232
+ });
233
+ emitStreamChange(sessionId, true, sessionProjectMap.get(sessionId) ?? null);
234
+ const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model }, abortController, lang);
235
+ if (!drainSuccess)
236
+ throw new Error('handleChatSend returned false');
237
+ // Success: mark as 'sent' and remove from chain
238
+ nextItem.status = 'sent';
239
+ chainResumableSessions.add(sessionId);
240
+ log.info(`[CHAIN-DRAIN] item completed successfully: sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}`);
241
+ const currentItems = chainState.get(sessionId);
242
+ if (currentItems) {
243
+ chainState.set(sessionId, currentItems.filter(item => item.id !== nextItem.id));
244
+ }
245
+ broadcastChainUpdate(sessionId);
246
+ }
247
+ catch (err) {
248
+ // Check if this was an intentional abort (chain:remove or chain:clear)
249
+ const isAborted = abortController.signal.aborted;
250
+ const abortReason = abortController.signal.reason;
251
+ const isChainCanceled = isAborted && (abortReason === 'chain-item-removed' || abortReason === 'chain-cleared' || abortReason === 'user-abort');
252
+ if (isChainCanceled) {
253
+ // User-initiated cancel — remove from chain, no disk record needed.
254
+ // Mark as 'sent' so the finally safety-net doesn't reset it to 'pending'.
255
+ nextItem.status = 'sent';
256
+ const cancelItems = chainState.get(sessionId);
257
+ if (cancelItems) {
258
+ chainState.set(sessionId, cancelItems.filter(item => item.id !== nextItem.id));
259
+ }
260
+ log.info(`Chain item ${nextItem.id} canceled (${abortReason}) for session ${sessionId}`);
261
+ }
262
+ else {
263
+ // On error: increment retry count and persist failure if max retries exceeded
264
+ const retries = (nextItem.retryCount || 0) + 1;
265
+ nextItem.retryCount = retries;
266
+ if (retries >= CHAIN_MAX_RETRIES) {
267
+ nextItem.status = 'failed';
268
+ // Persist to disk, then remove from memory only on success
269
+ try {
270
+ await persistChainFailure(sessionId, nextItem);
271
+ const failItems = chainState.get(sessionId);
272
+ if (failItems) {
273
+ chainState.set(sessionId, failItems.filter(item => item.id !== nextItem.id));
274
+ }
275
+ }
276
+ catch (persistErr) {
277
+ // Disk write failed — keep in memory so it's not lost
278
+ log.error(`Failed to persist chain failure for session ${sessionId}:`, persistErr);
279
+ }
280
+ log.error(`Chain item ${nextItem.id} failed after ${CHAIN_MAX_RETRIES} retries for session ${sessionId}:`, err);
281
+ }
282
+ else if (nextItem.status === 'sending') {
283
+ nextItem.status = 'pending';
284
+ }
285
+ log.error(`Chain drain error for session ${sessionId} (attempt ${retries}):`, err);
286
+ }
287
+ // Use disk-aware broadcast since failures may have been persisted above
288
+ broadcastChainUpdateWithFailures(sessionId);
289
+ }
290
+ finally {
291
+ // Safety net: ensure item is never left stuck in 'sending'
292
+ if (nextItem.status === 'sending') {
293
+ log.warn(`[CHAIN-DRAIN] finally: item still in 'sending', resetting to 'pending': sessionId=${sessionId}, itemId=${nextItem.id.slice(0, 8)}`);
294
+ nextItem.status = 'pending';
295
+ broadcastChainUpdate(sessionId);
296
+ }
297
+ if (stream) {
298
+ stream.status = 'completed';
299
+ const isCurrentStream = activeStreams.get(sessionId) === stream;
300
+ log.info(`[CHAIN-DRAIN] finally: sessionId=${sessionId}, streamCompleted=true, isCurrentStream=${isCurrentStream}`);
301
+ if (isCurrentStream) {
302
+ const remaining = chainState.get(sessionId);
303
+ const remainingPending = remaining?.filter(item => item.status === 'pending').length ?? 0;
304
+ log.info(`[CHAIN-DRAIN] finally: cleaning up stream, remainingItems=${remaining?.length ?? 0}, remainingPending=${remainingPending}`);
305
+ const chainEndSlug = sessionProjectMap.get(sessionId) ?? null;
306
+ cleanupStream(sessionId);
307
+ emitStreamChange(sessionId, false, chainEndSlug);
308
+ // Persist per-session permission mode before cleanup
309
+ const chainFinalMode = stream.chatService?.getPermissionMode();
310
+ if (chainFinalMode)
311
+ await persistSessionPermissionMode(sessionId, chainFinalMode);
312
+ const endProjectSlug = sessionProjectMap.get(sessionId);
313
+ if (endProjectSlug) {
314
+ triggerDashboardStatusChange(endProjectSlug);
315
+ sessionProjectMap.delete(sessionId);
316
+ }
317
+ // Continue draining or clean up — browser state is irrelevant
318
+ if (remaining && remaining.some(item => item.status === 'pending')) {
319
+ log.info(`[CHAIN-DRAIN] finally: scheduling next drain for sessionId=${sessionId}`);
320
+ scheduleChainDrain(sessionId, lang);
321
+ }
322
+ else {
323
+ log.info(`[CHAIN-DRAIN] finally: no more pending items, cleaning up chain for sessionId=${sessionId}`);
324
+ cleanupChainIfIdle(sessionId);
325
+ }
326
+ }
327
+ else {
328
+ log.warn(`[CHAIN-DRAIN] finally: stream is NOT the current active stream (replaced?): sessionId=${sessionId}`);
329
+ }
330
+ }
331
+ else {
332
+ // Stream creation failed — schedule retry or cleanup
333
+ log.warn(`[CHAIN-DRAIN] finally: no stream was created, scheduling retry: sessionId=${sessionId}`);
334
+ const remaining = chainState.get(sessionId);
335
+ if (remaining && remaining.some(item => item.status === 'pending')) {
336
+ scheduleChainDrain(sessionId, lang);
337
+ }
338
+ else {
339
+ cleanupChainIfIdle(sessionId);
340
+ }
341
+ }
342
+ }
343
+ }, 1000);
344
+ }
98
345
  let permissionRequestCounter = 0;
99
346
  /** Create a buffered emit function that buffers and broadcasts to all connected sockets */
100
347
  function createStreamEmit(stream) {
@@ -132,12 +379,104 @@ export function isSessionStreaming(sessionId) {
132
379
  const stream = activeStreams.get(sessionId);
133
380
  return !!stream && stream.status === 'running';
134
381
  }
135
- /** Clean up a stream from all maps */
136
- function cleanupStream(streamKey) {
137
- activeStreams.delete(streamKey);
138
- for (const [sockId, sessId] of socketToSession.entries()) {
139
- if (sessId === streamKey)
140
- socketToSession.delete(sockId);
382
+ /** Completed stream buffers kept independently of activeStreams.
383
+ * When a stream completes, its buffer is saved here for 5 seconds so clients
384
+ * joining during the JSONL flush window can still receive the completed turn.
385
+ * This is separate from activeStreams so new streams can be created immediately
386
+ * without losing the completed buffer. */
387
+ const completedBuffers = new Map();
388
+ /** Timer handles for completedBuffer expiry, keyed by sessionId.
389
+ * Tracked so we can cancel the previous timer when a new buffer replaces it,
390
+ * allowing the old buffer to be GC'd immediately instead of waiting for expiry. */
391
+ const completedBufferTimers = new Map();
392
+ /** How long to keep completed buffers (ms). Allows JSONL to flush before
393
+ * fetchMessages becomes the sole source for this turn's data. */
394
+ const COMPLETED_BUFFER_TTL_MS = 5000;
395
+ /** Get the earliest stream start timestamp (active OR recently completed).
396
+ * When both exist (e.g., chain: previous turn completed + new turn running),
397
+ * returns the earlier one so fetchMessages excludes ALL stream-period messages.
398
+ * Both the completed turn and active turn are provided via buffer replay. */
399
+ export function getStreamStartedAt(sessionId) {
400
+ const stream = activeStreams.get(sessionId);
401
+ const runningStart = stream && stream.status === 'running' ? stream.startedAt : null;
402
+ const completedStart = completedBuffers.get(sessionId)?.startedAt ?? null;
403
+ if (runningStart && completedStart)
404
+ return Math.min(runningStart, completedStart);
405
+ return runningStart ?? completedStart;
406
+ }
407
+ /** Get the completed buffer for a session (null if none or expired). */
408
+ export function getCompletedBuffer(sessionId) {
409
+ const completed = completedBuffers.get(sessionId);
410
+ return completed ? completed.events : null;
411
+ }
412
+ /** Clean up a stream from activeStreams immediately. Saves the buffer to
413
+ * completedBuffers for 5 seconds so it remains available independently
414
+ * of any new stream that may be created for the same session. */
415
+ function cleanupStream(streamKey, expectedStream) {
416
+ const current = activeStreams.get(streamKey);
417
+ // Identity guard: if caller specifies the expected stream but a replacement has
418
+ // taken over, don't delete activeStreams (would remove the new stream). However,
419
+ // still save the completed buffer so clients can replay the finished turn.
420
+ const replaced = expectedStream && current !== expectedStream;
421
+ const stream = expectedStream ?? current;
422
+ // Keep a reference to the completed buffer independently of activeStreams.
423
+ // No copy needed — the buffer is immutable after completion (createStreamEmit
424
+ // only pushes to running streams). Only the buffer array is retained; the rest
425
+ // of the stream object (sockets, chatService, etc.) is released for GC.
426
+ if (stream && stream.buffer.length > 0) {
427
+ // Only write if this stream is newer than (or same as) any existing entry.
428
+ // An older stream's delayed finalizeStream must not overwrite a newer buffer.
429
+ const existing = completedBuffers.get(streamKey);
430
+ if (!existing || stream.startedAt >= existing.startedAt) {
431
+ // Cancel previous expiry timer so the old buffer can be GC'd immediately
432
+ const prevTimer = completedBufferTimers.get(streamKey);
433
+ if (prevTimer)
434
+ clearTimeout(prevTimer);
435
+ completedBuffers.set(streamKey, {
436
+ events: stream.buffer,
437
+ startedAt: stream.startedAt,
438
+ });
439
+ const timer = setTimeout(() => {
440
+ // Guard: only delete if this timer is still the current one for this key.
441
+ if (completedBufferTimers.get(streamKey) === timer) {
442
+ completedBuffers.delete(streamKey);
443
+ completedBufferTimers.delete(streamKey);
444
+ }
445
+ }, COMPLETED_BUFFER_TTL_MS);
446
+ completedBufferTimers.set(streamKey, timer);
447
+ }
448
+ }
449
+ // Only delete from activeStreams and clean up socket mappings if the stream
450
+ // hasn't been replaced. When replaced, the new stream owns those resources.
451
+ if (!replaced) {
452
+ activeStreams.delete(streamKey);
453
+ for (const [sockId, sessId] of socketToSession.entries()) {
454
+ if (sessId === streamKey)
455
+ socketToSession.delete(sockId);
456
+ }
457
+ }
458
+ }
459
+ /** Normalize legacy 'never' sync policy to 'streaming' */
460
+ function normalizeSyncPolicy(policy) {
461
+ return policy === 'always' ? 'always' : 'streaming';
462
+ }
463
+ /**
464
+ * Persist the stream's final permission mode to .hammoc/session-permissions.json.
465
+ * Returns a promise that resolves when persistence is complete (or fails silently).
466
+ * Must be called before sessionProjectMap.delete() for the given sessionId.
467
+ */
468
+ async function persistSessionPermissionMode(sessionId, mode, fallbackSlug) {
469
+ const slug = sessionProjectMap.get(sessionId) || fallbackSlug;
470
+ if (!slug)
471
+ return;
472
+ try {
473
+ const projectPath = await projectService.resolveProjectPath(slug);
474
+ if (projectPath) {
475
+ await projectService.updateSessionPermission(projectPath, sessionId, mode);
476
+ }
477
+ }
478
+ catch (err) {
479
+ log.error('Failed to persist session permission mode:', err);
141
480
  }
142
481
  }
143
482
  /**
@@ -203,27 +542,52 @@ export function rekeyStream(stream, newSessionId) {
203
542
  * Mark a stream as completed and broadcast stream-change.
204
543
  * Cleans up from activeStreams map.
205
544
  */
206
- export function finalizeStream(sessionId) {
545
+ export async function finalizeStream(sessionId) {
207
546
  const stream = activeStreams.get(sessionId);
208
547
  if (stream) {
548
+ const finalMode = stream.chatService?.getPermissionMode();
549
+ if (finalMode)
550
+ await persistSessionPermissionMode(sessionId, finalMode);
209
551
  stream.status = 'completed';
210
- cleanupStream(sessionId);
552
+ // Pass stream reference so cleanupStream won't accidentally clean up a
553
+ // replacement stream that started during the async persistence above.
554
+ cleanupStream(sessionId, stream);
211
555
  }
212
- io.emit('session:stream-change', { sessionId, active: false });
213
- const slug = sessionProjectMap.get(sessionId);
214
- if (slug) {
215
- sessionProjectMap.delete(sessionId);
216
- triggerDashboardStatusChange(slug);
556
+ // Only emit inactive status and clear project mapping if a replacement stream
557
+ // hasn't taken over during the async persistence above. Without this guard,
558
+ // a new running stream would be falsely reported as inactive.
559
+ // Re-read slug at use time to avoid ABA race with stale capture.
560
+ const currentStream = activeStreams.get(sessionId);
561
+ if (!currentStream || currentStream.status !== 'running') {
562
+ const freshSlug = sessionProjectMap.get(sessionId);
563
+ emitStreamChange(sessionId, false, freshSlug ?? null);
564
+ if (freshSlug) {
565
+ sessionProjectMap.delete(sessionId);
566
+ triggerDashboardStatusChange(freshSlug);
567
+ }
568
+ }
569
+ }
570
+ /**
571
+ * Emit session:stream-change scoped to the project room when projectSlug is known,
572
+ * falling back to global broadcast otherwise.
573
+ */
574
+ function emitStreamChange(sessionId, active, projectSlug) {
575
+ const payload = { sessionId, active, projectSlug };
576
+ if (projectSlug) {
577
+ io.to(`project:${projectSlug}`).emit('session:stream-change', payload);
578
+ }
579
+ else {
580
+ io.emit('session:stream-change', payload);
217
581
  }
218
582
  }
219
583
  /**
220
- * Broadcast session:stream-change to all connected clients.
584
+ * Broadcast session:stream-change to project room (or all clients as fallback).
221
585
  * Used by queue service to signal stream start/end.
222
586
  * Story 20.1: Also triggers dashboard status change when projectSlug is known.
223
587
  */
224
588
  export function broadcastStreamChange(sessionId, active) {
225
- io.emit('session:stream-change', { sessionId, active });
226
589
  const slug = sessionProjectMap.get(sessionId);
590
+ emitStreamChange(sessionId, active, slug ?? null);
227
591
  if (slug) {
228
592
  triggerDashboardStatusChange(slug);
229
593
  if (!active)
@@ -256,7 +620,7 @@ function matchAcceptLanguage(header) {
256
620
  */
257
621
  export async function initializeWebSocket(httpServer) {
258
622
  io = new SocketIOServer(httpServer, {
259
- cors: config.websocket.cors,
623
+ cors: config.cors,
260
624
  maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
261
625
  });
262
626
  // Session middleware for WebSocket (Story 2.5 - Task 4)
@@ -302,7 +666,7 @@ export async function initializeWebSocket(httpServer) {
302
666
  try {
303
667
  const clientIP = extractClientIP(socket);
304
668
  const isLocal = isLocalIP(clientIP);
305
- const terminalEnabled = await preferencesService.getTerminalEnabled();
669
+ const terminalEnabled = preferencesService.getTerminalEnabled();
306
670
  socket.emit('terminal:access', {
307
671
  allowed: terminalEnabled && isLocal,
308
672
  enabled: terminalEnabled,
@@ -387,6 +751,8 @@ export async function initializeWebSocket(httpServer) {
387
751
  startedAt: Date.now(),
388
752
  };
389
753
  activeStreams.set(streamKey, stream);
754
+ // Bump drain generation so any pending scheduled drain is invalidated
755
+ chainDrainGeneration.set(streamKey, (chainDrainGeneration.get(streamKey) || 0) + 1);
390
756
  for (const sock of initialSockets) {
391
757
  socketToSession.set(sock.id, streamKey);
392
758
  }
@@ -400,23 +766,50 @@ export async function initializeWebSocket(httpServer) {
400
766
  }
401
767
  }).catch(() => { });
402
768
  try {
403
- await handleChatSend(stream, data, abortController, lang);
769
+ const sendSuccess = await handleChatSend(stream, data, abortController, lang);
770
+ if (sendSuccess)
771
+ chainResumableSessions.add(stream.sessionId);
404
772
  }
405
773
  finally {
406
774
  stream.status = 'completed';
407
775
  const endedSessionId = stream.sessionId;
776
+ const isCurrentStream = activeStreams.get(endedSessionId) === stream;
777
+ log.info(`[CHAIN-DRAIN] chat:send finally: endedSessionId=${endedSessionId}, isCurrentStream=${isCurrentStream}, socketsOnStream=${stream.sockets.size}`);
408
778
  // Only cleanup if this stream is still the active one for this session.
409
779
  // A replacement stream (from another chat:send) may have already taken over
410
780
  // the same key — deleting it would be a race condition.
411
- if (activeStreams.get(endedSessionId) === stream) {
781
+ if (isCurrentStream) {
782
+ const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
412
783
  cleanupStream(endedSessionId);
413
- io.emit('session:stream-change', { sessionId: endedSessionId, active: false });
784
+ emitStreamChange(endedSessionId, false, sendEndSlug);
785
+ // Persist per-session permission mode before cleanup
786
+ const sendFinalMode = stream.chatService?.getPermissionMode();
787
+ if (sendFinalMode)
788
+ await persistSessionPermissionMode(endedSessionId, sendFinalMode);
414
789
  // Story 20.1: Trigger dashboard status change on stream end
415
790
  const endProjectSlug = sessionProjectMap.get(endedSessionId);
416
791
  if (endProjectSlug) {
792
+ // Update sessions-index.json so future list queries hit cache
793
+ new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
794
+ log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
795
+ });
417
796
  triggerDashboardStatusChange(endProjectSlug);
418
797
  sessionProjectMap.delete(endedSessionId);
419
798
  }
799
+ // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
800
+ const pendingChain = chainState.get(endedSessionId);
801
+ const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
802
+ log.info(`[CHAIN-DRAIN] chat:send finally: chainItems=${pendingChain?.length ?? 0}, pendingCount=${pendingCount}, statuses=${JSON.stringify(pendingChain?.map(i => ({ id: i.id.slice(0, 8), status: i.status })) ?? [])}`);
803
+ if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
804
+ scheduleChainDrain(endedSessionId, lang);
805
+ }
806
+ else {
807
+ log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
808
+ cleanupChainIfIdle(endedSessionId);
809
+ }
810
+ }
811
+ else {
812
+ log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
420
813
  }
421
814
  }
422
815
  });
@@ -430,17 +823,16 @@ export async function initializeWebSocket(httpServer) {
430
823
  stream.pendingPermissions.get(data.requestId).resolve({ approved: data.approved, response: data.response });
431
824
  stream.pendingPermissions.delete(data.requestId);
432
825
  // Broadcast the actual resolution to all OTHER viewers so their
433
- // tool/interactive cards can show the correct approve/deny state
434
- for (const sock of stream.sockets) {
435
- if (sock.id !== socket.id) {
436
- sock.emit('permission:resolved', {
437
- requestId: data.requestId,
438
- approved: data.approved,
439
- interactionType: data.interactionType,
440
- response: data.response,
441
- });
442
- }
443
- }
826
+ // tool/interactive cards can show the correct approve/deny state.
827
+ // Also buffer via createStreamEmit so reconnecting clients see the
828
+ // resolved state instead of a stale 'waiting' permission card.
829
+ const emit = createStreamEmit(stream);
830
+ emit('permission:resolved', {
831
+ requestId: data.requestId,
832
+ approved: data.approved,
833
+ interactionType: data.interactionType,
834
+ response: data.response,
835
+ });
444
836
  }
445
837
  else {
446
838
  // Permission already resolved by another viewer — notify sender
@@ -462,13 +854,35 @@ export async function initializeWebSocket(httpServer) {
462
854
  }
463
855
  stream.abortController.abort('user-abort');
464
856
  }
857
+ // Also clear any pending prompt chain — user expects everything to stop.
858
+ // Always bump generation to invalidate any in-flight drain timers,
859
+ // even if chainState is already empty (stale timer edge case).
860
+ const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
861
+ chainDrainGeneration.set(sessionId, gen);
862
+ const chainItems = chainState.get(sessionId);
863
+ if (chainItems && chainItems.length > 0) {
864
+ chainState.set(sessionId, []);
865
+ broadcastChainUpdate(sessionId);
866
+ // Do NOT call cleanupChainIfIdle here — the active stream still exists
867
+ // and scheduleChainDrain's finally block will handle cleanup after it completes.
868
+ clearPersistedFailures(sessionId)
869
+ .then(() => {
870
+ // Guard: only broadcast if no new chain was started since this abort
871
+ if (chainDrainGeneration.get(sessionId) === gen) {
872
+ broadcastChainUpdate(sessionId);
873
+ }
874
+ })
875
+ .catch((err) => {
876
+ log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
877
+ });
878
+ }
465
879
  });
466
880
  // Handle permission:mode-change — update SDK permission mode and broadcast to viewers
467
881
  socket.on('permission:mode-change', async (data) => {
468
- const sessionId = socketToSession.get(socket.id);
882
+ const sessionId = socketToSession.get(socket.id) || socketSessionRoom.get(socket.id);
469
883
  if (!sessionId)
470
884
  return;
471
- const { mode, syncPolicy = 'streaming' } = data;
885
+ const { mode, projectSlug } = data;
472
886
  const stream = activeStreams.get(sessionId);
473
887
  // 1) Update SDK permission mode — only when stream is actively running
474
888
  if (stream?.chatService && stream.status === 'running') {
@@ -478,11 +892,32 @@ export async function initializeWebSocket(httpServer) {
478
892
  }
479
893
  catch (err) {
480
894
  log.error('Failed to change permission mode:', err);
895
+ return; // Don't persist or broadcast a mode that failed to apply
481
896
  }
482
897
  }
483
- // 2) Broadcast to other viewers based on sync policy
484
- if (syncPolicy === 'never')
485
- return;
898
+ // Update pending chain items so the next drain uses the new mode.
899
+ // Outside the running-stream block because mode can change between turns
900
+ // (e.g., during the 1s chain drain delay when no stream is active).
901
+ const chainItems = chainState.get(sessionId);
902
+ if (chainItems) {
903
+ for (const item of chainItems) {
904
+ if (item.status === 'pending' || item.status === 'sending') {
905
+ item.permissionMode = mode;
906
+ }
907
+ }
908
+ }
909
+ // 2) Always persist per-session permission mode (read only when policy is 'always')
910
+ // Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
911
+ await persistSessionPermissionMode(sessionId, mode, projectSlug);
912
+ // 3) Broadcast to other viewers based on sync policy
913
+ let syncPolicy = 'streaming';
914
+ try {
915
+ const prefs = await preferencesService.readPreferences();
916
+ syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
917
+ }
918
+ catch (err) {
919
+ log.error('Failed to read preferences for sync policy:', err);
920
+ }
486
921
  if (syncPolicy === 'streaming' && stream?.status !== 'running')
487
922
  return;
488
923
  // 'always' or ('streaming' + running) → broadcast via Socket.io room
@@ -490,7 +925,7 @@ export async function initializeWebSocket(httpServer) {
490
925
  });
491
926
  // Handle session:join event — attach socket to active running stream (broadcast)
492
927
  // Also joins a persistent Socket.io room so future streams auto-include this socket.
493
- socket.on('session:join', (sessionId) => {
928
+ socket.on('session:join', (sessionId, projectSlug) => {
494
929
  // Detach this socket from any previously-attached stream to prevent
495
930
  // events from the old stream leaking to a different session's listeners
496
931
  const prevSessionId = socketToSession.get(socket.id);
@@ -502,22 +937,124 @@ export async function initializeWebSocket(httpServer) {
502
937
  socketToSession.delete(socket.id);
503
938
  socket.leave(`session:${prevSessionId}`);
504
939
  }
940
+ // Also leave previous session room even when no active stream existed
941
+ // (socketToSession is only set when a stream is running)
942
+ const prevRoomSessionId = socketSessionRoom.get(socket.id);
943
+ if (prevRoomSessionId && prevRoomSessionId !== sessionId && prevRoomSessionId !== prevSessionId) {
944
+ socket.leave(`session:${prevRoomSessionId}`);
945
+ }
505
946
  // Join persistent session room (survives beyond ActiveStream lifecycle)
506
947
  socket.join(`session:${sessionId}`);
948
+ // Leave previous project room if switching projects (or if new join has no projectSlug)
949
+ const prevProjectRoom = socketProjectRoom.get(socket.id);
950
+ if (prevProjectRoom && prevProjectRoom !== projectSlug) {
951
+ socket.leave(`project:${prevProjectRoom}`);
952
+ socketProjectRoom.delete(socket.id);
953
+ }
954
+ // Join project room so scoped events (e.g., session:stream-change) are received
955
+ if (projectSlug) {
956
+ socket.join(`project:${projectSlug}`);
957
+ socketProjectRoom.set(socket.id, projectSlug);
958
+ }
959
+ // Story 24.3: Track session room membership for session:leave room management
960
+ socketSessionRoom.set(socket.id, sessionId);
507
961
  const stream = activeStreams.get(sessionId);
962
+ // Story 24.1: Send current chain state on join (in-memory + persisted failures)
963
+ // Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
964
+ if (UUID_RE.test(sessionId)) {
965
+ withChainFailureLock(sessionId, async () => {
966
+ return projectService.readChainFailures(sessionId);
967
+ }).then(failures => {
968
+ // Re-read in-memory state now (may have changed during async disk read)
969
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
970
+ const allItems = [...freshItems, ...failures];
971
+ socket.emit('chain:update', { sessionId, items: allItems });
972
+ }).catch((err) => {
973
+ log.error(`Failed to read chain failures on join (session ${sessionId}):`, err);
974
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
975
+ socket.emit('chain:update', { sessionId, items: freshItems });
976
+ });
977
+ }
978
+ else {
979
+ // Non-UUID session: only send in-memory chain state (no disk persistence)
980
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
981
+ socket.emit('chain:update', { sessionId, items: freshItems });
982
+ }
508
983
  if (!stream || stream.status !== 'running') {
509
- socket.emit('stream:status', { active: false, sessionId });
984
+ // Emit inactive status + completed buffer replay.
985
+ // Wrapped in a helper that re-checks activeStreams because the async
986
+ // preference-read path can yield, and a new stream may start in between.
987
+ const emitInactiveWithReplay = (permissionMode) => {
988
+ // Stale callback guard: if the socket has left this session (moved to
989
+ // another session or disconnected), don't emit anything for the old session.
990
+ if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
991
+ return;
992
+ }
993
+ // Re-check: if a running stream appeared during async wait, emit active
994
+ // state instead of stale inactive. Without this, the client would miss
995
+ // the initial stream:status/buffer-replay for the new stream.
996
+ const freshStream = activeStreams.get(sessionId);
997
+ if (freshStream && freshStream.status === 'running') {
998
+ socketToSession.set(socket.id, sessionId);
999
+ const bufSnapshot = [...freshStream.buffer];
1000
+ const freshMode = freshStream.chatService?.getPermissionMode();
1001
+ socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
1002
+ const completedBuf = getCompletedBuffer(sessionId);
1003
+ if (completedBuf) {
1004
+ socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
1005
+ }
1006
+ socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
1007
+ freshStream.sockets.add(socket);
1008
+ return;
1009
+ }
1010
+ socket.emit('stream:status', { active: false, sessionId, permissionMode });
1011
+ // Replay recently completed stream buffer so the client has the finished turn
1012
+ const completed = getCompletedBuffer(sessionId);
1013
+ if (completed) {
1014
+ socket.emit('stream:buffer-replay', { sessionId, events: completed });
1015
+ }
1016
+ };
1017
+ // For 'always' sync policy, restore per-session permission mode from disk
1018
+ const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
1019
+ if (resolvedSlug && UUID_RE.test(sessionId)) {
1020
+ preferencesService.readPreferences().then(async (prefs) => {
1021
+ if (normalizeSyncPolicy(prefs.permissionSyncPolicy) === 'always') {
1022
+ const projectPath = await projectService.resolveProjectPath(resolvedSlug);
1023
+ if (projectPath) {
1024
+ const perms = await projectService.readSessionPermissions(projectPath);
1025
+ const savedMode = perms[sessionId];
1026
+ emitInactiveWithReplay(savedMode);
1027
+ return;
1028
+ }
1029
+ }
1030
+ emitInactiveWithReplay();
1031
+ }).catch(() => {
1032
+ emitInactiveWithReplay();
1033
+ });
1034
+ }
1035
+ else {
1036
+ emitInactiveWithReplay();
1037
+ }
510
1038
  return;
511
1039
  }
512
- // Add socket to broadcast set (multiple browsers can watch simultaneously)
513
- stream.sockets.add(socket);
514
1040
  socketToSession.set(socket.id, sessionId);
515
- // Notify this client that stream is active, then replay entire buffer
516
- socket.emit('stream:status', { active: true, sessionId });
517
- for (const entry of stream.buffer) {
518
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
519
- socket.emit(entry.event, entry.data);
1041
+ // Snapshot BEFORE adding socket to broadcast set to prevent race.
1042
+ const bufferSnapshot = [...stream.buffer];
1043
+ const permissionMode = stream.chatService?.getPermissionMode();
1044
+ // Emit order matters for the client:
1045
+ // 1. stream:status { active: true } → client calls restoreStreaming + trimMessagesAfterLastUser
1046
+ // 2. completed buffer replay → client calls addMessages (trim already ran, won't remove these)
1047
+ // 3. active buffer replay → client sets streaming segments for the current turn
1048
+ socket.emit('stream:status', { active: true, sessionId, permissionMode });
1049
+ // Replay recently completed stream buffer (e.g., previous chain turn) AFTER
1050
+ // stream:status so the client has already trimmed stale messages.
1051
+ const completedBuf = getCompletedBuffer(sessionId);
1052
+ if (completedBuf) {
1053
+ socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
520
1054
  }
1055
+ socket.emit('stream:buffer-replay', { sessionId, events: bufferSnapshot });
1056
+ // NOW add to broadcast set — live events flow from here
1057
+ stream.sockets.add(socket);
521
1058
  });
522
1059
  // Handle session:leave event — detach socket from current stream and session room
523
1060
  // (client navigating away from a session while streaming continues in background)
@@ -530,12 +1067,133 @@ export async function initializeWebSocket(httpServer) {
530
1067
  }
531
1068
  socketToSession.delete(socket.id);
532
1069
  }
533
- // Use prevSessionId as fallback — client may send empty string when
534
- // the sessionId is not available at unmount time (e.g., ChatPage cleanup)
535
- const roomSessionId = sessionId || prevSessionId;
1070
+ // Use prevSessionId / socketSessionRoom as fallback — client may send empty
1071
+ // string when the sessionId is not available at unmount time (e.g., ChatPage cleanup)
1072
+ const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
536
1073
  if (roomSessionId) {
537
1074
  socket.leave(`session:${roomSessionId}`);
538
1075
  }
1076
+ // Story 24.3: Clean up session room tracking on leave
1077
+ // Only clear project room if the leaving session matches current tracking.
1078
+ // Prevents race where a new session:join overwrites tracking before old leave arrives.
1079
+ const trackedSession = socketSessionRoom.get(socket.id);
1080
+ if (!trackedSession || trackedSession === roomSessionId) {
1081
+ socketSessionRoom.delete(socket.id);
1082
+ const prevProjectSlug = socketProjectRoom.get(socket.id);
1083
+ if (prevProjectSlug) {
1084
+ socket.leave(`project:${prevProjectSlug}`);
1085
+ }
1086
+ socketProjectRoom.delete(socket.id);
1087
+ }
1088
+ });
1089
+ // Story 24.1: Prompt chain event handlers
1090
+ socket.on('chain:add', (data) => {
1091
+ if (!data || typeof data !== 'object')
1092
+ return;
1093
+ const { sessionId, content, workingDirectory, permissionMode, model } = data;
1094
+ const lang = socket.data.language || 'en';
1095
+ const t = i18next.getFixedT(lang);
1096
+ // Input validation (UUID required for disk persistence compatibility)
1097
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1098
+ return;
1099
+ if (!content || typeof content !== 'string' || !content.trim())
1100
+ return;
1101
+ if (!workingDirectory || typeof workingDirectory !== 'string')
1102
+ return;
1103
+ if (content.length > 100_000) {
1104
+ socket.emit('error', { code: ERROR_CODES.CHAT_ERROR, message: t('ws.error.chainContentTooLong') });
1105
+ return;
1106
+ }
1107
+ // Validate socket is a member of the session room
1108
+ if (!socket.rooms.has(`session:${sessionId}`))
1109
+ return;
1110
+ const items = chainState.get(sessionId) || [];
1111
+ if (items.length >= 10) {
1112
+ socket.emit('error', {
1113
+ code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
1114
+ message: t('ws.error.chainMaxExceeded'),
1115
+ });
1116
+ return;
1117
+ }
1118
+ const item = {
1119
+ id: generateChainItemId(),
1120
+ content,
1121
+ status: 'pending',
1122
+ createdAt: Date.now(),
1123
+ workingDirectory,
1124
+ permissionMode,
1125
+ model,
1126
+ };
1127
+ items.push(item);
1128
+ chainState.set(sessionId, items);
1129
+ broadcastChainUpdate(sessionId);
1130
+ // If no active stream, trigger drain so items don't stay pending indefinitely
1131
+ if (!activeStreams.has(sessionId)) {
1132
+ scheduleChainDrain(sessionId, lang);
1133
+ }
1134
+ });
1135
+ socket.on('chain:remove', (data) => {
1136
+ if (!data || typeof data !== 'object')
1137
+ return;
1138
+ const { sessionId, id } = data;
1139
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1140
+ return;
1141
+ if (!id || typeof id !== 'string')
1142
+ return;
1143
+ if (!socket.rooms.has(`session:${sessionId}`))
1144
+ return;
1145
+ const items = chainState.get(sessionId);
1146
+ if (items) {
1147
+ // If the removed item is currently sending, abort its active stream
1148
+ const removedItem = items.find(item => item.id === id);
1149
+ if (removedItem?.status === 'sending') {
1150
+ const stream = activeStreams.get(sessionId);
1151
+ if (stream && stream.status === 'running') {
1152
+ stream.abortController.abort('chain-item-removed');
1153
+ }
1154
+ }
1155
+ const filtered = items.filter(item => item.id !== id);
1156
+ chainState.set(sessionId, filtered);
1157
+ broadcastChainUpdate(sessionId);
1158
+ if (filtered.length === 0) {
1159
+ cleanupChainIfIdle(sessionId);
1160
+ }
1161
+ }
1162
+ // Also remove from persisted failures (dismiss)
1163
+ removePersistedFailure(sessionId, id).catch((err) => {
1164
+ log.error(`Failed to remove persisted failure ${id} for session ${sessionId}:`, err);
1165
+ });
1166
+ });
1167
+ socket.on('chain:clear', (data) => {
1168
+ if (!data || typeof data !== 'object')
1169
+ return;
1170
+ const { sessionId } = data;
1171
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1172
+ return;
1173
+ // Validate socket is a member of the session room
1174
+ if (!socket.rooms.has(`session:${sessionId}`))
1175
+ return;
1176
+ // If any item is currently sending, abort its active stream
1177
+ const items = chainState.get(sessionId);
1178
+ if (items?.some(item => item.status === 'sending')) {
1179
+ const stream = activeStreams.get(sessionId);
1180
+ if (stream && stream.status === 'running') {
1181
+ stream.abortController.abort('chain-cleared');
1182
+ }
1183
+ }
1184
+ chainState.set(sessionId, []);
1185
+ // Bump generation instead of deleting — prevents stale timers from matching
1186
+ // a reset counter value after clear + re-add sequence
1187
+ chainDrainGeneration.set(sessionId, (chainDrainGeneration.get(sessionId) || 0) + 1);
1188
+ broadcastChainUpdate(sessionId);
1189
+ cleanupChainIfIdle(sessionId);
1190
+ // Clear persisted failures from disk, then broadcast final consistent state
1191
+ // (prevents stale failures from reappearing via concurrent broadcastChainUpdateWithFailures)
1192
+ clearPersistedFailures(sessionId)
1193
+ .then(() => broadcastChainUpdate(sessionId))
1194
+ .catch((err) => {
1195
+ log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
1196
+ });
539
1197
  });
540
1198
  // Story 20.1: Dashboard subscribe/unsubscribe
541
1199
  socket.on('dashboard:subscribe', () => {
@@ -610,7 +1268,7 @@ export async function initializeWebSocket(httpServer) {
610
1268
  const lang = socket.data.language || 'en';
611
1269
  const t = i18next.getFixedT(lang);
612
1270
  // Story 17.5: Security guard
613
- const access = await checkTerminalAccess(socket, lang);
1271
+ const access = checkTerminalAccess(socket, lang);
614
1272
  if (!access.allowed) {
615
1273
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:create`);
616
1274
  socket.emit('terminal:error', access.error);
@@ -673,7 +1331,7 @@ export async function initializeWebSocket(httpServer) {
673
1331
  });
674
1332
  socket.on('terminal:input', async (data) => {
675
1333
  // Story 17.5: Security guard
676
- const inputAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1334
+ const inputAccess = checkTerminalAccess(socket, socket.data.language || 'en');
677
1335
  if (!inputAccess.allowed) {
678
1336
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:input`);
679
1337
  socket.emit('terminal:error', { ...inputAccess.error, terminalId: data.terminalId });
@@ -689,7 +1347,7 @@ export async function initializeWebSocket(httpServer) {
689
1347
  });
690
1348
  socket.on('terminal:resize', async (data) => {
691
1349
  // Story 17.5: Security guard
692
- const resizeAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1350
+ const resizeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
693
1351
  if (!resizeAccess.allowed) {
694
1352
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:resize`);
695
1353
  socket.emit('terminal:error', { ...resizeAccess.error, terminalId: data.terminalId });
@@ -705,7 +1363,7 @@ export async function initializeWebSocket(httpServer) {
705
1363
  });
706
1364
  socket.on('terminal:list', async (data) => {
707
1365
  const lang = socket.data.language || 'en';
708
- const access = await checkTerminalAccess(socket, lang);
1366
+ const access = checkTerminalAccess(socket, lang);
709
1367
  if (!access.allowed) {
710
1368
  socket.emit('terminal:list', { projectSlug: data.projectSlug, terminals: [] });
711
1369
  return;
@@ -738,7 +1396,7 @@ export async function initializeWebSocket(httpServer) {
738
1396
  });
739
1397
  socket.on('terminal:close', async (data) => {
740
1398
  // Story 17.5: Security guard
741
- const closeAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1399
+ const closeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
742
1400
  if (!closeAccess.allowed) {
743
1401
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:close`);
744
1402
  socket.emit('terminal:error', { ...closeAccess.error, terminalId: data.terminalId });
@@ -768,6 +1426,8 @@ export async function initializeWebSocket(httpServer) {
768
1426
  }
769
1427
  socketToSession.delete(socket.id);
770
1428
  }
1429
+ socketSessionRoom.delete(socket.id);
1430
+ socketProjectRoom.delete(socket.id);
771
1431
  // PTY sessions are NOT cleaned up on socket disconnect.
772
1432
  // They persist until explicitly closed by the user, the PTY process exits,
773
1433
  // or the server shuts down. This prevents losing long-running terminal
@@ -833,7 +1493,8 @@ function isSessionNotFoundError(error) {
833
1493
  return (message.includes('session not found') ||
834
1494
  message.includes('session does not exist') ||
835
1495
  message.includes('invalid session') ||
836
- message.includes('no such session'));
1496
+ message.includes('no such session') ||
1497
+ message.includes('no conversation found'));
837
1498
  }
838
1499
  /**
839
1500
  * Handle chat:send event from client
@@ -852,7 +1513,7 @@ async function handleChatSend(stream, data, abortController, lang) {
852
1513
  code: ERROR_CODES.VALIDATION_ERROR,
853
1514
  message: validation.error,
854
1515
  });
855
- return;
1516
+ return false;
856
1517
  }
857
1518
  }
858
1519
  // Validate workingDirectory exists
@@ -861,11 +1522,18 @@ async function handleChatSend(stream, data, abortController, lang) {
861
1522
  code: ERROR_CODES.INVALID_WORKING_DIR,
862
1523
  message: t('ws.error.projectPathNotFound'),
863
1524
  });
864
- return;
1525
+ return false;
865
1526
  }
866
1527
  // Buffer the user's message so reconnecting clients can display it
867
- // (SDK may not have written the JSONL file yet at reconnect time)
868
- emit('user:message', { content, sessionId: sessionId || '' });
1528
+ // (SDK may not have written the JSONL file yet at reconnect time).
1529
+ // Include timestamp for correct ordering. For images, only send count
1530
+ // (not full base64 data) to avoid bloating the buffer.
1531
+ emit('user:message', {
1532
+ content,
1533
+ sessionId: sessionId || '',
1534
+ timestamp: new Date().toISOString(),
1535
+ ...(images && images.length > 0 ? { imageCount: images.length } : {}),
1536
+ });
869
1537
  const isResuming = resume && sessionId;
870
1538
  const sessionService = new SessionService();
871
1539
  let timeoutId = null;
@@ -888,11 +1556,11 @@ async function handleChatSend(stream, data, abortController, lang) {
888
1556
  // Create canUseTool callback for permission & AskUserQuestion handling
889
1557
  // Promise stays pending if socket disconnected — SDK naturally waits
890
1558
  const canUseTool = async (toolName, input, options) => {
891
- // Auto-approve ExitPlanMode when the user originally chose Bypass mode.
892
- // The SDK internally switches to 'plan' after EnterPlanMode, which causes
893
- // ExitPlanMode to request approval even though the user intended full bypass.
894
- if (toolName === 'ExitPlanMode' && permissionMode === 'bypassPermissions') {
895
- log.debug('Auto-approving ExitPlanMode: original permissionMode is bypassPermissions');
1559
+ // Auto-approve ExitPlanMode when the current permission mode is Bypass.
1560
+ // Use chatService.getPermissionMode() instead of the closure-captured variable
1561
+ // so that mid-stream permission mode changes (e.g. Plan Bypass) are reflected.
1562
+ if (toolName === 'ExitPlanMode' && chatService.getPermissionMode() === 'bypassPermissions') {
1563
+ log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
896
1564
  return { behavior: 'allow', updatedInput: input };
897
1565
  }
898
1566
  const isAskUserQuestion = toolName === 'AskUserQuestion';
@@ -991,13 +1659,6 @@ async function handleChatSend(stream, data, abortController, lang) {
991
1659
  }
992
1660
  }
993
1661
  const sdkError = parseSDKError(error, lang);
994
- if (isResuming && isSessionNotFoundError(error)) {
995
- emit('error', {
996
- code: ERROR_CODES.SESSION_NOT_FOUND,
997
- message: t('ws.error.sessionNotFound'),
998
- });
999
- return;
1000
- }
1001
1662
  emit('error', {
1002
1663
  code: ERROR_CODES.CHAT_ERROR,
1003
1664
  message: sdkError.message,
@@ -1007,33 +1668,48 @@ async function handleChatSend(stream, data, abortController, lang) {
1007
1668
  notificationService.notifyError(stream.sessionId, sdkError.message);
1008
1669
  }
1009
1670
  };
1010
- await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
1011
- resetTimeout(`raw:${messageType}`);
1012
- });
1671
+ // Attempt to send — if resume fails with session-not-found, retry without resume
1672
+ try {
1673
+ await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
1674
+ resetTimeout(`raw:${messageType}`);
1675
+ });
1676
+ }
1677
+ catch (sendError) {
1678
+ // Resume failed because session doesn't exist (e.g., first send was aborted before SDK created it).
1679
+ // Retry once without resume so SDK creates a fresh session.
1680
+ if (isResuming && sendError instanceof Error && isSessionNotFoundError(sendError)) {
1681
+ log.info(`[CHAIN-DRAIN] resume failed (session not found), retrying without resume: sessionId=${sessionId}`);
1682
+ const retryOptions = { ...chatOptions, resume: undefined, sessionId };
1683
+ delete retryOptions.resume;
1684
+ resetTimeout('resume-retry');
1685
+ await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
1686
+ resetTimeout(`raw:${messageType}`);
1687
+ });
1688
+ }
1689
+ else {
1690
+ throw sendError;
1691
+ }
1692
+ }
1693
+ return true;
1013
1694
  }
1014
1695
  catch (error) {
1015
1696
  const sdkError = parseSDKError(error, lang);
1697
+ log.info(`[CHAIN-DRAIN] handleChatSend catch: sessionId=${stream.sessionId}, aborted=${abortController.signal.aborted}, reason=${abortController.signal.reason}, error=${sdkError.message.slice(0, 120)}`);
1016
1698
  if (sdkError instanceof AbortedError || abortController.signal.aborted) {
1017
1699
  if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
1018
- return;
1700
+ return false;
1019
1701
  }
1020
1702
  emit('error', {
1021
1703
  code: ERROR_CODES.TIMEOUT_ERROR,
1022
1704
  message: t('ws.error.timeout'),
1023
1705
  });
1024
- return;
1025
- }
1026
- if (isResuming && error instanceof Error && isSessionNotFoundError(error)) {
1027
- emit('error', {
1028
- code: ERROR_CODES.SESSION_NOT_FOUND,
1029
- message: t('ws.error.sessionNotFound'),
1030
- });
1031
- return;
1706
+ return false;
1032
1707
  }
1033
1708
  emit('error', {
1034
1709
  code: ERROR_CODES.CHAT_ERROR,
1035
1710
  message: sdkError.message,
1036
1711
  });
1712
+ return false;
1037
1713
  }
1038
1714
  finally {
1039
1715
  if (timeoutId) {