codekin 0.5.3 → 0.5.5
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 +2 -1
- package/dist/assets/{index-84JYN21S.js → index-BFkKlY3O.js} +46 -42
- package/dist/assets/index-CjEQkT2b.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/dist/approval-manager.js +1 -1
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/auth-routes.js +31 -1
- package/server/dist/auth-routes.js.map +1 -1
- package/server/dist/claude-process.d.ts +26 -0
- package/server/dist/claude-process.js +111 -18
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/config.d.ts +8 -0
- package/server/dist/config.js +19 -1
- package/server/dist/config.js.map +1 -1
- package/server/dist/native-permissions.js +1 -1
- package/server/dist/native-permissions.js.map +1 -1
- package/server/dist/orchestrator-children.js +22 -6
- package/server/dist/orchestrator-children.js.map +1 -1
- package/server/dist/orchestrator-reports.d.ts +0 -4
- package/server/dist/orchestrator-reports.js +6 -12
- package/server/dist/orchestrator-reports.js.map +1 -1
- package/server/dist/prompt-router.d.ts +95 -0
- package/server/dist/prompt-router.js +577 -0
- package/server/dist/prompt-router.js.map +1 -0
- package/server/dist/session-lifecycle.d.ts +73 -0
- package/server/dist/session-lifecycle.js +387 -0
- package/server/dist/session-lifecycle.js.map +1 -0
- package/server/dist/session-manager.d.ts +33 -60
- package/server/dist/session-manager.js +239 -785
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-naming.js +2 -1
- package/server/dist/session-naming.js.map +1 -1
- package/server/dist/session-persistence.js +21 -3
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/session-routes.js +18 -0
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +4 -0
- package/server/dist/upload-routes.js +3 -1
- package/server/dist/upload-routes.js.map +1 -1
- package/server/dist/webhook-handler.js +2 -1
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/ws-message-handler.js +19 -0
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +39 -9
- package/server/dist/ws-server.js.map +1 -1
- package/dist/assets/index-C0Iuc3iT.css +0 -1
|
@@ -15,25 +15,25 @@
|
|
|
15
15
|
* - SessionNaming: AI-powered session name generation with retry logic
|
|
16
16
|
* - SessionPersistence: disk I/O for session state
|
|
17
17
|
* - DiffManager: stateless git-diff operations
|
|
18
|
-
* -
|
|
18
|
+
* - SessionLifecycle: Claude process start/stop/restart and event wiring
|
|
19
|
+
* - evaluateRestart: pure restart-decision logic (used by SessionLifecycle)
|
|
19
20
|
*/
|
|
20
21
|
import { randomUUID } from 'crypto';
|
|
21
22
|
import { execFile } from 'child_process';
|
|
22
|
-
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs';
|
|
23
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'fs';
|
|
23
24
|
import { homedir } from 'os';
|
|
24
25
|
import path from 'path';
|
|
25
26
|
import { promisify } from 'util';
|
|
26
|
-
import { ClaudeProcess } from './claude-process.js';
|
|
27
27
|
import { PlanManager } from './plan-manager.js';
|
|
28
28
|
import { SessionArchive } from './session-archive.js';
|
|
29
29
|
import { cleanupWorkspace } from './webhook-workspace.js';
|
|
30
30
|
import { PORT } from './config.js';
|
|
31
31
|
import { ApprovalManager } from './approval-manager.js';
|
|
32
|
+
import { PromptRouter } from './prompt-router.js';
|
|
33
|
+
import { SessionLifecycle } from './session-lifecycle.js';
|
|
32
34
|
import { SessionNaming } from './session-naming.js';
|
|
33
35
|
import { SessionPersistence } from './session-persistence.js';
|
|
34
|
-
import { deriveSessionToken } from './crypto-utils.js';
|
|
35
36
|
import { cleanGitEnv, DiffManager } from './diff-manager.js';
|
|
36
|
-
import { evaluateRestart } from './session-restart-scheduler.js';
|
|
37
37
|
const execFileAsync = promisify(execFile);
|
|
38
38
|
/** Max messages retained in a session's output history buffer. */
|
|
39
39
|
const MAX_HISTORY = 2000;
|
|
@@ -89,12 +89,51 @@ export class SessionManager {
|
|
|
89
89
|
sessionPersistence;
|
|
90
90
|
/** Delegated diff operations (git diff, discard changes). */
|
|
91
91
|
diffManager;
|
|
92
|
+
/** Delegated prompt routing and tool approval logic. */
|
|
93
|
+
promptRouter;
|
|
94
|
+
/** Delegated Claude process lifecycle (start, stop, restart, event wiring). */
|
|
95
|
+
sessionLifecycle;
|
|
92
96
|
/** Interval handle for the idle session reaper. */
|
|
93
97
|
_idleReaperInterval = null;
|
|
94
98
|
constructor() {
|
|
95
99
|
this.archive = new SessionArchive();
|
|
96
100
|
this._approvalManager = new ApprovalManager();
|
|
97
101
|
this.diffManager = new DiffManager();
|
|
102
|
+
this.promptRouter = new PromptRouter({
|
|
103
|
+
getSession: (id) => this.sessions.get(id),
|
|
104
|
+
allSessions: () => this.sessions.values(),
|
|
105
|
+
broadcast: (session, msg) => this.broadcast(session, msg),
|
|
106
|
+
addToHistory: (session, msg) => this.addToHistory(session, msg),
|
|
107
|
+
globalBroadcast: (msg) => this._globalBroadcast?.(msg),
|
|
108
|
+
approvalManager: this._approvalManager,
|
|
109
|
+
promptListeners: this._promptListeners,
|
|
110
|
+
});
|
|
111
|
+
// Use a local ref so the getter closures capture `this` (the SessionManager instance)
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
113
|
+
const self = this;
|
|
114
|
+
this.sessionLifecycle = new SessionLifecycle({
|
|
115
|
+
getSession: (id) => this.sessions.get(id),
|
|
116
|
+
hasSession: (id) => this.sessions.has(id),
|
|
117
|
+
broadcast: (session, msg) => this.broadcast(session, msg),
|
|
118
|
+
addToHistory: (session, msg) => this.addToHistory(session, msg),
|
|
119
|
+
broadcastAndHistory: (session, msg) => this.broadcastAndHistory(session, msg),
|
|
120
|
+
persistToDisk: () => this.persistToDisk(),
|
|
121
|
+
get globalBroadcast() { return self._globalBroadcast; },
|
|
122
|
+
get authToken() { return self._authToken; },
|
|
123
|
+
get serverPort() { return self._serverPort; },
|
|
124
|
+
approvalManager: this._approvalManager,
|
|
125
|
+
promptRouter: this.promptRouter,
|
|
126
|
+
exitListeners: this._exitListeners,
|
|
127
|
+
onSystemInit: (cp, session, model) => this.onSystemInit(cp, session, model),
|
|
128
|
+
onTextEvent: (session, sessionId, text) => this.onTextEvent(session, sessionId, text),
|
|
129
|
+
onThinkingEvent: (session, summary) => this.onThinkingEvent(session, summary),
|
|
130
|
+
onToolOutputEvent: (session, content, isError) => this.onToolOutputEvent(session, content, isError),
|
|
131
|
+
onImageEvent: (session, base64, mediaType) => this.onImageEvent(session, base64, mediaType),
|
|
132
|
+
onToolActiveEvent: (session, toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput),
|
|
133
|
+
onToolDoneEvent: (session, toolName, summary) => this.onToolDoneEvent(session, toolName, summary),
|
|
134
|
+
handleClaudeResult: (session, sessionId, result, isError) => this.handleClaudeResult(session, sessionId, result, isError),
|
|
135
|
+
buildSessionContext: (session) => this.buildSessionContext(session),
|
|
136
|
+
});
|
|
98
137
|
this.sessionPersistence = new SessionPersistence(this.sessions);
|
|
99
138
|
this.sessionNaming = new SessionNaming({
|
|
100
139
|
getSession: (id) => this.sessions.get(id),
|
|
@@ -120,7 +159,7 @@ export class SessionManager {
|
|
|
120
159
|
const now = Date.now();
|
|
121
160
|
for (const session of this.sessions.values()) {
|
|
122
161
|
// Skip headless sessions — they are managed by their own lifecycles
|
|
123
|
-
if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow')
|
|
162
|
+
if (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'agent' || session.source === 'orchestrator')
|
|
124
163
|
continue;
|
|
125
164
|
// Skip sessions with connected clients or no running process
|
|
126
165
|
if (session.clients.size > 0 || !session.claudeProcess?.isAlive())
|
|
@@ -132,6 +171,10 @@ export class SessionManager {
|
|
|
132
171
|
if (idleMs > IDLE_SESSION_TIMEOUT_MS) {
|
|
133
172
|
console.log(`[idle-reaper] stopping idle session=${session.id} name="${session.name}" idle=${Math.round(idleMs / 60_000)}min`);
|
|
134
173
|
session._stoppedByUser = true; // prevent auto-restart
|
|
174
|
+
if (session._restartTimer) {
|
|
175
|
+
clearTimeout(session._restartTimer);
|
|
176
|
+
session._restartTimer = undefined;
|
|
177
|
+
}
|
|
135
178
|
session.claudeProcess.removeAllListeners();
|
|
136
179
|
session.claudeProcess.stop();
|
|
137
180
|
session.claudeProcess = null;
|
|
@@ -143,8 +186,11 @@ export class SessionManager {
|
|
|
143
186
|
}
|
|
144
187
|
}
|
|
145
188
|
// Prune stale sessions: no process, no clients, older than STALE_SESSION_AGE_MS
|
|
189
|
+
// Agent and orchestrator sessions are exempt — they are long-lived by design.
|
|
146
190
|
const staleIds = [];
|
|
147
191
|
for (const session of this.sessions.values()) {
|
|
192
|
+
if (session.source === 'agent' || session.source === 'orchestrator')
|
|
193
|
+
continue;
|
|
148
194
|
if (session.claudeProcess?.isAlive())
|
|
149
195
|
continue;
|
|
150
196
|
if (session.clients.size > 0)
|
|
@@ -232,8 +278,16 @@ export class SessionManager {
|
|
|
232
278
|
* Create a git worktree for a session. Creates a new branch and worktree
|
|
233
279
|
* as a sibling directory of the project root.
|
|
234
280
|
* Returns the worktree path on success, or null on failure.
|
|
281
|
+
*
|
|
282
|
+
* @param targetBranch — use this as the worktree branch name instead of
|
|
283
|
+
* the default `wt/{shortId}`. The orchestrator uses this to create the
|
|
284
|
+
* worktree directly on the desired feature branch so Claude doesn't
|
|
285
|
+
* need to create a second branch.
|
|
286
|
+
* @param baseBranch — create the worktree branch from this ref (e.g.
|
|
287
|
+
* 'main'). Defaults to auto-detecting the default branch. Prevents
|
|
288
|
+
* worktrees from accidentally branching off a random HEAD.
|
|
235
289
|
*/
|
|
236
|
-
async createWorktree(sessionId, workingDir) {
|
|
290
|
+
async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
|
|
237
291
|
const session = this.sessions.get(sessionId);
|
|
238
292
|
if (!session)
|
|
239
293
|
return null;
|
|
@@ -250,23 +304,72 @@ export class SessionManager {
|
|
|
250
304
|
console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
|
|
251
305
|
return null;
|
|
252
306
|
}
|
|
253
|
-
const prefix = this.getWorktreeBranchPrefix();
|
|
254
307
|
const shortId = sessionId.slice(0, 8);
|
|
255
|
-
const branchName = `${
|
|
308
|
+
const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
|
|
256
309
|
const projectName = path.basename(repoRoot);
|
|
257
310
|
const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
|
|
311
|
+
// Auto-detect the default branch if baseBranch not specified.
|
|
312
|
+
// Tries origin/HEAD, then falls back to common names.
|
|
313
|
+
let resolvedBase = baseBranch;
|
|
314
|
+
if (!resolvedBase) {
|
|
315
|
+
resolvedBase = await this.detectDefaultBranch(repoRoot, env) ?? undefined;
|
|
316
|
+
}
|
|
317
|
+
// Determine if this is an ephemeral branch (wt/ prefix, generated by us)
|
|
318
|
+
// vs a caller-supplied branch name (e.g. fix/feature-xyz from orchestrator).
|
|
319
|
+
// Caller-supplied branches must NEVER be force-deleted — they may contain
|
|
320
|
+
// unique commits from a previous session or manual work.
|
|
321
|
+
const isEphemeralBranch = !targetBranch;
|
|
322
|
+
// Check if the target branch already exists as a local branch
|
|
323
|
+
let branchExists = false;
|
|
324
|
+
try {
|
|
325
|
+
await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { cwd: repoRoot, env, timeout: 3000 });
|
|
326
|
+
branchExists = true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Branch doesn't exist — will be created
|
|
330
|
+
}
|
|
258
331
|
// Clean up stale state from previous failed attempts:
|
|
259
332
|
// 1. Prune orphaned worktree entries (directory gone but git still tracks it)
|
|
260
333
|
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
|
|
261
334
|
.catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
|
|
262
|
-
// 2. Remove existing worktree directory if leftover from a partial failure
|
|
335
|
+
// 2. Remove existing worktree directory if leftover from a partial failure.
|
|
336
|
+
// If git doesn't recognise it as a worktree, force-remove the directory
|
|
337
|
+
// so that `git worktree add` below doesn't fail with "already exists".
|
|
263
338
|
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
|
|
264
|
-
.catch(() => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
339
|
+
.catch((e) => {
|
|
340
|
+
console.debug(`[worktree] remove prior worktree (expected if fresh):`, e instanceof Error ? e.message : e);
|
|
341
|
+
// Git doesn't know about it — nuke the stale directory if it still exists
|
|
342
|
+
if (existsSync(worktreePath)) {
|
|
343
|
+
try {
|
|
344
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
345
|
+
console.log(`[worktree] Force-removed stale directory: ${worktreePath}`);
|
|
346
|
+
}
|
|
347
|
+
catch (rmErr) {
|
|
348
|
+
console.warn(`[worktree] Failed to force-remove stale directory ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
// 3. Only delete ephemeral branches (wt/*) during cleanup — never caller-supplied ones
|
|
353
|
+
if (isEphemeralBranch && branchExists) {
|
|
354
|
+
await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
|
|
355
|
+
.catch((e) => console.debug(`[worktree] ephemeral branch cleanup:`, e instanceof Error ? e.message : e));
|
|
356
|
+
branchExists = false;
|
|
357
|
+
}
|
|
358
|
+
// Create the worktree:
|
|
359
|
+
// - Existing branch: check it out in the worktree (no -b)
|
|
360
|
+
// - New branch: create with -b, branching from the resolved base
|
|
361
|
+
let worktreeArgs;
|
|
362
|
+
if (branchExists) {
|
|
363
|
+
worktreeArgs = ['worktree', 'add', worktreePath, branchName];
|
|
364
|
+
console.log(`[worktree] Using existing branch ${branchName}`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath];
|
|
368
|
+
if (resolvedBase)
|
|
369
|
+
worktreeArgs.push(resolvedBase);
|
|
370
|
+
console.log(`[worktree] Creating new branch ${branchName}${resolvedBase ? ` from ${resolvedBase}` : ''}`);
|
|
371
|
+
}
|
|
372
|
+
await execFileAsync('git', worktreeArgs, {
|
|
270
373
|
cwd: repoRoot,
|
|
271
374
|
env,
|
|
272
375
|
timeout: 15000,
|
|
@@ -360,8 +463,11 @@ export class SessionManager {
|
|
|
360
463
|
/**
|
|
361
464
|
* Clean up a git worktree and its branch. Runs asynchronously and logs errors
|
|
362
465
|
* but never throws — session deletion must not be blocked by cleanup failures.
|
|
466
|
+
* Retries once on failure after a short delay.
|
|
363
467
|
*/
|
|
364
|
-
cleanupWorktree(worktreePath, repoDir) {
|
|
468
|
+
cleanupWorktree(worktreePath, repoDir, attempt = 1) {
|
|
469
|
+
const MAX_CLEANUP_ATTEMPTS = 2;
|
|
470
|
+
const RETRY_DELAY_MS = 3000;
|
|
365
471
|
void (async () => {
|
|
366
472
|
try {
|
|
367
473
|
// Resolve the actual repo root (repoDir may itself be a worktree)
|
|
@@ -378,13 +484,65 @@ export class SessionManager {
|
|
|
378
484
|
console.log(`[worktree] Cleaned up worktree: ${worktreePath}`);
|
|
379
485
|
// Prune any stale worktree references
|
|
380
486
|
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, timeout: 5000 })
|
|
381
|
-
.catch(() =>
|
|
487
|
+
.catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
|
|
382
488
|
}
|
|
383
489
|
catch (err) {
|
|
384
|
-
|
|
490
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
491
|
+
if (attempt < MAX_CLEANUP_ATTEMPTS) {
|
|
492
|
+
console.warn(`[worktree] Failed to clean up worktree ${worktreePath} (attempt ${attempt}/${MAX_CLEANUP_ATTEMPTS}): ${errMsg} — retrying in ${RETRY_DELAY_MS}ms`);
|
|
493
|
+
setTimeout(() => this.cleanupWorktree(worktreePath, repoDir, attempt + 1), RETRY_DELAY_MS);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
console.error(`[worktree] Failed to clean up worktree ${worktreePath} after ${MAX_CLEANUP_ATTEMPTS} attempts: ${errMsg}`);
|
|
497
|
+
// Last resort: force-remove the directory so it doesn't block future
|
|
498
|
+
// worktree creation or leave the session in a broken restart loop.
|
|
499
|
+
if (existsSync(worktreePath)) {
|
|
500
|
+
try {
|
|
501
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
502
|
+
console.log(`[worktree] Force-removed stale worktree directory: ${worktreePath}`);
|
|
503
|
+
}
|
|
504
|
+
catch (rmErr) {
|
|
505
|
+
console.error(`[worktree] Failed to force-remove ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
385
509
|
}
|
|
386
510
|
})();
|
|
387
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Detect the default branch of a repository (main, master, etc.).
|
|
514
|
+
* Tries `git symbolic-ref refs/remotes/origin/HEAD` first, then checks
|
|
515
|
+
* for common branch names. Returns null if detection fails.
|
|
516
|
+
*/
|
|
517
|
+
async detectDefaultBranch(repoRoot, env) {
|
|
518
|
+
// Try origin/HEAD (set by git clone or git remote set-head)
|
|
519
|
+
try {
|
|
520
|
+
const { stdout } = await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: repoRoot, env, timeout: 5000 });
|
|
521
|
+
const ref = stdout.trim(); // e.g. "refs/remotes/origin/main"
|
|
522
|
+
if (ref) {
|
|
523
|
+
const branch = ref.replace('refs/remotes/origin/', '');
|
|
524
|
+
console.log(`[worktree] Detected default branch from origin/HEAD: ${branch}`);
|
|
525
|
+
return branch;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// origin/HEAD not set — fall through to heuristics
|
|
530
|
+
}
|
|
531
|
+
// Check for common default branch names — use show-ref to verify
|
|
532
|
+
// these are actual local branches, not tags or other refs
|
|
533
|
+
for (const candidate of ['main', 'master']) {
|
|
534
|
+
try {
|
|
535
|
+
await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { cwd: repoRoot, env, timeout: 3000 });
|
|
536
|
+
console.log(`[worktree] Detected default branch by name: ${candidate}`);
|
|
537
|
+
return candidate;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// branch doesn't exist, try next
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
console.warn(`[worktree] Could not detect default branch for ${repoRoot} — worktree will branch from HEAD`);
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
388
546
|
/** Get the configured worktree branch prefix (defaults to 'wt/'). */
|
|
389
547
|
getWorktreeBranchPrefix() {
|
|
390
548
|
return this.archive.getSetting('worktree_branch_prefix', 'wt/');
|
|
@@ -421,30 +579,7 @@ export class SessionManager {
|
|
|
421
579
|
}
|
|
422
580
|
/** Get all sessions that have pending prompts (waiting for approval or answer). */
|
|
423
581
|
getPendingPrompts() {
|
|
424
|
-
|
|
425
|
-
for (const session of this.sessions.values()) {
|
|
426
|
-
const prompts = [];
|
|
427
|
-
for (const [reqId, pending] of session.pendingToolApprovals) {
|
|
428
|
-
prompts.push({
|
|
429
|
-
requestId: reqId,
|
|
430
|
-
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
431
|
-
toolName: pending.toolName,
|
|
432
|
-
toolInput: pending.toolInput,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
436
|
-
prompts.push({
|
|
437
|
-
requestId: reqId,
|
|
438
|
-
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
439
|
-
toolName: pending.toolName,
|
|
440
|
-
toolInput: pending.toolInput,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
if (prompts.length > 0) {
|
|
444
|
-
results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
return results;
|
|
582
|
+
return this.promptRouter.getPendingPrompts();
|
|
448
583
|
}
|
|
449
584
|
/** Clear the isProcessing flag for a session and broadcast the update. */
|
|
450
585
|
clearProcessingFlag(sessionId) {
|
|
@@ -575,15 +710,29 @@ export class SessionManager {
|
|
|
575
710
|
clearTimeout(session._namingTimer);
|
|
576
711
|
if (session._leaveGraceTimer)
|
|
577
712
|
clearTimeout(session._leaveGraceTimer);
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
session.
|
|
581
|
-
|
|
582
|
-
|
|
713
|
+
if (session._restartTimer) {
|
|
714
|
+
clearTimeout(session._restartTimer);
|
|
715
|
+
session._restartTimer = undefined;
|
|
716
|
+
}
|
|
717
|
+
// Kill claude process if running — remove listeners first to prevent the
|
|
718
|
+
// exit handler from triggering an auto-restart on a deleted session.
|
|
719
|
+
// Wait for the process to fully exit before worktree cleanup so we don't
|
|
720
|
+
// run `git worktree remove` while Claude is still performing git operations.
|
|
721
|
+
const exitPromise = session.claudeProcess
|
|
722
|
+
? (() => {
|
|
723
|
+
const cp = session.claudeProcess;
|
|
724
|
+
cp.removeAllListeners();
|
|
725
|
+
cp.stop();
|
|
726
|
+
session.claudeProcess = null;
|
|
727
|
+
return cp.waitForExit();
|
|
728
|
+
})()
|
|
729
|
+
: Promise.resolve();
|
|
583
730
|
this.archiveSessionIfWorthSaving(session);
|
|
584
|
-
// Clean up git worktree if this session used one
|
|
731
|
+
// Clean up git worktree if this session used one — deferred until process exits
|
|
585
732
|
if (session.worktreePath) {
|
|
586
|
-
|
|
733
|
+
const wtPath = session.worktreePath;
|
|
734
|
+
const repoDir = session.groupDir ?? session.workingDir;
|
|
735
|
+
void exitPromise.then(() => this.cleanupWorktree(wtPath, repoDir));
|
|
587
736
|
}
|
|
588
737
|
// Clean up webhook workspace directory if applicable
|
|
589
738
|
if (session.source === 'webhook' || session.source === 'stepflow') {
|
|
@@ -635,123 +784,17 @@ export class SessionManager {
|
|
|
635
784
|
// ---------------------------------------------------------------------------
|
|
636
785
|
/**
|
|
637
786
|
* Spawn (or re-spawn) a Claude CLI process for a session.
|
|
638
|
-
*
|
|
787
|
+
* Delegates to SessionLifecycle.
|
|
639
788
|
*/
|
|
640
789
|
startClaude(sessionId) {
|
|
641
|
-
|
|
642
|
-
if (!session)
|
|
643
|
-
return false;
|
|
644
|
-
// Clear stopped flag on explicit start
|
|
645
|
-
session._stoppedByUser = false;
|
|
646
|
-
// Kill existing process if any
|
|
647
|
-
if (session.claudeProcess) {
|
|
648
|
-
session.claudeProcess.stop();
|
|
649
|
-
}
|
|
650
|
-
// Derive a session-scoped token instead of forwarding the master auth token.
|
|
651
|
-
// This limits child process privileges to approve/deny for their own session only.
|
|
652
|
-
const sessionToken = this._authToken
|
|
653
|
-
? deriveSessionToken(this._authToken, sessionId)
|
|
654
|
-
: '';
|
|
655
|
-
// Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
|
|
656
|
-
// (current canonical name) are set to the same derived value for backward compatibility.
|
|
657
|
-
const extraEnv = {
|
|
658
|
-
CODEKIN_SESSION_ID: sessionId,
|
|
659
|
-
CODEKIN_PORT: String(this._serverPort || PORT),
|
|
660
|
-
CODEKIN_TOKEN: sessionToken,
|
|
661
|
-
CODEKIN_AUTH_TOKEN: sessionToken,
|
|
662
|
-
CODEKIN_SESSION_TYPE: session.source || 'manual',
|
|
663
|
-
...(session.permissionMode === 'dangerouslySkipPermissions' ? { CODEKIN_SKIP_PERMISSIONS: '1' } : {}),
|
|
664
|
-
};
|
|
665
|
-
// Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
|
|
666
|
-
// even when the session's working directory differs from the project root
|
|
667
|
-
// (e.g. worktrees, webhook workspaces). Note: this does NOT control
|
|
668
|
-
// session storage path — Claude CLI uses the CWD for that.
|
|
669
|
-
if (session.groupDir) {
|
|
670
|
-
extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
|
|
671
|
-
}
|
|
672
|
-
else if (process.env.CLAUDE_PROJECT_DIR) {
|
|
673
|
-
extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
|
|
674
|
-
}
|
|
675
|
-
// When claudeSessionId exists, the session has run before and a JSONL file
|
|
676
|
-
// exists on disk. Use --resume (not --session-id) to continue it — --session-id
|
|
677
|
-
// creates a *new* session and fails with "already in use" if the JSONL exists.
|
|
678
|
-
const resume = !!session.claudeSessionId;
|
|
679
|
-
// Build comprehensive allowedTools from session-level overrides + registry approvals
|
|
680
|
-
const repoDir = session.groupDir ?? session.workingDir;
|
|
681
|
-
const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
|
|
682
|
-
const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
|
|
683
|
-
const cp = new ClaudeProcess(session.workingDir, {
|
|
684
|
-
sessionId: session.claudeSessionId || undefined,
|
|
685
|
-
extraEnv,
|
|
686
|
-
model: session.model,
|
|
687
|
-
permissionMode: session.permissionMode,
|
|
688
|
-
resume,
|
|
689
|
-
allowedTools: mergedAllowedTools,
|
|
690
|
-
});
|
|
691
|
-
this.wireClaudeEvents(cp, session, sessionId);
|
|
692
|
-
cp.start();
|
|
693
|
-
session.claudeProcess = cp;
|
|
694
|
-
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
695
|
-
const startMsg = { type: 'claude_started', sessionId };
|
|
696
|
-
this.addToHistory(session, startMsg);
|
|
697
|
-
this.broadcast(session, startMsg);
|
|
698
|
-
return true;
|
|
790
|
+
return this.sessionLifecycle.startClaude(sessionId);
|
|
699
791
|
}
|
|
700
792
|
/**
|
|
701
|
-
* Wait for a session's Claude process to emit its system_init event
|
|
702
|
-
*
|
|
703
|
-
* session already has a claudeSessionId (process previously initialized).
|
|
704
|
-
* Times out after `timeoutMs` (default 30s) to avoid hanging indefinitely.
|
|
793
|
+
* Wait for a session's Claude process to emit its system_init event.
|
|
794
|
+
* Delegates to SessionLifecycle.
|
|
705
795
|
*/
|
|
706
796
|
waitForReady(sessionId, timeoutMs = 30_000) {
|
|
707
|
-
|
|
708
|
-
if (!session?.claudeProcess)
|
|
709
|
-
return Promise.resolve();
|
|
710
|
-
// If the process already completed init in a prior turn, resolve immediately
|
|
711
|
-
if (session.claudeSessionId)
|
|
712
|
-
return Promise.resolve();
|
|
713
|
-
return new Promise((resolve) => {
|
|
714
|
-
const timer = setTimeout(() => {
|
|
715
|
-
console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`);
|
|
716
|
-
resolve();
|
|
717
|
-
}, timeoutMs);
|
|
718
|
-
session.claudeProcess.once('system_init', () => {
|
|
719
|
-
clearTimeout(timer);
|
|
720
|
-
resolve();
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* Attach all ClaudeProcess event listeners for a session.
|
|
726
|
-
* Extracted from startClaude() to keep that method focused on process setup.
|
|
727
|
-
*/
|
|
728
|
-
wireClaudeEvents(cp, session, sessionId) {
|
|
729
|
-
cp.on('system_init', (model) => this.onSystemInit(cp, session, model));
|
|
730
|
-
cp.on('text', (text) => this.onTextEvent(session, sessionId, text));
|
|
731
|
-
cp.on('thinking', (summary) => this.onThinkingEvent(session, summary));
|
|
732
|
-
cp.on('tool_output', (content, isError) => this.onToolOutputEvent(session, content, isError));
|
|
733
|
-
cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
|
|
734
|
-
cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
|
|
735
|
-
cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
|
|
736
|
-
cp.on('planning_mode', (active) => {
|
|
737
|
-
// Route EnterPlanMode through PlanManager for UI state tracking.
|
|
738
|
-
// ExitPlanMode (active=false) is ignored here — the PreToolUse hook
|
|
739
|
-
// is the enforcement gate, and it calls handleExitPlanModeApproval()
|
|
740
|
-
// which transitions PlanManager to 'reviewing'.
|
|
741
|
-
if (active) {
|
|
742
|
-
session.planManager.onEnterPlanMode();
|
|
743
|
-
}
|
|
744
|
-
// ExitPlanMode stream event intentionally ignored — hook handles it.
|
|
745
|
-
});
|
|
746
|
-
cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
|
|
747
|
-
cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
|
|
748
|
-
cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
|
|
749
|
-
cp.on('result', (result, isError) => {
|
|
750
|
-
session.planManager.onTurnEnd();
|
|
751
|
-
this.handleClaudeResult(session, sessionId, result, isError);
|
|
752
|
-
});
|
|
753
|
-
cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
|
|
754
|
-
cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(session, sessionId, code, signal); });
|
|
797
|
+
return this.sessionLifecycle.waitForReady(sessionId, timeoutMs);
|
|
755
798
|
}
|
|
756
799
|
/** Broadcast a message and add it to the session's output history. */
|
|
757
800
|
broadcastAndHistory(session, msg) {
|
|
@@ -801,89 +844,6 @@ export class SessionManager {
|
|
|
801
844
|
onToolDoneEvent(session, toolName, summary) {
|
|
802
845
|
this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
|
|
803
846
|
}
|
|
804
|
-
onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
|
|
805
|
-
const promptMsg = {
|
|
806
|
-
type: 'prompt',
|
|
807
|
-
promptType,
|
|
808
|
-
question,
|
|
809
|
-
options,
|
|
810
|
-
multiSelect,
|
|
811
|
-
toolName,
|
|
812
|
-
toolInput,
|
|
813
|
-
requestId,
|
|
814
|
-
...(questions ? { questions } : {}),
|
|
815
|
-
};
|
|
816
|
-
if (requestId) {
|
|
817
|
-
session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
|
|
818
|
-
}
|
|
819
|
-
this.broadcast(session, promptMsg);
|
|
820
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
821
|
-
for (const listener of this._promptListeners) {
|
|
822
|
-
try {
|
|
823
|
-
listener(session.id, promptType, toolName, requestId);
|
|
824
|
-
}
|
|
825
|
-
catch { /* listener error */ }
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
|
|
829
|
-
if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
|
|
830
|
-
console.warn(`[control_request] Rejected invalid requestId: ${JSON.stringify(requestId)}`);
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
console.log(`[control_request] session=${sessionId} tool=${toolName} requestId=${requestId}`);
|
|
834
|
-
if (this.resolveAutoApproval(session, toolName, toolInput) !== 'prompt') {
|
|
835
|
-
console.log(`[control_request] auto-approved: ${toolName}`);
|
|
836
|
-
cp.sendControlResponse(requestId, 'allow');
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
// Prevent double-gating: if a PreToolUse hook is already handling approval
|
|
840
|
-
// for this tool, auto-approve the control_request to avoid duplicate entries.
|
|
841
|
-
// Without this, both pendingToolApprovals and pendingControlRequests contain
|
|
842
|
-
// entries for the same tool invocation, causing stale-entry races when the
|
|
843
|
-
// orchestrator tries to respond via the REST API.
|
|
844
|
-
for (const pending of session.pendingToolApprovals.values()) {
|
|
845
|
-
if (pending.toolName === toolName) {
|
|
846
|
-
console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
|
|
847
|
-
cp.sendControlResponse(requestId, 'allow');
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
852
|
-
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
853
|
-
const options = [
|
|
854
|
-
{ label: 'Allow', value: 'allow' },
|
|
855
|
-
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
856
|
-
{ label: 'Deny', value: 'deny' },
|
|
857
|
-
];
|
|
858
|
-
const promptMsg = {
|
|
859
|
-
type: 'prompt',
|
|
860
|
-
promptType: 'permission',
|
|
861
|
-
question,
|
|
862
|
-
options,
|
|
863
|
-
toolName,
|
|
864
|
-
toolInput,
|
|
865
|
-
requestId,
|
|
866
|
-
};
|
|
867
|
-
session.pendingControlRequests.set(requestId, { requestId, toolName, toolInput, promptMsg });
|
|
868
|
-
if (session.clients.size > 0) {
|
|
869
|
-
this.broadcast(session, promptMsg);
|
|
870
|
-
}
|
|
871
|
-
else {
|
|
872
|
-
console.log(`[control_request] no clients connected, waiting for client to join: ${toolName}`);
|
|
873
|
-
this._globalBroadcast?.({
|
|
874
|
-
...promptMsg,
|
|
875
|
-
sessionId,
|
|
876
|
-
sessionName: session.name,
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
880
|
-
for (const listener of this._promptListeners) {
|
|
881
|
-
try {
|
|
882
|
-
listener(sessionId, 'permission', toolName, requestId);
|
|
883
|
-
}
|
|
884
|
-
catch { /* listener error */ }
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
847
|
/**
|
|
888
848
|
* Handle a Claude process 'result' event: update session state, apply API
|
|
889
849
|
* retry logic for transient errors, broadcast result to clients, and trigger
|
|
@@ -933,15 +893,20 @@ export class SessionManager {
|
|
|
933
893
|
handleApiRetry(session, sessionId, result) {
|
|
934
894
|
if (!session._lastUserInput || !this.isRetryableApiError(result)) {
|
|
935
895
|
session._apiRetryCount = 0;
|
|
896
|
+
session._apiRetryScheduled = false;
|
|
936
897
|
return false;
|
|
937
898
|
}
|
|
938
899
|
// Skip retry if the original input is older than 60 seconds — context has likely moved on
|
|
939
900
|
if (session._lastUserInputAt && Date.now() - session._lastUserInputAt > 60_000) {
|
|
940
901
|
console.log(`[api-retry] skipping stale retry for session=${sessionId} (input age=${Math.round((Date.now() - session._lastUserInputAt) / 1000)}s)`);
|
|
941
902
|
session._apiRetryCount = 0;
|
|
903
|
+
session._apiRetryScheduled = false;
|
|
942
904
|
return false;
|
|
943
905
|
}
|
|
944
906
|
if (session._apiRetryCount < MAX_API_RETRIES) {
|
|
907
|
+
// Prevent duplicate scheduling from concurrent error paths
|
|
908
|
+
if (session._apiRetryScheduled)
|
|
909
|
+
return true;
|
|
945
910
|
session._apiRetryCount++;
|
|
946
911
|
const attempt = session._apiRetryCount;
|
|
947
912
|
const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -955,8 +920,10 @@ export class SessionManager {
|
|
|
955
920
|
console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
|
|
956
921
|
if (session._apiRetryTimer)
|
|
957
922
|
clearTimeout(session._apiRetryTimer);
|
|
923
|
+
session._apiRetryScheduled = true;
|
|
958
924
|
session._apiRetryTimer = setTimeout(() => {
|
|
959
925
|
session._apiRetryTimer = undefined;
|
|
926
|
+
session._apiRetryScheduled = false;
|
|
960
927
|
if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
|
|
961
928
|
return;
|
|
962
929
|
console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
|
|
@@ -973,6 +940,7 @@ export class SessionManager {
|
|
|
973
940
|
this.addToHistory(session, exhaustedMsg);
|
|
974
941
|
this.broadcast(session, exhaustedMsg);
|
|
975
942
|
session._apiRetryCount = 0;
|
|
943
|
+
session._apiRetryScheduled = false;
|
|
976
944
|
return false;
|
|
977
945
|
}
|
|
978
946
|
/**
|
|
@@ -981,6 +949,7 @@ export class SessionManager {
|
|
|
981
949
|
*/
|
|
982
950
|
finalizeResult(session, sessionId, result, isError) {
|
|
983
951
|
session._apiRetryCount = 0;
|
|
952
|
+
session._apiRetryScheduled = false;
|
|
984
953
|
session._lastUserInput = undefined;
|
|
985
954
|
session._lastUserInputAt = undefined;
|
|
986
955
|
if (isError) {
|
|
@@ -1014,88 +983,9 @@ export class SessionManager {
|
|
|
1014
983
|
void this.sessionNaming.executeSessionNaming(sessionId);
|
|
1015
984
|
}
|
|
1016
985
|
}
|
|
1017
|
-
/**
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
*
|
|
1021
|
-
* Uses evaluateRestart() for the restart decision, keeping this method focused
|
|
1022
|
-
* on state updates, listener notification, and message broadcasting.
|
|
1023
|
-
*/
|
|
1024
|
-
handleClaudeExit(session, sessionId, code, signal) {
|
|
1025
|
-
session.claudeProcess = null;
|
|
1026
|
-
session.isProcessing = false;
|
|
1027
|
-
session.planManager.reset();
|
|
1028
|
-
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
1029
|
-
const action = evaluateRestart({
|
|
1030
|
-
restartCount: session.restartCount,
|
|
1031
|
-
lastRestartAt: session.lastRestartAt,
|
|
1032
|
-
stoppedByUser: session._stoppedByUser,
|
|
1033
|
-
});
|
|
1034
|
-
if (action.kind === 'stopped_by_user') {
|
|
1035
|
-
for (const listener of this._exitListeners) {
|
|
1036
|
-
try {
|
|
1037
|
-
listener(sessionId, code, signal, false);
|
|
1038
|
-
}
|
|
1039
|
-
catch { /* listener error */ }
|
|
1040
|
-
}
|
|
1041
|
-
const msg = { type: 'system_message', subtype: 'exit', text: `Claude process exited: code=${code}, signal=${signal}` };
|
|
1042
|
-
this.addToHistory(session, msg);
|
|
1043
|
-
this.broadcast(session, msg);
|
|
1044
|
-
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
if (action.kind === 'restart') {
|
|
1048
|
-
session.restartCount = action.updatedCount;
|
|
1049
|
-
session.lastRestartAt = action.updatedLastRestartAt;
|
|
1050
|
-
for (const listener of this._exitListeners) {
|
|
1051
|
-
try {
|
|
1052
|
-
listener(sessionId, code, signal, true);
|
|
1053
|
-
}
|
|
1054
|
-
catch { /* listener error */ }
|
|
1055
|
-
}
|
|
1056
|
-
const msg = {
|
|
1057
|
-
type: 'system_message',
|
|
1058
|
-
subtype: 'restart',
|
|
1059
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
|
|
1060
|
-
};
|
|
1061
|
-
this.addToHistory(session, msg);
|
|
1062
|
-
this.broadcast(session, msg);
|
|
1063
|
-
setTimeout(() => {
|
|
1064
|
-
// Verify session still exists and hasn't been stopped
|
|
1065
|
-
if (!this.sessions.has(sessionId) || session._stoppedByUser)
|
|
1066
|
-
return;
|
|
1067
|
-
// startClaude uses --resume when claudeSessionId exists, so the CLI
|
|
1068
|
-
// picks up the full conversation history from the JSONL automatically.
|
|
1069
|
-
this.startClaude(sessionId);
|
|
1070
|
-
// Fallback: if claudeSessionId was already null (fresh session that
|
|
1071
|
-
// crashed before system_init), inject a context summary so the new
|
|
1072
|
-
// session has some awareness of prior conversation.
|
|
1073
|
-
if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
|
|
1074
|
-
session.claudeProcess.once('system_init', () => {
|
|
1075
|
-
const context = this.buildSessionContext(session);
|
|
1076
|
-
if (context) {
|
|
1077
|
-
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.]');
|
|
1078
|
-
}
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
}, action.delayMs);
|
|
1082
|
-
return;
|
|
1083
|
-
}
|
|
1084
|
-
// action.kind === 'exhausted'
|
|
1085
|
-
for (const listener of this._exitListeners) {
|
|
1086
|
-
try {
|
|
1087
|
-
listener(sessionId, code, signal, false);
|
|
1088
|
-
}
|
|
1089
|
-
catch { /* listener error */ }
|
|
1090
|
-
}
|
|
1091
|
-
const msg = {
|
|
1092
|
-
type: 'system_message',
|
|
1093
|
-
subtype: 'error',
|
|
1094
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
|
|
1095
|
-
};
|
|
1096
|
-
this.addToHistory(session, msg);
|
|
1097
|
-
this.broadcast(session, msg);
|
|
1098
|
-
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
986
|
+
/** Handle Claude process exit. Delegates to SessionLifecycle. @internal — used by tests. */
|
|
987
|
+
handleClaudeExit(...args) {
|
|
988
|
+
this.sessionLifecycle.handleClaudeExit(...args);
|
|
1099
989
|
}
|
|
1100
990
|
/**
|
|
1101
991
|
* Send user input to a session's Claude process.
|
|
@@ -1151,436 +1041,19 @@ export class SessionManager {
|
|
|
1151
1041
|
session.claudeProcess?.sendMessage(data);
|
|
1152
1042
|
}
|
|
1153
1043
|
/**
|
|
1154
|
-
* Route a user's prompt response to the correct handler
|
|
1155
|
-
*
|
|
1156
|
-
* fallback path), or plain message fallback.
|
|
1044
|
+
* Route a user's prompt response to the correct handler.
|
|
1045
|
+
* Delegates to PromptRouter.
|
|
1157
1046
|
*/
|
|
1158
1047
|
sendPromptResponse(sessionId, value, requestId) {
|
|
1159
|
-
|
|
1160
|
-
if (!session)
|
|
1161
|
-
return;
|
|
1162
|
-
session._lastActivityAt = Date.now();
|
|
1163
|
-
// ExitPlanMode approvals are handled through the normal pendingToolApprovals
|
|
1164
|
-
// path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
|
|
1165
|
-
// Check for pending tool approval from PreToolUse hook
|
|
1166
|
-
if (!requestId) {
|
|
1167
|
-
const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
|
|
1168
|
-
if (totalPending === 1) {
|
|
1169
|
-
// Exactly one pending prompt — safe to infer the target
|
|
1170
|
-
const soleApproval = session.pendingToolApprovals.size === 1
|
|
1171
|
-
? session.pendingToolApprovals.values().next().value
|
|
1172
|
-
: undefined;
|
|
1173
|
-
if (soleApproval) {
|
|
1174
|
-
console.warn(`[prompt_response] no requestId, routing to sole pending tool approval: ${soleApproval.toolName}`);
|
|
1175
|
-
this.resolveToolApproval(session, soleApproval, value);
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
const soleControl = session.pendingControlRequests.size === 1
|
|
1179
|
-
? session.pendingControlRequests.values().next().value
|
|
1180
|
-
: undefined;
|
|
1181
|
-
if (soleControl) {
|
|
1182
|
-
console.warn(`[prompt_response] no requestId, routing to sole pending control request: ${soleControl.toolName}`);
|
|
1183
|
-
requestId = soleControl.requestId;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
else if (totalPending > 1) {
|
|
1187
|
-
console.warn(`[prompt_response] no requestId with ${totalPending} pending prompts — rejecting to prevent misrouted response`);
|
|
1188
|
-
this.broadcast(session, {
|
|
1189
|
-
type: 'system_message',
|
|
1190
|
-
subtype: 'error',
|
|
1191
|
-
text: 'Prompt response could not be routed: multiple prompts pending. Please refresh and try again.',
|
|
1192
|
-
});
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
else {
|
|
1196
|
-
console.warn(`[prompt_response] no requestId, no pending prompts — forwarding as user message`);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
const approval = requestId ? session.pendingToolApprovals.get(requestId) : undefined;
|
|
1200
|
-
if (approval) {
|
|
1201
|
-
this.resolveToolApproval(session, approval, value);
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
if (!session.claudeProcess?.isAlive())
|
|
1205
|
-
return;
|
|
1206
|
-
// Find matching pending control request
|
|
1207
|
-
const pending = requestId ? session.pendingControlRequests.get(requestId) : undefined;
|
|
1208
|
-
if (pending) {
|
|
1209
|
-
session.pendingControlRequests.delete(pending.requestId);
|
|
1210
|
-
// Dismiss prompt on all other clients viewing this session
|
|
1211
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: pending.requestId });
|
|
1212
|
-
if (pending.toolName === 'AskUserQuestion') {
|
|
1213
|
-
this.handleAskUserQuestion(session, pending, value);
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
this.sendControlResponseForRequest(session, pending, value);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
else {
|
|
1220
|
-
// Fallback: no pending control request, send as plain user message
|
|
1221
|
-
const answer = Array.isArray(value) ? value.join(', ') : value;
|
|
1222
|
-
session.claudeProcess.sendMessage(answer);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
/** Decode the allow/deny/always/pattern intent from a prompt response value. */
|
|
1226
|
-
decodeApprovalValue(value) {
|
|
1227
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
1228
|
-
return {
|
|
1229
|
-
isDeny: first === 'deny',
|
|
1230
|
-
isAlwaysAllow: first === 'always_allow',
|
|
1231
|
-
isApprovePattern: first === 'approve_pattern',
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
/** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
|
|
1235
|
-
resolveToolApproval(session, approval, value) {
|
|
1236
|
-
// AskUserQuestion: the value IS the user's answer, not a permission decision
|
|
1237
|
-
if (approval.toolName === 'AskUserQuestion') {
|
|
1238
|
-
const answer = Array.isArray(value) ? value.join(', ') : value;
|
|
1239
|
-
console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
|
|
1240
|
-
approval.resolve({ allow: true, always: false, answer });
|
|
1241
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1242
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
// ExitPlanMode: route through PlanManager for state tracking.
|
|
1246
|
-
// The hook will convert allow→deny-with-approval-message (CLI workaround).
|
|
1247
|
-
if (approval.toolName === 'ExitPlanMode') {
|
|
1248
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
1249
|
-
const isDeny = first === 'deny';
|
|
1250
|
-
if (isDeny) {
|
|
1251
|
-
// Extract feedback text if present (value may be ['deny', 'feedback text'])
|
|
1252
|
-
const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
|
|
1253
|
-
const reason = session.planManager.deny(approval.requestId, feedback);
|
|
1254
|
-
console.log(`[plan-approval] denied: ${reason}`);
|
|
1255
|
-
approval.resolve({ allow: false, always: false });
|
|
1256
|
-
}
|
|
1257
|
-
else {
|
|
1258
|
-
session.planManager.approve(approval.requestId);
|
|
1259
|
-
console.log(`[plan-approval] approved`);
|
|
1260
|
-
approval.resolve({ allow: true, always: false });
|
|
1261
|
-
}
|
|
1262
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1263
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
1267
|
-
if (isAlwaysAllow && !isDeny) {
|
|
1268
|
-
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
1269
|
-
}
|
|
1270
|
-
if (isApprovePattern && !isDeny) {
|
|
1271
|
-
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
1272
|
-
}
|
|
1273
|
-
console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
|
|
1274
|
-
approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
|
|
1275
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1276
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1277
|
-
}
|
|
1278
|
-
/**
|
|
1279
|
-
* Send an AskUserQuestion control response, mapping the user's answer(s) into
|
|
1280
|
-
* the structured answers map the tool expects.
|
|
1281
|
-
*/
|
|
1282
|
-
handleAskUserQuestion(session, pending, value) {
|
|
1283
|
-
const questions = pending.toolInput?.questions;
|
|
1284
|
-
const updatedInput = { ...pending.toolInput };
|
|
1285
|
-
let answers = {};
|
|
1286
|
-
if (typeof value === 'string') {
|
|
1287
|
-
// Try parsing as JSON answers map (multi-question flow)
|
|
1288
|
-
try {
|
|
1289
|
-
const parsed = JSON.parse(value);
|
|
1290
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1291
|
-
answers = parsed;
|
|
1292
|
-
}
|
|
1293
|
-
else if (Array.isArray(questions) && questions.length > 0) {
|
|
1294
|
-
answers[questions[0].question] = value;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
catch {
|
|
1298
|
-
// Plain string answer — map to first question
|
|
1299
|
-
if (Array.isArray(questions) && questions.length > 0) {
|
|
1300
|
-
answers[questions[0].question] = value;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
else if (Array.isArray(value) && Array.isArray(questions) && questions.length > 0) {
|
|
1305
|
-
// Array of answers — map to first question (multi-select single question)
|
|
1306
|
-
answers[questions[0].question] = value.join(', ');
|
|
1307
|
-
}
|
|
1308
|
-
updatedInput.answers = answers;
|
|
1309
|
-
session.claudeProcess.sendControlResponse(pending.requestId, 'allow', updatedInput);
|
|
1310
|
-
}
|
|
1311
|
-
/** Send a permission control response (allow/always_allow/approve_pattern/deny). */
|
|
1312
|
-
sendControlResponseForRequest(session, pending, value) {
|
|
1313
|
-
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
1314
|
-
if (isAlwaysAllow) {
|
|
1315
|
-
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
1316
|
-
}
|
|
1317
|
-
if (isApprovePattern) {
|
|
1318
|
-
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
1319
|
-
}
|
|
1320
|
-
const behavior = isDeny ? 'deny' : 'allow';
|
|
1321
|
-
session.claudeProcess.sendControlResponse(pending.requestId, behavior);
|
|
1048
|
+
this.promptRouter.sendPromptResponse(sessionId, value, requestId);
|
|
1322
1049
|
}
|
|
1323
1050
|
/**
|
|
1324
1051
|
* Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
|
|
1325
1052
|
* and returns a Promise that resolves when the user approves/denies.
|
|
1053
|
+
* Delegates to PromptRouter.
|
|
1326
1054
|
*/
|
|
1327
1055
|
requestToolApproval(sessionId, toolName, toolInput) {
|
|
1328
|
-
|
|
1329
|
-
if (!session) {
|
|
1330
|
-
console.log(`[tool-approval] session not found: ${sessionId}`);
|
|
1331
|
-
return Promise.resolve({ allow: false, always: false });
|
|
1332
|
-
}
|
|
1333
|
-
const autoResult = this.resolveAutoApproval(session, toolName, toolInput);
|
|
1334
|
-
if (autoResult === 'registry') {
|
|
1335
|
-
console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
|
|
1336
|
-
return Promise.resolve({ allow: true, always: true });
|
|
1337
|
-
}
|
|
1338
|
-
if (autoResult === 'session') {
|
|
1339
|
-
console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
|
|
1340
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1341
|
-
}
|
|
1342
|
-
if (autoResult === 'headless') {
|
|
1343
|
-
console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
|
|
1344
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1345
|
-
}
|
|
1346
|
-
console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
|
|
1347
|
-
// ExitPlanMode: route through PlanManager state machine for plan-specific
|
|
1348
|
-
// approval UI. The hook blocks until we resolve the promise.
|
|
1349
|
-
if (toolName === 'ExitPlanMode') {
|
|
1350
|
-
return this.handleExitPlanModeApproval(session, sessionId);
|
|
1351
|
-
}
|
|
1352
|
-
// Prevent double-gating: if a control_request already created a pending
|
|
1353
|
-
// entry for this tool, auto-approve the control_request and let the hook
|
|
1354
|
-
// take over as the sole approval gate. This is the reverse of the check
|
|
1355
|
-
// in onControlRequestEvent (which handles hook-first ordering).
|
|
1356
|
-
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
1357
|
-
if (pending.toolName === toolName) {
|
|
1358
|
-
console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
|
|
1359
|
-
session.claudeProcess?.sendControlResponse(reqId, 'allow');
|
|
1360
|
-
session.pendingControlRequests.delete(reqId);
|
|
1361
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
|
|
1362
|
-
break;
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
// AskUserQuestion: show a question prompt and collect the answer text,
|
|
1366
|
-
// rather than a permission prompt with Allow/Deny buttons.
|
|
1367
|
-
const isQuestion = toolName === 'AskUserQuestion';
|
|
1368
|
-
return new Promise((resolve) => {
|
|
1369
|
-
// Holder lets wrappedResolve reference the timeout before it's assigned
|
|
1370
|
-
const timer = { id: null };
|
|
1371
|
-
const wrappedResolve = (result) => {
|
|
1372
|
-
if (timer.id)
|
|
1373
|
-
clearTimeout(timer.id);
|
|
1374
|
-
resolve(result);
|
|
1375
|
-
};
|
|
1376
|
-
const approvalRequestId = randomUUID();
|
|
1377
|
-
// Timeout to prevent leaked promises if client disconnects after prompt is sent
|
|
1378
|
-
timer.id = setTimeout(() => {
|
|
1379
|
-
if (session.pendingToolApprovals.has(approvalRequestId)) {
|
|
1380
|
-
console.log(`[tool-approval] timed out for ${toolName}`);
|
|
1381
|
-
session.pendingToolApprovals.delete(approvalRequestId);
|
|
1382
|
-
// Dismiss the stale prompt in all clients so they don't inject
|
|
1383
|
-
// "allow"/"deny" as plain text after the timeout
|
|
1384
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
|
|
1385
|
-
resolve({ allow: false, always: false });
|
|
1386
|
-
}
|
|
1387
|
-
}, isQuestion ? 300_000 : (session.source === 'agent' ? 300_000 : 60_000)); // 5 min for questions & agent children, 1 min for interactive
|
|
1388
|
-
let promptMsg;
|
|
1389
|
-
if (isQuestion) {
|
|
1390
|
-
// AskUserQuestion: extract structured questions from toolInput.questions
|
|
1391
|
-
// and pass them through so PromptButtons can render the multi-question flow.
|
|
1392
|
-
const rawQuestions = toolInput.questions;
|
|
1393
|
-
const structuredQuestions = Array.isArray(rawQuestions)
|
|
1394
|
-
? rawQuestions.map(q => ({
|
|
1395
|
-
question: q.question,
|
|
1396
|
-
header: q.header,
|
|
1397
|
-
multiSelect: q.multiSelect ?? false,
|
|
1398
|
-
options: (q.options || []).map((opt) => ({
|
|
1399
|
-
label: opt.label,
|
|
1400
|
-
value: opt.value ?? opt.label,
|
|
1401
|
-
description: opt.description,
|
|
1402
|
-
})),
|
|
1403
|
-
}))
|
|
1404
|
-
: undefined;
|
|
1405
|
-
const firstQ = structuredQuestions?.[0];
|
|
1406
|
-
promptMsg = {
|
|
1407
|
-
type: 'prompt',
|
|
1408
|
-
promptType: 'question',
|
|
1409
|
-
question: firstQ?.question || 'Answer the question',
|
|
1410
|
-
options: firstQ?.options || [],
|
|
1411
|
-
multiSelect: firstQ?.multiSelect,
|
|
1412
|
-
toolName,
|
|
1413
|
-
toolInput,
|
|
1414
|
-
requestId: approvalRequestId,
|
|
1415
|
-
...(structuredQuestions ? { questions: structuredQuestions } : {}),
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1418
|
-
else {
|
|
1419
|
-
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
1420
|
-
const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
|
|
1421
|
-
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
1422
|
-
const options = [
|
|
1423
|
-
{ label: 'Allow', value: 'allow' },
|
|
1424
|
-
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
1425
|
-
{ label: 'Deny', value: 'deny' },
|
|
1426
|
-
];
|
|
1427
|
-
promptMsg = {
|
|
1428
|
-
type: 'prompt',
|
|
1429
|
-
promptType: 'permission',
|
|
1430
|
-
question,
|
|
1431
|
-
options,
|
|
1432
|
-
toolName,
|
|
1433
|
-
toolInput,
|
|
1434
|
-
requestId: approvalRequestId,
|
|
1435
|
-
...(approvePattern ? { approvePattern } : {}),
|
|
1436
|
-
};
|
|
1437
|
-
}
|
|
1438
|
-
session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
|
|
1439
|
-
if (session.clients.size > 0) {
|
|
1440
|
-
this.broadcast(session, promptMsg);
|
|
1441
|
-
}
|
|
1442
|
-
else {
|
|
1443
|
-
// No clients connected — DON'T auto-deny. Instead, wait for a client
|
|
1444
|
-
// to join this session (the prompt will be re-broadcast in join()).
|
|
1445
|
-
// Send a global notification so the user sees a waiting indicator.
|
|
1446
|
-
console.log(`[tool-approval] no clients connected, waiting for client to join (timeout 60s): ${toolName}`);
|
|
1447
|
-
this._globalBroadcast?.({
|
|
1448
|
-
...promptMsg,
|
|
1449
|
-
sessionId,
|
|
1450
|
-
sessionName: session.name,
|
|
1451
|
-
});
|
|
1452
|
-
}
|
|
1453
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
1454
|
-
for (const listener of this._promptListeners) {
|
|
1455
|
-
try {
|
|
1456
|
-
listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
|
|
1457
|
-
}
|
|
1458
|
-
catch { /* listener error */ }
|
|
1459
|
-
}
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
/**
|
|
1463
|
-
* Handle ExitPlanMode approval through PlanManager.
|
|
1464
|
-
* Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
|
|
1465
|
-
* until the user responds. On approve, returns allow:true (the hook will use
|
|
1466
|
-
* the deny-with-approval-message workaround). On deny, returns allow:false.
|
|
1467
|
-
*/
|
|
1468
|
-
handleExitPlanModeApproval(session, sessionId) {
|
|
1469
|
-
const reviewId = session.planManager.onExitPlanModeRequested();
|
|
1470
|
-
if (!reviewId) {
|
|
1471
|
-
// Not in planning state — fall through to allow (CLI handles natively)
|
|
1472
|
-
console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
|
|
1473
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1474
|
-
}
|
|
1475
|
-
return new Promise((resolve) => {
|
|
1476
|
-
const timer = { id: null };
|
|
1477
|
-
const wrappedResolve = (result) => {
|
|
1478
|
-
if (timer.id)
|
|
1479
|
-
clearTimeout(timer.id);
|
|
1480
|
-
resolve(result);
|
|
1481
|
-
};
|
|
1482
|
-
// Timeout: auto-deny after 5 minutes to prevent leaked promises
|
|
1483
|
-
timer.id = setTimeout(() => {
|
|
1484
|
-
if (session.pendingToolApprovals.has(reviewId)) {
|
|
1485
|
-
console.log(`[plan-approval] timed out, auto-denying`);
|
|
1486
|
-
session.pendingToolApprovals.delete(reviewId);
|
|
1487
|
-
session.planManager.deny(reviewId);
|
|
1488
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
|
|
1489
|
-
resolve({ allow: false, always: false });
|
|
1490
|
-
}
|
|
1491
|
-
}, 300_000);
|
|
1492
|
-
const promptMsg = {
|
|
1493
|
-
type: 'prompt',
|
|
1494
|
-
promptType: 'permission',
|
|
1495
|
-
question: 'Approve plan and start implementation?',
|
|
1496
|
-
options: [
|
|
1497
|
-
{ label: 'Approve', value: 'allow' },
|
|
1498
|
-
{ label: 'Reject', value: 'deny' },
|
|
1499
|
-
],
|
|
1500
|
-
toolName: 'ExitPlanMode',
|
|
1501
|
-
requestId: reviewId,
|
|
1502
|
-
};
|
|
1503
|
-
session.pendingToolApprovals.set(reviewId, {
|
|
1504
|
-
resolve: wrappedResolve,
|
|
1505
|
-
toolName: 'ExitPlanMode',
|
|
1506
|
-
toolInput: {},
|
|
1507
|
-
requestId: reviewId,
|
|
1508
|
-
promptMsg,
|
|
1509
|
-
});
|
|
1510
|
-
this.broadcast(session, promptMsg);
|
|
1511
|
-
if (session.clients.size === 0) {
|
|
1512
|
-
this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
|
|
1513
|
-
}
|
|
1514
|
-
for (const listener of this._promptListeners) {
|
|
1515
|
-
try {
|
|
1516
|
-
listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
|
|
1517
|
-
}
|
|
1518
|
-
catch { /* listener error */ }
|
|
1519
|
-
}
|
|
1520
|
-
});
|
|
1521
|
-
}
|
|
1522
|
-
/**
|
|
1523
|
-
* Check if a tool invocation can be auto-approved without prompting the user.
|
|
1524
|
-
* Returns 'registry' if matched by auto-approval rules, 'session' if matched
|
|
1525
|
-
* by the session's allowedTools list, 'headless' if the session has no clients
|
|
1526
|
-
* and is a non-interactive source, or 'prompt' if the user needs to decide.
|
|
1527
|
-
*/
|
|
1528
|
-
resolveAutoApproval(session, toolName, toolInput) {
|
|
1529
|
-
if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
|
|
1530
|
-
return 'registry';
|
|
1531
|
-
}
|
|
1532
|
-
if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
|
|
1533
|
-
return 'session';
|
|
1534
|
-
}
|
|
1535
|
-
if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'orchestrator')) {
|
|
1536
|
-
return 'headless';
|
|
1537
|
-
}
|
|
1538
|
-
return 'prompt';
|
|
1539
|
-
}
|
|
1540
|
-
/**
|
|
1541
|
-
* Check if a tool invocation matches any of the session's allowedTools patterns.
|
|
1542
|
-
* Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
|
|
1543
|
-
* Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
|
|
1544
|
-
*/
|
|
1545
|
-
matchesAllowedTools(allowedTools, toolName, toolInput) {
|
|
1546
|
-
for (const pattern of allowedTools) {
|
|
1547
|
-
// Simple tool name match: 'WebFetch', 'Read', etc.
|
|
1548
|
-
if (pattern === toolName)
|
|
1549
|
-
return true;
|
|
1550
|
-
// Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
|
|
1551
|
-
const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
|
|
1552
|
-
if (match) {
|
|
1553
|
-
const [, patternTool, prefix] = match;
|
|
1554
|
-
if (patternTool !== toolName)
|
|
1555
|
-
continue;
|
|
1556
|
-
// For Bash, check command prefix
|
|
1557
|
-
if (toolName === 'Bash') {
|
|
1558
|
-
const cmd = String(toolInput.command || '').trimStart();
|
|
1559
|
-
if (cmd === prefix || cmd.startsWith(prefix + ' '))
|
|
1560
|
-
return true;
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
return false;
|
|
1565
|
-
}
|
|
1566
|
-
/** Build a human-readable prompt string for a tool permission dialog. */
|
|
1567
|
-
summarizeToolPermission(toolName, toolInput) {
|
|
1568
|
-
switch (toolName) {
|
|
1569
|
-
case 'Bash': {
|
|
1570
|
-
const cmd = String(toolInput.command || '');
|
|
1571
|
-
const firstLine = cmd.split('\n')[0];
|
|
1572
|
-
const display = firstLine.length < cmd.length ? `${firstLine}...` : cmd;
|
|
1573
|
-
return `Allow Bash? \`$ ${display}\``;
|
|
1574
|
-
}
|
|
1575
|
-
case 'Task':
|
|
1576
|
-
return `Allow Task? ${String(toolInput.description || toolName)}`;
|
|
1577
|
-
case 'Read': {
|
|
1578
|
-
const filePath = String(toolInput.file_path || '');
|
|
1579
|
-
return `Allow Read? \`${filePath}\``;
|
|
1580
|
-
}
|
|
1581
|
-
default:
|
|
1582
|
-
return `Allow ${toolName}?`;
|
|
1583
|
-
}
|
|
1056
|
+
return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
|
|
1584
1057
|
}
|
|
1585
1058
|
/** Update the model for a session and restart Claude with the new model. */
|
|
1586
1059
|
setModel(sessionId, model) {
|
|
@@ -1589,15 +1062,16 @@ export class SessionManager {
|
|
|
1589
1062
|
return false;
|
|
1590
1063
|
session.model = model || undefined;
|
|
1591
1064
|
this.persistToDiskDebounced();
|
|
1592
|
-
// Restart Claude with the new model if it's running
|
|
1065
|
+
// Restart Claude with the new model if it's running.
|
|
1066
|
+
// Use stopClaudeAndWait to ensure the old process fully exits before
|
|
1067
|
+
// spawning a new one — avoids concurrent processes in the same worktree.
|
|
1593
1068
|
if (session.claudeProcess?.isAlive()) {
|
|
1594
|
-
this.
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1069
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1070
|
+
if (this.sessions.has(sessionId)) {
|
|
1071
|
+
session._stoppedByUser = false;
|
|
1598
1072
|
this.startClaude(sessionId);
|
|
1599
1073
|
}
|
|
1600
|
-
}
|
|
1074
|
+
});
|
|
1601
1075
|
}
|
|
1602
1076
|
return true;
|
|
1603
1077
|
}
|
|
@@ -1618,49 +1092,29 @@ export class SessionManager {
|
|
|
1618
1092
|
const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
|
|
1619
1093
|
this.addToHistory(session, sysMsg);
|
|
1620
1094
|
this.broadcast(session, sysMsg);
|
|
1621
|
-
// Restart Claude with the new permission mode if it's running
|
|
1095
|
+
// Restart Claude with the new permission mode if it's running.
|
|
1096
|
+
// Use stopClaudeAndWait to ensure the old process fully exits before
|
|
1097
|
+
// spawning a new one — avoids concurrent processes in the same worktree.
|
|
1622
1098
|
if (session.claudeProcess?.isAlive()) {
|
|
1623
|
-
this.
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1099
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1100
|
+
if (this.sessions.has(sessionId)) {
|
|
1101
|
+
session._stoppedByUser = false;
|
|
1627
1102
|
this.startClaude(sessionId);
|
|
1628
1103
|
}
|
|
1629
|
-
}
|
|
1104
|
+
});
|
|
1630
1105
|
}
|
|
1631
1106
|
return true;
|
|
1632
1107
|
}
|
|
1108
|
+
/** Stop the Claude process for a session. Delegates to SessionLifecycle. */
|
|
1633
1109
|
stopClaude(sessionId) {
|
|
1634
|
-
|
|
1635
|
-
if (session?.claudeProcess) {
|
|
1636
|
-
session._stoppedByUser = true;
|
|
1637
|
-
if (session._apiRetryTimer)
|
|
1638
|
-
clearTimeout(session._apiRetryTimer);
|
|
1639
|
-
session.claudeProcess.removeAllListeners();
|
|
1640
|
-
session.claudeProcess.stop();
|
|
1641
|
-
session.claudeProcess = null;
|
|
1642
|
-
this.broadcast(session, { type: 'claude_stopped' });
|
|
1643
|
-
}
|
|
1110
|
+
this.sessionLifecycle.stopClaude(sessionId);
|
|
1644
1111
|
}
|
|
1645
1112
|
/**
|
|
1646
1113
|
* Stop the Claude process and wait for it to fully exit before resolving.
|
|
1647
|
-
*
|
|
1648
|
-
* (e.g. during mid-session worktree migration).
|
|
1114
|
+
* Delegates to SessionLifecycle.
|
|
1649
1115
|
*/
|
|
1650
1116
|
async stopClaudeAndWait(sessionId) {
|
|
1651
|
-
|
|
1652
|
-
if (!session?.claudeProcess)
|
|
1653
|
-
return;
|
|
1654
|
-
const cp = session.claudeProcess;
|
|
1655
|
-
session._stoppedByUser = true;
|
|
1656
|
-
if (session._apiRetryTimer)
|
|
1657
|
-
clearTimeout(session._apiRetryTimer);
|
|
1658
|
-
cp.removeAllListeners();
|
|
1659
|
-
cp.stop();
|
|
1660
|
-
session.claudeProcess = null;
|
|
1661
|
-
this.broadcast(session, { type: 'claude_stopped' });
|
|
1662
|
-
// Wait for the underlying OS process to fully exit
|
|
1663
|
-
await cp.waitForExit();
|
|
1117
|
+
return this.sessionLifecycle.stopClaudeAndWait(sessionId);
|
|
1664
1118
|
}
|
|
1665
1119
|
// ---------------------------------------------------------------------------
|
|
1666
1120
|
// Helpers
|