centaurus-cli 3.1.2 → 3.1.4

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 (142) hide show
  1. package/dist/cli-adapter.js +689 -155
  2. package/dist/cli-adapter.js.map +1 -1
  3. package/dist/config/defaultConfig.js +1 -4
  4. package/dist/config/defaultConfig.js.map +1 -1
  5. package/dist/config/models.js +6 -0
  6. package/dist/config/models.js.map +1 -1
  7. package/dist/config/slash-commands.js +66 -2
  8. package/dist/config/slash-commands.js.map +1 -1
  9. package/dist/config/types.js +4 -4
  10. package/dist/config/types.js.map +1 -1
  11. package/dist/index.js +36 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/services/ai-context-injector.js +109 -0
  14. package/dist/services/ai-context-injector.js.map +1 -1
  15. package/dist/services/ai-service-client.js +3 -2
  16. package/dist/services/ai-service-client.js.map +1 -1
  17. package/dist/services/api-client.js.map +1 -1
  18. package/dist/services/background-task-manager.js +59 -0
  19. package/dist/services/background-task-manager.js.map +1 -1
  20. package/dist/services/local-chat-storage.js +2 -0
  21. package/dist/services/local-chat-storage.js.map +1 -1
  22. package/dist/services/skill-storage.js +141 -0
  23. package/dist/services/skill-storage.js.map +1 -0
  24. package/dist/services/sub-agent-manager.js +49 -8
  25. package/dist/services/sub-agent-manager.js.map +1 -1
  26. package/dist/services/warpify-detector.js +17 -5
  27. package/dist/services/warpify-detector.js.map +1 -1
  28. package/dist/tools/background-command.js +5 -2
  29. package/dist/tools/background-command.js.map +1 -1
  30. package/dist/tools/command.js +367 -109
  31. package/dist/tools/command.js.map +1 -1
  32. package/dist/tools/file-ops.js +23 -6
  33. package/dist/tools/file-ops.js.map +1 -1
  34. package/dist/tools/plan-mode.js +184 -336
  35. package/dist/tools/plan-mode.js.map +1 -1
  36. package/dist/tools/sub-agent.js +24 -5
  37. package/dist/tools/sub-agent.js.map +1 -1
  38. package/dist/tools/todo-list.js +157 -0
  39. package/dist/tools/todo-list.js.map +1 -0
  40. package/dist/types/skill.js +30 -0
  41. package/dist/types/skill.js.map +1 -0
  42. package/dist/ui/components/App.js +956 -162
  43. package/dist/ui/components/App.js.map +1 -1
  44. package/dist/ui/components/AuthScreen.js +3 -1
  45. package/dist/ui/components/AuthScreen.js.map +1 -1
  46. package/dist/ui/components/AuthWelcomeScreen.js +3 -1
  47. package/dist/ui/components/AuthWelcomeScreen.js.map +1 -1
  48. package/dist/ui/components/CodeBlock.js +3 -1
  49. package/dist/ui/components/CodeBlock.js.map +1 -1
  50. package/dist/ui/components/CompactShellPreview.js +44 -0
  51. package/dist/ui/components/CompactShellPreview.js.map +1 -0
  52. package/dist/ui/components/ConfigViewer.js +3 -1
  53. package/dist/ui/components/ConfigViewer.js.map +1 -1
  54. package/dist/ui/components/ConfirmPrompt.js +3 -1
  55. package/dist/ui/components/ConfirmPrompt.js.map +1 -1
  56. package/dist/ui/components/ConnectionStatusMessage.js +3 -1
  57. package/dist/ui/components/ConnectionStatusMessage.js.map +1 -1
  58. package/dist/ui/components/DetailedPlanReviewScreen.js +84 -74
  59. package/dist/ui/components/DetailedPlanReviewScreen.js.map +1 -1
  60. package/dist/ui/components/DiffViewer.js +6 -3
  61. package/dist/ui/components/DiffViewer.js.map +1 -1
  62. package/dist/ui/components/FileCreationPreview.js.map +1 -1
  63. package/dist/ui/components/FileTagAutocomplete.js +4 -2
  64. package/dist/ui/components/FileTagAutocomplete.js.map +1 -1
  65. package/dist/ui/components/InputBox.js +243 -40
  66. package/dist/ui/components/InputBox.js.map +1 -1
  67. package/dist/ui/components/InteractiveShell.js +5 -3
  68. package/dist/ui/components/InteractiveShell.js.map +1 -1
  69. package/dist/ui/components/KeyboardHelp.js +4 -1
  70. package/dist/ui/components/KeyboardHelp.js.map +1 -1
  71. package/dist/ui/components/LoadingIndicator.js +3 -1
  72. package/dist/ui/components/LoadingIndicator.js.map +1 -1
  73. package/dist/ui/components/MCPAddScreen.js +63 -13
  74. package/dist/ui/components/MCPAddScreen.js.map +1 -1
  75. package/dist/ui/components/MarkdownRenderer.js +3 -1
  76. package/dist/ui/components/MarkdownRenderer.js.map +1 -1
  77. package/dist/ui/components/MessageDisplay.js +9 -7
  78. package/dist/ui/components/MessageDisplay.js.map +1 -1
  79. package/dist/ui/components/ModelPicker.js +170 -0
  80. package/dist/ui/components/ModelPicker.js.map +1 -0
  81. package/dist/ui/components/MonitorModeAIPanel.js +3 -1
  82. package/dist/ui/components/MonitorModeAIPanel.js.map +1 -1
  83. package/dist/ui/components/PlanAcceptedMessage.js +12 -6
  84. package/dist/ui/components/PlanAcceptedMessage.js.map +1 -1
  85. package/dist/ui/components/PlanQuestionMessage.js +37 -0
  86. package/dist/ui/components/PlanQuestionMessage.js.map +1 -0
  87. package/dist/ui/components/PlanQuestionScreen.js +138 -0
  88. package/dist/ui/components/PlanQuestionScreen.js.map +1 -0
  89. package/dist/ui/components/PlanReviewScreen.js +7 -9
  90. package/dist/ui/components/PlanReviewScreen.js.map +1 -1
  91. package/dist/ui/components/RulesEditorScreen.js +65 -28
  92. package/dist/ui/components/RulesEditorScreen.js.map +1 -1
  93. package/dist/ui/components/SelectPrompt.js +3 -1
  94. package/dist/ui/components/SelectPrompt.js.map +1 -1
  95. package/dist/ui/components/SkillCreatorScreen.js +217 -0
  96. package/dist/ui/components/SkillCreatorScreen.js.map +1 -0
  97. package/dist/ui/components/SlashCommandAutocomplete.js +4 -2
  98. package/dist/ui/components/SlashCommandAutocomplete.js.map +1 -1
  99. package/dist/ui/components/StatusBar.js +4 -2
  100. package/dist/ui/components/StatusBar.js.map +1 -1
  101. package/dist/ui/components/StreamingMessageDisplay.js +5 -3
  102. package/dist/ui/components/StreamingMessageDisplay.js.map +1 -1
  103. package/dist/ui/components/SubAgentListScreen.js +65 -0
  104. package/dist/ui/components/SubAgentListScreen.js.map +1 -0
  105. package/dist/ui/components/SubAgentViewScreen.js +123 -0
  106. package/dist/ui/components/SubAgentViewScreen.js.map +1 -0
  107. package/dist/ui/components/TaskCompletedMessage.js +40 -8
  108. package/dist/ui/components/TaskCompletedMessage.js.map +1 -1
  109. package/dist/ui/components/TaskProgressIndicator.js +6 -4
  110. package/dist/ui/components/TaskProgressIndicator.js.map +1 -1
  111. package/dist/ui/components/TextEditor.js +297 -0
  112. package/dist/ui/components/TextEditor.js.map +1 -0
  113. package/dist/ui/components/TodoListMessage.js +59 -0
  114. package/dist/ui/components/TodoListMessage.js.map +1 -0
  115. package/dist/ui/components/ToolExecutionMessage.js +134 -84
  116. package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
  117. package/dist/ui/components/ToolExecutionStatus.js +3 -1
  118. package/dist/ui/components/ToolExecutionStatus.js.map +1 -1
  119. package/dist/ui/components/WelcomeBanner.js +33 -33
  120. package/dist/ui/components/WelcomeBanner.js.map +1 -1
  121. package/dist/ui/components/WorkflowCreatorScreen.js +5 -3
  122. package/dist/ui/components/WorkflowCreatorScreen.js.map +1 -1
  123. package/dist/ui/theme.js +97 -0
  124. package/dist/ui/theme.js.map +1 -0
  125. package/dist/ui/utils/chat-history-limit.js +247 -0
  126. package/dist/ui/utils/chat-history-limit.js.map +1 -0
  127. package/dist/utils/chat-formatter.js +22 -9
  128. package/dist/utils/chat-formatter.js.map +1 -1
  129. package/dist/utils/git-stats.js +7 -5
  130. package/dist/utils/git-stats.js.map +1 -1
  131. package/dist/utils/input-classifier.js +11 -1
  132. package/dist/utils/input-classifier.js.map +1 -1
  133. package/dist/utils/output-truncation.js +175 -0
  134. package/dist/utils/output-truncation.js.map +1 -0
  135. package/dist/utils/rule-reference-resolver.js +3 -3
  136. package/dist/utils/rule-reference-resolver.js.map +1 -1
  137. package/dist/utils/tunnel-commands-manager.js +134 -0
  138. package/dist/utils/tunnel-commands-manager.js.map +1 -0
  139. package/package.json +91 -90
  140. package/postinstall.js +4 -11
  141. package/dist/ui/components/MultiLineInput.js +0 -255
  142. package/dist/ui/components/MultiLineInput.js.map +0 -1
@@ -41,8 +41,12 @@ class BackgroundTaskManagerClass extends EventEmitter {
41
41
  }
42
42
  });
43
43
  task.ptyProcess = ptyProcess;
44
+ const MAX_OUTPUT_SIZE = 100 * 1024;
44
45
  ptyProcess.onData((data) => {
45
46
  task.output += data;
47
+ if (task.output.length > MAX_OUTPUT_SIZE) {
48
+ task.output = task.output.slice(-MAX_OUTPUT_SIZE);
49
+ }
46
50
  this.emit("taskOutput", id, data);
47
51
  });
48
52
  ptyProcess.onExit(({ exitCode }) => {
@@ -95,6 +99,9 @@ class BackgroundTaskManagerClass extends EventEmitter {
95
99
  id,
96
100
  onData: (data) => {
97
101
  task.output += data;
102
+ if (task.output.length > 100 * 1024) {
103
+ task.output = task.output.slice(-100 * 1024);
104
+ }
98
105
  this.emit("taskOutput", id, data);
99
106
  },
100
107
  onExit: (exitCode) => {
@@ -114,6 +121,58 @@ class BackgroundTaskManagerClass extends EventEmitter {
114
121
  }
115
122
  };
116
123
  }
124
+ /**
125
+ * Adopt a running foreground process into background task management.
126
+ *
127
+ * This is used for timeout-based transfer: a command started as foreground
128
+ * exceeds its timeout, so the STILL-RUNNING process is handed over to the
129
+ * background task manager without killing it. The process continues running
130
+ * and its output is captured by the background task.
131
+ *
132
+ * @param command The command string (for display)
133
+ * @param cwd The working directory (for display)
134
+ * @param existingOutput Output already captured while the process was foreground
135
+ * @param remoteContext Optional remote context label (e.g. "user@host")
136
+ * @returns Object with task ID and callbacks for wiring into the running process
137
+ */
138
+ adoptRunningProcess(command, cwd, existingOutput, remoteContext) {
139
+ const id = `bkg-${++this.taskCounter}-${Date.now()}`;
140
+ const task = {
141
+ id,
142
+ command,
143
+ cwd,
144
+ startTime: /* @__PURE__ */ new Date(),
145
+ output: existingOutput,
146
+ isRunning: true,
147
+ remoteContext
148
+ };
149
+ this.tasks.set(id, task);
150
+ this.emit("taskStarted", id, command);
151
+ this.emit("countChanged", this.getRunningCount());
152
+ quickLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] [BackgroundTaskManager] Adopted running process as ${id}: ${command}
153
+ `);
154
+ return {
155
+ id,
156
+ onData: (data) => {
157
+ task.output += data;
158
+ if (task.output.length > 100 * 1024) {
159
+ task.output = task.output.slice(-100 * 1024);
160
+ }
161
+ this.emit("taskOutput", id, data);
162
+ },
163
+ onExit: (exitCode) => {
164
+ task.isRunning = false;
165
+ task.exitCode = exitCode;
166
+ quickLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] [BackgroundTaskManager] Adopted task completed: id=${id}, exitCode=${exitCode}
167
+ `);
168
+ this.emit("taskCompleted", id, exitCode);
169
+ this.emit("countChanged", this.getRunningCount());
170
+ },
171
+ setRemotePty: (remotePty) => {
172
+ task.remotePtyProcess = remotePty;
173
+ }
174
+ };
175
+ }
117
176
  /**
118
177
  * Get all tasks (running and completed)
119
178
  */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/services/background-task-manager.ts"],"sourcesContent":["/**\r\n * Background Task Manager\r\n * \r\n * Manages background shell tasks, tracking their state and providing\r\n * notifications when tasks start, complete, or are cancelled.\r\n * Uses PTY for proper terminal emulation and signal handling.\r\n */\r\n\r\nimport { EventEmitter } from 'events';\r\nimport * as os from 'os';\r\nimport * as path from 'path';\r\nimport { createRequire } from 'module';\r\n\r\n// Use createRequire for ESM compatibility with native modules\r\nconst require = createRequire(import.meta.url);\r\nconst nodePty = require('@homebridge/node-pty-prebuilt-multiarch');\r\n\r\nimport { quickLog } from '../utils/conversation-logger.js';\r\n\r\n// PTY process interface\r\ninterface PtyProcess {\r\n write: (data: string) => void;\r\n resize: (cols: number, rows: number) => void;\r\n kill: () => void;\r\n onData: (callback: (data: string) => void) => void;\r\n onExit: (callback: (e: { exitCode: number }) => void) => void;\r\n pid: number;\r\n}\r\n\r\n// Remote PTY process interface (SSH/WSL/Docker)\r\n// These have a different callback signature (using onData string callback directly)\r\nexport interface RemotePtyProcess {\r\n write: (data: string) => void;\r\n kill: () => void;\r\n resize: (cols: number, rows: number) => void;\r\n isRunning: () => boolean;\r\n}\r\n\r\nexport interface BackgroundTask {\r\n id: string;\r\n command: string;\r\n cwd: string;\r\n startTime: Date;\r\n output: string;\r\n isRunning: boolean;\r\n exitCode?: number;\r\n error?: string;\r\n ptyProcess?: PtyProcess;\r\n remotePtyProcess?: RemotePtyProcess;\r\n remoteContext?: string; // Display string for remote context (e.g., \"user@host\", \"wsl:Ubuntu\")\r\n}\r\n\r\n\r\nexport interface BackgroundTaskInfo {\r\n id: string;\r\n command: string;\r\n cwd: string;\r\n startTime: Date;\r\n durationMs: number;\r\n isRunning: boolean;\r\n exitCode?: number;\r\n error?: string;\r\n outputPreview: string;\r\n}\r\n\r\nclass BackgroundTaskManagerClass extends EventEmitter {\r\n private tasks: Map<string, BackgroundTask> = new Map();\r\n private taskCounter: number = 0;\r\n\r\n /**\r\n * Start a new background task using PTY\r\n */\r\n startTask(command: string, cwd: string): string {\r\n const id = `bkg-${++this.taskCounter}-${Date.now()}`;\r\n\r\n const task: BackgroundTask = {\r\n id,\r\n command,\r\n cwd,\r\n startTime: new Date(),\r\n output: '',\r\n isRunning: true,\r\n };\r\n\r\n // Determine shell based on platform\r\n const isWindows = os.platform() === 'win32';\r\n const shell = isWindows ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');\r\n const args = isWindows ? ['-Command', command] : ['-c', command];\r\n\r\n // Get initial terminal dimensions\r\n const cols = process.stdout.columns || 80;\r\n const rows = process.stdout.rows || 24;\r\n\r\n try {\r\n // Spawn PTY process for proper terminal emulation\r\n const ptyProcess = nodePty.spawn(shell, args, {\r\n name: 'xterm-256color',\r\n cols,\r\n rows,\r\n cwd,\r\n env: {\r\n ...process.env,\r\n TERM: 'xterm-256color',\r\n COLORTERM: 'truecolor',\r\n FORCE_COLOR: '1',\r\n CLICOLOR: '1',\r\n PYTHONUNBUFFERED: '1',\r\n } as { [key: string]: string },\r\n });\r\n\r\n task.ptyProcess = ptyProcess;\r\n\r\n // Capture output\r\n ptyProcess.onData((data: string) => {\r\n task.output += data;\r\n this.emit('taskOutput', id, data);\r\n });\r\n\r\n // Handle process completion\r\n ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {\r\n task.isRunning = false;\r\n task.exitCode = exitCode;\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] taskCompleted event emitted: id=${id}, exitCode=${exitCode}\\n`);\r\n } catch (e) { }\r\n\r\n this.emit('taskCompleted', id, exitCode);\r\n this.emit('countChanged', this.getRunningCount());\r\n });\r\n\r\n this.tasks.set(id, task);\r\n this.emit('taskStarted', id, command);\r\n this.emit('countChanged', this.getRunningCount());\r\n\r\n return id;\r\n } catch (err: any) {\r\n task.isRunning = false;\r\n task.error = err.message;\r\n this.tasks.set(id, task);\r\n this.emit('taskError', id, err.message);\r\n return id;\r\n }\r\n }\r\n\r\n /**\r\n * Start a new remote background task (SSH/WSL/Docker)\r\n * This registers the task and returns callbacks that should be passed to the remote PTY creator\r\n * @param command The command being executed\r\n * @param cwd The working directory on the remote system\r\n * @param remoteContext Display string for the remote context (e.g., \"user@host\", \"wsl:Ubuntu\")\r\n * @returns Object with task ID and callbacks to pass to the remote PTY creator\r\n */\r\n startRemoteTask(\r\n command: string,\r\n cwd: string,\r\n remoteContext: string\r\n ): {\r\n id: string;\r\n onData: (data: string) => void;\r\n onExit: (exitCode: number) => void;\r\n setRemotePty: (remotePty: RemotePtyProcess) => void;\r\n } {\r\n const id = `bkg-${++this.taskCounter}-${Date.now()}`;\r\n\r\n const task: BackgroundTask = {\r\n id,\r\n command,\r\n cwd,\r\n startTime: new Date(),\r\n output: '',\r\n isRunning: true,\r\n remoteContext\r\n };\r\n\r\n this.tasks.set(id, task);\r\n this.emit('taskStarted', id, command);\r\n this.emit('countChanged', this.getRunningCount());\r\n\r\n // Return callbacks that the caller should pass to runSSHCommand/runWSLCommand/runDockerCommand\r\n return {\r\n id,\r\n onData: (data: string) => {\r\n task.output += data;\r\n this.emit('taskOutput', id, data);\r\n },\r\n onExit: (exitCode: number) => {\r\n task.isRunning = false;\r\n task.exitCode = exitCode;\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] Remote taskCompleted event emitted: id=${id}, exitCode=${exitCode}\\n`);\r\n } catch (e) { }\r\n\r\n this.emit('taskCompleted', id, exitCode);\r\n this.emit('countChanged', this.getRunningCount());\r\n },\r\n setRemotePty: (remotePty: RemotePtyProcess) => {\r\n task.remotePtyProcess = remotePty;\r\n }\r\n };\r\n }\r\n\r\n\r\n /**\r\n * Get all tasks (running and completed)\r\n */\r\n\r\n getAllTasks(): BackgroundTaskInfo[] {\r\n const now = Date.now();\r\n return Array.from(this.tasks.values()).map(task => ({\r\n id: task.id,\r\n command: task.command,\r\n cwd: task.cwd,\r\n startTime: task.startTime,\r\n durationMs: now - task.startTime.getTime(),\r\n isRunning: task.isRunning,\r\n exitCode: task.exitCode,\r\n error: task.error,\r\n outputPreview: task.output.slice(-200), // Last 200 chars\r\n }));\r\n }\r\n\r\n /**\r\n * Get only running tasks\r\n */\r\n getRunningTasks(): BackgroundTaskInfo[] {\r\n return this.getAllTasks().filter(t => t.isRunning);\r\n }\r\n\r\n /**\r\n * Get the count of running tasks\r\n */\r\n getRunningCount(): number {\r\n let count = 0;\r\n for (const task of this.tasks.values()) {\r\n if (task.isRunning) count++;\r\n }\r\n return count;\r\n }\r\n\r\n /**\r\n * Get a specific task by ID\r\n */\r\n getTask(id: string): BackgroundTask | undefined {\r\n return this.tasks.get(id);\r\n }\r\n\r\n /**\r\n * Get task output (full output for streaming view)\r\n */\r\n getTaskOutput(id: string): string {\r\n return this.tasks.get(id)?.output || '';\r\n }\r\n\r\n /**\r\n * Send input to a running background task (via PTY or remote PTY)\r\n */\r\n sendInput(id: string, input: string): boolean {\r\n const task = this.tasks.get(id);\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager.sendInput] taskId: ${id}, input: ${JSON.stringify(input)}, taskFound: ${!!task}, isRunning: ${task?.isRunning}, hasPty: ${!!task?.ptyProcess}, hasRemotePty: ${!!task?.remotePtyProcess}\\n`);\r\n } catch (e) { }\r\n\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n task.ptyProcess.write(input);\r\n return true;\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n task.remotePtyProcess.write(input);\r\n return true;\r\n }\r\n return false;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Send a signal to a running background task (via PTY or remote PTY)\r\n * For PTY, we send control characters directly - same as normal shell focus mode\r\n */\r\n sendSignal(id: string, signal: NodeJS.Signals): boolean {\r\n const task = this.tasks.get(id);\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n if (signal === 'SIGINT') {\r\n task.ptyProcess.write('\\x03'); // Ctrl+C\r\n setTimeout(() => {\r\n if (task.isRunning && task.ptyProcess) {\r\n task.ptyProcess.kill();\r\n }\r\n }, 500);\r\n } else {\r\n task.ptyProcess.kill();\r\n }\r\n return true;\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n if (signal === 'SIGINT') {\r\n task.remotePtyProcess.write('\\x03'); // Ctrl+C\r\n setTimeout(() => {\r\n if (task.isRunning && task.remotePtyProcess) {\r\n task.remotePtyProcess.kill();\r\n }\r\n }, 500);\r\n } else {\r\n task.remotePtyProcess.kill();\r\n }\r\n return true;\r\n }\r\n return false;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Cancel a running task (via PTY or remote PTY)\r\n */\r\n cancelTask(id: string): boolean {\r\n const task = this.tasks.get(id);\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n task.ptyProcess.kill();\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n task.remotePtyProcess.kill();\r\n }\r\n\r\n task.isRunning = false;\r\n task.error = 'Cancelled by user';\r\n this.emit('taskCancelled', id);\r\n this.emit('countChanged', this.getRunningCount());\r\n return true;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Clear completed tasks from the list\r\n */\r\n clearCompleted(): void {\r\n for (const [id, task] of this.tasks.entries()) {\r\n if (!task.isRunning) {\r\n this.tasks.delete(id);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * Check if a task is running\r\n */\r\n isTaskRunning(id: string): boolean {\r\n return this.tasks.get(id)?.isRunning ?? false;\r\n }\r\n}\r\n\r\n// Singleton instance\r\nexport const BackgroundTaskManager = new BackgroundTaskManagerClass();\r\n"],"mappings":"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","names":["require"]}
1
+ {"version":3,"sources":["../../src/services/background-task-manager.ts"],"sourcesContent":["/**\r\n * Background Task Manager\r\n * \r\n * Manages background shell tasks, tracking their state and providing\r\n * notifications when tasks start, complete, or are cancelled.\r\n * Uses PTY for proper terminal emulation and signal handling.\r\n */\r\n\r\nimport { EventEmitter } from 'events';\r\nimport * as os from 'os';\r\nimport * as path from 'path';\r\nimport { createRequire } from 'module';\r\n\r\n// Use createRequire for ESM compatibility with native modules\r\nconst require = createRequire(import.meta.url);\r\nconst nodePty = require('@homebridge/node-pty-prebuilt-multiarch');\r\n\r\nimport { quickLog } from '../utils/conversation-logger.js';\r\n\r\n// PTY process interface\r\ninterface PtyProcess {\r\n write: (data: string) => void;\r\n resize: (cols: number, rows: number) => void;\r\n kill: () => void;\r\n onData: (callback: (data: string) => void) => void;\r\n onExit: (callback: (e: { exitCode: number }) => void) => void;\r\n pid: number;\r\n}\r\n\r\n// Remote PTY process interface (SSH/WSL/Docker)\r\n// These have a different callback signature (using onData string callback directly)\r\nexport interface RemotePtyProcess {\r\n write: (data: string) => void;\r\n kill: () => void;\r\n resize: (cols: number, rows: number) => void;\r\n isRunning: () => boolean;\r\n}\r\n\r\nexport interface BackgroundTask {\r\n id: string;\r\n command: string;\r\n cwd: string;\r\n startTime: Date;\r\n output: string;\r\n isRunning: boolean;\r\n exitCode?: number;\r\n error?: string;\r\n ptyProcess?: PtyProcess;\r\n remotePtyProcess?: RemotePtyProcess;\r\n remoteContext?: string; // Display string for remote context (e.g., \"user@host\", \"wsl:Ubuntu\")\r\n}\r\n\r\n\r\nexport interface BackgroundTaskInfo {\r\n id: string;\r\n command: string;\r\n cwd: string;\r\n startTime: Date;\r\n durationMs: number;\r\n isRunning: boolean;\r\n exitCode?: number;\r\n error?: string;\r\n outputPreview: string;\r\n}\r\n\r\nclass BackgroundTaskManagerClass extends EventEmitter {\r\n private tasks: Map<string, BackgroundTask> = new Map();\r\n private taskCounter: number = 0;\r\n\r\n /**\r\n * Start a new background task using PTY\r\n */\r\n startTask(command: string, cwd: string): string {\r\n const id = `bkg-${++this.taskCounter}-${Date.now()}`;\r\n\r\n const task: BackgroundTask = {\r\n id,\r\n command,\r\n cwd,\r\n startTime: new Date(),\r\n output: '',\r\n isRunning: true,\r\n };\r\n\r\n // Determine shell based on platform\r\n const isWindows = os.platform() === 'win32';\r\n const shell = isWindows ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');\r\n const args = isWindows ? ['-Command', command] : ['-c', command];\r\n\r\n // Get initial terminal dimensions\r\n const cols = process.stdout.columns || 80;\r\n const rows = process.stdout.rows || 24;\r\n\r\n try {\r\n // Spawn PTY process for proper terminal emulation\r\n const ptyProcess = nodePty.spawn(shell, args, {\r\n name: 'xterm-256color',\r\n cols,\r\n rows,\r\n cwd,\r\n env: {\r\n ...process.env,\r\n TERM: 'xterm-256color',\r\n COLORTERM: 'truecolor',\r\n FORCE_COLOR: '1',\r\n CLICOLOR: '1',\r\n PYTHONUNBUFFERED: '1',\r\n } as { [key: string]: string },\r\n });\r\n\r\n task.ptyProcess = ptyProcess;\r\n\r\n // Capture output (capped to prevent unbounded memory growth)\r\n const MAX_OUTPUT_SIZE = 100 * 1024; // 100KB max\r\n ptyProcess.onData((data: string) => {\r\n task.output += data;\r\n if (task.output.length > MAX_OUTPUT_SIZE) {\r\n task.output = task.output.slice(-MAX_OUTPUT_SIZE);\r\n }\r\n this.emit('taskOutput', id, data);\r\n });\r\n\r\n // Handle process completion\r\n ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {\r\n task.isRunning = false;\r\n task.exitCode = exitCode;\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] taskCompleted event emitted: id=${id}, exitCode=${exitCode}\\n`);\r\n } catch (e) { }\r\n\r\n this.emit('taskCompleted', id, exitCode);\r\n this.emit('countChanged', this.getRunningCount());\r\n });\r\n\r\n this.tasks.set(id, task);\r\n this.emit('taskStarted', id, command);\r\n this.emit('countChanged', this.getRunningCount());\r\n\r\n return id;\r\n } catch (err: any) {\r\n task.isRunning = false;\r\n task.error = err.message;\r\n this.tasks.set(id, task);\r\n this.emit('taskError', id, err.message);\r\n return id;\r\n }\r\n }\r\n\r\n /**\r\n * Start a new remote background task (SSH/WSL/Docker)\r\n * This registers the task and returns callbacks that should be passed to the remote PTY creator\r\n * @param command The command being executed\r\n * @param cwd The working directory on the remote system\r\n * @param remoteContext Display string for the remote context (e.g., \"user@host\", \"wsl:Ubuntu\")\r\n * @returns Object with task ID and callbacks to pass to the remote PTY creator\r\n */\r\n startRemoteTask(\r\n command: string,\r\n cwd: string,\r\n remoteContext: string\r\n ): {\r\n id: string;\r\n onData: (data: string) => void;\r\n onExit: (exitCode: number) => void;\r\n setRemotePty: (remotePty: RemotePtyProcess) => void;\r\n } {\r\n const id = `bkg-${++this.taskCounter}-${Date.now()}`;\r\n\r\n const task: BackgroundTask = {\r\n id,\r\n command,\r\n cwd,\r\n startTime: new Date(),\r\n output: '',\r\n isRunning: true,\r\n remoteContext\r\n };\r\n\r\n this.tasks.set(id, task);\r\n this.emit('taskStarted', id, command);\r\n this.emit('countChanged', this.getRunningCount());\r\n\r\n // Return callbacks that the caller should pass to runSSHCommand/runWSLCommand/runDockerCommand\r\n return {\r\n id,\r\n onData: (data: string) => {\r\n task.output += data;\r\n if (task.output.length > 100 * 1024) {\r\n task.output = task.output.slice(-100 * 1024);\r\n }\r\n this.emit('taskOutput', id, data);\r\n },\r\n onExit: (exitCode: number) => {\r\n task.isRunning = false;\r\n task.exitCode = exitCode;\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] Remote taskCompleted event emitted: id=${id}, exitCode=${exitCode}\\n`);\r\n } catch (e) { }\r\n\r\n this.emit('taskCompleted', id, exitCode);\r\n this.emit('countChanged', this.getRunningCount());\r\n },\r\n setRemotePty: (remotePty: RemotePtyProcess) => {\r\n task.remotePtyProcess = remotePty;\r\n }\r\n };\r\n }\r\n\r\n /**\r\n * Adopt a running foreground process into background task management.\r\n * \r\n * This is used for timeout-based transfer: a command started as foreground\r\n * exceeds its timeout, so the STILL-RUNNING process is handed over to the\r\n * background task manager without killing it. The process continues running\r\n * and its output is captured by the background task.\r\n * \r\n * @param command The command string (for display)\r\n * @param cwd The working directory (for display)\r\n * @param existingOutput Output already captured while the process was foreground\r\n * @param remoteContext Optional remote context label (e.g. \"user@host\")\r\n * @returns Object with task ID and callbacks for wiring into the running process\r\n */\r\n adoptRunningProcess(\r\n command: string,\r\n cwd: string,\r\n existingOutput: string,\r\n remoteContext?: string,\r\n ): {\r\n id: string;\r\n onData: (data: string) => void;\r\n onExit: (exitCode: number) => void;\r\n setRemotePty: (remotePty: RemotePtyProcess) => void;\r\n } {\r\n const id = `bkg-${++this.taskCounter}-${Date.now()}`;\r\n\r\n const task: BackgroundTask = {\r\n id,\r\n command,\r\n cwd,\r\n startTime: new Date(),\r\n output: existingOutput,\r\n isRunning: true,\r\n remoteContext,\r\n };\r\n\r\n this.tasks.set(id, task);\r\n this.emit('taskStarted', id, command);\r\n this.emit('countChanged', this.getRunningCount());\r\n\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] Adopted running process as ${id}: ${command}\\n`);\r\n\r\n return {\r\n id,\r\n onData: (data: string) => {\r\n task.output += data;\r\n if (task.output.length > 100 * 1024) {\r\n task.output = task.output.slice(-100 * 1024);\r\n }\r\n this.emit('taskOutput', id, data);\r\n },\r\n onExit: (exitCode: number) => {\r\n task.isRunning = false;\r\n task.exitCode = exitCode;\r\n\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager] Adopted task completed: id=${id}, exitCode=${exitCode}\\n`);\r\n\r\n this.emit('taskCompleted', id, exitCode);\r\n this.emit('countChanged', this.getRunningCount());\r\n },\r\n setRemotePty: (remotePty: RemotePtyProcess) => {\r\n task.remotePtyProcess = remotePty;\r\n }\r\n };\r\n }\r\n\r\n\r\n /**\r\n * Get all tasks (running and completed)\r\n */\r\n\r\n getAllTasks(): BackgroundTaskInfo[] {\r\n const now = Date.now();\r\n return Array.from(this.tasks.values()).map(task => ({\r\n id: task.id,\r\n command: task.command,\r\n cwd: task.cwd,\r\n startTime: task.startTime,\r\n durationMs: now - task.startTime.getTime(),\r\n isRunning: task.isRunning,\r\n exitCode: task.exitCode,\r\n error: task.error,\r\n outputPreview: task.output.slice(-200), // Last 200 chars\r\n }));\r\n }\r\n\r\n /**\r\n * Get only running tasks\r\n */\r\n getRunningTasks(): BackgroundTaskInfo[] {\r\n return this.getAllTasks().filter(t => t.isRunning);\r\n }\r\n\r\n /**\r\n * Get the count of running tasks\r\n */\r\n getRunningCount(): number {\r\n let count = 0;\r\n for (const task of this.tasks.values()) {\r\n if (task.isRunning) count++;\r\n }\r\n return count;\r\n }\r\n\r\n /**\r\n * Get a specific task by ID\r\n */\r\n getTask(id: string): BackgroundTask | undefined {\r\n return this.tasks.get(id);\r\n }\r\n\r\n /**\r\n * Get task output (full output for streaming view)\r\n */\r\n getTaskOutput(id: string): string {\r\n return this.tasks.get(id)?.output || '';\r\n }\r\n\r\n /**\r\n * Send input to a running background task (via PTY or remote PTY)\r\n */\r\n sendInput(id: string, input: string): boolean {\r\n const task = this.tasks.get(id);\r\n\r\n // Debug logging\r\n const fs = require('fs');\r\n try {\r\n quickLog(`[${new Date().toISOString()}] [BackgroundTaskManager.sendInput] taskId: ${id}, input: ${JSON.stringify(input)}, taskFound: ${!!task}, isRunning: ${task?.isRunning}, hasPty: ${!!task?.ptyProcess}, hasRemotePty: ${!!task?.remotePtyProcess}\\n`);\r\n } catch (e) { }\r\n\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n task.ptyProcess.write(input);\r\n return true;\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n task.remotePtyProcess.write(input);\r\n return true;\r\n }\r\n return false;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Send a signal to a running background task (via PTY or remote PTY)\r\n * For PTY, we send control characters directly - same as normal shell focus mode\r\n */\r\n sendSignal(id: string, signal: NodeJS.Signals): boolean {\r\n const task = this.tasks.get(id);\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n if (signal === 'SIGINT') {\r\n task.ptyProcess.write('\\x03'); // Ctrl+C\r\n setTimeout(() => {\r\n if (task.isRunning && task.ptyProcess) {\r\n task.ptyProcess.kill();\r\n }\r\n }, 500);\r\n } else {\r\n task.ptyProcess.kill();\r\n }\r\n return true;\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n if (signal === 'SIGINT') {\r\n task.remotePtyProcess.write('\\x03'); // Ctrl+C\r\n setTimeout(() => {\r\n if (task.isRunning && task.remotePtyProcess) {\r\n task.remotePtyProcess.kill();\r\n }\r\n }, 500);\r\n } else {\r\n task.remotePtyProcess.kill();\r\n }\r\n return true;\r\n }\r\n return false;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Cancel a running task (via PTY or remote PTY)\r\n */\r\n cancelTask(id: string): boolean {\r\n const task = this.tasks.get(id);\r\n if (!task || !task.isRunning) {\r\n return false;\r\n }\r\n\r\n try {\r\n // Handle local PTY\r\n if (task.ptyProcess) {\r\n task.ptyProcess.kill();\r\n }\r\n // Handle remote PTY (SSH/WSL/Docker)\r\n if (task.remotePtyProcess) {\r\n task.remotePtyProcess.kill();\r\n }\r\n\r\n task.isRunning = false;\r\n task.error = 'Cancelled by user';\r\n this.emit('taskCancelled', id);\r\n this.emit('countChanged', this.getRunningCount());\r\n return true;\r\n } catch (err) {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Clear completed tasks from the list\r\n */\r\n clearCompleted(): void {\r\n for (const [id, task] of this.tasks.entries()) {\r\n if (!task.isRunning) {\r\n this.tasks.delete(id);\r\n }\r\n }\r\n }\r\n\r\n /**\r\n * Check if a task is running\r\n */\r\n isTaskRunning(id: string): boolean {\r\n return this.tasks.get(id)?.isRunning ?? false;\r\n }\r\n}\r\n\r\n// Singleton instance\r\nexport const BackgroundTaskManager = new BackgroundTaskManagerClass();\r\n"],"mappings":"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","names":["require"]}
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as os from "os";
4
4
  import { logError } from "../utils/logger.js";
5
+ import { cleanupToolOutputsForChat } from "../utils/output-truncation.js";
5
6
  class LocalChatStorage {
6
7
  chatsDir;
7
8
  indexPath;
@@ -291,6 +292,7 @@ class LocalChatStorage {
291
292
  if (fs.existsSync(checkpointsPath)) {
292
293
  fs.rmSync(checkpointsPath, { recursive: true, force: true });
293
294
  }
295
+ cleanupToolOutputsForChat(chatId);
294
296
  this.chatIndex.delete(chatId);
295
297
  this.saveIndex();
296
298
  return true;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/services/local-chat-storage.ts"],"sourcesContent":["/**\r\n * Local Chat Storage Service\r\n * \r\n * Provides persistent local storage for CLI conversations.\r\n * Stores chats in ~/.centaurus/chats/ directory as JSON files.\r\n */\r\n\r\nimport * as fs from 'fs';\r\nimport * as path from 'path';\r\nimport * as os from 'os';\r\nimport { logError } from '../utils/logger.js';\r\n\r\n/**\r\n * Message interface matching the AIMessage type from ai-service-client\r\n */\r\nexport interface StoredMessage {\r\n role: 'user' | 'assistant' | 'system' | 'tool';\r\n content: string;\r\n tool_calls?: Array<{\r\n id: string;\r\n name: string;\r\n arguments: Record<string, any>;\r\n }>;\r\n tool_call_id?: string;\r\n}\r\n\r\n/**\r\n * UI Message for restoring full chat history display\r\n * This is a serializable version of the Message type from types/index.ts\r\n */\r\nexport interface StoredUIMessage {\r\n id: string;\r\n role: 'user' | 'assistant' | 'system' | 'tool';\r\n content: string;\r\n timestamp?: string; // ISO string (Date is not JSON serializable)\r\n toolExecution?: {\r\n toolName: string;\r\n status: 'pending' | 'executing' | 'completed' | 'error';\r\n result?: string;\r\n error?: string;\r\n arguments?: Record<string, any>;\r\n };\r\n shouldStream?: boolean;\r\n isCommandMode?: boolean;\r\n tool_call_id?: string;\r\n tool_calls?: Array<{ id: string; name: string; arguments: Record<string, any> }>;\r\n thinkingDuration?: number;\r\n taskCompletion?: {\r\n taskNumber: number;\r\n totalTasks: number;\r\n taskDescription: string;\r\n completionNote?: string;\r\n };\r\n planAccepted?: {\r\n planTitle?: string;\r\n totalTasks?: number;\r\n };\r\n connectionStatus?: { // For SSH/WSL/Docker connection status\r\n type: 'ssh' | 'wsl' | 'docker';\r\n status: 'connecting' | 'connected' | 'error' | 'disconnected';\r\n connectionString?: string;\r\n error?: string;\r\n };\r\n}\r\n\r\n/**\r\n * Remote environment context for SSH/WSL/Docker sessions\r\n * Used to restore remote session state when resuming a chat\r\n */\r\nexport interface StoredRemoteContext {\r\n type: 'ssh' | 'wsl' | 'docker';\r\n connectionCommand: string; // Original command to reconnect (e.g., \"ssh user@host\" or \"wsl\")\r\n remoteCwd: string; // CWD in the remote environment\r\n localCwdBeforeRemote: string; // Local CWD before entering remote session\r\n metadata?: {\r\n hostname?: string; // For SSH\r\n username?: string; // For SSH \r\n distroName?: string; // For WSL\r\n containerId?: string; // For Docker\r\n port?: number; // For SSH\r\n };\r\n}\r\n\r\n/**\r\n * Serialized checkpoint metadata stored with chat history.\r\n * This allows checkpoint state to be restored even if the checkpoint index file is missing.\r\n */\r\nexport interface StoredCheckpointMeta {\r\n id: string;\r\n prompt: string;\r\n createdAt: string;\r\n createdAtMs: number;\r\n cwd: string;\r\n contextType: 'local' | 'ssh' | 'wsl' | 'docker';\r\n remoteSessionInfo?: {\r\n hostname?: string;\r\n username?: string;\r\n sessionId: string;\r\n connectionString?: string;\r\n };\r\n conversationIndex: number;\r\n uiMessageIndex?: number;\r\n uiMessageId?: string;\r\n snapshotDir: string;\r\n manifestPath: string;\r\n changes?: {\r\n added: string[];\r\n modified: string[];\r\n deleted: string[];\r\n };\r\n commands: string[];\r\n toolCalls: Array<{\r\n id?: string;\r\n name: string;\r\n arguments?: Record<string, any>;\r\n timestamp: string;\r\n }>;\r\n status: 'active' | 'finalized' | 'discarded';\r\n}\r\n\r\n/**\r\n * Local chat metadata (without messages, for listing)\r\n */\r\nexport interface LocalChatMeta {\r\n id: string;\r\n title: string;\r\n createdAt: string;\r\n updatedAt: string;\r\n messageCount: number;\r\n environment?: string; // 'local' or 'ssh:user@host' or 'wsl:distro' or 'docker:container'\r\n}\r\n\r\n/**\r\n * Full local chat with messages\r\n */\r\nexport interface LocalChat extends LocalChatMeta {\r\n messages: StoredMessage[];\r\n uiMessages?: StoredUIMessage[]; // Full UI history for restoration\r\n cwd?: string; // Working directory at time of save (for restoration)\r\n remoteContext?: StoredRemoteContext; // Remote context if saved while in SSH/WSL/Docker (backward compat)\r\n remoteContextStack?: StoredRemoteContext[]; // Stack of nested contexts (for SSH>SSH, SSH>Docker, etc.)\r\n checkpointIndex?: StoredCheckpointMeta[]; // Serialized checkpoint metadata for resume/revert restoration\r\n backendConversationId?: string; // Backend conversation UUID (for file storage/deletion)\r\n}\r\n\r\n/**\r\n * LocalChatStorage class for managing local conversation persistence\r\n */\r\nexport class LocalChatStorage {\r\n private chatsDir: string;\r\n private indexPath: string;\r\n private chatIndex: Map<string, LocalChatMeta> = new Map();\r\n private indexLoaded: boolean = false;\r\n\r\n constructor() {\r\n const homeDir = os.homedir();\r\n this.chatsDir = path.join(homeDir, '.centaurus', 'chats');\r\n this.indexPath = path.join(this.chatsDir, 'index.json');\r\n\r\n // Ensure chats directory exists\r\n if (!fs.existsSync(this.chatsDir)) {\r\n fs.mkdirSync(this.chatsDir, { recursive: true });\r\n }\r\n\r\n // Load or build the index\r\n this.loadIndex();\r\n }\r\n\r\n /**\r\n * Load the metadata index from disk, or rebuild it from chat files if missing\r\n */\r\n private loadIndex(): void {\r\n if (this.indexLoaded) return;\r\n\r\n try {\r\n if (fs.existsSync(this.indexPath)) {\r\n const data = fs.readFileSync(this.indexPath, 'utf-8');\r\n const indexArray: LocalChatMeta[] = JSON.parse(data);\r\n this.chatIndex.clear();\r\n for (const meta of indexArray) {\r\n this.chatIndex.set(meta.id, meta);\r\n }\r\n this.indexLoaded = true;\r\n return;\r\n }\r\n } catch (error) {\r\n // Index file corrupted, rebuild it\r\n }\r\n\r\n // Rebuild index from chat files (one-time migration)\r\n this.rebuildIndex();\r\n }\r\n\r\n /**\r\n * Rebuild the index by reading metadata from all chat files\r\n * This is a one-time migration that reads full files, but only happens once\r\n */\r\n private rebuildIndex(): void {\r\n this.chatIndex.clear();\r\n\r\n if (!fs.existsSync(this.chatsDir)) {\r\n this.indexLoaded = true;\r\n return;\r\n }\r\n\r\n const files = fs.readdirSync(this.chatsDir)\r\n .filter(f => f.endsWith('.json') && f !== 'index.json');\r\n\r\n for (const file of files) {\r\n try {\r\n const filePath = path.join(this.chatsDir, file);\r\n const data = fs.readFileSync(filePath, 'utf-8');\r\n const chat = JSON.parse(data) as LocalChat;\r\n\r\n // Determine environment string from remoteContext\r\n let environment = 'local';\r\n if (chat.remoteContext) {\r\n const rc = chat.remoteContext;\r\n if (rc.type === 'ssh' && rc.metadata?.hostname) {\r\n environment = `ssh:${rc.metadata.username || 'user'}@${rc.metadata.hostname}`;\r\n } else if (rc.type === 'wsl') {\r\n environment = `wsl:${rc.metadata?.distroName || 'Ubuntu'}`;\r\n } else if (rc.type === 'docker' && rc.metadata?.containerId) {\r\n environment = `docker:${rc.metadata.containerId.substring(0, 12)}`;\r\n } else {\r\n environment = rc.type;\r\n }\r\n }\r\n\r\n // Store metadata only\r\n this.chatIndex.set(chat.id, {\r\n id: chat.id,\r\n title: chat.title,\r\n createdAt: chat.createdAt,\r\n updatedAt: chat.updatedAt,\r\n messageCount: chat.messageCount,\r\n environment,\r\n });\r\n } catch (error) {\r\n // Skip invalid files\r\n continue;\r\n }\r\n }\r\n\r\n this.indexLoaded = true;\r\n this.saveIndex();\r\n }\r\n\r\n /**\r\n * Save the metadata index to disk\r\n */\r\n private saveIndex(): void {\r\n try {\r\n const indexArray = Array.from(this.chatIndex.values());\r\n fs.writeFileSync(this.indexPath, JSON.stringify(indexArray, null, 2), 'utf-8');\r\n } catch (error) {\r\n logError('Failed to save chat index', error as Error);\r\n }\r\n }\r\n\r\n /**\r\n * Update the index entry for a chat\r\n */\r\n private updateIndexEntry(chat: LocalChat): void {\r\n let environment = 'local';\r\n if (chat.remoteContext) {\r\n const rc = chat.remoteContext;\r\n if (rc.type === 'ssh' && rc.metadata?.hostname) {\r\n environment = `ssh:${rc.metadata.username || 'user'}@${rc.metadata.hostname}`;\r\n } else if (rc.type === 'wsl') {\r\n environment = `wsl:${rc.metadata?.distroName || 'Ubuntu'}`;\r\n } else if (rc.type === 'docker' && rc.metadata?.containerId) {\r\n environment = `docker:${rc.metadata.containerId.substring(0, 12)}`;\r\n } else {\r\n environment = rc.type;\r\n }\r\n }\r\n\r\n this.chatIndex.set(chat.id, {\r\n id: chat.id,\r\n title: chat.title,\r\n createdAt: chat.createdAt,\r\n updatedAt: chat.updatedAt,\r\n messageCount: chat.messageCount,\r\n environment,\r\n });\r\n this.saveIndex();\r\n }\r\n\r\n /**\r\n * Generate a unique chat ID based on timestamp\r\n */\r\n generateChatId(): string {\r\n const now = new Date();\r\n const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);\r\n const random = Math.random().toString(36).substring(2, 6);\r\n return `chat-${timestamp}-${random}`;\r\n }\r\n\r\n /**\r\n * Generate a title from the first user message\r\n */\r\n generateTitle(messages: StoredMessage[]): string {\r\n const firstUserMessage = messages.find(m => m.role === 'user');\r\n if (!firstUserMessage || !firstUserMessage.content) {\r\n return 'New Chat';\r\n }\r\n\r\n // Take first 60 chars, trim to last word boundary\r\n let title = firstUserMessage.content.substring(0, 60);\r\n if (firstUserMessage.content.length > 60) {\r\n const lastSpace = title.lastIndexOf(' ');\r\n if (lastSpace > 30) {\r\n title = title.substring(0, lastSpace);\r\n }\r\n title += '...';\r\n }\r\n\r\n // Clean up whitespace and newlines\r\n title = title.replace(/\\s+/g, ' ').trim();\r\n\r\n return title || 'New Chat';\r\n }\r\n\r\n /**\r\n * Get the file path for a chat\r\n */\r\n private getChatPath(chatId: string): string {\r\n return path.join(this.chatsDir, `${chatId}.json`);\r\n }\r\n\r\n /**\r\n * Save a chat to disk\r\n * @param remoteContext - undefined = preserve existing, null = clear, StoredRemoteContext = set new value\r\n * @param remoteContextStack - Stack of nested contexts for SSH>SSH, SSH>Docker etc.\r\n * @param checkpointIndex - undefined = preserve existing, null = clear, array = set latest checkpoint metadata\r\n */\r\n saveChat(\r\n chatId: string,\r\n messages: StoredMessage[],\r\n uiMessages?: StoredUIMessage[],\r\n cwd?: string,\r\n remoteContext?: StoredRemoteContext | null,\r\n remoteContextStack?: StoredRemoteContext[] | null,\r\n checkpointIndex?: StoredCheckpointMeta[] | null\r\n ): LocalChat {\r\n const chatPath = this.getChatPath(chatId);\r\n const now = new Date().toISOString();\r\n\r\n let chat: LocalChat;\r\n\r\n if (fs.existsSync(chatPath)) {\r\n // Update existing chat\r\n const existing = this.loadChat(chatId);\r\n if (existing) {\r\n chat = {\r\n ...existing,\r\n messages,\r\n uiMessages: uiMessages || existing.uiMessages,\r\n cwd: cwd || existing.cwd, // Preserve CWD if not provided\r\n // remoteContext: null = clear it, undefined = preserve existing\r\n remoteContext: remoteContext === null ? undefined : (remoteContext !== undefined ? remoteContext : existing.remoteContext),\r\n // remoteContextStack: null = clear it, undefined = preserve existing\r\n remoteContextStack: remoteContextStack === null ? undefined : (remoteContextStack !== undefined ? remoteContextStack : existing.remoteContextStack),\r\n // checkpointIndex: null = clear it, undefined = preserve existing\r\n checkpointIndex: checkpointIndex === null ? undefined : (checkpointIndex !== undefined ? checkpointIndex : existing.checkpointIndex),\r\n // Preserve backendConversationId if it exists\r\n backendConversationId: existing.backendConversationId,\r\n messageCount: messages.length,\r\n updatedAt: now,\r\n };\r\n } else {\r\n // File exists but couldn't be parsed, create new\r\n chat = {\r\n id: chatId,\r\n title: this.generateTitle(messages),\r\n createdAt: now,\r\n updatedAt: now,\r\n messageCount: messages.length,\r\n messages,\r\n uiMessages,\r\n cwd,\r\n remoteContext: remoteContext ?? undefined,\r\n remoteContextStack: remoteContextStack ?? undefined,\r\n checkpointIndex: checkpointIndex ?? undefined,\r\n };\r\n }\r\n } else {\r\n // Create new chat\r\n chat = {\r\n id: chatId,\r\n title: this.generateTitle(messages),\r\n createdAt: now,\r\n updatedAt: now,\r\n messageCount: messages.length,\r\n messages,\r\n uiMessages,\r\n cwd,\r\n remoteContext: remoteContext ?? undefined,\r\n remoteContextStack: remoteContextStack ?? undefined,\r\n checkpointIndex: checkpointIndex ?? undefined,\r\n };\r\n }\r\n\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n\r\n // Update the metadata index\r\n this.updateIndexEntry(chat);\r\n\r\n return chat;\r\n }\r\n\r\n /**\r\n * Set the backend conversation ID for a chat\r\n * This is used to associate local chats with backend file storage\r\n */\r\n setBackendConversationId(chatId: string, backendConversationId: string): boolean {\r\n const chat = this.loadChat(chatId);\r\n if (!chat) {\r\n return false;\r\n }\r\n\r\n try {\r\n chat.backendConversationId = backendConversationId;\r\n chat.updatedAt = new Date().toISOString();\r\n const chatPath = this.getChatPath(chatId);\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to set backend conversation ID for chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Get the backend conversation ID for a chat (if any)\r\n */\r\n getBackendConversationId(chatId: string): string | undefined {\r\n const chat = this.loadChat(chatId);\r\n return chat?.backendConversationId;\r\n }\r\n\r\n /**\r\n * Load a chat by ID\r\n */\r\n loadChat(chatId: string): LocalChat | null {\r\n const chatPath = this.getChatPath(chatId);\r\n\r\n if (!fs.existsSync(chatPath)) {\r\n return null;\r\n }\r\n\r\n try {\r\n const data = fs.readFileSync(chatPath, 'utf-8');\r\n return JSON.parse(data) as LocalChat;\r\n } catch (error) {\r\n logError(`Failed to load chat ${chatId}`, error as Error);\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * List all chats (metadata only, sorted by updatedAt descending)\r\n * Uses the in-memory index to avoid loading full chat files\r\n */\r\n listChats(): LocalChatMeta[] {\r\n // Ensure index is loaded\r\n this.loadIndex();\r\n\r\n // Return sorted metadata from the cache\r\n const chats = Array.from(this.chatIndex.values());\r\n\r\n // Sort by updatedAt descending (most recent first)\r\n chats.sort((a, b) =>\r\n new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()\r\n );\r\n\r\n return chats;\r\n }\r\n\r\n /**\r\n * Delete a chat by ID\r\n */\r\n deleteChat(chatId: string): boolean {\r\n const chatPath = this.getChatPath(chatId);\r\n const checkpointsPath = path.join(os.homedir(), '.centaurus', 'checkpoints', chatId);\r\n\r\n if (!fs.existsSync(chatPath)) {\r\n // Best-effort cleanup for orphaned checkpoint data.\r\n if (fs.existsSync(checkpointsPath)) {\r\n try {\r\n fs.rmSync(checkpointsPath, { recursive: true, force: true });\r\n } catch {\r\n // Ignore cleanup errors for missing chats.\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n try {\r\n fs.unlinkSync(chatPath);\r\n if (fs.existsSync(checkpointsPath)) {\r\n fs.rmSync(checkpointsPath, { recursive: true, force: true });\r\n }\r\n\r\n // Remove from index\r\n this.chatIndex.delete(chatId);\r\n this.saveIndex();\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to delete chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Import a full chat object (used for restoring from cloud sync)\r\n * Overwrites existing chat with same ID if present\r\n */\r\n importChat(chat: LocalChat): boolean {\r\n if (!chat || !chat.id) {\r\n return false;\r\n }\r\n\r\n try {\r\n const chatPath = this.getChatPath(chat.id);\r\n\r\n // Ensure the chat has required fields\r\n const now = new Date().toISOString();\r\n const importedChat: LocalChat = {\r\n id: chat.id,\r\n title: chat.title || 'Imported Chat',\r\n createdAt: chat.createdAt || now,\r\n updatedAt: now, // Always update the timestamp on import\r\n messageCount: chat.messages?.length || chat.messageCount || 0,\r\n messages: chat.messages || [],\r\n uiMessages: chat.uiMessages,\r\n cwd: chat.cwd,\r\n remoteContext: chat.remoteContext,\r\n remoteContextStack: chat.remoteContextStack,\r\n checkpointIndex: chat.checkpointIndex,\r\n backendConversationId: chat.backendConversationId,\r\n environment: chat.environment,\r\n };\r\n\r\n fs.writeFileSync(chatPath, JSON.stringify(importedChat, null, 2), 'utf-8');\r\n\r\n // Update the index\r\n this.updateIndexEntry(importedChat);\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to import chat ${chat.id}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Rename a chat by ID\r\n */\r\n renameChat(chatId: string, newTitle: string): boolean {\r\n const chat = this.loadChat(chatId);\r\n if (!chat) {\r\n return false;\r\n }\r\n\r\n try {\r\n chat.title = newTitle;\r\n chat.updatedAt = new Date().toISOString();\r\n const chatPath = this.getChatPath(chatId);\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n\r\n // Update the index\r\n this.updateIndexEntry(chat);\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to rename chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Get the chats directory path\r\n */\r\n getChatsDir(): string {\r\n return this.chatsDir;\r\n }\r\n}\r\n\r\n// Export singleton instance\r\nexport const localChatStorage = new LocalChatStorage();\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/services/local-chat-storage.ts"],"sourcesContent":["/**\r\n * Local Chat Storage Service\r\n * \r\n * Provides persistent local storage for CLI conversations.\r\n * Stores chats in ~/.centaurus/chats/ directory as JSON files.\r\n */\r\n\r\nimport * as fs from 'fs';\r\nimport * as path from 'path';\r\nimport * as os from 'os';\r\nimport { logError } from '../utils/logger.js';\r\nimport { cleanupToolOutputsForChat } from '../utils/output-truncation.js';\r\n\r\n/**\r\n * Message interface matching the AIMessage type from ai-service-client\r\n */\r\nexport interface StoredMessage {\r\n role: 'user' | 'assistant' | 'system' | 'tool';\r\n content: string;\r\n tool_calls?: Array<{\r\n id: string;\r\n name: string;\r\n arguments: Record<string, any>;\r\n }>;\r\n tool_call_id?: string;\r\n}\r\n\r\n/**\r\n * UI Message for restoring full chat history display\r\n * This is a serializable version of the Message type from types/index.ts\r\n */\r\nexport interface StoredUIMessage {\r\n id: string;\r\n role: 'user' | 'assistant' | 'system' | 'tool';\r\n content: string;\r\n timestamp?: string; // ISO string (Date is not JSON serializable)\r\n toolExecution?: {\r\n toolName: string;\r\n status: 'pending' | 'executing' | 'completed' | 'error';\r\n result?: string;\r\n error?: string;\r\n arguments?: Record<string, any>;\r\n };\r\n shouldStream?: boolean;\r\n isCommandMode?: boolean;\r\n tool_call_id?: string;\r\n tool_calls?: Array<{ id: string; name: string; arguments: Record<string, any> }>;\r\n thinkingDuration?: number;\r\n taskCompletion?: {\r\n completedStepNumber: number;\r\n completedStepDescription: string;\r\n completedCount: number;\r\n totalCount: number;\r\n completionNote?: string;\r\n allSteps?: Array<{\r\n id: number;\r\n description: string;\r\n status: 'pending' | 'completed';\r\n }>;\r\n };\r\n planAccepted?: {\r\n planTitle?: string;\r\n totalSteps?: number;\r\n steps?: Array<{\r\n id: number;\r\n description: string;\r\n status: 'pending' | 'completed';\r\n }>;\r\n };\r\n planQuestion?: {\r\n question: string;\r\n options: string[];\r\n userAnswer: string;\r\n wasSkipped: boolean;\r\n };\r\n connectionStatus?: { // For SSH/WSL/Docker connection status\r\n type: 'ssh' | 'wsl' | 'docker';\r\n status: 'connecting' | 'connected' | 'error' | 'disconnected';\r\n connectionString?: string;\r\n error?: string;\r\n };\r\n}\r\n\r\n/**\r\n * Remote environment context for SSH/WSL/Docker sessions\r\n * Used to restore remote session state when resuming a chat\r\n */\r\nexport interface StoredRemoteContext {\r\n type: 'ssh' | 'wsl' | 'docker';\r\n connectionCommand: string; // Original command to reconnect (e.g., \"ssh user@host\" or \"wsl\")\r\n remoteCwd: string; // CWD in the remote environment\r\n localCwdBeforeRemote: string; // Local CWD before entering remote session\r\n metadata?: {\r\n hostname?: string; // For SSH\r\n username?: string; // For SSH \r\n distroName?: string; // For WSL\r\n containerId?: string; // For Docker\r\n port?: number; // For SSH\r\n };\r\n}\r\n\r\n/**\r\n * Serialized checkpoint metadata stored with chat history.\r\n * This allows checkpoint state to be restored even if the checkpoint index file is missing.\r\n */\r\nexport interface StoredCheckpointMeta {\r\n id: string;\r\n prompt: string;\r\n createdAt: string;\r\n createdAtMs: number;\r\n cwd: string;\r\n contextType: 'local' | 'ssh' | 'wsl' | 'docker';\r\n remoteSessionInfo?: {\r\n hostname?: string;\r\n username?: string;\r\n sessionId: string;\r\n connectionString?: string;\r\n };\r\n conversationIndex: number;\r\n uiMessageIndex?: number;\r\n uiMessageId?: string;\r\n snapshotDir: string;\r\n manifestPath: string;\r\n changes?: {\r\n added: string[];\r\n modified: string[];\r\n deleted: string[];\r\n };\r\n commands: string[];\r\n toolCalls: Array<{\r\n id?: string;\r\n name: string;\r\n arguments?: Record<string, any>;\r\n timestamp: string;\r\n }>;\r\n status: 'active' | 'finalized' | 'discarded';\r\n}\r\n\r\n/**\r\n * Local chat metadata (without messages, for listing)\r\n */\r\nexport interface LocalChatMeta {\r\n id: string;\r\n title: string;\r\n createdAt: string;\r\n updatedAt: string;\r\n messageCount: number;\r\n environment?: string; // 'local' or 'ssh:user@host' or 'wsl:distro' or 'docker:container'\r\n}\r\n\r\n/**\r\n * Full local chat with messages\r\n */\r\nexport interface LocalChat extends LocalChatMeta {\r\n messages: StoredMessage[];\r\n uiMessages?: StoredUIMessage[]; // Full UI history for restoration\r\n cwd?: string; // Working directory at time of save (for restoration)\r\n remoteContext?: StoredRemoteContext; // Remote context if saved while in SSH/WSL/Docker (backward compat)\r\n remoteContextStack?: StoredRemoteContext[]; // Stack of nested contexts (for SSH>SSH, SSH>Docker, etc.)\r\n checkpointIndex?: StoredCheckpointMeta[]; // Serialized checkpoint metadata for resume/revert restoration\r\n backendConversationId?: string; // Backend conversation UUID (for file storage/deletion)\r\n}\r\n\r\n/**\r\n * LocalChatStorage class for managing local conversation persistence\r\n */\r\nexport class LocalChatStorage {\r\n private chatsDir: string;\r\n private indexPath: string;\r\n private chatIndex: Map<string, LocalChatMeta> = new Map();\r\n private indexLoaded: boolean = false;\r\n\r\n constructor() {\r\n const homeDir = os.homedir();\r\n this.chatsDir = path.join(homeDir, '.centaurus', 'chats');\r\n this.indexPath = path.join(this.chatsDir, 'index.json');\r\n\r\n // Ensure chats directory exists\r\n if (!fs.existsSync(this.chatsDir)) {\r\n fs.mkdirSync(this.chatsDir, { recursive: true });\r\n }\r\n\r\n // Load or build the index\r\n this.loadIndex();\r\n }\r\n\r\n /**\r\n * Load the metadata index from disk, or rebuild it from chat files if missing\r\n */\r\n private loadIndex(): void {\r\n if (this.indexLoaded) return;\r\n\r\n try {\r\n if (fs.existsSync(this.indexPath)) {\r\n const data = fs.readFileSync(this.indexPath, 'utf-8');\r\n const indexArray: LocalChatMeta[] = JSON.parse(data);\r\n this.chatIndex.clear();\r\n for (const meta of indexArray) {\r\n this.chatIndex.set(meta.id, meta);\r\n }\r\n this.indexLoaded = true;\r\n return;\r\n }\r\n } catch (error) {\r\n // Index file corrupted, rebuild it\r\n }\r\n\r\n // Rebuild index from chat files (one-time migration)\r\n this.rebuildIndex();\r\n }\r\n\r\n /**\r\n * Rebuild the index by reading metadata from all chat files\r\n * This is a one-time migration that reads full files, but only happens once\r\n */\r\n private rebuildIndex(): void {\r\n this.chatIndex.clear();\r\n\r\n if (!fs.existsSync(this.chatsDir)) {\r\n this.indexLoaded = true;\r\n return;\r\n }\r\n\r\n const files = fs.readdirSync(this.chatsDir)\r\n .filter(f => f.endsWith('.json') && f !== 'index.json');\r\n\r\n for (const file of files) {\r\n try {\r\n const filePath = path.join(this.chatsDir, file);\r\n const data = fs.readFileSync(filePath, 'utf-8');\r\n const chat = JSON.parse(data) as LocalChat;\r\n\r\n // Determine environment string from remoteContext\r\n let environment = 'local';\r\n if (chat.remoteContext) {\r\n const rc = chat.remoteContext;\r\n if (rc.type === 'ssh' && rc.metadata?.hostname) {\r\n environment = `ssh:${rc.metadata.username || 'user'}@${rc.metadata.hostname}`;\r\n } else if (rc.type === 'wsl') {\r\n environment = `wsl:${rc.metadata?.distroName || 'Ubuntu'}`;\r\n } else if (rc.type === 'docker' && rc.metadata?.containerId) {\r\n environment = `docker:${rc.metadata.containerId.substring(0, 12)}`;\r\n } else {\r\n environment = rc.type;\r\n }\r\n }\r\n\r\n // Store metadata only\r\n this.chatIndex.set(chat.id, {\r\n id: chat.id,\r\n title: chat.title,\r\n createdAt: chat.createdAt,\r\n updatedAt: chat.updatedAt,\r\n messageCount: chat.messageCount,\r\n environment,\r\n });\r\n } catch (error) {\r\n // Skip invalid files\r\n continue;\r\n }\r\n }\r\n\r\n this.indexLoaded = true;\r\n this.saveIndex();\r\n }\r\n\r\n /**\r\n * Save the metadata index to disk\r\n */\r\n private saveIndex(): void {\r\n try {\r\n const indexArray = Array.from(this.chatIndex.values());\r\n fs.writeFileSync(this.indexPath, JSON.stringify(indexArray, null, 2), 'utf-8');\r\n } catch (error) {\r\n logError('Failed to save chat index', error as Error);\r\n }\r\n }\r\n\r\n /**\r\n * Update the index entry for a chat\r\n */\r\n private updateIndexEntry(chat: LocalChat): void {\r\n let environment = 'local';\r\n if (chat.remoteContext) {\r\n const rc = chat.remoteContext;\r\n if (rc.type === 'ssh' && rc.metadata?.hostname) {\r\n environment = `ssh:${rc.metadata.username || 'user'}@${rc.metadata.hostname}`;\r\n } else if (rc.type === 'wsl') {\r\n environment = `wsl:${rc.metadata?.distroName || 'Ubuntu'}`;\r\n } else if (rc.type === 'docker' && rc.metadata?.containerId) {\r\n environment = `docker:${rc.metadata.containerId.substring(0, 12)}`;\r\n } else {\r\n environment = rc.type;\r\n }\r\n }\r\n\r\n this.chatIndex.set(chat.id, {\r\n id: chat.id,\r\n title: chat.title,\r\n createdAt: chat.createdAt,\r\n updatedAt: chat.updatedAt,\r\n messageCount: chat.messageCount,\r\n environment,\r\n });\r\n this.saveIndex();\r\n }\r\n\r\n /**\r\n * Generate a unique chat ID based on timestamp\r\n */\r\n generateChatId(): string {\r\n const now = new Date();\r\n const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);\r\n const random = Math.random().toString(36).substring(2, 6);\r\n return `chat-${timestamp}-${random}`;\r\n }\r\n\r\n /**\r\n * Generate a title from the first user message\r\n */\r\n generateTitle(messages: StoredMessage[]): string {\r\n const firstUserMessage = messages.find(m => m.role === 'user');\r\n if (!firstUserMessage || !firstUserMessage.content) {\r\n return 'New Chat';\r\n }\r\n\r\n // Take first 60 chars, trim to last word boundary\r\n let title = firstUserMessage.content.substring(0, 60);\r\n if (firstUserMessage.content.length > 60) {\r\n const lastSpace = title.lastIndexOf(' ');\r\n if (lastSpace > 30) {\r\n title = title.substring(0, lastSpace);\r\n }\r\n title += '...';\r\n }\r\n\r\n // Clean up whitespace and newlines\r\n title = title.replace(/\\s+/g, ' ').trim();\r\n\r\n return title || 'New Chat';\r\n }\r\n\r\n /**\r\n * Get the file path for a chat\r\n */\r\n private getChatPath(chatId: string): string {\r\n return path.join(this.chatsDir, `${chatId}.json`);\r\n }\r\n\r\n /**\r\n * Save a chat to disk\r\n * @param remoteContext - undefined = preserve existing, null = clear, StoredRemoteContext = set new value\r\n * @param remoteContextStack - Stack of nested contexts for SSH>SSH, SSH>Docker etc.\r\n * @param checkpointIndex - undefined = preserve existing, null = clear, array = set latest checkpoint metadata\r\n */\r\n saveChat(\r\n chatId: string,\r\n messages: StoredMessage[],\r\n uiMessages?: StoredUIMessage[],\r\n cwd?: string,\r\n remoteContext?: StoredRemoteContext | null,\r\n remoteContextStack?: StoredRemoteContext[] | null,\r\n checkpointIndex?: StoredCheckpointMeta[] | null\r\n ): LocalChat {\r\n const chatPath = this.getChatPath(chatId);\r\n const now = new Date().toISOString();\r\n\r\n let chat: LocalChat;\r\n\r\n if (fs.existsSync(chatPath)) {\r\n // Update existing chat\r\n const existing = this.loadChat(chatId);\r\n if (existing) {\r\n chat = {\r\n ...existing,\r\n messages,\r\n uiMessages: uiMessages || existing.uiMessages,\r\n cwd: cwd || existing.cwd, // Preserve CWD if not provided\r\n // remoteContext: null = clear it, undefined = preserve existing\r\n remoteContext: remoteContext === null ? undefined : (remoteContext !== undefined ? remoteContext : existing.remoteContext),\r\n // remoteContextStack: null = clear it, undefined = preserve existing\r\n remoteContextStack: remoteContextStack === null ? undefined : (remoteContextStack !== undefined ? remoteContextStack : existing.remoteContextStack),\r\n // checkpointIndex: null = clear it, undefined = preserve existing\r\n checkpointIndex: checkpointIndex === null ? undefined : (checkpointIndex !== undefined ? checkpointIndex : existing.checkpointIndex),\r\n // Preserve backendConversationId if it exists\r\n backendConversationId: existing.backendConversationId,\r\n messageCount: messages.length,\r\n updatedAt: now,\r\n };\r\n } else {\r\n // File exists but couldn't be parsed, create new\r\n chat = {\r\n id: chatId,\r\n title: this.generateTitle(messages),\r\n createdAt: now,\r\n updatedAt: now,\r\n messageCount: messages.length,\r\n messages,\r\n uiMessages,\r\n cwd,\r\n remoteContext: remoteContext ?? undefined,\r\n remoteContextStack: remoteContextStack ?? undefined,\r\n checkpointIndex: checkpointIndex ?? undefined,\r\n };\r\n }\r\n } else {\r\n // Create new chat\r\n chat = {\r\n id: chatId,\r\n title: this.generateTitle(messages),\r\n createdAt: now,\r\n updatedAt: now,\r\n messageCount: messages.length,\r\n messages,\r\n uiMessages,\r\n cwd,\r\n remoteContext: remoteContext ?? undefined,\r\n remoteContextStack: remoteContextStack ?? undefined,\r\n checkpointIndex: checkpointIndex ?? undefined,\r\n };\r\n }\r\n\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n\r\n // Update the metadata index\r\n this.updateIndexEntry(chat);\r\n\r\n return chat;\r\n }\r\n\r\n /**\r\n * Set the backend conversation ID for a chat\r\n * This is used to associate local chats with backend file storage\r\n */\r\n setBackendConversationId(chatId: string, backendConversationId: string): boolean {\r\n const chat = this.loadChat(chatId);\r\n if (!chat) {\r\n return false;\r\n }\r\n\r\n try {\r\n chat.backendConversationId = backendConversationId;\r\n chat.updatedAt = new Date().toISOString();\r\n const chatPath = this.getChatPath(chatId);\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to set backend conversation ID for chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Get the backend conversation ID for a chat (if any)\r\n */\r\n getBackendConversationId(chatId: string): string | undefined {\r\n const chat = this.loadChat(chatId);\r\n return chat?.backendConversationId;\r\n }\r\n\r\n /**\r\n * Load a chat by ID\r\n */\r\n loadChat(chatId: string): LocalChat | null {\r\n const chatPath = this.getChatPath(chatId);\r\n\r\n if (!fs.existsSync(chatPath)) {\r\n return null;\r\n }\r\n\r\n try {\r\n const data = fs.readFileSync(chatPath, 'utf-8');\r\n return JSON.parse(data) as LocalChat;\r\n } catch (error) {\r\n logError(`Failed to load chat ${chatId}`, error as Error);\r\n return null;\r\n }\r\n }\r\n\r\n /**\r\n * List all chats (metadata only, sorted by updatedAt descending)\r\n * Uses the in-memory index to avoid loading full chat files\r\n */\r\n listChats(): LocalChatMeta[] {\r\n // Ensure index is loaded\r\n this.loadIndex();\r\n\r\n // Return sorted metadata from the cache\r\n const chats = Array.from(this.chatIndex.values());\r\n\r\n // Sort by updatedAt descending (most recent first)\r\n chats.sort((a, b) =>\r\n new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()\r\n );\r\n\r\n return chats;\r\n }\r\n\r\n /**\r\n * Delete a chat by ID\r\n */\r\n deleteChat(chatId: string): boolean {\r\n const chatPath = this.getChatPath(chatId);\r\n const checkpointsPath = path.join(os.homedir(), '.centaurus', 'checkpoints', chatId);\r\n\r\n if (!fs.existsSync(chatPath)) {\r\n // Best-effort cleanup for orphaned checkpoint data.\r\n if (fs.existsSync(checkpointsPath)) {\r\n try {\r\n fs.rmSync(checkpointsPath, { recursive: true, force: true });\r\n } catch {\r\n // Ignore cleanup errors for missing chats.\r\n }\r\n }\r\n return false;\r\n }\r\n\r\n try {\r\n fs.unlinkSync(chatPath);\r\n if (fs.existsSync(checkpointsPath)) {\r\n fs.rmSync(checkpointsPath, { recursive: true, force: true });\r\n }\r\n\r\n // Clean up tool output files for this chat\r\n cleanupToolOutputsForChat(chatId);\r\n\r\n // Remove from index\r\n this.chatIndex.delete(chatId);\r\n this.saveIndex();\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to delete chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Import a full chat object (used for restoring from cloud sync)\r\n * Overwrites existing chat with same ID if present\r\n */\r\n importChat(chat: LocalChat): boolean {\r\n if (!chat || !chat.id) {\r\n return false;\r\n }\r\n\r\n try {\r\n const chatPath = this.getChatPath(chat.id);\r\n\r\n // Ensure the chat has required fields\r\n const now = new Date().toISOString();\r\n const importedChat: LocalChat = {\r\n id: chat.id,\r\n title: chat.title || 'Imported Chat',\r\n createdAt: chat.createdAt || now,\r\n updatedAt: now, // Always update the timestamp on import\r\n messageCount: chat.messages?.length || chat.messageCount || 0,\r\n messages: chat.messages || [],\r\n uiMessages: chat.uiMessages,\r\n cwd: chat.cwd,\r\n remoteContext: chat.remoteContext,\r\n remoteContextStack: chat.remoteContextStack,\r\n checkpointIndex: chat.checkpointIndex,\r\n backendConversationId: chat.backendConversationId,\r\n environment: chat.environment,\r\n };\r\n\r\n fs.writeFileSync(chatPath, JSON.stringify(importedChat, null, 2), 'utf-8');\r\n\r\n // Update the index\r\n this.updateIndexEntry(importedChat);\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to import chat ${chat.id}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Rename a chat by ID\r\n */\r\n renameChat(chatId: string, newTitle: string): boolean {\r\n const chat = this.loadChat(chatId);\r\n if (!chat) {\r\n return false;\r\n }\r\n\r\n try {\r\n chat.title = newTitle;\r\n chat.updatedAt = new Date().toISOString();\r\n const chatPath = this.getChatPath(chatId);\r\n fs.writeFileSync(chatPath, JSON.stringify(chat, null, 2), 'utf-8');\r\n\r\n // Update the index\r\n this.updateIndexEntry(chat);\r\n\r\n return true;\r\n } catch (error) {\r\n logError(`Failed to rename chat ${chatId}`, error as Error);\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Get the chats directory path\r\n */\r\n getChatsDir(): string {\r\n return this.chatsDir;\r\n }\r\n}\r\n\r\n// Export singleton instance\r\nexport const localChatStorage = new LocalChatStorage();\r\n"],"mappings":"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","names":[]}
@@ -0,0 +1,141 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import {
5
+ SKILL_TOOL_PRESETS
6
+ } from "../types/skill.js";
7
+ const SKILLS_DIR = path.join(os.homedir(), ".centaurus", "skills");
8
+ function ensureSkillsDir() {
9
+ if (!fs.existsSync(SKILLS_DIR)) {
10
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
11
+ }
12
+ }
13
+ function truncatePreview(content, maxLength = 80) {
14
+ const firstLine = content.split("\n").find((l) => l.trim()) || "";
15
+ return firstLine.length > maxLength ? firstLine.slice(0, maxLength - 1) + "\u2026" : firstLine;
16
+ }
17
+ class SkillStorageService {
18
+ static instance;
19
+ static getInstance() {
20
+ if (!SkillStorageService.instance) {
21
+ SkillStorageService.instance = new SkillStorageService();
22
+ }
23
+ return SkillStorageService.instance;
24
+ }
25
+ /** Sanitize a skill name for use as a filename and slash command */
26
+ sanitizeName(name) {
27
+ return name.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
28
+ }
29
+ getSkillPath(name) {
30
+ return path.join(SKILLS_DIR, `${this.sanitizeName(name)}.json`);
31
+ }
32
+ /** Check if a skill exists */
33
+ exists(name) {
34
+ return fs.existsSync(this.getSkillPath(name));
35
+ }
36
+ /** Load a single skill by name */
37
+ load(name) {
38
+ try {
39
+ const filePath = this.getSkillPath(name);
40
+ if (!fs.existsSync(filePath)) return null;
41
+ const raw = fs.readFileSync(filePath, "utf-8");
42
+ return JSON.parse(raw);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** List all saved skills */
48
+ list() {
49
+ ensureSkillsDir();
50
+ try {
51
+ const files = fs.readdirSync(SKILLS_DIR).filter((f) => f.endsWith(".json"));
52
+ const skills = [];
53
+ for (const file of files) {
54
+ try {
55
+ const filePath = path.join(SKILLS_DIR, file);
56
+ const raw = fs.readFileSync(filePath, "utf-8");
57
+ const skill = JSON.parse(raw);
58
+ skills.push({
59
+ name: skill.name,
60
+ promptPreview: truncatePreview(skill.prompt),
61
+ accessLevel: skill.accessLevel,
62
+ model: skill.model,
63
+ createdAt: skill.createdAt,
64
+ updatedAt: skill.updatedAt
65
+ });
66
+ } catch {
67
+ }
68
+ }
69
+ return skills.sort(
70
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
71
+ );
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+ /** Get all skill names (for slash command detection) */
77
+ getSkillNames() {
78
+ return this.list().map((s) => s.name);
79
+ }
80
+ /** Save (create or update) a skill */
81
+ save(skill, previousName) {
82
+ ensureSkillsDir();
83
+ const normalizedName = this.sanitizeName(skill.name);
84
+ if (!normalizedName) {
85
+ return { success: false, error: "Skill name is required." };
86
+ }
87
+ if (!skill.prompt || !skill.prompt.trim()) {
88
+ return { success: false, error: "Skill prompt is required." };
89
+ }
90
+ const normalizedPreviousName = previousName ? this.sanitizeName(previousName) : void 0;
91
+ const isRename = !!normalizedPreviousName && normalizedPreviousName !== normalizedName;
92
+ if (isRename || !normalizedPreviousName) {
93
+ if (this.exists(normalizedName)) {
94
+ return { success: false, error: `A skill named "${normalizedName}" already exists.` };
95
+ }
96
+ }
97
+ const now = (/* @__PURE__ */ new Date()).toISOString();
98
+ const savedSkill = {
99
+ ...skill,
100
+ name: normalizedName,
101
+ allowedTools: skill.allowedTools.length > 0 ? skill.allowedTools : SKILL_TOOL_PRESETS[skill.accessLevel],
102
+ updatedAt: now,
103
+ createdAt: skill.createdAt || now
104
+ };
105
+ try {
106
+ const filePath = this.getSkillPath(normalizedName);
107
+ fs.writeFileSync(filePath, JSON.stringify(savedSkill, null, 2), "utf-8");
108
+ if (isRename && normalizedPreviousName) {
109
+ const oldPath = this.getSkillPath(normalizedPreviousName);
110
+ if (fs.existsSync(oldPath)) {
111
+ fs.unlinkSync(oldPath);
112
+ }
113
+ }
114
+ return {
115
+ success: true,
116
+ savedName: normalizedName,
117
+ renamedFrom: isRename ? normalizedPreviousName : void 0
118
+ };
119
+ } catch (error) {
120
+ return { success: false, error: error.message || "Failed to save skill." };
121
+ }
122
+ }
123
+ /** Delete a skill */
124
+ delete(name) {
125
+ const filePath = this.getSkillPath(name);
126
+ if (!fs.existsSync(filePath)) {
127
+ return { success: false, error: `Skill "${name}" not found.` };
128
+ }
129
+ try {
130
+ fs.unlinkSync(filePath);
131
+ return { success: true };
132
+ } catch (error) {
133
+ return { success: false, error: error.message };
134
+ }
135
+ }
136
+ }
137
+ const skillStorage = SkillStorageService.getInstance();
138
+ export {
139
+ skillStorage
140
+ };
141
+ //# sourceMappingURL=skill-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/services/skill-storage.ts"],"sourcesContent":["/**\r\n * Skill Storage Service\r\n *\r\n * Manages CRUD operations for user-defined skills stored as JSON files\r\n * in ~/.centaurus/skills/\r\n */\r\n\r\nimport * as fs from 'fs';\r\nimport * as path from 'path';\r\nimport * as os from 'os';\r\nimport {\r\n SavedSkill,\r\n SkillMeta,\r\n SaveSkillResult,\r\n SkillAccessLevel,\r\n SKILL_TOOL_PRESETS,\r\n} from '../types/skill.js';\r\n\r\nconst SKILLS_DIR = path.join(os.homedir(), '.centaurus', 'skills');\r\n\r\nfunction ensureSkillsDir(): void {\r\n if (!fs.existsSync(SKILLS_DIR)) {\r\n fs.mkdirSync(SKILLS_DIR, { recursive: true });\r\n }\r\n}\r\n\r\nfunction truncatePreview(content: string, maxLength = 80): string {\r\n const firstLine = content.split('\\n').find(l => l.trim()) || '';\r\n return firstLine.length > maxLength\r\n ? firstLine.slice(0, maxLength - 1) + '\\u2026'\r\n : firstLine;\r\n}\r\n\r\nclass SkillStorageService {\r\n private static instance: SkillStorageService;\r\n\r\n static getInstance(): SkillStorageService {\r\n if (!SkillStorageService.instance) {\r\n SkillStorageService.instance = new SkillStorageService();\r\n }\r\n return SkillStorageService.instance;\r\n }\r\n\r\n /** Sanitize a skill name for use as a filename and slash command */\r\n sanitizeName(name: string): string {\r\n return name\r\n .toLowerCase()\r\n .replace(/[^a-z0-9_-]/g, '-')\r\n .replace(/-+/g, '-')\r\n .replace(/^-|-$/g, '');\r\n }\r\n\r\n private getSkillPath(name: string): string {\r\n return path.join(SKILLS_DIR, `${this.sanitizeName(name)}.json`);\r\n }\r\n\r\n /** Check if a skill exists */\r\n exists(name: string): boolean {\r\n return fs.existsSync(this.getSkillPath(name));\r\n }\r\n\r\n /** Load a single skill by name */\r\n load(name: string): SavedSkill | null {\r\n try {\r\n const filePath = this.getSkillPath(name);\r\n if (!fs.existsSync(filePath)) return null;\r\n const raw = fs.readFileSync(filePath, 'utf-8');\r\n return JSON.parse(raw) as SavedSkill;\r\n } catch {\r\n return null;\r\n }\r\n }\r\n\r\n /** List all saved skills */\r\n list(): SkillMeta[] {\r\n ensureSkillsDir();\r\n try {\r\n const files = fs.readdirSync(SKILLS_DIR).filter(f => f.endsWith('.json'));\r\n const skills: SkillMeta[] = [];\r\n\r\n for (const file of files) {\r\n try {\r\n const filePath = path.join(SKILLS_DIR, file);\r\n const raw = fs.readFileSync(filePath, 'utf-8');\r\n const skill = JSON.parse(raw) as SavedSkill;\r\n skills.push({\r\n name: skill.name,\r\n promptPreview: truncatePreview(skill.prompt),\r\n accessLevel: skill.accessLevel,\r\n model: skill.model,\r\n createdAt: skill.createdAt,\r\n updatedAt: skill.updatedAt,\r\n });\r\n } catch {\r\n // Skip malformed files\r\n }\r\n }\r\n\r\n return skills.sort((a, b) =>\r\n new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()\r\n );\r\n } catch {\r\n return [];\r\n }\r\n }\r\n\r\n /** Get all skill names (for slash command detection) */\r\n getSkillNames(): string[] {\r\n return this.list().map(s => s.name);\r\n }\r\n\r\n /** Save (create or update) a skill */\r\n save(skill: SavedSkill, previousName?: string): SaveSkillResult {\r\n ensureSkillsDir();\r\n\r\n const normalizedName = this.sanitizeName(skill.name);\r\n if (!normalizedName) {\r\n return { success: false, error: 'Skill name is required.' };\r\n }\r\n if (!skill.prompt || !skill.prompt.trim()) {\r\n return { success: false, error: 'Skill prompt is required.' };\r\n }\r\n\r\n const normalizedPreviousName = previousName ? this.sanitizeName(previousName) : undefined;\r\n const isRename = !!normalizedPreviousName && normalizedPreviousName !== normalizedName;\r\n\r\n // Check for conflicts\r\n if (isRename || !normalizedPreviousName) {\r\n if (this.exists(normalizedName)) {\r\n return { success: false, error: `A skill named \"${normalizedName}\" already exists.` };\r\n }\r\n }\r\n\r\n const now = new Date().toISOString();\r\n const savedSkill: SavedSkill = {\r\n ...skill,\r\n name: normalizedName,\r\n allowedTools: skill.allowedTools.length > 0\r\n ? skill.allowedTools\r\n : SKILL_TOOL_PRESETS[skill.accessLevel],\r\n updatedAt: now,\r\n createdAt: skill.createdAt || now,\r\n };\r\n\r\n try {\r\n const filePath = this.getSkillPath(normalizedName);\r\n fs.writeFileSync(filePath, JSON.stringify(savedSkill, null, 2), 'utf-8');\r\n\r\n // Handle rename\r\n if (isRename && normalizedPreviousName) {\r\n const oldPath = this.getSkillPath(normalizedPreviousName);\r\n if (fs.existsSync(oldPath)) {\r\n fs.unlinkSync(oldPath);\r\n }\r\n }\r\n\r\n return {\r\n success: true,\r\n savedName: normalizedName,\r\n renamedFrom: isRename ? normalizedPreviousName : undefined,\r\n };\r\n } catch (error: any) {\r\n return { success: false, error: error.message || 'Failed to save skill.' };\r\n }\r\n }\r\n\r\n /** Delete a skill */\r\n delete(name: string): { success: boolean; error?: string } {\r\n const filePath = this.getSkillPath(name);\r\n if (!fs.existsSync(filePath)) {\r\n return { success: false, error: `Skill \"${name}\" not found.` };\r\n }\r\n try {\r\n fs.unlinkSync(filePath);\r\n return { success: true };\r\n } catch (error: any) {\r\n return { success: false, error: error.message };\r\n }\r\n }\r\n}\r\n\r\nexport const skillStorage = SkillStorageService.getInstance();\r\n"],"mappings":"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","names":[]}
@@ -8,20 +8,21 @@ import { ContextManager } from "../context/context-manager.js";
8
8
  const MAX_CONCURRENT_SUBAGENTS = 5;
9
9
  const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1e3;
10
10
  const MAX_TURNS_PER_SUBAGENT = 50;
11
- const SIMPLE_MODEL = "gemini-3-flash-preview";
12
- const COMPLEX_MODEL = "gemini-3.1-pro-preview";
11
+ const SIMPLE_MODEL = "minimaxai/minimax-m2.5";
12
+ const COMPLEX_MODEL = "z-ai/glm5";
13
13
  const COMPLEXITY_THRESHOLD = 5;
14
14
  const SUBAGENT_SYSTEM_PROMPT = `You are a sub-agent spawned by a main AI agent to complete a specific task.
15
15
 
16
16
  IMPORTANT RULES:
17
- 1. You have full access to file system tools (read, write, edit, list, grep, find)
17
+ 1. You have access to file system tools (read, write, edit, list, grep, find)
18
18
  2. You have access to command execution tools
19
19
  3. Focus ONLY on the task assigned to you
20
20
  4. Always provide reason_text for every tool call to explain what you're doing
21
21
  5. Call task_complete when you've finished the assigned task
22
22
  6. Be efficient - don't explore unnecessarily, stick to the task
23
23
  7. If you encounter errors, try to resolve them or report clearly what went wrong
24
-
24
+ 8. CRITICAL: Your working directory is {WORKING_DIRECTORY}. All file operations (create, edit, write) MUST be within this directory. Use relative paths from this directory. Do NOT create files in other directories.
25
+ {TOOL_RESTRICTIONS}
25
26
  CONTEXT FROM MAIN AGENT:
26
27
  Working Directory: {WORKING_DIRECTORY}
27
28
 
@@ -81,7 +82,16 @@ class SubAgentManagerClass {
81
82
  * Generate system prompt for sub-agent
82
83
  */
83
84
  generateSystemPrompt(config) {
84
- return SUBAGENT_SYSTEM_PROMPT.replace("{WORKING_DIRECTORY}", config.workingDirectory).replace("{CONTEXT}", config.context || "No additional context provided.").replace("{PROMPT}", config.prompt);
85
+ let toolRestrictions = "";
86
+ if (config.allowedTools && config.allowedTools.length > 0) {
87
+ toolRestrictions = `
88
+ TOOL RESTRICTIONS (CRITICAL):
89
+ You are ONLY allowed to use the following tools: ${config.allowedTools.join(", ")}, task_complete
90
+ If you need a tool that is not in this list, DO NOT attempt to call it \u2014 it will be rejected.
91
+ You must complete your task using only the tools listed above.
92
+ `;
93
+ }
94
+ return SUBAGENT_SYSTEM_PROMPT.replace("{WORKING_DIRECTORY}", config.workingDirectory).replace("{CONTEXT}", config.context || "No additional context provided.").replace("{PROMPT}", config.prompt).replace("{TOOL_RESTRICTIONS}", toolRestrictions);
85
95
  }
86
96
  /**
87
97
  * Spawn a new sub-agent
@@ -112,7 +122,7 @@ class SubAgentManagerClass {
112
122
  };
113
123
  }
114
124
  const agentId = `subagent-${randomUUID().substring(0, 8)}`;
115
- const model = this.selectModel(config.complexity);
125
+ const model = config.modelOverride || this.selectModel(config.complexity);
116
126
  const subAgent = {
117
127
  id: agentId,
118
128
  status: "pending",
@@ -127,7 +137,8 @@ class SubAgentManagerClass {
127
137
  fileOperations: [],
128
138
  toolHistory: [],
129
139
  isRead: false,
130
- abortController: new AbortController()
140
+ abortController: new AbortController(),
141
+ allowedTools: config.allowedTools
131
142
  };
132
143
  this.subAgents.set(agentId, subAgent);
133
144
  quickLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] [SubAgentManager] Sub-agent ${agentId} added to map, calling notifyCountChange\\n`);
@@ -170,7 +181,11 @@ class SubAgentManagerClass {
170
181
  role: "user",
171
182
  content: systemPrompt
172
183
  });
173
- const tools = this.toolRegistry.getSchemas();
184
+ let tools = this.toolRegistry.getSchemas();
185
+ if (config.allowedTools && config.allowedTools.length > 0) {
186
+ const allowedSet = /* @__PURE__ */ new Set([...config.allowedTools, "task_complete"]);
187
+ tools = tools.filter((t) => allowedSet.has(t.name));
188
+ }
174
189
  const effectiveContextManager = config.parentContextManager || new ContextManager(config.workingDirectory, process.platform);
175
190
  const context = {
176
191
  cwd: config.workingDirectory,
@@ -270,6 +285,28 @@ class SubAgentManagerClass {
270
285
  subAgent.endTime = /* @__PURE__ */ new Date();
271
286
  break;
272
287
  }
288
+ if (subAgent.allowedTools && subAgent.allowedTools.length > 0) {
289
+ const allowedSet = /* @__PURE__ */ new Set([...subAgent.allowedTools, "task_complete"]);
290
+ if (!allowedSet.has(toolCall.name)) {
291
+ quickLog(`[SubAgent ${subAgent.id}] REJECTED tool "${toolCall.name}" \u2014 not in allowed list: ${subAgent.allowedTools.join(", ")}`);
292
+ const rejectionMsg = `Tool "${toolCall.name}" is not allowed for this sub-agent. Allowed tools: ${subAgent.allowedTools.join(", ")}, task_complete. Please use only allowed tools.`;
293
+ const rejectedExecution = {
294
+ toolName: toolCall.name,
295
+ arguments: toolCall.arguments,
296
+ reasonText: toolCall.arguments?.reason_text,
297
+ result: rejectionMsg,
298
+ success: false,
299
+ timestamp: /* @__PURE__ */ new Date()
300
+ };
301
+ subAgent.toolHistory.push(rejectedExecution);
302
+ subAgent.conversationHistory.push({
303
+ role: "tool",
304
+ content: `ERROR: ${rejectionMsg}`,
305
+ tool_call_id: toolCall.id
306
+ });
307
+ continue;
308
+ }
309
+ }
273
310
  const reasonText = toolCall.arguments?.reason_text;
274
311
  const toolExecution = {
275
312
  toolName: toolCall.name,
@@ -314,6 +351,10 @@ class SubAgentManagerClass {
314
351
  } finally {
315
352
  clearTimeout(timeoutId);
316
353
  this.notifyCountChange();
354
+ if (subAgent.status !== "running") {
355
+ subAgent.conversationHistory = [];
356
+ }
357
+ this.cleanup();
317
358
  }
318
359
  quickLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] [SubAgent] ${agentId} finished with status: ${subAgent.status}
319
360
  `);