codekin 0.4.1 → 0.5.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 (81) hide show
  1. package/README.md +12 -15
  2. package/bin/codekin.mjs +52 -32
  3. package/dist/assets/index-BwKZeT4V.css +1 -0
  4. package/dist/assets/index-CfBnNU24.js +186 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +2 -7
  7. package/server/dist/approval-manager.d.ts +7 -2
  8. package/server/dist/approval-manager.js +44 -78
  9. package/server/dist/approval-manager.js.map +1 -1
  10. package/server/dist/claude-process.d.ts +23 -3
  11. package/server/dist/claude-process.js +120 -27
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/commit-event-handler.js.map +1 -1
  14. package/server/dist/config.d.ts +4 -0
  15. package/server/dist/config.js +17 -0
  16. package/server/dist/config.js.map +1 -1
  17. package/server/dist/diff-manager.d.ts +41 -0
  18. package/server/dist/diff-manager.js +303 -0
  19. package/server/dist/diff-manager.js.map +1 -0
  20. package/server/dist/error-page.d.ts +5 -0
  21. package/server/dist/error-page.js +144 -0
  22. package/server/dist/error-page.js.map +1 -0
  23. package/server/dist/native-permissions.d.ts +44 -0
  24. package/server/dist/native-permissions.js +163 -0
  25. package/server/dist/native-permissions.js.map +1 -0
  26. package/server/dist/orchestrator-children.d.ts +74 -0
  27. package/server/dist/orchestrator-children.js +281 -0
  28. package/server/dist/orchestrator-children.js.map +1 -0
  29. package/server/dist/orchestrator-learning.d.ts +134 -0
  30. package/server/dist/orchestrator-learning.js +567 -0
  31. package/server/dist/orchestrator-learning.js.map +1 -0
  32. package/server/dist/orchestrator-manager.d.ts +25 -0
  33. package/server/dist/orchestrator-manager.js +353 -0
  34. package/server/dist/orchestrator-manager.js.map +1 -0
  35. package/server/dist/orchestrator-memory.d.ts +77 -0
  36. package/server/dist/orchestrator-memory.js +288 -0
  37. package/server/dist/orchestrator-memory.js.map +1 -0
  38. package/server/dist/orchestrator-monitor.d.ts +59 -0
  39. package/server/dist/orchestrator-monitor.js +238 -0
  40. package/server/dist/orchestrator-monitor.js.map +1 -0
  41. package/server/dist/orchestrator-reports.d.ts +45 -0
  42. package/server/dist/orchestrator-reports.js +124 -0
  43. package/server/dist/orchestrator-reports.js.map +1 -0
  44. package/server/dist/orchestrator-routes.d.ts +17 -0
  45. package/server/dist/orchestrator-routes.js +526 -0
  46. package/server/dist/orchestrator-routes.js.map +1 -0
  47. package/server/dist/session-archive.js +9 -2
  48. package/server/dist/session-archive.js.map +1 -1
  49. package/server/dist/session-manager.d.ts +99 -39
  50. package/server/dist/session-manager.js +565 -394
  51. package/server/dist/session-manager.js.map +1 -1
  52. package/server/dist/session-naming.d.ts +6 -10
  53. package/server/dist/session-naming.js +60 -62
  54. package/server/dist/session-naming.js.map +1 -1
  55. package/server/dist/session-persistence.d.ts +6 -1
  56. package/server/dist/session-persistence.js +6 -0
  57. package/server/dist/session-persistence.js.map +1 -1
  58. package/server/dist/session-restart-scheduler.d.ts +30 -0
  59. package/server/dist/session-restart-scheduler.js +41 -0
  60. package/server/dist/session-restart-scheduler.js.map +1 -0
  61. package/server/dist/session-routes.js +122 -61
  62. package/server/dist/session-routes.js.map +1 -1
  63. package/server/dist/stepflow-types.d.ts +1 -1
  64. package/server/dist/tsconfig.tsbuildinfo +1 -1
  65. package/server/dist/types.d.ts +34 -2
  66. package/server/dist/types.js +8 -1
  67. package/server/dist/types.js.map +1 -1
  68. package/server/dist/upload-routes.js +7 -1
  69. package/server/dist/upload-routes.js.map +1 -1
  70. package/server/dist/version-check.d.ts +17 -0
  71. package/server/dist/version-check.js +89 -0
  72. package/server/dist/version-check.js.map +1 -0
  73. package/server/dist/workflow-engine.d.ts +74 -1
  74. package/server/dist/workflow-engine.js +20 -1
  75. package/server/dist/workflow-engine.js.map +1 -1
  76. package/server/dist/ws-message-handler.js +115 -9
  77. package/server/dist/ws-message-handler.js.map +1 -1
  78. package/server/dist/ws-server.js +90 -15
  79. package/server/dist/ws-server.js.map +1 -1
  80. package/dist/assets/index-BAdQqYEY.js +0 -182
  81. package/dist/assets/index-CeZYNLWt.css +0 -1
@@ -14,10 +14,13 @@
14
14
  * - ApprovalManager: repo-level auto-approval rules for tools/commands
15
15
  * - SessionNaming: AI-powered session name generation with retry logic
16
16
  * - SessionPersistence: disk I/O for session state
17
+ * - DiffManager: stateless git-diff operations
18
+ * - evaluateRestart: pure restart-decision logic
17
19
  */
18
20
  import { randomUUID } from 'crypto';
19
21
  import { execFile } from 'child_process';
20
- import { promises as fs } from 'fs';
22
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs';
23
+ import { homedir } from 'os';
21
24
  import path from 'path';
22
25
  import { promisify } from 'util';
23
26
  import { ClaudeProcess } from './claude-process.js';
@@ -28,87 +31,11 @@ import { ApprovalManager } from './approval-manager.js';
28
31
  import { SessionNaming } from './session-naming.js';
29
32
  import { SessionPersistence } from './session-persistence.js';
30
33
  import { deriveSessionToken } from './crypto-utils.js';
31
- import { parseDiff, createUntrackedFileDiff } from './diff-parser.js';
34
+ import { cleanGitEnv, DiffManager } from './diff-manager.js';
35
+ import { evaluateRestart } from './session-restart-scheduler.js';
32
36
  const execFileAsync = promisify(execFile);
33
- /** Max stdout for git commands (2 MB). */
34
- const GIT_MAX_BUFFER = 2 * 1024 * 1024;
35
- /** Timeout for git commands (10 seconds). */
36
- const GIT_TIMEOUT_MS = 10_000;
37
- /** Max paths per git command to stay under ARG_MAX (~128 KB on Linux). */
38
- const GIT_PATH_CHUNK_SIZE = 200;
39
- /** Run a git command as a fixed argv array (no shell interpolation). */
40
- async function execGit(args, cwd) {
41
- const { stdout } = await execFileAsync('git', args, {
42
- cwd,
43
- maxBuffer: GIT_MAX_BUFFER,
44
- timeout: GIT_TIMEOUT_MS,
45
- });
46
- return stdout;
47
- }
48
- /** Run a git command with paths chunked to avoid E2BIG. Concatenates stdout. */
49
- async function execGitChunked(baseArgs, paths, cwd) {
50
- let result = '';
51
- for (let i = 0; i < paths.length; i += GIT_PATH_CHUNK_SIZE) {
52
- const chunk = paths.slice(i, i + GIT_PATH_CHUNK_SIZE);
53
- result += await execGit([...baseArgs, '--', ...chunk], cwd);
54
- }
55
- return result;
56
- }
57
- /** Get file statuses from `git status --porcelain` for given paths (or all). */
58
- async function getFileStatuses(cwd, paths) {
59
- const args = ['status', '--porcelain', '-z'];
60
- if (paths)
61
- args.push('--', ...paths);
62
- const raw = await execGit(args, cwd);
63
- const result = {};
64
- // git status --porcelain=v1 -z format: XY NUL path NUL
65
- // XY is a two-character status code: X = index status, Y = worktree status.
66
- // Examples: " M" = unstaged modification, "A " = staged addition, "??" = untracked.
67
- // For renames/copies: XY NUL oldpath NUL newpath NUL
68
- const parts = raw.split('\0');
69
- let i = 0;
70
- while (i < parts.length) {
71
- const entry = parts[i];
72
- if (entry.length < 3) {
73
- i++;
74
- continue;
75
- } // skip empty trailing entries
76
- const x = entry[0]; // index status
77
- const y = entry[1]; // worktree status
78
- const filePath = entry.slice(3);
79
- if (x === 'R' || x === 'C') {
80
- // Rename/copy: next NUL-separated part is the new path
81
- const newPath = parts[i + 1] ?? filePath;
82
- result[newPath] = 'renamed';
83
- i += 2;
84
- }
85
- else if (x === 'D' || y === 'D') {
86
- result[filePath] = 'deleted';
87
- i++;
88
- }
89
- else if (x === '?' && y === '?') {
90
- result[filePath] = 'added';
91
- i++;
92
- }
93
- else if (x === 'A') {
94
- result[filePath] = 'added';
95
- i++;
96
- }
97
- else {
98
- result[filePath] = 'modified';
99
- i++;
100
- }
101
- }
102
- return result;
103
- }
104
37
  /** Max messages retained in a session's output history buffer. */
105
38
  const MAX_HISTORY = 2000;
106
- /** Max auto-restart attempts before requiring manual intervention. */
107
- const MAX_RESTARTS = 3;
108
- /** Window after which the restart counter resets (5 minutes). */
109
- const RESTART_COOLDOWN_MS = 5 * 60 * 1000;
110
- /** Delay between crash and auto-restart attempt. */
111
- const RESTART_DELAY_MS = 2000;
112
39
  /** No-output duration before emitting a stall warning (5 minutes). */
113
40
  const STALL_TIMEOUT_MS = 5 * 60 * 1000;
114
41
  /** Max API error retries per turn before giving up. */
@@ -141,55 +68,36 @@ export class SessionManager {
141
68
  _globalBroadcast = null;
142
69
  /** Registered listeners notified when a session's Claude process exits (used by webhook-handler for chained workflows). */
143
70
  _exitListeners = [];
71
+ /** Registered listeners notified when a session emits a prompt (permission or question). */
72
+ _promptListeners = [];
73
+ /** Registered listeners notified when a session completes a turn (result event). */
74
+ _resultListeners = [];
144
75
  /** Delegated approval logic (auto-approve patterns, deny-lists, pattern management). */
145
- approvalManager;
76
+ _approvalManager;
146
77
  /** Delegated auto-naming logic (generates session names from first user message via Claude API). */
147
78
  sessionNaming;
148
79
  /** Delegated persistence logic (saves/restores session metadata to disk across server restarts). */
149
80
  sessionPersistence;
81
+ /** Delegated diff operations (git diff, discard changes). */
82
+ diffManager;
150
83
  constructor() {
151
84
  this.archive = new SessionArchive();
152
- this.approvalManager = new ApprovalManager();
85
+ this._approvalManager = new ApprovalManager();
86
+ this.diffManager = new DiffManager();
153
87
  this.sessionPersistence = new SessionPersistence(this.sessions);
154
88
  this.sessionNaming = new SessionNaming({
155
89
  getSession: (id) => this.sessions.get(id),
156
90
  hasSession: (id) => this.sessions.has(id),
157
- getSetting: (key, fallback) => this.archive.getSetting(key, fallback),
158
91
  rename: (sessionId, newName) => this.rename(sessionId, newName),
159
92
  });
160
93
  this.sessionPersistence.restoreFromDisk();
161
94
  }
162
95
  // ---------------------------------------------------------------------------
163
- // Approval delegation (preserves public API)
96
+ // Approval direct accessor (callers use sessions.approvalManager.xxx)
164
97
  // ---------------------------------------------------------------------------
165
- /** Check if a tool/command is auto-approved for a repo. */
166
- checkAutoApproval(workingDir, toolName, toolInput) {
167
- return this.approvalManager.checkAutoApproval(workingDir, toolName, toolInput);
168
- }
169
- /** Derive a glob pattern from a tool invocation for "Approve Pattern". */
170
- derivePattern(toolName, toolInput) {
171
- return this.approvalManager.derivePattern(toolName, toolInput);
172
- }
173
- /** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
174
- getApprovals(workingDir) {
175
- return this.approvalManager.getApprovals(workingDir);
176
- }
177
- /** Return approvals effective globally via cross-repo inference. */
178
- getGlobalApprovals() {
179
- return this.approvalManager.getGlobalApprovals();
180
- }
181
- /** Remove an auto-approval rule for a repo (workingDir) and persist to disk. */
182
- removeApproval(workingDir, opts, skipPersist = false) {
183
- return this.approvalManager.removeApproval(workingDir, opts, skipPersist);
184
- }
185
- /** Add an auto-approval rule for a repo and persist (used by tests via `as any`). */
186
- /* @ts-expect-error noUnusedLocals — accessed by tests via (sm as any).addRepoApproval */
187
- addRepoApproval(workingDir, opts) {
188
- this.approvalManager.addRepoApproval(workingDir, opts);
189
- }
190
- /** Write repo-level approvals to disk. Exposed for shutdown. */
191
- persistRepoApprovals() {
192
- this.approvalManager.persistRepoApprovals();
98
+ /** Direct access to the approval manager for callers that need repo-level approval operations. */
99
+ get approvalManager() {
100
+ return this._approvalManager;
193
101
  }
194
102
  // ---------------------------------------------------------------------------
195
103
  // Naming delegation (preserves public API)
@@ -226,6 +134,8 @@ export class SessionManager {
226
134
  created: new Date().toISOString(),
227
135
  source: options?.source ?? 'manual',
228
136
  model: options?.model,
137
+ permissionMode: options?.permissionMode,
138
+ allowedTools: options?.allowedTools,
229
139
  claudeProcess: null,
230
140
  clients: new Set(),
231
141
  outputHistory: [],
@@ -248,16 +158,242 @@ export class SessionManager {
248
158
  this._globalBroadcast?.({ type: 'sessions_updated' });
249
159
  return session;
250
160
  }
161
+ /**
162
+ * Create a git worktree for a session. Creates a new branch and worktree
163
+ * as a sibling directory of the project root.
164
+ * Returns the worktree path on success, or null on failure.
165
+ */
166
+ async createWorktree(sessionId, workingDir) {
167
+ const session = this.sessions.get(sessionId);
168
+ if (!session)
169
+ return null;
170
+ try {
171
+ // Resolve the actual git repo root — workingDir may be a subdirectory
172
+ const env = cleanGitEnv();
173
+ const { stdout: repoRootRaw } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
174
+ cwd: workingDir,
175
+ env,
176
+ timeout: 5000,
177
+ });
178
+ const repoRoot = repoRootRaw.trim();
179
+ if (!repoRoot || !path.isAbsolute(repoRoot)) {
180
+ console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
181
+ return null;
182
+ }
183
+ const prefix = this.getWorktreeBranchPrefix();
184
+ const shortId = sessionId.slice(0, 8);
185
+ const branchName = `${prefix}${shortId}`;
186
+ const projectName = path.basename(repoRoot);
187
+ const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
188
+ // Clean up stale state from previous failed attempts:
189
+ // 1. Prune orphaned worktree entries (directory gone but git still tracks it)
190
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
191
+ .catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
192
+ // 2. Remove existing worktree directory if leftover from a partial failure
193
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
194
+ .catch(() => { }); // Expected to fail if no prior worktree exists
195
+ // 3. Delete the branch if it exists (leftover from a failed worktree add)
196
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
197
+ .catch((e) => console.debug(`[worktree] branch cleanup (expected if fresh):`, e instanceof Error ? e.message : e));
198
+ // Create the worktree with a new branch
199
+ await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath], {
200
+ cwd: repoRoot,
201
+ env,
202
+ timeout: 15000,
203
+ });
204
+ // Update session to use the worktree as its working directory
205
+ session.groupDir = repoRoot; // Group under original repo in sidebar
206
+ session.workingDir = worktreePath;
207
+ session.worktreePath = worktreePath;
208
+ // Copy Claude CLI session data to the worktree's project storage dir.
209
+ // startClaude() will use --resume (not --session-id) to continue the
210
+ // session, which should find the JSONL globally. The copy here ensures
211
+ // it's also available in the worktree's project dir as a safety net.
212
+ if (session.claudeSessionId) {
213
+ try {
214
+ this.migrateClaudeSession(session.claudeSessionId, session.claudeSessionId, workingDir, worktreePath, session);
215
+ console.log(`[worktree] Copied Claude session ${session.claudeSessionId} to worktree project dir`);
216
+ }
217
+ catch (err) {
218
+ console.warn(`[worktree] Failed to migrate session data:`, err instanceof Error ? err.message : err);
219
+ }
220
+ }
221
+ this.persistToDisk();
222
+ this._globalBroadcast?.({ type: 'sessions_updated' });
223
+ console.log(`[worktree] Created worktree for session ${sessionId}: ${worktreePath} (branch: ${branchName})`);
224
+ return worktreePath;
225
+ }
226
+ catch (err) {
227
+ console.error(`[worktree] Failed to create worktree for session ${sessionId}:`, err);
228
+ return null;
229
+ }
230
+ }
231
+ /**
232
+ * Resolve the Claude CLI project storage directory for a given working dir.
233
+ * Claude encodes the absolute path by replacing `/` with `-`.
234
+ */
235
+ claudeProjectPath(cwd) {
236
+ const encoded = cwd.replace(/\//g, '-');
237
+ return path.join(homedir(), '.claude', 'projects', encoded);
238
+ }
239
+ /**
240
+ * Copy Claude CLI session data from the original project storage to
241
+ * the target directory's project storage. When oldId === newId the
242
+ * file is copied without renaming, preserving internal sessionId fields.
243
+ * Claude CLI determines session storage from the CWD, so for worktree
244
+ * migrations the JSONL must be placed in the worktree's project dir.
245
+ */
246
+ migrateClaudeSession(oldId, newId, originalDir, targetDir, session) {
247
+ const srcProjectDir = this.claudeProjectPath(originalDir);
248
+ const dstProjectDir = this.claudeProjectPath(targetDir);
249
+ const srcJsonl = path.join(srcProjectDir, `${oldId}.jsonl`);
250
+ if (!existsSync(srcJsonl)) {
251
+ console.warn(`[worktree] No session JSONL at ${srcJsonl}, conversation history will not be preserved`);
252
+ if (session) {
253
+ const warningMsg = {
254
+ type: 'system_message',
255
+ subtype: 'notification',
256
+ text: 'Conversation history could not be preserved during worktree migration. The session will continue without prior context.',
257
+ };
258
+ this.addToHistory(session, warningMsg);
259
+ this.broadcast(session, warningMsg);
260
+ }
261
+ return;
262
+ }
263
+ // Claude CLI determines session storage from the CWD, not CLAUDE_PROJECT_DIR.
264
+ // The worktree has a different CWD, so we must copy the JSONL into the
265
+ // worktree's project storage directory for --session-id to find it.
266
+ mkdirSync(dstProjectDir, { recursive: true });
267
+ copyFileSync(srcJsonl, path.join(dstProjectDir, `${newId}.jsonl`));
268
+ console.log(`[worktree] Copied session JSONL ${oldId} → ${newId} (${srcProjectDir} → ${dstProjectDir})`);
269
+ // Copy session subdirectory (subagents/, tool-results/) if it exists
270
+ const srcSessionDir = path.join(srcProjectDir, oldId);
271
+ if (existsSync(srcSessionDir) && statSync(srcSessionDir).isDirectory()) {
272
+ this.copyDirRecursive(srcSessionDir, path.join(dstProjectDir, newId));
273
+ console.log(`[worktree] Copied session subdirectory ${oldId} → ${newId}`);
274
+ }
275
+ }
276
+ /** Recursively copy a directory. */
277
+ copyDirRecursive(src, dst) {
278
+ mkdirSync(dst, { recursive: true });
279
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
280
+ const srcPath = path.join(src, entry.name);
281
+ const dstPath = path.join(dst, entry.name);
282
+ if (entry.isDirectory()) {
283
+ this.copyDirRecursive(srcPath, dstPath);
284
+ }
285
+ else {
286
+ copyFileSync(srcPath, dstPath);
287
+ }
288
+ }
289
+ }
290
+ /**
291
+ * Clean up a git worktree and its branch. Runs asynchronously and logs errors
292
+ * but never throws — session deletion must not be blocked by cleanup failures.
293
+ */
294
+ cleanupWorktree(worktreePath, repoDir) {
295
+ void (async () => {
296
+ try {
297
+ // Resolve the actual repo root (repoDir may itself be a worktree)
298
+ const { stdout: repoRootRaw } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
299
+ cwd: repoDir,
300
+ timeout: 5000,
301
+ }).catch(() => ({ stdout: repoDir }));
302
+ const repoRoot = repoRootRaw.trim() || repoDir;
303
+ // Remove the worktree
304
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], {
305
+ cwd: repoRoot,
306
+ timeout: 10000,
307
+ });
308
+ console.log(`[worktree] Cleaned up worktree: ${worktreePath}`);
309
+ // Prune any stale worktree references
310
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, timeout: 5000 })
311
+ .catch(() => { });
312
+ }
313
+ catch (err) {
314
+ console.warn(`[worktree] Failed to clean up worktree ${worktreePath}:`, err instanceof Error ? err.message : err);
315
+ }
316
+ })();
317
+ }
318
+ /** Get the configured worktree branch prefix (defaults to 'wt/'). */
319
+ getWorktreeBranchPrefix() {
320
+ return this.archive.getSetting('worktree_branch_prefix', 'wt/');
321
+ }
322
+ /** Set the worktree branch prefix. */
323
+ setWorktreeBranchPrefix(prefix) {
324
+ this.archive.setSetting('worktree_branch_prefix', prefix);
325
+ }
251
326
  /** Register a listener called when any session's Claude process exits.
252
327
  * The `willRestart` flag indicates whether the session will be auto-restarted. */
253
328
  onSessionExit(listener) {
254
329
  this._exitListeners.push(listener);
255
330
  }
331
+ /** Register a listener called when any session emits a prompt (permission request or question). */
332
+ onSessionPrompt(listener) {
333
+ this._promptListeners.push(listener);
334
+ }
335
+ /** Register a listener called when any session completes a turn (result event). */
336
+ onSessionResult(listener) {
337
+ this._resultListeners.push(listener);
338
+ }
256
339
  get(id) {
257
340
  return this.sessions.get(id);
258
341
  }
342
+ /** Get all sessions that have pending prompts (waiting for approval or answer). */
343
+ getPendingPrompts() {
344
+ const results = [];
345
+ for (const session of this.sessions.values()) {
346
+ const prompts = [];
347
+ for (const [reqId, pending] of session.pendingToolApprovals) {
348
+ prompts.push({
349
+ requestId: reqId,
350
+ promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
351
+ toolName: pending.toolName,
352
+ toolInput: pending.toolInput,
353
+ });
354
+ }
355
+ for (const [reqId, pending] of session.pendingControlRequests) {
356
+ prompts.push({
357
+ requestId: reqId,
358
+ promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
359
+ toolName: pending.toolName,
360
+ toolInput: pending.toolInput,
361
+ });
362
+ }
363
+ if (prompts.length > 0) {
364
+ results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
365
+ }
366
+ }
367
+ return results;
368
+ }
369
+ /** Clear the isProcessing flag for a session and broadcast the update. */
370
+ clearProcessingFlag(sessionId) {
371
+ const session = this.sessions.get(sessionId);
372
+ if (session && session.isProcessing) {
373
+ session.isProcessing = false;
374
+ this._globalBroadcast?.({ type: 'sessions_updated' });
375
+ }
376
+ }
259
377
  list() {
260
- return Array.from(this.sessions.values()).map((s) => ({
378
+ return Array.from(this.sessions.values())
379
+ .map((s) => ({
380
+ id: s.id,
381
+ name: s.name,
382
+ created: s.created,
383
+ active: s.claudeProcess?.isAlive() ?? false,
384
+ isProcessing: s.isProcessing,
385
+ workingDir: s.workingDir,
386
+ groupDir: s.groupDir,
387
+ worktreePath: s.worktreePath,
388
+ connectedClients: s.clients.size,
389
+ lastActivity: s.created,
390
+ source: s.source,
391
+ }));
392
+ }
393
+ /** List ALL sessions including orchestrator — used by orchestrator cleanup endpoints. */
394
+ listAll() {
395
+ return Array.from(this.sessions.values())
396
+ .map((s) => ({
261
397
  id: s.id,
262
398
  name: s.name,
263
399
  created: s.created,
@@ -265,6 +401,7 @@ export class SessionManager {
265
401
  isProcessing: s.isProcessing,
266
402
  workingDir: s.workingDir,
267
403
  groupDir: s.groupDir,
404
+ worktreePath: s.worktreePath,
268
405
  connectedClients: s.clients.size,
269
406
  lastActivity: s.created,
270
407
  source: s.source,
@@ -364,6 +501,10 @@ export class SessionManager {
364
501
  session.claudeProcess = null;
365
502
  }
366
503
  this.archiveSessionIfWorthSaving(session);
504
+ // Clean up git worktree if this session used one
505
+ if (session.worktreePath) {
506
+ this.cleanupWorktree(session.worktreePath, session.groupDir ?? session.workingDir);
507
+ }
367
508
  // Clean up webhook workspace directory if applicable
368
509
  if (session.source === 'webhook' || session.source === 'stepflow') {
369
510
  cleanupWorkspace(sessionId);
@@ -431,6 +572,8 @@ export class SessionManager {
431
572
  const sessionToken = this._authToken
432
573
  ? deriveSessionToken(this._authToken, sessionId)
433
574
  : '';
575
+ // Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
576
+ // (current canonical name) are set to the same derived value for backward compatibility.
434
577
  const extraEnv = {
435
578
  CODEKIN_SESSION_ID: sessionId,
436
579
  CODEKIN_PORT: String(this._serverPort || PORT),
@@ -438,12 +581,25 @@ export class SessionManager {
438
581
  CODEKIN_AUTH_TOKEN: sessionToken,
439
582
  CODEKIN_SESSION_TYPE: session.source || 'manual',
440
583
  };
441
- // Pass CLAUDE_PROJECT_DIR so hooks resolve correctly even when the session's
442
- // working directory differs from the project root (e.g. webhook workspaces).
443
- if (process.env.CLAUDE_PROJECT_DIR) {
584
+ // Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
585
+ // even when the session's working directory differs from the project root
586
+ // (e.g. worktrees, webhook workspaces). Note: this does NOT control
587
+ // session storage path — Claude CLI uses the CWD for that.
588
+ if (session.groupDir) {
589
+ extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
590
+ }
591
+ else if (process.env.CLAUDE_PROJECT_DIR) {
444
592
  extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
445
593
  }
446
- const cp = new ClaudeProcess(session.workingDir, session.claudeSessionId || undefined, extraEnv, session.model);
594
+ // When claudeSessionId exists, the session has run before and a JSONL file
595
+ // exists on disk. Use --resume (not --session-id) to continue it — --session-id
596
+ // creates a *new* session and fails with "already in use" if the JSONL exists.
597
+ const resume = !!session.claudeSessionId;
598
+ // Build comprehensive allowedTools from session-level overrides + registry approvals
599
+ const repoDir = session.groupDir ?? session.workingDir;
600
+ const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
601
+ const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
602
+ const cp = new ClaudeProcess(session.workingDir, session.claudeSessionId || undefined, extraEnv, session.model, session.permissionMode, resume, mergedAllowedTools);
447
603
  this.wireClaudeEvents(cp, session, sessionId);
448
604
  cp.start();
449
605
  session.claudeProcess = cp;
@@ -530,6 +686,13 @@ export class SessionManager {
530
686
  session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
531
687
  }
532
688
  this.broadcast(session, promptMsg);
689
+ // Notify prompt listeners (orchestrator, child monitor, etc.)
690
+ for (const listener of this._promptListeners) {
691
+ try {
692
+ listener(session.id, promptType, toolName, requestId);
693
+ }
694
+ catch { /* listener error */ }
695
+ }
533
696
  }
534
697
  onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
535
698
  if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
@@ -542,10 +705,23 @@ export class SessionManager {
542
705
  cp.sendControlResponse(requestId, 'allow');
543
706
  return;
544
707
  }
708
+ // Prevent double-gating: if a PreToolUse hook is already handling approval
709
+ // for this tool, auto-approve the control_request to avoid duplicate entries.
710
+ // Without this, both pendingToolApprovals and pendingControlRequests contain
711
+ // entries for the same tool invocation, causing stale-entry races when the
712
+ // orchestrator tries to respond via the REST API.
713
+ for (const pending of session.pendingToolApprovals.values()) {
714
+ if (pending.toolName === toolName) {
715
+ console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
716
+ cp.sendControlResponse(requestId, 'allow');
717
+ return;
718
+ }
719
+ }
545
720
  const question = this.summarizeToolPermission(toolName, toolInput);
721
+ const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
546
722
  const options = [
547
723
  { label: 'Allow', value: 'allow' },
548
- { label: 'Always Allow', value: 'always_allow' },
724
+ ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
549
725
  { label: 'Deny', value: 'deny' },
550
726
  ];
551
727
  const promptMsg = {
@@ -569,6 +745,13 @@ export class SessionManager {
569
745
  sessionName: session.name,
570
746
  });
571
747
  }
748
+ // Notify prompt listeners (orchestrator, child monitor, etc.)
749
+ for (const listener of this._promptListeners) {
750
+ try {
751
+ listener(sessionId, 'permission', toolName, requestId);
752
+ }
753
+ catch { /* listener error */ }
754
+ }
572
755
  }
573
756
  /**
574
757
  * Handle a Claude process 'result' event: update session state, apply API
@@ -626,6 +809,13 @@ export class SessionManager {
626
809
  const resultMsg = { type: 'result' };
627
810
  this.addToHistory(session, resultMsg);
628
811
  this.broadcast(session, resultMsg);
812
+ // Notify result listeners (orchestrator, child monitor, etc.)
813
+ for (const listener of this._resultListeners) {
814
+ try {
815
+ listener(sessionId, isError);
816
+ }
817
+ catch { /* listener error */ }
818
+ }
629
819
  // If session is still unnamed after first response, name it now — we have full context
630
820
  if (session.name.startsWith('hub:') && session._namingAttempts === 0) {
631
821
  if (session._namingTimer) {
@@ -638,15 +828,21 @@ export class SessionManager {
638
828
  /**
639
829
  * Handle a Claude process 'exit' event: clean up state, notify exit listeners,
640
830
  * and either auto-restart (within limits) or broadcast the final exit message.
831
+ *
832
+ * Uses evaluateRestart() for the restart decision, keeping this method focused
833
+ * on state updates, listener notification, and message broadcasting.
641
834
  */
642
835
  handleClaudeExit(session, sessionId, code, signal) {
643
836
  session.claudeProcess = null;
644
837
  session.isProcessing = false;
645
838
  this.clearStallTimer(session);
646
839
  this._globalBroadcast?.({ type: 'sessions_updated' });
647
- // If stopped by user (stop button or session deleted), don't auto-restart
648
- if (session._stoppedByUser) {
649
- // Notify exit listeners — no restart will occur
840
+ const action = evaluateRestart({
841
+ restartCount: session.restartCount,
842
+ lastRestartAt: session.lastRestartAt,
843
+ stoppedByUser: session._stoppedByUser,
844
+ });
845
+ if (action.kind === 'stopped_by_user') {
650
846
  for (const listener of this._exitListeners) {
651
847
  try {
652
848
  listener(sessionId, code, signal, false);
@@ -659,34 +855,19 @@ export class SessionManager {
659
855
  this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
660
856
  return;
661
857
  }
662
- // Auto-restart logic
663
- const now = Date.now();
664
- if (session.lastRestartAt && (now - session.lastRestartAt) > RESTART_COOLDOWN_MS) {
665
- session.restartCount = 0;
666
- }
667
- if (session.restartCount < MAX_RESTARTS) {
668
- session.restartCount++;
669
- session.lastRestartAt = now;
670
- const attempt = session.restartCount;
671
- // Notify exit listeners with willRestart=true so they don't treat
672
- // this as a final exit (e.g. webhook handler won't mark event as error)
858
+ if (action.kind === 'restart') {
859
+ session.restartCount = action.updatedCount;
860
+ session.lastRestartAt = action.updatedLastRestartAt;
673
861
  for (const listener of this._exitListeners) {
674
862
  try {
675
863
  listener(sessionId, code, signal, true);
676
864
  }
677
865
  catch { /* listener error */ }
678
866
  }
679
- // If Claude exited with code=1 on first attempt and we had a saved
680
- // session ID, it may be stale/invalid. Clear it so the next retry
681
- // starts a fresh session instead of repeating the same failure.
682
- if (code === 1 && attempt === 1 && session.claudeSessionId) {
683
- console.log(`[restart] Clearing potentially stale claudeSessionId for session ${sessionId}`);
684
- session.claudeSessionId = null;
685
- }
686
867
  const msg = {
687
868
  type: 'system_message',
688
869
  subtype: 'restart',
689
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${attempt}/${MAX_RESTARTS})...`,
870
+ text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
690
871
  };
691
872
  this.addToHistory(session, msg);
692
873
  this.broadcast(session, msg);
@@ -694,26 +875,38 @@ export class SessionManager {
694
875
  // Verify session still exists and hasn't been stopped
695
876
  if (!this.sessions.has(sessionId) || session._stoppedByUser)
696
877
  return;
878
+ // startClaude uses --resume when claudeSessionId exists, so the CLI
879
+ // picks up the full conversation history from the JSONL automatically.
697
880
  this.startClaude(sessionId);
698
- }, RESTART_DELAY_MS);
699
- }
700
- else {
701
- // Final exit all restart attempts exhausted
702
- for (const listener of this._exitListeners) {
703
- try {
704
- listener(sessionId, code, signal, false);
881
+ // Fallback: if claudeSessionId was already null (fresh session that
882
+ // crashed before system_init), inject a context summary so the new
883
+ // session has some awareness of prior conversation.
884
+ if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
885
+ session.claudeProcess.once('system_init', () => {
886
+ const context = this.buildSessionContext(session);
887
+ if (context) {
888
+ session.claudeProcess?.sendMessage(context + '\n\n[Session resumed after process restart. Continue where you left off. If you were in the middle of a task, resume it.]');
889
+ }
890
+ });
705
891
  }
706
- catch { /* listener error */ }
892
+ }, action.delayMs);
893
+ return;
894
+ }
895
+ // action.kind === 'exhausted'
896
+ for (const listener of this._exitListeners) {
897
+ try {
898
+ listener(sessionId, code, signal, false);
707
899
  }
708
- const msg = {
709
- type: 'system_message',
710
- subtype: 'error',
711
- text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${MAX_RESTARTS} attempts. Please restart manually.`,
712
- };
713
- this.addToHistory(session, msg);
714
- this.broadcast(session, msg);
715
- this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
900
+ catch { /* listener error */ }
716
901
  }
902
+ const msg = {
903
+ type: 'system_message',
904
+ subtype: 'error',
905
+ text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
906
+ };
907
+ this.addToHistory(session, msg);
908
+ this.broadcast(session, msg);
909
+ this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
717
910
  }
718
911
  /**
719
912
  * Send user input to a session's Claude process.
@@ -843,17 +1036,32 @@ export class SessionManager {
843
1036
  }
844
1037
  /** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
845
1038
  resolveToolApproval(session, approval, value) {
1039
+ // AskUserQuestion: the value IS the user's answer, not a permission decision
1040
+ if (approval.toolName === 'AskUserQuestion') {
1041
+ const answer = Array.isArray(value) ? value.join(', ') : value;
1042
+ console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
1043
+ approval.resolve({ allow: true, always: false, answer });
1044
+ session.pendingToolApprovals.delete(approval.requestId);
1045
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1046
+ return;
1047
+ }
846
1048
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
847
1049
  if (isAlwaysAllow && !isDeny) {
848
- this.approvalManager.saveAlwaysAllow(session.workingDir, approval.toolName, approval.toolInput);
1050
+ this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
849
1051
  }
850
1052
  if (isApprovePattern && !isDeny) {
851
- this.approvalManager.savePatternApproval(session.workingDir, approval.toolName, approval.toolInput);
1053
+ this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
852
1054
  }
853
1055
  console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
854
1056
  approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
855
1057
  session.pendingToolApprovals.delete(approval.requestId);
856
1058
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
1059
+ // When ExitPlanMode is approved via the PreToolUse hook, immediately clear
1060
+ // pending state and emit planning_mode:false. The control_request path may
1061
+ // never arrive (or arrive as is_error=true), so this ensures plan mode exits.
1062
+ if (approval.toolName === 'ExitPlanMode' && !isDeny) {
1063
+ session.claudeProcess?.clearPendingExitPlanMode();
1064
+ }
857
1065
  }
858
1066
  /**
859
1067
  * Send an AskUserQuestion control response, mapping the user's answer(s) into
@@ -892,10 +1100,10 @@ export class SessionManager {
892
1100
  sendControlResponseForRequest(session, pending, value) {
893
1101
  const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
894
1102
  if (isAlwaysAllow) {
895
- this.approvalManager.saveAlwaysAllow(session.workingDir, pending.toolName, pending.toolInput);
1103
+ this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
896
1104
  }
897
1105
  if (isApprovePattern) {
898
- this.approvalManager.savePatternApproval(session.workingDir, pending.toolName, pending.toolInput);
1106
+ this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
899
1107
  }
900
1108
  const behavior = isDeny ? 'deny' : 'allow';
901
1109
  session.claudeProcess.sendControlResponse(pending.requestId, behavior);
@@ -915,11 +1123,31 @@ export class SessionManager {
915
1123
  console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
916
1124
  return Promise.resolve({ allow: true, always: true });
917
1125
  }
1126
+ if (autoResult === 'session') {
1127
+ console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
1128
+ return Promise.resolve({ allow: true, always: false });
1129
+ }
918
1130
  if (autoResult === 'headless') {
919
1131
  console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
920
1132
  return Promise.resolve({ allow: true, always: false });
921
1133
  }
922
1134
  console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
1135
+ // Prevent double-gating: if a control_request already created a pending
1136
+ // entry for this tool, auto-approve the control_request and let the hook
1137
+ // take over as the sole approval gate. This is the reverse of the check
1138
+ // in onControlRequestEvent (which handles hook-first ordering).
1139
+ for (const [reqId, pending] of session.pendingControlRequests) {
1140
+ if (pending.toolName === toolName) {
1141
+ console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
1142
+ session.claudeProcess?.sendControlResponse(reqId, 'allow');
1143
+ session.pendingControlRequests.delete(reqId);
1144
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
1145
+ break;
1146
+ }
1147
+ }
1148
+ // AskUserQuestion: show a question prompt and collect the answer text,
1149
+ // rather than a permission prompt with Allow/Deny buttons.
1150
+ const isQuestion = toolName === 'AskUserQuestion';
923
1151
  return new Promise((resolve) => {
924
1152
  // Holder lets wrappedResolve reference the timeout before it's assigned
925
1153
  const timer = { id: null };
@@ -939,24 +1167,57 @@ export class SessionManager {
939
1167
  this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
940
1168
  resolve({ allow: false, always: false });
941
1169
  }
942
- }, 60_000);
943
- const question = this.summarizeToolPermission(toolName, toolInput);
944
- const approvePattern = this.derivePattern(toolName, toolInput);
945
- const options = [
946
- { label: 'Allow', value: 'allow' },
947
- { label: 'Always Allow', value: 'always_allow' },
948
- { label: 'Deny', value: 'deny' },
949
- ];
950
- const promptMsg = {
951
- type: 'prompt',
952
- promptType: 'permission',
953
- question,
954
- options,
955
- toolName,
956
- toolInput,
957
- requestId: approvalRequestId,
958
- ...(approvePattern ? { approvePattern } : {}),
959
- };
1170
+ }, isQuestion || toolName === 'ExitPlanMode' ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions, plan exit & agent children, 1 min for interactive
1171
+ let promptMsg;
1172
+ if (isQuestion) {
1173
+ // AskUserQuestion: extract structured questions from toolInput.questions
1174
+ // and pass them through so PromptButtons can render the multi-question flow.
1175
+ const rawQuestions = toolInput.questions;
1176
+ const structuredQuestions = Array.isArray(rawQuestions)
1177
+ ? rawQuestions.map(q => ({
1178
+ question: q.question,
1179
+ header: q.header,
1180
+ multiSelect: q.multiSelect ?? false,
1181
+ options: (q.options || []).map((opt) => ({
1182
+ label: opt.label,
1183
+ value: opt.value ?? opt.label,
1184
+ description: opt.description,
1185
+ })),
1186
+ }))
1187
+ : undefined;
1188
+ const firstQ = structuredQuestions?.[0];
1189
+ promptMsg = {
1190
+ type: 'prompt',
1191
+ promptType: 'question',
1192
+ question: firstQ?.question || 'Answer the question',
1193
+ options: firstQ?.options || [],
1194
+ multiSelect: firstQ?.multiSelect,
1195
+ toolName,
1196
+ toolInput,
1197
+ requestId: approvalRequestId,
1198
+ ...(structuredQuestions ? { questions: structuredQuestions } : {}),
1199
+ };
1200
+ }
1201
+ else {
1202
+ const question = this.summarizeToolPermission(toolName, toolInput);
1203
+ const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
1204
+ const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
1205
+ const options = [
1206
+ { label: 'Allow', value: 'allow' },
1207
+ ...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
1208
+ { label: 'Deny', value: 'deny' },
1209
+ ];
1210
+ promptMsg = {
1211
+ type: 'prompt',
1212
+ promptType: 'permission',
1213
+ question,
1214
+ options,
1215
+ toolName,
1216
+ toolInput,
1217
+ requestId: approvalRequestId,
1218
+ ...(approvePattern ? { approvePattern } : {}),
1219
+ };
1220
+ }
960
1221
  session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
961
1222
  if (session.clients.size > 0) {
962
1223
  this.broadcast(session, promptMsg);
@@ -972,22 +1233,59 @@ export class SessionManager {
972
1233
  sessionName: session.name,
973
1234
  });
974
1235
  }
1236
+ // Notify prompt listeners (orchestrator, child monitor, etc.)
1237
+ for (const listener of this._promptListeners) {
1238
+ try {
1239
+ listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
1240
+ }
1241
+ catch { /* listener error */ }
1242
+ }
975
1243
  });
976
1244
  }
977
1245
  /**
978
1246
  * Check if a tool invocation can be auto-approved without prompting the user.
979
- * Returns 'registry' if matched by auto-approval rules, 'headless' if the session
980
- * has no clients and is a non-interactive source, or 'prompt' if the user needs to decide.
1247
+ * Returns 'registry' if matched by auto-approval rules, 'session' if matched
1248
+ * by the session's allowedTools list, 'headless' if the session has no clients
1249
+ * and is a non-interactive source, or 'prompt' if the user needs to decide.
981
1250
  */
982
1251
  resolveAutoApproval(session, toolName, toolInput) {
983
- if (this.checkAutoApproval(session.workingDir, toolName, toolInput)) {
1252
+ if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
984
1253
  return 'registry';
985
1254
  }
1255
+ if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
1256
+ return 'session';
1257
+ }
986
1258
  if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')) {
987
1259
  return 'headless';
988
1260
  }
989
1261
  return 'prompt';
990
1262
  }
1263
+ /**
1264
+ * Check if a tool invocation matches any of the session's allowedTools patterns.
1265
+ * Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
1266
+ * Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
1267
+ */
1268
+ matchesAllowedTools(allowedTools, toolName, toolInput) {
1269
+ for (const pattern of allowedTools) {
1270
+ // Simple tool name match: 'WebFetch', 'Read', etc.
1271
+ if (pattern === toolName)
1272
+ return true;
1273
+ // Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
1274
+ const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
1275
+ if (match) {
1276
+ const [, patternTool, prefix] = match;
1277
+ if (patternTool !== toolName)
1278
+ continue;
1279
+ // For Bash, check command prefix
1280
+ if (toolName === 'Bash') {
1281
+ const cmd = String(toolInput.command || '').trimStart();
1282
+ if (cmd === prefix || cmd.startsWith(prefix + ' '))
1283
+ return true;
1284
+ }
1285
+ }
1286
+ }
1287
+ return false;
1288
+ }
991
1289
  /** Build a human-readable prompt string for a tool permission dialog. */
992
1290
  summarizeToolPermission(toolName, toolInput) {
993
1291
  switch (toolName) {
@@ -1019,7 +1317,41 @@ export class SessionManager {
1019
1317
  // Restart Claude with the new model if it's running
1020
1318
  if (session.claudeProcess?.isAlive()) {
1021
1319
  this.stopClaude(sessionId);
1022
- setTimeout(() => this.startClaude(sessionId), 500);
1320
+ session._stoppedByUser = false;
1321
+ setTimeout(() => {
1322
+ if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1323
+ this.startClaude(sessionId);
1324
+ }
1325
+ }, 500);
1326
+ }
1327
+ return true;
1328
+ }
1329
+ /** Update the permission mode for a session and restart Claude with the new mode. */
1330
+ setPermissionMode(sessionId, permissionMode) {
1331
+ const session = this.sessions.get(sessionId);
1332
+ if (!session)
1333
+ return false;
1334
+ const previousMode = session.permissionMode;
1335
+ session.permissionMode = permissionMode;
1336
+ this.persistToDiskDebounced();
1337
+ // Audit log for dangerous mode changes
1338
+ if (permissionMode === 'bypassPermissions') {
1339
+ console.warn(`[security] Session ${sessionId} ("${session.name}") activated bypassPermissions mode (was: ${previousMode ?? 'default'})`);
1340
+ }
1341
+ // Emit a visible system message so all clients see the mode change
1342
+ const modeLabel = permissionMode === 'bypassPermissions' ? 'Bypass permissions (all tools auto-accepted)' : permissionMode;
1343
+ const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
1344
+ this.addToHistory(session, sysMsg);
1345
+ this.broadcast(session, sysMsg);
1346
+ // Restart Claude with the new permission mode if it's running
1347
+ if (session.claudeProcess?.isAlive()) {
1348
+ this.stopClaude(sessionId);
1349
+ session._stoppedByUser = false;
1350
+ setTimeout(() => {
1351
+ if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1352
+ this.startClaude(sessionId);
1353
+ }
1354
+ }, 500);
1023
1355
  }
1024
1356
  return true;
1025
1357
  }
@@ -1036,6 +1368,27 @@ export class SessionManager {
1036
1368
  this.broadcast(session, { type: 'claude_stopped' });
1037
1369
  }
1038
1370
  }
1371
+ /**
1372
+ * Stop the Claude process and wait for it to fully exit before resolving.
1373
+ * This prevents race conditions when restarting with the same session ID
1374
+ * (e.g. during mid-session worktree migration).
1375
+ */
1376
+ async stopClaudeAndWait(sessionId) {
1377
+ const session = this.sessions.get(sessionId);
1378
+ if (!session?.claudeProcess)
1379
+ return;
1380
+ const cp = session.claudeProcess;
1381
+ session._stoppedByUser = true;
1382
+ this.clearStallTimer(session);
1383
+ if (session._apiRetryTimer)
1384
+ clearTimeout(session._apiRetryTimer);
1385
+ cp.removeAllListeners();
1386
+ cp.stop();
1387
+ session.claudeProcess = null;
1388
+ this.broadcast(session, { type: 'claude_stopped' });
1389
+ // Wait for the underlying OS process to fully exit
1390
+ await cp.waitForExit();
1391
+ }
1039
1392
  // ---------------------------------------------------------------------------
1040
1393
  // Helpers
1041
1394
  // ---------------------------------------------------------------------------
@@ -1159,15 +1512,17 @@ export class SessionManager {
1159
1512
  }
1160
1513
  }
1161
1514
  }
1162
- // Find which session a WebSocket is connected to (O(1) via reverse map)
1515
+ /** Find which session a WebSocket is connected to (O(1) via reverse map). */
1163
1516
  findSessionForClient(ws) {
1164
1517
  const sessionId = this.clientSessionMap.get(ws);
1165
1518
  if (sessionId)
1166
1519
  return this.sessions.get(sessionId);
1167
1520
  return undefined;
1168
1521
  }
1169
- // Remove a client from all sessions (iterates for safety since a ws
1170
- // could theoretically appear in multiple session client sets)
1522
+ /**
1523
+ * Remove a client from all sessions (iterates for safety since a ws
1524
+ * could theoretically appear in multiple session client sets).
1525
+ */
1171
1526
  removeClient(ws) {
1172
1527
  for (const session of this.sessions.values()) {
1173
1528
  session.clients.delete(ws);
@@ -1175,214 +1530,24 @@ export class SessionManager {
1175
1530
  this.clientSessionMap.delete(ws);
1176
1531
  }
1177
1532
  // ---------------------------------------------------------------------------
1178
- // Diff viewer
1533
+ // Diff viewer — delegates to DiffManager
1179
1534
  // ---------------------------------------------------------------------------
1180
- /**
1181
- * Run git diff in a session's workingDir and return structured results.
1182
- * Includes untracked file discovery for 'unstaged' and 'all' scopes.
1183
- */
1535
+ /** Run git diff in a session's workingDir and return structured results. */
1184
1536
  async getDiff(sessionId, scope = 'all') {
1185
1537
  const session = this.sessions.get(sessionId);
1186
1538
  if (!session)
1187
1539
  return { type: 'diff_error', message: 'Session not found' };
1188
- const cwd = session.workingDir;
1189
- try {
1190
- // Get branch name
1191
- let branch;
1192
- try {
1193
- const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
1194
- branch = branchResult.trim();
1195
- if (branch === 'HEAD') {
1196
- const shaResult = await execGit(['rev-parse', '--short', 'HEAD'], cwd);
1197
- branch = `detached at ${shaResult.trim()}`;
1198
- }
1199
- }
1200
- catch {
1201
- branch = 'unknown';
1202
- }
1203
- // Build diff command based on scope
1204
- const diffArgs = ['diff', '--find-renames', '--no-color', '--unified=3'];
1205
- if (scope === 'staged') {
1206
- diffArgs.push('--cached');
1207
- }
1208
- else if (scope === 'all') {
1209
- diffArgs.push('HEAD');
1210
- }
1211
- // 'unstaged' uses bare `git diff` (working tree vs index)
1212
- let rawDiff;
1213
- try {
1214
- rawDiff = await execGit(diffArgs, cwd);
1215
- }
1216
- catch {
1217
- // git diff HEAD fails if no commits yet — fall back to staged + unstaged
1218
- if (scope === 'all') {
1219
- const [staged, unstaged] = await Promise.all([
1220
- execGit(['diff', '--cached', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
1221
- execGit(['diff', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
1222
- ]);
1223
- rawDiff = staged + unstaged;
1224
- }
1225
- else {
1226
- rawDiff = '';
1227
- }
1228
- }
1229
- const { files, truncated, truncationReason } = parseDiff(rawDiff);
1230
- // Discover untracked files for 'unstaged' and 'all' scopes
1231
- if (scope !== 'staged') {
1232
- try {
1233
- const untrackedRaw = await execGit(['ls-files', '--others', '--exclude-standard'], cwd);
1234
- const untrackedPaths = untrackedRaw.trim().split('\n').filter(Boolean);
1235
- for (const relPath of untrackedPaths) {
1236
- try {
1237
- const fullPath = path.join(cwd, relPath);
1238
- // Check if binary by attempting to read as utf-8
1239
- const content = await fs.readFile(fullPath, 'utf-8');
1240
- files.push(createUntrackedFileDiff(relPath, content));
1241
- }
1242
- catch {
1243
- // Binary or unreadable — add as binary
1244
- files.push({
1245
- path: relPath,
1246
- status: 'added',
1247
- isBinary: true,
1248
- additions: 0,
1249
- deletions: 0,
1250
- hunks: [],
1251
- });
1252
- }
1253
- }
1254
- }
1255
- catch {
1256
- // ls-files failed — skip untracked
1257
- }
1258
- }
1259
- const summary = {
1260
- filesChanged: files.length,
1261
- insertions: files.reduce((sum, f) => sum + f.additions, 0),
1262
- deletions: files.reduce((sum, f) => sum + f.deletions, 0),
1263
- truncated,
1264
- truncationReason,
1265
- };
1266
- return { type: 'diff_result', files, summary, branch, scope };
1267
- }
1268
- catch (err) {
1269
- const message = err instanceof Error ? err.message : 'Failed to get diff';
1270
- return { type: 'diff_error', message };
1271
- }
1540
+ return this.diffManager.getDiff(session.workingDir, scope);
1272
1541
  }
1273
- /**
1274
- * Discard changes in a session's workingDir per the given scope and paths.
1275
- * Returns a fresh diff_result after discarding.
1276
- */
1542
+ /** Discard changes in a session's workingDir per the given scope and paths. */
1277
1543
  async discardChanges(sessionId, scope, paths, statuses) {
1278
1544
  const session = this.sessions.get(sessionId);
1279
1545
  if (!session)
1280
1546
  return { type: 'diff_error', message: 'Session not found' };
1281
- const cwd = session.workingDir;
1282
- try {
1283
- // Validate paths — enforce separator boundary to prevent /repoX matching /repo
1284
- if (paths) {
1285
- const root = path.join(path.resolve(cwd), path.sep);
1286
- for (const p of paths) {
1287
- if (p.includes('..') || path.isAbsolute(p)) {
1288
- return { type: 'diff_error', message: `Invalid path: ${p}` };
1289
- }
1290
- const resolved = path.resolve(cwd, p);
1291
- if (resolved !== path.resolve(cwd) && !resolved.startsWith(root)) {
1292
- return { type: 'diff_error', message: `Path escapes working directory: ${p}` };
1293
- }
1294
- }
1295
- }
1296
- // Determine file statuses if not provided
1297
- let fileStatuses = statuses ?? {};
1298
- if (!statuses && paths) {
1299
- fileStatuses = await getFileStatuses(cwd, paths);
1300
- }
1301
- else if (!statuses && !paths) {
1302
- fileStatuses = await getFileStatuses(cwd);
1303
- }
1304
- const targetPaths = paths ?? Object.keys(fileStatuses);
1305
- // Separate files by status for different handling
1306
- const trackedPaths = [];
1307
- const untrackedPaths = [];
1308
- const stagedNewPaths = [];
1309
- for (const p of targetPaths) {
1310
- const status = fileStatuses[p];
1311
- if (status === 'added') {
1312
- // Determine if untracked or staged-new by checking the index
1313
- try {
1314
- const indexEntry = (await execGit(['ls-files', '--stage', '--', p], cwd)).trim();
1315
- if (indexEntry) {
1316
- stagedNewPaths.push(p);
1317
- }
1318
- else {
1319
- untrackedPaths.push(p);
1320
- }
1321
- }
1322
- catch {
1323
- untrackedPaths.push(p);
1324
- }
1325
- }
1326
- else {
1327
- trackedPaths.push(p);
1328
- }
1329
- }
1330
- // Handle tracked files (modified, deleted, renamed) with git restore
1331
- if (trackedPaths.length > 0) {
1332
- const restoreArgs = ['restore'];
1333
- if (scope === 'staged') {
1334
- restoreArgs.push('--staged');
1335
- }
1336
- else if (scope === 'all') {
1337
- restoreArgs.push('--staged', '--worktree');
1338
- }
1339
- else {
1340
- restoreArgs.push('--worktree');
1341
- }
1342
- try {
1343
- await execGitChunked(restoreArgs, trackedPaths, cwd);
1344
- }
1345
- catch (err) {
1346
- // Fallback for Git < 2.23
1347
- console.warn('[discard] git restore failed, trying fallback:', err);
1348
- if (scope === 'staged' || scope === 'all') {
1349
- await execGitChunked(['reset', 'HEAD'], trackedPaths, cwd);
1350
- }
1351
- if (scope === 'unstaged' || scope === 'all') {
1352
- await execGitChunked(['checkout'], trackedPaths, cwd);
1353
- }
1354
- }
1355
- }
1356
- // Handle staged new files
1357
- if (stagedNewPaths.length > 0) {
1358
- if (scope === 'staged') {
1359
- // Unstage only — leave on disk
1360
- await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
1361
- }
1362
- else if (scope === 'all') {
1363
- // Remove from index and disk
1364
- await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
1365
- for (const p of stagedNewPaths) {
1366
- await fs.unlink(path.join(cwd, p)).catch(() => { });
1367
- }
1368
- }
1369
- // 'unstaged' scope: N/A for staged-new files
1370
- }
1371
- // Handle untracked files (delete from disk)
1372
- if (untrackedPaths.length > 0 && scope !== 'staged') {
1373
- for (const p of untrackedPaths) {
1374
- await fs.unlink(path.join(cwd, p)).catch(() => { });
1375
- }
1376
- }
1377
- // Return fresh diff
1378
- return await this.getDiff(sessionId, scope);
1379
- }
1380
- catch (err) {
1381
- const message = err instanceof Error ? err.message : 'Failed to discard changes';
1382
- return { type: 'diff_error', message };
1383
- }
1547
+ return this.diffManager.discardChanges(session.workingDir, scope, paths, statuses);
1384
1548
  }
1385
- /** Graceful shutdown: complete in-progress tasks, persist state, kill all processes. */
1549
+ /** Graceful shutdown: complete in-progress tasks, persist state, kill all processes.
1550
+ * Returns a promise that resolves once all Claude processes have exited. */
1386
1551
  shutdown() {
1387
1552
  // Complete in-progress tasks for active sessions before persisting.
1388
1553
  // This handles self-deploy: the commit/push task was the last step, and
@@ -1395,15 +1560,27 @@ export class SessionManager {
1395
1560
  }
1396
1561
  // Persist BEFORE killing processes so wasActive flag captures which were running
1397
1562
  this.persistToDisk();
1398
- this.persistRepoApprovals();
1399
- // Kill all Claude child processes on server shutdown
1563
+ this._approvalManager.persistRepoApprovals();
1564
+ // Kill all Claude child processes and wait for them to exit so their
1565
+ // session locks are released before the next server start.
1566
+ const exitPromises = [];
1400
1567
  for (const session of this.sessions.values()) {
1401
1568
  if (session.claudeProcess?.isAlive()) {
1402
- session.claudeProcess.stop();
1569
+ exitPromises.push(new Promise((resolve) => {
1570
+ session.claudeProcess.once('exit', () => resolve());
1571
+ session.claudeProcess.stop();
1572
+ }));
1403
1573
  }
1404
1574
  this.clearStallTimer(session);
1405
1575
  }
1406
1576
  this.archive.shutdown();
1577
+ if (exitPromises.length === 0)
1578
+ return Promise.resolve();
1579
+ // Wait for all processes to exit, but cap at 6s (stop() SIGKILL is at 5s)
1580
+ return Promise.race([
1581
+ Promise.all(exitPromises).then(() => { }),
1582
+ new Promise((resolve) => setTimeout(resolve, 6000)),
1583
+ ]);
1407
1584
  }
1408
1585
  /**
1409
1586
  * Mark all in_progress tasks as completed in a session's outputHistory.
@@ -1446,17 +1623,11 @@ export class SessionManager {
1446
1623
  setTimeout(() => {
1447
1624
  if (session.claudeProcess?.isAlive())
1448
1625
  return; // already running
1449
- console.log(`[restore] Starting Claude for session ${session.id} (${session.name}) with claudeSessionId=${session.claudeSessionId}`);
1626
+ // startClaude uses --resume when claudeSessionId exists (which it always
1627
+ // does here — the restore loop filters on it), so Claude CLI picks up
1628
+ // the full conversation history from its JSONL automatically.
1629
+ console.log(`[restore] Starting Claude for session ${session.id} (${session.name}) (claudeSessionId=${session.claudeSessionId})`);
1450
1630
  this.startClaude(session.id);
1451
- // Wait for Claude CLI to finish initializing before sending the
1452
- // continuation message. Writing to stdin before system_init causes
1453
- // the CLI to exit immediately with code=1.
1454
- if (session.claudeProcess) {
1455
- session.claudeProcess.once('system_init', () => {
1456
- const continueMsg = '[Session restored after server restart. Continue where you left off. If you were in the middle of a task, resume it.]';
1457
- session.claudeProcess?.sendMessage(continueMsg);
1458
- });
1459
- }
1460
1631
  const msg = {
1461
1632
  type: 'system_message',
1462
1633
  subtype: 'restart',