@bahulam/code 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +80 -0
  2. package/package.json +49 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +222 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +306 -0
  136. package/src/commands/agent.mjs +220 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +200 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/settings-loader.mjs +45 -0
  143. package/src/config/settings.mjs +132 -0
  144. package/src/context/ast-parser.mjs +298 -0
  145. package/src/context/bm25.mjs +85 -0
  146. package/src/context/retriever.mjs +308 -0
  147. package/src/context/skeleton.mjs +134 -0
  148. package/src/context/symbol-indexer.mjs +375 -0
  149. package/src/core/agent-history.mjs +111 -0
  150. package/src/core/agent-loop.mjs +486 -0
  151. package/src/core/approval-log.mjs +104 -0
  152. package/src/core/approval.mjs +476 -0
  153. package/src/core/attachments.mjs +380 -0
  154. package/src/core/backend-url.mjs +55 -0
  155. package/src/core/cache-control.mjs +92 -0
  156. package/src/core/cache.mjs +105 -0
  157. package/src/core/callback-client.mjs +180 -0
  158. package/src/core/checkpoints.mjs +142 -0
  159. package/src/core/compact-history.mjs +127 -0
  160. package/src/core/context-envelope.mjs +54 -0
  161. package/src/core/context-manager.mjs +198 -0
  162. package/src/core/error-guidance.mjs +311 -0
  163. package/src/core/file-diff.mjs +217 -0
  164. package/src/core/headless.mjs +448 -0
  165. package/src/core/hooks-manager.mjs +87 -0
  166. package/src/core/jsonl-writer.mjs +449 -0
  167. package/src/core/local-agent.mjs +537 -0
  168. package/src/core/local-store.mjs +836 -0
  169. package/src/core/mode-selector.mjs +51 -0
  170. package/src/core/output-filter.mjs +177 -0
  171. package/src/core/paths.mjs +190 -0
  172. package/src/core/policy-resolver.mjs +156 -0
  173. package/src/core/pricing.mjs +336 -0
  174. package/src/core/project-artifacts.mjs +39 -0
  175. package/src/core/project-context-loader.mjs +139 -0
  176. package/src/core/providers.mjs +219 -0
  177. package/src/core/rate-limit-display.mjs +121 -0
  178. package/src/core/rate-limiter.mjs +119 -0
  179. package/src/core/resume-mode.mjs +192 -0
  180. package/src/core/risk-tier.mjs +337 -0
  181. package/src/core/safety.mjs +203 -0
  182. package/src/core/scheduler.mjs +173 -0
  183. package/src/core/session-manager.mjs +360 -0
  184. package/src/core/session.mjs +143 -0
  185. package/src/core/settings-sync.mjs +85 -0
  186. package/src/core/stagnation.mjs +57 -0
  187. package/src/core/stream-client.mjs +829 -0
  188. package/src/core/streaming.mjs +182 -0
  189. package/src/core/system-prompt.mjs +140 -0
  190. package/src/core/tasks.mjs +196 -0
  191. package/src/core/tool-executor.mjs +1950 -0
  192. package/src/core/trust.mjs +158 -0
  193. package/src/core/work-scope.mjs +248 -0
  194. package/src/hooks/engine.mjs +162 -0
  195. package/src/index.mjs +426 -0
  196. package/src/mcp/client.mjs +253 -0
  197. package/src/mcp/transport-shttp.mjs +130 -0
  198. package/src/mcp/transport-sse.mjs +131 -0
  199. package/src/mcp/transport-ws.mjs +134 -0
  200. package/src/onboarding/preflight.mjs +360 -0
  201. package/src/permissions/checker.mjs +57 -0
  202. package/src/permissions/command-classifier.mjs +652 -0
  203. package/src/permissions/injection-check.mjs +60 -0
  204. package/src/permissions/path-check.mjs +102 -0
  205. package/src/permissions/prompt.mjs +73 -0
  206. package/src/permissions/sandbox.mjs +112 -0
  207. package/src/plugins/loader.mjs +138 -0
  208. package/src/skills/installer.mjs +188 -0
  209. package/src/skills/loader.mjs +252 -0
  210. package/src/skills/runner.mjs +55 -0
  211. package/src/state/orbit.mjs +263 -0
  212. package/src/state/verbosity.mjs +99 -0
  213. package/src/telemetry/index.mjs +96 -0
  214. package/src/terminal/agents.mjs +177 -0
  215. package/src/terminal/analytics.mjs +292 -0
  216. package/src/terminal/ansi.mjs +695 -0
  217. package/src/terminal/init.mjs +145 -0
  218. package/src/terminal/main.mjs +269 -0
  219. package/src/terminal/repl-explore.mjs +35 -0
  220. package/src/terminal/repl-format.mjs +257 -0
  221. package/src/terminal/repl-render.mjs +561 -0
  222. package/src/terminal/repl-resume.mjs +625 -0
  223. package/src/terminal/repl-state.mjs +103 -0
  224. package/src/terminal/repl-utils.mjs +34 -0
  225. package/src/terminal/repl.mjs +3832 -0
  226. package/src/terminal/skills.mjs +54 -0
  227. package/src/terminal/tool-display.mjs +240 -0
  228. package/src/tools/agent.mjs +137 -0
  229. package/src/tools/ask-user.mjs +61 -0
  230. package/src/tools/bash.mjs +231 -0
  231. package/src/tools/cron-create.mjs +120 -0
  232. package/src/tools/cron-delete.mjs +49 -0
  233. package/src/tools/cron-list.mjs +37 -0
  234. package/src/tools/edit.mjs +82 -0
  235. package/src/tools/enter-worktree.mjs +69 -0
  236. package/src/tools/exit-worktree.mjs +57 -0
  237. package/src/tools/glob.mjs +117 -0
  238. package/src/tools/grep.mjs +129 -0
  239. package/src/tools/lint.mjs +71 -0
  240. package/src/tools/ls.mjs +58 -0
  241. package/src/tools/lsp.mjs +115 -0
  242. package/src/tools/multi-edit.mjs +94 -0
  243. package/src/tools/notebook-edit.mjs +96 -0
  244. package/src/tools/project-overview.mjs +641 -0
  245. package/src/tools/read-mcp-resource.mjs +57 -0
  246. package/src/tools/read.mjs +138 -0
  247. package/src/tools/registry.mjs +116 -0
  248. package/src/tools/remote-trigger.mjs +84 -0
  249. package/src/tools/send-message.mjs +64 -0
  250. package/src/tools/skill.mjs +52 -0
  251. package/src/tools/test-runner.mjs +49 -0
  252. package/src/tools/todo-write.mjs +68 -0
  253. package/src/tools/tool-search.mjs +77 -0
  254. package/src/tools/web-fetch.mjs +65 -0
  255. package/src/tools/web-search.mjs +89 -0
  256. package/src/tools/write.mjs +55 -0
  257. package/src/ui/approval.mjs +263 -0
  258. package/src/ui/banner.mjs +235 -0
  259. package/src/ui/commands.mjs +537 -0
  260. package/src/ui/formatter.mjs +409 -0
  261. package/src/ui/icons.mjs +164 -0
  262. package/src/ui/input-dock.mjs +444 -0
  263. package/src/ui/markdown.mjs +278 -0
  264. package/src/ui/mission-report.mjs +296 -0
  265. package/src/ui/palette.mjs +189 -0
  266. package/src/ui/slash-commands.mjs +245 -0
  267. package/src/ui/spinner.mjs +116 -0
  268. package/src/ui/sub-agent.mjs +152 -0
  269. package/src/ui/term.mjs +159 -0
  270. package/src/ui/text-layout.mjs +127 -0
  271. package/src/ui/tool-card.mjs +463 -0
  272. package/src/ui/tool-details.mjs +312 -0
  273. package/src/ui/transcript-block.mjs +21 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Scheduler — cron-based task scheduling.
3
+ *
4
+ * Stores scheduled tasks in ~/.claude/scheduled_tasks.json.
5
+ * Tasks have a cron expression, prompt, and optional environment.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import crypto from 'crypto';
12
+
13
+ export class Scheduler {
14
+ /**
15
+ * @param {string} [tasksFile] - path to scheduled tasks JSON
16
+ */
17
+ constructor(tasksFile) {
18
+ this.tasksFile = tasksFile ||
19
+ path.join(os.homedir(), '.claude', 'scheduled_tasks.json');
20
+ this.timers = new Map();
21
+ }
22
+
23
+ /**
24
+ * Create a new scheduled task.
25
+ * @param {string} cron - cron expression or interval shorthand (e.g., "5m", "1h")
26
+ * @param {string} prompt - prompt to execute
27
+ * @param {object} [options]
28
+ * @param {string} [options.name] - human-readable name
29
+ * @param {string} [options.model] - model to use
30
+ * @param {boolean} [options.enabled] - whether task is enabled (default: true)
31
+ * @returns {object} created task
32
+ */
33
+ async create(cron, prompt, options = {}) {
34
+ const tasks = this._loadTasks();
35
+ const task = {
36
+ id: `task_${crypto.randomBytes(4).toString('hex')}`,
37
+ name: options.name || `Task ${tasks.length + 1}`,
38
+ cron,
39
+ prompt,
40
+ model: options.model || null,
41
+ enabled: options.enabled !== false,
42
+ createdAt: new Date().toISOString(),
43
+ lastRun: null,
44
+ runCount: 0,
45
+ intervalMs: parseCronInterval(cron),
46
+ };
47
+
48
+ tasks.push(task);
49
+ this._saveTasks(tasks);
50
+ return task;
51
+ }
52
+
53
+ /**
54
+ * Delete a scheduled task by ID.
55
+ * @param {string} taskId
56
+ * @returns {boolean} true if deleted
57
+ */
58
+ async delete(taskId) {
59
+ const tasks = this._loadTasks();
60
+ const idx = tasks.findIndex(t => t.id === taskId);
61
+ if (idx === -1) return false;
62
+
63
+ tasks.splice(idx, 1);
64
+ this._saveTasks(tasks);
65
+
66
+ // Clear timer if running
67
+ if (this.timers.has(taskId)) {
68
+ clearInterval(this.timers.get(taskId));
69
+ this.timers.delete(taskId);
70
+ }
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * List all scheduled tasks.
77
+ * @returns {Array<object>}
78
+ */
79
+ async list() {
80
+ return this._loadTasks();
81
+ }
82
+
83
+ /**
84
+ * Check and return tasks that are due to run.
85
+ * @returns {Array<object>} tasks that are due
86
+ */
87
+ async runDue() {
88
+ const tasks = this._loadTasks();
89
+ const due = [];
90
+ const now = Date.now();
91
+
92
+ for (const task of tasks) {
93
+ if (!task.enabled) continue;
94
+ if (!task.intervalMs) continue;
95
+
96
+ const lastRun = task.lastRun ? new Date(task.lastRun).getTime() : 0;
97
+ if (now - lastRun >= task.intervalMs) {
98
+ task.lastRun = new Date().toISOString();
99
+ task.runCount = (task.runCount || 0) + 1;
100
+ due.push(task);
101
+ }
102
+ }
103
+
104
+ if (due.length > 0) {
105
+ this._saveTasks(tasks);
106
+ }
107
+
108
+ return due;
109
+ }
110
+
111
+ /**
112
+ * Enable or disable a task.
113
+ * @param {string} taskId
114
+ * @param {boolean} enabled
115
+ * @returns {boolean}
116
+ */
117
+ async setEnabled(taskId, enabled) {
118
+ const tasks = this._loadTasks();
119
+ const task = tasks.find(t => t.id === taskId);
120
+ if (!task) return false;
121
+
122
+ task.enabled = enabled;
123
+ this._saveTasks(tasks);
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Load tasks from disk.
129
+ * @returns {Array<object>}
130
+ */
131
+ _loadTasks() {
132
+ try {
133
+ const raw = fs.readFileSync(this.tasksFile, 'utf-8');
134
+ return JSON.parse(raw);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Save tasks to disk.
142
+ * @param {Array<object>} tasks
143
+ */
144
+ _saveTasks(tasks) {
145
+ try {
146
+ const dir = path.dirname(this.tasksFile);
147
+ fs.mkdirSync(dir, { recursive: true });
148
+ fs.writeFileSync(this.tasksFile, JSON.stringify(tasks, null, 2));
149
+ } catch {
150
+ // Best effort
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse a cron expression or interval shorthand into milliseconds.
157
+ * @param {string} cron
158
+ * @returns {number}
159
+ */
160
+ export function parseCronInterval(cron) {
161
+ const match = cron.match(/^(\d+)(s|m|h|d)$/);
162
+ if (match) {
163
+ const value = parseInt(match[1], 10);
164
+ const units = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
165
+ return value * (units[match[2]] || 60000);
166
+ }
167
+
168
+ const num = parseInt(cron, 10);
169
+ if (!isNaN(num)) return num * 60000;
170
+
171
+ // Full cron expression — default to 5 minutes
172
+ return 300000;
173
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Session Manager — persist session state, history, and conversation messages.
3
+ *
4
+ * All data lives under ~/.bahulam/:
5
+ * ~/.bahulam/
6
+ * projects/{hash}/
7
+ * state.json — current session metadata
8
+ * sessions/ — session metadata archive
9
+ * conversations/
10
+ * {sessionId}.jsonl — conversation messages (JSONL)
11
+ * Line 1: {"type":"header","instruction":"...","project":"..."}
12
+ * Line 2+: {"role":"user","content":"...","timestamp":"..."}
13
+ *
14
+ * Zero per-project files. /resume works from anywhere.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import {
20
+ projectDir as getProjectDir,
21
+ statePath as getStatePath,
22
+ sessionsDir as getSessionsDir,
23
+ conversationsDir as getConversationsDir,
24
+ conversationPath as getConversationPath,
25
+ } from './paths.mjs';
26
+
27
+ const MAX_SESSIONS = 100;
28
+
29
+ export class SessionManager {
30
+ constructor(projectPath = process.cwd()) {
31
+ this.projectPath = projectPath;
32
+ this.projectKeplerDir = getProjectDir(projectPath);
33
+ this.statePath = getStatePath(projectPath);
34
+ this.sessionsDir = getSessionsDir(projectPath);
35
+ this.conversationsDir = getConversationsDir();
36
+ this.currentState = null;
37
+ }
38
+
39
+ _ensureDirs() {
40
+ fs.mkdirSync(this.projectKeplerDir, { recursive: true });
41
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
42
+ fs.mkdirSync(this.conversationsDir, { recursive: true });
43
+ }
44
+
45
+ /** Start tracking a new session. */
46
+ start(instruction) {
47
+ this._ensureDirs();
48
+ this.currentState = {
49
+ instruction,
50
+ started_at: new Date().toISOString(),
51
+ status: 'running',
52
+ task_id: null,
53
+ job_id: null,
54
+ session_id: null,
55
+ tool_count: 0,
56
+ turn_count: 0,
57
+ events: [],
58
+ };
59
+ this._writeState();
60
+ }
61
+
62
+ /** Update state from session_info event. */
63
+ setSessionInfo(data) {
64
+ if (!this.currentState) return;
65
+ this.currentState.task_id = data.task_id || this.currentState.task_id;
66
+ this.currentState.job_id = data.job_id || this.currentState.job_id;
67
+ this.currentState.session_id = data.session_id || this.currentState.session_id;
68
+ this._writeState();
69
+ }
70
+
71
+ /** Record a tool call. */
72
+ recordToolCall(toolName) {
73
+ if (!this.currentState) return;
74
+ this.currentState.tool_count++;
75
+ }
76
+
77
+ /** Mark session as complete. */
78
+ complete(summary) {
79
+ if (!this.currentState) return;
80
+ this.currentState.status = 'completed';
81
+ this.currentState.completed_at = new Date().toISOString();
82
+ this.currentState.summary = summary;
83
+ const duration = (Date.now() - new Date(this.currentState.started_at).getTime()) / 1000;
84
+ this.currentState.duration_s = Math.round(duration * 10) / 10;
85
+ this._writeState();
86
+ this._saveToHistory();
87
+ }
88
+
89
+ /** Mark session as failed. */
90
+ fail(errorMessage) {
91
+ if (!this.currentState) return;
92
+ this.currentState.status = 'failed';
93
+ this.currentState.error = errorMessage;
94
+ this.currentState.completed_at = new Date().toISOString();
95
+ this._writeState();
96
+ this._saveToHistory();
97
+ }
98
+
99
+ /** Mark session as cancelled. */
100
+ cancel() {
101
+ if (!this.currentState) return;
102
+ this.currentState.status = 'cancelled';
103
+ this.currentState.completed_at = new Date().toISOString();
104
+ this._writeState();
105
+ this._saveToHistory();
106
+ }
107
+
108
+ /** Mark session as paused. */
109
+ pause() {
110
+ if (!this.currentState) return;
111
+ this.currentState.status = 'paused';
112
+ this._writeState();
113
+ }
114
+
115
+ /** Load saved state for resume. */
116
+ loadState() {
117
+ try {
118
+ if (fs.existsSync(this.statePath)) {
119
+ return JSON.parse(fs.readFileSync(this.statePath, 'utf-8'));
120
+ }
121
+ } catch { /* corrupt file */ }
122
+ return null;
123
+ }
124
+
125
+ /** List recent sessions. */
126
+ listSessions(limit = 20) {
127
+ if (!fs.existsSync(this.sessionsDir)) return [];
128
+ return fs.readdirSync(this.sessionsDir)
129
+ .filter(f => f.endsWith('.json'))
130
+ .sort()
131
+ .reverse()
132
+ .slice(0, limit)
133
+ .map(file => {
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(path.join(this.sessionsDir, file), 'utf-8'));
136
+ return { file, ...data };
137
+ } catch {
138
+ return { file, status: 'unreadable' };
139
+ }
140
+ });
141
+ }
142
+
143
+ // ── Conversation Persistence ──
144
+
145
+ /**
146
+ * Get the JSONL file path for a session's conversation.
147
+ * @param {string} [sessionId] - defaults to current session
148
+ */
149
+ _conversationPath(sessionId) {
150
+ const id = sessionId || this.currentState?.session_id || this.currentState?.task_id || 'unknown';
151
+ return getConversationPath(id);
152
+ }
153
+
154
+ /**
155
+ * Append a message to the conversation JSONL file.
156
+ * On first write, prepends a header line with session metadata.
157
+ * @param {string} role - 'user' or 'assistant'
158
+ * @param {string} content - message content
159
+ * @param {object} [meta] - optional metadata (tokens, cost, tools)
160
+ */
161
+ saveMessage(role, content, meta = {}) {
162
+ if (!this.currentState) return;
163
+ this._ensureDirs();
164
+
165
+ const convPath = this._conversationPath();
166
+
167
+ // Write header line on first message (so listResumable can read metadata)
168
+ if (!fs.existsSync(convPath)) {
169
+ const header = {
170
+ type: 'header',
171
+ instruction: this.currentState.instruction || '',
172
+ project: this.projectPath,
173
+ project_name: path.basename(this.projectPath),
174
+ started_at: this.currentState.started_at || new Date().toISOString(),
175
+ session_id: this.currentState.session_id || '',
176
+ };
177
+ fs.appendFileSync(convPath, JSON.stringify(header) + '\n');
178
+ }
179
+
180
+ const entry = {
181
+ role,
182
+ content,
183
+ timestamp: new Date().toISOString(),
184
+ turn: this.currentState.turn_count || 0,
185
+ ...meta,
186
+ };
187
+
188
+ fs.appendFileSync(convPath, JSON.stringify(entry) + '\n');
189
+ }
190
+
191
+ /**
192
+ * Read the header line from a conversation JSONL file.
193
+ * @param {string} filePath
194
+ * @returns {object|null}
195
+ */
196
+ _readHeader(filePath) {
197
+ try {
198
+ const first = fs.readFileSync(filePath, 'utf-8').split('\n')[0];
199
+ if (!first) return null;
200
+ const parsed = JSON.parse(first);
201
+ return parsed.type === 'header' ? parsed : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Load all messages from a session's conversation file (skips header).
209
+ * @param {string} sessionId - session to load
210
+ * @returns {{ role: string, content: string }[]}
211
+ */
212
+ loadMessages(sessionId) {
213
+ return this.loadConversation(sessionId).messages;
214
+ }
215
+
216
+ /**
217
+ * Load the header and all messages from a session conversation file.
218
+ * @param {string} sessionId
219
+ * @returns {{ header: object|null, messages: { role: string, content: string }[] }}
220
+ */
221
+ loadConversation(sessionId) {
222
+ const filePath = this._conversationPath(sessionId);
223
+ if (!fs.existsSync(filePath)) return { header: null, messages: [] };
224
+
225
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
226
+ const entries = lines
227
+ .map(line => {
228
+ try { return JSON.parse(line); } catch { return null; }
229
+ })
230
+ .filter(Boolean);
231
+ const header = entries.find(entry => entry.type === 'header') || null;
232
+ const messages = entries
233
+ .filter(entry => entry.role) // skip header (type=header, no role)
234
+ .map(entry => ({ role: entry.role, content: entry.content }));
235
+ return { header, messages };
236
+ }
237
+
238
+ /**
239
+ * Activate an existing conversation as the current session so additional
240
+ * turns append to the same JSONL file.
241
+ * @param {string} sessionId
242
+ * @param {object|null} header
243
+ * @param {{ role: string, content: string }[]} messages
244
+ */
245
+ activateSession(sessionId, header = null, messages = []) {
246
+ this._ensureDirs();
247
+ this.currentState = {
248
+ instruction: header?.instruction || messages.find(m => m.role === 'user')?.content || '',
249
+ started_at: header?.started_at || new Date().toISOString(),
250
+ status: 'running',
251
+ task_id: null,
252
+ job_id: null,
253
+ session_id: sessionId,
254
+ tool_count: 0,
255
+ turn_count: messages.filter(m => m.role === 'user').length,
256
+ events: [],
257
+ resumed_at: new Date().toISOString(),
258
+ };
259
+ this._writeState();
260
+ return this.currentState;
261
+ }
262
+
263
+ /**
264
+ * Get the most recent session that has a conversation file.
265
+ * @returns {{ sessionId: string, instruction: string, startedAt: string }|null}
266
+ */
267
+ getLastSession() {
268
+ if (!fs.existsSync(this.conversationsDir)) return null;
269
+
270
+ // Sort by file modification time (most recent first)
271
+ const files = fs.readdirSync(this.conversationsDir)
272
+ .filter(f => f.endsWith('.jsonl'))
273
+ .map(f => ({
274
+ name: f,
275
+ mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
276
+ }))
277
+ .sort((a, b) => b.mtime - a.mtime);
278
+
279
+ if (files.length === 0) return null;
280
+
281
+ const file = files[0].name;
282
+ const sessionId = file.replace('.jsonl', '');
283
+ const header = this._readHeader(path.join(this.conversationsDir, file));
284
+
285
+ return {
286
+ sessionId,
287
+ instruction: header?.instruction || '',
288
+ startedAt: header?.started_at || '',
289
+ project: header?.project_name || '',
290
+ projectPath: header?.project || '',
291
+ };
292
+ }
293
+
294
+ /**
295
+ * List sessions that have conversation history (resumable).
296
+ * Reads metadata from JSONL header line — no cross-referencing needed.
297
+ * @param {number} [limit=Infinity]
298
+ * @returns {Array<{ sessionId, instruction, startedAt, updatedAt, project, messageCount }>}
299
+ */
300
+ listResumable(limit = Infinity) {
301
+ if (!fs.existsSync(this.conversationsDir)) return [];
302
+
303
+ // Sort by latest activity (conversation file mtime) so resumed sessions
304
+ // move back to the top. The UI displays this same timestamp.
305
+ const files = fs.readdirSync(this.conversationsDir)
306
+ .filter(f => f.endsWith('.jsonl'))
307
+ .map(f => ({
308
+ name: f,
309
+ mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
310
+ }))
311
+ .sort((a, b) => b.mtime - a.mtime);
312
+
313
+ const limited = Number.isFinite(limit) ? files.slice(0, limit) : files;
314
+
315
+ return limited.map(({ name, mtime }) => {
316
+ const sessionId = name.replace('.jsonl', '');
317
+ const convPath = path.join(this.conversationsDir, name);
318
+ const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
319
+ const header = this._readHeader(convPath);
320
+
321
+ // Message count = total lines minus header
322
+ const messageCount = header ? lines.length - 1 : lines.length;
323
+
324
+ return {
325
+ sessionId,
326
+ instruction: header?.instruction || '(no instruction)',
327
+ startedAt: header?.started_at || '',
328
+ updatedAt: new Date(mtime).toISOString(),
329
+ project: header?.project_name || '',
330
+ projectPath: header?.project || '',
331
+ messageCount,
332
+ };
333
+ });
334
+ }
335
+
336
+ _writeState() {
337
+ this._ensureDirs();
338
+ fs.writeFileSync(this.statePath, JSON.stringify(this.currentState, null, 2));
339
+ }
340
+
341
+ _saveToHistory() {
342
+ this._ensureDirs();
343
+ const filename = this.currentState.started_at.replace(/[:.]/g, '-') + '.json';
344
+ fs.writeFileSync(
345
+ path.join(this.sessionsDir, filename),
346
+ JSON.stringify(this.currentState, null, 2)
347
+ );
348
+ this._pruneHistory();
349
+ }
350
+
351
+ _pruneHistory() {
352
+ const files = fs.readdirSync(this.sessionsDir)
353
+ .filter(f => f.endsWith('.json'))
354
+ .sort();
355
+ while (files.length > MAX_SESSIONS) {
356
+ const oldest = files.shift();
357
+ try { fs.unlinkSync(path.join(this.sessionsDir, oldest)); } catch {}
358
+ }
359
+ }
360
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Session Manager — save, resume, and teleport sessions.
3
+ *
4
+ * Sessions are stored at ~/.claude/projects/<hash>/session.json
5
+ * and contain conversation history, token usage, and metadata.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import crypto from 'crypto';
12
+
13
+ export class SessionManager {
14
+ constructor(projectDir = process.cwd()) {
15
+ this.projectDir = projectDir;
16
+ this.sessionId = `sess_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
17
+ this.conversationId = null;
18
+ this.startedAt = new Date().toISOString();
19
+ }
20
+
21
+ /**
22
+ * Get the session storage directory for the current project.
23
+ */
24
+ getSessionDir() {
25
+ const hash = crypto.createHash('sha256')
26
+ .update(this.projectDir)
27
+ .digest('hex')
28
+ .slice(0, 16);
29
+
30
+ return path.join(os.homedir(), '.claude', 'projects', hash);
31
+ }
32
+
33
+ /**
34
+ * Save the current session state.
35
+ * @param {object} state - agent loop state to save
36
+ */
37
+ save(state) {
38
+ const dir = this.getSessionDir();
39
+ fs.mkdirSync(dir, { recursive: true });
40
+
41
+ const session = {
42
+ id: this.sessionId,
43
+ conversationId: this.conversationId,
44
+ projectDir: this.projectDir,
45
+ startedAt: this.startedAt,
46
+ savedAt: new Date().toISOString(),
47
+ model: state.model,
48
+ turnCount: state.turnCount,
49
+ tokenUsage: state.tokenUsage,
50
+ messages: state.messages,
51
+ systemPrompt: state.systemPrompt,
52
+ };
53
+
54
+ const filePath = path.join(dir, 'session.json');
55
+ fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
56
+ return filePath;
57
+ }
58
+
59
+ /**
60
+ * Resume a saved session.
61
+ * @param {object} state - agent loop state to restore into
62
+ * @returns {boolean} true if session was restored
63
+ */
64
+ resume(state) {
65
+ const dir = this.getSessionDir();
66
+ const filePath = path.join(dir, 'session.json');
67
+
68
+ try {
69
+ const raw = fs.readFileSync(filePath, 'utf-8');
70
+ const session = JSON.parse(raw);
71
+
72
+ state.messages = session.messages || [];
73
+ state.turnCount = session.turnCount || 0;
74
+ state.tokenUsage = session.tokenUsage || { input: 0, output: 0 };
75
+ if (session.model) state.model = session.model;
76
+
77
+ this.sessionId = session.id;
78
+ this.conversationId = session.conversationId;
79
+ this.startedAt = session.startedAt;
80
+
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Export session for teleport (transfer between machines).
89
+ * @param {object} state - agent loop state
90
+ * @returns {string} base64-encoded session data
91
+ */
92
+ exportForTeleport(state) {
93
+ const session = {
94
+ id: this.sessionId,
95
+ projectDir: this.projectDir,
96
+ messages: state.messages,
97
+ turnCount: state.turnCount,
98
+ model: state.model,
99
+ exportedAt: new Date().toISOString(),
100
+ };
101
+
102
+ return Buffer.from(JSON.stringify(session)).toString('base64');
103
+ }
104
+
105
+ /**
106
+ * Import a teleported session.
107
+ * @param {string} data - base64-encoded session data
108
+ * @param {object} state - agent loop state to restore into
109
+ */
110
+ importFromTeleport(data, state) {
111
+ const session = JSON.parse(Buffer.from(data, 'base64').toString());
112
+ state.messages = session.messages || [];
113
+ state.turnCount = session.turnCount || 0;
114
+ if (session.model) state.model = session.model;
115
+ this.sessionId = `sess_teleport_${Date.now()}`;
116
+ }
117
+
118
+ /**
119
+ * Get session info.
120
+ */
121
+ info() {
122
+ return {
123
+ id: this.sessionId,
124
+ conversationId: this.conversationId,
125
+ projectDir: this.projectDir,
126
+ startedAt: this.startedAt,
127
+ sessionDir: this.getSessionDir(),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Delete the saved session.
133
+ */
134
+ clear() {
135
+ const filePath = path.join(this.getSessionDir(), 'session.json');
136
+ try {
137
+ fs.unlinkSync(filePath);
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+ }