codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Daemon entry point
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as net from "node:net";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { EventBusAdapter, SQLiteEventBus } from "@codepiper/core";
10
+ import { createServer, type DaemonServer } from "./api/server";
11
+ import { Database } from "./db/db";
12
+ import { SessionManager } from "./sessions/sessionManager";
13
+
14
+ const DEFAULT_SOCKET_PATH = "/tmp/codepiper.sock";
15
+ const DEFAULT_DB_PATH = path.join(os.homedir(), ".codepiper", "codepiper.db");
16
+ const DEFAULT_WEB_DIR = path.resolve(__dirname, "../../web/dist");
17
+ const DETACHED_STARTUP_GRACE_MS = 1500;
18
+
19
+ const PID_FILE = path.join(os.homedir(), ".codepiper", "daemon.pid");
20
+
21
+ function parseArgs(argv: string[]) {
22
+ const args = argv.slice(2);
23
+ let web = false;
24
+ let port: number | undefined;
25
+ let webDir: string | undefined;
26
+ let socketPath: string | undefined;
27
+ let detach = false;
28
+
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ const next = args[i + 1];
32
+
33
+ if (arg === "--web") {
34
+ web = true;
35
+ } else if (arg === "--port" && next) {
36
+ port = Number.parseInt(next, 10);
37
+ i++;
38
+ } else if (arg === "--web-dir" && next) {
39
+ webDir = next;
40
+ i++;
41
+ } else if (arg === "--socket" && next) {
42
+ socketPath = next;
43
+ i++;
44
+ } else if (arg === "--detach") {
45
+ detach = true;
46
+ }
47
+ }
48
+
49
+ return { web, port, webDir, socketPath, detach };
50
+ }
51
+
52
+ function cleanupPidFile(expectedPid?: number) {
53
+ try {
54
+ if (!fs.existsSync(PID_FILE)) {
55
+ return;
56
+ }
57
+
58
+ if (expectedPid !== undefined) {
59
+ const filePid = Number.parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
60
+ if (!Number.isFinite(filePid) || filePid !== expectedPid) {
61
+ return;
62
+ }
63
+ }
64
+
65
+ fs.unlinkSync(PID_FILE);
66
+ } catch {
67
+ // ignore cleanup errors
68
+ }
69
+ }
70
+
71
+ function writePidFile(pid: number) {
72
+ const dir = path.dirname(PID_FILE);
73
+ if (!fs.existsSync(dir)) {
74
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ }
76
+ // Write PID file with restrictive permissions (owner-only)
77
+ const fd = fs.openSync(PID_FILE, "w", 0o600);
78
+ fs.writeSync(fd, String(pid));
79
+ fs.closeSync(fd);
80
+ }
81
+
82
+ async function isSocketActive(socketPath: string): Promise<boolean> {
83
+ return await new Promise((resolve) => {
84
+ let settled = false;
85
+ const finalize = (isActive: boolean) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ resolve(isActive);
89
+ };
90
+
91
+ const client = net.createConnection(socketPath);
92
+ const timeout = setTimeout(() => {
93
+ client.destroy();
94
+ finalize(false);
95
+ }, 250);
96
+
97
+ client.once("connect", () => {
98
+ clearTimeout(timeout);
99
+ client.end();
100
+ finalize(true);
101
+ });
102
+
103
+ client.once("error", () => {
104
+ clearTimeout(timeout);
105
+ finalize(false);
106
+ });
107
+ });
108
+ }
109
+
110
+ async function ensureSocketPathAvailable(socketPath: string): Promise<void> {
111
+ if (!fs.existsSync(socketPath)) return;
112
+
113
+ let stats: fs.Stats;
114
+ try {
115
+ stats = fs.lstatSync(socketPath);
116
+ } catch {
117
+ return;
118
+ }
119
+
120
+ if (!stats.isSocket()) {
121
+ throw new Error(`Refusing to use non-socket path: ${socketPath}`);
122
+ }
123
+
124
+ if (await isSocketActive(socketPath)) {
125
+ throw new Error(
126
+ `Socket ${socketPath} is already in use. Another daemon may be running.\n` +
127
+ " Stop it with: codepiper daemon stop"
128
+ );
129
+ }
130
+
131
+ console.log(`Removing stale socket: ${socketPath}`);
132
+ fs.unlinkSync(socketPath);
133
+ }
134
+
135
+ async function waitForDetachedStartup(child: ReturnType<typeof Bun.spawn>): Promise<number | null> {
136
+ return await Promise.race([
137
+ child.exited,
138
+ new Promise<null>((resolve) => {
139
+ setTimeout(() => resolve(null), DETACHED_STARTUP_GRACE_MS);
140
+ }),
141
+ ]);
142
+ }
143
+
144
+ /**
145
+ * Reconcile DB session states against actual tmux sessions on startup.
146
+ * - RUNNING/STARTING sessions with a live tmux → re-adopt into SessionManager
147
+ * - RUNNING/STARTING sessions without a tmux session → mark STOPPED
148
+ * - Orphaned tmux sessions (no matching RUNNING DB record) → kill them
149
+ */
150
+ async function reconcileOrphanedSessions(
151
+ db: Database,
152
+ sessionManager?: SessionManager
153
+ ): Promise<void> {
154
+ // 1. Find DB sessions that claim to be active
155
+ const activeSessions = [
156
+ ...db.listSessions({ status: "RUNNING" }),
157
+ ...db.listSessions({ status: "STARTING" }),
158
+ ];
159
+
160
+ // 2. Get actual tmux codepiper sessions
161
+ const tmuxResult = Bun.spawnSync(["tmux", "list-sessions", "-F", "#{session_name}"], {
162
+ stdout: "pipe",
163
+ stderr: "pipe",
164
+ });
165
+ const tmuxSessionNames = new Set(
166
+ (tmuxResult.stdout?.toString() || "")
167
+ .split("\n")
168
+ .filter((name) => name.startsWith("codepiper-"))
169
+ );
170
+
171
+ // 3. Reconcile each DB session
172
+ let reconciled = 0;
173
+ let adopted = 0;
174
+ for (const session of activeSessions) {
175
+ const tmuxName = `codepiper-${session.id}`;
176
+ if (!tmuxSessionNames.has(tmuxName)) {
177
+ // Tmux is gone — mark STOPPED
178
+ db.updateSession(session.id, { status: "STOPPED" });
179
+ reconciled++;
180
+ } else if (sessionManager) {
181
+ // Tmux is alive — re-adopt into SessionManager
182
+ try {
183
+ await sessionManager.adoptSession(session.id);
184
+ adopted++;
185
+ } catch (err) {
186
+ console.warn(`Failed to adopt session ${session.id}:`, err);
187
+ db.updateSession(session.id, { status: "STOPPED" });
188
+ reconciled++;
189
+ }
190
+ }
191
+ }
192
+ if (reconciled > 0) {
193
+ console.log(`Reconciled ${reconciled} orphaned session(s) → STOPPED (no tmux session)`);
194
+ }
195
+ if (adopted > 0) {
196
+ console.log(`Re-adopted ${adopted} live session(s) from previous daemon run`);
197
+ }
198
+
199
+ // 4. Kill orphaned tmux sessions that have no matching active DB record
200
+ const activeIds = new Set(activeSessions.map((s) => `codepiper-${s.id}`));
201
+ let killedTmux = 0;
202
+ for (const tmuxName of tmuxSessionNames) {
203
+ if (!activeIds.has(tmuxName)) {
204
+ try {
205
+ Bun.spawnSync(["tmux", "kill-session", "-t", tmuxName], {
206
+ stdout: "ignore",
207
+ stderr: "ignore",
208
+ });
209
+ killedTmux++;
210
+ } catch {
211
+ // ignore kill errors
212
+ }
213
+ }
214
+ }
215
+ if (killedTmux > 0) {
216
+ console.log(`Killed ${killedTmux} orphaned tmux session(s)`);
217
+ }
218
+ }
219
+
220
+ async function main() {
221
+ const cliArgs = parseArgs(process.argv);
222
+
223
+ // Handle --detach: re-spawn self in background without --detach flag
224
+ if (cliArgs.detach) {
225
+ const runtimeBin = process.argv[0];
226
+ const entryScript = process.argv[1];
227
+ if (!(runtimeBin && entryScript)) {
228
+ throw new Error("Unable to determine daemon runtime entrypoint");
229
+ }
230
+
231
+ const filteredArgs = process.argv.slice(2).filter((a) => a !== "--detach");
232
+ const child = Bun.spawn([runtimeBin, entryScript, ...filteredArgs], {
233
+ stdio: ["ignore", "ignore", "ignore"],
234
+ env: process.env,
235
+ });
236
+ const earlyExitCode = await waitForDetachedStartup(child);
237
+ if (earlyExitCode !== null) {
238
+ console.error(`Failed to start daemon in background (exit code: ${earlyExitCode})`);
239
+ process.exit(earlyExitCode === 0 ? 1 : earlyExitCode);
240
+ }
241
+
242
+ child.unref();
243
+ console.log(`Daemon started in background (PID: ${child.pid})`);
244
+ console.log("Use `codepiper daemon status` to confirm readiness.");
245
+ process.exit(0);
246
+ }
247
+
248
+ // Get socket path from environment or use default
249
+ const socketPath = cliArgs.socketPath || process.env.CODEPIPER_SOCKET || DEFAULT_SOCKET_PATH;
250
+ const dbPath = process.env.CODEPIPER_DB_PATH || DEFAULT_DB_PATH;
251
+
252
+ // Create .codepiper directory if it doesn't exist (for persistent DB)
253
+ // Use restrictive permissions (owner-only) to protect session data and credentials
254
+ if (dbPath !== ":memory:") {
255
+ const dbDir = path.dirname(dbPath);
256
+ if (!fs.existsSync(dbDir)) {
257
+ fs.mkdirSync(dbDir, { recursive: true, mode: 0o700 });
258
+ console.log(`Created directory: ${dbDir}`);
259
+ }
260
+ }
261
+
262
+ // Clean up stale socket files, but never unlink an active daemon socket.
263
+ await ensureSocketPathAvailable(socketPath);
264
+
265
+ // Check tmux availability (warn only — daemon can serve API/web without it)
266
+ const tmuxCheck = Bun.spawnSync(["tmux", "-V"], { stdout: "pipe", stderr: "pipe" });
267
+ if (tmuxCheck.exitCode !== 0) {
268
+ console.warn(
269
+ "Warning: tmux not found. Session management requires tmux 3.0+.\n" +
270
+ " Install tmux: https://github.com/tmux/tmux/wiki/Installing"
271
+ );
272
+ }
273
+
274
+ // Initialize database
275
+ const db = new Database(dbPath);
276
+ await db.init();
277
+
278
+ // Lock down DB file permissions (contains auth hashes and session data)
279
+ if (dbPath !== ":memory:") {
280
+ for (const suffix of ["", "-wal", "-shm"]) {
281
+ const p = dbPath + suffix;
282
+ try {
283
+ if (fs.existsSync(p)) {
284
+ fs.chmodSync(p, 0o600);
285
+ }
286
+ } catch (err) {
287
+ console.warn(
288
+ `Warning: Failed to set permissions on ${p}: ${err instanceof Error ? err.message : err}\n` +
289
+ " The database may be readable by other users."
290
+ );
291
+ }
292
+ }
293
+ }
294
+
295
+ console.log(`Database initialized: ${dbPath === ":memory:" ? "in-memory" : dbPath}`);
296
+
297
+ // Clean up old STOPPED/CRASHED sessions on startup (older than 24 hours)
298
+ const cleanupAgeMs = 24 * 60 * 60 * 1000; // 24 hours
299
+ const cleaned = db.cleanupOldSessions(cleanupAgeMs);
300
+ if (cleaned > 0) {
301
+ console.log(`Cleaned up ${cleaned} old session(s) from database`);
302
+ }
303
+
304
+ // Backfill token_usage from existing transcript assistant events
305
+ const backfillRows = db.query(`
306
+ SELECT e.id, e.session_id, e.payload_json FROM events e
307
+ WHERE e.source = 'transcript' AND e.type = 'assistant'
308
+ AND e.id NOT IN (SELECT event_id FROM token_usage WHERE event_id IS NOT NULL)
309
+ `) as Array<{ id: number; session_id: string; payload_json: string }>;
310
+
311
+ let backfilled = 0;
312
+ for (const row of backfillRows) {
313
+ try {
314
+ const parsed = JSON.parse(row.payload_json);
315
+ if (parsed.message?.usage) {
316
+ const usage = parsed.message.usage;
317
+ const promptTokens = usage.input_tokens || 0;
318
+ const completionTokens = usage.output_tokens || 0;
319
+ const cacheCreation = usage.cache_creation_input_tokens || 0;
320
+ const cacheRead = usage.cache_read_input_tokens || 0;
321
+ db.insertTokenUsage({
322
+ sessionId: row.session_id,
323
+ eventId: row.id,
324
+ model: parsed.message.model || "unknown",
325
+ promptTokens,
326
+ completionTokens,
327
+ cacheCreationInputTokens: cacheCreation,
328
+ cacheReadInputTokens: cacheRead,
329
+ totalTokens: promptTokens + completionTokens + cacheCreation + cacheRead,
330
+ });
331
+ backfilled++;
332
+ }
333
+ } catch {
334
+ // Skip unparseable events
335
+ }
336
+ }
337
+ if (backfilled > 0) {
338
+ console.log(`Backfilled ${backfilled} token usage record(s) from existing events`);
339
+ }
340
+
341
+ // Create SQLite event bus (shares same database file for zero dependencies)
342
+ const sqliteEventBus = new SQLiteEventBus({
343
+ dbPath,
344
+ consumerGroup: "codepiper-daemon",
345
+ consumerName: "daemon-main",
346
+ pollingIntervalMs: 100,
347
+ });
348
+
349
+ // Wrap with adapter to provide synchronous emit/on API
350
+ const eventBus = new EventBusAdapter<Record<string, any>>(sqliteEventBus);
351
+ console.log(`EventBus initialized: SQLite-based (polling interval: 100ms)`);
352
+
353
+ // Create session manager
354
+ const sessionManager = new SessionManager(db, eventBus);
355
+
356
+ // Reconcile DB sessions against actual tmux sessions (re-adopt live ones)
357
+ await reconcileOrphanedSessions(db, sessionManager);
358
+
359
+ // Resolve web directory
360
+ let webDir: string | undefined;
361
+ if (cliArgs.web) {
362
+ webDir = cliArgs.webDir || DEFAULT_WEB_DIR;
363
+ if (!fs.existsSync(webDir)) {
364
+ console.warn(
365
+ `Warning: Web assets not found at ${webDir}\n` +
366
+ " The --web flag was set but no dashboard will be served.\n" +
367
+ " Build web assets with: bun run build:web\n" +
368
+ " Then restart the daemon."
369
+ );
370
+ webDir = undefined;
371
+ }
372
+ }
373
+
374
+ // Initialize authentication
375
+ const { AuthService } = await import("./auth/authService");
376
+ const { RateLimiter } = await import("./auth/rateLimiter");
377
+ const { getOrCreateEncryptionKey, getOrCreateHookSecret } = await import("./crypto/encryption");
378
+
379
+ const encryptionKey = getOrCreateEncryptionKey();
380
+ const hookSecret = process.env.CODEPIPER_SECRET || getOrCreateHookSecret();
381
+ process.env.CODEPIPER_SECRET = hookSecret;
382
+ const authService = new AuthService(db, encryptionKey);
383
+ const rateLimiter = new RateLimiter();
384
+
385
+ // Clean up expired auth sessions
386
+ const expiredSessions = db.cleanupExpiredAuthSessions();
387
+ if (expiredSessions > 0) {
388
+ console.log(`Cleaned up ${expiredSessions} expired auth session(s)`);
389
+ }
390
+
391
+ if (authService.isSetupRequired()) {
392
+ console.log("Auth: No password configured. Web dashboard will show setup page.");
393
+ } else {
394
+ console.log("Auth: Password configured. Web dashboard requires login.");
395
+ }
396
+
397
+ // Handle graceful shutdown/restart (guard against concurrent calls)
398
+ let shutdownInProgress = false;
399
+ let restartRequested = false;
400
+ let server: DaemonServer | null = null;
401
+
402
+ const spawnReplacementDaemon = (): boolean => {
403
+ const runtimeBin = process.argv[0];
404
+ const entryScript = process.argv[1];
405
+ if (!(runtimeBin && entryScript)) {
406
+ console.error("[daemon] Unable to determine runtime entrypoint for restart");
407
+ return false;
408
+ }
409
+
410
+ const args = process.argv.slice(2).filter((arg) => arg !== "--detach");
411
+ try {
412
+ const child = Bun.spawn([runtimeBin, entryScript, ...args], {
413
+ stdio: ["ignore", "ignore", "ignore"],
414
+ env: process.env,
415
+ });
416
+ child.unref();
417
+ console.log(`[daemon] Spawned replacement process (PID: ${child.pid})`);
418
+ return true;
419
+ } catch (err) {
420
+ console.error("[daemon] Failed to spawn replacement process:", err);
421
+ return false;
422
+ }
423
+ };
424
+
425
+ const shutdown = async (mode: "stop" | "restart" = "stop") => {
426
+ if (shutdownInProgress) return;
427
+ shutdownInProgress = true;
428
+
429
+ console.log(`\n${mode === "restart" ? "Restarting daemon..." : "Shutting down..."}`);
430
+ let restartSpawned = false;
431
+ try {
432
+ const settings = db.getDaemonSettings();
433
+ if (settings.preserveSessions) {
434
+ const activeCount = sessionManager.listSessions().length;
435
+ if (activeCount > 0) {
436
+ console.log(
437
+ `Preserving ${activeCount} active session(s) for re-adoption on next startup`
438
+ );
439
+ }
440
+ await sessionManager.detachAll();
441
+ } else {
442
+ await sessionManager.stopAll();
443
+ }
444
+ await eventBus.close();
445
+ if (server) {
446
+ await server.stop();
447
+ }
448
+ cleanupPidFile(process.pid);
449
+
450
+ if (mode === "restart") {
451
+ restartSpawned = spawnReplacementDaemon();
452
+ if (!restartSpawned) {
453
+ console.error("[daemon] Restart failed: replacement process was not spawned");
454
+ }
455
+ }
456
+
457
+ console.log(mode === "restart" ? "Daemon restart complete" : "Daemon stopped");
458
+ } catch (err) {
459
+ console.error("Error during shutdown:", err);
460
+ }
461
+ process.exit(mode === "restart" && !restartSpawned ? 1 : 0);
462
+ };
463
+
464
+ const requestRestart = () => {
465
+ if (shutdownInProgress || restartRequested) {
466
+ return;
467
+ }
468
+ restartRequested = true;
469
+ console.log("[daemon] Restart requested via API");
470
+ setTimeout(() => {
471
+ void shutdown("restart");
472
+ }, 25);
473
+ };
474
+
475
+ // Start server
476
+ console.log(`Starting CodePiper daemon on ${socketPath}...`);
477
+ server = await createServer(socketPath, sessionManager, db, eventBus, {
478
+ webDir,
479
+ httpPort: cliArgs.port,
480
+ authService,
481
+ rateLimiter,
482
+ onRestartRequested: requestRestart,
483
+ });
484
+
485
+ // Wire WebSocket manager to session manager for PTY streaming
486
+ sessionManager.setWebSocketManager(server.wsManager);
487
+
488
+ // Write PID only after startup succeeds to avoid stale/clobbered pid files.
489
+ writePidFile(process.pid);
490
+
491
+ const bannerLines = [
492
+ "",
493
+ "============================================================",
494
+ " CodePiper daemon is ready",
495
+ "============================================================",
496
+ ` Socket: ${socketPath}`,
497
+ ` WebSocket: ws://127.0.0.1:${server.wsPort}/ws`,
498
+ ];
499
+ if (server.httpPort > 0) {
500
+ bannerLines.push(` Dashboard: http://127.0.0.1:${server.httpPort}`);
501
+ }
502
+ if (process.env.CODEPIPER_ALLOWED_ORIGINS) {
503
+ bannerLines.push(` Origins: ${process.env.CODEPIPER_ALLOWED_ORIGINS}`);
504
+ }
505
+ bannerLines.push(
506
+ "",
507
+ " Press Ctrl+C to stop",
508
+ "============================================================"
509
+ );
510
+ console.log(bannerLines.join("\n"));
511
+
512
+ process.on("SIGINT", () => {
513
+ void shutdown("stop");
514
+ });
515
+ process.on("SIGTERM", () => {
516
+ void shutdown("stop");
517
+ });
518
+ }
519
+
520
+ main().catch((error) => {
521
+ cleanupPidFile(process.pid);
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ console.error(`\nFailed to start daemon: ${message}`);
524
+ process.exit(1);
525
+ });