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,588 @@
1
+ /**
2
+ * Hooks ingestion endpoint
3
+ *
4
+ * Receives hook events from `codepiper hook-forward` and processes them.
5
+ * Events are stored in database, emitted on event bus, and may trigger session state updates.
6
+ */
7
+
8
+ import * as crypto from "node:crypto";
9
+ import * as path from "node:path";
10
+ import type { EventBus } from "@codepiper/core";
11
+ import type { Database, UpdateSessionParams } from "../db/db";
12
+ import type { AuditLogger } from "../sessions/auditLogger";
13
+ import type { PolicyEngine } from "../sessions/policyEngine";
14
+ import type { PermissionRequest as PolicyPermissionRequest } from "../sessions/policyTypes";
15
+ import type { SessionManager } from "../sessions/sessionManager";
16
+ import { isDangerousModeMetadata } from "../sessions/sessionManager";
17
+
18
+ export interface HookContext {
19
+ db: Database;
20
+ eventBus: EventBus;
21
+ sessionManager?: SessionManager;
22
+ policyEngine?: PolicyEngine;
23
+ auditLogger?: AuditLogger;
24
+ /** Shared daemon secret for authenticating hook requests */
25
+ hookSecret?: string;
26
+ }
27
+
28
+ interface HookEventPayload {
29
+ sessionId: string;
30
+ event: string;
31
+ data: Record<string, any>;
32
+ }
33
+
34
+ interface PermissionRequest {
35
+ input: string;
36
+ requestedPermissions: Array<{
37
+ operation: string;
38
+ path: string;
39
+ }>;
40
+ }
41
+
42
+ interface PermissionDecision {
43
+ decision: "allow" | "deny" | "ask";
44
+ allow: boolean;
45
+ updatedInput?: string;
46
+ updatedPermissions?: Array<{
47
+ operation: string;
48
+ path: string;
49
+ }>;
50
+ message?: string;
51
+ denialMessage?: string;
52
+ }
53
+
54
+ const VALID_EVENT_TYPES = ["SessionStart", "Notification", "Stop", "PermissionRequest"];
55
+ const STOP_NOTIFICATION_EVENT_TYPE = "session.turn_completed";
56
+ const STOP_NOTIFICATION_TITLE = "Turn completed";
57
+ const PERMISSION_NOTIFICATION_EVENT_TYPE = "session.permission_required";
58
+ const PERMISSION_NOTIFICATION_TITLE = "Permission required";
59
+ const INPUT_REQUIRED_NOTIFICATION_EVENT_TYPE = "session.input_required";
60
+ const INPUT_REQUIRED_NOTIFICATION_TITLE = "Input required";
61
+
62
+ interface HookSessionNotificationParams {
63
+ eventType: string;
64
+ title: string;
65
+ body: string;
66
+ payload: Record<string, unknown>;
67
+ }
68
+
69
+ const textEncoder = new TextEncoder();
70
+
71
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
72
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
73
+ }
74
+
75
+ /**
76
+ * Constant-time string comparison to prevent timing attacks.
77
+ * Returns false for mismatched lengths without leaking which bytes differ.
78
+ */
79
+ function timingSafeEquals(a: string, b: string): boolean {
80
+ const bufA = textEncoder.encode(a);
81
+ const bufB = textEncoder.encode(b);
82
+ if (bufA.length !== bufB.length) return false;
83
+ return crypto.timingSafeEqual(bufA, bufB);
84
+ }
85
+
86
+ export async function handleHookEvent(req: Request, ctx: HookContext): Promise<Response> {
87
+ // Validate hook secret (authenticates that request comes from a codepiper-spawned process)
88
+ if (ctx.hookSecret) {
89
+ const providedSecret = req.headers.get("X-CodePiper-Secret");
90
+ if (!(providedSecret && timingSafeEquals(ctx.hookSecret, providedSecret))) {
91
+ return Response.json({ error: "Invalid or missing hook secret" }, { status: 403 });
92
+ }
93
+ }
94
+
95
+ let rawBody: unknown;
96
+ try {
97
+ rawBody = await req.json();
98
+ } catch {
99
+ return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
100
+ }
101
+
102
+ if (!(rawBody && typeof rawBody === "object" && !Array.isArray(rawBody))) {
103
+ return Response.json({ error: "Request body must be a JSON object" }, { status: 400 });
104
+ }
105
+
106
+ const parsedBody = rawBody as Record<string, unknown>;
107
+ const sessionId = typeof parsedBody.sessionId === "string" ? parsedBody.sessionId : "";
108
+ const event = typeof parsedBody.event === "string" ? parsedBody.event : "";
109
+ const rawData = parsedBody.data;
110
+ const data =
111
+ rawData && typeof rawData === "object" && !Array.isArray(rawData)
112
+ ? (rawData as Record<string, any>)
113
+ : {};
114
+
115
+ const body: HookEventPayload = { sessionId, event, data };
116
+
117
+ if (!body.sessionId) {
118
+ return Response.json({ error: "Missing required field: sessionId" }, { status: 400 });
119
+ }
120
+
121
+ if (!body.event) {
122
+ return Response.json({ error: "Missing required field: event" }, { status: 400 });
123
+ }
124
+
125
+ if (!VALID_EVENT_TYPES.includes(body.event)) {
126
+ return Response.json(
127
+ { error: `Unknown event type: ${body.event}. Valid types: ${VALID_EVENT_TYPES.join(", ")}` },
128
+ { status: 400 }
129
+ );
130
+ }
131
+
132
+ if (!ctx.db.getSession(body.sessionId)) {
133
+ return Response.json({ error: `Session not found: ${body.sessionId}` }, { status: 404 });
134
+ }
135
+
136
+ switch (body.event) {
137
+ case "SessionStart":
138
+ return await handleSessionStart(body, ctx);
139
+ case "Notification":
140
+ return handleNotification(body, ctx);
141
+ case "PermissionRequest":
142
+ return await handlePermissionRequest(body, ctx);
143
+ case "Stop":
144
+ return handleStop(body, ctx);
145
+ default:
146
+ return Response.json({ error: `Unhandled event type: ${body.event}` }, { status: 400 });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Insert an event into the database and emit it on the event bus.
152
+ */
153
+ function recordAndEmit(
154
+ ctx: HookContext,
155
+ sessionId: string,
156
+ type: string,
157
+ payload: unknown
158
+ ): number {
159
+ const eventId = ctx.db.insertEvent({
160
+ sessionId,
161
+ source: "hook",
162
+ type,
163
+ payload,
164
+ });
165
+
166
+ ctx.eventBus.emit("session:event", {
167
+ id: eventId,
168
+ sessionId,
169
+ type,
170
+ source: "hook",
171
+ timestamp: new Date(),
172
+ payload,
173
+ });
174
+
175
+ return eventId;
176
+ }
177
+
178
+ async function handleSessionStart(payload: HookEventPayload, ctx: HookContext): Promise<Response> {
179
+ const { sessionId, data } = payload;
180
+
181
+ const update: UpdateSessionParams = {
182
+ status: "RUNNING",
183
+ };
184
+
185
+ if (data.transcript_path) {
186
+ update.transcriptPath = data.transcript_path;
187
+ }
188
+
189
+ ctx.db.updateSession(sessionId, update);
190
+ recordAndEmit(ctx, sessionId, "SessionStart", data);
191
+
192
+ if (data.transcript_path && ctx.sessionManager?.getSession(sessionId)) {
193
+ try {
194
+ await ctx.sessionManager.setTranscriptPath(
195
+ sessionId,
196
+ data.transcript_path,
197
+ ctx.db,
198
+ ctx.eventBus
199
+ );
200
+ } catch (error) {
201
+ console.error(`Failed to start transcript tailer for session ${sessionId}:`, error);
202
+ }
203
+ }
204
+
205
+ return Response.json({ success: true });
206
+ }
207
+
208
+ function handleNotification(payload: HookEventPayload, ctx: HookContext): Response {
209
+ const { sessionId, data } = payload;
210
+
211
+ if (data.type === "permission_prompt") {
212
+ ctx.db.updateSession(sessionId, { status: "NEEDS_PERMISSION" });
213
+ } else if (data.type === "idle_prompt") {
214
+ ctx.db.updateSession(sessionId, { status: "NEEDS_INPUT" });
215
+ }
216
+
217
+ const sourceEventId = recordAndEmit(ctx, sessionId, "Notification", data);
218
+ maybeCreatePromptNotification(ctx, sessionId, sourceEventId, data);
219
+ return Response.json({ success: true });
220
+ }
221
+
222
+ async function handlePermissionRequest(
223
+ payload: HookEventPayload,
224
+ ctx: HookContext
225
+ ): Promise<Response> {
226
+ const { sessionId, data } = payload;
227
+ const session = ctx.db.getSession(sessionId);
228
+ const dangerousMode = isDangerousModeMetadata(session?.metadata);
229
+
230
+ let decision: PermissionDecision;
231
+ let policyAction: "allow" | "deny" | "ask" = "ask";
232
+
233
+ if (dangerousMode) {
234
+ policyAction = "allow";
235
+ decision = toPermissionDecision("allow", "Dangerous mode bypassed CodePiper policy checks");
236
+ const payloadWithDecision = { ...data, decision, policyAction, dangerousMode: true };
237
+ const eventId = ctx.db.insertEvent({
238
+ sessionId,
239
+ source: "hook",
240
+ type: "PermissionRequest",
241
+ payload: payloadWithDecision,
242
+ });
243
+
244
+ if (ctx.auditLogger) {
245
+ ctx.auditLogger.logDecision({
246
+ sessionId,
247
+ eventId,
248
+ toolName: "dangerous_mode_bypass",
249
+ args: { provider: session?.provider ?? "unknown" },
250
+ decision: {
251
+ action: "allow",
252
+ reason: "Dangerous mode bypassed CodePiper policy checks",
253
+ },
254
+ });
255
+ }
256
+
257
+ ctx.eventBus.emit("session:event", {
258
+ id: eventId,
259
+ sessionId,
260
+ type: "PermissionRequest",
261
+ source: "hook",
262
+ timestamp: new Date(),
263
+ payload: payloadWithDecision,
264
+ });
265
+ } else if (ctx.policyEngine && ctx.auditLogger) {
266
+ // Use PolicyEngine if available, otherwise fall back to simple evaluation
267
+ const permData = data as unknown as PermissionRequest;
268
+
269
+ // Get session for CWD
270
+ const cwd = session?.cwd ?? "/";
271
+
272
+ // Build policy permission request
273
+ const policyRequest: PolicyPermissionRequest = {
274
+ sessionId,
275
+ tool: permData.requestedPermissions?.[0]?.operation ?? "unknown",
276
+ args: { input: permData.input },
277
+ cwd,
278
+ };
279
+
280
+ // Load applicable policies (direct + policy sets + global, deduplicated)
281
+ const allPolicies = ctx.db.getEffectivePolicies(sessionId);
282
+
283
+ // Evaluate with policy engine
284
+ const policyDecision = await ctx.policyEngine.evaluate(policyRequest, allPolicies);
285
+ policyAction = policyDecision.action;
286
+
287
+ // Convert policy decision to permission decision
288
+ decision = toPermissionDecision(policyDecision.action, policyDecision.reason);
289
+
290
+ // Store event with decision (use the permission decision format)
291
+ const payloadWithDecision = { ...data, decision, policyAction };
292
+ const eventId = ctx.db.insertEvent({
293
+ sessionId,
294
+ source: "hook",
295
+ type: "PermissionRequest",
296
+ payload: payloadWithDecision,
297
+ });
298
+
299
+ // Log decision to audit log
300
+ ctx.auditLogger.logDecision({
301
+ sessionId,
302
+ eventId,
303
+ toolName: policyRequest.tool,
304
+ args: policyRequest.args,
305
+ decision: policyDecision,
306
+ });
307
+
308
+ // Emit event on bus
309
+ ctx.eventBus.emit("session:event", {
310
+ id: eventId,
311
+ sessionId,
312
+ type: "PermissionRequest",
313
+ source: "hook",
314
+ timestamp: new Date(),
315
+ payload: payloadWithDecision,
316
+ });
317
+ } else {
318
+ // Fallback to simple MVP policy
319
+ decision = evaluatePermission(data as unknown as PermissionRequest);
320
+ policyAction = decision.decision;
321
+
322
+ // Store event with decision
323
+ const payloadWithDecision = { ...data, decision, policyAction };
324
+ const eventId = ctx.db.insertEvent({
325
+ sessionId,
326
+ source: "hook",
327
+ type: "PermissionRequest",
328
+ payload: payloadWithDecision,
329
+ });
330
+
331
+ // Emit event on bus
332
+ ctx.eventBus.emit("session:event", {
333
+ id: eventId,
334
+ sessionId,
335
+ type: "PermissionRequest",
336
+ source: "hook",
337
+ timestamp: new Date(),
338
+ payload: payloadWithDecision,
339
+ });
340
+ }
341
+
342
+ // Automatically send approval/denial to session if policy is clear
343
+ if (ctx.sessionManager?.getSession(sessionId) && policyAction !== "ask") {
344
+ try {
345
+ if (policyAction === "allow") {
346
+ await sendApproval(sessionId, ctx.sessionManager);
347
+ } else if (policyAction === "deny") {
348
+ await sendDenial(sessionId, ctx.sessionManager);
349
+ }
350
+ } catch (error) {
351
+ console.error(`Failed to send permission response to session ${sessionId}:`, error);
352
+ // Don't fail the request - decision is still recorded
353
+ }
354
+ }
355
+
356
+ return Response.json(decision);
357
+ }
358
+
359
+ function handleStop(payload: HookEventPayload, ctx: HookContext): Response {
360
+ const { sessionId, data } = payload;
361
+ const sourceEventId = recordAndEmit(ctx, sessionId, "Stop", data);
362
+ maybeCreateStopNotification(ctx, sessionId, sourceEventId, data);
363
+ return Response.json({ success: true });
364
+ }
365
+
366
+ function maybeCreateStopNotification(
367
+ ctx: HookContext,
368
+ sessionId: string,
369
+ sourceEventId: number,
370
+ data: Record<string, any>
371
+ ): void {
372
+ const session = ctx.db.getSession(sessionId);
373
+ if (!session) {
374
+ return;
375
+ }
376
+
377
+ const sessionLabel = getSessionLabel(session);
378
+ const body = `${sessionLabel} is ready for your next prompt.`;
379
+
380
+ const notificationPayload = {
381
+ sessionId,
382
+ sessionLabel,
383
+ provider: session.provider,
384
+ hookEvent: "Stop",
385
+ reason: typeof data.reason === "string" ? data.reason : null,
386
+ };
387
+
388
+ maybeCreateSessionNotification(ctx, session, sourceEventId, {
389
+ eventType: STOP_NOTIFICATION_EVENT_TYPE,
390
+ title: STOP_NOTIFICATION_TITLE,
391
+ body,
392
+ payload: notificationPayload,
393
+ });
394
+ }
395
+
396
+ function maybeCreatePromptNotification(
397
+ ctx: HookContext,
398
+ sessionId: string,
399
+ sourceEventId: number,
400
+ data: Record<string, any>
401
+ ): void {
402
+ if (!(data.type === "permission_prompt" || data.type === "idle_prompt")) {
403
+ return;
404
+ }
405
+
406
+ const session = ctx.db.getSession(sessionId);
407
+ if (!session) {
408
+ return;
409
+ }
410
+
411
+ const sessionLabel = getSessionLabel(session);
412
+ const providerLabel = getProviderLabel(session.provider);
413
+ const notificationPayload = {
414
+ sessionId,
415
+ sessionLabel,
416
+ provider: session.provider,
417
+ hookEvent: "Notification",
418
+ notificationType: data.type,
419
+ message: typeof data.message === "string" ? data.message : null,
420
+ };
421
+
422
+ if (data.type === "permission_prompt") {
423
+ maybeCreateSessionNotification(ctx, session, sourceEventId, {
424
+ eventType: PERMISSION_NOTIFICATION_EVENT_TYPE,
425
+ title: PERMISSION_NOTIFICATION_TITLE,
426
+ body: `${sessionLabel} is waiting for permission approval in ${providerLabel}.`,
427
+ payload: notificationPayload,
428
+ });
429
+ return;
430
+ }
431
+
432
+ maybeCreateSessionNotification(ctx, session, sourceEventId, {
433
+ eventType: INPUT_REQUIRED_NOTIFICATION_EVENT_TYPE,
434
+ title: INPUT_REQUIRED_NOTIFICATION_TITLE,
435
+ body: `${sessionLabel} is waiting for your input in ${providerLabel}.`,
436
+ payload: notificationPayload,
437
+ });
438
+ }
439
+
440
+ function maybeCreateSessionNotification(
441
+ ctx: HookContext,
442
+ session: { id: string; provider: string },
443
+ sourceEventId: number,
444
+ params: HookSessionNotificationParams
445
+ ): void {
446
+ const settings = ctx.db.getDaemonSettings();
447
+ if (!settings.notificationsEnabled) {
448
+ return;
449
+ }
450
+
451
+ const sessionPrefs = ctx.db.getSessionNotificationPrefs(session.id);
452
+ if (sessionPrefs.enabled === false) {
453
+ return;
454
+ }
455
+
456
+ const explicitEnabled = settings.notificationEventDefaults[params.eventType];
457
+ const eventEnabled = explicitEnabled !== undefined ? explicitEnabled : true;
458
+ if (!eventEnabled) {
459
+ return;
460
+ }
461
+
462
+ const notification = ctx.db.insertSessionNotificationWithStatus({
463
+ sessionId: session.id,
464
+ provider: session.provider,
465
+ eventType: params.eventType,
466
+ sourceEventId,
467
+ title: params.title,
468
+ body: params.body,
469
+ payload: params.payload,
470
+ });
471
+
472
+ if (!notification.inserted) {
473
+ return;
474
+ }
475
+
476
+ const createdAt = new Date();
477
+ ctx.eventBus.emit("notification:created", {
478
+ id: notification.id,
479
+ sessionId: session.id,
480
+ provider: session.provider,
481
+ eventType: params.eventType,
482
+ sourceEventId,
483
+ title: params.title,
484
+ body: params.body,
485
+ payload: params.payload,
486
+ createdAt,
487
+ readAt: null,
488
+ readSource: null,
489
+ });
490
+
491
+ ctx.eventBus.emit("notification:counts_updated", ctx.db.getSessionNotificationCounts());
492
+ }
493
+
494
+ function getSessionCustomName(metadata: unknown): string | null {
495
+ if (!isObjectRecord(metadata)) {
496
+ return null;
497
+ }
498
+
499
+ const ui = metadata.ui;
500
+ if (!(isObjectRecord(ui) && typeof ui.customName === "string")) {
501
+ return null;
502
+ }
503
+
504
+ const trimmed = ui.customName.trim();
505
+ return trimmed.length > 0 ? trimmed : null;
506
+ }
507
+
508
+ function getSessionLabel(session: {
509
+ id: string;
510
+ cwd: string;
511
+ metadata?: Record<string, unknown>;
512
+ }): string {
513
+ const customName = getSessionCustomName(session.metadata);
514
+ if (customName) {
515
+ return customName;
516
+ }
517
+
518
+ const directoryName = path.basename(session.cwd).trim();
519
+ if (directoryName && directoryName !== "." && directoryName !== "/") {
520
+ return directoryName;
521
+ }
522
+ return `session ${session.id.slice(0, 8)}`;
523
+ }
524
+
525
+ function getProviderLabel(provider: string): string {
526
+ if (provider === "claude-code") {
527
+ return "Claude Code";
528
+ }
529
+ if (provider === "codex") {
530
+ return "Codex";
531
+ }
532
+ return "this session";
533
+ }
534
+
535
+ /**
536
+ * Evaluate permission request using simple MVP policy:
537
+ * allow all read operations, deny write and execute operations.
538
+ */
539
+ function evaluatePermission(request: PermissionRequest): PermissionDecision {
540
+ const hasWriteOp = request.requestedPermissions.some(
541
+ (perm) => perm.operation === "write" || perm.operation === "execute"
542
+ );
543
+
544
+ if (hasWriteOp) {
545
+ return toPermissionDecision("deny", "Write operations are denied by default policy");
546
+ }
547
+
548
+ return toPermissionDecision("allow");
549
+ }
550
+
551
+ function toPermissionDecision(
552
+ action: "allow" | "deny" | "ask",
553
+ message?: string
554
+ ): PermissionDecision {
555
+ const decision: PermissionDecision = {
556
+ decision: action,
557
+ allow: action === "allow", // Backward compatibility for older consumers
558
+ };
559
+
560
+ if (message) {
561
+ decision.message = message;
562
+ if (action === "deny") {
563
+ decision.denialMessage = message;
564
+ }
565
+ }
566
+
567
+ return decision;
568
+ }
569
+
570
+ /**
571
+ * Send approval to Claude Code session
572
+ * Claude Code permission prompts typically use:
573
+ * 1 = Yes/Approve
574
+ */
575
+ async function sendApproval(sessionId: string, sessionManager: SessionManager): Promise<void> {
576
+ console.log(`[Permission] Auto-approving permission request for session ${sessionId}`);
577
+ await sessionManager.sendKeys(sessionId, ["1", "enter"]);
578
+ }
579
+
580
+ /**
581
+ * Send denial to Claude Code session
582
+ * Claude Code permission prompts typically use:
583
+ * 2 = No/Deny or 3 = Cancel
584
+ */
585
+ async function sendDenial(sessionId: string, sessionManager: SessionManager): Promise<void> {
586
+ console.log(`[Permission] Auto-denying permission request for session ${sessionId}`);
587
+ await sessionManager.sendKeys(sessionId, ["2", "enter"]);
588
+ }