hammoc 1.0.4 → 1.1.1

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-JvaBmdnx.css +32 -0
  5. package/packages/client/dist/assets/index-RJTIkL2R.js +1446 -0
  6. package/packages/client/dist/assets/{index-Zkw0a1l9.js → index-cwHQKX3r.js} +1 -1
  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 +3 -1
  41. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  42. package/packages/server/dist/handlers/websocket.d.ts +14 -2
  43. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  44. package/packages/server/dist/handlers/websocket.js +780 -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 +142 -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 +7 -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,109 @@ 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 start timestamp of the currently running stream only (ignores completed buffers). */
408
+ export function getRunningStreamStartedAt(sessionId) {
409
+ const stream = activeStreams.get(sessionId);
410
+ return stream && stream.status === 'running' ? stream.startedAt : null;
411
+ }
412
+ /** Get the completed buffer for a session (null if none or expired). */
413
+ export function getCompletedBuffer(sessionId) {
414
+ const completed = completedBuffers.get(sessionId);
415
+ return completed ? completed.events : null;
416
+ }
417
+ /** Clean up a stream from activeStreams immediately. Saves the buffer to
418
+ * completedBuffers for 5 seconds so it remains available independently
419
+ * of any new stream that may be created for the same session. */
420
+ function cleanupStream(streamKey, expectedStream) {
421
+ const current = activeStreams.get(streamKey);
422
+ // Identity guard: if caller specifies the expected stream but a replacement has
423
+ // taken over, don't delete activeStreams (would remove the new stream). However,
424
+ // still save the completed buffer so clients can replay the finished turn.
425
+ const replaced = expectedStream && current !== expectedStream;
426
+ const stream = expectedStream ?? current;
427
+ // Keep a reference to the completed buffer independently of activeStreams.
428
+ // No copy needed — the buffer is immutable after completion (createStreamEmit
429
+ // only pushes to running streams). Only the buffer array is retained; the rest
430
+ // of the stream object (sockets, chatService, etc.) is released for GC.
431
+ if (stream && stream.buffer.length > 0) {
432
+ // Only write if this stream is newer than (or same as) any existing entry.
433
+ // An older stream's delayed finalizeStream must not overwrite a newer buffer.
434
+ const existing = completedBuffers.get(streamKey);
435
+ if (!existing || stream.startedAt >= existing.startedAt) {
436
+ // Cancel previous expiry timer so the old buffer can be GC'd immediately
437
+ const prevTimer = completedBufferTimers.get(streamKey);
438
+ if (prevTimer)
439
+ clearTimeout(prevTimer);
440
+ completedBuffers.set(streamKey, {
441
+ events: stream.buffer,
442
+ startedAt: stream.startedAt,
443
+ });
444
+ const timer = setTimeout(() => {
445
+ // Guard: only delete if this timer is still the current one for this key.
446
+ if (completedBufferTimers.get(streamKey) === timer) {
447
+ completedBuffers.delete(streamKey);
448
+ completedBufferTimers.delete(streamKey);
449
+ }
450
+ }, COMPLETED_BUFFER_TTL_MS);
451
+ completedBufferTimers.set(streamKey, timer);
452
+ }
453
+ }
454
+ // Only delete from activeStreams and clean up socket mappings if the stream
455
+ // hasn't been replaced. When replaced, the new stream owns those resources.
456
+ if (!replaced) {
457
+ activeStreams.delete(streamKey);
458
+ for (const [sockId, sessId] of socketToSession.entries()) {
459
+ if (sessId === streamKey)
460
+ socketToSession.delete(sockId);
461
+ }
462
+ }
463
+ }
464
+ /** Normalize legacy 'never' sync policy to 'streaming' */
465
+ function normalizeSyncPolicy(policy) {
466
+ return policy === 'always' ? 'always' : 'streaming';
467
+ }
468
+ /**
469
+ * Persist the stream's final permission mode to .hammoc/session-permissions.json.
470
+ * Returns a promise that resolves when persistence is complete (or fails silently).
471
+ * Must be called before sessionProjectMap.delete() for the given sessionId.
472
+ */
473
+ async function persistSessionPermissionMode(sessionId, mode, fallbackSlug) {
474
+ const slug = sessionProjectMap.get(sessionId) || fallbackSlug;
475
+ if (!slug)
476
+ return;
477
+ try {
478
+ const projectPath = await projectService.resolveProjectPath(slug);
479
+ if (projectPath) {
480
+ await projectService.updateSessionPermission(projectPath, sessionId, mode);
481
+ }
482
+ }
483
+ catch (err) {
484
+ log.error('Failed to persist session permission mode:', err);
141
485
  }
142
486
  }
143
487
  /**
@@ -203,27 +547,52 @@ export function rekeyStream(stream, newSessionId) {
203
547
  * Mark a stream as completed and broadcast stream-change.
204
548
  * Cleans up from activeStreams map.
205
549
  */
206
- export function finalizeStream(sessionId) {
550
+ export async function finalizeStream(sessionId) {
207
551
  const stream = activeStreams.get(sessionId);
208
552
  if (stream) {
553
+ const finalMode = stream.chatService?.getPermissionMode();
554
+ if (finalMode)
555
+ await persistSessionPermissionMode(sessionId, finalMode);
209
556
  stream.status = 'completed';
210
- cleanupStream(sessionId);
557
+ // Pass stream reference so cleanupStream won't accidentally clean up a
558
+ // replacement stream that started during the async persistence above.
559
+ cleanupStream(sessionId, stream);
211
560
  }
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);
561
+ // Only emit inactive status and clear project mapping if a replacement stream
562
+ // hasn't taken over during the async persistence above. Without this guard,
563
+ // a new running stream would be falsely reported as inactive.
564
+ // Re-read slug at use time to avoid ABA race with stale capture.
565
+ const currentStream = activeStreams.get(sessionId);
566
+ if (!currentStream || currentStream.status !== 'running') {
567
+ const freshSlug = sessionProjectMap.get(sessionId);
568
+ emitStreamChange(sessionId, false, freshSlug ?? null);
569
+ if (freshSlug) {
570
+ sessionProjectMap.delete(sessionId);
571
+ triggerDashboardStatusChange(freshSlug);
572
+ }
573
+ }
574
+ }
575
+ /**
576
+ * Emit session:stream-change scoped to the project room when projectSlug is known,
577
+ * falling back to global broadcast otherwise.
578
+ */
579
+ function emitStreamChange(sessionId, active, projectSlug) {
580
+ const payload = { sessionId, active, projectSlug };
581
+ if (projectSlug) {
582
+ io.to(`project:${projectSlug}`).emit('session:stream-change', payload);
583
+ }
584
+ else {
585
+ io.emit('session:stream-change', payload);
217
586
  }
218
587
  }
219
588
  /**
220
- * Broadcast session:stream-change to all connected clients.
589
+ * Broadcast session:stream-change to project room (or all clients as fallback).
221
590
  * Used by queue service to signal stream start/end.
222
591
  * Story 20.1: Also triggers dashboard status change when projectSlug is known.
223
592
  */
224
593
  export function broadcastStreamChange(sessionId, active) {
225
- io.emit('session:stream-change', { sessionId, active });
226
594
  const slug = sessionProjectMap.get(sessionId);
595
+ emitStreamChange(sessionId, active, slug ?? null);
227
596
  if (slug) {
228
597
  triggerDashboardStatusChange(slug);
229
598
  if (!active)
@@ -256,7 +625,7 @@ function matchAcceptLanguage(header) {
256
625
  */
257
626
  export async function initializeWebSocket(httpServer) {
258
627
  io = new SocketIOServer(httpServer, {
259
- cors: config.websocket.cors,
628
+ cors: config.cors,
260
629
  maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
261
630
  });
262
631
  // Session middleware for WebSocket (Story 2.5 - Task 4)
@@ -302,7 +671,7 @@ export async function initializeWebSocket(httpServer) {
302
671
  try {
303
672
  const clientIP = extractClientIP(socket);
304
673
  const isLocal = isLocalIP(clientIP);
305
- const terminalEnabled = await preferencesService.getTerminalEnabled();
674
+ const terminalEnabled = preferencesService.getTerminalEnabled();
306
675
  socket.emit('terminal:access', {
307
676
  allowed: terminalEnabled && isLocal,
308
677
  enabled: terminalEnabled,
@@ -387,6 +756,8 @@ export async function initializeWebSocket(httpServer) {
387
756
  startedAt: Date.now(),
388
757
  };
389
758
  activeStreams.set(streamKey, stream);
759
+ // Bump drain generation so any pending scheduled drain is invalidated
760
+ chainDrainGeneration.set(streamKey, (chainDrainGeneration.get(streamKey) || 0) + 1);
390
761
  for (const sock of initialSockets) {
391
762
  socketToSession.set(sock.id, streamKey);
392
763
  }
@@ -400,23 +771,50 @@ export async function initializeWebSocket(httpServer) {
400
771
  }
401
772
  }).catch(() => { });
402
773
  try {
403
- await handleChatSend(stream, data, abortController, lang);
774
+ const sendSuccess = await handleChatSend(stream, data, abortController, lang);
775
+ if (sendSuccess)
776
+ chainResumableSessions.add(stream.sessionId);
404
777
  }
405
778
  finally {
406
779
  stream.status = 'completed';
407
780
  const endedSessionId = stream.sessionId;
781
+ const isCurrentStream = activeStreams.get(endedSessionId) === stream;
782
+ log.info(`[CHAIN-DRAIN] chat:send finally: endedSessionId=${endedSessionId}, isCurrentStream=${isCurrentStream}, socketsOnStream=${stream.sockets.size}`);
408
783
  // Only cleanup if this stream is still the active one for this session.
409
784
  // A replacement stream (from another chat:send) may have already taken over
410
785
  // the same key — deleting it would be a race condition.
411
- if (activeStreams.get(endedSessionId) === stream) {
786
+ if (isCurrentStream) {
787
+ const sendEndSlug = sessionProjectMap.get(endedSessionId) ?? null;
412
788
  cleanupStream(endedSessionId);
413
- io.emit('session:stream-change', { sessionId: endedSessionId, active: false });
789
+ emitStreamChange(endedSessionId, false, sendEndSlug);
790
+ // Persist per-session permission mode before cleanup
791
+ const sendFinalMode = stream.chatService?.getPermissionMode();
792
+ if (sendFinalMode)
793
+ await persistSessionPermissionMode(endedSessionId, sendFinalMode);
414
794
  // Story 20.1: Trigger dashboard status change on stream end
415
795
  const endProjectSlug = sessionProjectMap.get(endedSessionId);
416
796
  if (endProjectSlug) {
797
+ // Update sessions-index.json so future list queries hit cache
798
+ new SessionService().updateSessionIndex(endProjectSlug, endedSessionId).catch((err) => {
799
+ log.warn(`Failed to update session index: project=${endProjectSlug} session=${endedSessionId}`, err);
800
+ });
417
801
  triggerDashboardStatusChange(endProjectSlug);
418
802
  sessionProjectMap.delete(endedSessionId);
419
803
  }
804
+ // Story 24.1: Schedule chain drain if pending items exist (browser-independent)
805
+ const pendingChain = chainState.get(endedSessionId);
806
+ const pendingCount = pendingChain?.filter(item => item.status === 'pending').length ?? 0;
807
+ 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 })) ?? [])}`);
808
+ if (pendingChain && pendingChain.some(item => item.status === 'pending')) {
809
+ scheduleChainDrain(endedSessionId, lang);
810
+ }
811
+ else {
812
+ log.info(`[CHAIN-DRAIN] chat:send finally: no pending chain items, calling cleanupChainIfIdle for ${endedSessionId}`);
813
+ cleanupChainIfIdle(endedSessionId);
814
+ }
815
+ }
816
+ else {
817
+ log.warn(`[CHAIN-DRAIN] chat:send finally: stream is NOT current active stream (replaced?): endedSessionId=${endedSessionId}`);
420
818
  }
421
819
  }
422
820
  });
@@ -430,17 +828,16 @@ export async function initializeWebSocket(httpServer) {
430
828
  stream.pendingPermissions.get(data.requestId).resolve({ approved: data.approved, response: data.response });
431
829
  stream.pendingPermissions.delete(data.requestId);
432
830
  // 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
- }
831
+ // tool/interactive cards can show the correct approve/deny state.
832
+ // Also buffer via createStreamEmit so reconnecting clients see the
833
+ // resolved state instead of a stale 'waiting' permission card.
834
+ const emit = createStreamEmit(stream);
835
+ emit('permission:resolved', {
836
+ requestId: data.requestId,
837
+ approved: data.approved,
838
+ interactionType: data.interactionType,
839
+ response: data.response,
840
+ });
444
841
  }
445
842
  else {
446
843
  // Permission already resolved by another viewer — notify sender
@@ -462,13 +859,35 @@ export async function initializeWebSocket(httpServer) {
462
859
  }
463
860
  stream.abortController.abort('user-abort');
464
861
  }
862
+ // Also clear any pending prompt chain — user expects everything to stop.
863
+ // Always bump generation to invalidate any in-flight drain timers,
864
+ // even if chainState is already empty (stale timer edge case).
865
+ const gen = (chainDrainGeneration.get(sessionId) || 0) + 1;
866
+ chainDrainGeneration.set(sessionId, gen);
867
+ const chainItems = chainState.get(sessionId);
868
+ if (chainItems && chainItems.length > 0) {
869
+ chainState.set(sessionId, []);
870
+ broadcastChainUpdate(sessionId);
871
+ // Do NOT call cleanupChainIfIdle here — the active stream still exists
872
+ // and scheduleChainDrain's finally block will handle cleanup after it completes.
873
+ clearPersistedFailures(sessionId)
874
+ .then(() => {
875
+ // Guard: only broadcast if no new chain was started since this abort
876
+ if (chainDrainGeneration.get(sessionId) === gen) {
877
+ broadcastChainUpdate(sessionId);
878
+ }
879
+ })
880
+ .catch((err) => {
881
+ log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
882
+ });
883
+ }
465
884
  });
466
885
  // Handle permission:mode-change — update SDK permission mode and broadcast to viewers
467
886
  socket.on('permission:mode-change', async (data) => {
468
- const sessionId = socketToSession.get(socket.id);
887
+ const sessionId = socketToSession.get(socket.id) || socketSessionRoom.get(socket.id);
469
888
  if (!sessionId)
470
889
  return;
471
- const { mode, syncPolicy = 'streaming' } = data;
890
+ const { mode, projectSlug } = data;
472
891
  const stream = activeStreams.get(sessionId);
473
892
  // 1) Update SDK permission mode — only when stream is actively running
474
893
  if (stream?.chatService && stream.status === 'running') {
@@ -478,11 +897,32 @@ export async function initializeWebSocket(httpServer) {
478
897
  }
479
898
  catch (err) {
480
899
  log.error('Failed to change permission mode:', err);
900
+ return; // Don't persist or broadcast a mode that failed to apply
481
901
  }
482
902
  }
483
- // 2) Broadcast to other viewers based on sync policy
484
- if (syncPolicy === 'never')
485
- return;
903
+ // Update pending chain items so the next drain uses the new mode.
904
+ // Outside the running-stream block because mode can change between turns
905
+ // (e.g., during the 1s chain drain delay when no stream is active).
906
+ const chainItems = chainState.get(sessionId);
907
+ if (chainItems) {
908
+ for (const item of chainItems) {
909
+ if (item.status === 'pending' || item.status === 'sending') {
910
+ item.permissionMode = mode;
911
+ }
912
+ }
913
+ }
914
+ // 2) Always persist per-session permission mode (read only when policy is 'always')
915
+ // Use projectSlug from client as fallback when sessionProjectMap entry is gone (stream ended)
916
+ await persistSessionPermissionMode(sessionId, mode, projectSlug);
917
+ // 3) Broadcast to other viewers based on sync policy
918
+ let syncPolicy = 'streaming';
919
+ try {
920
+ const prefs = await preferencesService.readPreferences();
921
+ syncPolicy = normalizeSyncPolicy(prefs.permissionSyncPolicy);
922
+ }
923
+ catch (err) {
924
+ log.error('Failed to read preferences for sync policy:', err);
925
+ }
486
926
  if (syncPolicy === 'streaming' && stream?.status !== 'running')
487
927
  return;
488
928
  // 'always' or ('streaming' + running) → broadcast via Socket.io room
@@ -490,7 +930,7 @@ export async function initializeWebSocket(httpServer) {
490
930
  });
491
931
  // Handle session:join event — attach socket to active running stream (broadcast)
492
932
  // Also joins a persistent Socket.io room so future streams auto-include this socket.
493
- socket.on('session:join', (sessionId) => {
933
+ socket.on('session:join', (sessionId, projectSlug) => {
494
934
  // Detach this socket from any previously-attached stream to prevent
495
935
  // events from the old stream leaking to a different session's listeners
496
936
  const prevSessionId = socketToSession.get(socket.id);
@@ -502,22 +942,124 @@ export async function initializeWebSocket(httpServer) {
502
942
  socketToSession.delete(socket.id);
503
943
  socket.leave(`session:${prevSessionId}`);
504
944
  }
945
+ // Also leave previous session room even when no active stream existed
946
+ // (socketToSession is only set when a stream is running)
947
+ const prevRoomSessionId = socketSessionRoom.get(socket.id);
948
+ if (prevRoomSessionId && prevRoomSessionId !== sessionId && prevRoomSessionId !== prevSessionId) {
949
+ socket.leave(`session:${prevRoomSessionId}`);
950
+ }
505
951
  // Join persistent session room (survives beyond ActiveStream lifecycle)
506
952
  socket.join(`session:${sessionId}`);
953
+ // Leave previous project room if switching projects (or if new join has no projectSlug)
954
+ const prevProjectRoom = socketProjectRoom.get(socket.id);
955
+ if (prevProjectRoom && prevProjectRoom !== projectSlug) {
956
+ socket.leave(`project:${prevProjectRoom}`);
957
+ socketProjectRoom.delete(socket.id);
958
+ }
959
+ // Join project room so scoped events (e.g., session:stream-change) are received
960
+ if (projectSlug) {
961
+ socket.join(`project:${projectSlug}`);
962
+ socketProjectRoom.set(socket.id, projectSlug);
963
+ }
964
+ // Story 24.3: Track session room membership for session:leave room management
965
+ socketSessionRoom.set(socket.id, sessionId);
507
966
  const stream = activeStreams.get(sessionId);
967
+ // Story 24.1: Send current chain state on join (in-memory + persisted failures)
968
+ // Only attempt disk read for valid UUID sessionIds to avoid unbounded lock map growth
969
+ if (UUID_RE.test(sessionId)) {
970
+ withChainFailureLock(sessionId, async () => {
971
+ return projectService.readChainFailures(sessionId);
972
+ }).then(failures => {
973
+ // Re-read in-memory state now (may have changed during async disk read)
974
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
975
+ const allItems = [...freshItems, ...failures];
976
+ socket.emit('chain:update', { sessionId, items: allItems });
977
+ }).catch((err) => {
978
+ log.error(`Failed to read chain failures on join (session ${sessionId}):`, err);
979
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
980
+ socket.emit('chain:update', { sessionId, items: freshItems });
981
+ });
982
+ }
983
+ else {
984
+ // Non-UUID session: only send in-memory chain state (no disk persistence)
985
+ const freshItems = (chainState.get(sessionId) || []).map(toPublicChainItem);
986
+ socket.emit('chain:update', { sessionId, items: freshItems });
987
+ }
508
988
  if (!stream || stream.status !== 'running') {
509
- socket.emit('stream:status', { active: false, sessionId });
989
+ // Emit inactive status + completed buffer replay.
990
+ // Wrapped in a helper that re-checks activeStreams because the async
991
+ // preference-read path can yield, and a new stream may start in between.
992
+ const emitInactiveWithReplay = (permissionMode) => {
993
+ // Stale callback guard: if the socket has left this session (moved to
994
+ // another session or disconnected), don't emit anything for the old session.
995
+ if (socketSessionRoom.get(socket.id) !== sessionId || !socket.connected) {
996
+ return;
997
+ }
998
+ // Re-check: if a running stream appeared during async wait, emit active
999
+ // state instead of stale inactive. Without this, the client would miss
1000
+ // the initial stream:status/buffer-replay for the new stream.
1001
+ const freshStream = activeStreams.get(sessionId);
1002
+ if (freshStream && freshStream.status === 'running') {
1003
+ socketToSession.set(socket.id, sessionId);
1004
+ const bufSnapshot = [...freshStream.buffer];
1005
+ const freshMode = freshStream.chatService?.getPermissionMode();
1006
+ socket.emit('stream:status', { active: true, sessionId, permissionMode: freshMode });
1007
+ const completedBuf = getCompletedBuffer(sessionId);
1008
+ if (completedBuf) {
1009
+ socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
1010
+ }
1011
+ socket.emit('stream:buffer-replay', { sessionId, events: bufSnapshot });
1012
+ freshStream.sockets.add(socket);
1013
+ return;
1014
+ }
1015
+ socket.emit('stream:status', { active: false, sessionId, permissionMode });
1016
+ // Replay recently completed stream buffer so the client has the finished turn
1017
+ const completed = getCompletedBuffer(sessionId);
1018
+ if (completed) {
1019
+ socket.emit('stream:buffer-replay', { sessionId, events: completed });
1020
+ }
1021
+ };
1022
+ // For 'always' sync policy, restore per-session permission mode from disk
1023
+ const resolvedSlug = projectSlug || sessionProjectMap.get(sessionId);
1024
+ if (resolvedSlug && UUID_RE.test(sessionId)) {
1025
+ preferencesService.readPreferences().then(async (prefs) => {
1026
+ if (normalizeSyncPolicy(prefs.permissionSyncPolicy) === 'always') {
1027
+ const projectPath = await projectService.resolveProjectPath(resolvedSlug);
1028
+ if (projectPath) {
1029
+ const perms = await projectService.readSessionPermissions(projectPath);
1030
+ const savedMode = perms[sessionId];
1031
+ emitInactiveWithReplay(savedMode);
1032
+ return;
1033
+ }
1034
+ }
1035
+ emitInactiveWithReplay();
1036
+ }).catch(() => {
1037
+ emitInactiveWithReplay();
1038
+ });
1039
+ }
1040
+ else {
1041
+ emitInactiveWithReplay();
1042
+ }
510
1043
  return;
511
1044
  }
512
- // Add socket to broadcast set (multiple browsers can watch simultaneously)
513
- stream.sockets.add(socket);
514
1045
  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);
1046
+ // Snapshot BEFORE adding socket to broadcast set to prevent race.
1047
+ const bufferSnapshot = [...stream.buffer];
1048
+ const permissionMode = stream.chatService?.getPermissionMode();
1049
+ // Emit order matters for the client:
1050
+ // 1. stream:status { active: true } → client calls restoreStreaming + trimMessagesAfterLastUser
1051
+ // 2. completed buffer replay → client calls addMessages (trim already ran, won't remove these)
1052
+ // 3. active buffer replay → client sets streaming segments for the current turn
1053
+ socket.emit('stream:status', { active: true, sessionId, permissionMode });
1054
+ // Replay recently completed stream buffer (e.g., previous chain turn) AFTER
1055
+ // stream:status so the client has already trimmed stale messages.
1056
+ const completedBuf = getCompletedBuffer(sessionId);
1057
+ if (completedBuf) {
1058
+ socket.emit('stream:buffer-replay', { sessionId, events: completedBuf });
520
1059
  }
1060
+ socket.emit('stream:buffer-replay', { sessionId, events: bufferSnapshot });
1061
+ // NOW add to broadcast set — live events flow from here
1062
+ stream.sockets.add(socket);
521
1063
  });
522
1064
  // Handle session:leave event — detach socket from current stream and session room
523
1065
  // (client navigating away from a session while streaming continues in background)
@@ -530,12 +1072,133 @@ export async function initializeWebSocket(httpServer) {
530
1072
  }
531
1073
  socketToSession.delete(socket.id);
532
1074
  }
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;
1075
+ // Use prevSessionId / socketSessionRoom as fallback — client may send empty
1076
+ // string when the sessionId is not available at unmount time (e.g., ChatPage cleanup)
1077
+ const roomSessionId = sessionId || prevSessionId || socketSessionRoom.get(socket.id);
536
1078
  if (roomSessionId) {
537
1079
  socket.leave(`session:${roomSessionId}`);
538
1080
  }
1081
+ // Story 24.3: Clean up session room tracking on leave
1082
+ // Only clear project room if the leaving session matches current tracking.
1083
+ // Prevents race where a new session:join overwrites tracking before old leave arrives.
1084
+ const trackedSession = socketSessionRoom.get(socket.id);
1085
+ if (!trackedSession || trackedSession === roomSessionId) {
1086
+ socketSessionRoom.delete(socket.id);
1087
+ const prevProjectSlug = socketProjectRoom.get(socket.id);
1088
+ if (prevProjectSlug) {
1089
+ socket.leave(`project:${prevProjectSlug}`);
1090
+ }
1091
+ socketProjectRoom.delete(socket.id);
1092
+ }
1093
+ });
1094
+ // Story 24.1: Prompt chain event handlers
1095
+ socket.on('chain:add', (data) => {
1096
+ if (!data || typeof data !== 'object')
1097
+ return;
1098
+ const { sessionId, content, workingDirectory, permissionMode, model } = data;
1099
+ const lang = socket.data.language || 'en';
1100
+ const t = i18next.getFixedT(lang);
1101
+ // Input validation (UUID required for disk persistence compatibility)
1102
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1103
+ return;
1104
+ if (!content || typeof content !== 'string' || !content.trim())
1105
+ return;
1106
+ if (!workingDirectory || typeof workingDirectory !== 'string')
1107
+ return;
1108
+ if (content.length > 100_000) {
1109
+ socket.emit('error', { code: ERROR_CODES.CHAT_ERROR, message: t('ws.error.chainContentTooLong') });
1110
+ return;
1111
+ }
1112
+ // Validate socket is a member of the session room
1113
+ if (!socket.rooms.has(`session:${sessionId}`))
1114
+ return;
1115
+ const items = chainState.get(sessionId) || [];
1116
+ if (items.length >= 10) {
1117
+ socket.emit('error', {
1118
+ code: ERROR_CODES.CHAIN_MAX_EXCEEDED,
1119
+ message: t('ws.error.chainMaxExceeded'),
1120
+ });
1121
+ return;
1122
+ }
1123
+ const item = {
1124
+ id: generateChainItemId(),
1125
+ content,
1126
+ status: 'pending',
1127
+ createdAt: Date.now(),
1128
+ workingDirectory,
1129
+ permissionMode,
1130
+ model,
1131
+ };
1132
+ items.push(item);
1133
+ chainState.set(sessionId, items);
1134
+ broadcastChainUpdate(sessionId);
1135
+ // If no active stream, trigger drain so items don't stay pending indefinitely
1136
+ if (!activeStreams.has(sessionId)) {
1137
+ scheduleChainDrain(sessionId, lang);
1138
+ }
1139
+ });
1140
+ socket.on('chain:remove', (data) => {
1141
+ if (!data || typeof data !== 'object')
1142
+ return;
1143
+ const { sessionId, id } = data;
1144
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1145
+ return;
1146
+ if (!id || typeof id !== 'string')
1147
+ return;
1148
+ if (!socket.rooms.has(`session:${sessionId}`))
1149
+ return;
1150
+ const items = chainState.get(sessionId);
1151
+ if (items) {
1152
+ // If the removed item is currently sending, abort its active stream
1153
+ const removedItem = items.find(item => item.id === id);
1154
+ if (removedItem?.status === 'sending') {
1155
+ const stream = activeStreams.get(sessionId);
1156
+ if (stream && stream.status === 'running') {
1157
+ stream.abortController.abort('chain-item-removed');
1158
+ }
1159
+ }
1160
+ const filtered = items.filter(item => item.id !== id);
1161
+ chainState.set(sessionId, filtered);
1162
+ broadcastChainUpdate(sessionId);
1163
+ if (filtered.length === 0) {
1164
+ cleanupChainIfIdle(sessionId);
1165
+ }
1166
+ }
1167
+ // Also remove from persisted failures (dismiss)
1168
+ removePersistedFailure(sessionId, id).catch((err) => {
1169
+ log.error(`Failed to remove persisted failure ${id} for session ${sessionId}:`, err);
1170
+ });
1171
+ });
1172
+ socket.on('chain:clear', (data) => {
1173
+ if (!data || typeof data !== 'object')
1174
+ return;
1175
+ const { sessionId } = data;
1176
+ if (!sessionId || typeof sessionId !== 'string' || !UUID_RE.test(sessionId))
1177
+ return;
1178
+ // Validate socket is a member of the session room
1179
+ if (!socket.rooms.has(`session:${sessionId}`))
1180
+ return;
1181
+ // If any item is currently sending, abort its active stream
1182
+ const items = chainState.get(sessionId);
1183
+ if (items?.some(item => item.status === 'sending')) {
1184
+ const stream = activeStreams.get(sessionId);
1185
+ if (stream && stream.status === 'running') {
1186
+ stream.abortController.abort('chain-cleared');
1187
+ }
1188
+ }
1189
+ chainState.set(sessionId, []);
1190
+ // Bump generation instead of deleting — prevents stale timers from matching
1191
+ // a reset counter value after clear + re-add sequence
1192
+ chainDrainGeneration.set(sessionId, (chainDrainGeneration.get(sessionId) || 0) + 1);
1193
+ broadcastChainUpdate(sessionId);
1194
+ cleanupChainIfIdle(sessionId);
1195
+ // Clear persisted failures from disk, then broadcast final consistent state
1196
+ // (prevents stale failures from reappearing via concurrent broadcastChainUpdateWithFailures)
1197
+ clearPersistedFailures(sessionId)
1198
+ .then(() => broadcastChainUpdate(sessionId))
1199
+ .catch((err) => {
1200
+ log.error(`Failed to clear persisted failures for session ${sessionId}:`, err);
1201
+ });
539
1202
  });
540
1203
  // Story 20.1: Dashboard subscribe/unsubscribe
541
1204
  socket.on('dashboard:subscribe', () => {
@@ -610,7 +1273,7 @@ export async function initializeWebSocket(httpServer) {
610
1273
  const lang = socket.data.language || 'en';
611
1274
  const t = i18next.getFixedT(lang);
612
1275
  // Story 17.5: Security guard
613
- const access = await checkTerminalAccess(socket, lang);
1276
+ const access = checkTerminalAccess(socket, lang);
614
1277
  if (!access.allowed) {
615
1278
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:create`);
616
1279
  socket.emit('terminal:error', access.error);
@@ -673,7 +1336,7 @@ export async function initializeWebSocket(httpServer) {
673
1336
  });
674
1337
  socket.on('terminal:input', async (data) => {
675
1338
  // Story 17.5: Security guard
676
- const inputAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1339
+ const inputAccess = checkTerminalAccess(socket, socket.data.language || 'en');
677
1340
  if (!inputAccess.allowed) {
678
1341
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:input`);
679
1342
  socket.emit('terminal:error', { ...inputAccess.error, terminalId: data.terminalId });
@@ -689,7 +1352,7 @@ export async function initializeWebSocket(httpServer) {
689
1352
  });
690
1353
  socket.on('terminal:resize', async (data) => {
691
1354
  // Story 17.5: Security guard
692
- const resizeAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1355
+ const resizeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
693
1356
  if (!resizeAccess.allowed) {
694
1357
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:resize`);
695
1358
  socket.emit('terminal:error', { ...resizeAccess.error, terminalId: data.terminalId });
@@ -705,7 +1368,7 @@ export async function initializeWebSocket(httpServer) {
705
1368
  });
706
1369
  socket.on('terminal:list', async (data) => {
707
1370
  const lang = socket.data.language || 'en';
708
- const access = await checkTerminalAccess(socket, lang);
1371
+ const access = checkTerminalAccess(socket, lang);
709
1372
  if (!access.allowed) {
710
1373
  socket.emit('terminal:list', { projectSlug: data.projectSlug, terminals: [] });
711
1374
  return;
@@ -738,7 +1401,7 @@ export async function initializeWebSocket(httpServer) {
738
1401
  });
739
1402
  socket.on('terminal:close', async (data) => {
740
1403
  // Story 17.5: Security guard
741
- const closeAccess = await checkTerminalAccess(socket, socket.data.language || 'en');
1404
+ const closeAccess = checkTerminalAccess(socket, socket.data.language || 'en');
742
1405
  if (!closeAccess.allowed) {
743
1406
  log.warn(`Terminal access denied for ${extractClientIP(socket)} on terminal:close`);
744
1407
  socket.emit('terminal:error', { ...closeAccess.error, terminalId: data.terminalId });
@@ -768,6 +1431,8 @@ export async function initializeWebSocket(httpServer) {
768
1431
  }
769
1432
  socketToSession.delete(socket.id);
770
1433
  }
1434
+ socketSessionRoom.delete(socket.id);
1435
+ socketProjectRoom.delete(socket.id);
771
1436
  // PTY sessions are NOT cleaned up on socket disconnect.
772
1437
  // They persist until explicitly closed by the user, the PTY process exits,
773
1438
  // or the server shuts down. This prevents losing long-running terminal
@@ -833,7 +1498,8 @@ function isSessionNotFoundError(error) {
833
1498
  return (message.includes('session not found') ||
834
1499
  message.includes('session does not exist') ||
835
1500
  message.includes('invalid session') ||
836
- message.includes('no such session'));
1501
+ message.includes('no such session') ||
1502
+ message.includes('no conversation found'));
837
1503
  }
838
1504
  /**
839
1505
  * Handle chat:send event from client
@@ -852,7 +1518,7 @@ async function handleChatSend(stream, data, abortController, lang) {
852
1518
  code: ERROR_CODES.VALIDATION_ERROR,
853
1519
  message: validation.error,
854
1520
  });
855
- return;
1521
+ return false;
856
1522
  }
857
1523
  }
858
1524
  // Validate workingDirectory exists
@@ -861,11 +1527,18 @@ async function handleChatSend(stream, data, abortController, lang) {
861
1527
  code: ERROR_CODES.INVALID_WORKING_DIR,
862
1528
  message: t('ws.error.projectPathNotFound'),
863
1529
  });
864
- return;
1530
+ return false;
865
1531
  }
866
1532
  // 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 || '' });
1533
+ // (SDK may not have written the JSONL file yet at reconnect time).
1534
+ // Include timestamp for correct ordering. For images, only send count
1535
+ // (not full base64 data) to avoid bloating the buffer.
1536
+ emit('user:message', {
1537
+ content,
1538
+ sessionId: sessionId || '',
1539
+ timestamp: new Date().toISOString(),
1540
+ ...(images && images.length > 0 ? { imageCount: images.length } : {}),
1541
+ });
869
1542
  const isResuming = resume && sessionId;
870
1543
  const sessionService = new SessionService();
871
1544
  let timeoutId = null;
@@ -888,11 +1561,11 @@ async function handleChatSend(stream, data, abortController, lang) {
888
1561
  // Create canUseTool callback for permission & AskUserQuestion handling
889
1562
  // Promise stays pending if socket disconnected — SDK naturally waits
890
1563
  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');
1564
+ // Auto-approve ExitPlanMode when the current permission mode is Bypass.
1565
+ // Use chatService.getPermissionMode() instead of the closure-captured variable
1566
+ // so that mid-stream permission mode changes (e.g. Plan Bypass) are reflected.
1567
+ if (toolName === 'ExitPlanMode' && chatService.getPermissionMode() === 'bypassPermissions') {
1568
+ log.debug('Auto-approving ExitPlanMode: current permissionMode is bypassPermissions');
896
1569
  return { behavior: 'allow', updatedInput: input };
897
1570
  }
898
1571
  const isAskUserQuestion = toolName === 'AskUserQuestion';
@@ -991,13 +1664,6 @@ async function handleChatSend(stream, data, abortController, lang) {
991
1664
  }
992
1665
  }
993
1666
  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
1667
  emit('error', {
1002
1668
  code: ERROR_CODES.CHAT_ERROR,
1003
1669
  message: sdkError.message,
@@ -1007,33 +1673,48 @@ async function handleChatSend(stream, data, abortController, lang) {
1007
1673
  notificationService.notifyError(stream.sessionId, sdkError.message);
1008
1674
  }
1009
1675
  };
1010
- await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
1011
- resetTimeout(`raw:${messageType}`);
1012
- });
1676
+ // Attempt to send — if resume fails with session-not-found, retry without resume
1677
+ try {
1678
+ await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
1679
+ resetTimeout(`raw:${messageType}`);
1680
+ });
1681
+ }
1682
+ catch (sendError) {
1683
+ // Resume failed because session doesn't exist (e.g., first send was aborted before SDK created it).
1684
+ // Retry once without resume so SDK creates a fresh session.
1685
+ if (isResuming && sendError instanceof Error && isSessionNotFoundError(sendError)) {
1686
+ log.info(`[CHAIN-DRAIN] resume failed (session not found), retrying without resume: sessionId=${sessionId}`);
1687
+ const retryOptions = { ...chatOptions, resume: undefined, sessionId };
1688
+ delete retryOptions.resume;
1689
+ resetTimeout('resume-retry');
1690
+ await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
1691
+ resetTimeout(`raw:${messageType}`);
1692
+ });
1693
+ }
1694
+ else {
1695
+ throw sendError;
1696
+ }
1697
+ }
1698
+ return true;
1013
1699
  }
1014
1700
  catch (error) {
1015
1701
  const sdkError = parseSDKError(error, lang);
1702
+ log.info(`[CHAIN-DRAIN] handleChatSend catch: sessionId=${stream.sessionId}, aborted=${abortController.signal.aborted}, reason=${abortController.signal.reason}, error=${sdkError.message.slice(0, 120)}`);
1016
1703
  if (sdkError instanceof AbortedError || abortController.signal.aborted) {
1017
1704
  if (abortController.signal.reason === 'user-abort' || abortController.signal.reason === 'another-client') {
1018
- return;
1705
+ return false;
1019
1706
  }
1020
1707
  emit('error', {
1021
1708
  code: ERROR_CODES.TIMEOUT_ERROR,
1022
1709
  message: t('ws.error.timeout'),
1023
1710
  });
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;
1711
+ return false;
1032
1712
  }
1033
1713
  emit('error', {
1034
1714
  code: ERROR_CODES.CHAT_ERROR,
1035
1715
  message: sdkError.message,
1036
1716
  });
1717
+ return false;
1037
1718
  }
1038
1719
  finally {
1039
1720
  if (timeoutId) {