codekin 0.5.4 → 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 +1 -0
- package/dist/assets/{index-COGLICp9.js → index-BFkKlY3O.js} +46 -42
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/dist/approval-manager.js +1 -2
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/claude-process.d.ts +9 -0
- package/server/dist/claude-process.js +35 -3
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/config.js +2 -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 +234 -801
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-persistence.js +21 -3
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +4 -0
- package/server/dist/ws-message-handler.js +2 -1
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +6 -0
- package/server/dist/ws-server.js.map +1 -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),
|
|
@@ -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;
|
|
@@ -235,8 +278,16 @@ export class SessionManager {
|
|
|
235
278
|
* Create a git worktree for a session. Creates a new branch and worktree
|
|
236
279
|
* as a sibling directory of the project root.
|
|
237
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.
|
|
238
289
|
*/
|
|
239
|
-
async createWorktree(sessionId, workingDir) {
|
|
290
|
+
async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
|
|
240
291
|
const session = this.sessions.get(sessionId);
|
|
241
292
|
if (!session)
|
|
242
293
|
return null;
|
|
@@ -253,23 +304,72 @@ export class SessionManager {
|
|
|
253
304
|
console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
|
|
254
305
|
return null;
|
|
255
306
|
}
|
|
256
|
-
const prefix = this.getWorktreeBranchPrefix();
|
|
257
307
|
const shortId = sessionId.slice(0, 8);
|
|
258
|
-
const branchName = `${
|
|
308
|
+
const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
|
|
259
309
|
const projectName = path.basename(repoRoot);
|
|
260
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
|
+
}
|
|
261
331
|
// Clean up stale state from previous failed attempts:
|
|
262
332
|
// 1. Prune orphaned worktree entries (directory gone but git still tracks it)
|
|
263
333
|
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
|
|
264
334
|
.catch((e) => console.warn(`[worktree] prune failed:`, e instanceof Error ? e.message : e));
|
|
265
|
-
// 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".
|
|
266
338
|
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
|
|
267
|
-
.catch((e) =>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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, {
|
|
273
373
|
cwd: repoRoot,
|
|
274
374
|
env,
|
|
275
375
|
timeout: 15000,
|
|
@@ -363,8 +463,11 @@ export class SessionManager {
|
|
|
363
463
|
/**
|
|
364
464
|
* Clean up a git worktree and its branch. Runs asynchronously and logs errors
|
|
365
465
|
* but never throws — session deletion must not be blocked by cleanup failures.
|
|
466
|
+
* Retries once on failure after a short delay.
|
|
366
467
|
*/
|
|
367
|
-
cleanupWorktree(worktreePath, repoDir) {
|
|
468
|
+
cleanupWorktree(worktreePath, repoDir, attempt = 1) {
|
|
469
|
+
const MAX_CLEANUP_ATTEMPTS = 2;
|
|
470
|
+
const RETRY_DELAY_MS = 3000;
|
|
368
471
|
void (async () => {
|
|
369
472
|
try {
|
|
370
473
|
// Resolve the actual repo root (repoDir may itself be a worktree)
|
|
@@ -384,10 +487,62 @@ export class SessionManager {
|
|
|
384
487
|
.catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
|
|
385
488
|
}
|
|
386
489
|
catch (err) {
|
|
387
|
-
|
|
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
|
+
}
|
|
388
509
|
}
|
|
389
510
|
})();
|
|
390
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
|
+
}
|
|
391
546
|
/** Get the configured worktree branch prefix (defaults to 'wt/'). */
|
|
392
547
|
getWorktreeBranchPrefix() {
|
|
393
548
|
return this.archive.getSetting('worktree_branch_prefix', 'wt/');
|
|
@@ -424,30 +579,7 @@ export class SessionManager {
|
|
|
424
579
|
}
|
|
425
580
|
/** Get all sessions that have pending prompts (waiting for approval or answer). */
|
|
426
581
|
getPendingPrompts() {
|
|
427
|
-
|
|
428
|
-
for (const session of this.sessions.values()) {
|
|
429
|
-
const prompts = [];
|
|
430
|
-
for (const [reqId, pending] of session.pendingToolApprovals) {
|
|
431
|
-
prompts.push({
|
|
432
|
-
requestId: reqId,
|
|
433
|
-
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
434
|
-
toolName: pending.toolName,
|
|
435
|
-
toolInput: pending.toolInput,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
439
|
-
prompts.push({
|
|
440
|
-
requestId: reqId,
|
|
441
|
-
promptType: pending.toolName === 'AskUserQuestion' ? 'question' : 'permission',
|
|
442
|
-
toolName: pending.toolName,
|
|
443
|
-
toolInput: pending.toolInput,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
if (prompts.length > 0) {
|
|
447
|
-
results.push({ sessionId: session.id, sessionName: session.name, source: session.source, prompts });
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
return results;
|
|
582
|
+
return this.promptRouter.getPendingPrompts();
|
|
451
583
|
}
|
|
452
584
|
/** Clear the isProcessing flag for a session and broadcast the update. */
|
|
453
585
|
clearProcessingFlag(sessionId) {
|
|
@@ -578,15 +710,29 @@ export class SessionManager {
|
|
|
578
710
|
clearTimeout(session._namingTimer);
|
|
579
711
|
if (session._leaveGraceTimer)
|
|
580
712
|
clearTimeout(session._leaveGraceTimer);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
session.
|
|
584
|
-
|
|
585
|
-
|
|
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();
|
|
586
730
|
this.archiveSessionIfWorthSaving(session);
|
|
587
|
-
// Clean up git worktree if this session used one
|
|
731
|
+
// Clean up git worktree if this session used one — deferred until process exits
|
|
588
732
|
if (session.worktreePath) {
|
|
589
|
-
|
|
733
|
+
const wtPath = session.worktreePath;
|
|
734
|
+
const repoDir = session.groupDir ?? session.workingDir;
|
|
735
|
+
void exitPromise.then(() => this.cleanupWorktree(wtPath, repoDir));
|
|
590
736
|
}
|
|
591
737
|
// Clean up webhook workspace directory if applicable
|
|
592
738
|
if (session.source === 'webhook' || session.source === 'stepflow') {
|
|
@@ -638,126 +784,17 @@ export class SessionManager {
|
|
|
638
784
|
// ---------------------------------------------------------------------------
|
|
639
785
|
/**
|
|
640
786
|
* Spawn (or re-spawn) a Claude CLI process for a session.
|
|
641
|
-
*
|
|
787
|
+
* Delegates to SessionLifecycle.
|
|
642
788
|
*/
|
|
643
789
|
startClaude(sessionId) {
|
|
644
|
-
|
|
645
|
-
if (!session)
|
|
646
|
-
return false;
|
|
647
|
-
// Clear stopped flag on explicit start
|
|
648
|
-
session._stoppedByUser = false;
|
|
649
|
-
// Kill existing process if any — remove listeners first to prevent the
|
|
650
|
-
// old process's exit handler from clobbering the new process reference
|
|
651
|
-
// and triggering an unwanted auto-restart cycle.
|
|
652
|
-
if (session.claudeProcess) {
|
|
653
|
-
session.claudeProcess.removeAllListeners();
|
|
654
|
-
session.claudeProcess.stop();
|
|
655
|
-
}
|
|
656
|
-
// Derive a session-scoped token instead of forwarding the master auth token.
|
|
657
|
-
// This limits child process privileges to approve/deny for their own session only.
|
|
658
|
-
const sessionToken = this._authToken
|
|
659
|
-
? deriveSessionToken(this._authToken, sessionId)
|
|
660
|
-
: '';
|
|
661
|
-
// Both CODEKIN_TOKEN (legacy name, used by older hooks) and CODEKIN_AUTH_TOKEN
|
|
662
|
-
// (current canonical name) are set to the same derived value for backward compatibility.
|
|
663
|
-
const extraEnv = {
|
|
664
|
-
CODEKIN_SESSION_ID: sessionId,
|
|
665
|
-
CODEKIN_PORT: String(this._serverPort || PORT),
|
|
666
|
-
CODEKIN_TOKEN: sessionToken,
|
|
667
|
-
CODEKIN_AUTH_TOKEN: sessionToken,
|
|
668
|
-
CODEKIN_SESSION_TYPE: session.source || 'manual',
|
|
669
|
-
...(session.permissionMode === 'dangerouslySkipPermissions' ? { CODEKIN_SKIP_PERMISSIONS: '1' } : {}),
|
|
670
|
-
};
|
|
671
|
-
// Pass CLAUDE_PROJECT_DIR so hooks and CLAUDE.md resolve correctly
|
|
672
|
-
// even when the session's working directory differs from the project root
|
|
673
|
-
// (e.g. worktrees, webhook workspaces). Note: this does NOT control
|
|
674
|
-
// session storage path — Claude CLI uses the CWD for that.
|
|
675
|
-
if (session.groupDir) {
|
|
676
|
-
extraEnv.CLAUDE_PROJECT_DIR = session.groupDir;
|
|
677
|
-
}
|
|
678
|
-
else if (process.env.CLAUDE_PROJECT_DIR) {
|
|
679
|
-
extraEnv.CLAUDE_PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR;
|
|
680
|
-
}
|
|
681
|
-
// When claudeSessionId exists, the session has run before and a JSONL file
|
|
682
|
-
// exists on disk. Use --resume (not --session-id) to continue it — --session-id
|
|
683
|
-
// creates a *new* session and fails with "already in use" if the JSONL exists.
|
|
684
|
-
const resume = !!session.claudeSessionId;
|
|
685
|
-
// Build comprehensive allowedTools from session-level overrides + registry approvals
|
|
686
|
-
const repoDir = session.groupDir ?? session.workingDir;
|
|
687
|
-
const registryPatterns = this._approvalManager.getAllowedToolsForRepo(repoDir);
|
|
688
|
-
const mergedAllowedTools = [...new Set([...(session.allowedTools || []), ...registryPatterns])];
|
|
689
|
-
const cp = new ClaudeProcess(session.workingDir, {
|
|
690
|
-
sessionId: session.claudeSessionId || undefined,
|
|
691
|
-
extraEnv,
|
|
692
|
-
model: session.model,
|
|
693
|
-
permissionMode: session.permissionMode,
|
|
694
|
-
resume,
|
|
695
|
-
allowedTools: mergedAllowedTools,
|
|
696
|
-
});
|
|
697
|
-
this.wireClaudeEvents(cp, session, sessionId);
|
|
698
|
-
cp.start();
|
|
699
|
-
session.claudeProcess = cp;
|
|
700
|
-
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
701
|
-
const startMsg = { type: 'claude_started', sessionId };
|
|
702
|
-
this.addToHistory(session, startMsg);
|
|
703
|
-
this.broadcast(session, startMsg);
|
|
704
|
-
return true;
|
|
790
|
+
return this.sessionLifecycle.startClaude(sessionId);
|
|
705
791
|
}
|
|
706
792
|
/**
|
|
707
|
-
* Wait for a session's Claude process to emit its system_init event
|
|
708
|
-
*
|
|
709
|
-
* session already has a claudeSessionId (process previously initialized).
|
|
710
|
-
* 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.
|
|
711
795
|
*/
|
|
712
796
|
waitForReady(sessionId, timeoutMs = 30_000) {
|
|
713
|
-
|
|
714
|
-
if (!session?.claudeProcess)
|
|
715
|
-
return Promise.resolve();
|
|
716
|
-
// If the process already completed init in a prior turn, resolve immediately
|
|
717
|
-
if (session.claudeSessionId)
|
|
718
|
-
return Promise.resolve();
|
|
719
|
-
return new Promise((resolve) => {
|
|
720
|
-
const timer = setTimeout(() => {
|
|
721
|
-
console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`);
|
|
722
|
-
resolve();
|
|
723
|
-
}, timeoutMs);
|
|
724
|
-
session.claudeProcess.once('system_init', () => {
|
|
725
|
-
clearTimeout(timer);
|
|
726
|
-
resolve();
|
|
727
|
-
});
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Attach all ClaudeProcess event listeners for a session.
|
|
732
|
-
* Extracted from startClaude() to keep that method focused on process setup.
|
|
733
|
-
*/
|
|
734
|
-
wireClaudeEvents(cp, session, sessionId) {
|
|
735
|
-
cp.on('system_init', (model) => this.onSystemInit(cp, session, model));
|
|
736
|
-
cp.on('text', (text) => this.onTextEvent(session, sessionId, text));
|
|
737
|
-
cp.on('thinking', (summary) => this.onThinkingEvent(session, summary));
|
|
738
|
-
cp.on('tool_output', (content, isError) => this.onToolOutputEvent(session, content, isError));
|
|
739
|
-
cp.on('image', (base64Data, mediaType) => this.onImageEvent(session, base64Data, mediaType));
|
|
740
|
-
cp.on('tool_active', (toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput));
|
|
741
|
-
cp.on('tool_done', (toolName, summary) => this.onToolDoneEvent(session, toolName, summary));
|
|
742
|
-
cp.on('planning_mode', (active) => {
|
|
743
|
-
// Route EnterPlanMode through PlanManager for UI state tracking.
|
|
744
|
-
// ExitPlanMode (active=false) is ignored here — the PreToolUse hook
|
|
745
|
-
// is the enforcement gate, and it calls handleExitPlanModeApproval()
|
|
746
|
-
// which transitions PlanManager to 'reviewing'.
|
|
747
|
-
if (active) {
|
|
748
|
-
session.planManager.onEnterPlanMode();
|
|
749
|
-
}
|
|
750
|
-
// ExitPlanMode stream event intentionally ignored — hook handles it.
|
|
751
|
-
});
|
|
752
|
-
cp.on('todo_update', (tasks) => { this.broadcastAndHistory(session, { type: 'todo_update', tasks }); });
|
|
753
|
-
cp.on('prompt', (...args) => this.onPromptEvent(session, ...args));
|
|
754
|
-
cp.on('control_request', (requestId, toolName, toolInput) => this.onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput));
|
|
755
|
-
cp.on('result', (result, isError) => {
|
|
756
|
-
session.planManager.onTurnEnd();
|
|
757
|
-
this.handleClaudeResult(session, sessionId, result, isError);
|
|
758
|
-
});
|
|
759
|
-
cp.on('error', (message) => this.broadcast(session, { type: 'error', message }));
|
|
760
|
-
cp.on('exit', (code, signal) => { cp.removeAllListeners(); this.handleClaudeExit(cp, session, sessionId, code, signal); });
|
|
797
|
+
return this.sessionLifecycle.waitForReady(sessionId, timeoutMs);
|
|
761
798
|
}
|
|
762
799
|
/** Broadcast a message and add it to the session's output history. */
|
|
763
800
|
broadcastAndHistory(session, msg) {
|
|
@@ -807,89 +844,6 @@ export class SessionManager {
|
|
|
807
844
|
onToolDoneEvent(session, toolName, summary) {
|
|
808
845
|
this.broadcastAndHistory(session, { type: 'tool_done', toolName, summary });
|
|
809
846
|
}
|
|
810
|
-
onPromptEvent(session, promptType, question, options, multiSelect, toolName, toolInput, requestId, questions) {
|
|
811
|
-
const promptMsg = {
|
|
812
|
-
type: 'prompt',
|
|
813
|
-
promptType,
|
|
814
|
-
question,
|
|
815
|
-
options,
|
|
816
|
-
multiSelect,
|
|
817
|
-
toolName,
|
|
818
|
-
toolInput,
|
|
819
|
-
requestId,
|
|
820
|
-
...(questions ? { questions } : {}),
|
|
821
|
-
};
|
|
822
|
-
if (requestId) {
|
|
823
|
-
session.pendingControlRequests.set(requestId, { requestId, toolName: 'AskUserQuestion', toolInput: toolInput || {}, promptMsg });
|
|
824
|
-
}
|
|
825
|
-
this.broadcast(session, promptMsg);
|
|
826
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
827
|
-
for (const listener of this._promptListeners) {
|
|
828
|
-
try {
|
|
829
|
-
listener(session.id, promptType, toolName, requestId);
|
|
830
|
-
}
|
|
831
|
-
catch { /* listener error */ }
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
onControlRequestEvent(cp, session, sessionId, requestId, toolName, toolInput) {
|
|
835
|
-
if (typeof requestId !== 'string' || !/^[\w-]{1,64}$/.test(requestId)) {
|
|
836
|
-
console.warn(`[control_request] Rejected invalid requestId: ${JSON.stringify(requestId)}`);
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
console.log(`[control_request] session=${sessionId} tool=${toolName} requestId=${requestId}`);
|
|
840
|
-
if (this.resolveAutoApproval(session, toolName, toolInput) !== 'prompt') {
|
|
841
|
-
console.log(`[control_request] auto-approved: ${toolName}`);
|
|
842
|
-
cp.sendControlResponse(requestId, 'allow');
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
// Prevent double-gating: if a PreToolUse hook is already handling approval
|
|
846
|
-
// for this tool, auto-approve the control_request to avoid duplicate entries.
|
|
847
|
-
// Without this, both pendingToolApprovals and pendingControlRequests contain
|
|
848
|
-
// entries for the same tool invocation, causing stale-entry races when the
|
|
849
|
-
// orchestrator tries to respond via the REST API.
|
|
850
|
-
for (const pending of session.pendingToolApprovals.values()) {
|
|
851
|
-
if (pending.toolName === toolName) {
|
|
852
|
-
console.log(`[control_request] auto-approving ${toolName} (PreToolUse hook already handling approval)`);
|
|
853
|
-
cp.sendControlResponse(requestId, 'allow');
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
858
|
-
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
859
|
-
const options = [
|
|
860
|
-
{ label: 'Allow', value: 'allow' },
|
|
861
|
-
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
862
|
-
{ label: 'Deny', value: 'deny' },
|
|
863
|
-
];
|
|
864
|
-
const promptMsg = {
|
|
865
|
-
type: 'prompt',
|
|
866
|
-
promptType: 'permission',
|
|
867
|
-
question,
|
|
868
|
-
options,
|
|
869
|
-
toolName,
|
|
870
|
-
toolInput,
|
|
871
|
-
requestId,
|
|
872
|
-
};
|
|
873
|
-
session.pendingControlRequests.set(requestId, { requestId, toolName, toolInput, promptMsg });
|
|
874
|
-
if (session.clients.size > 0) {
|
|
875
|
-
this.broadcast(session, promptMsg);
|
|
876
|
-
}
|
|
877
|
-
else {
|
|
878
|
-
console.log(`[control_request] no clients connected, waiting for client to join: ${toolName}`);
|
|
879
|
-
this._globalBroadcast?.({
|
|
880
|
-
...promptMsg,
|
|
881
|
-
sessionId,
|
|
882
|
-
sessionName: session.name,
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
886
|
-
for (const listener of this._promptListeners) {
|
|
887
|
-
try {
|
|
888
|
-
listener(sessionId, 'permission', toolName, requestId);
|
|
889
|
-
}
|
|
890
|
-
catch { /* listener error */ }
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
847
|
/**
|
|
894
848
|
* Handle a Claude process 'result' event: update session state, apply API
|
|
895
849
|
* retry logic for transient errors, broadcast result to clients, and trigger
|
|
@@ -939,15 +893,20 @@ export class SessionManager {
|
|
|
939
893
|
handleApiRetry(session, sessionId, result) {
|
|
940
894
|
if (!session._lastUserInput || !this.isRetryableApiError(result)) {
|
|
941
895
|
session._apiRetryCount = 0;
|
|
896
|
+
session._apiRetryScheduled = false;
|
|
942
897
|
return false;
|
|
943
898
|
}
|
|
944
899
|
// Skip retry if the original input is older than 60 seconds — context has likely moved on
|
|
945
900
|
if (session._lastUserInputAt && Date.now() - session._lastUserInputAt > 60_000) {
|
|
946
901
|
console.log(`[api-retry] skipping stale retry for session=${sessionId} (input age=${Math.round((Date.now() - session._lastUserInputAt) / 1000)}s)`);
|
|
947
902
|
session._apiRetryCount = 0;
|
|
903
|
+
session._apiRetryScheduled = false;
|
|
948
904
|
return false;
|
|
949
905
|
}
|
|
950
906
|
if (session._apiRetryCount < MAX_API_RETRIES) {
|
|
907
|
+
// Prevent duplicate scheduling from concurrent error paths
|
|
908
|
+
if (session._apiRetryScheduled)
|
|
909
|
+
return true;
|
|
951
910
|
session._apiRetryCount++;
|
|
952
911
|
const attempt = session._apiRetryCount;
|
|
953
912
|
const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
@@ -961,8 +920,10 @@ export class SessionManager {
|
|
|
961
920
|
console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
|
|
962
921
|
if (session._apiRetryTimer)
|
|
963
922
|
clearTimeout(session._apiRetryTimer);
|
|
923
|
+
session._apiRetryScheduled = true;
|
|
964
924
|
session._apiRetryTimer = setTimeout(() => {
|
|
965
925
|
session._apiRetryTimer = undefined;
|
|
926
|
+
session._apiRetryScheduled = false;
|
|
966
927
|
if (!session.claudeProcess?.isAlive() || session._stoppedByUser)
|
|
967
928
|
return;
|
|
968
929
|
console.log(`[api-retry] resending message for session=${sessionId} attempt=${attempt}`);
|
|
@@ -979,6 +940,7 @@ export class SessionManager {
|
|
|
979
940
|
this.addToHistory(session, exhaustedMsg);
|
|
980
941
|
this.broadcast(session, exhaustedMsg);
|
|
981
942
|
session._apiRetryCount = 0;
|
|
943
|
+
session._apiRetryScheduled = false;
|
|
982
944
|
return false;
|
|
983
945
|
}
|
|
984
946
|
/**
|
|
@@ -987,6 +949,7 @@ export class SessionManager {
|
|
|
987
949
|
*/
|
|
988
950
|
finalizeResult(session, sessionId, result, isError) {
|
|
989
951
|
session._apiRetryCount = 0;
|
|
952
|
+
session._apiRetryScheduled = false;
|
|
990
953
|
session._lastUserInput = undefined;
|
|
991
954
|
session._lastUserInputAt = undefined;
|
|
992
955
|
if (isError) {
|
|
@@ -1020,103 +983,9 @@ export class SessionManager {
|
|
|
1020
983
|
void this.sessionNaming.executeSessionNaming(sessionId);
|
|
1021
984
|
}
|
|
1022
985
|
}
|
|
1023
|
-
/**
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
*
|
|
1027
|
-
* Uses evaluateRestart() for the restart decision, keeping this method focused
|
|
1028
|
-
* on state updates, listener notification, and message broadcasting.
|
|
1029
|
-
*/
|
|
1030
|
-
handleClaudeExit(exitedProcess, session, sessionId, code, signal) {
|
|
1031
|
-
session.claudeProcess = null;
|
|
1032
|
-
session.isProcessing = false;
|
|
1033
|
-
session.planManager.reset();
|
|
1034
|
-
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
1035
|
-
// "Session ID is already in use" means another process holds the lock.
|
|
1036
|
-
// Retrying with the same session ID will fail every time, so treat this
|
|
1037
|
-
// as a non-restartable exit (same as stopped-by-user).
|
|
1038
|
-
const sessionConflict = exitedProcess.hasSessionConflict();
|
|
1039
|
-
// If the process exited without ever producing stdout output, --resume
|
|
1040
|
-
// hung on a broken/stale session. Clear claudeSessionId so the next
|
|
1041
|
-
// restart attempt uses a fresh session instead of retrying the same
|
|
1042
|
-
// broken resume — which would just hang again.
|
|
1043
|
-
if (!exitedProcess.hadOutput() && session.claudeSessionId) {
|
|
1044
|
-
console.warn(`[restart] Session ${sessionId} produced no output before exit — clearing claudeSessionId to force fresh session`);
|
|
1045
|
-
session.claudeSessionId = null;
|
|
1046
|
-
}
|
|
1047
|
-
const action = evaluateRestart({
|
|
1048
|
-
restartCount: session.restartCount,
|
|
1049
|
-
lastRestartAt: session.lastRestartAt,
|
|
1050
|
-
stoppedByUser: session._stoppedByUser || sessionConflict,
|
|
1051
|
-
});
|
|
1052
|
-
if (action.kind === 'stopped_by_user') {
|
|
1053
|
-
for (const listener of this._exitListeners) {
|
|
1054
|
-
try {
|
|
1055
|
-
listener(sessionId, code, signal, false);
|
|
1056
|
-
}
|
|
1057
|
-
catch { /* listener error */ }
|
|
1058
|
-
}
|
|
1059
|
-
const text = sessionConflict
|
|
1060
|
-
? 'Claude process exited: session ID is already in use by another process. Please restart manually.'
|
|
1061
|
-
: `Claude process exited: code=${code}, signal=${signal}`;
|
|
1062
|
-
const msg = { type: 'system_message', subtype: 'exit', text };
|
|
1063
|
-
this.addToHistory(session, msg);
|
|
1064
|
-
this.broadcast(session, msg);
|
|
1065
|
-
this.broadcast(session, { type: 'exit', code: code ?? -1, signal });
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
if (action.kind === 'restart') {
|
|
1069
|
-
session.restartCount = action.updatedCount;
|
|
1070
|
-
session.lastRestartAt = action.updatedLastRestartAt;
|
|
1071
|
-
for (const listener of this._exitListeners) {
|
|
1072
|
-
try {
|
|
1073
|
-
listener(sessionId, code, signal, true);
|
|
1074
|
-
}
|
|
1075
|
-
catch { /* listener error */ }
|
|
1076
|
-
}
|
|
1077
|
-
const msg = {
|
|
1078
|
-
type: 'system_message',
|
|
1079
|
-
subtype: 'restart',
|
|
1080
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Restarting (attempt ${action.attempt}/${action.maxAttempts})...`,
|
|
1081
|
-
};
|
|
1082
|
-
this.addToHistory(session, msg);
|
|
1083
|
-
this.broadcast(session, msg);
|
|
1084
|
-
setTimeout(() => {
|
|
1085
|
-
// Verify session still exists and hasn't been stopped
|
|
1086
|
-
if (!this.sessions.has(sessionId) || session._stoppedByUser)
|
|
1087
|
-
return;
|
|
1088
|
-
// startClaude uses --resume when claudeSessionId exists, so the CLI
|
|
1089
|
-
// picks up the full conversation history from the JSONL automatically.
|
|
1090
|
-
this.startClaude(sessionId);
|
|
1091
|
-
// Fallback: if claudeSessionId was already null (fresh session that
|
|
1092
|
-
// crashed before system_init), inject a context summary so the new
|
|
1093
|
-
// session has some awareness of prior conversation.
|
|
1094
|
-
if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
|
|
1095
|
-
session.claudeProcess.once('system_init', () => {
|
|
1096
|
-
const context = this.buildSessionContext(session);
|
|
1097
|
-
if (context) {
|
|
1098
|
-
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.]');
|
|
1099
|
-
}
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
}, action.delayMs);
|
|
1103
|
-
return;
|
|
1104
|
-
}
|
|
1105
|
-
// action.kind === 'exhausted'
|
|
1106
|
-
for (const listener of this._exitListeners) {
|
|
1107
|
-
try {
|
|
1108
|
-
listener(sessionId, code, signal, false);
|
|
1109
|
-
}
|
|
1110
|
-
catch { /* listener error */ }
|
|
1111
|
-
}
|
|
1112
|
-
const msg = {
|
|
1113
|
-
type: 'system_message',
|
|
1114
|
-
subtype: 'error',
|
|
1115
|
-
text: `Claude process exited unexpectedly (code=${code}, signal=${signal}). Auto-restart disabled after ${action.maxAttempts} attempts. Please restart manually.`,
|
|
1116
|
-
};
|
|
1117
|
-
this.addToHistory(session, msg);
|
|
1118
|
-
this.broadcast(session, msg);
|
|
1119
|
-
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);
|
|
1120
989
|
}
|
|
1121
990
|
/**
|
|
1122
991
|
* Send user input to a session's Claude process.
|
|
@@ -1172,436 +1041,19 @@ export class SessionManager {
|
|
|
1172
1041
|
session.claudeProcess?.sendMessage(data);
|
|
1173
1042
|
}
|
|
1174
1043
|
/**
|
|
1175
|
-
* Route a user's prompt response to the correct handler
|
|
1176
|
-
*
|
|
1177
|
-
* fallback path), or plain message fallback.
|
|
1044
|
+
* Route a user's prompt response to the correct handler.
|
|
1045
|
+
* Delegates to PromptRouter.
|
|
1178
1046
|
*/
|
|
1179
1047
|
sendPromptResponse(sessionId, value, requestId) {
|
|
1180
|
-
|
|
1181
|
-
if (!session)
|
|
1182
|
-
return;
|
|
1183
|
-
session._lastActivityAt = Date.now();
|
|
1184
|
-
// ExitPlanMode approvals are handled through the normal pendingToolApprovals
|
|
1185
|
-
// path (routed via the PreToolUse hook). No special plan_review_ prefix needed.
|
|
1186
|
-
// Check for pending tool approval from PreToolUse hook
|
|
1187
|
-
if (!requestId) {
|
|
1188
|
-
const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
|
|
1189
|
-
if (totalPending === 1) {
|
|
1190
|
-
// Exactly one pending prompt — safe to infer the target
|
|
1191
|
-
const soleApproval = session.pendingToolApprovals.size === 1
|
|
1192
|
-
? session.pendingToolApprovals.values().next().value
|
|
1193
|
-
: undefined;
|
|
1194
|
-
if (soleApproval) {
|
|
1195
|
-
console.warn(`[prompt_response] no requestId, routing to sole pending tool approval: ${soleApproval.toolName}`);
|
|
1196
|
-
this.resolveToolApproval(session, soleApproval, value);
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
const soleControl = session.pendingControlRequests.size === 1
|
|
1200
|
-
? session.pendingControlRequests.values().next().value
|
|
1201
|
-
: undefined;
|
|
1202
|
-
if (soleControl) {
|
|
1203
|
-
console.warn(`[prompt_response] no requestId, routing to sole pending control request: ${soleControl.toolName}`);
|
|
1204
|
-
requestId = soleControl.requestId;
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
else if (totalPending > 1) {
|
|
1208
|
-
console.warn(`[prompt_response] no requestId with ${totalPending} pending prompts — rejecting to prevent misrouted response`);
|
|
1209
|
-
this.broadcast(session, {
|
|
1210
|
-
type: 'system_message',
|
|
1211
|
-
subtype: 'error',
|
|
1212
|
-
text: 'Prompt response could not be routed: multiple prompts pending. Please refresh and try again.',
|
|
1213
|
-
});
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
else {
|
|
1217
|
-
console.warn(`[prompt_response] no requestId, no pending prompts — forwarding as user message`);
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
const approval = requestId ? session.pendingToolApprovals.get(requestId) : undefined;
|
|
1221
|
-
if (approval) {
|
|
1222
|
-
this.resolveToolApproval(session, approval, value);
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
if (!session.claudeProcess?.isAlive())
|
|
1226
|
-
return;
|
|
1227
|
-
// Find matching pending control request
|
|
1228
|
-
const pending = requestId ? session.pendingControlRequests.get(requestId) : undefined;
|
|
1229
|
-
if (pending) {
|
|
1230
|
-
session.pendingControlRequests.delete(pending.requestId);
|
|
1231
|
-
// Dismiss prompt on all other clients viewing this session
|
|
1232
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: pending.requestId });
|
|
1233
|
-
if (pending.toolName === 'AskUserQuestion') {
|
|
1234
|
-
this.handleAskUserQuestion(session, pending, value);
|
|
1235
|
-
}
|
|
1236
|
-
else {
|
|
1237
|
-
this.sendControlResponseForRequest(session, pending, value);
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
else {
|
|
1241
|
-
// Fallback: no pending control request, send as plain user message
|
|
1242
|
-
const answer = Array.isArray(value) ? value.join(', ') : value;
|
|
1243
|
-
session.claudeProcess.sendMessage(answer);
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
/** Decode the allow/deny/always/pattern intent from a prompt response value. */
|
|
1247
|
-
decodeApprovalValue(value) {
|
|
1248
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
1249
|
-
return {
|
|
1250
|
-
isDeny: first === 'deny',
|
|
1251
|
-
isAlwaysAllow: first === 'always_allow',
|
|
1252
|
-
isApprovePattern: first === 'approve_pattern',
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
/** Resolve a pending PreToolUse hook approval and update auto-approval registries. */
|
|
1256
|
-
resolveToolApproval(session, approval, value) {
|
|
1257
|
-
// AskUserQuestion: the value IS the user's answer, not a permission decision
|
|
1258
|
-
if (approval.toolName === 'AskUserQuestion') {
|
|
1259
|
-
const answer = Array.isArray(value) ? value.join(', ') : value;
|
|
1260
|
-
console.log(`[tool-approval] resolving AskUserQuestion: answer=${answer.slice(0, 100)}`);
|
|
1261
|
-
approval.resolve({ allow: true, always: false, answer });
|
|
1262
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1263
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
// ExitPlanMode: route through PlanManager for state tracking.
|
|
1267
|
-
// The hook will convert allow→deny-with-approval-message (CLI workaround).
|
|
1268
|
-
if (approval.toolName === 'ExitPlanMode') {
|
|
1269
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
1270
|
-
const isDeny = first === 'deny';
|
|
1271
|
-
if (isDeny) {
|
|
1272
|
-
// Extract feedback text if present (value may be ['deny', 'feedback text'])
|
|
1273
|
-
const feedback = Array.isArray(value) && value.length > 1 ? value[1] : undefined;
|
|
1274
|
-
const reason = session.planManager.deny(approval.requestId, feedback);
|
|
1275
|
-
console.log(`[plan-approval] denied: ${reason}`);
|
|
1276
|
-
approval.resolve({ allow: false, always: false, answer: reason || undefined });
|
|
1277
|
-
}
|
|
1278
|
-
else {
|
|
1279
|
-
session.planManager.approve(approval.requestId);
|
|
1280
|
-
console.log(`[plan-approval] approved`);
|
|
1281
|
-
approval.resolve({ allow: true, always: false });
|
|
1282
|
-
}
|
|
1283
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1284
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
1288
|
-
if (isAlwaysAllow && !isDeny) {
|
|
1289
|
-
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
1290
|
-
}
|
|
1291
|
-
if (isApprovePattern && !isDeny) {
|
|
1292
|
-
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, approval.toolName, approval.toolInput);
|
|
1293
|
-
}
|
|
1294
|
-
console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
|
|
1295
|
-
approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
|
|
1296
|
-
session.pendingToolApprovals.delete(approval.requestId);
|
|
1297
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approval.requestId });
|
|
1298
|
-
}
|
|
1299
|
-
/**
|
|
1300
|
-
* Send an AskUserQuestion control response, mapping the user's answer(s) into
|
|
1301
|
-
* the structured answers map the tool expects.
|
|
1302
|
-
*/
|
|
1303
|
-
handleAskUserQuestion(session, pending, value) {
|
|
1304
|
-
const questions = pending.toolInput?.questions;
|
|
1305
|
-
const updatedInput = { ...pending.toolInput };
|
|
1306
|
-
let answers = {};
|
|
1307
|
-
if (typeof value === 'string') {
|
|
1308
|
-
// Try parsing as JSON answers map (multi-question flow)
|
|
1309
|
-
try {
|
|
1310
|
-
const parsed = JSON.parse(value);
|
|
1311
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1312
|
-
answers = parsed;
|
|
1313
|
-
}
|
|
1314
|
-
else if (Array.isArray(questions) && questions.length > 0) {
|
|
1315
|
-
answers[questions[0].question] = value;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
catch {
|
|
1319
|
-
// Plain string answer — map to first question
|
|
1320
|
-
if (Array.isArray(questions) && questions.length > 0) {
|
|
1321
|
-
answers[questions[0].question] = value;
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
else if (Array.isArray(value) && Array.isArray(questions) && questions.length > 0) {
|
|
1326
|
-
// Array of answers — map to first question (multi-select single question)
|
|
1327
|
-
answers[questions[0].question] = value.join(', ');
|
|
1328
|
-
}
|
|
1329
|
-
updatedInput.answers = answers;
|
|
1330
|
-
session.claudeProcess.sendControlResponse(pending.requestId, 'allow', updatedInput);
|
|
1331
|
-
}
|
|
1332
|
-
/** Send a permission control response (allow/always_allow/approve_pattern/deny). */
|
|
1333
|
-
sendControlResponseForRequest(session, pending, value) {
|
|
1334
|
-
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
1335
|
-
if (isAlwaysAllow) {
|
|
1336
|
-
this._approvalManager.saveAlwaysAllow(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
1337
|
-
}
|
|
1338
|
-
if (isApprovePattern) {
|
|
1339
|
-
this._approvalManager.savePatternApproval(session.groupDir ?? session.workingDir, pending.toolName, pending.toolInput);
|
|
1340
|
-
}
|
|
1341
|
-
const behavior = isDeny ? 'deny' : 'allow';
|
|
1342
|
-
session.claudeProcess.sendControlResponse(pending.requestId, behavior);
|
|
1048
|
+
this.promptRouter.sendPromptResponse(sessionId, value, requestId);
|
|
1343
1049
|
}
|
|
1344
1050
|
/**
|
|
1345
1051
|
* Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
|
|
1346
1052
|
* and returns a Promise that resolves when the user approves/denies.
|
|
1053
|
+
* Delegates to PromptRouter.
|
|
1347
1054
|
*/
|
|
1348
1055
|
requestToolApproval(sessionId, toolName, toolInput) {
|
|
1349
|
-
|
|
1350
|
-
if (!session) {
|
|
1351
|
-
console.log(`[tool-approval] session not found: ${sessionId}`);
|
|
1352
|
-
return Promise.resolve({ allow: false, always: false });
|
|
1353
|
-
}
|
|
1354
|
-
const autoResult = this.resolveAutoApproval(session, toolName, toolInput);
|
|
1355
|
-
if (autoResult === 'registry') {
|
|
1356
|
-
console.log(`[tool-approval] auto-approved (registry): ${toolName}`);
|
|
1357
|
-
return Promise.resolve({ allow: true, always: true });
|
|
1358
|
-
}
|
|
1359
|
-
if (autoResult === 'session') {
|
|
1360
|
-
console.log(`[tool-approval] auto-approved (session allowedTools): ${toolName}`);
|
|
1361
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1362
|
-
}
|
|
1363
|
-
if (autoResult === 'headless') {
|
|
1364
|
-
console.log(`[tool-approval] auto-approved (headless ${session.source}): ${toolName}`);
|
|
1365
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1366
|
-
}
|
|
1367
|
-
console.log(`[tool-approval] requesting approval: session=${sessionId} tool=${toolName} clients=${session.clients.size}`);
|
|
1368
|
-
// ExitPlanMode: route through PlanManager state machine for plan-specific
|
|
1369
|
-
// approval UI. The hook blocks until we resolve the promise.
|
|
1370
|
-
if (toolName === 'ExitPlanMode') {
|
|
1371
|
-
return this.handleExitPlanModeApproval(session, sessionId);
|
|
1372
|
-
}
|
|
1373
|
-
// Prevent double-gating: if a control_request already created a pending
|
|
1374
|
-
// entry for this tool, auto-approve the control_request and let the hook
|
|
1375
|
-
// take over as the sole approval gate. This is the reverse of the check
|
|
1376
|
-
// in onControlRequestEvent (which handles hook-first ordering).
|
|
1377
|
-
for (const [reqId, pending] of session.pendingControlRequests) {
|
|
1378
|
-
if (pending.toolName === toolName) {
|
|
1379
|
-
console.log(`[tool-approval] auto-approving control_request for ${toolName} (PreToolUse hook taking over)`);
|
|
1380
|
-
session.claudeProcess?.sendControlResponse(reqId, 'allow');
|
|
1381
|
-
session.pendingControlRequests.delete(reqId);
|
|
1382
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
|
|
1383
|
-
break;
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
// AskUserQuestion: show a question prompt and collect the answer text,
|
|
1387
|
-
// rather than a permission prompt with Allow/Deny buttons.
|
|
1388
|
-
const isQuestion = toolName === 'AskUserQuestion';
|
|
1389
|
-
return new Promise((resolve) => {
|
|
1390
|
-
// Holder lets wrappedResolve reference the timeout before it's assigned
|
|
1391
|
-
const timer = { id: null };
|
|
1392
|
-
const wrappedResolve = (result) => {
|
|
1393
|
-
if (timer.id)
|
|
1394
|
-
clearTimeout(timer.id);
|
|
1395
|
-
resolve(result);
|
|
1396
|
-
};
|
|
1397
|
-
const approvalRequestId = randomUUID();
|
|
1398
|
-
// Timeout to prevent leaked promises if client disconnects after prompt is sent
|
|
1399
|
-
timer.id = setTimeout(() => {
|
|
1400
|
-
if (session.pendingToolApprovals.has(approvalRequestId)) {
|
|
1401
|
-
console.log(`[tool-approval] timed out for ${toolName}`);
|
|
1402
|
-
session.pendingToolApprovals.delete(approvalRequestId);
|
|
1403
|
-
// Dismiss the stale prompt in all clients so they don't inject
|
|
1404
|
-
// "allow"/"deny" as plain text after the timeout
|
|
1405
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: approvalRequestId });
|
|
1406
|
-
resolve({ allow: false, always: false });
|
|
1407
|
-
}
|
|
1408
|
-
}, 300_000); // 5 min for all approval types — prevents premature auto-deny when user is reading or tabbed away
|
|
1409
|
-
let promptMsg;
|
|
1410
|
-
if (isQuestion) {
|
|
1411
|
-
// AskUserQuestion: extract structured questions from toolInput.questions
|
|
1412
|
-
// and pass them through so PromptButtons can render the multi-question flow.
|
|
1413
|
-
const rawQuestions = toolInput.questions;
|
|
1414
|
-
const structuredQuestions = Array.isArray(rawQuestions)
|
|
1415
|
-
? rawQuestions.map(q => ({
|
|
1416
|
-
question: q.question,
|
|
1417
|
-
header: q.header,
|
|
1418
|
-
multiSelect: q.multiSelect ?? false,
|
|
1419
|
-
options: (q.options || []).map((opt) => ({
|
|
1420
|
-
label: opt.label,
|
|
1421
|
-
value: opt.value ?? opt.label,
|
|
1422
|
-
description: opt.description,
|
|
1423
|
-
})),
|
|
1424
|
-
}))
|
|
1425
|
-
: undefined;
|
|
1426
|
-
const firstQ = structuredQuestions?.[0];
|
|
1427
|
-
promptMsg = {
|
|
1428
|
-
type: 'prompt',
|
|
1429
|
-
promptType: 'question',
|
|
1430
|
-
question: firstQ?.question || 'Answer the question',
|
|
1431
|
-
options: firstQ?.options || [],
|
|
1432
|
-
multiSelect: firstQ?.multiSelect,
|
|
1433
|
-
toolName,
|
|
1434
|
-
toolInput,
|
|
1435
|
-
requestId: approvalRequestId,
|
|
1436
|
-
...(structuredQuestions ? { questions: structuredQuestions } : {}),
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
else {
|
|
1440
|
-
const question = this.summarizeToolPermission(toolName, toolInput);
|
|
1441
|
-
const approvePattern = this._approvalManager.derivePattern(toolName, toolInput);
|
|
1442
|
-
const neverAutoApprove = ApprovalManager.NEVER_AUTO_APPROVE_TOOLS.has(toolName);
|
|
1443
|
-
const options = [
|
|
1444
|
-
{ label: 'Allow', value: 'allow' },
|
|
1445
|
-
...(!neverAutoApprove ? [{ label: 'Always Allow', value: 'always_allow' }] : []),
|
|
1446
|
-
{ label: 'Deny', value: 'deny' },
|
|
1447
|
-
];
|
|
1448
|
-
promptMsg = {
|
|
1449
|
-
type: 'prompt',
|
|
1450
|
-
promptType: 'permission',
|
|
1451
|
-
question,
|
|
1452
|
-
options,
|
|
1453
|
-
toolName,
|
|
1454
|
-
toolInput,
|
|
1455
|
-
requestId: approvalRequestId,
|
|
1456
|
-
...(approvePattern ? { approvePattern } : {}),
|
|
1457
|
-
};
|
|
1458
|
-
}
|
|
1459
|
-
session.pendingToolApprovals.set(approvalRequestId, { resolve: wrappedResolve, toolName, toolInput, requestId: approvalRequestId, promptMsg });
|
|
1460
|
-
if (session.clients.size > 0) {
|
|
1461
|
-
this.broadcast(session, promptMsg);
|
|
1462
|
-
}
|
|
1463
|
-
else {
|
|
1464
|
-
// No clients connected — DON'T auto-deny. Instead, wait for a client
|
|
1465
|
-
// to join this session (the prompt will be re-broadcast in join()).
|
|
1466
|
-
// Send a global notification so the user sees a waiting indicator.
|
|
1467
|
-
console.log(`[tool-approval] no clients connected, waiting for client to join (timeout 300s): ${toolName}`);
|
|
1468
|
-
this._globalBroadcast?.({
|
|
1469
|
-
...promptMsg,
|
|
1470
|
-
sessionId,
|
|
1471
|
-
sessionName: session.name,
|
|
1472
|
-
});
|
|
1473
|
-
}
|
|
1474
|
-
// Notify prompt listeners (orchestrator, child monitor, etc.)
|
|
1475
|
-
for (const listener of this._promptListeners) {
|
|
1476
|
-
try {
|
|
1477
|
-
listener(sessionId, isQuestion ? 'question' : 'permission', toolName, approvalRequestId);
|
|
1478
|
-
}
|
|
1479
|
-
catch { /* listener error */ }
|
|
1480
|
-
}
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
/**
|
|
1484
|
-
* Handle ExitPlanMode approval through PlanManager.
|
|
1485
|
-
* Shows a plan-specific approval prompt (Approve/Reject) and blocks the hook
|
|
1486
|
-
* until the user responds. On approve, returns allow:true (the hook will use
|
|
1487
|
-
* the deny-with-approval-message workaround). On deny, returns allow:false.
|
|
1488
|
-
*/
|
|
1489
|
-
handleExitPlanModeApproval(session, sessionId) {
|
|
1490
|
-
const reviewId = session.planManager.onExitPlanModeRequested();
|
|
1491
|
-
if (!reviewId) {
|
|
1492
|
-
// Not in planning state — fall through to allow (CLI handles natively)
|
|
1493
|
-
console.log(`[plan-approval] ExitPlanMode but PlanManager not in planning state, allowing`);
|
|
1494
|
-
return Promise.resolve({ allow: true, always: false });
|
|
1495
|
-
}
|
|
1496
|
-
return new Promise((resolve) => {
|
|
1497
|
-
const timer = { id: null };
|
|
1498
|
-
const wrappedResolve = (result) => {
|
|
1499
|
-
if (timer.id)
|
|
1500
|
-
clearTimeout(timer.id);
|
|
1501
|
-
resolve(result);
|
|
1502
|
-
};
|
|
1503
|
-
// Timeout: auto-deny after 5 minutes to prevent leaked promises
|
|
1504
|
-
timer.id = setTimeout(() => {
|
|
1505
|
-
if (session.pendingToolApprovals.has(reviewId)) {
|
|
1506
|
-
console.log(`[plan-approval] timed out, auto-denying`);
|
|
1507
|
-
session.pendingToolApprovals.delete(reviewId);
|
|
1508
|
-
session.planManager.deny(reviewId);
|
|
1509
|
-
this.broadcast(session, { type: 'prompt_dismiss', requestId: reviewId });
|
|
1510
|
-
resolve({ allow: false, always: false });
|
|
1511
|
-
}
|
|
1512
|
-
}, 300_000);
|
|
1513
|
-
const promptMsg = {
|
|
1514
|
-
type: 'prompt',
|
|
1515
|
-
promptType: 'permission',
|
|
1516
|
-
question: 'Approve plan and start implementation?',
|
|
1517
|
-
options: [
|
|
1518
|
-
{ label: 'Approve', value: 'allow' },
|
|
1519
|
-
{ label: 'Reject', value: 'deny' },
|
|
1520
|
-
],
|
|
1521
|
-
toolName: 'ExitPlanMode',
|
|
1522
|
-
requestId: reviewId,
|
|
1523
|
-
};
|
|
1524
|
-
session.pendingToolApprovals.set(reviewId, {
|
|
1525
|
-
resolve: wrappedResolve,
|
|
1526
|
-
toolName: 'ExitPlanMode',
|
|
1527
|
-
toolInput: {},
|
|
1528
|
-
requestId: reviewId,
|
|
1529
|
-
promptMsg,
|
|
1530
|
-
});
|
|
1531
|
-
this.broadcast(session, promptMsg);
|
|
1532
|
-
if (session.clients.size === 0) {
|
|
1533
|
-
this._globalBroadcast?.({ ...promptMsg, sessionId, sessionName: session.name });
|
|
1534
|
-
}
|
|
1535
|
-
for (const listener of this._promptListeners) {
|
|
1536
|
-
try {
|
|
1537
|
-
listener(sessionId, 'permission', 'ExitPlanMode', reviewId);
|
|
1538
|
-
}
|
|
1539
|
-
catch { /* listener error */ }
|
|
1540
|
-
}
|
|
1541
|
-
});
|
|
1542
|
-
}
|
|
1543
|
-
/**
|
|
1544
|
-
* Check if a tool invocation can be auto-approved without prompting the user.
|
|
1545
|
-
* Returns 'registry' if matched by auto-approval rules, 'session' if matched
|
|
1546
|
-
* by the session's allowedTools list, 'headless' if the session has no clients
|
|
1547
|
-
* and is a non-interactive source, or 'prompt' if the user needs to decide.
|
|
1548
|
-
*/
|
|
1549
|
-
resolveAutoApproval(session, toolName, toolInput) {
|
|
1550
|
-
if (this._approvalManager.checkAutoApproval(session.groupDir ?? session.workingDir, toolName, toolInput)) {
|
|
1551
|
-
return 'registry';
|
|
1552
|
-
}
|
|
1553
|
-
if (session.allowedTools && this.matchesAllowedTools(session.allowedTools, toolName, toolInput)) {
|
|
1554
|
-
return 'session';
|
|
1555
|
-
}
|
|
1556
|
-
if (session.clients.size === 0 && (session.source === 'webhook' || session.source === 'workflow' || session.source === 'stepflow' || session.source === 'orchestrator')) {
|
|
1557
|
-
return 'headless';
|
|
1558
|
-
}
|
|
1559
|
-
return 'prompt';
|
|
1560
|
-
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Check if a tool invocation matches any of the session's allowedTools patterns.
|
|
1563
|
-
* Patterns follow Claude CLI format: 'ToolName' or 'ToolName(prefix:*)'.
|
|
1564
|
-
* Examples: 'WebFetch', 'Bash(curl:*)', 'Bash(git:*)'.
|
|
1565
|
-
*/
|
|
1566
|
-
matchesAllowedTools(allowedTools, toolName, toolInput) {
|
|
1567
|
-
for (const pattern of allowedTools) {
|
|
1568
|
-
// Simple tool name match: 'WebFetch', 'Read', etc.
|
|
1569
|
-
if (pattern === toolName)
|
|
1570
|
-
return true;
|
|
1571
|
-
// Parameterized match: 'Bash(curl:*)' → toolName=Bash, command starts with 'curl'
|
|
1572
|
-
const match = pattern.match(/^(\w+)\(([^:]+):\*\)$/);
|
|
1573
|
-
if (match) {
|
|
1574
|
-
const [, patternTool, prefix] = match;
|
|
1575
|
-
if (patternTool !== toolName)
|
|
1576
|
-
continue;
|
|
1577
|
-
// For Bash, check command prefix
|
|
1578
|
-
if (toolName === 'Bash') {
|
|
1579
|
-
const cmd = String(toolInput.command || '').trimStart();
|
|
1580
|
-
if (cmd === prefix || cmd.startsWith(prefix + ' '))
|
|
1581
|
-
return true;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
return false;
|
|
1586
|
-
}
|
|
1587
|
-
/** Build a human-readable prompt string for a tool permission dialog. */
|
|
1588
|
-
summarizeToolPermission(toolName, toolInput) {
|
|
1589
|
-
switch (toolName) {
|
|
1590
|
-
case 'Bash': {
|
|
1591
|
-
const cmd = String(toolInput.command || '');
|
|
1592
|
-
const firstLine = cmd.split('\n')[0];
|
|
1593
|
-
const display = firstLine.length < cmd.length ? `${firstLine}...` : cmd;
|
|
1594
|
-
return `Allow Bash? \`$ ${display}\``;
|
|
1595
|
-
}
|
|
1596
|
-
case 'Task':
|
|
1597
|
-
return `Allow Task? ${String(toolInput.description || toolName)}`;
|
|
1598
|
-
case 'Read': {
|
|
1599
|
-
const filePath = String(toolInput.file_path || '');
|
|
1600
|
-
return `Allow Read? \`${filePath}\``;
|
|
1601
|
-
}
|
|
1602
|
-
default:
|
|
1603
|
-
return `Allow ${toolName}?`;
|
|
1604
|
-
}
|
|
1056
|
+
return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
|
|
1605
1057
|
}
|
|
1606
1058
|
/** Update the model for a session and restart Claude with the new model. */
|
|
1607
1059
|
setModel(sessionId, model) {
|
|
@@ -1610,15 +1062,16 @@ export class SessionManager {
|
|
|
1610
1062
|
return false;
|
|
1611
1063
|
session.model = model || undefined;
|
|
1612
1064
|
this.persistToDiskDebounced();
|
|
1613
|
-
// 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.
|
|
1614
1068
|
if (session.claudeProcess?.isAlive()) {
|
|
1615
|
-
this.
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1069
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1070
|
+
if (this.sessions.has(sessionId)) {
|
|
1071
|
+
session._stoppedByUser = false;
|
|
1619
1072
|
this.startClaude(sessionId);
|
|
1620
1073
|
}
|
|
1621
|
-
}
|
|
1074
|
+
});
|
|
1622
1075
|
}
|
|
1623
1076
|
return true;
|
|
1624
1077
|
}
|
|
@@ -1639,49 +1092,29 @@ export class SessionManager {
|
|
|
1639
1092
|
const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
|
|
1640
1093
|
this.addToHistory(session, sysMsg);
|
|
1641
1094
|
this.broadcast(session, sysMsg);
|
|
1642
|
-
// 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.
|
|
1643
1098
|
if (session.claudeProcess?.isAlive()) {
|
|
1644
|
-
this.
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1099
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1100
|
+
if (this.sessions.has(sessionId)) {
|
|
1101
|
+
session._stoppedByUser = false;
|
|
1648
1102
|
this.startClaude(sessionId);
|
|
1649
1103
|
}
|
|
1650
|
-
}
|
|
1104
|
+
});
|
|
1651
1105
|
}
|
|
1652
1106
|
return true;
|
|
1653
1107
|
}
|
|
1108
|
+
/** Stop the Claude process for a session. Delegates to SessionLifecycle. */
|
|
1654
1109
|
stopClaude(sessionId) {
|
|
1655
|
-
|
|
1656
|
-
if (session?.claudeProcess) {
|
|
1657
|
-
session._stoppedByUser = true;
|
|
1658
|
-
if (session._apiRetryTimer)
|
|
1659
|
-
clearTimeout(session._apiRetryTimer);
|
|
1660
|
-
session.claudeProcess.removeAllListeners();
|
|
1661
|
-
session.claudeProcess.stop();
|
|
1662
|
-
session.claudeProcess = null;
|
|
1663
|
-
this.broadcast(session, { type: 'claude_stopped' });
|
|
1664
|
-
}
|
|
1110
|
+
this.sessionLifecycle.stopClaude(sessionId);
|
|
1665
1111
|
}
|
|
1666
1112
|
/**
|
|
1667
1113
|
* Stop the Claude process and wait for it to fully exit before resolving.
|
|
1668
|
-
*
|
|
1669
|
-
* (e.g. during mid-session worktree migration).
|
|
1114
|
+
* Delegates to SessionLifecycle.
|
|
1670
1115
|
*/
|
|
1671
1116
|
async stopClaudeAndWait(sessionId) {
|
|
1672
|
-
|
|
1673
|
-
if (!session?.claudeProcess)
|
|
1674
|
-
return;
|
|
1675
|
-
const cp = session.claudeProcess;
|
|
1676
|
-
session._stoppedByUser = true;
|
|
1677
|
-
if (session._apiRetryTimer)
|
|
1678
|
-
clearTimeout(session._apiRetryTimer);
|
|
1679
|
-
cp.removeAllListeners();
|
|
1680
|
-
cp.stop();
|
|
1681
|
-
session.claudeProcess = null;
|
|
1682
|
-
this.broadcast(session, { type: 'claude_stopped' });
|
|
1683
|
-
// Wait for the underlying OS process to fully exit
|
|
1684
|
-
await cp.waitForExit();
|
|
1117
|
+
return this.sessionLifecycle.stopClaudeAndWait(sessionId);
|
|
1685
1118
|
}
|
|
1686
1119
|
// ---------------------------------------------------------------------------
|
|
1687
1120
|
// Helpers
|