codekin 0.3.6 → 0.4.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.
Files changed (55) hide show
  1. package/README.md +13 -2
  2. package/bin/codekin.mjs +2 -1
  3. package/dist/assets/index-BAdQqYEY.js +182 -0
  4. package/dist/assets/index-CeZYNLWt.css +1 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -2
  7. package/server/dist/approval-manager.d.ts +44 -8
  8. package/server/dist/approval-manager.js +262 -23
  9. package/server/dist/approval-manager.js.map +1 -1
  10. package/server/dist/claude-process.d.ts +16 -1
  11. package/server/dist/claude-process.js +36 -12
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/commit-event-handler.d.ts +41 -0
  14. package/server/dist/commit-event-handler.js +99 -0
  15. package/server/dist/commit-event-handler.js.map +1 -0
  16. package/server/dist/commit-event-hooks.d.ts +35 -0
  17. package/server/dist/commit-event-hooks.js +177 -0
  18. package/server/dist/commit-event-hooks.js.map +1 -0
  19. package/server/dist/crypto-utils.js +10 -5
  20. package/server/dist/crypto-utils.js.map +1 -1
  21. package/server/dist/diff-parser.d.ts +23 -0
  22. package/server/dist/diff-parser.js +236 -0
  23. package/server/dist/diff-parser.js.map +1 -0
  24. package/server/dist/session-archive.js +6 -1
  25. package/server/dist/session-archive.js.map +1 -1
  26. package/server/dist/session-manager.d.ts +25 -8
  27. package/server/dist/session-manager.js +370 -30
  28. package/server/dist/session-manager.js.map +1 -1
  29. package/server/dist/session-routes.js +101 -4
  30. package/server/dist/session-routes.js.map +1 -1
  31. package/server/dist/stepflow-handler.js +19 -5
  32. package/server/dist/stepflow-handler.js.map +1 -1
  33. package/server/dist/tsconfig.tsbuildinfo +1 -1
  34. package/server/dist/types.d.ts +63 -8
  35. package/server/dist/upload-routes.d.ts +1 -1
  36. package/server/dist/upload-routes.js +40 -13
  37. package/server/dist/upload-routes.js.map +1 -1
  38. package/server/dist/webhook-handler.js +2 -2
  39. package/server/dist/webhook-handler.js.map +1 -1
  40. package/server/dist/webhook-workspace.js +5 -2
  41. package/server/dist/webhook-workspace.js.map +1 -1
  42. package/server/dist/workflow-loader.d.ts +6 -0
  43. package/server/dist/workflow-loader.js +11 -0
  44. package/server/dist/workflow-loader.js.map +1 -1
  45. package/server/dist/workflow-routes.d.ts +5 -1
  46. package/server/dist/workflow-routes.js +49 -8
  47. package/server/dist/workflow-routes.js.map +1 -1
  48. package/server/dist/ws-message-handler.js +19 -2
  49. package/server/dist/ws-message-handler.js.map +1 -1
  50. package/server/dist/ws-server.js +37 -9
  51. package/server/dist/ws-server.js.map +1 -1
  52. package/server/workflows/commit-review.md +22 -0
  53. package/server/workflows/docs-audit.weekly.md +97 -0
  54. package/dist/assets/index-Cp27uOZO.js +0 -174
  55. package/dist/assets/index-D3SqBuHB.css +0 -1
@@ -16,6 +16,10 @@
16
16
  * - SessionPersistence: disk I/O for session state
17
17
  */
18
18
  import { randomUUID } from 'crypto';
19
+ import { execFile } from 'child_process';
20
+ import { promises as fs } from 'fs';
21
+ import path from 'path';
22
+ import { promisify } from 'util';
19
23
  import { ClaudeProcess } from './claude-process.js';
20
24
  import { SessionArchive } from './session-archive.js';
21
25
  import { cleanupWorkspace } from './webhook-workspace.js';
@@ -24,6 +28,79 @@ import { ApprovalManager } from './approval-manager.js';
24
28
  import { SessionNaming } from './session-naming.js';
25
29
  import { SessionPersistence } from './session-persistence.js';
26
30
  import { deriveSessionToken } from './crypto-utils.js';
31
+ import { parseDiff, createUntrackedFileDiff } from './diff-parser.js';
32
+ const execFileAsync = promisify(execFile);
33
+ /** Max stdout for git commands (2 MB). */
34
+ const GIT_MAX_BUFFER = 2 * 1024 * 1024;
35
+ /** Timeout for git commands (10 seconds). */
36
+ const GIT_TIMEOUT_MS = 10_000;
37
+ /** Max paths per git command to stay under ARG_MAX (~128 KB on Linux). */
38
+ const GIT_PATH_CHUNK_SIZE = 200;
39
+ /** Run a git command as a fixed argv array (no shell interpolation). */
40
+ async function execGit(args, cwd) {
41
+ const { stdout } = await execFileAsync('git', args, {
42
+ cwd,
43
+ maxBuffer: GIT_MAX_BUFFER,
44
+ timeout: GIT_TIMEOUT_MS,
45
+ });
46
+ return stdout;
47
+ }
48
+ /** Run a git command with paths chunked to avoid E2BIG. Concatenates stdout. */
49
+ async function execGitChunked(baseArgs, paths, cwd) {
50
+ let result = '';
51
+ for (let i = 0; i < paths.length; i += GIT_PATH_CHUNK_SIZE) {
52
+ const chunk = paths.slice(i, i + GIT_PATH_CHUNK_SIZE);
53
+ result += await execGit([...baseArgs, '--', ...chunk], cwd);
54
+ }
55
+ return result;
56
+ }
57
+ /** Get file statuses from `git status --porcelain` for given paths (or all). */
58
+ async function getFileStatuses(cwd, paths) {
59
+ const args = ['status', '--porcelain', '-z'];
60
+ if (paths)
61
+ args.push('--', ...paths);
62
+ const raw = await execGit(args, cwd);
63
+ const result = {};
64
+ // git status --porcelain=v1 -z format: XY NUL path NUL
65
+ // XY is a two-character status code: X = index status, Y = worktree status.
66
+ // Examples: " M" = unstaged modification, "A " = staged addition, "??" = untracked.
67
+ // For renames/copies: XY NUL oldpath NUL newpath NUL
68
+ const parts = raw.split('\0');
69
+ let i = 0;
70
+ while (i < parts.length) {
71
+ const entry = parts[i];
72
+ if (entry.length < 3) {
73
+ i++;
74
+ continue;
75
+ } // skip empty trailing entries
76
+ const x = entry[0]; // index status
77
+ const y = entry[1]; // worktree status
78
+ const filePath = entry.slice(3);
79
+ if (x === 'R' || x === 'C') {
80
+ // Rename/copy: next NUL-separated part is the new path
81
+ const newPath = parts[i + 1] ?? filePath;
82
+ result[newPath] = 'renamed';
83
+ i += 2;
84
+ }
85
+ else if (x === 'D' || y === 'D') {
86
+ result[filePath] = 'deleted';
87
+ i++;
88
+ }
89
+ else if (x === '?' && y === '?') {
90
+ result[filePath] = 'added';
91
+ i++;
92
+ }
93
+ else if (x === 'A') {
94
+ result[filePath] = 'added';
95
+ i++;
96
+ }
97
+ else {
98
+ result[filePath] = 'modified';
99
+ i++;
100
+ }
101
+ }
102
+ return result;
103
+ }
27
104
  /** Max messages retained in a session's output history buffer. */
28
105
  const MAX_HISTORY = 2000;
29
106
  /** Max auto-restart attempts before requiring manual intervention. */
@@ -50,22 +127,23 @@ const API_RETRY_PATTERNS = [
50
127
  /503/,
51
128
  ];
52
129
  export class SessionManager {
130
+ /** All active (non-archived) sessions, keyed by session UUID. */
53
131
  sessions = new Map();
54
- /** SQLite archive for closed sessions. */
132
+ /** SQLite archive for closed sessions (persists conversation summaries across restarts). */
55
133
  archive;
56
134
  /** Exposed so ws-server can pass its port to child Claude processes. */
57
135
  _serverPort = PORT;
58
136
  /** Exposed so ws-server can pass the auth token to child Claude processes. */
59
137
  _authToken = '';
60
- /** Callback to broadcast a message to ALL connected WebSocket clients (set by ws-server). */
138
+ /** Callback to broadcast a message to ALL connected WebSocket clients (set by ws-server on startup). */
61
139
  _globalBroadcast = null;
62
- /** Registered listeners notified when a session's Claude process exits. */
140
+ /** Registered listeners notified when a session's Claude process exits (used by webhook-handler for chained workflows). */
63
141
  _exitListeners = [];
64
- /** Delegated approval logic. */
142
+ /** Delegated approval logic (auto-approve patterns, deny-lists, pattern management). */
65
143
  approvalManager;
66
- /** Delegated naming logic. */
144
+ /** Delegated auto-naming logic (generates session names from first user message via Claude API). */
67
145
  sessionNaming;
68
- /** Delegated persistence logic. */
146
+ /** Delegated persistence logic (saves/restores session metadata to disk across server restarts). */
69
147
  sessionPersistence;
70
148
  constructor() {
71
149
  this.archive = new SessionArchive();
@@ -94,11 +172,16 @@ export class SessionManager {
94
172
  getApprovals(workingDir) {
95
173
  return this.approvalManager.getApprovals(workingDir);
96
174
  }
175
+ /** Return approvals effective globally via cross-repo inference. */
176
+ getGlobalApprovals() {
177
+ return this.approvalManager.getGlobalApprovals();
178
+ }
97
179
  /** Remove an auto-approval rule for a repo (workingDir) and persist to disk. */
98
180
  removeApproval(workingDir, opts, skipPersist = false) {
99
181
  return this.approvalManager.removeApproval(workingDir, opts, skipPersist);
100
182
  }
101
183
  /** Add an auto-approval rule for a repo and persist (used by tests via `as any`). */
184
+ /* @ts-expect-error noUnusedLocals — accessed by tests via (sm as any).addRepoApproval */
102
185
  addRepoApproval(workingDir, opts) {
103
186
  this.approvalManager.addRepoApproval(workingDir, opts);
104
187
  }
@@ -156,6 +239,7 @@ export class SessionManager {
156
239
  isProcessing: false,
157
240
  pendingControlRequests: new Map(),
158
241
  pendingToolApprovals: new Map(),
242
+ _leaveGraceTimer: null,
159
243
  };
160
244
  this.sessions.set(id, session);
161
245
  this.persistToDisk();
@@ -200,6 +284,11 @@ export class SessionManager {
200
284
  const session = this.sessions.get(sessionId);
201
285
  if (!session)
202
286
  return undefined;
287
+ // Cancel pending auto-deny from leave grace period
288
+ if (session._leaveGraceTimer) {
289
+ clearTimeout(session._leaveGraceTimer);
290
+ session._leaveGraceTimer = null;
291
+ }
203
292
  session.clients.add(ws);
204
293
  // Re-broadcast pending tool approval prompts (PreToolUse hook path)
205
294
  for (const pending of session.pendingToolApprovals.values()) {
@@ -217,28 +306,37 @@ export class SessionManager {
217
306
  }
218
307
  return session;
219
308
  }
220
- /** Remove a WebSocket client from a session. Auto-denies pending prompts if last client leaves. */
309
+ /** Remove a WebSocket client from a session. Auto-denies pending prompts if last client leaves (after grace period). */
221
310
  leave(sessionId, ws) {
222
311
  const session = this.sessions.get(sessionId);
223
312
  if (session) {
224
313
  session.clients.delete(ws);
225
- // If no clients remain, auto-deny all pending prompts to prevent hangs
314
+ // If no clients remain, wait a grace period before auto-denying.
315
+ // This prevents false denials when the user is just refreshing the page.
226
316
  if (session.clients.size === 0) {
227
- if (session.pendingControlRequests.size > 0) {
228
- console.log(`[session] last client left, auto-denying ${session.pendingControlRequests.size} pending control requests`);
229
- for (const [requestId] of session.pendingControlRequests) {
230
- session.claudeProcess?.sendControlResponse(requestId, 'deny');
231
- }
232
- session.pendingControlRequests.clear();
233
- }
234
- if (session.pendingToolApprovals.size > 0) {
235
- console.log(`[session] last client left, auto-denying ${session.pendingToolApprovals.size} pending tool approval(s)`);
236
- for (const [reqId, pending] of session.pendingToolApprovals) {
237
- pending.resolve({ allow: false, always: false });
238
- this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
317
+ if (session._leaveGraceTimer)
318
+ clearTimeout(session._leaveGraceTimer);
319
+ session._leaveGraceTimer = setTimeout(() => {
320
+ session._leaveGraceTimer = null;
321
+ // Re-check: if still no clients after grace period, auto-deny
322
+ if (session.clients.size === 0) {
323
+ if (session.pendingControlRequests.size > 0) {
324
+ console.log(`[session] last client left, auto-denying ${session.pendingControlRequests.size} pending control requests`);
325
+ for (const [requestId] of session.pendingControlRequests) {
326
+ session.claudeProcess?.sendControlResponse(requestId, 'deny');
327
+ }
328
+ session.pendingControlRequests.clear();
329
+ }
330
+ if (session.pendingToolApprovals.size > 0) {
331
+ console.log(`[session] last client left, auto-denying ${session.pendingToolApprovals.size} pending tool approval(s)`);
332
+ for (const [reqId, pending] of session.pendingToolApprovals) {
333
+ pending.resolve({ allow: false, always: false });
334
+ this.broadcast(session, { type: 'prompt_dismiss', requestId: reqId });
335
+ }
336
+ session.pendingToolApprovals.clear();
337
+ }
239
338
  }
240
- session.pendingToolApprovals.clear();
241
- }
339
+ }, 3000);
242
340
  }
243
341
  }
244
342
  }
@@ -254,6 +352,8 @@ export class SessionManager {
254
352
  clearTimeout(session._apiRetryTimer);
255
353
  if (session._namingTimer)
256
354
  clearTimeout(session._namingTimer);
355
+ if (session._leaveGraceTimer)
356
+ clearTimeout(session._leaveGraceTimer);
257
357
  // Kill claude process if running
258
358
  if (session.claudeProcess) {
259
359
  session.claudeProcess.stop();
@@ -377,7 +477,11 @@ export class SessionManager {
377
477
  }
378
478
  onSystemInit(cp, session, model) {
379
479
  session.claudeSessionId = cp.getSessionId();
380
- this.broadcastAndHistory(session, { type: 'system_message', subtype: 'init', text: `Model: ${model}`, model });
480
+ // Only show model message on first init or when model actually changes
481
+ if (!session._lastReportedModel || session._lastReportedModel !== model) {
482
+ session._lastReportedModel = model;
483
+ this.broadcastAndHistory(session, { type: 'system_message', subtype: 'init', text: `Model: ${model}`, model });
484
+ }
381
485
  }
382
486
  onTextEvent(session, sessionId, text) {
383
487
  this.resetStallTimer(session);
@@ -665,10 +769,40 @@ export class SessionManager {
665
769
  if (!session)
666
770
  return;
667
771
  // Check for pending tool approval from PreToolUse hook
668
- // Match by requestId if provided, otherwise fall back to oldest pending approval
669
- const approval = requestId
670
- ? session.pendingToolApprovals.get(requestId)
671
- : session.pendingToolApprovals.values().next().value; // fallback: oldest
772
+ if (!requestId) {
773
+ const totalPending = session.pendingToolApprovals.size + session.pendingControlRequests.size;
774
+ if (totalPending === 1) {
775
+ // Exactly one pending prompt — safe to infer the target
776
+ const soleApproval = session.pendingToolApprovals.size === 1
777
+ ? session.pendingToolApprovals.values().next().value
778
+ : undefined;
779
+ if (soleApproval) {
780
+ console.warn(`[prompt_response] no requestId, routing to sole pending tool approval: ${soleApproval.toolName}`);
781
+ this.resolveToolApproval(session, soleApproval, value);
782
+ return;
783
+ }
784
+ const soleControl = session.pendingControlRequests.size === 1
785
+ ? session.pendingControlRequests.values().next().value
786
+ : undefined;
787
+ if (soleControl) {
788
+ console.warn(`[prompt_response] no requestId, routing to sole pending control request: ${soleControl.toolName}`);
789
+ requestId = soleControl.requestId;
790
+ }
791
+ }
792
+ else if (totalPending > 1) {
793
+ console.warn(`[prompt_response] no requestId with ${totalPending} pending prompts — rejecting to prevent misrouted response`);
794
+ this.broadcast(session, {
795
+ type: 'system_message',
796
+ subtype: 'error',
797
+ text: 'Prompt response could not be routed: multiple prompts pending. Please refresh and try again.',
798
+ });
799
+ return;
800
+ }
801
+ else {
802
+ console.warn(`[prompt_response] no requestId, no pending prompts — forwarding as user message`);
803
+ }
804
+ }
805
+ const approval = requestId ? session.pendingToolApprovals.get(requestId) : undefined;
672
806
  if (approval) {
673
807
  this.resolveToolApproval(session, approval, value);
674
808
  return;
@@ -676,9 +810,7 @@ export class SessionManager {
676
810
  if (!session.claudeProcess?.isAlive())
677
811
  return;
678
812
  // Find matching pending control request
679
- const pending = requestId
680
- ? session.pendingControlRequests.get(requestId)
681
- : session.pendingControlRequests.values().next().value; // fallback: oldest
813
+ const pending = requestId ? session.pendingControlRequests.get(requestId) : undefined;
682
814
  if (pending) {
683
815
  session.pendingControlRequests.delete(pending.requestId);
684
816
  // Dismiss prompt on all other clients viewing this session
@@ -1037,6 +1169,214 @@ export class SessionManager {
1037
1169
  session.clients.delete(ws);
1038
1170
  }
1039
1171
  }
1172
+ // ---------------------------------------------------------------------------
1173
+ // Diff viewer
1174
+ // ---------------------------------------------------------------------------
1175
+ /**
1176
+ * Run git diff in a session's workingDir and return structured results.
1177
+ * Includes untracked file discovery for 'unstaged' and 'all' scopes.
1178
+ */
1179
+ async getDiff(sessionId, scope = 'all') {
1180
+ const session = this.sessions.get(sessionId);
1181
+ if (!session)
1182
+ return { type: 'diff_error', message: 'Session not found' };
1183
+ const cwd = session.workingDir;
1184
+ try {
1185
+ // Get branch name
1186
+ let branch;
1187
+ try {
1188
+ const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
1189
+ branch = branchResult.trim();
1190
+ if (branch === 'HEAD') {
1191
+ const shaResult = await execGit(['rev-parse', '--short', 'HEAD'], cwd);
1192
+ branch = `detached at ${shaResult.trim()}`;
1193
+ }
1194
+ }
1195
+ catch {
1196
+ branch = 'unknown';
1197
+ }
1198
+ // Build diff command based on scope
1199
+ const diffArgs = ['diff', '--find-renames', '--no-color', '--unified=3'];
1200
+ if (scope === 'staged') {
1201
+ diffArgs.push('--cached');
1202
+ }
1203
+ else if (scope === 'all') {
1204
+ diffArgs.push('HEAD');
1205
+ }
1206
+ // 'unstaged' uses bare `git diff` (working tree vs index)
1207
+ let rawDiff;
1208
+ try {
1209
+ rawDiff = await execGit(diffArgs, cwd);
1210
+ }
1211
+ catch {
1212
+ // git diff HEAD fails if no commits yet — fall back to staged + unstaged
1213
+ if (scope === 'all') {
1214
+ const [staged, unstaged] = await Promise.all([
1215
+ execGit(['diff', '--cached', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
1216
+ execGit(['diff', '--find-renames', '--no-color', '--unified=3'], cwd).catch(() => ''),
1217
+ ]);
1218
+ rawDiff = staged + unstaged;
1219
+ }
1220
+ else {
1221
+ rawDiff = '';
1222
+ }
1223
+ }
1224
+ const { files, truncated, truncationReason } = parseDiff(rawDiff);
1225
+ // Discover untracked files for 'unstaged' and 'all' scopes
1226
+ if (scope !== 'staged') {
1227
+ try {
1228
+ const untrackedRaw = await execGit(['ls-files', '--others', '--exclude-standard'], cwd);
1229
+ const untrackedPaths = untrackedRaw.trim().split('\n').filter(Boolean);
1230
+ for (const relPath of untrackedPaths) {
1231
+ try {
1232
+ const fullPath = path.join(cwd, relPath);
1233
+ // Check if binary by attempting to read as utf-8
1234
+ const content = await fs.readFile(fullPath, 'utf-8');
1235
+ files.push(createUntrackedFileDiff(relPath, content));
1236
+ }
1237
+ catch {
1238
+ // Binary or unreadable — add as binary
1239
+ files.push({
1240
+ path: relPath,
1241
+ status: 'added',
1242
+ isBinary: true,
1243
+ additions: 0,
1244
+ deletions: 0,
1245
+ hunks: [],
1246
+ });
1247
+ }
1248
+ }
1249
+ }
1250
+ catch {
1251
+ // ls-files failed — skip untracked
1252
+ }
1253
+ }
1254
+ const summary = {
1255
+ filesChanged: files.length,
1256
+ insertions: files.reduce((sum, f) => sum + f.additions, 0),
1257
+ deletions: files.reduce((sum, f) => sum + f.deletions, 0),
1258
+ truncated,
1259
+ truncationReason,
1260
+ };
1261
+ return { type: 'diff_result', files, summary, branch, scope };
1262
+ }
1263
+ catch (err) {
1264
+ const message = err instanceof Error ? err.message : 'Failed to get diff';
1265
+ return { type: 'diff_error', message };
1266
+ }
1267
+ }
1268
+ /**
1269
+ * Discard changes in a session's workingDir per the given scope and paths.
1270
+ * Returns a fresh diff_result after discarding.
1271
+ */
1272
+ async discardChanges(sessionId, scope, paths, statuses) {
1273
+ const session = this.sessions.get(sessionId);
1274
+ if (!session)
1275
+ return { type: 'diff_error', message: 'Session not found' };
1276
+ const cwd = session.workingDir;
1277
+ try {
1278
+ // Validate paths — enforce separator boundary to prevent /repoX matching /repo
1279
+ if (paths) {
1280
+ const root = path.resolve(cwd) + path.sep;
1281
+ for (const p of paths) {
1282
+ if (p.includes('..') || path.isAbsolute(p)) {
1283
+ return { type: 'diff_error', message: `Invalid path: ${p}` };
1284
+ }
1285
+ const resolved = path.resolve(cwd, p);
1286
+ if (resolved !== path.resolve(cwd) && !resolved.startsWith(root)) {
1287
+ return { type: 'diff_error', message: `Path escapes working directory: ${p}` };
1288
+ }
1289
+ }
1290
+ }
1291
+ // Determine file statuses if not provided
1292
+ let fileStatuses = statuses ?? {};
1293
+ if (!statuses && paths) {
1294
+ fileStatuses = await getFileStatuses(cwd, paths);
1295
+ }
1296
+ else if (!statuses && !paths) {
1297
+ fileStatuses = await getFileStatuses(cwd);
1298
+ }
1299
+ const targetPaths = paths ?? Object.keys(fileStatuses);
1300
+ // Separate files by status for different handling
1301
+ const trackedPaths = [];
1302
+ const untrackedPaths = [];
1303
+ const stagedNewPaths = [];
1304
+ for (const p of targetPaths) {
1305
+ const status = fileStatuses[p];
1306
+ if (status === 'added') {
1307
+ // Determine if untracked or staged-new by checking the index
1308
+ try {
1309
+ const indexEntry = (await execGit(['ls-files', '--stage', '--', p], cwd)).trim();
1310
+ if (indexEntry) {
1311
+ stagedNewPaths.push(p);
1312
+ }
1313
+ else {
1314
+ untrackedPaths.push(p);
1315
+ }
1316
+ }
1317
+ catch {
1318
+ untrackedPaths.push(p);
1319
+ }
1320
+ }
1321
+ else {
1322
+ trackedPaths.push(p);
1323
+ }
1324
+ }
1325
+ // Handle tracked files (modified, deleted, renamed) with git restore
1326
+ if (trackedPaths.length > 0) {
1327
+ const restoreArgs = ['restore'];
1328
+ if (scope === 'staged') {
1329
+ restoreArgs.push('--staged');
1330
+ }
1331
+ else if (scope === 'all') {
1332
+ restoreArgs.push('--staged', '--worktree');
1333
+ }
1334
+ else {
1335
+ restoreArgs.push('--worktree');
1336
+ }
1337
+ try {
1338
+ await execGitChunked(restoreArgs, trackedPaths, cwd);
1339
+ }
1340
+ catch (err) {
1341
+ // Fallback for Git < 2.23
1342
+ console.warn('[discard] git restore failed, trying fallback:', err);
1343
+ if (scope === 'staged' || scope === 'all') {
1344
+ await execGitChunked(['reset', 'HEAD'], trackedPaths, cwd);
1345
+ }
1346
+ if (scope === 'unstaged' || scope === 'all') {
1347
+ await execGitChunked(['checkout'], trackedPaths, cwd);
1348
+ }
1349
+ }
1350
+ }
1351
+ // Handle staged new files
1352
+ if (stagedNewPaths.length > 0) {
1353
+ if (scope === 'staged') {
1354
+ // Unstage only — leave on disk
1355
+ await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
1356
+ }
1357
+ else if (scope === 'all') {
1358
+ // Remove from index and disk
1359
+ await execGitChunked(['rm', '--cached'], stagedNewPaths, cwd);
1360
+ for (const p of stagedNewPaths) {
1361
+ await fs.unlink(path.join(cwd, p)).catch(() => { });
1362
+ }
1363
+ }
1364
+ // 'unstaged' scope: N/A for staged-new files
1365
+ }
1366
+ // Handle untracked files (delete from disk)
1367
+ if (untrackedPaths.length > 0 && scope !== 'staged') {
1368
+ for (const p of untrackedPaths) {
1369
+ await fs.unlink(path.join(cwd, p)).catch(() => { });
1370
+ }
1371
+ }
1372
+ // Return fresh diff
1373
+ return await this.getDiff(sessionId, scope);
1374
+ }
1375
+ catch (err) {
1376
+ const message = err instanceof Error ? err.message : 'Failed to discard changes';
1377
+ return { type: 'diff_error', message };
1378
+ }
1379
+ }
1040
1380
  /** Graceful shutdown: complete in-progress tasks, persist state, kill all processes. */
1041
1381
  shutdown() {
1042
1382
  // Complete in-progress tasks for active sessions before persisting.