codekin 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/assets/index-BvKzbtKg.css +1 -0
- package/dist/assets/index-D59Xr9pK.js +182 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/dist/approval-manager.js +2 -3
- package/server/dist/approval-manager.js.map +1 -1
- package/server/dist/claude-process.d.ts +17 -5
- package/server/dist/claude-process.js +56 -66
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/coding-process.d.ts +83 -0
- package/server/dist/coding-process.js +32 -0
- package/server/dist/coding-process.js.map +1 -0
- package/server/dist/commit-event-handler.js +1 -0
- package/server/dist/commit-event-handler.js.map +1 -1
- package/server/dist/config.js +2 -1
- package/server/dist/config.js.map +1 -1
- package/server/dist/docs-routes.js +22 -1
- package/server/dist/docs-routes.js.map +1 -1
- package/server/dist/native-permissions.js +3 -2
- package/server/dist/native-permissions.js.map +1 -1
- package/server/dist/opencode-process.d.ts +104 -0
- package/server/dist/opencode-process.js +657 -0
- package/server/dist/opencode-process.js.map +1 -0
- package/server/dist/orchestrator-children.d.ts +9 -0
- package/server/dist/orchestrator-children.js +50 -7
- package/server/dist/orchestrator-children.js.map +1 -1
- package/server/dist/orchestrator-manager.js +17 -0
- package/server/dist/orchestrator-manager.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/orchestrator-routes.js +8 -1
- package/server/dist/orchestrator-routes.js.map +1 -1
- package/server/dist/prompt-router.d.ts +95 -0
- package/server/dist/prompt-router.js +589 -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 +408 -0
- package/server/dist/session-lifecycle.js.map +1 -0
- package/server/dist/session-manager.d.ts +42 -60
- package/server/dist/session-manager.js +310 -835
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-persistence.d.ts +1 -0
- package/server/dist/session-persistence.js +24 -4
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/session-routes.js +15 -1
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/stepflow-handler.d.ts +2 -2
- package/server/dist/stepflow-handler.js +4 -4
- package/server/dist/stepflow-handler.js.map +1 -1
- package/server/dist/tool-labels.d.ts +8 -0
- package/server/dist/tool-labels.js +51 -0
- package/server/dist/tool-labels.js.map +1 -0
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +27 -8
- package/server/dist/types.js +4 -1
- package/server/dist/types.js.map +1 -1
- package/server/dist/webhook-dedup.d.ts +11 -0
- package/server/dist/webhook-dedup.js +23 -0
- package/server/dist/webhook-dedup.js.map +1 -1
- package/server/dist/webhook-handler.d.ts +20 -4
- package/server/dist/webhook-handler.js +256 -20
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/webhook-pr-cache.d.ts +57 -0
- package/server/dist/webhook-pr-cache.js +95 -0
- package/server/dist/webhook-pr-cache.js.map +1 -0
- package/server/dist/webhook-pr-github.d.ts +68 -0
- package/server/dist/webhook-pr-github.js +202 -0
- package/server/dist/webhook-pr-github.js.map +1 -0
- package/server/dist/webhook-pr-prompt.d.ts +27 -0
- package/server/dist/webhook-pr-prompt.js +251 -0
- package/server/dist/webhook-pr-prompt.js.map +1 -0
- package/server/dist/webhook-types.d.ts +70 -1
- package/server/dist/webhook-workspace.js +20 -1
- package/server/dist/webhook-workspace.js.map +1 -1
- package/server/dist/workflow-config.d.ts +2 -0
- package/server/dist/workflow-config.js.map +1 -1
- package/server/dist/workflow-loader.js +3 -0
- package/server/dist/workflow-loader.js.map +1 -1
- package/server/dist/workflow-routes.js +6 -2
- package/server/dist/workflow-routes.js.map +1 -1
- package/server/dist/ws-message-handler.js +24 -4
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +10 -2
- package/server/dist/ws-server.js.map +1 -1
- package/dist/assets/index-COGLICp9.js +0 -178
- package/dist/assets/index-CjEQkT2b.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;
|
|
@@ -62,6 +62,12 @@ const API_RETRY_PATTERNS = [
|
|
|
62
62
|
/502/,
|
|
63
63
|
/503/,
|
|
64
64
|
];
|
|
65
|
+
/** Sources that represent headless (non-interactive) sessions managed by their own lifecycles. */
|
|
66
|
+
const HEADLESS_SOURCES = new Set(['webhook', 'workflow', 'stepflow', 'agent', 'orchestrator']);
|
|
67
|
+
/** Check whether a session is headless (webhook, workflow, stepflow, agent, orchestrator). */
|
|
68
|
+
function isHeadlessSession(session) {
|
|
69
|
+
return HEADLESS_SOURCES.has(session.source ?? '');
|
|
70
|
+
}
|
|
65
71
|
export class SessionManager {
|
|
66
72
|
/** All active (non-archived) sessions, keyed by session UUID. */
|
|
67
73
|
sessions = new Map();
|
|
@@ -89,12 +95,51 @@ export class SessionManager {
|
|
|
89
95
|
sessionPersistence;
|
|
90
96
|
/** Delegated diff operations (git diff, discard changes). */
|
|
91
97
|
diffManager;
|
|
98
|
+
/** Delegated prompt routing and tool approval logic. */
|
|
99
|
+
promptRouter;
|
|
100
|
+
/** Delegated Claude process lifecycle (start, stop, restart, event wiring). */
|
|
101
|
+
sessionLifecycle;
|
|
92
102
|
/** Interval handle for the idle session reaper. */
|
|
93
103
|
_idleReaperInterval = null;
|
|
94
104
|
constructor() {
|
|
95
105
|
this.archive = new SessionArchive();
|
|
96
106
|
this._approvalManager = new ApprovalManager();
|
|
97
107
|
this.diffManager = new DiffManager();
|
|
108
|
+
this.promptRouter = new PromptRouter({
|
|
109
|
+
getSession: (id) => this.sessions.get(id),
|
|
110
|
+
allSessions: () => this.sessions.values(),
|
|
111
|
+
broadcast: (session, msg) => this.broadcast(session, msg),
|
|
112
|
+
addToHistory: (session, msg) => this.addToHistory(session, msg),
|
|
113
|
+
globalBroadcast: (msg) => this._globalBroadcast?.(msg),
|
|
114
|
+
approvalManager: this._approvalManager,
|
|
115
|
+
promptListeners: this._promptListeners,
|
|
116
|
+
});
|
|
117
|
+
// Use a local ref so the getter closures capture `this` (the SessionManager instance)
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
119
|
+
const self = this;
|
|
120
|
+
this.sessionLifecycle = new SessionLifecycle({
|
|
121
|
+
getSession: (id) => this.sessions.get(id),
|
|
122
|
+
hasSession: (id) => this.sessions.has(id),
|
|
123
|
+
broadcast: (session, msg) => this.broadcast(session, msg),
|
|
124
|
+
addToHistory: (session, msg) => this.addToHistory(session, msg),
|
|
125
|
+
broadcastAndHistory: (session, msg) => this.broadcastAndHistory(session, msg),
|
|
126
|
+
persistToDisk: () => this.persistToDisk(),
|
|
127
|
+
get globalBroadcast() { return self._globalBroadcast; },
|
|
128
|
+
get authToken() { return self._authToken; },
|
|
129
|
+
get serverPort() { return self._serverPort; },
|
|
130
|
+
approvalManager: this._approvalManager,
|
|
131
|
+
promptRouter: this.promptRouter,
|
|
132
|
+
exitListeners: this._exitListeners,
|
|
133
|
+
onSystemInit: (cp, session, model) => this.onSystemInit(cp, session, model),
|
|
134
|
+
onTextEvent: (session, sessionId, text) => this.onTextEvent(session, sessionId, text),
|
|
135
|
+
onThinkingEvent: (session, summary) => this.onThinkingEvent(session, summary),
|
|
136
|
+
onToolOutputEvent: (session, content, isError) => this.onToolOutputEvent(session, content, isError),
|
|
137
|
+
onImageEvent: (session, base64, mediaType) => this.onImageEvent(session, base64, mediaType),
|
|
138
|
+
onToolActiveEvent: (session, toolName, toolInput) => this.onToolActiveEvent(session, toolName, toolInput),
|
|
139
|
+
onToolDoneEvent: (session, toolName, summary) => this.onToolDoneEvent(session, toolName, summary),
|
|
140
|
+
handleClaudeResult: (session, sessionId, result, isError) => this.handleClaudeResult(session, sessionId, result, isError),
|
|
141
|
+
buildSessionContext: (session) => this.buildSessionContext(session),
|
|
142
|
+
});
|
|
98
143
|
this.sessionPersistence = new SessionPersistence(this.sessions);
|
|
99
144
|
this.sessionNaming = new SessionNaming({
|
|
100
145
|
getSession: (id) => this.sessions.get(id),
|
|
@@ -120,7 +165,7 @@ export class SessionManager {
|
|
|
120
165
|
const now = Date.now();
|
|
121
166
|
for (const session of this.sessions.values()) {
|
|
122
167
|
// Skip headless sessions — they are managed by their own lifecycles
|
|
123
|
-
if (session
|
|
168
|
+
if (isHeadlessSession(session))
|
|
124
169
|
continue;
|
|
125
170
|
// Skip sessions with connected clients or no running process
|
|
126
171
|
if (session.clients.size > 0 || !session.claudeProcess?.isAlive())
|
|
@@ -132,6 +177,10 @@ export class SessionManager {
|
|
|
132
177
|
if (idleMs > IDLE_SESSION_TIMEOUT_MS) {
|
|
133
178
|
console.log(`[idle-reaper] stopping idle session=${session.id} name="${session.name}" idle=${Math.round(idleMs / 60_000)}min`);
|
|
134
179
|
session._stoppedByUser = true; // prevent auto-restart
|
|
180
|
+
if (session._restartTimer) {
|
|
181
|
+
clearTimeout(session._restartTimer);
|
|
182
|
+
session._restartTimer = undefined;
|
|
183
|
+
}
|
|
135
184
|
session.claudeProcess.removeAllListeners();
|
|
136
185
|
session.claudeProcess.stop();
|
|
137
186
|
session.claudeProcess = null;
|
|
@@ -143,10 +192,10 @@ export class SessionManager {
|
|
|
143
192
|
}
|
|
144
193
|
}
|
|
145
194
|
// Prune stale sessions: no process, no clients, older than STALE_SESSION_AGE_MS
|
|
146
|
-
//
|
|
195
|
+
// Headless sessions are exempt from stale pruning — they are long-lived by design.
|
|
147
196
|
const staleIds = [];
|
|
148
197
|
for (const session of this.sessions.values()) {
|
|
149
|
-
if (session
|
|
198
|
+
if (isHeadlessSession(session))
|
|
150
199
|
continue;
|
|
151
200
|
if (session.claudeProcess?.isAlive())
|
|
152
201
|
continue;
|
|
@@ -203,9 +252,11 @@ export class SessionManager {
|
|
|
203
252
|
groupDir: options?.groupDir,
|
|
204
253
|
created: new Date().toISOString(),
|
|
205
254
|
source: options?.source ?? 'manual',
|
|
255
|
+
provider: options?.provider ?? 'claude',
|
|
206
256
|
model: options?.model,
|
|
207
257
|
permissionMode: options?.permissionMode,
|
|
208
258
|
allowedTools: options?.allowedTools,
|
|
259
|
+
addDirs: options?.addDirs,
|
|
209
260
|
claudeProcess: null,
|
|
210
261
|
clients: new Set(),
|
|
211
262
|
outputHistory: [],
|
|
@@ -214,7 +265,7 @@ export class SessionManager {
|
|
|
214
265
|
lastRestartAt: null,
|
|
215
266
|
_stoppedByUser: false,
|
|
216
267
|
_wasActiveBeforeRestart: false,
|
|
217
|
-
|
|
268
|
+
_apiRetry: { count: 0 },
|
|
218
269
|
_turnCount: 0,
|
|
219
270
|
_claudeTurnCount: 0,
|
|
220
271
|
_namingAttempts: 0,
|
|
@@ -235,8 +286,16 @@ export class SessionManager {
|
|
|
235
286
|
* Create a git worktree for a session. Creates a new branch and worktree
|
|
236
287
|
* as a sibling directory of the project root.
|
|
237
288
|
* Returns the worktree path on success, or null on failure.
|
|
289
|
+
*
|
|
290
|
+
* @param targetBranch — use this as the worktree branch name instead of
|
|
291
|
+
* the default `wt/{shortId}`. The orchestrator uses this to create the
|
|
292
|
+
* worktree directly on the desired feature branch so Claude doesn't
|
|
293
|
+
* need to create a second branch.
|
|
294
|
+
* @param baseBranch — create the worktree branch from this ref (e.g.
|
|
295
|
+
* 'main'). Defaults to auto-detecting the default branch. Prevents
|
|
296
|
+
* worktrees from accidentally branching off a random HEAD.
|
|
238
297
|
*/
|
|
239
|
-
async createWorktree(sessionId, workingDir) {
|
|
298
|
+
async createWorktree(sessionId, workingDir, targetBranch, baseBranch) {
|
|
240
299
|
const session = this.sessions.get(sessionId);
|
|
241
300
|
if (!session)
|
|
242
301
|
return null;
|
|
@@ -253,23 +312,72 @@ export class SessionManager {
|
|
|
253
312
|
console.error(`[worktree] Invalid repo root resolved: "${repoRoot}"`);
|
|
254
313
|
return null;
|
|
255
314
|
}
|
|
256
|
-
const prefix = this.getWorktreeBranchPrefix();
|
|
257
315
|
const shortId = sessionId.slice(0, 8);
|
|
258
|
-
const branchName = `${
|
|
316
|
+
const branchName = targetBranch ?? `${this.getWorktreeBranchPrefix()}${shortId}`;
|
|
259
317
|
const projectName = path.basename(repoRoot);
|
|
260
318
|
const worktreePath = path.resolve(repoRoot, '..', `${projectName}-wt-${shortId}`);
|
|
319
|
+
// Auto-detect the default branch if baseBranch not specified.
|
|
320
|
+
// Tries origin/HEAD, then falls back to common names.
|
|
321
|
+
let resolvedBase = baseBranch;
|
|
322
|
+
if (!resolvedBase) {
|
|
323
|
+
resolvedBase = await this.detectDefaultBranch(repoRoot, env) ?? undefined;
|
|
324
|
+
}
|
|
325
|
+
// Determine if this is an ephemeral branch (wt/ prefix, generated by us)
|
|
326
|
+
// vs a caller-supplied branch name (e.g. fix/feature-xyz from orchestrator).
|
|
327
|
+
// Caller-supplied branches must NEVER be force-deleted — they may contain
|
|
328
|
+
// unique commits from a previous session or manual work.
|
|
329
|
+
const isEphemeralBranch = !targetBranch;
|
|
330
|
+
// Check if the target branch already exists as a local branch
|
|
331
|
+
let branchExists = false;
|
|
332
|
+
try {
|
|
333
|
+
await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], { cwd: repoRoot, env, timeout: 3000 });
|
|
334
|
+
branchExists = true;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Branch doesn't exist — will be created
|
|
338
|
+
}
|
|
261
339
|
// Clean up stale state from previous failed attempts:
|
|
262
340
|
// 1. Prune orphaned worktree entries (directory gone but git still tracks it)
|
|
263
341
|
await execFileAsync('git', ['worktree', 'prune'], { cwd: repoRoot, env, timeout: 5000 })
|
|
264
342
|
.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
|
|
343
|
+
// 2. Remove existing worktree directory if leftover from a partial failure.
|
|
344
|
+
// If git doesn't recognise it as a worktree, force-remove the directory
|
|
345
|
+
// so that `git worktree add` below doesn't fail with "already exists".
|
|
266
346
|
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env, timeout: 5000 })
|
|
267
|
-
.catch((e) =>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
347
|
+
.catch((e) => {
|
|
348
|
+
console.debug(`[worktree] remove prior worktree (expected if fresh):`, e instanceof Error ? e.message : e);
|
|
349
|
+
// Git doesn't know about it — nuke the stale directory if it still exists
|
|
350
|
+
if (existsSync(worktreePath)) {
|
|
351
|
+
try {
|
|
352
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
353
|
+
console.log(`[worktree] Force-removed stale directory: ${worktreePath}`);
|
|
354
|
+
}
|
|
355
|
+
catch (rmErr) {
|
|
356
|
+
console.warn(`[worktree] Failed to force-remove stale directory ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// 3. Only delete ephemeral branches (wt/*) during cleanup — never caller-supplied ones
|
|
361
|
+
if (isEphemeralBranch && branchExists) {
|
|
362
|
+
await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoRoot, env, timeout: 5000 })
|
|
363
|
+
.catch((e) => console.debug(`[worktree] ephemeral branch cleanup:`, e instanceof Error ? e.message : e));
|
|
364
|
+
branchExists = false;
|
|
365
|
+
}
|
|
366
|
+
// Create the worktree:
|
|
367
|
+
// - Existing branch: check it out in the worktree (no -b)
|
|
368
|
+
// - New branch: create with -b, branching from the resolved base
|
|
369
|
+
let worktreeArgs;
|
|
370
|
+
if (branchExists) {
|
|
371
|
+
worktreeArgs = ['worktree', 'add', worktreePath, branchName];
|
|
372
|
+
console.log(`[worktree] Using existing branch ${branchName}`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
worktreeArgs = ['worktree', 'add', '-b', branchName, worktreePath];
|
|
376
|
+
if (resolvedBase)
|
|
377
|
+
worktreeArgs.push(resolvedBase);
|
|
378
|
+
console.log(`[worktree] Creating new branch ${branchName}${resolvedBase ? ` from ${resolvedBase}` : ''}`);
|
|
379
|
+
}
|
|
380
|
+
await execFileAsync('git', worktreeArgs, {
|
|
273
381
|
cwd: repoRoot,
|
|
274
382
|
env,
|
|
275
383
|
timeout: 15000,
|
|
@@ -363,8 +471,11 @@ export class SessionManager {
|
|
|
363
471
|
/**
|
|
364
472
|
* Clean up a git worktree and its branch. Runs asynchronously and logs errors
|
|
365
473
|
* but never throws — session deletion must not be blocked by cleanup failures.
|
|
474
|
+
* Retries once on failure after a short delay.
|
|
366
475
|
*/
|
|
367
|
-
cleanupWorktree(worktreePath, repoDir) {
|
|
476
|
+
cleanupWorktree(worktreePath, repoDir, attempt = 1) {
|
|
477
|
+
const MAX_CLEANUP_ATTEMPTS = 2;
|
|
478
|
+
const RETRY_DELAY_MS = 3000;
|
|
368
479
|
void (async () => {
|
|
369
480
|
try {
|
|
370
481
|
// Resolve the actual repo root (repoDir may itself be a worktree)
|
|
@@ -384,10 +495,62 @@ export class SessionManager {
|
|
|
384
495
|
.catch((e) => console.warn(`[worktree] prune after cleanup failed:`, e instanceof Error ? e.message : e));
|
|
385
496
|
}
|
|
386
497
|
catch (err) {
|
|
387
|
-
|
|
498
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
499
|
+
if (attempt < MAX_CLEANUP_ATTEMPTS) {
|
|
500
|
+
console.warn(`[worktree] Failed to clean up worktree ${worktreePath} (attempt ${attempt}/${MAX_CLEANUP_ATTEMPTS}): ${errMsg} — retrying in ${RETRY_DELAY_MS}ms`);
|
|
501
|
+
setTimeout(() => this.cleanupWorktree(worktreePath, repoDir, attempt + 1), RETRY_DELAY_MS);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
console.error(`[worktree] Failed to clean up worktree ${worktreePath} after ${MAX_CLEANUP_ATTEMPTS} attempts: ${errMsg}`);
|
|
505
|
+
// Last resort: force-remove the directory so it doesn't block future
|
|
506
|
+
// worktree creation or leave the session in a broken restart loop.
|
|
507
|
+
if (existsSync(worktreePath)) {
|
|
508
|
+
try {
|
|
509
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
510
|
+
console.log(`[worktree] Force-removed stale worktree directory: ${worktreePath}`);
|
|
511
|
+
}
|
|
512
|
+
catch (rmErr) {
|
|
513
|
+
console.error(`[worktree] Failed to force-remove ${worktreePath}:`, rmErr instanceof Error ? rmErr.message : rmErr);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
388
517
|
}
|
|
389
518
|
})();
|
|
390
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Detect the default branch of a repository (main, master, etc.).
|
|
522
|
+
* Tries `git symbolic-ref refs/remotes/origin/HEAD` first, then checks
|
|
523
|
+
* for common branch names. Returns null if detection fails.
|
|
524
|
+
*/
|
|
525
|
+
async detectDefaultBranch(repoRoot, env) {
|
|
526
|
+
// Try origin/HEAD (set by git clone or git remote set-head)
|
|
527
|
+
try {
|
|
528
|
+
const { stdout } = await execFileAsync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: repoRoot, env, timeout: 5000 });
|
|
529
|
+
const ref = stdout.trim(); // e.g. "refs/remotes/origin/main"
|
|
530
|
+
if (ref) {
|
|
531
|
+
const branch = ref.replace('refs/remotes/origin/', '');
|
|
532
|
+
console.log(`[worktree] Detected default branch from origin/HEAD: ${branch}`);
|
|
533
|
+
return branch;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// origin/HEAD not set — fall through to heuristics
|
|
538
|
+
}
|
|
539
|
+
// Check for common default branch names — use show-ref to verify
|
|
540
|
+
// these are actual local branches, not tags or other refs
|
|
541
|
+
for (const candidate of ['main', 'master']) {
|
|
542
|
+
try {
|
|
543
|
+
await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { cwd: repoRoot, env, timeout: 3000 });
|
|
544
|
+
console.log(`[worktree] Detected default branch by name: ${candidate}`);
|
|
545
|
+
return candidate;
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// branch doesn't exist, try next
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
console.warn(`[worktree] Could not detect default branch for ${repoRoot} — worktree will branch from HEAD`);
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
391
554
|
/** Get the configured worktree branch prefix (defaults to 'wt/'). */
|
|
392
555
|
getWorktreeBranchPrefix() {
|
|
393
556
|
return this.archive.getSetting('worktree_branch_prefix', 'wt/');
|
|
@@ -424,30 +587,7 @@ export class SessionManager {
|
|
|
424
587
|
}
|
|
425
588
|
/** Get all sessions that have pending prompts (waiting for approval or answer). */
|
|
426
589
|
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;
|
|
590
|
+
return this.promptRouter.getPendingPrompts();
|
|
451
591
|
}
|
|
452
592
|
/** Clear the isProcessing flag for a session and broadcast the update. */
|
|
453
593
|
clearProcessingFlag(sessionId) {
|
|
@@ -457,9 +597,8 @@ export class SessionManager {
|
|
|
457
597
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
458
598
|
}
|
|
459
599
|
}
|
|
460
|
-
|
|
461
|
-
return
|
|
462
|
-
.map((s) => ({
|
|
600
|
+
serializeSession(s) {
|
|
601
|
+
return {
|
|
463
602
|
id: s.id,
|
|
464
603
|
name: s.name,
|
|
465
604
|
created: s.created,
|
|
@@ -471,24 +610,17 @@ export class SessionManager {
|
|
|
471
610
|
connectedClients: s.clients.size,
|
|
472
611
|
lastActivity: new Date(s._lastActivityAt).toISOString(),
|
|
473
612
|
source: s.source,
|
|
474
|
-
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
list() {
|
|
616
|
+
return Array.from(this.sessions.values())
|
|
617
|
+
.filter((s) => s.source !== 'orchestrator')
|
|
618
|
+
.map((s) => this.serializeSession(s));
|
|
475
619
|
}
|
|
476
620
|
/** List ALL sessions including orchestrator — used by orchestrator cleanup endpoints. */
|
|
477
621
|
listAll() {
|
|
478
622
|
return Array.from(this.sessions.values())
|
|
479
|
-
.map((s) => (
|
|
480
|
-
id: s.id,
|
|
481
|
-
name: s.name,
|
|
482
|
-
created: s.created,
|
|
483
|
-
active: s.claudeProcess?.isAlive() ?? false,
|
|
484
|
-
isProcessing: s.isProcessing,
|
|
485
|
-
workingDir: s.workingDir,
|
|
486
|
-
groupDir: s.groupDir,
|
|
487
|
-
worktreePath: s.worktreePath,
|
|
488
|
-
connectedClients: s.clients.size,
|
|
489
|
-
lastActivity: new Date(s._lastActivityAt).toISOString(),
|
|
490
|
-
source: s.source,
|
|
491
|
-
}));
|
|
623
|
+
.map((s) => this.serializeSession(s));
|
|
492
624
|
}
|
|
493
625
|
rename(sessionId, newName) {
|
|
494
626
|
const session = this.sessions.get(sessionId);
|
|
@@ -572,21 +704,35 @@ export class SessionManager {
|
|
|
572
704
|
return false;
|
|
573
705
|
// Prevent auto-restart when deleting
|
|
574
706
|
session._stoppedByUser = true;
|
|
575
|
-
if (session.
|
|
576
|
-
clearTimeout(session.
|
|
707
|
+
if (session._apiRetry.timer)
|
|
708
|
+
clearTimeout(session._apiRetry.timer);
|
|
577
709
|
if (session._namingTimer)
|
|
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
|
|
@@ -938,18 +892,23 @@ export class SessionManager {
|
|
|
938
892
|
*/
|
|
939
893
|
handleApiRetry(session, sessionId, result) {
|
|
940
894
|
if (!session._lastUserInput || !this.isRetryableApiError(result)) {
|
|
941
|
-
session.
|
|
895
|
+
session._apiRetry.count = 0;
|
|
896
|
+
session._apiRetry.scheduled = 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
|
-
session.
|
|
902
|
+
session._apiRetry.count = 0;
|
|
903
|
+
session._apiRetry.scheduled = false;
|
|
948
904
|
return false;
|
|
949
905
|
}
|
|
950
|
-
if (session.
|
|
951
|
-
|
|
952
|
-
|
|
906
|
+
if (session._apiRetry.count < MAX_API_RETRIES) {
|
|
907
|
+
// Prevent duplicate scheduling from concurrent error paths
|
|
908
|
+
if (session._apiRetry.scheduled)
|
|
909
|
+
return true;
|
|
910
|
+
session._apiRetry.count++;
|
|
911
|
+
const attempt = session._apiRetry.count;
|
|
953
912
|
const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
954
913
|
const retryMsg = {
|
|
955
914
|
type: 'system_message',
|
|
@@ -959,10 +918,12 @@ export class SessionManager {
|
|
|
959
918
|
this.addToHistory(session, retryMsg);
|
|
960
919
|
this.broadcast(session, retryMsg);
|
|
961
920
|
console.log(`[api-retry] session=${sessionId} attempt=${attempt}/${MAX_API_RETRIES} delay=${delay}ms error=${result.slice(0, 200)}`);
|
|
962
|
-
if (session.
|
|
963
|
-
clearTimeout(session.
|
|
964
|
-
session.
|
|
965
|
-
|
|
921
|
+
if (session._apiRetry.timer)
|
|
922
|
+
clearTimeout(session._apiRetry.timer);
|
|
923
|
+
session._apiRetry.scheduled = true;
|
|
924
|
+
session._apiRetry.timer = setTimeout(() => {
|
|
925
|
+
session._apiRetry.timer = undefined;
|
|
926
|
+
session._apiRetry.scheduled = 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}`);
|
|
@@ -978,7 +939,8 @@ export class SessionManager {
|
|
|
978
939
|
};
|
|
979
940
|
this.addToHistory(session, exhaustedMsg);
|
|
980
941
|
this.broadcast(session, exhaustedMsg);
|
|
981
|
-
session.
|
|
942
|
+
session._apiRetry.count = 0;
|
|
943
|
+
session._apiRetry.scheduled = false;
|
|
982
944
|
return false;
|
|
983
945
|
}
|
|
984
946
|
/**
|
|
@@ -986,7 +948,8 @@ export class SessionManager {
|
|
|
986
948
|
* and trigger session naming if needed.
|
|
987
949
|
*/
|
|
988
950
|
finalizeResult(session, sessionId, result, isError) {
|
|
989
|
-
session.
|
|
951
|
+
session._apiRetry.count = 0;
|
|
952
|
+
session._apiRetry.scheduled = 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.
|
|
@@ -1144,15 +1013,33 @@ export class SessionManager {
|
|
|
1144
1013
|
const combined = context + '\n\n' + data;
|
|
1145
1014
|
session._lastUserInput = combined;
|
|
1146
1015
|
session._lastUserInputAt = Date.now();
|
|
1147
|
-
session.
|
|
1016
|
+
session._apiRetry.count = 0;
|
|
1148
1017
|
if (!session.isProcessing) {
|
|
1149
1018
|
session.isProcessing = true;
|
|
1150
1019
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
1151
1020
|
}
|
|
1152
|
-
session.claudeProcess
|
|
1021
|
+
if (session.claudeProcess && !session.claudeProcess.isReady()) {
|
|
1022
|
+
void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(combined));
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
session.claudeProcess?.sendMessage(combined);
|
|
1026
|
+
}
|
|
1153
1027
|
return;
|
|
1154
1028
|
}
|
|
1155
1029
|
}
|
|
1030
|
+
// Process just started — if not ready yet (OpenCode needs server init),
|
|
1031
|
+
// queue the message via waitForReady.
|
|
1032
|
+
if (session.claudeProcess && !session.claudeProcess.isReady()) {
|
|
1033
|
+
session._lastUserInput = data;
|
|
1034
|
+
session._lastUserInputAt = Date.now();
|
|
1035
|
+
session._apiRetry.count = 0;
|
|
1036
|
+
if (!session.isProcessing) {
|
|
1037
|
+
session.isProcessing = true;
|
|
1038
|
+
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
1039
|
+
}
|
|
1040
|
+
void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(data));
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1156
1043
|
}
|
|
1157
1044
|
// Track turn count; retry naming on subsequent interactions if still unnamed
|
|
1158
1045
|
if (session._turnCount === 0 && session.name.startsWith('hub:')) {
|
|
@@ -1164,7 +1051,7 @@ export class SessionManager {
|
|
|
1164
1051
|
}
|
|
1165
1052
|
session._lastUserInput = data;
|
|
1166
1053
|
session._lastUserInputAt = Date.now();
|
|
1167
|
-
session.
|
|
1054
|
+
session._apiRetry.count = 0;
|
|
1168
1055
|
if (!session.isProcessing) {
|
|
1169
1056
|
session.isProcessing = true;
|
|
1170
1057
|
this._globalBroadcast?.({ type: 'sessions_updated' });
|
|
@@ -1172,436 +1059,43 @@ export class SessionManager {
|
|
|
1172
1059
|
session.claudeProcess?.sendMessage(data);
|
|
1173
1060
|
}
|
|
1174
1061
|
/**
|
|
1175
|
-
* Route a user's prompt response to the correct handler
|
|
1176
|
-
*
|
|
1177
|
-
* fallback path), or plain message fallback.
|
|
1062
|
+
* Route a user's prompt response to the correct handler.
|
|
1063
|
+
* Delegates to PromptRouter.
|
|
1178
1064
|
*/
|
|
1179
1065
|
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);
|
|
1066
|
+
this.promptRouter.sendPromptResponse(sessionId, value, requestId);
|
|
1343
1067
|
}
|
|
1344
1068
|
/**
|
|
1345
1069
|
* Called by the PermissionRequest hook HTTP endpoint. Sends a prompt to clients
|
|
1346
1070
|
* and returns a Promise that resolves when the user approves/denies.
|
|
1071
|
+
* Delegates to PromptRouter.
|
|
1347
1072
|
*/
|
|
1348
1073
|
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
|
-
});
|
|
1074
|
+
return this.promptRouter.requestToolApproval(sessionId, toolName, toolInput);
|
|
1542
1075
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
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';
|
|
1076
|
+
getSessionProvider(sessionId) {
|
|
1077
|
+
return this.sessions.get(sessionId)?.provider ?? 'claude';
|
|
1560
1078
|
}
|
|
1561
|
-
/**
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
if (
|
|
1576
|
-
|
|
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;
|
|
1079
|
+
/** Update the provider for a session and restart with the new provider process. */
|
|
1080
|
+
setProvider(sessionId, provider) {
|
|
1081
|
+
const session = this.sessions.get(sessionId);
|
|
1082
|
+
if (!session)
|
|
1083
|
+
return false;
|
|
1084
|
+
if (session.provider === provider)
|
|
1085
|
+
return true;
|
|
1086
|
+
session.provider = provider;
|
|
1087
|
+
session.claudeSessionId = null;
|
|
1088
|
+
this.persistToDiskDebounced();
|
|
1089
|
+
if (session.claudeProcess?.isAlive()) {
|
|
1090
|
+
this.stopClaude(sessionId);
|
|
1091
|
+
session._stoppedByUser = false;
|
|
1092
|
+
setTimeout(() => {
|
|
1093
|
+
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1094
|
+
this.startClaude(sessionId);
|
|
1582
1095
|
}
|
|
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}?`;
|
|
1096
|
+
}, 500);
|
|
1604
1097
|
}
|
|
1098
|
+
return true;
|
|
1605
1099
|
}
|
|
1606
1100
|
/** Update the model for a session and restart Claude with the new model. */
|
|
1607
1101
|
setModel(sessionId, model) {
|
|
@@ -1610,15 +1104,16 @@ export class SessionManager {
|
|
|
1610
1104
|
return false;
|
|
1611
1105
|
session.model = model || undefined;
|
|
1612
1106
|
this.persistToDiskDebounced();
|
|
1613
|
-
// Restart Claude with the new model if it's running
|
|
1107
|
+
// Restart Claude with the new model if it's running.
|
|
1108
|
+
// Use stopClaudeAndWait to ensure the old process fully exits before
|
|
1109
|
+
// spawning a new one — avoids concurrent processes in the same worktree.
|
|
1614
1110
|
if (session.claudeProcess?.isAlive()) {
|
|
1615
|
-
this.
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1111
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1112
|
+
if (this.sessions.has(sessionId)) {
|
|
1113
|
+
session._stoppedByUser = false;
|
|
1619
1114
|
this.startClaude(sessionId);
|
|
1620
1115
|
}
|
|
1621
|
-
}
|
|
1116
|
+
});
|
|
1622
1117
|
}
|
|
1623
1118
|
return true;
|
|
1624
1119
|
}
|
|
@@ -1639,49 +1134,29 @@ export class SessionManager {
|
|
|
1639
1134
|
const sysMsg = { type: 'system_message', subtype: 'notification', text: `Permission mode changed to: ${modeLabel}` };
|
|
1640
1135
|
this.addToHistory(session, sysMsg);
|
|
1641
1136
|
this.broadcast(session, sysMsg);
|
|
1642
|
-
// Restart Claude with the new permission mode if it's running
|
|
1137
|
+
// Restart Claude with the new permission mode if it's running.
|
|
1138
|
+
// Use stopClaudeAndWait to ensure the old process fully exits before
|
|
1139
|
+
// spawning a new one — avoids concurrent processes in the same worktree.
|
|
1643
1140
|
if (session.claudeProcess?.isAlive()) {
|
|
1644
|
-
this.
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
|
|
1141
|
+
void this.stopClaudeAndWait(sessionId).then(() => {
|
|
1142
|
+
if (this.sessions.has(sessionId)) {
|
|
1143
|
+
session._stoppedByUser = false;
|
|
1648
1144
|
this.startClaude(sessionId);
|
|
1649
1145
|
}
|
|
1650
|
-
}
|
|
1146
|
+
});
|
|
1651
1147
|
}
|
|
1652
1148
|
return true;
|
|
1653
1149
|
}
|
|
1150
|
+
/** Stop the Claude process for a session. Delegates to SessionLifecycle. */
|
|
1654
1151
|
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
|
-
}
|
|
1152
|
+
this.sessionLifecycle.stopClaude(sessionId);
|
|
1665
1153
|
}
|
|
1666
1154
|
/**
|
|
1667
1155
|
* Stop the Claude process and wait for it to fully exit before resolving.
|
|
1668
|
-
*
|
|
1669
|
-
* (e.g. during mid-session worktree migration).
|
|
1156
|
+
* Delegates to SessionLifecycle.
|
|
1670
1157
|
*/
|
|
1671
1158
|
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();
|
|
1159
|
+
return this.sessionLifecycle.stopClaudeAndWait(sessionId);
|
|
1685
1160
|
}
|
|
1686
1161
|
// ---------------------------------------------------------------------------
|
|
1687
1162
|
// Helpers
|
|
@@ -1927,7 +1402,7 @@ export class SessionManager {
|
|
|
1927
1402
|
restoreActiveSessions() {
|
|
1928
1403
|
const toRestore = [];
|
|
1929
1404
|
for (const session of this.sessions.values()) {
|
|
1930
|
-
if (session._wasActiveBeforeRestart && session.claudeSessionId) {
|
|
1405
|
+
if (session._wasActiveBeforeRestart && session.claudeSessionId && session.source !== 'webhook') {
|
|
1931
1406
|
toRestore.push(session);
|
|
1932
1407
|
}
|
|
1933
1408
|
}
|