codekin 0.4.1 → 0.5.1
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-B8opKRtJ.js +186 -0
- package/dist/assets/index-wajPH8o6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -7
- package/server/dist/approval-manager.d.ts +9 -2
- package/server/dist/approval-manager.js +47 -78
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/claude-process.d.ts +20 -4
- package/server/dist/claude-process.js +79 -50
- 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/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 +287 -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/plan-manager.d.ts +74 -0
- package/server/dist/plan-manager.js +121 -0
- package/server/dist/plan-manager.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 +117 -42
- package/server/dist/session-manager.js +728 -433
- 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 +8 -1
- 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 +122 -61
- 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 +40 -5
- package/server/dist/types.js +8 -1
- package/server/dist/types.js.map +1 -1
- package/server/dist/upload-routes.js +7 -1
- 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,13 +14,17 @@
|
|
|
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';
|
|
27
|
+
import { PlanManager } from './plan-manager.js';
|
|
24
28
|
import { SessionArchive } from './session-archive.js';
|
|
25
29
|
import { cleanupWorkspace } from './webhook-workspace.js';
|
|
26
30
|
import { PORT } from './config.js';
|
|
@@ -28,89 +32,11 @@ import { ApprovalManager } from './approval-manager.js';
|
|
|
28
32
|
import { SessionNaming } from './session-naming.js';
|
|
29
33
|
import { SessionPersistence } from './session-persistence.js';
|
|
30
34
|
import { deriveSessionToken } from './crypto-utils.js';
|
|
31
|
-
import {
|
|
35
|
+
import { cleanGitEnv, DiffManager } from './diff-manager.js';
|
|
36
|
+
import { evaluateRestart } from './session-restart-scheduler.js';
|
|
32
37
|
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
38
|
/** Max messages retained in a session's output history buffer. */
|
|
105
39
|
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
|
-
/** No-output duration before emitting a stall warning (5 minutes). */
|
|
113
|
-
const STALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
114
40
|
/** Max API error retries per turn before giving up. */
|
|
115
41
|
const MAX_API_RETRIES = 3;
|
|
116
42
|
/** Base delay for API error retry (doubles each attempt: 3s, 6s, 12s). */
|
|
@@ -141,55 +67,40 @@ export class SessionManager {
|
|
|
141
67
|
_globalBroadcast = null;
|
|
142
68
|
/** Registered listeners notified when a session's Claude process exits (used by webhook-handler for chained workflows). */
|
|
143
69
|
_exitListeners = [];
|
|
70
|
+
/** Registered listeners notified when a session emits a prompt (permission or question). */
|
|
71
|
+
_promptListeners = [];
|
|
72
|
+
/** Registered listeners notified when a session completes a turn (result event). */
|
|
73
|
+
_resultListeners = [];
|
|
144
74
|
/** Delegated approval logic (auto-approve patterns, deny-lists, pattern management). */
|
|
145
|
-
|
|
75
|
+
_approvalManager;
|
|
146
76
|
/** Delegated auto-naming logic (generates session names from first user message via Claude API). */
|
|
147
77
|
sessionNaming;
|
|
148
78
|
/** Delegated persistence logic (saves/restores session metadata to disk across server restarts). */
|
|
149
79
|
sessionPersistence;
|
|
80
|
+
/** Delegated diff operations (git diff, discard changes). */
|
|
81
|
+
diffManager;
|
|
150
82
|
constructor() {
|
|
151
83
|
this.archive = new SessionArchive();
|
|
152
|
-
this.
|
|
84
|
+
this._approvalManager = new ApprovalManager();
|
|
85
|
+
this.diffManager = new DiffManager();
|
|
153
86
|
this.sessionPersistence = new SessionPersistence(this.sessions);
|
|
154
87
|
this.sessionNaming = new SessionNaming({
|
|
155
88
|
getSession: (id) => this.sessions.get(id),
|
|
156
89
|
hasSession: (id) => this.sessions.has(id),
|
|
157
|
-
getSetting: (key, fallback) => this.archive.getSetting(key, fallback),
|
|
158
90
|
rename: (sessionId, newName) => this.rename(sessionId, newName),
|
|
159
91
|
});
|
|
160
92
|
this.sessionPersistence.restoreFromDisk();
|
|
93
|
+
// Wire PlanManager events for restored sessions
|
|
94
|
+
for (const session of this.sessions.values()) {
|
|
95
|
+
this.wirePlanManager(session);
|
|
96
|
+
}
|
|
161
97
|
}
|
|
162
98
|
// ---------------------------------------------------------------------------
|
|
163
|
-
// Approval
|
|
99
|
+
// Approval — direct accessor (callers use sessions.approvalManager.xxx)
|
|
164
100
|
// ---------------------------------------------------------------------------
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
return this.
|
|
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();
|
|
101
|
+
/** Direct access to the approval manager for callers that need repo-level approval operations. */
|
|
102
|
+
get approvalManager() {
|
|
103
|
+
return this._approvalManager;
|
|
193
104
|
}
|
|
194
105
|
// ---------------------------------------------------------------------------
|
|
195
106
|
// Naming delegation (preserves public API)
|
|
@@ -226,6 +137,8 @@ export class SessionManager {
|
|
|
226
137
|
created: new Date().toISOString(),
|
|
227
138
|
source: options?.source ?? 'manual',
|
|
228
139
|
model: options?.model,
|
|
140
|
+
permissionMode: options?.permissionMode,
|
|
141
|
+
allowedTools: options?.allowedTools,
|
|
229
142
|
claudeProcess: null,
|
|
230
143
|
clients: new Set(),
|
|
231
144
|
outputHistory: [],
|
|
@@ -233,7 +146,6 @@ export class SessionManager {
|
|
|
233
146
|
restartCount: 0,
|
|
234
147
|
lastRestartAt: null,
|
|
235
148
|
_stoppedByUser: false,
|
|
236
|
-
_stallTimer: null,
|
|
237
149
|
_wasActiveBeforeRestart: false,
|
|
238
150
|
_apiRetryCount: 0,
|
|
239
151
|
_turnCount: 0,
|
|
@@ -242,22 +154,243 @@ export class SessionManager {
|
|
|
242
154
|
pendingControlRequests: new Map(),
|
|
243
155
|
pendingToolApprovals: new Map(),
|
|
244
156
|
_leaveGraceTimer: null,
|
|
157
|
+
planManager: new PlanManager(),
|
|
245
158
|
};
|
|
159
|
+
this.wirePlanManager(session);
|
|
246
160
|
this.sessions.set(id, session);
|
|
247
161
|
this.persistToDisk();
|
|
248
162
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
249
163
|
return session;
|
|
250
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Create a git worktree for a session. Creates a new branch and worktree
|
|
167
|
+
* as a sibling directory of the project root.
|
|
168
|
+
* Returns the worktree path on success, or null on failure.
|
|
169
|
+
*/
|
|
170
|
+
async createWorktree(sessionId, workingDir) {
|
|
171
|
+
const session = this.sessions.get(sessionId);
|
|
172
|
+
if (!session)
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
// Resolve the actual git repo root — workingDir may be a subdirectory
|
|
176
|
+
const env = cleanGitEnv();
|
|
177
|
+
const { stdout: repoRootRaw } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
|
|
178
|
+
cwd: workingDir,
|
|
179
|
+
env,
|
|
180
|
+
timeout: 5000,
|
|
181
|
+
});
|
|
182
|
+
const repoRoot = repoRootRaw.trim();
|
|
183
|
+
if (!repoRoot || !path.isAbsolute(repoRoot)) {
|
|
184
|
+
console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const prefix = this.getWorktreeBranchPrefix();
|
|
188
|
+
const shortId = sessionId.slice(0, 8);
|
|
189
|
+
const branchName = `${prefix}${shortId}`;
|
|
190
|
+
const projectName = path.basename(repoRoot);
|
|
191
|
+
const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
|
|
192
|
+
// Clean up stale state from previous failed attempts:
|
|
193
|
+
// 1. Prune orphaned worktree entries (directory gone but git still tracks it)
|
|
194
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
|
|
195
|
+
.catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
|
|
196
|
+
// 2. Remove existing worktree directory if leftover from a partial failure
|
|
197
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
|
|
198
|
+
.catch(() => { }); // Expected to fail if no prior worktree exists
|
|
199
|
+
// 3. Delete the branch if it exists (leftover from a failed worktree add)
|
|
200
|
+
await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
|
|
201
|
+
.catch((e) => console.debug(`[worktree] branch cleanup (expected if fresh):`, e instanceof Error ? e.message : e));
|
|
202
|
+
// Create the worktree with a new branch
|
|
203
|
+
await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
env,
|
|
206
|
+
timeout: 15000,
|
|
207
|
+
});
|
|
208
|
+
// Update session to use the worktree as its working directory
|
|
209
|
+
session.groupDir = repoRoot; // Group under original repo in sidebar
|
|
210
|
+
session.workingDir = worktreePath;
|
|
211
|
+
session.worktreePath = worktreePath;
|
|
212
|
+
// Copy Claude CLI session data to the worktree's project storage dir.
|
|
213
|
+
// startClaude() will use --resume (not --session-id) to continue the
|
|
214
|
+
// session, which should find the JSONL globally. The copy here ensures
|
|
215
|
+
// it's also available in the worktree's project dir as a safety net.
|
|
216
|
+
if (session.claudeSessionId) {
|
|
217
|
+
try {
|
|
218
|
+
this.migrateClaudeSession(session.claudeSessionId, session.claudeSessionId, workingDir, worktreePath, session);
|
|
219
|
+
console.log(`[worktree] Copied Claude session ${session.claudeSessionId} to worktree project dir`);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
console.warn(`[worktree] Failed to migrate session data:`, err instanceof Error ? err.message : err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
this.persistToDisk();
|
|
226
|
+
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
227
|
+
console.log(`[worktree] Created worktree for session ${sessionId}: ${worktreePath} (branch: ${branchName})`);
|
|
228
|
+
return worktreePath;
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(`[worktree] Failed to create worktree for session ${sessionId}:`, err);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the Claude CLI project storage directory for a given working dir.
|
|
237
|
+
* Claude encodes the absolute path by replacing `/` with `-`.
|
|
238
|
+
*/
|
|
239
|
+
claudeProjectPath(cwd) {
|
|
240
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
241
|
+
return path.join(homedir(), '.claude', 'projects', encoded);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Copy Claude CLI session data from the original project storage to
|
|
245
|
+
* the target directory's project storage. When oldId === newId the
|
|
246
|
+
* file is copied without renaming, preserving internal sessionId fields.
|
|
247
|
+
* Claude CLI determines session storage from the CWD, so for worktree
|
|
248
|
+
* migrations the JSONL must be placed in the worktree's project dir.
|
|
249
|
+
*/
|
|
250
|
+
migrateClaudeSession(oldId, newId, originalDir, targetDir, session) {
|
|
251
|
+
const srcProjectDir = this.claudeProjectPath(originalDir);
|
|
252
|
+
const dstProjectDir = this.claudeProjectPath(targetDir);
|
|
253
|
+
const srcJsonl = path.join(srcProjectDir, `${oldId}.jsonl`);
|
|
254
|
+
if (!existsSync(srcJsonl)) {
|
|
255
|
+
console.warn(`[worktree] No session JSONL at ${srcJsonl}, conversation history will not be preserved`);
|
|
256
|
+
if (session) {
|
|
257
|
+
const warningMsg = {
|
|
258
|
+
type: 'system_message',
|
|
259
|
+
subtype: 'notification',
|
|
260
|
+
text: 'Conversation history could not be preserved during worktree migration. The session will continue without prior context.',
|
|
261
|
+
};
|
|
262
|
+
this.addToHistory(session, warningMsg);
|
|
263
|
+
this.broadcast(session, warningMsg);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Claude CLI determines session storage from the CWD, not CLAUDE_PROJECT_DIR.
|
|
268
|
+
// The worktree has a different CWD, so we must copy the JSONL into the
|
|
269
|
+
// worktree's project storage directory for --session-id to find it.
|
|
270
|
+
mkdirSync(dstProjectDir, { recursive: true });
|
|
271
|
+
copyFileSync(srcJsonl, path.join(dstProjectDir, `${newId}.jsonl`));
|
|
272
|
+
console.log(`[worktree] Copied session JSONL ${oldId} → ${newId} (${srcProjectDir} → ${dstProjectDir})`);
|
|
273
|
+
// Copy session subdirectory (subagents/, tool-results/) if it exists
|
|
274
|
+
const srcSessionDir = path.join(srcProjectDir, oldId);
|
|
275
|
+
if (existsSync(srcSessionDir) && statSync(srcSessionDir).isDirectory()) {
|
|
276
|
+
this.copyDirRecursive(srcSessionDir, path.join(dstProjectDir, newId));
|
|
277
|
+
console.log(`[worktree] Copied session subdirectory ${oldId} → ${newId}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Recursively copy a directory. */
|
|
281
|
+
copyDirRecursive(src, dst) {
|
|
282
|
+
mkdirSync(dst, { recursive: true });
|
|
283
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
284
|
+
const srcPath = path.join(src, entry.name);
|
|
285
|
+
const dstPath = path.join(dst, entry.name);
|
|
286
|
+
if (entry.isDirectory()) {
|
|
287
|
+
this.copyDirRecursive(srcPath, dstPath);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
copyFileSync(srcPath, dstPath);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Clean up a git worktree and its branch. Runs asynchronously and logs errors
|
|
296
|
+
* but never throws — session deletion must not be blocked by cleanup failures.
|
|
297
|
+
*/
|
|
298
|
+
cleanupWorktree(worktreePath, repoDir) {
|
|
299
|
+
void (async () => {
|
|
300
|
+
try {
|
|
301
|
+
// Resolve the actual repo root (repoDir may itself be a worktree)
|
|
302
|
+
const { stdout: repoRootRaw } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
|
|
303
|
+
cwd: repoDir,
|
|
304
|
+
timeout: 5000,
|
|
305
|
+
}).catch(() => ({ stdout: repoDir }));
|
|
306
|
+
const repoRoot = repoRootRaw.trim() || repoDir;
|
|
307
|
+
// Remove the worktree
|
|
308
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
309
|
+
cwd: repoRoot,
|
|
310
|
+
timeout: 10000,
|
|
311
|
+
});
|
|
312
|
+
console.log(`[worktree] Cleaned up worktree: ${worktreePath}`);
|
|
313
|
+
// Prune any stale worktree references
|
|
314
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, timeout: 5000 })
|
|
315
|
+
.catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
console.warn(`[worktree] Failed to clean up worktree ${worktreePath}:`, err instanceof Error ? err.message : err);
|
|
319
|
+
}
|
|
320
|
+
})();
|
|
321
|
+
}
|
|
322
|
+
/** Get the configured worktree branch prefix (defaults to 'wt/'). */
|
|
323
|
+
getWorktreeBranchPrefix() {
|
|
324
|
+
return this.archive.getSetting('worktree_branch_prefix', 'wt/');
|
|
325
|
+
}
|
|
326
|
+
/** Set the worktree branch prefix. */
|
|
327
|
+
setWorktreeBranchPrefix(prefix) {
|
|
328
|
+
this.archive.setSetting('worktree_branch_prefix', prefix);
|
|
329
|
+
}
|
|
251
330
|
/** Register a listener called when any session's Claude process exits.
|
|
252
331
|
* The `willRestart` flag indicates whether the session will be auto-restarted. */
|
|
253
332
|
onSessionExit(listener) {
|
|
254
333
|
this._exitListeners.push(listener);
|
|
334
|
+
return () => {
|
|
335
|
+
const idx = this._exitListeners.indexOf(listener);
|
|
336
|
+
if (idx >= 0)
|
|
337
|
+
this._exitListeners.splice(idx, 1);
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/** Register a listener called when any session emits a prompt (permission request or question). */
|
|
341
|
+
onSessionPrompt(listener) {
|
|
342
|
+
this._promptListeners.push(listener);
|
|
343
|
+
}
|
|
344
|
+
/** Register a listener called when any session completes a turn (result event). */
|
|
345
|
+
onSessionResult(listener) {
|
|
346
|
+
this._resultListeners.push(listener);
|
|
347
|
+
return () => {
|
|
348
|
+
const idx = this._resultListeners.indexOf(listener);
|
|
349
|
+
if (idx >= 0)
|
|
350
|
+
this._resultListeners.splice(idx, 1);
|
|
351
|
+
};
|
|
255
352
|
}
|
|
256
353
|
get(id) {
|
|
257
354
|
return this.sessions.get(id);
|
|
258
355
|
}
|
|
356
|
+
/** Get all sessions that have pending prompts (waiting for approval or answer). */
|
|
357
|
+
getPendingPrompts() {
|
|
358
|
+
const results = [];
|
|
359
|
+
for (const session of this.sessions.values()) {
|
|
360
|
+
const prompts = [];
|
|
361
|
+
for (const [reqId, pending] of session.pendingToolApprovals) {
|
|
362
|
+
prompts.push({
|
|
363
|
+
requestId: reqId,
|
|
364
|
+
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
365
|
+
toolName: pending.toolName,
|
|
366
|
+
toolInput: pending.toolInput,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
370
|
+
prompts.push({
|
|
371
|
+
requestId: reqId,
|
|
372
|
+
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
373
|
+
toolName: pending.toolName,
|
|
374
|
+
toolInput: pending.toolInput,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (prompts.length > 0) {
|
|
378
|
+
results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return results;
|
|
382
|
+
}
|
|
383
|
+
/** Clear the isProcessing flag for a session and broadcast the update. */
|
|
384
|
+
clearProcessingFlag(sessionId) {
|
|
385
|
+
const session = this.sessions.get(sessionId);
|
|
386
|
+
if (session && session.isProcessing) {
|
|
387
|
+
session.isProcessing = false;
|
|
388
|
+
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
259
391
|
list() {
|
|
260
|
-
return Array.from(this.sessions.values())
|
|
392
|
+
return Array.from(this.sessions.values())
|
|
393
|
+
.map((s) => ({
|
|
261
394
|
id: s.id,
|
|
262
395
|
name: s.name,
|
|
263
396
|
created: s.created,
|
|
@@ -265,6 +398,24 @@ export class SessionManager {
|
|
|
265
398
|
isProcessing: s.isProcessing,
|
|
266
399
|
workingDir: s.workingDir,
|
|
267
400
|
groupDir: s.groupDir,
|
|
401
|
+
worktreePath: s.worktreePath,
|
|
402
|
+
connectedClients: s.clients.size,
|
|
403
|
+
lastActivity: s.created,
|
|
404
|
+
source: s.source,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
/** List ALL sessions including orchestrator — used by orchestrator cleanup endpoints. */
|
|
408
|
+
listAll() {
|
|
409
|
+
return Array.from(this.sessions.values())
|
|
410
|
+
.map((s) => ({
|
|
411
|
+
id: s.id,
|
|
412
|
+
name: s.name,
|
|
413
|
+
created: s.created,
|
|
414
|
+
active: s.claudeProcess?.isAlive() ?? false,
|
|
415
|
+
isProcessing: s.isProcessing,
|
|
416
|
+
workingDir: s.workingDir,
|
|
417
|
+
groupDir: s.groupDir,
|
|
418
|
+
worktreePath: s.worktreePath,
|
|
268
419
|
connectedClients: s.clients.size,
|
|
269
420
|
lastActivity: s.created,
|
|
270
421
|
source: s.source,
|
|
@@ -351,7 +502,6 @@ export class SessionManager {
|
|
|
351
502
|
return false;
|
|
352
503
|
// Prevent auto-restart when deleting
|
|
353
504
|
session._stoppedByUser = true;
|
|
354
|
-
this.clearStallTimer(session);
|
|
355
505
|
if (session._apiRetryTimer)
|
|
356
506
|
clearTimeout(session._apiRetryTimer);
|
|
357
507
|
if (session._namingTimer)
|
|
@@ -364,6 +514,10 @@ export class SessionManager {
|
|
|
364
514
|
session.claudeProcess = null;
|
|
365
515
|
}
|
|
366
516
|
this.archiveSessionIfWorthSaving(session);
|
|
517
|
+
// Clean up git worktree if this session used one
|
|
518
|
+
if (session.worktreePath) {
|
|
519
|
+
this.cleanupWorktree(session.worktreePath, session.groupDir ?? session.workingDir);
|
|
520
|
+
}
|
|
367
521
|
// Clean up webhook workspace directory if applicable
|
|
368
522
|
if (session.source === 'webhook' || session.source === 'stepflow') {
|
|
369
523
|
cleanupWorkspace(sessionId);
|
|
@@ -431,6 +585,8 @@ export class SessionManager {
|
|
|
431
585
|
const sessionToken = this._authToken
|
|
432
586
|
? deriveSessionToken(this._authToken, sessionId)
|
|
433
587
|
: '';
|
|
588
|
+
// Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
|
|
589
|
+
// (current canonical name) are set to the same derived value for backward compatibility.
|
|
434
590
|
const extraEnv = {
|
|
435
591
|
CODEKIN_SESSION_ID: sessionId,
|
|
436
592
|
CODEKIN_PORT: String(this._serverPort || PORT),
|
|
@@ -438,16 +594,28 @@ export class SessionManager {
|
|
|
438
594
|
CODEKIN_AUTH_TOKEN: sessionToken,
|
|
439
595
|
CODEKIN_SESSION_TYPE: session.source || 'manual',
|
|
440
596
|
};
|
|
441
|
-
// Pass CLAUDE_PROJECT_DIR so hooks resolve correctly
|
|
442
|
-
// working directory differs from the project root
|
|
443
|
-
|
|
597
|
+
// Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
|
|
598
|
+
// even when the session's working directory differs from the project root
|
|
599
|
+
// (e.g. worktrees, webhook workspaces). Note: this does NOT control
|
|
600
|
+
// session storage path — Claude CLI uses the CWD for that.
|
|
601
|
+
if (session.groupDir) {
|
|
602
|
+
extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
|
|
603
|
+
}
|
|
604
|
+
else if (process.env.CLAUDE_PROJECT_DIR) {
|
|
444
605
|
extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
|
|
445
606
|
}
|
|
446
|
-
|
|
607
|
+
// When claudeSessionId exists, the session has run before and a JSONL file
|
|
608
|
+
// exists on disk. Use --resume (not --session-id) to continue it — --session-id
|
|
609
|
+
// creates a *new* session and fails with "already in use" if the JSONL exists.
|
|
610
|
+
const resume = !!session.claudeSessionId;
|
|
611
|
+
// Build comprehensive allowedTools from session-level overrides + registry approvals
|
|
612
|
+
const repoDir = session.groupDir ?? session.workingDir;
|
|
613
|
+
const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
|
|
614
|
+
const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
|
|
615
|
+
const cp = new ClaudeProcess(session.workingDir, session.claudeSessionId || undefined, extraEnv, session.model, session.permissionMode, resume, mergedAllowedTools);
|
|
447
616
|
this.wireClaudeEvents(cp, session, sessionId);
|
|
448
617
|
cp.start();
|
|
449
618
|
session.claudeProcess = cp;
|
|
450
|
-
this.resetStallTimer(session);
|
|
451
619
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
452
620
|
const startMsg = { type: 'claude_started', sessionId };
|
|
453
621
|
this.addToHistory(session, startMsg);
|
|
@@ -466,11 +634,23 @@ export class SessionManager {
|
|
|
466
634
|
cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
|
|
467
635
|
cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
|
|
468
636
|
cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
|
|
469
|
-
cp.on('planning_mode', (active) => {
|
|
637
|
+
cp.on('planning_mode', (active) => {
|
|
638
|
+
// Route EnterPlanMode through PlanManager for UI state tracking.
|
|
639
|
+
// ExitPlanMode (active=false) is ignored here — the PreToolUse hook
|
|
640
|
+
// is the enforcement gate, and it calls handleExitPlanModeApproval()
|
|
641
|
+
// which transitions PlanManager to 'reviewing'.
|
|
642
|
+
if (active) {
|
|
643
|
+
session.planManager.onEnterPlanMode();
|
|
644
|
+
}
|
|
645
|
+
// ExitPlanMode stream event intentionally ignored — hook handles it.
|
|
646
|
+
});
|
|
470
647
|
cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
|
|
471
648
|
cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
|
|
472
649
|
cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
|
|
473
|
-
cp.on('result', (result, isError) => {
|
|
650
|
+
cp.on('result', (result, isError) => {
|
|
651
|
+
session.planManager.onTurnEnd();
|
|
652
|
+
this.handleClaudeResult(session, sessionId, result, isError);
|
|
653
|
+
});
|
|
474
654
|
cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
|
|
475
655
|
cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(session, sessionId, code, signal); });
|
|
476
656
|
}
|
|
@@ -479,6 +659,20 @@ export class SessionManager {
|
|
|
479
659
|
this.addToHistory(session, msg);
|
|
480
660
|
this.broadcast(session, msg);
|
|
481
661
|
}
|
|
662
|
+
/**
|
|
663
|
+
* Wire PlanManager events for a session.
|
|
664
|
+
* Called once at session creation (not per-process, since PlanManager outlives restarts).
|
|
665
|
+
* Idempotent — guards against double-wiring on restore + restart.
|
|
666
|
+
*/
|
|
667
|
+
wirePlanManager(session) {
|
|
668
|
+
if (session._planManagerWired)
|
|
669
|
+
return;
|
|
670
|
+
session._planManagerWired = true;
|
|
671
|
+
const pm = session.planManager;
|
|
672
|
+
pm.on('planning_mode', (active) => {
|
|
673
|
+
this.broadcastAndHistory(session, { type: 'planning_mode', active });
|
|
674
|
+
});
|
|
675
|
+
}
|
|
482
676
|
onSystemInit(cp, session, model) {
|
|
483
677
|
session.claudeSessionId = cp.getSessionId();
|
|
484
678
|
// Only show model message on first init or when model actually changes
|
|
@@ -488,30 +682,24 @@ export class SessionManager {
|
|
|
488
682
|
}
|
|
489
683
|
}
|
|
490
684
|
onTextEvent(session, sessionId, text) {
|
|
491
|
-
this.resetStallTimer(session);
|
|
492
685
|
this.broadcastAndHistory(session, { type: 'output', data: text });
|
|
493
686
|
if (session.name.startsWith('hub:') && !session._namingTimer) {
|
|
494
687
|
this.scheduleSessionNaming(sessionId);
|
|
495
688
|
}
|
|
496
689
|
}
|
|
497
690
|
onThinkingEvent(session, summary) {
|
|
498
|
-
this.resetStallTimer(session);
|
|
499
691
|
this.broadcast(session, { type: 'thinking', summary });
|
|
500
692
|
}
|
|
501
693
|
onToolOutputEvent(session, content, isError) {
|
|
502
|
-
this.resetStallTimer(session);
|
|
503
694
|
this.broadcastAndHistory(session, { type: 'tool_output', content, isError });
|
|
504
695
|
}
|
|
505
696
|
onImageEvent(session, base64, mediaType) {
|
|
506
|
-
this.resetStallTimer(session);
|
|
507
697
|
this.broadcastAndHistory(session, { type: 'image', base64, mediaType });
|
|
508
698
|
}
|
|
509
699
|
onToolActiveEvent(session, toolName, toolInput) {
|
|
510
|
-
this.resetStallTimer(session);
|
|
511
700
|
this.broadcastAndHistory(session, { type: 'tool_active', toolName, toolInput });
|
|
512
701
|
}
|
|
513
702
|
onToolDoneEvent(session, toolName, summary) {
|
|
514
|
-
this.resetStallTimer(session);
|
|
515
703
|
this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
|
|
516
704
|
}
|
|
517
705
|
onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
|
|
@@ -530,6 +718,13 @@ export class SessionManager {
|
|
|
530
718
|
session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
|
|
531
719
|
}
|
|
532
720
|
this.broadcast(session, promptMsg);
|
|
721
|
+
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
722
|
+
for (const listener of this._promptListeners) {
|
|
723
|
+
try {
|
|
724
|
+
listener(session.id, promptType, toolName, requestId);
|
|
725
|
+
}
|
|
726
|
+
catch { /* listener error */ }
|
|
727
|
+
}
|
|
533
728
|
}
|
|
534
729
|
onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
|
|
535
730
|
if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
|
|
@@ -542,10 +737,23 @@ export class SessionManager {
|
|
|
542
737
|
cp.sendControlResponse(requestId, 'allow');
|
|
543
738
|
return;
|
|
544
739
|
}
|
|
740
|
+
// Prevent double-gating: if a PreToolUse hook is already handling approval
|
|
741
|
+
// for this tool, auto-approve the control_request to avoid duplicate entries.
|
|
742
|
+
// Without this, both pendingToolApprovals and pendingControlRequests contain
|
|
743
|
+
// entries for the same tool invocation, causing stale-entry races when the
|
|
744
|
+
// orchestrator tries to respond via the REST API.
|
|
745
|
+
for (const pending of session.pendingToolApprovals.values()) {
|
|
746
|
+
if (pending.toolName === toolName) {
|
|
747
|
+
console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
|
|
748
|
+
cp.sendControlResponse(requestId, 'allow');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
545
752
|
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
753
|
+
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
546
754
|
const options = [
|
|
547
755
|
{ label: 'Allow', value: 'allow' },
|
|
548
|
-
{ label: 'Always Allow', value: 'always_allow' },
|
|
756
|
+
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
549
757
|
{ label: 'Deny', value: 'deny' },
|
|
550
758
|
];
|
|
551
759
|
const promptMsg = {
|
|
@@ -569,6 +777,13 @@ export class SessionManager {
|
|
|
569
777
|
sessionName: session.name,
|
|
570
778
|
});
|
|
571
779
|
}
|
|
780
|
+
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
781
|
+
for (const listener of this._promptListeners) {
|
|
782
|
+
try {
|
|
783
|
+
listener(sessionId, 'permission', toolName, requestId);
|
|
784
|
+
}
|
|
785
|
+
catch { /* listener error */ }
|
|
786
|
+
}
|
|
572
787
|
}
|
|
573
788
|
/**
|
|
574
789
|
* Handle a Claude process 'result' event: update session state, apply API
|
|
@@ -623,9 +838,26 @@ export class SessionManager {
|
|
|
623
838
|
this.broadcast(session, msg);
|
|
624
839
|
}
|
|
625
840
|
}
|
|
841
|
+
// Suppress noise from orchestrator/agent sessions: if the entire turn's
|
|
842
|
+
// text output is a short, low-value phrase, strip it from history so it
|
|
843
|
+
// doesn't pollute the chat or replay on rejoin.
|
|
844
|
+
if ((session.source === 'orchestrator' || session.source === 'agent') && !isError) {
|
|
845
|
+
const turnText = this.extractCurrentTurnText(session);
|
|
846
|
+
if (turnText && turnText.length < 80 && /^(no response requested|please approve|nothing to do|no action needed|acknowledged)[.!]?$/i.test(turnText.trim())) {
|
|
847
|
+
this.stripCurrentTurnOutput(session);
|
|
848
|
+
console.log(`[noise-filter] suppressed orchestrator noise: "${turnText.trim().slice(0, 60)}"`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
626
851
|
const resultMsg = { type: 'result' };
|
|
627
852
|
this.addToHistory(session, resultMsg);
|
|
628
853
|
this.broadcast(session, resultMsg);
|
|
854
|
+
// Notify result listeners (orchestrator, child monitor, etc.)
|
|
855
|
+
for (const listener of this._resultListeners) {
|
|
856
|
+
try {
|
|
857
|
+
listener(sessionId, isError);
|
|
858
|
+
}
|
|
859
|
+
catch { /* listener error */ }
|
|
860
|
+
}
|
|
629
861
|
// If session is still unnamed after first response, name it now — we have full context
|
|
630
862
|
if (session.name.startsWith('hub:') && session._namingAttempts === 0) {
|
|
631
863
|
if (session._namingTimer) {
|
|
@@ -638,15 +870,21 @@ export class SessionManager {
|
|
|
638
870
|
/**
|
|
639
871
|
* Handle a Claude process 'exit' event: clean up state, notify exit listeners,
|
|
640
872
|
* and either auto-restart (within limits) or broadcast the final exit message.
|
|
873
|
+
*
|
|
874
|
+
* Uses evaluateRestart() for the restart decision, keeping this method focused
|
|
875
|
+
* on state updates, listener notification, and message broadcasting.
|
|
641
876
|
*/
|
|
642
877
|
handleClaudeExit(session, sessionId, code, signal) {
|
|
643
878
|
session.claudeProcess = null;
|
|
644
879
|
session.isProcessing = false;
|
|
645
|
-
|
|
880
|
+
session.planManager.reset();
|
|
646
881
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
882
|
+
const action = evaluateRestart({
|
|
883
|
+
restartCount: session.restartCount,
|
|
884
|
+
lastRestartAt: session.lastRestartAt,
|
|
885
|
+
stoppedByUser: session._stoppedByUser,
|
|
886
|
+
});
|
|
887
|
+
if (action.kind === 'stopped_by_user') {
|
|
650
888
|
for (const listener of this._exitListeners) {
|
|
651
889
|
try {
|
|
652
890
|
listener(sessionId, code, signal, false);
|
|
@@ -659,34 +897,19 @@ export class SessionManager {
|
|
|
659
897
|
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
660
898
|
return;
|
|
661
899
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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)
|
|
900
|
+
if (action.kind === 'restart') {
|
|
901
|
+
session.restartCount = action.updatedCount;
|
|
902
|
+
session.lastRestartAt = action.updatedLastRestartAt;
|
|
673
903
|
for (const listener of this._exitListeners) {
|
|
674
904
|
try {
|
|
675
905
|
listener(sessionId, code, signal, true);
|
|
676
906
|
}
|
|
677
907
|
catch { /* listener error */ }
|
|
678
908
|
}
|
|
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
909
|
const msg = {
|
|
687
910
|
type: 'system_message',
|
|
688
911
|
subtype: 'restart',
|
|
689
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${attempt}/${
|
|
912
|
+
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
|
|
690
913
|
};
|
|
691
914
|
this.addToHistory(session, msg);
|
|
692
915
|
this.broadcast(session, msg);
|
|
@@ -694,26 +917,38 @@ export class SessionManager {
|
|
|
694
917
|
// Verify session still exists and hasn't been stopped
|
|
695
918
|
if (!this.sessions.has(sessionId) || session._stoppedByUser)
|
|
696
919
|
return;
|
|
920
|
+
// startClaude uses --resume when claudeSessionId exists, so the CLI
|
|
921
|
+
// picks up the full conversation history from the JSONL automatically.
|
|
697
922
|
this.startClaude(sessionId);
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
923
|
+
// Fallback: if claudeSessionId was already null (fresh session that
|
|
924
|
+
// crashed before system_init), inject a context summary so the new
|
|
925
|
+
// session has some awareness of prior conversation.
|
|
926
|
+
if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
|
|
927
|
+
session.claudeProcess.once('system_init', () => {
|
|
928
|
+
const context = this.buildSessionContext(session);
|
|
929
|
+
if (context) {
|
|
930
|
+
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.]');
|
|
931
|
+
}
|
|
932
|
+
});
|
|
705
933
|
}
|
|
706
|
-
|
|
934
|
+
}, action.delayMs);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
// action.kind === 'exhausted'
|
|
938
|
+
for (const listener of this._exitListeners) {
|
|
939
|
+
try {
|
|
940
|
+
listener(sessionId, code, signal, false);
|
|
707
941
|
}
|
|
708
|
-
|
|
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 });
|
|
942
|
+
catch { /* listener error */ }
|
|
716
943
|
}
|
|
944
|
+
const msg = {
|
|
945
|
+
type: 'system_message',
|
|
946
|
+
subtype: 'error',
|
|
947
|
+
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
|
|
948
|
+
};
|
|
949
|
+
this.addToHistory(session, msg);
|
|
950
|
+
this.broadcast(session, msg);
|
|
951
|
+
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
717
952
|
}
|
|
718
953
|
/**
|
|
719
954
|
* Send user input to a session's Claude process.
|
|
@@ -772,6 +1007,8 @@ export class SessionManager {
|
|
|
772
1007
|
const session = this.sessions.get(sessionId);
|
|
773
1008
|
if (!session)
|
|
774
1009
|
return;
|
|
1010
|
+
// ExitPlanMode approvals are handled through the normal pendingToolApprovals
|
|
1011
|
+
// path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
|
|
775
1012
|
// Check for pending tool approval from PreToolUse hook
|
|
776
1013
|
if (!requestId) {
|
|
777
1014
|
const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
|
|
@@ -843,12 +1080,42 @@ export class SessionManager {
|
|
|
843
1080
|
}
|
|
844
1081
|
/** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
|
|
845
1082
|
resolveToolApproval(session, approval, value) {
|
|
1083
|
+
// AskUserQuestion: the value IS the user's answer, not a permission decision
|
|
1084
|
+
if (approval.toolName === 'AskUserQuestion') {
|
|
1085
|
+
const answer = Array.isArray(value) ? value.join(', ') : value;
|
|
1086
|
+
console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
|
|
1087
|
+
approval.resolve({ allow: true, always: false, answer });
|
|
1088
|
+
session.pendingToolApprovals.delete(approval.requestId);
|
|
1089
|
+
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
// ExitPlanMode: route through PlanManager for state tracking.
|
|
1093
|
+
// The hook will convert allow→deny-with-approval-message (CLI workaround).
|
|
1094
|
+
if (approval.toolName === 'ExitPlanMode') {
|
|
1095
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
1096
|
+
const isDeny = first === 'deny';
|
|
1097
|
+
if (isDeny) {
|
|
1098
|
+
// Extract feedback text if present (value may be ['deny', 'feedback text'])
|
|
1099
|
+
const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
|
|
1100
|
+
const reason = session.planManager.deny(approval.requestId, feedback);
|
|
1101
|
+
console.log(`[plan-approval] denied: ${reason}`);
|
|
1102
|
+
approval.resolve({ allow: false, always: false });
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
session.planManager.approve(approval.requestId);
|
|
1106
|
+
console.log(`[plan-approval] approved`);
|
|
1107
|
+
approval.resolve({ allow: true, always: false });
|
|
1108
|
+
}
|
|
1109
|
+
session.pendingToolApprovals.delete(approval.requestId);
|
|
1110
|
+
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
846
1113
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
847
1114
|
if (isAlwaysAllow && !isDeny) {
|
|
848
|
-
this.
|
|
1115
|
+
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
849
1116
|
}
|
|
850
1117
|
if (isApprovePattern && !isDeny) {
|
|
851
|
-
this.
|
|
1118
|
+
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
852
1119
|
}
|
|
853
1120
|
console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
|
|
854
1121
|
approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
|
|
@@ -892,10 +1159,10 @@ export class SessionManager {
|
|
|
892
1159
|
sendControlResponseForRequest(session, pending, value) {
|
|
893
1160
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
894
1161
|
if (isAlwaysAllow) {
|
|
895
|
-
this.
|
|
1162
|
+
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
896
1163
|
}
|
|
897
1164
|
if (isApprovePattern) {
|
|
898
|
-
this.
|
|
1165
|
+
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
899
1166
|
}
|
|
900
1167
|
const behavior = isDeny ? 'deny' : 'allow';
|
|
901
1168
|
session.claudeProcess.sendControlResponse(pending.requestId, behavior);
|
|
@@ -915,11 +1182,36 @@ export class SessionManager {
|
|
|
915
1182
|
console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
|
|
916
1183
|
return Promise.resolve({ allow: true, always: true });
|
|
917
1184
|
}
|
|
1185
|
+
if (autoResult === 'session') {
|
|
1186
|
+
console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
|
|
1187
|
+
return Promise.resolve({ allow: true, always: false });
|
|
1188
|
+
}
|
|
918
1189
|
if (autoResult === 'headless') {
|
|
919
1190
|
console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
|
|
920
1191
|
return Promise.resolve({ allow: true, always: false });
|
|
921
1192
|
}
|
|
922
1193
|
console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
|
|
1194
|
+
// ExitPlanMode: route through PlanManager state machine for plan-specific
|
|
1195
|
+
// approval UI. The hook blocks until we resolve the promise.
|
|
1196
|
+
if (toolName === 'ExitPlanMode') {
|
|
1197
|
+
return this.handleExitPlanModeApproval(session, sessionId);
|
|
1198
|
+
}
|
|
1199
|
+
// Prevent double-gating: if a control_request already created a pending
|
|
1200
|
+
// entry for this tool, auto-approve the control_request and let the hook
|
|
1201
|
+
// take over as the sole approval gate. This is the reverse of the check
|
|
1202
|
+
// in onControlRequestEvent (which handles hook-first ordering).
|
|
1203
|
+
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
1204
|
+
if (pending.toolName === toolName) {
|
|
1205
|
+
console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
|
|
1206
|
+
session.claudeProcess?.sendControlResponse(reqId, 'allow');
|
|
1207
|
+
session.pendingControlRequests.delete(reqId);
|
|
1208
|
+
this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// AskUserQuestion: show a question prompt and collect the answer text,
|
|
1213
|
+
// rather than a permission prompt with Allow/Deny buttons.
|
|
1214
|
+
const isQuestion = toolName === 'AskUserQuestion';
|
|
923
1215
|
return new Promise((resolve) => {
|
|
924
1216
|
// Holder lets wrappedResolve reference the timeout before it's assigned
|
|
925
1217
|
const timer = { id: null };
|
|
@@ -939,24 +1231,57 @@ export class SessionManager {
|
|
|
939
1231
|
this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
|
|
940
1232
|
resolve({ allow: false, always: false });
|
|
941
1233
|
}
|
|
942
|
-
}, 60_000);
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1234
|
+
}, isQuestion ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions & agent children, 1 min for interactive
|
|
1235
|
+
let promptMsg;
|
|
1236
|
+
if (isQuestion) {
|
|
1237
|
+
// AskUserQuestion: extract structured questions from toolInput.questions
|
|
1238
|
+
// and pass them through so PromptButtons can render the multi-question flow.
|
|
1239
|
+
const rawQuestions = toolInput.questions;
|
|
1240
|
+
const structuredQuestions = Array.isArray(rawQuestions)
|
|
1241
|
+
? rawQuestions.map(q => ({
|
|
1242
|
+
question: q.question,
|
|
1243
|
+
header: q.header,
|
|
1244
|
+
multiSelect: q.multiSelect ?? false,
|
|
1245
|
+
options: (q.options || []).map((opt) => ({
|
|
1246
|
+
label: opt.label,
|
|
1247
|
+
value: opt.value ?? opt.label,
|
|
1248
|
+
description: opt.description,
|
|
1249
|
+
})),
|
|
1250
|
+
}))
|
|
1251
|
+
: undefined;
|
|
1252
|
+
const firstQ = structuredQuestions?.[0];
|
|
1253
|
+
promptMsg = {
|
|
1254
|
+
type: 'prompt',
|
|
1255
|
+
promptType: 'question',
|
|
1256
|
+
question: firstQ?.question || 'Answer the question',
|
|
1257
|
+
options: firstQ?.options || [],
|
|
1258
|
+
multiSelect: firstQ?.multiSelect,
|
|
1259
|
+
toolName,
|
|
1260
|
+
toolInput,
|
|
1261
|
+
requestId: approvalRequestId,
|
|
1262
|
+
...(structuredQuestions ? { questions: structuredQuestions } : {}),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
1267
|
+
const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
|
|
1268
|
+
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
1269
|
+
const options = [
|
|
1270
|
+
{ label: 'Allow', value: 'allow' },
|
|
1271
|
+
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
1272
|
+
{ label: 'Deny', value: 'deny' },
|
|
1273
|
+
];
|
|
1274
|
+
promptMsg = {
|
|
1275
|
+
type: 'prompt',
|
|
1276
|
+
promptType: 'permission',
|
|
1277
|
+
question,
|
|
1278
|
+
options,
|
|
1279
|
+
toolName,
|
|
1280
|
+
toolInput,
|
|
1281
|
+
requestId: approvalRequestId,
|
|
1282
|
+
...(approvePattern ? { approvePattern } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
960
1285
|
session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
|
|
961
1286
|
if (session.clients.size > 0) {
|
|
962
1287
|
this.broadcast(session, promptMsg);
|
|
@@ -972,22 +1297,119 @@ export class SessionManager {
|
|
|
972
1297
|
sessionName: session.name,
|
|
973
1298
|
});
|
|
974
1299
|
}
|
|
1300
|
+
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
1301
|
+
for (const listener of this._promptListeners) {
|
|
1302
|
+
try {
|
|
1303
|
+
listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
|
|
1304
|
+
}
|
|
1305
|
+
catch { /* listener error */ }
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Handle ExitPlanMode approval through PlanManager.
|
|
1311
|
+
* Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
|
|
1312
|
+
* until the user responds. On approve, returns allow:true (the hook will use
|
|
1313
|
+
* the deny-with-approval-message workaround). On deny, returns allow:false.
|
|
1314
|
+
*/
|
|
1315
|
+
handleExitPlanModeApproval(session, sessionId) {
|
|
1316
|
+
const reviewId = session.planManager.onExitPlanModeRequested();
|
|
1317
|
+
if (!reviewId) {
|
|
1318
|
+
// Not in planning state — fall through to allow (CLI handles natively)
|
|
1319
|
+
console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
|
|
1320
|
+
return Promise.resolve({ allow: true, always: false });
|
|
1321
|
+
}
|
|
1322
|
+
return new Promise((resolve) => {
|
|
1323
|
+
const timer = { id: null };
|
|
1324
|
+
const wrappedResolve = (result) => {
|
|
1325
|
+
if (timer.id)
|
|
1326
|
+
clearTimeout(timer.id);
|
|
1327
|
+
resolve(result);
|
|
1328
|
+
};
|
|
1329
|
+
// Timeout: auto-deny after 5 minutes to prevent leaked promises
|
|
1330
|
+
timer.id = setTimeout(() => {
|
|
1331
|
+
if (session.pendingToolApprovals.has(reviewId)) {
|
|
1332
|
+
console.log(`[plan-approval] timed out, auto-denying`);
|
|
1333
|
+
session.pendingToolApprovals.delete(reviewId);
|
|
1334
|
+
session.planManager.deny(reviewId);
|
|
1335
|
+
this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
|
|
1336
|
+
resolve({ allow: false, always: false });
|
|
1337
|
+
}
|
|
1338
|
+
}, 300_000);
|
|
1339
|
+
const promptMsg = {
|
|
1340
|
+
type: 'prompt',
|
|
1341
|
+
promptType: 'permission',
|
|
1342
|
+
question: 'Approve plan and start implementation?',
|
|
1343
|
+
options: [
|
|
1344
|
+
{ label: 'Approve', value: 'allow' },
|
|
1345
|
+
{ label: 'Reject', value: 'deny' },
|
|
1346
|
+
],
|
|
1347
|
+
toolName: 'ExitPlanMode',
|
|
1348
|
+
requestId: reviewId,
|
|
1349
|
+
};
|
|
1350
|
+
session.pendingToolApprovals.set(reviewId, {
|
|
1351
|
+
resolve: wrappedResolve,
|
|
1352
|
+
toolName: 'ExitPlanMode',
|
|
1353
|
+
toolInput: {},
|
|
1354
|
+
requestId: reviewId,
|
|
1355
|
+
promptMsg,
|
|
1356
|
+
});
|
|
1357
|
+
this.broadcast(session, promptMsg);
|
|
1358
|
+
if (session.clients.size === 0) {
|
|
1359
|
+
this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
|
|
1360
|
+
}
|
|
1361
|
+
for (const listener of this._promptListeners) {
|
|
1362
|
+
try {
|
|
1363
|
+
listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
|
|
1364
|
+
}
|
|
1365
|
+
catch { /* listener error */ }
|
|
1366
|
+
}
|
|
975
1367
|
});
|
|
976
1368
|
}
|
|
977
1369
|
/**
|
|
978
1370
|
* Check if a tool invocation can be auto-approved without prompting the user.
|
|
979
|
-
* Returns 'registry' if matched by auto-approval rules, '
|
|
980
|
-
*
|
|
1371
|
+
* Returns 'registry' if matched by auto-approval rules, 'session' if matched
|
|
1372
|
+
* by the session's allowedTools list, 'headless' if the session has no clients
|
|
1373
|
+
* and is a non-interactive source, or 'prompt' if the user needs to decide.
|
|
981
1374
|
*/
|
|
982
1375
|
resolveAutoApproval(session, toolName, toolInput) {
|
|
983
|
-
if (this.checkAutoApproval(session.workingDir, toolName, toolInput)) {
|
|
1376
|
+
if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
|
|
984
1377
|
return 'registry';
|
|
985
1378
|
}
|
|
1379
|
+
if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
|
|
1380
|
+
return 'session';
|
|
1381
|
+
}
|
|
986
1382
|
if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')) {
|
|
987
1383
|
return 'headless';
|
|
988
1384
|
}
|
|
989
1385
|
return 'prompt';
|
|
990
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Check if a tool invocation matches any of the session's allowedTools patterns.
|
|
1389
|
+
* Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
|
|
1390
|
+
* Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
|
|
1391
|
+
*/
|
|
1392
|
+
matchesAllowedTools(allowedTools, toolName, toolInput) {
|
|
1393
|
+
for (const pattern of allowedTools) {
|
|
1394
|
+
// Simple tool name match: 'WebFetch', 'Read', etc.
|
|
1395
|
+
if (pattern === toolName)
|
|
1396
|
+
return true;
|
|
1397
|
+
// Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
|
|
1398
|
+
const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
|
|
1399
|
+
if (match) {
|
|
1400
|
+
const [, patternTool, prefix] = match;
|
|
1401
|
+
if (patternTool !== toolName)
|
|
1402
|
+
continue;
|
|
1403
|
+
// For Bash, check command prefix
|
|
1404
|
+
if (toolName === 'Bash') {
|
|
1405
|
+
const cmd = String(toolInput.command || '').trimStart();
|
|
1406
|
+
if (cmd === prefix || cmd.startsWith(prefix + ' '))
|
|
1407
|
+
return true;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return false;
|
|
1412
|
+
}
|
|
991
1413
|
/** Build a human-readable prompt string for a tool permission dialog. */
|
|
992
1414
|
summarizeToolPermission(toolName, toolInput) {
|
|
993
1415
|
switch (toolName) {
|
|
@@ -1003,8 +1425,6 @@ export class SessionManager {
|
|
|
1003
1425
|
const filePath = String(toolInput.file_path || '');
|
|
1004
1426
|
return `Allow Read? \`${filePath}\``;
|
|
1005
1427
|
}
|
|
1006
|
-
case 'ExitPlanMode':
|
|
1007
|
-
return 'Approve plan and start implementation?';
|
|
1008
1428
|
default:
|
|
1009
1429
|
return `Allow ${toolName}?`;
|
|
1010
1430
|
}
|
|
@@ -1019,7 +1439,41 @@ export class SessionManager {
|
|
|
1019
1439
|
// Restart Claude with the new model if it's running
|
|
1020
1440
|
if (session.claudeProcess?.isAlive()) {
|
|
1021
1441
|
this.stopClaude(sessionId);
|
|
1022
|
-
|
|
1442
|
+
session._stoppedByUser = false;
|
|
1443
|
+
setTimeout(() => {
|
|
1444
|
+
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1445
|
+
this.startClaude(sessionId);
|
|
1446
|
+
}
|
|
1447
|
+
}, 500);
|
|
1448
|
+
}
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
/** Update the permission mode for a session and restart Claude with the new mode. */
|
|
1452
|
+
setPermissionMode(sessionId, permissionMode) {
|
|
1453
|
+
const session = this.sessions.get(sessionId);
|
|
1454
|
+
if (!session)
|
|
1455
|
+
return false;
|
|
1456
|
+
const previousMode = session.permissionMode;
|
|
1457
|
+
session.permissionMode = permissionMode;
|
|
1458
|
+
this.persistToDiskDebounced();
|
|
1459
|
+
// Audit log for dangerous mode changes
|
|
1460
|
+
if (permissionMode === 'bypassPermissions') {
|
|
1461
|
+
console.warn(`[security] Session ${sessionId} ("${session.name}") activated bypassPermissions mode (was: ${previousMode ?? 'default'})`);
|
|
1462
|
+
}
|
|
1463
|
+
// Emit a visible system message so all clients see the mode change
|
|
1464
|
+
const modeLabel = permissionMode === 'bypassPermissions' ? 'Bypass permissions (all tools auto-accepted)' : permissionMode;
|
|
1465
|
+
const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
|
|
1466
|
+
this.addToHistory(session, sysMsg);
|
|
1467
|
+
this.broadcast(session, sysMsg);
|
|
1468
|
+
// Restart Claude with the new permission mode if it's running
|
|
1469
|
+
if (session.claudeProcess?.isAlive()) {
|
|
1470
|
+
this.stopClaude(sessionId);
|
|
1471
|
+
session._stoppedByUser = false;
|
|
1472
|
+
setTimeout(() => {
|
|
1473
|
+
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1474
|
+
this.startClaude(sessionId);
|
|
1475
|
+
}
|
|
1476
|
+
}, 500);
|
|
1023
1477
|
}
|
|
1024
1478
|
return true;
|
|
1025
1479
|
}
|
|
@@ -1027,7 +1481,6 @@ export class SessionManager {
|
|
|
1027
1481
|
const session = this.sessions.get(sessionId);
|
|
1028
1482
|
if (session?.claudeProcess) {
|
|
1029
1483
|
session._stoppedByUser = true;
|
|
1030
|
-
this.clearStallTimer(session);
|
|
1031
1484
|
if (session._apiRetryTimer)
|
|
1032
1485
|
clearTimeout(session._apiRetryTimer);
|
|
1033
1486
|
session.claudeProcess.removeAllListeners();
|
|
@@ -1036,6 +1489,26 @@ export class SessionManager {
|
|
|
1036
1489
|
this.broadcast(session, { type: 'claude_stopped' });
|
|
1037
1490
|
}
|
|
1038
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Stop the Claude process and wait for it to fully exit before resolving.
|
|
1494
|
+
* This prevents race conditions when restarting with the same session ID
|
|
1495
|
+
* (e.g. during mid-session worktree migration).
|
|
1496
|
+
*/
|
|
1497
|
+
async stopClaudeAndWait(sessionId) {
|
|
1498
|
+
const session = this.sessions.get(sessionId);
|
|
1499
|
+
if (!session?.claudeProcess)
|
|
1500
|
+
return;
|
|
1501
|
+
const cp = session.claudeProcess;
|
|
1502
|
+
session._stoppedByUser = true;
|
|
1503
|
+
if (session._apiRetryTimer)
|
|
1504
|
+
clearTimeout(session._apiRetryTimer);
|
|
1505
|
+
cp.removeAllListeners();
|
|
1506
|
+
cp.stop();
|
|
1507
|
+
session.claudeProcess = null;
|
|
1508
|
+
this.broadcast(session, { type: 'claude_stopped' });
|
|
1509
|
+
// Wait for the underlying OS process to fully exit
|
|
1510
|
+
await cp.waitForExit();
|
|
1511
|
+
}
|
|
1039
1512
|
// ---------------------------------------------------------------------------
|
|
1040
1513
|
// Helpers
|
|
1041
1514
|
// ---------------------------------------------------------------------------
|
|
@@ -1043,6 +1516,32 @@ export class SessionManager {
|
|
|
1043
1516
|
isRetryableApiError(text) {
|
|
1044
1517
|
return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
|
|
1045
1518
|
}
|
|
1519
|
+
/** Extract the concatenated text output from the current turn (after the last 'result' in history). */
|
|
1520
|
+
extractCurrentTurnText(session) {
|
|
1521
|
+
let text = '';
|
|
1522
|
+
for (let i = session.outputHistory.length - 1; i >= 0; i--) {
|
|
1523
|
+
const msg = session.outputHistory[i];
|
|
1524
|
+
if (msg.type === 'result')
|
|
1525
|
+
break;
|
|
1526
|
+
if (msg.type === 'output')
|
|
1527
|
+
text = msg.data + text;
|
|
1528
|
+
}
|
|
1529
|
+
return text;
|
|
1530
|
+
}
|
|
1531
|
+
/** Remove output messages from the current turn in history (after the last 'result'). */
|
|
1532
|
+
stripCurrentTurnOutput(session) {
|
|
1533
|
+
let cutIndex = session.outputHistory.length;
|
|
1534
|
+
for (let i = session.outputHistory.length - 1; i >= 0; i--) {
|
|
1535
|
+
const msg = session.outputHistory[i];
|
|
1536
|
+
if (msg.type === 'result')
|
|
1537
|
+
break;
|
|
1538
|
+
if (msg.type === 'output') {
|
|
1539
|
+
cutIndex = i;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
// Remove output entries from cutIndex onwards (keep non-output entries like tool events)
|
|
1543
|
+
session.outputHistory = session.outputHistory.filter((msg, idx) => idx < cutIndex || msg.type !== 'output');
|
|
1544
|
+
}
|
|
1046
1545
|
/**
|
|
1047
1546
|
* Build a condensed text summary of a session's conversation history.
|
|
1048
1547
|
* Used as context when auto-starting Claude for sessions without a saved
|
|
@@ -1106,27 +1605,6 @@ export class SessionManager {
|
|
|
1106
1605
|
}
|
|
1107
1606
|
return `[This session was interrupted by a server restart. Here is the previous conversation for context:]\n${context}\n[End of previous context. The user's new message follows.]`;
|
|
1108
1607
|
}
|
|
1109
|
-
resetStallTimer(session) {
|
|
1110
|
-
this.clearStallTimer(session);
|
|
1111
|
-
session._stallTimer = setTimeout(() => {
|
|
1112
|
-
session._stallTimer = null;
|
|
1113
|
-
if (!session.claudeProcess?.isAlive())
|
|
1114
|
-
return;
|
|
1115
|
-
const msg = {
|
|
1116
|
-
type: 'system_message',
|
|
1117
|
-
subtype: 'stall',
|
|
1118
|
-
text: 'No output for 5 minutes. The process may be stalled.',
|
|
1119
|
-
};
|
|
1120
|
-
this.addToHistory(session, msg);
|
|
1121
|
-
this.broadcast(session, msg);
|
|
1122
|
-
}, STALL_TIMEOUT_MS);
|
|
1123
|
-
}
|
|
1124
|
-
clearStallTimer(session) {
|
|
1125
|
-
if (session._stallTimer) {
|
|
1126
|
-
clearTimeout(session._stallTimer);
|
|
1127
|
-
session._stallTimer = null;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
1608
|
/**
|
|
1131
1609
|
* Append a message to a session's output history for replay.
|
|
1132
1610
|
* Merges consecutive 'output' chunks into a single entry to save space.
|
|
@@ -1159,15 +1637,17 @@ export class SessionManager {
|
|
|
1159
1637
|
}
|
|
1160
1638
|
}
|
|
1161
1639
|
}
|
|
1162
|
-
|
|
1640
|
+
/** Find which session a WebSocket is connected to (O(1) via reverse map). */
|
|
1163
1641
|
findSessionForClient(ws) {
|
|
1164
1642
|
const sessionId = this.clientSessionMap.get(ws);
|
|
1165
1643
|
if (sessionId)
|
|
1166
1644
|
return this.sessions.get(sessionId);
|
|
1167
1645
|
return undefined;
|
|
1168
1646
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1647
|
+
/**
|
|
1648
|
+
* Remove a client from all sessions (iterates for safety since a ws
|
|
1649
|
+
* could theoretically appear in multiple session client sets).
|
|
1650
|
+
*/
|
|
1171
1651
|
removeClient(ws) {
|
|
1172
1652
|
for (const session of this.sessions.values()) {
|
|
1173
1653
|
session.clients.delete(ws);
|
|
@@ -1175,214 +1655,24 @@ export class SessionManager {
|
|
|
1175
1655
|
this.clientSessionMap.delete(ws);
|
|
1176
1656
|
}
|
|
1177
1657
|
// ---------------------------------------------------------------------------
|
|
1178
|
-
// Diff viewer
|
|
1658
|
+
// Diff viewer — delegates to DiffManager
|
|
1179
1659
|
// ---------------------------------------------------------------------------
|
|
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
|
-
*/
|
|
1660
|
+
/** Run git diff in a session's workingDir and return structured results. */
|
|
1184
1661
|
async getDiff(sessionId, scope = 'all') {
|
|
1185
1662
|
const session = this.sessions.get(sessionId);
|
|
1186
1663
|
if (!session)
|
|
1187
1664
|
return { type: 'diff_error', message: 'Session not found' };
|
|
1188
|
-
|
|
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
|
-
}
|
|
1665
|
+
return this.diffManager.getDiff(session.workingDir, scope);
|
|
1272
1666
|
}
|
|
1273
|
-
/**
|
|
1274
|
-
* Discard changes in a session's workingDir per the given scope and paths.
|
|
1275
|
-
* Returns a fresh diff_result after discarding.
|
|
1276
|
-
*/
|
|
1667
|
+
/** Discard changes in a session's workingDir per the given scope and paths. */
|
|
1277
1668
|
async discardChanges(sessionId, scope, paths, statuses) {
|
|
1278
1669
|
const session = this.sessions.get(sessionId);
|
|
1279
1670
|
if (!session)
|
|
1280
1671
|
return { type: 'diff_error', message: 'Session not found' };
|
|
1281
|
-
|
|
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
|
-
}
|
|
1672
|
+
return this.diffManager.discardChanges(session.workingDir, scope, paths, statuses);
|
|
1384
1673
|
}
|
|
1385
|
-
/** Graceful shutdown: complete in-progress tasks, persist state, kill all processes.
|
|
1674
|
+
/** Graceful shutdown: complete in-progress tasks, persist state, kill all processes.
|
|
1675
|
+
* Returns a promise that resolves once all Claude processes have exited. */
|
|
1386
1676
|
shutdown() {
|
|
1387
1677
|
// Complete in-progress tasks for active sessions before persisting.
|
|
1388
1678
|
// This handles self-deploy: the commit/push task was the last step, and
|
|
@@ -1395,15 +1685,26 @@ export class SessionManager {
|
|
|
1395
1685
|
}
|
|
1396
1686
|
// Persist BEFORE killing processes so wasActive flag captures which were running
|
|
1397
1687
|
this.persistToDisk();
|
|
1398
|
-
this.persistRepoApprovals();
|
|
1399
|
-
// Kill all Claude child processes
|
|
1688
|
+
this._approvalManager.persistRepoApprovals();
|
|
1689
|
+
// Kill all Claude child processes and wait for them to exit so their
|
|
1690
|
+
// session locks are released before the next server start.
|
|
1691
|
+
const exitPromises = [];
|
|
1400
1692
|
for (const session of this.sessions.values()) {
|
|
1401
1693
|
if (session.claudeProcess?.isAlive()) {
|
|
1402
|
-
|
|
1694
|
+
exitPromises.push(new Promise((resolve) => {
|
|
1695
|
+
session.claudeProcess.once('exit', () => resolve());
|
|
1696
|
+
session.claudeProcess.stop();
|
|
1697
|
+
}));
|
|
1403
1698
|
}
|
|
1404
|
-
this.clearStallTimer(session);
|
|
1405
1699
|
}
|
|
1406
1700
|
this.archive.shutdown();
|
|
1701
|
+
if (exitPromises.length === 0)
|
|
1702
|
+
return Promise.resolve();
|
|
1703
|
+
// Wait for all processes to exit, but cap at 6s (stop() SIGKILL is at 5s)
|
|
1704
|
+
return Promise.race([
|
|
1705
|
+
Promise.all(exitPromises).then(() => { }),
|
|
1706
|
+
new Promise((resolve) => setTimeout(resolve, 6000)),
|
|
1707
|
+
]);
|
|
1407
1708
|
}
|
|
1408
1709
|
/**
|
|
1409
1710
|
* Mark all in_progress tasks as completed in a session's outputHistory.
|
|
@@ -1446,17 +1747,11 @@ export class SessionManager {
|
|
|
1446
1747
|
setTimeout(() => {
|
|
1447
1748
|
if (session.claudeProcess?.isAlive())
|
|
1448
1749
|
return; // already running
|
|
1449
|
-
|
|
1750
|
+
// startClaude uses --resume when claudeSessionId exists (which it always
|
|
1751
|
+
// does here — the restore loop filters on it), so Claude CLI picks up
|
|
1752
|
+
// the full conversation history from its JSONL automatically.
|
|
1753
|
+
console.log(`[restore] Starting Claude for session ${session.id} (${session.name}) (claudeSessionId=${session.claudeSessionId})`);
|
|
1450
1754
|
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
1755
|
const msg = {
|
|
1461
1756
|
type: 'system_message',
|
|
1462
1757
|
subtype: 'restart',
|