codekin 0.2.2 → 0.3.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 (64) hide show
  1. package/dist/assets/index-B4rYTSlV.css +1 -0
  2. package/dist/assets/index-CWc3yfPn.js +181 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +8 -5
  5. package/server/dist/approval-manager.d.ts +71 -0
  6. package/server/dist/approval-manager.js +249 -0
  7. package/server/dist/approval-manager.js.map +1 -0
  8. package/server/dist/auth-routes.d.ts +11 -0
  9. package/server/dist/auth-routes.js +11 -0
  10. package/server/dist/auth-routes.js.map +1 -1
  11. package/server/dist/claude-process.js +2 -1
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/config.d.ts +1 -2
  14. package/server/dist/config.js +16 -4
  15. package/server/dist/config.js.map +1 -1
  16. package/server/dist/crypto-utils.d.ts +16 -1
  17. package/server/dist/crypto-utils.js +44 -1
  18. package/server/dist/crypto-utils.js.map +1 -1
  19. package/server/dist/docs-routes.d.ts +16 -0
  20. package/server/dist/docs-routes.js +141 -0
  21. package/server/dist/docs-routes.js.map +1 -0
  22. package/server/dist/session-manager.d.ts +37 -84
  23. package/server/dist/session-manager.js +89 -472
  24. package/server/dist/session-manager.js.map +1 -1
  25. package/server/dist/session-naming.d.ts +35 -0
  26. package/server/dist/session-naming.js +168 -0
  27. package/server/dist/session-naming.js.map +1 -0
  28. package/server/dist/session-persistence.d.ts +30 -0
  29. package/server/dist/session-persistence.js +93 -0
  30. package/server/dist/session-persistence.js.map +1 -0
  31. package/server/dist/session-routes.d.ts +2 -1
  32. package/server/dist/session-routes.js +11 -8
  33. package/server/dist/session-routes.js.map +1 -1
  34. package/server/dist/stepflow-handler.d.ts +3 -9
  35. package/server/dist/stepflow-handler.js +19 -54
  36. package/server/dist/stepflow-handler.js.map +1 -1
  37. package/server/dist/types.d.ts +3 -0
  38. package/server/dist/upload-routes.js +6 -3
  39. package/server/dist/upload-routes.js.map +1 -1
  40. package/server/dist/webhook-github.d.ts +23 -5
  41. package/server/dist/webhook-github.js +23 -5
  42. package/server/dist/webhook-github.js.map +1 -1
  43. package/server/dist/webhook-handler-base.d.ts +45 -0
  44. package/server/dist/webhook-handler-base.js +86 -0
  45. package/server/dist/webhook-handler-base.js.map +1 -0
  46. package/server/dist/webhook-handler.d.ts +3 -10
  47. package/server/dist/webhook-handler.js +6 -46
  48. package/server/dist/webhook-handler.js.map +1 -1
  49. package/server/dist/webhook-workspace.d.ts +8 -1
  50. package/server/dist/webhook-workspace.js +71 -47
  51. package/server/dist/webhook-workspace.js.map +1 -1
  52. package/server/dist/workflow-config.d.ts +2 -0
  53. package/server/dist/workflow-config.js +1 -1
  54. package/server/dist/workflow-config.js.map +1 -1
  55. package/server/dist/workflow-loader.d.ts +2 -0
  56. package/server/dist/workflow-loader.js +13 -9
  57. package/server/dist/workflow-loader.js.map +1 -1
  58. package/server/dist/workflow-routes.js +3 -2
  59. package/server/dist/workflow-routes.js.map +1 -1
  60. package/server/dist/ws-server.js +77 -16
  61. package/server/dist/ws-server.js.map +1 -1
  62. package/server/workflows/repo-health.weekly.md +111 -0
  63. package/dist/assets/index-CQdQ4FhF.css +0 -1
  64. package/dist/assets/index-D6pRORYQ.js +0 -115
@@ -10,22 +10,20 @@
10
10
  * on server startup. Active sessions are automatically restarted after a
11
11
  * server restart with staggered delays.
12
12
  *
13
- * Auto-approval rules (tools and Bash commands) are stored per-repo (keyed by
14
- * workingDir) in ~/.codekin/repo-approvals.json, so they persist across
15
- * sessions sharing the same repo.
13
+ * Delegates to focused modules:
14
+ * - ApprovalManager: repo-level auto-approval rules for tools/commands
15
+ * - SessionNaming: AI-powered session name generation with retry logic
16
+ * - SessionPersistence: disk I/O for session state
16
17
  */
17
18
  import { randomUUID } from 'crypto';
18
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
19
- import { join } from 'path';
20
- import { generateText } from 'ai';
21
- import { createGroq } from '@ai-sdk/groq';
22
- import { createOpenAI } from '@ai-sdk/openai';
23
- import { createGoogleGenerativeAI } from '@ai-sdk/google';
24
- import { createAnthropic } from '@ai-sdk/anthropic';
25
19
  import { ClaudeProcess } from './claude-process.js';
26
20
  import { SessionArchive } from './session-archive.js';
27
21
  import { cleanupWorkspace } from './webhook-workspace.js';
28
- import { DATA_DIR, PORT } from './config.js';
22
+ import { PORT } from './config.js';
23
+ import { ApprovalManager } from './approval-manager.js';
24
+ import { SessionNaming } from './session-naming.js';
25
+ import { SessionPersistence } from './session-persistence.js';
26
+ import { deriveSessionToken } from './crypto-utils.js';
29
27
  /** Max messages retained in a session's output history buffer. */
30
28
  const MAX_HISTORY = 2000;
31
29
  /** Max auto-restart attempts before requiring manual intervention. */
@@ -51,15 +49,8 @@ const API_RETRY_PATTERNS = [
51
49
  /502/,
52
50
  /503/,
53
51
  ];
54
- const SESSIONS_FILE = join(DATA_DIR, 'sessions.json');
55
- const REPO_APPROVALS_FILE = join(DATA_DIR, 'repo-approvals.json');
56
- const PERSIST_DEBOUNCE_MS = 2000;
57
52
  export class SessionManager {
58
53
  sessions = new Map();
59
- /** Repo-level auto-approval store, keyed by workingDir (repo path). */
60
- repoApprovals = new Map();
61
- _persistTimer = null;
62
- _approvalPersistTimer = null;
63
54
  /** SQLite archive for closed sessions. */
64
55
  archive;
65
56
  /** Exposed so ws-server can pass its port to child Claude processes. */
@@ -70,154 +61,75 @@ export class SessionManager {
70
61
  _globalBroadcast = null;
71
62
  /** Registered listeners notified when a session's Claude process exits. */
72
63
  _exitListeners = [];
64
+ /** Delegated approval logic. */
65
+ approvalManager;
66
+ /** Delegated naming logic. */
67
+ sessionNaming;
68
+ /** Delegated persistence logic. */
69
+ sessionPersistence;
73
70
  constructor() {
74
71
  this.archive = new SessionArchive();
75
- this.restoreFromDisk();
76
- this.restoreRepoApprovalsFromDisk();
72
+ this.approvalManager = new ApprovalManager();
73
+ this.sessionPersistence = new SessionPersistence(this.sessions);
74
+ this.sessionNaming = new SessionNaming({
75
+ getSession: (id) => this.sessions.get(id),
76
+ hasSession: (id) => this.sessions.has(id),
77
+ getSetting: (key, fallback) => this.archive.getSetting(key, fallback),
78
+ rename: (sessionId, newName) => this.rename(sessionId, newName),
79
+ });
80
+ this.sessionPersistence.restoreFromDisk();
77
81
  }
78
- /** Get or create the approval entry for a repo (workingDir). */
79
- getRepoApprovalEntry(workingDir) {
80
- let entry = this.repoApprovals.get(workingDir);
81
- if (!entry) {
82
- entry = { tools: new Set(), commands: new Set(), patterns: new Set() };
83
- this.repoApprovals.set(workingDir, entry);
84
- }
85
- return entry;
82
+ // ---------------------------------------------------------------------------
83
+ // Approval delegation (preserves public API)
84
+ // ---------------------------------------------------------------------------
85
+ /** Check if a tool/command is auto-approved for a repo. */
86
+ checkAutoApproval(workingDir, toolName, toolInput) {
87
+ return this.approvalManager.checkAutoApproval(workingDir, toolName, toolInput);
86
88
  }
87
- /** Add an auto-approval rule for a repo and persist. */
88
- addRepoApproval(workingDir, opts) {
89
- const entry = this.getRepoApprovalEntry(workingDir);
90
- if (opts.tool)
91
- entry.tools.add(opts.tool);
92
- if (opts.command)
93
- entry.commands.add(opts.command);
94
- if (opts.pattern)
95
- entry.patterns.add(opts.pattern);
96
- this.persistRepoApprovalsDebounced();
89
+ /** Derive a glob pattern from a tool invocation for "Approve Pattern". */
90
+ derivePattern(toolName, toolInput) {
91
+ return this.approvalManager.derivePattern(toolName, toolInput);
97
92
  }
98
- /**
99
- * Command prefixes where prefix-based auto-approval is safe.
100
- * Only commands whose behavior is determined by later arguments (not by target)
101
- * should be listed here. Dangerous commands like rm, sudo, curl, etc. require
102
- * exact match to prevent escalation (e.g. approving `rm -rf /tmp/x` should NOT
103
- * also approve `rm -rf /`).
104
- */
105
- static SAFE_PREFIX_COMMANDS = new Set([
106
- 'git add', 'git commit', 'git diff', 'git log', 'git show', 'git stash',
107
- 'git status', 'git branch', 'git checkout', 'git switch', 'git rebase',
108
- 'git fetch', 'git pull', 'git merge', 'git tag', 'git rev-parse',
109
- 'npm run', 'npm test', 'npm install', 'npm ci', 'npm exec',
110
- 'npx', 'node', 'bun', 'deno',
111
- 'cargo build', 'cargo test', 'cargo run', 'cargo check', 'cargo clippy',
112
- 'make', 'cmake',
113
- 'python', 'python3', 'pip install',
114
- 'go build', 'go test', 'go run', 'go vet',
115
- 'tsc', 'eslint', 'prettier',
116
- 'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'diff', 'less',
117
- 'ls', 'pwd', 'echo', 'date', 'which', 'whoami', 'env', 'printenv',
118
- 'find', 'grep', 'rg', 'ag', 'fd',
119
- 'mkdir', 'touch',
120
- ]);
121
- /**
122
- * Check if a tool/command is auto-approved for a repo.
123
- * For Bash commands, uses prefix matching only for safe commands;
124
- * dangerous commands require exact match to prevent escalation.
125
- */
126
- checkAutoApproval(workingDir, toolName, toolInput) {
127
- const approvals = this.getRepoApprovalEntry(workingDir);
128
- if (approvals.tools.has(toolName))
129
- return true;
130
- if (toolName === 'Bash') {
131
- const cmd = String(toolInput.command || '').trim();
132
- // Exact match always works
133
- if (approvals.commands.has(cmd))
134
- return true;
135
- // Pattern match (e.g. "cat *" matches any cat command)
136
- for (const pattern of approvals.patterns) {
137
- if (this.matchesPattern(pattern, cmd))
138
- return true;
139
- }
140
- // Prefix match only for safe commands
141
- const cmdPrefix = this.commandPrefix(cmd);
142
- if (cmdPrefix && SessionManager.SAFE_PREFIX_COMMANDS.has(cmdPrefix)) {
143
- for (const approved of approvals.commands) {
144
- if (this.commandPrefix(approved) === cmdPrefix)
145
- return true;
146
- }
147
- }
148
- }
149
- return false;
93
+ /** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
94
+ getApprovals(workingDir) {
95
+ return this.approvalManager.getApprovals(workingDir);
150
96
  }
151
- /** Extract the command prefix (first two tokens) for prefix-based matching. */
152
- commandPrefix(cmd) {
153
- const tokens = cmd.split(/\s+/).filter(Boolean);
154
- if (tokens.length === 0)
155
- return '';
156
- if (tokens.length === 1)
157
- return tokens[0];
158
- return `${tokens[0]} ${tokens[1]}`;
97
+ /** Remove an auto-approval rule for a repo (workingDir) and persist to disk. */
98
+ removeApproval(workingDir, opts, skipPersist = false) {
99
+ return this.approvalManager.removeApproval(workingDir, opts, skipPersist);
159
100
  }
160
- /**
161
- * Derive a glob pattern from a tool invocation for "Approve Pattern".
162
- * Returns a string like "cat *" or "git diff *", or null if no safe pattern applies.
163
- * Patterns use the format "<prefix> *" meaning "this prefix followed by anything".
164
- */
165
- derivePattern(toolName, toolInput) {
166
- if (toolName !== 'Bash')
167
- return null;
168
- const cmd = String(toolInput.command || '').trim();
169
- const tokens = cmd.split(/\s+/).filter(Boolean);
170
- if (tokens.length === 0)
171
- return null;
172
- // Check single-token safe commands (cat, grep, ls, etc.)
173
- const first = tokens[0];
174
- if (SessionManager.SAFE_PREFIX_COMMANDS.has(first)) {
175
- return `${first} *`;
176
- }
177
- // Check two-token safe commands (git diff, npm run, etc.)
178
- if (tokens.length >= 2) {
179
- const twoToken = `${tokens[0]} ${tokens[1]}`;
180
- if (SessionManager.SAFE_PREFIX_COMMANDS.has(twoToken)) {
181
- return `${twoToken} *`;
182
- }
183
- }
184
- return null;
101
+ /** Add an auto-approval rule for a repo and persist (used by tests via `as any`). */
102
+ addRepoApproval(workingDir, opts) {
103
+ this.approvalManager.addRepoApproval(workingDir, opts);
185
104
  }
186
- /**
187
- * Check if a bash command matches a stored pattern.
188
- * Patterns of the form "<prefix> *" match any command starting with <prefix>.
189
- */
190
- matchesPattern(pattern, cmd) {
191
- if (pattern.endsWith(' *')) {
192
- const prefix = pattern.slice(0, -2);
193
- return cmd === prefix || cmd.startsWith(prefix + ' ');
194
- }
195
- return cmd === pattern;
105
+ /** Write repo-level approvals to disk. Exposed for shutdown. */
106
+ persistRepoApprovals() {
107
+ this.approvalManager.persistRepoApprovals();
196
108
  }
197
- /** Save an "Always Allow" approval for a tool/command. */
198
- saveAlwaysAllow(workingDir, toolName, toolInput) {
199
- if (toolName === 'Bash') {
200
- const cmd = String(toolInput.command || '').trim();
201
- this.addRepoApproval(workingDir, { command: cmd });
202
- console.log(`[auto-approve] saved command for repo ${workingDir}: ${cmd.slice(0, 80)}`);
203
- }
204
- else {
205
- this.addRepoApproval(workingDir, { tool: toolName });
206
- console.log(`[auto-approve] saved tool for repo ${workingDir}: ${toolName}`);
207
- }
109
+ // ---------------------------------------------------------------------------
110
+ // Naming delegation (preserves public API)
111
+ // ---------------------------------------------------------------------------
112
+ /** Schedule session naming via AI provider. */
113
+ scheduleSessionNaming(sessionId) {
114
+ this.sessionNaming.scheduleSessionNaming(sessionId);
208
115
  }
209
- /** Save a pattern-based approval (e.g. "cat *") for a tool/command. */
210
- savePatternApproval(workingDir, toolName, toolInput) {
211
- const pattern = this.derivePattern(toolName, toolInput);
212
- if (pattern) {
213
- this.addRepoApproval(workingDir, { pattern });
214
- console.log(`[auto-approve] saved pattern for repo ${workingDir}: ${pattern}`);
215
- }
216
- else {
217
- // No pattern derivable — skip saving rather than silently escalating to always-allow
218
- console.log(`[auto-approve] no pattern derivable for ${toolName}, skipping pattern save`);
219
- }
116
+ /** Re-trigger session naming on user interaction. */
117
+ retrySessionNamingOnInteraction(sessionId) {
118
+ this.sessionNaming.retrySessionNamingOnInteraction(sessionId);
220
119
  }
120
+ // ---------------------------------------------------------------------------
121
+ // Persistence delegation (preserves public API)
122
+ // ---------------------------------------------------------------------------
123
+ /** Write all sessions to disk as JSON (atomic rename to prevent corruption). */
124
+ persistToDisk() {
125
+ this.sessionPersistence.persistToDisk();
126
+ }
127
+ persistToDiskDebounced() {
128
+ this.sessionPersistence.persistToDiskDebounced();
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Session CRUD
132
+ // ---------------------------------------------------------------------------
221
133
  /** Create a new session and persist to disk. */
222
134
  create(name, workingDir, options) {
223
135
  const id = options?.id ?? randomUUID();
@@ -282,155 +194,6 @@ export class SessionManager {
282
194
  this.broadcast(session, { type: 'session_name_update', sessionId, name: newName });
283
195
  return true;
284
196
  }
285
- /** Max naming retry attempts before giving up. */
286
- static MAX_NAMING_ATTEMPTS = 5;
287
- /** Back-off delays for naming retries: 20s, 60s, 120s, 240s, 240s */
288
- static NAMING_DELAYS = [20_000, 60_000, 120_000, 240_000, 240_000];
289
- /** Schedule session naming via the Anthropic API.
290
- * Used as a 20s fallback for long responses — fires immediately via result handler
291
- * for responses that finish sooner.
292
- * Automatically retries on failure with increasing delays. */
293
- scheduleSessionNaming(sessionId) {
294
- const session = this.sessions.get(sessionId);
295
- if (!session)
296
- return;
297
- if (!session.name.startsWith('hub:'))
298
- return;
299
- // Don't schedule if a naming timer is already pending
300
- if (session._namingTimer)
301
- return;
302
- // Give up after max attempts
303
- if (session._namingAttempts >= SessionManager.MAX_NAMING_ATTEMPTS)
304
- return;
305
- const delay = SessionManager.NAMING_DELAYS[Math.min(session._namingAttempts, SessionManager.NAMING_DELAYS.length - 1)];
306
- session._namingTimer = setTimeout(() => {
307
- delete session._namingTimer;
308
- this.executeSessionNaming(sessionId);
309
- }, delay);
310
- }
311
- /** Resolve the AI model to use for session naming based on available API keys.
312
- * Respects the user's preferred support provider setting.
313
- * Fallback priority: Groq (free/fast) → OpenAI → Gemini → Anthropic. */
314
- getNamingModel() {
315
- const preferred = this.archive.getSetting('support_provider', 'auto');
316
- const providers = {
317
- groq: () => process.env.GROQ_API_KEY
318
- ? createGroq({ apiKey: process.env.GROQ_API_KEY })('meta-llama/llama-4-scout-17b-16e-instruct')
319
- : null,
320
- openai: () => process.env.OPENAI_API_KEY
321
- ? createOpenAI({ apiKey: process.env.OPENAI_API_KEY })('gpt-4o-mini')
322
- : null,
323
- gemini: () => process.env.GEMINI_API_KEY
324
- ? createGoogleGenerativeAI({ apiKey: process.env.GEMINI_API_KEY })('gemini-2.5-flash')
325
- : null,
326
- anthropic: () => process.env.ANTHROPIC_API_KEY
327
- ? createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-haiku-4-5-20251001')
328
- : null,
329
- };
330
- // If a specific provider is preferred and available, use it
331
- if (preferred !== 'auto' && providers[preferred]) {
332
- const model = providers[preferred]();
333
- if (model)
334
- return model;
335
- }
336
- // Auto: try in priority order
337
- for (const key of ['groq', 'openai', 'gemini', 'anthropic']) {
338
- const model = providers[key]();
339
- if (model)
340
- return model;
341
- }
342
- return null;
343
- }
344
- /** Execute the actual naming call. Called by the timer set in scheduleSessionNaming.
345
- * Uses the first available API provider for minimal cost and latency. */
346
- async executeSessionNaming(sessionId) {
347
- const session = this.sessions.get(sessionId);
348
- if (!session)
349
- return;
350
- if (!session.name.startsWith('hub:'))
351
- return;
352
- session._namingAttempts++;
353
- // Gather context from conversation history
354
- const latestContext = session.outputHistory
355
- .filter(m => m.type === 'output')
356
- .map(m => m.data || '')
357
- .join('')
358
- .slice(0, 2000);
359
- const userMsg = session._lastUserInput || '';
360
- if (!userMsg && !latestContext) {
361
- // No context yet — schedule a retry
362
- this.scheduleSessionNaming(sessionId);
363
- return;
364
- }
365
- const model = this.getNamingModel();
366
- if (!model) {
367
- console.warn('[session-name] No API key set for naming (GROQ_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or ANTHROPIC_API_KEY), skipping');
368
- return;
369
- }
370
- try {
371
- const { text } = await generateText({
372
- model,
373
- maxOutputTokens: 60,
374
- prompt: [
375
- 'Generate a descriptive name (3-6 words, max 60 characters) for this coding session.',
376
- 'The name MUST be at least 3 words that clearly summarize what the user is working on.',
377
- 'Reply with ONLY the session name. No quotes, no punctuation, no explanation.',
378
- 'Example names: "Fix Login Page Styling", "Add User Auth Flow", "Refactor Database Query Performance"',
379
- '',
380
- `User message: ${userMsg.slice(0, 500)}`,
381
- '',
382
- `Assistant response (truncated): ${latestContext.slice(0, 1500)}`,
383
- ].join('\n'),
384
- });
385
- if (!this.sessions.has(sessionId))
386
- return;
387
- if (!session.name.startsWith('hub:'))
388
- return;
389
- const rawName = text
390
- .trim()
391
- .replace(/^["'`*_]+|["'`*_]+$/g, '')
392
- .replace(/^(session\s*name\s*[::]\s*)/i, '')
393
- .trim();
394
- const wordCount = rawName.split(/\s+/).filter(w => w.length > 0).length;
395
- if (rawName.length >= 2 && rawName.length <= 80 && wordCount >= 3) {
396
- const finalName = rawName.length <= 60
397
- ? rawName
398
- : (rawName.slice(0, 60).replace(/\s+\S*$/, '') || rawName.slice(0, 60));
399
- this.rename(sessionId, finalName);
400
- }
401
- else {
402
- console.warn(`[session-name] invalid name (${rawName.length} chars, ${wordCount} words): "${rawName.slice(0, 80)}"`);
403
- this.scheduleSessionNaming(sessionId);
404
- }
405
- }
406
- catch (err) {
407
- if (!this.sessions.has(sessionId))
408
- return;
409
- const msg = err instanceof Error ? err.message : String(err);
410
- console.warn(`[session-name] attempt ${session._namingAttempts} failed: ${msg}`);
411
- this.scheduleSessionNaming(sessionId);
412
- }
413
- }
414
- /** Re-trigger session naming when user interacts, if the session is still unnamed
415
- * and no naming timer is already pending. Uses a short delay (5s) since we already
416
- * have conversation context at this point. */
417
- retrySessionNamingOnInteraction(sessionId) {
418
- const session = this.sessions.get(sessionId);
419
- if (!session)
420
- return;
421
- if (!session.name.startsWith('hub:'))
422
- return;
423
- // Already have a timer pending or exhausted retries
424
- if (session._namingTimer)
425
- return;
426
- if (session._namingAttempts >= SessionManager.MAX_NAMING_ATTEMPTS)
427
- return;
428
- // Short delay — context already available from prior turns
429
- session._namingTimer = setTimeout(() => {
430
- delete session._namingTimer;
431
- this.executeSessionNaming(sessionId);
432
- }, 5_000);
433
- }
434
197
  /** Add a WebSocket client to a session. Returns the session or undefined if not found.
435
198
  * Re-broadcasts any pending approval/control prompts so the joining client sees them. */
436
199
  join(sessionId, ws) {
@@ -542,6 +305,9 @@ export class SessionManager {
542
305
  console.error('[session-manager] Failed to archive session:', err);
543
306
  }
544
307
  }
308
+ // ---------------------------------------------------------------------------
309
+ // Claude process lifecycle
310
+ // ---------------------------------------------------------------------------
545
311
  /**
546
312
  * Spawn (or re-spawn) a Claude CLI process for a session.
547
313
  * Wires up all event handlers for streaming text, tools, prompts, and auto-restart.
@@ -556,11 +322,16 @@ export class SessionManager {
556
322
  if (session.claudeProcess) {
557
323
  session.claudeProcess.stop();
558
324
  }
325
+ // Derive a session-scoped token instead of forwarding the master auth token.
326
+ // This limits child process privileges to approve/deny for their own session only.
327
+ const sessionToken = this._authToken
328
+ ? deriveSessionToken(this._authToken, sessionId)
329
+ : '';
559
330
  const extraEnv = {
560
331
  CODEKIN_SESSION_ID: sessionId,
561
332
  CODEKIN_PORT: String(this._serverPort || PORT),
562
- CODEKIN_TOKEN: this._authToken || '',
563
- CODEKIN_AUTH_TOKEN: this._authToken || '',
333
+ CODEKIN_TOKEN: sessionToken,
334
+ CODEKIN_AUTH_TOKEN: sessionToken,
564
335
  CODEKIN_SESSION_TYPE: session.source || 'manual',
565
336
  };
566
337
  // Pass CLAUDE_PROJECT_DIR so hooks resolve correctly even when the session's
@@ -770,7 +541,7 @@ export class SessionManager {
770
541
  clearTimeout(session._namingTimer);
771
542
  delete session._namingTimer;
772
543
  }
773
- void this.executeSessionNaming(sessionId);
544
+ void this.sessionNaming.executeSessionNaming(sessionId);
774
545
  }
775
546
  }
776
547
  /**
@@ -955,10 +726,10 @@ export class SessionManager {
955
726
  resolveToolApproval(session, approval, value) {
956
727
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
957
728
  if (isAlwaysAllow && !isDeny) {
958
- this.saveAlwaysAllow(session.workingDir, approval.toolName, approval.toolInput);
729
+ this.approvalManager.saveAlwaysAllow(session.workingDir, approval.toolName, approval.toolInput);
959
730
  }
960
731
  if (isApprovePattern && !isDeny) {
961
- this.savePatternApproval(session.workingDir, approval.toolName, approval.toolInput);
732
+ this.approvalManager.savePatternApproval(session.workingDir, approval.toolName, approval.toolInput);
962
733
  }
963
734
  console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
964
735
  approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
@@ -1002,10 +773,10 @@ export class SessionManager {
1002
773
  sendControlResponseForRequest(session, pending, value) {
1003
774
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
1004
775
  if (isAlwaysAllow) {
1005
- this.saveAlwaysAllow(session.workingDir, pending.toolName, pending.toolInput);
776
+ this.approvalManager.saveAlwaysAllow(session.workingDir, pending.toolName, pending.toolInput);
1006
777
  }
1007
778
  if (isApprovePattern) {
1008
- this.savePatternApproval(session.workingDir, pending.toolName, pending.toolInput);
779
+ this.approvalManager.savePatternApproval(session.workingDir, pending.toolName, pending.toolInput);
1009
780
  }
1010
781
  const behavior = isDeny ? 'deny' : 'allow';
1011
782
  session.claudeProcess.sendControlResponse(pending.requestId, behavior);
@@ -1132,16 +903,19 @@ export class SessionManager {
1132
903
  this.broadcast(session, { type: 'claude_stopped' });
1133
904
  }
1134
905
  }
906
+ // ---------------------------------------------------------------------------
907
+ // Helpers
908
+ // ---------------------------------------------------------------------------
909
+ /** Check if an error result text matches a transient API error worth retrying. */
910
+ isRetryableApiError(text) {
911
+ return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
912
+ }
1135
913
  /**
1136
914
  * Build a condensed text summary of a session's conversation history.
1137
915
  * Used as context when auto-starting Claude for sessions without a saved
1138
916
  * Claude session ID (so the CLI can't resume from its own storage).
1139
917
  * Caps output at ~4000 chars, keeping the most recent exchanges.
1140
918
  */
1141
- /** Check if an error result text matches a transient API error worth retrying. */
1142
- isRetryableApiError(text) {
1143
- return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
1144
- }
1145
919
  buildSessionContext(session) {
1146
920
  const history = session.outputHistory;
1147
921
  if (history.length === 0)
@@ -1310,163 +1084,6 @@ export class SessionManager {
1310
1084
  }
1311
1085
  }
1312
1086
  }
1313
- /** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
1314
- getApprovals(workingDir) {
1315
- const entry = this.repoApprovals.get(workingDir);
1316
- if (!entry)
1317
- return { tools: [], commands: [], patterns: [] };
1318
- return {
1319
- tools: Array.from(entry.tools).sort(),
1320
- commands: Array.from(entry.commands).sort(),
1321
- patterns: Array.from(entry.patterns).sort(),
1322
- };
1323
- }
1324
- /** Remove an auto-approval rule for a repo (workingDir) and persist to disk.
1325
- * Returns 'invalid' if no tool/command provided, or boolean indicating if something was deleted.
1326
- * Pass skipPersist=true for bulk operations (caller must call persistRepoApprovals after). */
1327
- removeApproval(workingDir, opts, skipPersist = false) {
1328
- const tool = typeof opts.tool === 'string' ? opts.tool.trim() : '';
1329
- const command = typeof opts.command === 'string' ? opts.command.trim() : '';
1330
- const pattern = typeof opts.pattern === 'string' ? opts.pattern.trim() : '';
1331
- if (!tool && !command && !pattern)
1332
- return 'invalid';
1333
- const entry = this.repoApprovals.get(workingDir);
1334
- if (!entry)
1335
- return false;
1336
- let removed = false;
1337
- if (tool)
1338
- removed = entry.tools.delete(tool) || removed;
1339
- if (command)
1340
- removed = entry.commands.delete(command) || removed;
1341
- if (pattern)
1342
- removed = entry.patterns.delete(pattern) || removed;
1343
- if (removed && !skipPersist)
1344
- this.persistRepoApprovals();
1345
- return removed;
1346
- }
1347
- /** Write all sessions to disk as JSON (atomic rename to prevent corruption). */
1348
- persistToDisk() {
1349
- const data = Array.from(this.sessions.values()).map((s) => ({
1350
- id: s.id,
1351
- name: s.name,
1352
- workingDir: s.workingDir,
1353
- groupDir: s.groupDir,
1354
- created: s.created,
1355
- source: s.source,
1356
- model: s.model,
1357
- claudeSessionId: s.claudeSessionId,
1358
- wasActive: s.claudeProcess?.isAlive() ?? false,
1359
- outputHistory: s.outputHistory,
1360
- }));
1361
- try {
1362
- mkdirSync(DATA_DIR, { recursive: true });
1363
- const tmp = SESSIONS_FILE + '.tmp';
1364
- writeFileSync(tmp, JSON.stringify(data, null, 2));
1365
- renameSync(tmp, SESSIONS_FILE);
1366
- }
1367
- catch (err) {
1368
- console.error('Failed to persist sessions:', err);
1369
- }
1370
- }
1371
- persistToDiskDebounced() {
1372
- if (this._persistTimer)
1373
- return;
1374
- this._persistTimer = setTimeout(() => {
1375
- this._persistTimer = null;
1376
- this.persistToDisk();
1377
- }, PERSIST_DEBOUNCE_MS);
1378
- }
1379
- /** Write repo-level approvals to disk (atomic rename). */
1380
- persistRepoApprovals() {
1381
- const data = {};
1382
- for (const [dir, entry] of this.repoApprovals) {
1383
- // Only persist non-empty entries
1384
- if (entry.tools.size > 0 || entry.commands.size > 0 || entry.patterns.size > 0) {
1385
- data[dir] = {
1386
- tools: Array.from(entry.tools).sort(),
1387
- commands: Array.from(entry.commands).sort(),
1388
- patterns: Array.from(entry.patterns).sort(),
1389
- };
1390
- }
1391
- }
1392
- try {
1393
- mkdirSync(DATA_DIR, { recursive: true });
1394
- const tmp = REPO_APPROVALS_FILE + '.tmp';
1395
- writeFileSync(tmp, JSON.stringify(data, null, 2));
1396
- renameSync(tmp, REPO_APPROVALS_FILE);
1397
- }
1398
- catch (err) {
1399
- console.error('Failed to persist repo approvals:', err);
1400
- }
1401
- }
1402
- persistRepoApprovalsDebounced() {
1403
- if (this._approvalPersistTimer)
1404
- return;
1405
- this._approvalPersistTimer = setTimeout(() => {
1406
- this._approvalPersistTimer = null;
1407
- this.persistRepoApprovals();
1408
- }, PERSIST_DEBOUNCE_MS);
1409
- }
1410
- restoreFromDisk() {
1411
- if (!existsSync(SESSIONS_FILE))
1412
- return;
1413
- try {
1414
- const raw = readFileSync(SESSIONS_FILE, 'utf-8');
1415
- const data = JSON.parse(raw);
1416
- for (const s of data) {
1417
- const session = {
1418
- id: s.id,
1419
- name: s.name,
1420
- workingDir: s.workingDir,
1421
- groupDir: s.groupDir,
1422
- created: s.created,
1423
- source: s.source ?? 'manual',
1424
- model: s.model,
1425
- claudeProcess: null,
1426
- clients: new Set(),
1427
- outputHistory: s.outputHistory || [],
1428
- // Restore claudeSessionId so Claude CLI resumes with full conversation
1429
- // history from its own session storage (not just our 4000-char summary).
1430
- claudeSessionId: s.claudeSessionId ?? null,
1431
- restartCount: 0,
1432
- lastRestartAt: null,
1433
- _stoppedByUser: false,
1434
- _stallTimer: null,
1435
- _wasActiveBeforeRestart: s.wasActive ?? false,
1436
- _apiRetryCount: 0,
1437
- _turnCount: 99, // restored sessions already have a name
1438
- _namingAttempts: 0,
1439
- isProcessing: false,
1440
- pendingControlRequests: new Map(),
1441
- pendingToolApprovals: new Map(),
1442
- };
1443
- this.sessions.set(session.id, session);
1444
- }
1445
- console.log(`Restored ${data.length} session(s) from disk`);
1446
- }
1447
- catch (err) {
1448
- console.error('Failed to restore sessions from disk:', err);
1449
- }
1450
- }
1451
- restoreRepoApprovalsFromDisk() {
1452
- if (!existsSync(REPO_APPROVALS_FILE))
1453
- return;
1454
- try {
1455
- const raw = readFileSync(REPO_APPROVALS_FILE, 'utf-8');
1456
- const data = JSON.parse(raw);
1457
- for (const [dir, entry] of Object.entries(data)) {
1458
- this.repoApprovals.set(dir, {
1459
- tools: new Set(entry.tools || []),
1460
- commands: new Set(entry.commands || []),
1461
- patterns: new Set(entry.patterns || []),
1462
- });
1463
- }
1464
- console.log(`Restored repo approvals for ${Object.keys(data).length} repo(s) from disk`);
1465
- }
1466
- catch (err) {
1467
- console.error('Failed to restore repo approvals from disk:', err);
1468
- }
1469
- }
1470
1087
  /**
1471
1088
  * Auto-restart Claude processes for sessions that were active before a server
1472
1089
  * restart. Each session is started with a staggered delay to avoid flooding.