codekin 0.2.1 → 0.3.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/dist/assets/index-B4rYTSlV.css +1 -0
- package/dist/assets/index-CWc3yfPn.js +181 -0
- package/dist/index.html +2 -2
- package/package.json +10 -6
- package/server/dist/approval-manager.d.ts +71 -0
- package/server/dist/approval-manager.js +249 -0
- package/server/dist/approval-manager.js.map +1 -0
- package/server/dist/auth-routes.d.ts +11 -0
- package/server/dist/auth-routes.js +11 -0
- package/server/dist/auth-routes.js.map +1 -1
- package/server/dist/claude-process.js +2 -1
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/config.d.ts +1 -2
- package/server/dist/config.js +17 -5
- package/server/dist/config.js.map +1 -1
- package/server/dist/crypto-utils.d.ts +16 -1
- package/server/dist/crypto-utils.js +44 -1
- package/server/dist/crypto-utils.js.map +1 -1
- package/server/dist/docs-routes.d.ts +16 -0
- package/server/dist/docs-routes.js +141 -0
- package/server/dist/docs-routes.js.map +1 -0
- package/server/dist/session-manager.d.ts +37 -84
- package/server/dist/session-manager.js +89 -472
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-naming.d.ts +35 -0
- package/server/dist/session-naming.js +168 -0
- package/server/dist/session-naming.js.map +1 -0
- package/server/dist/session-persistence.d.ts +30 -0
- package/server/dist/session-persistence.js +93 -0
- package/server/dist/session-persistence.js.map +1 -0
- package/server/dist/session-routes.d.ts +2 -1
- package/server/dist/session-routes.js +13 -6
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/stepflow-handler.d.ts +3 -9
- package/server/dist/stepflow-handler.js +32 -50
- package/server/dist/stepflow-handler.js.map +1 -1
- package/server/dist/stepflow-types.d.ts +2 -0
- package/server/dist/types.d.ts +3 -0
- package/server/dist/upload-routes.js +9 -4
- package/server/dist/upload-routes.js.map +1 -1
- package/server/dist/webhook-github.d.ts +23 -5
- package/server/dist/webhook-github.js +23 -5
- package/server/dist/webhook-github.js.map +1 -1
- package/server/dist/webhook-handler-base.d.ts +45 -0
- package/server/dist/webhook-handler-base.js +86 -0
- package/server/dist/webhook-handler-base.js.map +1 -0
- package/server/dist/webhook-handler.d.ts +3 -10
- package/server/dist/webhook-handler.js +6 -46
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/webhook-workspace.d.ts +8 -1
- package/server/dist/webhook-workspace.js +73 -41
- package/server/dist/webhook-workspace.js.map +1 -1
- package/server/dist/workflow-config.d.ts +2 -0
- package/server/dist/workflow-config.js +1 -1
- package/server/dist/workflow-config.js.map +1 -1
- package/server/dist/workflow-engine.d.ts +1 -0
- package/server/dist/workflow-engine.js +3 -0
- package/server/dist/workflow-engine.js.map +1 -1
- package/server/dist/workflow-loader.d.ts +33 -6
- package/server/dist/workflow-loader.js +129 -14
- package/server/dist/workflow-loader.js.map +1 -1
- package/server/dist/workflow-routes.d.ts +7 -3
- package/server/dist/workflow-routes.js +63 -8
- package/server/dist/workflow-routes.js.map +1 -1
- package/server/dist/ws-server.js +91 -18
- package/server/dist/ws-server.js.map +1 -1
- package/server/workflows/code-review.daily.md +22 -0
- package/server/workflows/comment-assessment.daily.md +41 -0
- package/server/workflows/complexity.weekly.md +54 -0
- package/server/workflows/coverage.daily.md +41 -0
- package/server/workflows/dependency-health.daily.md +46 -0
- package/server/workflows/repo-health.weekly.md +111 -0
- package/server/workflows/security-audit.weekly.md +66 -0
- package/dist/assets/index-CPxHiZP2.js +0 -105
- package/dist/assets/index-DU_Viph_.css +0 -1
|
@@ -10,22 +10,20 @@
|
|
|
10
10
|
* on server startup. Active sessions are automatically restarted after a
|
|
11
11
|
* server restart with staggered delays.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Delegates to focused modules:
|
|
14
|
+
* - ApprovalManager: repo-level auto-approval rules for tools/commands
|
|
15
|
+
* - SessionNaming: AI-powered session name generation with retry logic
|
|
16
|
+
* - SessionPersistence: disk I/O for session state
|
|
16
17
|
*/
|
|
17
18
|
import { randomUUID } from 'crypto';
|
|
18
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
19
|
-
import { join } from 'path';
|
|
20
|
-
import { generateText } from 'ai';
|
|
21
|
-
import { createGroq } from '@ai-sdk/groq';
|
|
22
|
-
import { createOpenAI } from '@ai-sdk/openai';
|
|
23
|
-
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
24
|
-
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
25
19
|
import { ClaudeProcess } from './claude-process.js';
|
|
26
20
|
import { SessionArchive } from './session-archive.js';
|
|
27
21
|
import { cleanupWorkspace } from './webhook-workspace.js';
|
|
28
|
-
import {
|
|
22
|
+
import { PORT } from './config.js';
|
|
23
|
+
import { ApprovalManager } from './approval-manager.js';
|
|
24
|
+
import { SessionNaming } from './session-naming.js';
|
|
25
|
+
import { SessionPersistence } from './session-persistence.js';
|
|
26
|
+
import { deriveSessionToken } from './crypto-utils.js';
|
|
29
27
|
/** Max messages retained in a session's output history buffer. */
|
|
30
28
|
const MAX_HISTORY = 2000;
|
|
31
29
|
/** Max auto-restart attempts before requiring manual intervention. */
|
|
@@ -51,15 +49,8 @@ const API_RETRY_PATTERNS = [
|
|
|
51
49
|
/502/,
|
|
52
50
|
/503/,
|
|
53
51
|
];
|
|
54
|
-
const SESSIONS_FILE = join(DATA_DIR, 'sessions.json');
|
|
55
|
-
const REPO_APPROVALS_FILE = join(DATA_DIR, 'repo-approvals.json');
|
|
56
|
-
const PERSIST_DEBOUNCE_MS = 2000;
|
|
57
52
|
export class SessionManager {
|
|
58
53
|
sessions = new Map();
|
|
59
|
-
/** Repo-level auto-approval store, keyed by workingDir (repo path). */
|
|
60
|
-
repoApprovals = new Map();
|
|
61
|
-
_persistTimer = null;
|
|
62
|
-
_approvalPersistTimer = null;
|
|
63
54
|
/** SQLite archive for closed sessions. */
|
|
64
55
|
archive;
|
|
65
56
|
/** Exposed so ws-server can pass its port to child Claude processes. */
|
|
@@ -70,154 +61,75 @@ export class SessionManager {
|
|
|
70
61
|
_globalBroadcast = null;
|
|
71
62
|
/** Registered listeners notified when a session's Claude process exits. */
|
|
72
63
|
_exitListeners = [];
|
|
64
|
+
/** Delegated approval logic. */
|
|
65
|
+
approvalManager;
|
|
66
|
+
/** Delegated naming logic. */
|
|
67
|
+
sessionNaming;
|
|
68
|
+
/** Delegated persistence logic. */
|
|
69
|
+
sessionPersistence;
|
|
73
70
|
constructor() {
|
|
74
71
|
this.archive = new SessionArchive();
|
|
75
|
-
this.
|
|
76
|
-
this.
|
|
72
|
+
this.approvalManager = new ApprovalManager();
|
|
73
|
+
this.sessionPersistence = new SessionPersistence(this.sessions);
|
|
74
|
+
this.sessionNaming = new SessionNaming({
|
|
75
|
+
getSession: (id) => this.sessions.get(id),
|
|
76
|
+
hasSession: (id) => this.sessions.has(id),
|
|
77
|
+
getSetting: (key, fallback) => this.archive.getSetting(key, fallback),
|
|
78
|
+
rename: (sessionId, newName) => this.rename(sessionId, newName),
|
|
79
|
+
});
|
|
80
|
+
this.sessionPersistence.restoreFromDisk();
|
|
77
81
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
return entry;
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Approval delegation (preserves public API)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** Check if a tool/command is auto-approved for a repo. */
|
|
86
|
+
checkAutoApproval(workingDir, toolName, toolInput) {
|
|
87
|
+
return this.approvalManager.checkAutoApproval(workingDir, toolName, toolInput);
|
|
86
88
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (opts.tool)
|
|
91
|
-
entry.tools.add(opts.tool);
|
|
92
|
-
if (opts.command)
|
|
93
|
-
entry.commands.add(opts.command);
|
|
94
|
-
if (opts.pattern)
|
|
95
|
-
entry.patterns.add(opts.pattern);
|
|
96
|
-
this.persistRepoApprovalsDebounced();
|
|
89
|
+
/** Derive a glob pattern from a tool invocation for "Approve Pattern". */
|
|
90
|
+
derivePattern(toolName, toolInput) {
|
|
91
|
+
return this.approvalManager.derivePattern(toolName, toolInput);
|
|
97
92
|
}
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
* should be listed here. Dangerous commands like rm, sudo, curl, etc. require
|
|
102
|
-
* exact match to prevent escalation (e.g. approving `rm -rf /tmp/x` should NOT
|
|
103
|
-
* also approve `rm -rf /`).
|
|
104
|
-
*/
|
|
105
|
-
static SAFE_PREFIX_COMMANDS = new Set([
|
|
106
|
-
'git add', 'git commit', 'git diff', 'git log', 'git show', 'git stash',
|
|
107
|
-
'git status', 'git branch', 'git checkout', 'git switch', 'git rebase',
|
|
108
|
-
'git fetch', 'git pull', 'git merge', 'git tag', 'git rev-parse',
|
|
109
|
-
'npm run', 'npm test', 'npm install', 'npm ci', 'npm exec',
|
|
110
|
-
'npx', 'node', 'bun', 'deno',
|
|
111
|
-
'cargo build', 'cargo test', 'cargo run', 'cargo check', 'cargo clippy',
|
|
112
|
-
'make', 'cmake',
|
|
113
|
-
'python', 'python3', 'pip install',
|
|
114
|
-
'go build', 'go test', 'go run', 'go vet',
|
|
115
|
-
'tsc', 'eslint', 'prettier',
|
|
116
|
-
'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'diff', 'less',
|
|
117
|
-
'ls', 'pwd', 'echo', 'date', 'which', 'whoami', 'env', 'printenv',
|
|
118
|
-
'find', 'grep', 'rg', 'ag', 'fd',
|
|
119
|
-
'mkdir', 'touch',
|
|
120
|
-
]);
|
|
121
|
-
/**
|
|
122
|
-
* Check if a tool/command is auto-approved for a repo.
|
|
123
|
-
* For Bash commands, uses prefix matching only for safe commands;
|
|
124
|
-
* dangerous commands require exact match to prevent escalation.
|
|
125
|
-
*/
|
|
126
|
-
checkAutoApproval(workingDir, toolName, toolInput) {
|
|
127
|
-
const approvals = this.getRepoApprovalEntry(workingDir);
|
|
128
|
-
if (approvals.tools.has(toolName))
|
|
129
|
-
return true;
|
|
130
|
-
if (toolName === 'Bash') {
|
|
131
|
-
const cmd = String(toolInput.command || '').trim();
|
|
132
|
-
// Exact match always works
|
|
133
|
-
if (approvals.commands.has(cmd))
|
|
134
|
-
return true;
|
|
135
|
-
// Pattern match (e.g. "cat *" matches any cat command)
|
|
136
|
-
for (const pattern of approvals.patterns) {
|
|
137
|
-
if (this.matchesPattern(pattern, cmd))
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
// Prefix match only for safe commands
|
|
141
|
-
const cmdPrefix = this.commandPrefix(cmd);
|
|
142
|
-
if (cmdPrefix && SessionManager.SAFE_PREFIX_COMMANDS.has(cmdPrefix)) {
|
|
143
|
-
for (const approved of approvals.commands) {
|
|
144
|
-
if (this.commandPrefix(approved) === cmdPrefix)
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return false;
|
|
93
|
+
/** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
|
|
94
|
+
getApprovals(workingDir) {
|
|
95
|
+
return this.approvalManager.getApprovals(workingDir);
|
|
150
96
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (tokens.length === 0)
|
|
155
|
-
return '';
|
|
156
|
-
if (tokens.length === 1)
|
|
157
|
-
return tokens[0];
|
|
158
|
-
return `${tokens[0]} ${tokens[1]}`;
|
|
97
|
+
/** Remove an auto-approval rule for a repo (workingDir) and persist to disk. */
|
|
98
|
+
removeApproval(workingDir, opts, skipPersist = false) {
|
|
99
|
+
return this.approvalManager.removeApproval(workingDir, opts, skipPersist);
|
|
159
100
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* Patterns use the format "<prefix> *" meaning "this prefix followed by anything".
|
|
164
|
-
*/
|
|
165
|
-
derivePattern(toolName, toolInput) {
|
|
166
|
-
if (toolName !== 'Bash')
|
|
167
|
-
return null;
|
|
168
|
-
const cmd = String(toolInput.command || '').trim();
|
|
169
|
-
const tokens = cmd.split(/\s+/).filter(Boolean);
|
|
170
|
-
if (tokens.length === 0)
|
|
171
|
-
return null;
|
|
172
|
-
// Check single-token safe commands (cat, grep, ls, etc.)
|
|
173
|
-
const first = tokens[0];
|
|
174
|
-
if (SessionManager.SAFE_PREFIX_COMMANDS.has(first)) {
|
|
175
|
-
return `${first} *`;
|
|
176
|
-
}
|
|
177
|
-
// Check two-token safe commands (git diff, npm run, etc.)
|
|
178
|
-
if (tokens.length >= 2) {
|
|
179
|
-
const twoToken = `${tokens[0]} ${tokens[1]}`;
|
|
180
|
-
if (SessionManager.SAFE_PREFIX_COMMANDS.has(twoToken)) {
|
|
181
|
-
return `${twoToken} *`;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return null;
|
|
101
|
+
/** Add an auto-approval rule for a repo and persist (used by tests via `as any`). */
|
|
102
|
+
addRepoApproval(workingDir, opts) {
|
|
103
|
+
this.approvalManager.addRepoApproval(workingDir, opts);
|
|
185
104
|
}
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
*/
|
|
190
|
-
matchesPattern(pattern, cmd) {
|
|
191
|
-
if (pattern.endsWith(' *')) {
|
|
192
|
-
const prefix = pattern.slice(0, -2);
|
|
193
|
-
return cmd === prefix || cmd.startsWith(prefix + ' ');
|
|
194
|
-
}
|
|
195
|
-
return cmd === pattern;
|
|
105
|
+
/** Write repo-level approvals to disk. Exposed for shutdown. */
|
|
106
|
+
persistRepoApprovals() {
|
|
107
|
+
this.approvalManager.persistRepoApprovals();
|
|
196
108
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
this.addRepoApproval(workingDir, { tool: toolName });
|
|
206
|
-
console.log(`[auto-approve] saved tool for repo ${workingDir}: ${toolName}`);
|
|
207
|
-
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Naming delegation (preserves public API)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/** Schedule session naming via AI provider. */
|
|
113
|
+
scheduleSessionNaming(sessionId) {
|
|
114
|
+
this.sessionNaming.scheduleSessionNaming(sessionId);
|
|
208
115
|
}
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (pattern) {
|
|
213
|
-
this.addRepoApproval(workingDir, { pattern });
|
|
214
|
-
console.log(`[auto-approve] saved pattern for repo ${workingDir}: ${pattern}`);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
// No pattern derivable — skip saving rather than silently escalating to always-allow
|
|
218
|
-
console.log(`[auto-approve] no pattern derivable for ${toolName}, skipping pattern save`);
|
|
219
|
-
}
|
|
116
|
+
/** Re-trigger session naming on user interaction. */
|
|
117
|
+
retrySessionNamingOnInteraction(sessionId) {
|
|
118
|
+
this.sessionNaming.retrySessionNamingOnInteraction(sessionId);
|
|
220
119
|
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Persistence delegation (preserves public API)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/** Write all sessions to disk as JSON (atomic rename to prevent corruption). */
|
|
124
|
+
persistToDisk() {
|
|
125
|
+
this.sessionPersistence.persistToDisk();
|
|
126
|
+
}
|
|
127
|
+
persistToDiskDebounced() {
|
|
128
|
+
this.sessionPersistence.persistToDiskDebounced();
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Session CRUD
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
221
133
|
/** Create a new session and persist to disk. */
|
|
222
134
|
create(name, workingDir, options) {
|
|
223
135
|
const id = options?.id ?? randomUUID();
|
|
@@ -282,155 +194,6 @@ export class SessionManager {
|
|
|
282
194
|
this.broadcast(session, { type: 'session_name_update', sessionId, name: newName });
|
|
283
195
|
return true;
|
|
284
196
|
}
|
|
285
|
-
/** Max naming retry attempts before giving up. */
|
|
286
|
-
static MAX_NAMING_ATTEMPTS = 5;
|
|
287
|
-
/** Back-off delays for naming retries: 20s, 60s, 120s, 240s, 240s */
|
|
288
|
-
static NAMING_DELAYS = [20_000, 60_000, 120_000, 240_000, 240_000];
|
|
289
|
-
/** Schedule session naming via the Anthropic API.
|
|
290
|
-
* Used as a 20s fallback for long responses — fires immediately via result handler
|
|
291
|
-
* for responses that finish sooner.
|
|
292
|
-
* Automatically retries on failure with increasing delays. */
|
|
293
|
-
scheduleSessionNaming(sessionId) {
|
|
294
|
-
const session = this.sessions.get(sessionId);
|
|
295
|
-
if (!session)
|
|
296
|
-
return;
|
|
297
|
-
if (!session.name.startsWith('hub:'))
|
|
298
|
-
return;
|
|
299
|
-
// Don't schedule if a naming timer is already pending
|
|
300
|
-
if (session._namingTimer)
|
|
301
|
-
return;
|
|
302
|
-
// Give up after max attempts
|
|
303
|
-
if (session._namingAttempts >= SessionManager.MAX_NAMING_ATTEMPTS)
|
|
304
|
-
return;
|
|
305
|
-
const delay = SessionManager.NAMING_DELAYS[Math.min(session._namingAttempts, SessionManager.NAMING_DELAYS.length - 1)];
|
|
306
|
-
session._namingTimer = setTimeout(() => {
|
|
307
|
-
delete session._namingTimer;
|
|
308
|
-
this.executeSessionNaming(sessionId);
|
|
309
|
-
}, delay);
|
|
310
|
-
}
|
|
311
|
-
/** Resolve the AI model to use for session naming based on available API keys.
|
|
312
|
-
* Respects the user's preferred support provider setting.
|
|
313
|
-
* Fallback priority: Groq (free/fast) → OpenAI → Gemini → Anthropic. */
|
|
314
|
-
getNamingModel() {
|
|
315
|
-
const preferred = this.archive.getSetting('support_provider', 'auto');
|
|
316
|
-
const providers = {
|
|
317
|
-
groq: () => process.env.GROQ_API_KEY
|
|
318
|
-
? createGroq({ apiKey: process.env.GROQ_API_KEY })('meta-llama/llama-4-scout-17b-16e-instruct')
|
|
319
|
-
: null,
|
|
320
|
-
openai: () => process.env.OPENAI_API_KEY
|
|
321
|
-
? createOpenAI({ apiKey: process.env.OPENAI_API_KEY })('gpt-4o-mini')
|
|
322
|
-
: null,
|
|
323
|
-
gemini: () => process.env.GEMINI_API_KEY
|
|
324
|
-
? createGoogleGenerativeAI({ apiKey: process.env.GEMINI_API_KEY })('gemini-2.5-flash')
|
|
325
|
-
: null,
|
|
326
|
-
anthropic: () => process.env.ANTHROPIC_API_KEY
|
|
327
|
-
? createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-haiku-4-5-20251001')
|
|
328
|
-
: null,
|
|
329
|
-
};
|
|
330
|
-
// If a specific provider is preferred and available, use it
|
|
331
|
-
if (preferred !== 'auto' && providers[preferred]) {
|
|
332
|
-
const model = providers[preferred]();
|
|
333
|
-
if (model)
|
|
334
|
-
return model;
|
|
335
|
-
}
|
|
336
|
-
// Auto: try in priority order
|
|
337
|
-
for (const key of ['groq', 'openai', 'gemini', 'anthropic']) {
|
|
338
|
-
const model = providers[key]();
|
|
339
|
-
if (model)
|
|
340
|
-
return model;
|
|
341
|
-
}
|
|
342
|
-
return null;
|
|
343
|
-
}
|
|
344
|
-
/** Execute the actual naming call. Called by the timer set in scheduleSessionNaming.
|
|
345
|
-
* Uses the first available API provider for minimal cost and latency. */
|
|
346
|
-
async executeSessionNaming(sessionId) {
|
|
347
|
-
const session = this.sessions.get(sessionId);
|
|
348
|
-
if (!session)
|
|
349
|
-
return;
|
|
350
|
-
if (!session.name.startsWith('hub:'))
|
|
351
|
-
return;
|
|
352
|
-
session._namingAttempts++;
|
|
353
|
-
// Gather context from conversation history
|
|
354
|
-
const latestContext = session.outputHistory
|
|
355
|
-
.filter(m => m.type === 'output')
|
|
356
|
-
.map(m => m.data || '')
|
|
357
|
-
.join('')
|
|
358
|
-
.slice(0, 2000);
|
|
359
|
-
const userMsg = session._lastUserInput || '';
|
|
360
|
-
if (!userMsg && !latestContext) {
|
|
361
|
-
// No context yet — schedule a retry
|
|
362
|
-
this.scheduleSessionNaming(sessionId);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const model = this.getNamingModel();
|
|
366
|
-
if (!model) {
|
|
367
|
-
console.warn('[session-name] No API key set for naming (GROQ_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or ANTHROPIC_API_KEY), skipping');
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
try {
|
|
371
|
-
const { text } = await generateText({
|
|
372
|
-
model,
|
|
373
|
-
maxOutputTokens: 60,
|
|
374
|
-
prompt: [
|
|
375
|
-
'Generate a descriptive name (3-6 words, max 60 characters) for this coding session.',
|
|
376
|
-
'The name MUST be at least 3 words that clearly summarize what the user is working on.',
|
|
377
|
-
'Reply with ONLY the session name. No quotes, no punctuation, no explanation.',
|
|
378
|
-
'Example names: "Fix Login Page Styling", "Add User Auth Flow", "Refactor Database Query Performance"',
|
|
379
|
-
'',
|
|
380
|
-
`User message: ${userMsg.slice(0, 500)}`,
|
|
381
|
-
'',
|
|
382
|
-
`Assistant response (truncated): ${latestContext.slice(0, 1500)}`,
|
|
383
|
-
].join('\n'),
|
|
384
|
-
});
|
|
385
|
-
if (!this.sessions.has(sessionId))
|
|
386
|
-
return;
|
|
387
|
-
if (!session.name.startsWith('hub:'))
|
|
388
|
-
return;
|
|
389
|
-
const rawName = text
|
|
390
|
-
.trim()
|
|
391
|
-
.replace(/^["'`*_]+|["'`*_]+$/g, '')
|
|
392
|
-
.replace(/^(session\s*name\s*[::]\s*)/i, '')
|
|
393
|
-
.trim();
|
|
394
|
-
const wordCount = rawName.split(/\s+/).filter(w => w.length > 0).length;
|
|
395
|
-
if (rawName.length >= 2 && rawName.length <= 80 && wordCount >= 3) {
|
|
396
|
-
const finalName = rawName.length <= 60
|
|
397
|
-
? rawName
|
|
398
|
-
: (rawName.slice(0, 60).replace(/\s+\S*$/, '') || rawName.slice(0, 60));
|
|
399
|
-
this.rename(sessionId, finalName);
|
|
400
|
-
}
|
|
401
|
-
else {
|
|
402
|
-
console.warn(`[session-name] invalid name (${rawName.length} chars, ${wordCount} words): "${rawName.slice(0, 80)}"`);
|
|
403
|
-
this.scheduleSessionNaming(sessionId);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
catch (err) {
|
|
407
|
-
if (!this.sessions.has(sessionId))
|
|
408
|
-
return;
|
|
409
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
410
|
-
console.warn(`[session-name] attempt ${session._namingAttempts} failed: ${msg}`);
|
|
411
|
-
this.scheduleSessionNaming(sessionId);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
/** Re-trigger session naming when user interacts, if the session is still unnamed
|
|
415
|
-
* and no naming timer is already pending. Uses a short delay (5s) since we already
|
|
416
|
-
* have conversation context at this point. */
|
|
417
|
-
retrySessionNamingOnInteraction(sessionId) {
|
|
418
|
-
const session = this.sessions.get(sessionId);
|
|
419
|
-
if (!session)
|
|
420
|
-
return;
|
|
421
|
-
if (!session.name.startsWith('hub:'))
|
|
422
|
-
return;
|
|
423
|
-
// Already have a timer pending or exhausted retries
|
|
424
|
-
if (session._namingTimer)
|
|
425
|
-
return;
|
|
426
|
-
if (session._namingAttempts >= SessionManager.MAX_NAMING_ATTEMPTS)
|
|
427
|
-
return;
|
|
428
|
-
// Short delay — context already available from prior turns
|
|
429
|
-
session._namingTimer = setTimeout(() => {
|
|
430
|
-
delete session._namingTimer;
|
|
431
|
-
this.executeSessionNaming(sessionId);
|
|
432
|
-
}, 5_000);
|
|
433
|
-
}
|
|
434
197
|
/** Add a WebSocket client to a session. Returns the session or undefined if not found.
|
|
435
198
|
* Re-broadcasts any pending approval/control prompts so the joining client sees them. */
|
|
436
199
|
join(sessionId, ws) {
|
|
@@ -542,6 +305,9 @@ export class SessionManager {
|
|
|
542
305
|
console.error('[session-manager] Failed to archive session:', err);
|
|
543
306
|
}
|
|
544
307
|
}
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Claude process lifecycle
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
545
311
|
/**
|
|
546
312
|
* Spawn (or re-spawn) a Claude CLI process for a session.
|
|
547
313
|
* Wires up all event handlers for streaming text, tools, prompts, and auto-restart.
|
|
@@ -556,11 +322,16 @@ export class SessionManager {
|
|
|
556
322
|
if (session.claudeProcess) {
|
|
557
323
|
session.claudeProcess.stop();
|
|
558
324
|
}
|
|
325
|
+
// Derive a session-scoped token instead of forwarding the master auth token.
|
|
326
|
+
// This limits child process privileges to approve/deny for their own session only.
|
|
327
|
+
const sessionToken = this._authToken
|
|
328
|
+
? deriveSessionToken(this._authToken, sessionId)
|
|
329
|
+
: '';
|
|
559
330
|
const extraEnv = {
|
|
560
331
|
CODEKIN_SESSION_ID: sessionId,
|
|
561
332
|
CODEKIN_PORT: String(this._serverPort || PORT),
|
|
562
|
-
CODEKIN_TOKEN:
|
|
563
|
-
CODEKIN_AUTH_TOKEN:
|
|
333
|
+
CODEKIN_TOKEN: sessionToken,
|
|
334
|
+
CODEKIN_AUTH_TOKEN: sessionToken,
|
|
564
335
|
CODEKIN_SESSION_TYPE: session.source || 'manual',
|
|
565
336
|
};
|
|
566
337
|
// Pass CLAUDE_PROJECT_DIR so hooks resolve correctly even when the session's
|
|
@@ -770,7 +541,7 @@ export class SessionManager {
|
|
|
770
541
|
clearTimeout(session._namingTimer);
|
|
771
542
|
delete session._namingTimer;
|
|
772
543
|
}
|
|
773
|
-
void this.executeSessionNaming(sessionId);
|
|
544
|
+
void this.sessionNaming.executeSessionNaming(sessionId);
|
|
774
545
|
}
|
|
775
546
|
}
|
|
776
547
|
/**
|
|
@@ -955,10 +726,10 @@ export class SessionManager {
|
|
|
955
726
|
resolveToolApproval(session, approval, value) {
|
|
956
727
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
957
728
|
if (isAlwaysAllow && !isDeny) {
|
|
958
|
-
this.saveAlwaysAllow(session.workingDir, approval.toolName, approval.toolInput);
|
|
729
|
+
this.approvalManager.saveAlwaysAllow(session.workingDir, approval.toolName, approval.toolInput);
|
|
959
730
|
}
|
|
960
731
|
if (isApprovePattern && !isDeny) {
|
|
961
|
-
this.savePatternApproval(session.workingDir, approval.toolName, approval.toolInput);
|
|
732
|
+
this.approvalManager.savePatternApproval(session.workingDir, approval.toolName, approval.toolInput);
|
|
962
733
|
}
|
|
963
734
|
console.log(`[tool-approval] resolving: allow=${!isDeny} always=${isAlwaysAllow} pattern=${isApprovePattern} tool=${approval.toolName}`);
|
|
964
735
|
approval.resolve({ allow: !isDeny, always: isAlwaysAllow || isApprovePattern });
|
|
@@ -1002,10 +773,10 @@ export class SessionManager {
|
|
|
1002
773
|
sendControlResponseForRequest(session, pending, value) {
|
|
1003
774
|
const { isDeny, isAlwaysAllow, isApprovePattern } = this.decodeApprovalValue(value);
|
|
1004
775
|
if (isAlwaysAllow) {
|
|
1005
|
-
this.saveAlwaysAllow(session.workingDir, pending.toolName, pending.toolInput);
|
|
776
|
+
this.approvalManager.saveAlwaysAllow(session.workingDir, pending.toolName, pending.toolInput);
|
|
1006
777
|
}
|
|
1007
778
|
if (isApprovePattern) {
|
|
1008
|
-
this.savePatternApproval(session.workingDir, pending.toolName, pending.toolInput);
|
|
779
|
+
this.approvalManager.savePatternApproval(session.workingDir, pending.toolName, pending.toolInput);
|
|
1009
780
|
}
|
|
1010
781
|
const behavior = isDeny ? 'deny' : 'allow';
|
|
1011
782
|
session.claudeProcess.sendControlResponse(pending.requestId, behavior);
|
|
@@ -1132,16 +903,19 @@ export class SessionManager {
|
|
|
1132
903
|
this.broadcast(session, { type: 'claude_stopped' });
|
|
1133
904
|
}
|
|
1134
905
|
}
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Helpers
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
/** Check if an error result text matches a transient API error worth retrying. */
|
|
910
|
+
isRetryableApiError(text) {
|
|
911
|
+
return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
|
|
912
|
+
}
|
|
1135
913
|
/**
|
|
1136
914
|
* Build a condensed text summary of a session's conversation history.
|
|
1137
915
|
* Used as context when auto-starting Claude for sessions without a saved
|
|
1138
916
|
* Claude session ID (so the CLI can't resume from its own storage).
|
|
1139
917
|
* Caps output at ~4000 chars, keeping the most recent exchanges.
|
|
1140
918
|
*/
|
|
1141
|
-
/** Check if an error result text matches a transient API error worth retrying. */
|
|
1142
|
-
isRetryableApiError(text) {
|
|
1143
|
-
return API_RETRY_PATTERNS.some((pattern) => pattern.test(text));
|
|
1144
|
-
}
|
|
1145
919
|
buildSessionContext(session) {
|
|
1146
920
|
const history = session.outputHistory;
|
|
1147
921
|
if (history.length === 0)
|
|
@@ -1310,163 +1084,6 @@ export class SessionManager {
|
|
|
1310
1084
|
}
|
|
1311
1085
|
}
|
|
1312
1086
|
}
|
|
1313
|
-
/** Return the auto-approved tools, commands, and patterns for a repo (workingDir). */
|
|
1314
|
-
getApprovals(workingDir) {
|
|
1315
|
-
const entry = this.repoApprovals.get(workingDir);
|
|
1316
|
-
if (!entry)
|
|
1317
|
-
return { tools: [], commands: [], patterns: [] };
|
|
1318
|
-
return {
|
|
1319
|
-
tools: Array.from(entry.tools).sort(),
|
|
1320
|
-
commands: Array.from(entry.commands).sort(),
|
|
1321
|
-
patterns: Array.from(entry.patterns).sort(),
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
|
-
/** Remove an auto-approval rule for a repo (workingDir) and persist to disk.
|
|
1325
|
-
* Returns 'invalid' if no tool/command provided, or boolean indicating if something was deleted.
|
|
1326
|
-
* Pass skipPersist=true for bulk operations (caller must call persistRepoApprovals after). */
|
|
1327
|
-
removeApproval(workingDir, opts, skipPersist = false) {
|
|
1328
|
-
const tool = typeof opts.tool === 'string' ? opts.tool.trim() : '';
|
|
1329
|
-
const command = typeof opts.command === 'string' ? opts.command.trim() : '';
|
|
1330
|
-
const pattern = typeof opts.pattern === 'string' ? opts.pattern.trim() : '';
|
|
1331
|
-
if (!tool && !command && !pattern)
|
|
1332
|
-
return 'invalid';
|
|
1333
|
-
const entry = this.repoApprovals.get(workingDir);
|
|
1334
|
-
if (!entry)
|
|
1335
|
-
return false;
|
|
1336
|
-
let removed = false;
|
|
1337
|
-
if (tool)
|
|
1338
|
-
removed = entry.tools.delete(tool) || removed;
|
|
1339
|
-
if (command)
|
|
1340
|
-
removed = entry.commands.delete(command) || removed;
|
|
1341
|
-
if (pattern)
|
|
1342
|
-
removed = entry.patterns.delete(pattern) || removed;
|
|
1343
|
-
if (removed && !skipPersist)
|
|
1344
|
-
this.persistRepoApprovals();
|
|
1345
|
-
return removed;
|
|
1346
|
-
}
|
|
1347
|
-
/** Write all sessions to disk as JSON (atomic rename to prevent corruption). */
|
|
1348
|
-
persistToDisk() {
|
|
1349
|
-
const data = Array.from(this.sessions.values()).map((s) => ({
|
|
1350
|
-
id: s.id,
|
|
1351
|
-
name: s.name,
|
|
1352
|
-
workingDir: s.workingDir,
|
|
1353
|
-
groupDir: s.groupDir,
|
|
1354
|
-
created: s.created,
|
|
1355
|
-
source: s.source,
|
|
1356
|
-
model: s.model,
|
|
1357
|
-
claudeSessionId: s.claudeSessionId,
|
|
1358
|
-
wasActive: s.claudeProcess?.isAlive() ?? false,
|
|
1359
|
-
outputHistory: s.outputHistory,
|
|
1360
|
-
}));
|
|
1361
|
-
try {
|
|
1362
|
-
mkdirSync(DATA_DIR, { recursive: true });
|
|
1363
|
-
const tmp = SESSIONS_FILE + '.tmp';
|
|
1364
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
1365
|
-
renameSync(tmp, SESSIONS_FILE);
|
|
1366
|
-
}
|
|
1367
|
-
catch (err) {
|
|
1368
|
-
console.error('Failed to persist sessions:', err);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
persistToDiskDebounced() {
|
|
1372
|
-
if (this._persistTimer)
|
|
1373
|
-
return;
|
|
1374
|
-
this._persistTimer = setTimeout(() => {
|
|
1375
|
-
this._persistTimer = null;
|
|
1376
|
-
this.persistToDisk();
|
|
1377
|
-
}, PERSIST_DEBOUNCE_MS);
|
|
1378
|
-
}
|
|
1379
|
-
/** Write repo-level approvals to disk (atomic rename). */
|
|
1380
|
-
persistRepoApprovals() {
|
|
1381
|
-
const data = {};
|
|
1382
|
-
for (const [dir, entry] of this.repoApprovals) {
|
|
1383
|
-
// Only persist non-empty entries
|
|
1384
|
-
if (entry.tools.size > 0 || entry.commands.size > 0 || entry.patterns.size > 0) {
|
|
1385
|
-
data[dir] = {
|
|
1386
|
-
tools: Array.from(entry.tools).sort(),
|
|
1387
|
-
commands: Array.from(entry.commands).sort(),
|
|
1388
|
-
patterns: Array.from(entry.patterns).sort(),
|
|
1389
|
-
};
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
try {
|
|
1393
|
-
mkdirSync(DATA_DIR, { recursive: true });
|
|
1394
|
-
const tmp = REPO_APPROVALS_FILE + '.tmp';
|
|
1395
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
1396
|
-
renameSync(tmp, REPO_APPROVALS_FILE);
|
|
1397
|
-
}
|
|
1398
|
-
catch (err) {
|
|
1399
|
-
console.error('Failed to persist repo approvals:', err);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
persistRepoApprovalsDebounced() {
|
|
1403
|
-
if (this._approvalPersistTimer)
|
|
1404
|
-
return;
|
|
1405
|
-
this._approvalPersistTimer = setTimeout(() => {
|
|
1406
|
-
this._approvalPersistTimer = null;
|
|
1407
|
-
this.persistRepoApprovals();
|
|
1408
|
-
}, PERSIST_DEBOUNCE_MS);
|
|
1409
|
-
}
|
|
1410
|
-
restoreFromDisk() {
|
|
1411
|
-
if (!existsSync(SESSIONS_FILE))
|
|
1412
|
-
return;
|
|
1413
|
-
try {
|
|
1414
|
-
const raw = readFileSync(SESSIONS_FILE, 'utf-8');
|
|
1415
|
-
const data = JSON.parse(raw);
|
|
1416
|
-
for (const s of data) {
|
|
1417
|
-
const session = {
|
|
1418
|
-
id: s.id,
|
|
1419
|
-
name: s.name,
|
|
1420
|
-
workingDir: s.workingDir,
|
|
1421
|
-
groupDir: s.groupDir,
|
|
1422
|
-
created: s.created,
|
|
1423
|
-
source: s.source ?? 'manual',
|
|
1424
|
-
model: s.model,
|
|
1425
|
-
claudeProcess: null,
|
|
1426
|
-
clients: new Set(),
|
|
1427
|
-
outputHistory: s.outputHistory || [],
|
|
1428
|
-
// Restore claudeSessionId so Claude CLI resumes with full conversation
|
|
1429
|
-
// history from its own session storage (not just our 4000-char summary).
|
|
1430
|
-
claudeSessionId: s.claudeSessionId ?? null,
|
|
1431
|
-
restartCount: 0,
|
|
1432
|
-
lastRestartAt: null,
|
|
1433
|
-
_stoppedByUser: false,
|
|
1434
|
-
_stallTimer: null,
|
|
1435
|
-
_wasActiveBeforeRestart: s.wasActive ?? false,
|
|
1436
|
-
_apiRetryCount: 0,
|
|
1437
|
-
_turnCount: 99, // restored sessions already have a name
|
|
1438
|
-
_namingAttempts: 0,
|
|
1439
|
-
isProcessing: false,
|
|
1440
|
-
pendingControlRequests: new Map(),
|
|
1441
|
-
pendingToolApprovals: new Map(),
|
|
1442
|
-
};
|
|
1443
|
-
this.sessions.set(session.id, session);
|
|
1444
|
-
}
|
|
1445
|
-
console.log(`Restored ${data.length} session(s) from disk`);
|
|
1446
|
-
}
|
|
1447
|
-
catch (err) {
|
|
1448
|
-
console.error('Failed to restore sessions from disk:', err);
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
restoreRepoApprovalsFromDisk() {
|
|
1452
|
-
if (!existsSync(REPO_APPROVALS_FILE))
|
|
1453
|
-
return;
|
|
1454
|
-
try {
|
|
1455
|
-
const raw = readFileSync(REPO_APPROVALS_FILE, 'utf-8');
|
|
1456
|
-
const data = JSON.parse(raw);
|
|
1457
|
-
for (const [dir, entry] of Object.entries(data)) {
|
|
1458
|
-
this.repoApprovals.set(dir, {
|
|
1459
|
-
tools: new Set(entry.tools || []),
|
|
1460
|
-
commands: new Set(entry.commands || []),
|
|
1461
|
-
patterns: new Set(entry.patterns || []),
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
console.log(`Restored repo approvals for ${Object.keys(data).length} repo(s) from disk`);
|
|
1465
|
-
}
|
|
1466
|
-
catch (err) {
|
|
1467
|
-
console.error('Failed to restore repo approvals from disk:', err);
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
1087
|
/**
|
|
1471
1088
|
* Auto-restart Claude processes for sessions that were active before a server
|
|
1472
1089
|
* restart. Each session is started with a staggered delay to avoid flooding.
|