rl-rockcli 0.0.8 → 0.0.10

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 (162) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. package/commands/log/core/constants.js +237 -0
  90. package/commands/log/core/display.js +370 -0
  91. package/commands/log/core/search.js +330 -0
  92. package/commands/log/core/tail.js +216 -0
  93. package/commands/log/core/utils.js +424 -0
  94. package/commands/log.js +298 -0
  95. package/commands/sandbox/core/log-bridge.js +119 -0
  96. package/commands/sandbox/core/replay/analyzer.js +311 -0
  97. package/commands/sandbox/core/replay/batch-orchestrator.js +536 -0
  98. package/commands/sandbox/core/replay/batch-task.js +369 -0
  99. package/commands/sandbox/core/replay/concurrent-display.js +70 -0
  100. package/commands/sandbox/core/replay/concurrent-orchestrator.js +170 -0
  101. package/commands/sandbox/core/replay/data-source.js +86 -0
  102. package/commands/sandbox/core/replay/display.js +231 -0
  103. package/commands/sandbox/core/replay/executor.js +634 -0
  104. package/commands/sandbox/core/replay/history-fetcher.js +124 -0
  105. package/commands/sandbox/core/replay/index.js +338 -0
  106. package/commands/sandbox/core/replay/loghouse-data-source.js +177 -0
  107. package/commands/sandbox/core/replay/pid-mapping.js +26 -0
  108. package/commands/sandbox/core/replay/request.js +109 -0
  109. package/commands/sandbox/core/replay/worker.js +166 -0
  110. package/commands/sandbox/core/session.js +346 -0
  111. package/commands/sandbox/log-bridge.js +2 -0
  112. package/commands/sandbox/ray.js +2 -0
  113. package/commands/sandbox/replay/analyzer.js +311 -0
  114. package/commands/sandbox/replay/batch-orchestrator.js +536 -0
  115. package/commands/sandbox/replay/batch-task.js +369 -0
  116. package/commands/sandbox/replay/concurrent-display.js +70 -0
  117. package/commands/sandbox/replay/concurrent-orchestrator.js +170 -0
  118. package/commands/sandbox/replay/display.js +231 -0
  119. package/commands/sandbox/replay/executor.js +634 -0
  120. package/commands/sandbox/replay/history-fetcher.js +118 -0
  121. package/commands/sandbox/replay/index.js +338 -0
  122. package/commands/sandbox/replay/pid-mapping.js +26 -0
  123. package/commands/sandbox/replay/request.js +109 -0
  124. package/commands/sandbox/replay/worker.js +166 -0
  125. package/commands/sandbox/replay.js +2 -0
  126. package/commands/sandbox/session.js +2 -0
  127. package/commands/sandbox-original.js +1393 -0
  128. package/commands/sandbox.js +499 -0
  129. package/help/help.json +1071 -0
  130. package/help/middleware.js +71 -0
  131. package/help/renderer.js +800 -0
  132. package/index.js +5 -15
  133. package/lib/plugin-context.js +40 -0
  134. package/package.json +2 -2
  135. package/sdks/sandbox/core/client.js +845 -0
  136. package/sdks/sandbox/core/config.js +70 -0
  137. package/sdks/sandbox/core/types.js +74 -0
  138. package/sdks/sandbox/httpLogger.js +251 -0
  139. package/sdks/sandbox/index.js +9 -0
  140. package/utils/asciiArt.js +138 -0
  141. package/utils/bun-compat.js +59 -0
  142. package/utils/ciPipelines.js +138 -0
  143. package/utils/cli.js +17 -0
  144. package/utils/command-router.js +79 -0
  145. package/utils/configManager.js +503 -0
  146. package/utils/dependency-resolver.js +135 -0
  147. package/utils/eagleeye_traceid.js +151 -0
  148. package/utils/envDetector.js +78 -0
  149. package/utils/execution_logger.js +415 -0
  150. package/utils/featureManager.js +68 -0
  151. package/utils/firstTimeTip.js +44 -0
  152. package/utils/hook-manager.js +125 -0
  153. package/utils/http-logger.js +264 -0
  154. package/utils/i18n.js +139 -0
  155. package/utils/image-progress.js +159 -0
  156. package/utils/logger.js +154 -0
  157. package/utils/plugin-loader.js +124 -0
  158. package/utils/plugin-manager.js +348 -0
  159. package/utils/ray_cli_wrapper.js +746 -0
  160. package/utils/sandbox-client.js +419 -0
  161. package/utils/terminal.js +32 -0
  162. package/utils/tips.js +106 -0
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const logger = require('../../utils/logger');
8
+
9
+ function safeJsonParse(content, fallback) {
10
+ try {
11
+ return JSON.parse(content);
12
+ } catch {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ function writeFileAtomicSync(filePath, data) {
18
+ const dir = path.dirname(filePath);
19
+ fs.mkdirSync(dir, { recursive: true });
20
+
21
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
22
+ fs.writeFileSync(tmpPath, data);
23
+ fs.renameSync(tmpPath, filePath);
24
+ }
25
+
26
+ function writeJsonAtomicSync(filePath, obj) {
27
+ writeFileAtomicSync(filePath, JSON.stringify(obj, null, 2));
28
+ }
29
+
30
+ class HistoryManager {
31
+ constructor(sandboxId, options = {}) {
32
+ this.sandboxId = sandboxId;
33
+ this.baseDir = options.baseDir || path.join(os.homedir(), '.rock');
34
+ this.sessionId = null;
35
+ this.maxHistoryPerSession = options.maxHistoryPerSession || 200;
36
+ this._onNotice = typeof options.onNotice === 'function' ? options.onNotice : null;
37
+ this._noticeShown = new Set();
38
+ }
39
+
40
+ _noticeOnce(key, message) {
41
+ if (!this._onNotice) return;
42
+ if (this._noticeShown.has(key)) return;
43
+ this._noticeShown.add(key);
44
+ try {
45
+ this._onNotice(message);
46
+ } catch {
47
+ // ignore
48
+ }
49
+ }
50
+
51
+ async _withIndexLock(fn) {
52
+ const lockPath = path.join(this._getSandboxDir(), 'index.json.lock');
53
+ const staleMs = 10_000;
54
+ const maxWaitMs = 2_000;
55
+ const start = Date.now();
56
+
57
+ while (true) {
58
+ try {
59
+ fs.mkdirSync(this._getSandboxDir(), { recursive: true });
60
+ const fd = fs.openSync(lockPath, 'wx');
61
+ try {
62
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
63
+ } finally {
64
+ fs.closeSync(fd);
65
+ }
66
+ break;
67
+ } catch (e) {
68
+ if (!e || e.code !== 'EEXIST') throw e;
69
+
70
+ // Stale lock recovery
71
+ try {
72
+ const stat = fs.statSync(lockPath);
73
+ if (Date.now() - stat.mtimeMs > staleMs) {
74
+ fs.rmSync(lockPath, { force: true });
75
+ continue;
76
+ }
77
+ } catch {
78
+ // ignore
79
+ }
80
+
81
+ if (Date.now() - start > maxWaitMs) {
82
+ throw new Error('History index is locked; please retry.');
83
+ }
84
+ // eslint-disable-next-line no-await-in-loop
85
+ await new Promise(r => setTimeout(r, 25));
86
+ }
87
+ }
88
+
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ try {
93
+ fs.rmSync(lockPath, { force: true });
94
+ } catch {
95
+ // ignore
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Switch to an existing history session (does not create a new one).
102
+ * Updates index.json current_session and the current symlink when possible.
103
+ * @param {string} sessionId
104
+ * @returns {Promise<string>} sessionId
105
+ */
106
+ async resume(sessionId) {
107
+ if (!sessionId) {
108
+ throw new Error('sessionId required');
109
+ }
110
+
111
+ const sessionDir = path.join(this._getSandboxDir(), sessionId);
112
+ if (!fs.existsSync(sessionDir)) {
113
+ throw new Error(`Session not found: ${sessionId}`);
114
+ }
115
+
116
+ this.sessionId = sessionId;
117
+
118
+ // Ensure index exists and points to this session as current
119
+ const sandboxDir = this._getSandboxDir();
120
+ const indexFile = path.join(sandboxDir, 'index.json');
121
+
122
+ await this._withIndexLock(async () => {
123
+ let index = { sandbox_id: this.sandboxId, sessions: [] };
124
+ if (fs.existsSync(indexFile)) {
125
+ index = safeJsonParse(fs.readFileSync(indexFile, 'utf8'), index);
126
+ }
127
+
128
+ index.sandbox_id = index.sandbox_id || this.sandboxId;
129
+ index.sessions = Array.isArray(index.sessions) ? index.sessions : [];
130
+ index.current_session = sessionId;
131
+
132
+ // Add to index if missing
133
+ const existsInIndex = index.sessions.some(s => s.id === sessionId || s.session_id === sessionId);
134
+ if (!existsInIndex) {
135
+ let createdAt = new Date().toISOString();
136
+ const metaFile = path.join(sessionDir, 'meta.json');
137
+ if (fs.existsSync(metaFile)) {
138
+ const meta = safeJsonParse(fs.readFileSync(metaFile, 'utf8'), null);
139
+ createdAt = (meta && meta.created_at) || createdAt;
140
+ }
141
+ index.sessions.push({
142
+ id: sessionId,
143
+ created_at: createdAt,
144
+ ended_at: null,
145
+ command_count: 0,
146
+ });
147
+ }
148
+
149
+ writeJsonAtomicSync(indexFile, index);
150
+ });
151
+
152
+ await this._updateCurrentSymlink();
153
+ return this.sessionId;
154
+ }
155
+
156
+ /**
157
+ * Find a history session id by the underlying sandbox shell session name.
158
+ * @param {string} shellSessionName
159
+ * @returns {Promise<string|null>}
160
+ */
161
+ async findSessionByShellSessionName(shellSessionName) {
162
+ if (!shellSessionName) return null;
163
+ const sessions = await this.listSessions();
164
+ for (const session of sessions) {
165
+ const sessionId = session.session_id || session.id;
166
+ if (!sessionId) continue;
167
+ if (session.shell_session_name === shellSessionName) {
168
+ return sessionId;
169
+ }
170
+
171
+ // Back-compat: read meta.json if listSessions did not populate it
172
+ const metaFile = path.join(this._getSandboxDir(), sessionId, 'meta.json');
173
+ if (fs.existsSync(metaFile)) {
174
+ try {
175
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
176
+ if (meta.shell_session_name === shellSessionName) {
177
+ return sessionId;
178
+ }
179
+ } catch {
180
+ // ignore
181
+ }
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Resume an existing history session by sandbox shell session name.
189
+ * @param {string} shellSessionName
190
+ * @returns {Promise<string|null>} history session id
191
+ */
192
+ async resumeByShellSessionName(shellSessionName) {
193
+ logger.debug(`resumeByShellSessionName: searching for shellSessionName=${shellSessionName}`);
194
+ const sessionId = await this.findSessionByShellSessionName(shellSessionName);
195
+ if (!sessionId) {
196
+ logger.debug(`resumeByShellSessionName: no history session found for shellSessionName=${shellSessionName}`);
197
+ return null;
198
+ }
199
+ logger.debug(`resumeByShellSessionName: found history sessionId=${sessionId}`);
200
+ await this.resume(sessionId);
201
+ return sessionId;
202
+ }
203
+
204
+ async init(options = {}) {
205
+ this.sessionId = this._generateSessionId();
206
+ const sessionDir = this._getSessionDir();
207
+
208
+ // Create directory structure
209
+ fs.mkdirSync(sessionDir, { recursive: true });
210
+
211
+ // Create meta.json
212
+ const meta = {
213
+ session_id: this.sessionId,
214
+ sandbox_id: this.sandboxId,
215
+ shell_session_name: options.shellSessionName || null,
216
+ created_at: new Date().toISOString(),
217
+ ended_at: null,
218
+ command_count: 0,
219
+ };
220
+ writeJsonAtomicSync(path.join(sessionDir, 'meta.json'), meta);
221
+
222
+ // Create empty history file
223
+ fs.writeFileSync(path.join(sessionDir, 'shell_history'), '');
224
+
225
+ // Update index
226
+ await this._updateIndex();
227
+
228
+ // Update current symlink
229
+ await this._updateCurrentSymlink();
230
+
231
+ return this.sessionId;
232
+ }
233
+
234
+ getLastShellSessionName() {
235
+ const indexFile = path.join(this._getSandboxDir(), 'index.json');
236
+ if (!fs.existsSync(indexFile)) {
237
+ return null;
238
+ }
239
+
240
+ try {
241
+ const index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
242
+ if (!index.current_session) {
243
+ return null;
244
+ }
245
+
246
+ const metaFile = path.join(this._getSandboxDir(), index.current_session, 'meta.json');
247
+ if (!fs.existsSync(metaFile)) {
248
+ return null;
249
+ }
250
+
251
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
252
+ return meta.shell_session_name || null;
253
+ } catch (error) {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ async updateShellSessionName(shellSessionName) {
259
+ const metaFile = path.join(this._getSessionDir(), 'meta.json');
260
+ if (fs.existsSync(metaFile)) {
261
+ const meta = safeJsonParse(fs.readFileSync(metaFile, 'utf8'), {});
262
+ meta.shell_session_name = shellSessionName;
263
+ writeJsonAtomicSync(metaFile, meta);
264
+ }
265
+ }
266
+
267
+ async addCommand(command) {
268
+ // Skip if command is undefined, null, or not a string
269
+ if (typeof command !== 'string' || command.trim() === '') {
270
+ return;
271
+ }
272
+ const historyFile = path.join(this._getSessionDir(), 'shell_history');
273
+ fs.appendFileSync(historyFile, command + '\n');
274
+
275
+ // Update command count in meta
276
+ const metaFile = path.join(this._getSessionDir(), 'meta.json');
277
+ const meta = safeJsonParse(fs.readFileSync(metaFile, 'utf8'), {});
278
+ meta.command_count++;
279
+ writeJsonAtomicSync(metaFile, meta);
280
+ }
281
+
282
+ /**
283
+ * Save output history to disk
284
+ * @param {Array} outputs - Array of output items from REPL state
285
+ */
286
+ async saveOutputs(outputs) {
287
+ const outputFile = path.join(this._getSessionDir(), 'outputs.json');
288
+ try {
289
+ // Filter out welcome messages and only keep relevant data
290
+ const filteredOutputs = outputs
291
+ .filter(item => !item.isWelcome)
292
+ .map(item => ({
293
+ command: item.command,
294
+ output: item.output,
295
+ exitCode: item.exitCode,
296
+ timestamp: item.timestamp,
297
+ prompt: item.prompt,
298
+ // Save metaInfo and tips for error display
299
+ metaInfo: item.metaInfo,
300
+ tips: item.tips,
301
+ }));
302
+
303
+ writeJsonAtomicSync(outputFile, filteredOutputs);
304
+ } catch (error) {
305
+ logger.debug(`Failed to save outputs: ${error && error.message ? error.message : String(error)}`);
306
+ this._noticeOnce('history-save-failed', 'Warning: failed to save attach history (outputs).');
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Load output history from disk
312
+ * @returns {Array} Array of output items
313
+ */
314
+ async loadOutputs() {
315
+ const outputFile = path.join(this._getSessionDir(), 'outputs.json');
316
+ if (!fs.existsSync(outputFile)) {
317
+ return [];
318
+ }
319
+
320
+ try {
321
+ const content = fs.readFileSync(outputFile, 'utf8');
322
+ const outputs = safeJsonParse(content, null);
323
+ if (!Array.isArray(outputs)) return [];
324
+
325
+ // Restore output format with IDs
326
+ return outputs.map((item, index) => ({
327
+ id: `output-${Date.now()}-${index}`,
328
+ command: item.command,
329
+ output: item.output,
330
+ exitCode: item.exitCode !== undefined ? item.exitCode : 0,
331
+ timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
332
+ truncated: false,
333
+ prompt: item.prompt || null,
334
+ isWelcome: false,
335
+ // Restore metaInfo and tips for error display
336
+ metaInfo: item.metaInfo || null,
337
+ tips: item.tips || null,
338
+ }));
339
+ } catch (error) {
340
+ logger.debug(`Failed to load outputs: ${error && error.message ? error.message : String(error)}`);
341
+ this._noticeOnce('history-load-failed', 'Warning: failed to load attach history (outputs).');
342
+ return [];
343
+ }
344
+ }
345
+
346
+ async getHistory() {
347
+ const historyFile = path.join(this._getSessionDir(), 'shell_history');
348
+ if (!fs.existsSync(historyFile)) {
349
+ return [];
350
+ }
351
+ const content = fs.readFileSync(historyFile, 'utf8');
352
+ return content.split('\n').filter(line => line.trim());
353
+ }
354
+
355
+ async listSessions() {
356
+ const indexFile = path.join(this._getSandboxDir(), 'index.json');
357
+ let index = null;
358
+ if (fs.existsSync(indexFile)) {
359
+ try {
360
+ index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
361
+ } catch (e) {
362
+ index = null;
363
+ }
364
+ }
365
+
366
+ // Fallback: scan directories when index is missing/corrupt
367
+ if (!index) {
368
+ const sandboxDir = this._getSandboxDir();
369
+ if (!fs.existsSync(sandboxDir)) {
370
+ return [];
371
+ }
372
+ const dirs = fs.readdirSync(sandboxDir, { withFileTypes: true })
373
+ .filter(d => d.isDirectory())
374
+ .map(d => d.name)
375
+ .filter(name => name !== 'current');
376
+ index = { sandbox_id: this.sandboxId, sessions: dirs.map(id => ({ id })) };
377
+ }
378
+
379
+ // Read actual data from each session's meta.json
380
+ const sessions = (index.sessions || []).map(session => {
381
+ const metaFile = path.join(this._getSandboxDir(), session.id, 'meta.json');
382
+ if (fs.existsSync(metaFile)) {
383
+ try {
384
+ try {
385
+ const stat = fs.statSync(metaFile);
386
+ session.updated_at = new Date(stat.mtimeMs).toISOString();
387
+ } catch {
388
+ // ignore
389
+ }
390
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
391
+ session.command_count = meta.command_count || 0;
392
+ session.work_dir = meta.work_dir || null;
393
+ session.created_at = meta.created_at || null;
394
+ session.ended_at = meta.ended_at || null;
395
+ session.shell_session_name = meta.shell_session_name || null;
396
+ } catch (e) {
397
+ // Ignore parse errors
398
+ }
399
+ }
400
+ return session;
401
+ });
402
+
403
+ // Sort by created_at descending (newest first)
404
+ return sessions.sort((a, b) => {
405
+ const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
406
+ const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
407
+ return timeB - timeA;
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Update the work_dir in current session's meta
413
+ * @param {string} workDir - Current working directory
414
+ */
415
+ async updateWorkDir(workDir) {
416
+ const metaFile = path.join(this._getSessionDir(), 'meta.json');
417
+ if (fs.existsSync(metaFile)) {
418
+ const meta = safeJsonParse(fs.readFileSync(metaFile, 'utf8'), {});
419
+ meta.work_dir = workDir;
420
+ writeJsonAtomicSync(metaFile, meta);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Get history from a specific session
426
+ * @param {string} sessionId - The session ID to get history from
427
+ * @returns {string[]} Array of commands
428
+ */
429
+ async getSessionHistory(sessionId) {
430
+ const historyFile = path.join(this._getSandboxDir(), sessionId, 'shell_history');
431
+ if (!fs.existsSync(historyFile)) {
432
+ return [];
433
+ }
434
+ const content = fs.readFileSync(historyFile, 'utf8');
435
+ return content.split('\n').filter(line => line.trim());
436
+ }
437
+
438
+ async close() {
439
+ const metaFile = path.join(this._getSessionDir(), 'meta.json');
440
+ if (fs.existsSync(metaFile)) {
441
+ const meta = safeJsonParse(fs.readFileSync(metaFile, 'utf8'), {});
442
+ meta.ended_at = new Date().toISOString();
443
+ writeJsonAtomicSync(metaFile, meta);
444
+ }
445
+ }
446
+
447
+ _generateSessionId() {
448
+ // Use timestamp prefix for sorting + short UUID suffix to avoid collision
449
+ const now = new Date();
450
+ const pad = (n) => String(n).padStart(2, '0');
451
+ const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_` +
452
+ `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
453
+ const shortId = crypto.randomUUID().split('-')[0]; // 8 chars
454
+ return `${timestamp}_${shortId}`;
455
+ }
456
+
457
+ _getSandboxDir() {
458
+ return path.join(this.baseDir, 'history', this.sandboxId);
459
+ }
460
+
461
+ _getSessionDir() {
462
+ return path.join(this._getSandboxDir(), this.sessionId);
463
+ }
464
+
465
+ async _updateIndex() {
466
+ const sandboxDir = this._getSandboxDir();
467
+ const indexFile = path.join(sandboxDir, 'index.json');
468
+
469
+ await this._withIndexLock(async () => {
470
+ let index = { sandbox_id: this.sandboxId, sessions: [] };
471
+ if (fs.existsSync(indexFile)) {
472
+ index = safeJsonParse(fs.readFileSync(indexFile, 'utf8'), index);
473
+ }
474
+
475
+ index.sandbox_id = index.sandbox_id || this.sandboxId;
476
+ index.sessions = Array.isArray(index.sessions) ? index.sessions : [];
477
+ index.current_session = this.sessionId;
478
+
479
+ // Avoid duplicates if init() is retried or called concurrently
480
+ const exists = index.sessions.some(s => s.id === this.sessionId);
481
+ if (!exists) {
482
+ index.sessions.push({
483
+ id: this.sessionId,
484
+ created_at: new Date().toISOString(),
485
+ ended_at: null,
486
+ command_count: 0,
487
+ });
488
+ }
489
+
490
+ writeJsonAtomicSync(indexFile, index);
491
+ });
492
+ }
493
+
494
+ async _updateCurrentSymlink() {
495
+ const currentLink = path.join(this._getSandboxDir(), 'current');
496
+ try {
497
+ if (fs.existsSync(currentLink)) {
498
+ fs.unlinkSync(currentLink);
499
+ }
500
+ fs.symlinkSync(this.sessionId, currentLink);
501
+ } catch (error) {
502
+ // Symlinks may not work on all systems, ignore
503
+ }
504
+ }
505
+ }
506
+
507
+ module.exports = HistoryManager;
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Ensure HistoryManager is pointing at the right history session for a given sandbox shell session.
5
+ *
6
+ * - If preferResume is true, try to resume an existing history session whose meta.shell_session_name
7
+ * matches shellSessionName.
8
+ * - Otherwise (or if resume fails), create a new history session and persist shellSessionName.
9
+ *
10
+ * @param {Object} params
11
+ * @param {Object} params.historyManager
12
+ * @param {string} params.shellSessionName
13
+ * @param {boolean} params.preferResume
14
+ * @returns {Promise<string|null>} history session id
15
+ */
16
+ async function ensureHistorySession({ historyManager, shellSessionName, preferResume }) {
17
+ if (!historyManager) {
18
+ throw new Error('ensureHistorySession requires historyManager');
19
+ }
20
+ if (!shellSessionName) {
21
+ throw new Error('ensureHistorySession requires shellSessionName');
22
+ }
23
+
24
+ const logger = require('../../utils/logger');
25
+ logger.debug(`ensureHistorySession: shellSessionName=${shellSessionName}, preferResume=${preferResume}`);
26
+
27
+ if (preferResume && typeof historyManager.resumeByShellSessionName === 'function') {
28
+ const resumed = await historyManager.resumeByShellSessionName(shellSessionName);
29
+ if (resumed) {
30
+ logger.debug(`History session resumed: ${resumed}`);
31
+ return resumed;
32
+ }
33
+ logger.debug('No history session found to resume, will create new one');
34
+ }
35
+
36
+ if (typeof historyManager.init !== 'function') {
37
+ throw new Error('HistoryManager.init not available');
38
+ }
39
+
40
+ const created = await historyManager.init({ shellSessionName });
41
+ logger.debug(`History session created: ${created}`);
42
+ return created || historyManager.sessionId || null;
43
+ }
44
+
45
+ module.exports = {
46
+ ensureHistorySession,
47
+ };
48
+