codekin 0.4.0 → 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.
- package/README.md +12 -15
- package/bin/codekin.mjs +52 -32
- package/dist/assets/index-BwKZeT4V.css +1 -0
- package/dist/assets/index-CfBnNU24.js +186 -0
- package/dist/index.html +2 -2
- package/package.json +2 -7
- package/server/dist/approval-manager.d.ts +7 -2
- package/server/dist/approval-manager.js +45 -79
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/claude-process.d.ts +23 -3
- package/server/dist/claude-process.js +123 -23
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/commit-event-handler.js.map +1 -1
- package/server/dist/config.d.ts +4 -0
- package/server/dist/config.js +17 -0
- package/server/dist/config.js.map +1 -1
- package/server/dist/diff-manager.d.ts +41 -0
- package/server/dist/diff-manager.js +303 -0
- package/server/dist/diff-manager.js.map +1 -0
- package/server/dist/diff-parser.js +8 -2
- package/server/dist/diff-parser.js.map +1 -1
- package/server/dist/error-page.d.ts +5 -0
- package/server/dist/error-page.js +144 -0
- package/server/dist/error-page.js.map +1 -0
- package/server/dist/native-permissions.d.ts +44 -0
- package/server/dist/native-permissions.js +163 -0
- package/server/dist/native-permissions.js.map +1 -0
- package/server/dist/orchestrator-children.d.ts +74 -0
- package/server/dist/orchestrator-children.js +281 -0
- package/server/dist/orchestrator-children.js.map +1 -0
- package/server/dist/orchestrator-learning.d.ts +134 -0
- package/server/dist/orchestrator-learning.js +567 -0
- package/server/dist/orchestrator-learning.js.map +1 -0
- package/server/dist/orchestrator-manager.d.ts +25 -0
- package/server/dist/orchestrator-manager.js +353 -0
- package/server/dist/orchestrator-manager.js.map +1 -0
- package/server/dist/orchestrator-memory.d.ts +77 -0
- package/server/dist/orchestrator-memory.js +288 -0
- package/server/dist/orchestrator-memory.js.map +1 -0
- package/server/dist/orchestrator-monitor.d.ts +59 -0
- package/server/dist/orchestrator-monitor.js +238 -0
- package/server/dist/orchestrator-monitor.js.map +1 -0
- package/server/dist/orchestrator-reports.d.ts +45 -0
- package/server/dist/orchestrator-reports.js +124 -0
- package/server/dist/orchestrator-reports.js.map +1 -0
- package/server/dist/orchestrator-routes.d.ts +17 -0
- package/server/dist/orchestrator-routes.js +526 -0
- package/server/dist/orchestrator-routes.js.map +1 -0
- package/server/dist/session-archive.js +9 -2
- package/server/dist/session-archive.js.map +1 -1
- package/server/dist/session-manager.d.ts +101 -39
- package/server/dist/session-manager.js +573 -397
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-naming.d.ts +6 -10
- package/server/dist/session-naming.js +60 -62
- package/server/dist/session-naming.js.map +1 -1
- package/server/dist/session-persistence.d.ts +6 -1
- package/server/dist/session-persistence.js +6 -0
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/session-restart-scheduler.d.ts +30 -0
- package/server/dist/session-restart-scheduler.js +41 -0
- package/server/dist/session-restart-scheduler.js.map +1 -0
- package/server/dist/session-routes.js +127 -58
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/stepflow-types.d.ts +1 -1
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +34 -2
- package/server/dist/types.js +8 -1
- package/server/dist/types.js.map +1 -1
- package/server/dist/upload-routes.js +13 -4
- package/server/dist/upload-routes.js.map +1 -1
- package/server/dist/version-check.d.ts +17 -0
- package/server/dist/version-check.js +89 -0
- package/server/dist/version-check.js.map +1 -0
- package/server/dist/workflow-engine.d.ts +74 -1
- package/server/dist/workflow-engine.js +20 -1
- package/server/dist/workflow-engine.js.map +1 -1
- package/server/dist/ws-message-handler.js +115 -9
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +90 -15
- package/server/dist/ws-server.js.map +1 -1
- package/dist/assets/index-BAdQqYEY.js +0 -182
- 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 {
|
|
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 {
|
|
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. */
|
|
@@ -129,6 +56,8 @@ const API_RETRY_PATTERNS = [
|
|
|
129
56
|
export class SessionManager {
|
|
130
57
|
/** All active (non-archived) sessions, keyed by session UUID. */
|
|
131
58
|
sessions = new Map();
|
|
59
|
+
/** Reverse lookup: WebSocket → session ID for O(1) client-to-session resolution. */
|
|
60
|
+
clientSessionMap = new Map();
|
|
132
61
|
/** SQLite archive for closed sessions (persists conversation summaries across restarts). */
|
|
133
62
|
archive;
|
|
134
63
|
/** Exposed so ws-server can pass its port to child Claude processes. */
|
|
@@ -139,55 +68,36 @@ export class SessionManager {
|
|
|
139
68
|
_globalBroadcast = null;
|
|
140
69
|
/** Registered listeners notified when a session's Claude process exits (used by webhook-handler for chained workflows). */
|
|
141
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 = [];
|
|
142
75
|
/** Delegated approval logic (auto-approve patterns, deny-lists, pattern management). */
|
|
143
|
-
|
|
76
|
+
_approvalManager;
|
|
144
77
|
/** Delegated auto-naming logic (generates session names from first user message via Claude API). */
|
|
145
78
|
sessionNaming;
|
|
146
79
|
/** Delegated persistence logic (saves/restores session metadata to disk across server restarts). */
|
|
147
80
|
sessionPersistence;
|
|
81
|
+
/** Delegated diff operations (git diff, discard changes). */
|
|
82
|
+
diffManager;
|
|
148
83
|
constructor() {
|
|
149
84
|
this.archive = new SessionArchive();
|
|
150
|
-
this.
|
|
85
|
+
this._approvalManager = new ApprovalManager();
|
|
86
|
+
this.diffManager = new DiffManager();
|
|
151
87
|
this.sessionPersistence = new SessionPersistence(this.sessions);
|
|
152
88
|
this.sessionNaming = new SessionNaming({
|
|
153
89
|
getSession: (id) => this.sessions.get(id),
|
|
154
90
|
hasSession: (id) => this.sessions.has(id),
|
|
155
|
-
getSetting: (key, fallback) => this.archive.getSetting(key, fallback),
|
|
156
91
|
rename: (sessionId, newName) => this.rename(sessionId, newName),
|
|
157
92
|
});
|
|
158
93
|
this.sessionPersistence.restoreFromDisk();
|
|
159
94
|
}
|
|
160
95
|
// ---------------------------------------------------------------------------
|
|
161
|
-
// Approval
|
|
96
|
+
// Approval — direct accessor (callers use sessions.approvalManager.xxx)
|
|
162
97
|
// ---------------------------------------------------------------------------
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
return this.
|
|
166
|
-
}
|
|
167
|
-
/** Derive a glob pattern from a tool invocation for "Approve Pattern". */
|
|
168
|
-
derivePattern(toolName, toolInput) {
|
|
169
|
-
return this.approvalManager.derivePattern(toolName, toolInput);
|
|
170
|
-
}
|
|
171
|
-
/** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
|
|
172
|
-
getApprovals(workingDir) {
|
|
173
|
-
return this.approvalManager.getApprovals(workingDir);
|
|
174
|
-
}
|
|
175
|
-
/** Return approvals effective globally via cross-repo inference. */
|
|
176
|
-
getGlobalApprovals() {
|
|
177
|
-
return this.approvalManager.getGlobalApprovals();
|
|
178
|
-
}
|
|
179
|
-
/** Remove an auto-approval rule for a repo (workingDir) and persist to disk. */
|
|
180
|
-
removeApproval(workingDir, opts, skipPersist = false) {
|
|
181
|
-
return this.approvalManager.removeApproval(workingDir, opts, skipPersist);
|
|
182
|
-
}
|
|
183
|
-
/** Add an auto-approval rule for a repo and persist (used by tests via `as any`). */
|
|
184
|
-
/* @ts-expect-error noUnusedLocals — accessed by tests via (sm as any).addRepoApproval */
|
|
185
|
-
addRepoApproval(workingDir, opts) {
|
|
186
|
-
this.approvalManager.addRepoApproval(workingDir, opts);
|
|
187
|
-
}
|
|
188
|
-
/** Write repo-level approvals to disk. Exposed for shutdown. */
|
|
189
|
-
persistRepoApprovals() {
|
|
190
|
-
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;
|
|
191
101
|
}
|
|
192
102
|
// ---------------------------------------------------------------------------
|
|
193
103
|
// Naming delegation (preserves public API)
|
|
@@ -224,6 +134,8 @@ export class SessionManager {
|
|
|
224
134
|
created: new Date().toISOString(),
|
|
225
135
|
source: options?.source ?? 'manual',
|
|
226
136
|
model: options?.model,
|
|
137
|
+
permissionMode: options?.permissionMode,
|
|
138
|
+
allowedTools: options?.allowedTools,
|
|
227
139
|
claudeProcess: null,
|
|
228
140
|
clients: new Set(),
|
|
229
141
|
outputHistory: [],
|
|
@@ -246,16 +158,225 @@ export class SessionManager {
|
|
|
246
158
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
247
159
|
return session;
|
|
248
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
|
+
}
|
|
249
326
|
/** Register a listener called when any session's Claude process exits.
|
|
250
327
|
* The `willRestart` flag indicates whether the session will be auto-restarted. */
|
|
251
328
|
onSessionExit(listener) {
|
|
252
329
|
this._exitListeners.push(listener);
|
|
253
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
|
+
}
|
|
254
339
|
get(id) {
|
|
255
340
|
return this.sessions.get(id);
|
|
256
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
|
+
}
|
|
257
377
|
list() {
|
|
258
|
-
return Array.from(this.sessions.values())
|
|
378
|
+
return Array.from(this.sessions.values())
|
|
379
|
+
.map((s) => ({
|
|
259
380
|
id: s.id,
|
|
260
381
|
name: s.name,
|
|
261
382
|
created: s.created,
|
|
@@ -263,6 +384,24 @@ export class SessionManager {
|
|
|
263
384
|
isProcessing: s.isProcessing,
|
|
264
385
|
workingDir: s.workingDir,
|
|
265
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) => ({
|
|
397
|
+
id: s.id,
|
|
398
|
+
name: s.name,
|
|
399
|
+
created: s.created,
|
|
400
|
+
active: s.claudeProcess?.isAlive() ?? false,
|
|
401
|
+
isProcessing: s.isProcessing,
|
|
402
|
+
workingDir: s.workingDir,
|
|
403
|
+
groupDir: s.groupDir,
|
|
404
|
+
worktreePath: s.worktreePath,
|
|
266
405
|
connectedClients: s.clients.size,
|
|
267
406
|
lastActivity: s.created,
|
|
268
407
|
source: s.source,
|
|
@@ -290,6 +429,7 @@ export class SessionManager {
|
|
|
290
429
|
session._leaveGraceTimer = null;
|
|
291
430
|
}
|
|
292
431
|
session.clients.add(ws);
|
|
432
|
+
this.clientSessionMap.set(ws, sessionId);
|
|
293
433
|
// Re-broadcast pending tool approval prompts (PreToolUse hook path)
|
|
294
434
|
for (const pending of session.pendingToolApprovals.values()) {
|
|
295
435
|
if (pending.promptMsg) {
|
|
@@ -311,6 +451,7 @@ export class SessionManager {
|
|
|
311
451
|
const session = this.sessions.get(sessionId);
|
|
312
452
|
if (session) {
|
|
313
453
|
session.clients.delete(ws);
|
|
454
|
+
this.clientSessionMap.delete(ws);
|
|
314
455
|
// If no clients remain, wait a grace period before auto-denying.
|
|
315
456
|
// This prevents false denials when the user is just refreshing the page.
|
|
316
457
|
if (session.clients.size === 0) {
|
|
@@ -360,6 +501,10 @@ export class SessionManager {
|
|
|
360
501
|
session.claudeProcess = null;
|
|
361
502
|
}
|
|
362
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
|
+
}
|
|
363
508
|
// Clean up webhook workspace directory if applicable
|
|
364
509
|
if (session.source === 'webhook' || session.source === 'stepflow') {
|
|
365
510
|
cleanupWorkspace(sessionId);
|
|
@@ -427,6 +572,8 @@ export class SessionManager {
|
|
|
427
572
|
const sessionToken = this._authToken
|
|
428
573
|
? deriveSessionToken(this._authToken, sessionId)
|
|
429
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.
|
|
430
577
|
const extraEnv = {
|
|
431
578
|
CODEKIN_SESSION_ID: sessionId,
|
|
432
579
|
CODEKIN_PORT: String(this._serverPort || PORT),
|
|
@@ -434,12 +581,25 @@ export class SessionManager {
|
|
|
434
581
|
CODEKIN_AUTH_TOKEN: sessionToken,
|
|
435
582
|
CODEKIN_SESSION_TYPE: session.source || 'manual',
|
|
436
583
|
};
|
|
437
|
-
// Pass CLAUDE_PROJECT_DIR so hooks resolve correctly
|
|
438
|
-
// working directory differs from the project root
|
|
439
|
-
|
|
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) {
|
|
440
592
|
extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
|
|
441
593
|
}
|
|
442
|
-
|
|
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);
|
|
443
603
|
this.wireClaudeEvents(cp, session, sessionId);
|
|
444
604
|
cp.start();
|
|
445
605
|
session.claudeProcess = cp;
|
|
@@ -526,6 +686,13 @@ export class SessionManager {
|
|
|
526
686
|
session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
|
|
527
687
|
}
|
|
528
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
|
+
}
|
|
529
696
|
}
|
|
530
697
|
onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
|
|
531
698
|
if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
|
|
@@ -538,10 +705,23 @@ export class SessionManager {
|
|
|
538
705
|
cp.sendControlResponse(requestId, 'allow');
|
|
539
706
|
return;
|
|
540
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
|
+
}
|
|
541
720
|
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
721
|
+
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
542
722
|
const options = [
|
|
543
723
|
{ label: 'Allow', value: 'allow' },
|
|
544
|
-
{ label: 'Always Allow', value: 'always_allow' },
|
|
724
|
+
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
545
725
|
{ label: 'Deny', value: 'deny' },
|
|
546
726
|
];
|
|
547
727
|
const promptMsg = {
|
|
@@ -565,6 +745,13 @@ export class SessionManager {
|
|
|
565
745
|
sessionName: session.name,
|
|
566
746
|
});
|
|
567
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
|
+
}
|
|
568
755
|
}
|
|
569
756
|
/**
|
|
570
757
|
* Handle a Claude process 'result' event: update session state, apply API
|
|
@@ -622,6 +809,13 @@ export class SessionManager {
|
|
|
622
809
|
const resultMsg = { type: 'result' };
|
|
623
810
|
this.addToHistory(session, resultMsg);
|
|
624
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
|
+
}
|
|
625
819
|
// If session is still unnamed after first response, name it now — we have full context
|
|
626
820
|
if (session.name.startsWith('hub:') && session._namingAttempts === 0) {
|
|
627
821
|
if (session._namingTimer) {
|
|
@@ -634,15 +828,21 @@ export class SessionManager {
|
|
|
634
828
|
/**
|
|
635
829
|
* Handle a Claude process 'exit' event: clean up state, notify exit listeners,
|
|
636
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.
|
|
637
834
|
*/
|
|
638
835
|
handleClaudeExit(session, sessionId, code, signal) {
|
|
639
836
|
session.claudeProcess = null;
|
|
640
837
|
session.isProcessing = false;
|
|
641
838
|
this.clearStallTimer(session);
|
|
642
839
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
840
|
+
const action = evaluateRestart({
|
|
841
|
+
restartCount: session.restartCount,
|
|
842
|
+
lastRestartAt: session.lastRestartAt,
|
|
843
|
+
stoppedByUser: session._stoppedByUser,
|
|
844
|
+
});
|
|
845
|
+
if (action.kind === 'stopped_by_user') {
|
|
646
846
|
for (const listener of this._exitListeners) {
|
|
647
847
|
try {
|
|
648
848
|
listener(sessionId, code, signal, false);
|
|
@@ -655,34 +855,19 @@ export class SessionManager {
|
|
|
655
855
|
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
656
856
|
return;
|
|
657
857
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
session.restartCount = 0;
|
|
662
|
-
}
|
|
663
|
-
if (session.restartCount < MAX_RESTARTS) {
|
|
664
|
-
session.restartCount++;
|
|
665
|
-
session.lastRestartAt = now;
|
|
666
|
-
const attempt = session.restartCount;
|
|
667
|
-
// Notify exit listeners with willRestart=true so they don't treat
|
|
668
|
-
// 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;
|
|
669
861
|
for (const listener of this._exitListeners) {
|
|
670
862
|
try {
|
|
671
863
|
listener(sessionId, code, signal, true);
|
|
672
864
|
}
|
|
673
865
|
catch { /* listener error */ }
|
|
674
866
|
}
|
|
675
|
-
// If Claude exited with code=1 on first attempt and we had a saved
|
|
676
|
-
// session ID, it may be stale/invalid. Clear it so the next retry
|
|
677
|
-
// starts a fresh session instead of repeating the same failure.
|
|
678
|
-
if (code === 1 && attempt === 1 && session.claudeSessionId) {
|
|
679
|
-
console.log(`[restart] Clearing potentially stale claudeSessionId for session ${sessionId}`);
|
|
680
|
-
session.claudeSessionId = null;
|
|
681
|
-
}
|
|
682
867
|
const msg = {
|
|
683
868
|
type: 'system_message',
|
|
684
869
|
subtype: 'restart',
|
|
685
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${attempt}/${
|
|
870
|
+
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
|
|
686
871
|
};
|
|
687
872
|
this.addToHistory(session, msg);
|
|
688
873
|
this.broadcast(session, msg);
|
|
@@ -690,26 +875,38 @@ export class SessionManager {
|
|
|
690
875
|
// Verify session still exists and hasn't been stopped
|
|
691
876
|
if (!this.sessions.has(sessionId) || session._stoppedByUser)
|
|
692
877
|
return;
|
|
878
|
+
// startClaude uses --resume when claudeSessionId exists, so the CLI
|
|
879
|
+
// picks up the full conversation history from the JSONL automatically.
|
|
693
880
|
this.startClaude(sessionId);
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
+
});
|
|
701
891
|
}
|
|
702
|
-
|
|
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);
|
|
703
899
|
}
|
|
704
|
-
|
|
705
|
-
type: 'system_message',
|
|
706
|
-
subtype: 'error',
|
|
707
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${MAX_RESTARTS} attempts. Please restart manually.`,
|
|
708
|
-
};
|
|
709
|
-
this.addToHistory(session, msg);
|
|
710
|
-
this.broadcast(session, msg);
|
|
711
|
-
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
900
|
+
catch { /* listener error */ }
|
|
712
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 });
|
|
713
910
|
}
|
|
714
911
|
/**
|
|
715
912
|
* Send user input to a session's Claude process.
|
|
@@ -839,17 +1036,32 @@ export class SessionManager {
|
|
|
839
1036
|
}
|
|
840
1037
|
/** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
|
|
841
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
|
+
}
|
|
842
1048
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
843
1049
|
if (isAlwaysAllow && !isDeny) {
|
|
844
|
-
this.
|
|
1050
|
+
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
845
1051
|
}
|
|
846
1052
|
if (isApprovePattern && !isDeny) {
|
|
847
|
-
this.
|
|
1053
|
+
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
848
1054
|
}
|
|
849
1055
|
console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
|
|
850
1056
|
approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
|
|
851
1057
|
session.pendingToolApprovals.delete(approval.requestId);
|
|
852
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
|
+
}
|
|
853
1065
|
}
|
|
854
1066
|
/**
|
|
855
1067
|
* Send an AskUserQuestion control response, mapping the user's answer(s) into
|
|
@@ -888,10 +1100,10 @@ export class SessionManager {
|
|
|
888
1100
|
sendControlResponseForRequest(session, pending, value) {
|
|
889
1101
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
890
1102
|
if (isAlwaysAllow) {
|
|
891
|
-
this.
|
|
1103
|
+
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
892
1104
|
}
|
|
893
1105
|
if (isApprovePattern) {
|
|
894
|
-
this.
|
|
1106
|
+
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
895
1107
|
}
|
|
896
1108
|
const behavior = isDeny ? 'deny' : 'allow';
|
|
897
1109
|
session.claudeProcess.sendControlResponse(pending.requestId, behavior);
|
|
@@ -911,11 +1123,31 @@ export class SessionManager {
|
|
|
911
1123
|
console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
|
|
912
1124
|
return Promise.resolve({ allow: true, always: true });
|
|
913
1125
|
}
|
|
1126
|
+
if (autoResult === 'session') {
|
|
1127
|
+
console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
|
|
1128
|
+
return Promise.resolve({ allow: true, always: false });
|
|
1129
|
+
}
|
|
914
1130
|
if (autoResult === 'headless') {
|
|
915
1131
|
console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
|
|
916
1132
|
return Promise.resolve({ allow: true, always: false });
|
|
917
1133
|
}
|
|
918
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';
|
|
919
1151
|
return new Promise((resolve) => {
|
|
920
1152
|
// Holder lets wrappedResolve reference the timeout before it's assigned
|
|
921
1153
|
const timer = { id: null };
|
|
@@ -935,24 +1167,57 @@ export class SessionManager {
|
|
|
935
1167
|
this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
|
|
936
1168
|
resolve({ allow: false, always: false });
|
|
937
1169
|
}
|
|
938
|
-
}, 60_000);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
+
}
|
|
956
1221
|
session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
|
|
957
1222
|
if (session.clients.size > 0) {
|
|
958
1223
|
this.broadcast(session, promptMsg);
|
|
@@ -968,22 +1233,59 @@ export class SessionManager {
|
|
|
968
1233
|
sessionName: session.name,
|
|
969
1234
|
});
|
|
970
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
|
+
}
|
|
971
1243
|
});
|
|
972
1244
|
}
|
|
973
1245
|
/**
|
|
974
1246
|
* Check if a tool invocation can be auto-approved without prompting the user.
|
|
975
|
-
* Returns 'registry' if matched by auto-approval rules, '
|
|
976
|
-
*
|
|
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.
|
|
977
1250
|
*/
|
|
978
1251
|
resolveAutoApproval(session, toolName, toolInput) {
|
|
979
|
-
if (this.checkAutoApproval(session.workingDir, toolName, toolInput)) {
|
|
1252
|
+
if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
|
|
980
1253
|
return 'registry';
|
|
981
1254
|
}
|
|
1255
|
+
if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
|
|
1256
|
+
return 'session';
|
|
1257
|
+
}
|
|
982
1258
|
if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')) {
|
|
983
1259
|
return 'headless';
|
|
984
1260
|
}
|
|
985
1261
|
return 'prompt';
|
|
986
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
|
+
}
|
|
987
1289
|
/** Build a human-readable prompt string for a tool permission dialog. */
|
|
988
1290
|
summarizeToolPermission(toolName, toolInput) {
|
|
989
1291
|
switch (toolName) {
|
|
@@ -1015,7 +1317,41 @@ export class SessionManager {
|
|
|
1015
1317
|
// Restart Claude with the new model if it's running
|
|
1016
1318
|
if (session.claudeProcess?.isAlive()) {
|
|
1017
1319
|
this.stopClaude(sessionId);
|
|
1018
|
-
|
|
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);
|
|
1019
1355
|
}
|
|
1020
1356
|
return true;
|
|
1021
1357
|
}
|
|
@@ -1032,6 +1368,27 @@ export class SessionManager {
|
|
|
1032
1368
|
this.broadcast(session, { type: 'claude_stopped' });
|
|
1033
1369
|
}
|
|
1034
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
|
+
}
|
|
1035
1392
|
// ---------------------------------------------------------------------------
|
|
1036
1393
|
// Helpers
|
|
1037
1394
|
// ---------------------------------------------------------------------------
|
|
@@ -1155,229 +1512,42 @@ export class SessionManager {
|
|
|
1155
1512
|
}
|
|
1156
1513
|
}
|
|
1157
1514
|
}
|
|
1158
|
-
|
|
1515
|
+
/** Find which session a WebSocket is connected to (O(1) via reverse map). */
|
|
1159
1516
|
findSessionForClient(ws) {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
}
|
|
1517
|
+
const sessionId = this.clientSessionMap.get(ws);
|
|
1518
|
+
if (sessionId)
|
|
1519
|
+
return this.sessions.get(sessionId);
|
|
1164
1520
|
return undefined;
|
|
1165
1521
|
}
|
|
1166
|
-
|
|
1522
|
+
/**
|
|
1523
|
+
* Remove a client from all sessions (iterates for safety since a ws
|
|
1524
|
+
* could theoretically appear in multiple session client sets).
|
|
1525
|
+
*/
|
|
1167
1526
|
removeClient(ws) {
|
|
1168
1527
|
for (const session of this.sessions.values()) {
|
|
1169
1528
|
session.clients.delete(ws);
|
|
1170
1529
|
}
|
|
1530
|
+
this.clientSessionMap.delete(ws);
|
|
1171
1531
|
}
|
|
1172
1532
|
// ---------------------------------------------------------------------------
|
|
1173
|
-
// Diff viewer
|
|
1533
|
+
// Diff viewer — delegates to DiffManager
|
|
1174
1534
|
// ---------------------------------------------------------------------------
|
|
1175
|
-
/**
|
|
1176
|
-
* Run git diff in a session's workingDir and return structured results.
|
|
1177
|
-
* Includes untracked file discovery for 'unstaged' and 'all' scopes.
|
|
1178
|
-
*/
|
|
1535
|
+
/** Run git diff in a session's workingDir and return structured results. */
|
|
1179
1536
|
async getDiff(sessionId, scope = 'all') {
|
|
1180
1537
|
const session = this.sessions.get(sessionId);
|
|
1181
1538
|
if (!session)
|
|
1182
1539
|
return { type: 'diff_error', message: 'Session not found' };
|
|
1183
|
-
|
|
1184
|
-
try {
|
|
1185
|
-
// Get branch name
|
|
1186
|
-
let branch;
|
|
1187
|
-
try {
|
|
1188
|
-
const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
1189
|
-
branch = branchResult.trim();
|
|
1190
|
-
if (branch === 'HEAD') {
|
|
1191
|
-
const shaResult = await execGit(['rev-parse', '--short', 'HEAD'], cwd);
|
|
1192
|
-
branch = `detached at ${shaResult.trim()}`;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
catch {
|
|
1196
|
-
branch = 'unknown';
|
|
1197
|
-
}
|
|
1198
|
-
// Build diff command based on scope
|
|
1199
|
-
const diffArgs = ['diff', '--find-renames', '--no-color', '--unified=3'];
|
|
1200
|
-
if (scope === 'staged') {
|
|
1201
|
-
diffArgs.push('--cached');
|
|
1202
|
-
}
|
|
1203
|
-
else if (scope === 'all') {
|
|
1204
|
-
diffArgs.push('HEAD');
|
|
1205
|
-
}
|
|
1206
|
-
// 'unstaged' uses bare `git diff` (working tree vs index)
|
|
1207
|
-
let rawDiff;
|
|
1208
|
-
try {
|
|
1209
|
-
rawDiff = await execGit(diffArgs, cwd);
|
|
1210
|
-
}
|
|
1211
|
-
catch {
|
|
1212
|
-
// git diff HEAD fails if no commits yet — fall back to staged + unstaged
|
|
1213
|
-
if (scope === 'all') {
|
|
1214
|
-
const [staged, unstaged] = await Promise.all([
|
|
1215
|
-
execGit(['diff', '--cached', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
|
|
1216
|
-
execGit(['diff', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
|
|
1217
|
-
]);
|
|
1218
|
-
rawDiff = staged + unstaged;
|
|
1219
|
-
}
|
|
1220
|
-
else {
|
|
1221
|
-
rawDiff = '';
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
const { files, truncated, truncationReason } = parseDiff(rawDiff);
|
|
1225
|
-
// Discover untracked files for 'unstaged' and 'all' scopes
|
|
1226
|
-
if (scope !== 'staged') {
|
|
1227
|
-
try {
|
|
1228
|
-
const untrackedRaw = await execGit(['ls-files', '--others', '--exclude-standard'], cwd);
|
|
1229
|
-
const untrackedPaths = untrackedRaw.trim().split('\n').filter(Boolean);
|
|
1230
|
-
for (const relPath of untrackedPaths) {
|
|
1231
|
-
try {
|
|
1232
|
-
const fullPath = path.join(cwd, relPath);
|
|
1233
|
-
// Check if binary by attempting to read as utf-8
|
|
1234
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
1235
|
-
files.push(createUntrackedFileDiff(relPath, content));
|
|
1236
|
-
}
|
|
1237
|
-
catch {
|
|
1238
|
-
// Binary or unreadable — add as binary
|
|
1239
|
-
files.push({
|
|
1240
|
-
path: relPath,
|
|
1241
|
-
status: 'added',
|
|
1242
|
-
isBinary: true,
|
|
1243
|
-
additions: 0,
|
|
1244
|
-
deletions: 0,
|
|
1245
|
-
hunks: [],
|
|
1246
|
-
});
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
catch {
|
|
1251
|
-
// ls-files failed — skip untracked
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
const summary = {
|
|
1255
|
-
filesChanged: files.length,
|
|
1256
|
-
insertions: files.reduce((sum, f) => sum + f.additions, 0),
|
|
1257
|
-
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
|
1258
|
-
truncated,
|
|
1259
|
-
truncationReason,
|
|
1260
|
-
};
|
|
1261
|
-
return { type: 'diff_result', files, summary, branch, scope };
|
|
1262
|
-
}
|
|
1263
|
-
catch (err) {
|
|
1264
|
-
const message = err instanceof Error ? err.message : 'Failed to get diff';
|
|
1265
|
-
return { type: 'diff_error', message };
|
|
1266
|
-
}
|
|
1540
|
+
return this.diffManager.getDiff(session.workingDir, scope);
|
|
1267
1541
|
}
|
|
1268
|
-
/**
|
|
1269
|
-
* Discard changes in a session's workingDir per the given scope and paths.
|
|
1270
|
-
* Returns a fresh diff_result after discarding.
|
|
1271
|
-
*/
|
|
1542
|
+
/** Discard changes in a session's workingDir per the given scope and paths. */
|
|
1272
1543
|
async discardChanges(sessionId, scope, paths, statuses) {
|
|
1273
1544
|
const session = this.sessions.get(sessionId);
|
|
1274
1545
|
if (!session)
|
|
1275
1546
|
return { type: 'diff_error', message: 'Session not found' };
|
|
1276
|
-
|
|
1277
|
-
try {
|
|
1278
|
-
// Validate paths — enforce separator boundary to prevent /repoX matching /repo
|
|
1279
|
-
if (paths) {
|
|
1280
|
-
const root = path.resolve(cwd) + path.sep;
|
|
1281
|
-
for (const p of paths) {
|
|
1282
|
-
if (p.includes('..') || path.isAbsolute(p)) {
|
|
1283
|
-
return { type: 'diff_error', message: `Invalid path: ${p}` };
|
|
1284
|
-
}
|
|
1285
|
-
const resolved = path.resolve(cwd, p);
|
|
1286
|
-
if (resolved !== path.resolve(cwd) && !resolved.startsWith(root)) {
|
|
1287
|
-
return { type: 'diff_error', message: `Path escapes working directory: ${p}` };
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
// Determine file statuses if not provided
|
|
1292
|
-
let fileStatuses = statuses ?? {};
|
|
1293
|
-
if (!statuses && paths) {
|
|
1294
|
-
fileStatuses = await getFileStatuses(cwd, paths);
|
|
1295
|
-
}
|
|
1296
|
-
else if (!statuses && !paths) {
|
|
1297
|
-
fileStatuses = await getFileStatuses(cwd);
|
|
1298
|
-
}
|
|
1299
|
-
const targetPaths = paths ?? Object.keys(fileStatuses);
|
|
1300
|
-
// Separate files by status for different handling
|
|
1301
|
-
const trackedPaths = [];
|
|
1302
|
-
const untrackedPaths = [];
|
|
1303
|
-
const stagedNewPaths = [];
|
|
1304
|
-
for (const p of targetPaths) {
|
|
1305
|
-
const status = fileStatuses[p];
|
|
1306
|
-
if (status === 'added') {
|
|
1307
|
-
// Determine if untracked or staged-new by checking the index
|
|
1308
|
-
try {
|
|
1309
|
-
const indexEntry = (await execGit(['ls-files', '--stage', '--', p], cwd)).trim();
|
|
1310
|
-
if (indexEntry) {
|
|
1311
|
-
stagedNewPaths.push(p);
|
|
1312
|
-
}
|
|
1313
|
-
else {
|
|
1314
|
-
untrackedPaths.push(p);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
catch {
|
|
1318
|
-
untrackedPaths.push(p);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
else {
|
|
1322
|
-
trackedPaths.push(p);
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
// Handle tracked files (modified, deleted, renamed) with git restore
|
|
1326
|
-
if (trackedPaths.length > 0) {
|
|
1327
|
-
const restoreArgs = ['restore'];
|
|
1328
|
-
if (scope === 'staged') {
|
|
1329
|
-
restoreArgs.push('--staged');
|
|
1330
|
-
}
|
|
1331
|
-
else if (scope === 'all') {
|
|
1332
|
-
restoreArgs.push('--staged', '--worktree');
|
|
1333
|
-
}
|
|
1334
|
-
else {
|
|
1335
|
-
restoreArgs.push('--worktree');
|
|
1336
|
-
}
|
|
1337
|
-
try {
|
|
1338
|
-
await execGitChunked(restoreArgs, trackedPaths, cwd);
|
|
1339
|
-
}
|
|
1340
|
-
catch (err) {
|
|
1341
|
-
// Fallback for Git < 2.23
|
|
1342
|
-
console.warn('[discard] git restore failed, trying fallback:', err);
|
|
1343
|
-
if (scope === 'staged' || scope === 'all') {
|
|
1344
|
-
await execGitChunked(['reset', 'HEAD'], trackedPaths, cwd);
|
|
1345
|
-
}
|
|
1346
|
-
if (scope === 'unstaged' || scope === 'all') {
|
|
1347
|
-
await execGitChunked(['checkout'], trackedPaths, cwd);
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
// Handle staged new files
|
|
1352
|
-
if (stagedNewPaths.length > 0) {
|
|
1353
|
-
if (scope === 'staged') {
|
|
1354
|
-
// Unstage only — leave on disk
|
|
1355
|
-
await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
|
|
1356
|
-
}
|
|
1357
|
-
else if (scope === 'all') {
|
|
1358
|
-
// Remove from index and disk
|
|
1359
|
-
await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
|
|
1360
|
-
for (const p of stagedNewPaths) {
|
|
1361
|
-
await fs.unlink(path.join(cwd, p)).catch(() => { });
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
// 'unstaged' scope: N/A for staged-new files
|
|
1365
|
-
}
|
|
1366
|
-
// Handle untracked files (delete from disk)
|
|
1367
|
-
if (untrackedPaths.length > 0 && scope !== 'staged') {
|
|
1368
|
-
for (const p of untrackedPaths) {
|
|
1369
|
-
await fs.unlink(path.join(cwd, p)).catch(() => { });
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
// Return fresh diff
|
|
1373
|
-
return await this.getDiff(sessionId, scope);
|
|
1374
|
-
}
|
|
1375
|
-
catch (err) {
|
|
1376
|
-
const message = err instanceof Error ? err.message : 'Failed to discard changes';
|
|
1377
|
-
return { type: 'diff_error', message };
|
|
1378
|
-
}
|
|
1547
|
+
return this.diffManager.discardChanges(session.workingDir, scope, paths, statuses);
|
|
1379
1548
|
}
|
|
1380
|
-
/** 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. */
|
|
1381
1551
|
shutdown() {
|
|
1382
1552
|
// Complete in-progress tasks for active sessions before persisting.
|
|
1383
1553
|
// This handles self-deploy: the commit/push task was the last step, and
|
|
@@ -1390,15 +1560,27 @@ export class SessionManager {
|
|
|
1390
1560
|
}
|
|
1391
1561
|
// Persist BEFORE killing processes so wasActive flag captures which were running
|
|
1392
1562
|
this.persistToDisk();
|
|
1393
|
-
this.persistRepoApprovals();
|
|
1394
|
-
// Kill all Claude child processes
|
|
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 = [];
|
|
1395
1567
|
for (const session of this.sessions.values()) {
|
|
1396
1568
|
if (session.claudeProcess?.isAlive()) {
|
|
1397
|
-
|
|
1569
|
+
exitPromises.push(new Promise((resolve) => {
|
|
1570
|
+
session.claudeProcess.once('exit', () => resolve());
|
|
1571
|
+
session.claudeProcess.stop();
|
|
1572
|
+
}));
|
|
1398
1573
|
}
|
|
1399
1574
|
this.clearStallTimer(session);
|
|
1400
1575
|
}
|
|
1401
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
|
+
]);
|
|
1402
1584
|
}
|
|
1403
1585
|
/**
|
|
1404
1586
|
* Mark all in_progress tasks as completed in a session's outputHistory.
|
|
@@ -1441,17 +1623,11 @@ export class SessionManager {
|
|
|
1441
1623
|
setTimeout(() => {
|
|
1442
1624
|
if (session.claudeProcess?.isAlive())
|
|
1443
1625
|
return; // already running
|
|
1444
|
-
|
|
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})`);
|
|
1445
1630
|
this.startClaude(session.id);
|
|
1446
|
-
// Wait for Claude CLI to finish initializing before sending the
|
|
1447
|
-
// continuation message. Writing to stdin before system_init causes
|
|
1448
|
-
// the CLI to exit immediately with code=1.
|
|
1449
|
-
if (session.claudeProcess) {
|
|
1450
|
-
session.claudeProcess.once('system_init', () => {
|
|
1451
|
-
const continueMsg = '[Session restored after server restart. Continue where you left off. If you were in the middle of a task, resume it.]';
|
|
1452
|
-
session.claudeProcess?.sendMessage(continueMsg);
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
1631
|
const msg = {
|
|
1456
1632
|
type: 'system_message',
|
|
1457
1633
|
subtype: 'restart',
|