@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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 (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,288 @@
1
+ import { getUnquotedShellText } from './agent-mode.js';
2
+ export const PERMISSION_BUCKETS = ['create', 'edit', 'delete', 'run', 'browse'];
3
+ export function createSessionGrants() {
4
+ return { buckets: [], commandPrefixes: [], origins: [] };
5
+ }
6
+ export function bucketActionLabel(bucket) {
7
+ if (bucket === 'create')
8
+ return 'create files';
9
+ if (bucket === 'edit')
10
+ return 'edit files';
11
+ if (bucket === 'delete')
12
+ return 'delete files';
13
+ if (bucket === 'browse')
14
+ return 'act on any website';
15
+ return 'run commands';
16
+ }
17
+ export function originForUrl(url) {
18
+ const raw = String(url ?? '').trim();
19
+ if (!raw)
20
+ return null;
21
+ try {
22
+ const parsed = new URL(/^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`);
23
+ if (parsed.protocol === 'about:' || parsed.protocol === 'chrome:')
24
+ return null;
25
+ return parsed.origin;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function isOriginGranted(grants, origin) {
32
+ if (!grants || !origin)
33
+ return false;
34
+ return grants.origins?.includes(origin) === true;
35
+ }
36
+ export function grantOrigin(grants, origin) {
37
+ const normalized = String(origin ?? '').trim();
38
+ if (!normalized)
39
+ return;
40
+ const origins = grants.origins ??= [];
41
+ if (!origins.includes(normalized))
42
+ origins.push(normalized);
43
+ }
44
+ const NO_PREFIX_GRANT_BINARIES = new Set([
45
+ 'rm',
46
+ 'rmdir',
47
+ 'mv',
48
+ 'dd',
49
+ 'mkfs',
50
+ 'shred',
51
+ 'sudo',
52
+ 'doas',
53
+ 'su',
54
+ 'chmod',
55
+ 'chown',
56
+ 'chgrp',
57
+ 'kill',
58
+ 'pkill',
59
+ 'killall',
60
+ 'shutdown',
61
+ 'reboot',
62
+ 'curl',
63
+ 'wget',
64
+ 'ssh',
65
+ 'scp',
66
+ 'nc',
67
+ 'eval',
68
+ 'exec',
69
+ 'source',
70
+ ]);
71
+ const INTERPRETER_OR_WRAPPER_BINARIES = new Set([
72
+ 'bash',
73
+ 'sh',
74
+ 'zsh',
75
+ 'fish',
76
+ 'dash',
77
+ 'ksh',
78
+ 'csh',
79
+ 'tcsh',
80
+ 'python',
81
+ 'python2',
82
+ 'python3',
83
+ 'node',
84
+ 'deno',
85
+ 'bun',
86
+ 'ruby',
87
+ 'perl',
88
+ 'php',
89
+ 'lua',
90
+ 'osascript',
91
+ 'awk',
92
+ 'gawk',
93
+ 'sed',
94
+ 'env',
95
+ 'timeout',
96
+ 'xargs',
97
+ 'nohup',
98
+ 'setsid',
99
+ 'watch',
100
+ 'script',
101
+ 'nice',
102
+ 'ionice',
103
+ 'stdbuf',
104
+ 'time',
105
+ 'sudo',
106
+ 'doas',
107
+ 'su',
108
+ 'find',
109
+ 'make',
110
+ ]);
111
+ const SCRIPT_RUNNER_VERBS = new Set(['run', 'exec', 'run-script', 'x', 'dlx']);
112
+ const VERB_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]*$/;
113
+ const BINARY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
114
+ const SHELL_CONTROL_OPERATOR_PATTERN = /[&|;<>\n]/;
115
+ const COMMAND_SUBSTITUTION_PATTERN = /`|\$\(/;
116
+ function normalizeCommand(command) {
117
+ return String(command ?? '').trim().replace(/\s+/g, ' ');
118
+ }
119
+ function parseCommand(command) {
120
+ const raw = String(command ?? '');
121
+ if (!raw.trim())
122
+ return null;
123
+ if (COMMAND_SUBSTITUTION_PATTERN.test(raw))
124
+ return null;
125
+ if (SHELL_CONTROL_OPERATOR_PATTERN.test(getUnquotedShellText(raw)))
126
+ return null;
127
+ const all = normalizeCommand(raw).match(/\S+/g) ?? [];
128
+ const tokens = [];
129
+ for (const token of all) {
130
+ if (!tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
131
+ continue;
132
+ if (!tokens.length && token === 'command')
133
+ continue;
134
+ tokens.push(token);
135
+ }
136
+ if (!tokens.length)
137
+ return null;
138
+ const binary = (tokens[0].split('/').pop() ?? '').trim();
139
+ if (!binary)
140
+ return null;
141
+ tokens[0] = binary;
142
+ return { canonical: tokens.join(' '), tokens };
143
+ }
144
+ function grantPrefixFromTokens(tokens) {
145
+ const binary = tokens[0];
146
+ if (!BINARY_PATTERN.test(binary))
147
+ return null;
148
+ if (NO_PREFIX_GRANT_BINARIES.has(binary))
149
+ return null;
150
+ if (INTERPRETER_OR_WRAPPER_BINARIES.has(binary))
151
+ return null;
152
+ const verb = tokens[1];
153
+ if (!verb || !VERB_PATTERN.test(verb))
154
+ return null;
155
+ if (SCRIPT_RUNNER_VERBS.has(verb)) {
156
+ const script = tokens[2];
157
+ if (!script || !VERB_PATTERN.test(script))
158
+ return null;
159
+ return `${binary} ${verb} ${script}`;
160
+ }
161
+ return `${binary} ${verb}`;
162
+ }
163
+ export function commandGrantPrefix(command) {
164
+ const parsed = parseCommand(command);
165
+ return parsed ? grantPrefixFromTokens(parsed.tokens) : null;
166
+ }
167
+ export function isBucketGranted(grants, bucket) {
168
+ return Boolean(grants?.buckets.includes(bucket));
169
+ }
170
+ export function isCommandGranted(grants, command) {
171
+ if (!grants)
172
+ return false;
173
+ if (grants.buckets.includes('run'))
174
+ return true;
175
+ if (!grants.commandPrefixes.length)
176
+ return false;
177
+ const parsed = parseCommand(command);
178
+ if (!parsed)
179
+ return false;
180
+ if (grantPrefixFromTokens(parsed.tokens) === null)
181
+ return false;
182
+ return grants.commandPrefixes.some((prefix) => parsed.canonical === prefix || parsed.canonical.startsWith(`${prefix} `));
183
+ }
184
+ export function grantBucket(grants, bucket) {
185
+ if (!grants.buckets.includes(bucket))
186
+ grants.buckets.push(bucket);
187
+ }
188
+ export function grantCommandPrefix(grants, prefix) {
189
+ const normalized = normalizeCommand(prefix);
190
+ if (!normalized)
191
+ return;
192
+ if (!grants.commandPrefixes.includes(normalized)) {
193
+ grants.commandPrefixes.push(normalized);
194
+ }
195
+ }
196
+ export function buildPermissionOptions(bucket, command, origin) {
197
+ const options = [
198
+ { label: 'Approve once', decision: { kind: 'once' } },
199
+ ];
200
+ const prefix = bucket === 'run' && command ? commandGrantPrefix(command) : null;
201
+ if (prefix) {
202
+ options.push({
203
+ label: `Always allow: ${prefix}`,
204
+ decision: { kind: 'always-prefix', prefix },
205
+ });
206
+ }
207
+ if (bucket === 'browse' && origin) {
208
+ options.push({
209
+ label: `Allow this site for the session: ${origin}`,
210
+ decision: { kind: 'always-origin', origin },
211
+ });
212
+ }
213
+ options.push({
214
+ label: `Always allow: ${bucketActionLabel(bucket)}`,
215
+ decision: { kind: 'always-bucket' },
216
+ });
217
+ options.push({ label: 'Deny', decision: { kind: 'deny' } });
218
+ return options;
219
+ }
220
+ function declinedSubject(bucket) {
221
+ if (bucket === 'run') {
222
+ return { noun: 'command', effect: 'Nothing was executed' };
223
+ }
224
+ if (bucket === 'create') {
225
+ return { noun: 'file creation', effect: 'Nothing was created' };
226
+ }
227
+ if (bucket === 'delete') {
228
+ return { noun: 'file deletion', effect: 'Nothing was deleted' };
229
+ }
230
+ if (bucket === 'browse') {
231
+ return { noun: 'browser action', effect: 'The page was not touched' };
232
+ }
233
+ return { noun: 'edit', effect: 'Nothing was changed' };
234
+ }
235
+ function declinedResponse(bucket, toolName, extra) {
236
+ const { noun, effect } = declinedSubject(bucket);
237
+ return {
238
+ ok: false,
239
+ skipped: true,
240
+ ...extra,
241
+ failureCategory: 'user_declined',
242
+ failureDetails: {
243
+ category: 'user_declined',
244
+ tool: toolName,
245
+ action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
246
+ },
247
+ error: `The real user rejected this proposed ${noun}. ${effect}; this was not a tool failure or an automated system skip.`,
248
+ };
249
+ }
250
+ export async function ensurePermission(context, request, toolName, extra = {}) {
251
+ if (context.autoYes)
252
+ return null;
253
+ if (isBucketGranted(context.grants, request.bucket))
254
+ return null;
255
+ if (request.bucket === 'run' &&
256
+ request.command &&
257
+ isCommandGranted(context.grants, request.command)) {
258
+ return null;
259
+ }
260
+ if (request.bucket === 'browse' &&
261
+ isOriginGranted(context.grants, request.origin ?? null)) {
262
+ return null;
263
+ }
264
+ if (!context.requestPermission) {
265
+ return {
266
+ ok: false,
267
+ ...extra,
268
+ error: `requestPermission is required when autoYes is false (${toolName})`,
269
+ };
270
+ }
271
+ const options = buildPermissionOptions(request.bucket, request.command, request.origin);
272
+ const decision = await context.requestPermission({ ...request, options });
273
+ if (decision.kind === 'deny') {
274
+ return declinedResponse(request.bucket, toolName, extra);
275
+ }
276
+ if (context.grants) {
277
+ if (decision.kind === 'always-bucket') {
278
+ grantBucket(context.grants, request.bucket);
279
+ }
280
+ else if (decision.kind === 'always-prefix') {
281
+ grantCommandPrefix(context.grants, decision.prefix);
282
+ }
283
+ else if (decision.kind === 'always-origin') {
284
+ grantOrigin(context.grants, decision.origin);
285
+ }
286
+ }
287
+ return null;
288
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getClientStateDir } from './client-state.js';
4
+ export const DEFAULT_PREFERENCES = {
5
+ notifications: true,
6
+ };
7
+ export function getPreferencesPath(env = process.env) {
8
+ return path.join(getClientStateDir(env), 'prefs.json');
9
+ }
10
+ export function readPreferences(env = process.env) {
11
+ const filePath = getPreferencesPath(env);
12
+ if (!existsSync(filePath))
13
+ return { ...DEFAULT_PREFERENCES };
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
16
+ return {
17
+ notifications: parsed.notifications === false ? false : DEFAULT_PREFERENCES.notifications,
18
+ };
19
+ }
20
+ catch {
21
+ return { ...DEFAULT_PREFERENCES };
22
+ }
23
+ }
24
+ export function writePreferences(preferences, env = process.env) {
25
+ const filePath = getPreferencesPath(env);
26
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
27
+ const tempPath = `${filePath}.${process.pid}.tmp`;
28
+ try {
29
+ writeFileSync(tempPath, `${JSON.stringify(preferences, null, 2)}\n`, {
30
+ encoding: 'utf8',
31
+ mode: 0o600,
32
+ });
33
+ renameSync(tempPath, filePath);
34
+ }
35
+ catch (error) {
36
+ rmSync(tempPath, { force: true });
37
+ throw error;
38
+ }
39
+ }
@@ -0,0 +1,35 @@
1
+ import { isQuotaExceededError, ServerApiError } from './api/http.js';
2
+ import { USAGE_WINDOWS, formatUtcMinute, usageWindowLabel } from './usage.js';
3
+ export function buildQuotaNotice(error) {
4
+ if (!(error instanceof ServerApiError) || !isQuotaExceededError(error)) {
5
+ return null;
6
+ }
7
+ const label = usageWindowLabel(String(error.details.window ?? ''));
8
+ const inSentence = label.toLowerCase();
9
+ const resetsAt = error.details.resetsAt ?? null;
10
+ const lines = [];
11
+ if (label) {
12
+ const reset = resetsAt ? formatUtcMinute(resetsAt) : '';
13
+ lines.push(reset && reset !== 'unknown'
14
+ ? `You've used your ${inSentence} limit. It resets at ${reset}.`
15
+ : `You've used your ${inSentence} limit.`);
16
+ }
17
+ else {
18
+ lines.push(firstMessageLine(error));
19
+ }
20
+ lines.push(label
21
+ ? `Run /usage to see your ${otherWindowNames(label)} windows.`
22
+ : 'Run /usage to see your remaining usage.');
23
+ return { title: 'Usage limit reached', body: lines.join('\n') };
24
+ }
25
+ function otherWindowNames(usedLabel) {
26
+ const names = USAGE_WINDOWS.map(([name]) => name)
27
+ .filter((name) => name !== usedLabel)
28
+ .map((name) => name.toLowerCase());
29
+ return names.length > 1
30
+ ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
31
+ : (names[0] ?? '');
32
+ }
33
+ function firstMessageLine(error) {
34
+ return String(error.message ?? '').split('\n')[0]?.trim() || 'Usage limit reached.';
35
+ }
@@ -4,7 +4,6 @@ import path from 'node:path';
4
4
  import { ARTIFACT_IGNORE_DIRS, normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
5
  import { hashBytes, readFileEditSnapshot, storedContentBuffer, } from './edit-journal.js';
6
6
  import { deleteProjectFile, resolveProjectPath, writeProjectFile, writeProjectFileBuffer, } from './patcher.js';
7
- import { removeIndexFile, upsertIndexFile } from './project-index.js';
8
7
  const MAX_CHECKPOINTS = 20;
9
8
  const MAX_SESSION_EDITS = 500;
10
9
  const MAX_READ_RECORDS = 200;
@@ -672,7 +671,6 @@ export async function restoreCheckpointFiles(args) {
672
671
  const restored = [];
673
672
  const applied = [];
674
673
  let changed = false;
675
- const syncPolicy = args.projectIndex.initialized;
676
674
  for (const snapshot of targets) {
677
675
  const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
678
676
  try {
@@ -680,15 +678,11 @@ export async function restoreCheckpointFiles(args) {
680
678
  const result = writeStoredProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '', snapshot.contentEncoding);
681
679
  if (result.changed)
682
680
  changed = true;
683
- if (syncPolicy)
684
- await upsertIndexFile(args.projectIndex, snapshot.filePath);
685
681
  }
686
682
  else {
687
683
  const result = deleteProjectFile(args.rootDir, snapshot.filePath);
688
684
  if (result.deleted)
689
685
  changed = true;
690
- if (syncPolicy)
691
- await removeIndexFile(args.projectIndex, snapshot.filePath);
692
686
  }
693
687
  const after = readFileEditSnapshot(args.rootDir, snapshot.filePath);
694
688
  applied.push({ snapshot, before, after });
@@ -710,15 +704,9 @@ export async function restoreCheckpointFiles(args) {
710
704
  throw new Error('previous file content is unavailable');
711
705
  }
712
706
  writeStoredProjectFile(args.rootDir, item.snapshot.filePath, item.before.content, item.before.contentEncoding);
713
- if (syncPolicy) {
714
- await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
715
- }
716
707
  }
717
708
  else {
718
709
  deleteProjectFile(args.rootDir, item.snapshot.filePath);
719
- if (syncPolicy) {
720
- await removeIndexFile(args.projectIndex, item.snapshot.filePath);
721
- }
722
710
  }
723
711
  rolledBack.push({ filePath: item.snapshot.filePath });
724
712
  }
@@ -1,12 +1,16 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { getClientStateDir } from './client-state.js';
6
+ import { findStoredImageByContent, pruneSessionImages, readSessionImage, sweepOrphanSessionImages, } from './core/session-image-store.js';
7
+ import { sweepGeneratedImages } from './tools/save-generated-image.js';
5
8
  import { normalizeAssistantEditJournal } from './edit-journal.js';
9
+ import { createSessionGrants } from './permissions.js';
6
10
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
7
- import { truncate } from './utils.js';
11
+ import { singleLinePreview } from './utils.js';
8
12
  const SESSION_STORE_VERSION = 1;
9
- const MAX_RECENT_SESSIONS = 5;
13
+ export const MAX_RECENT_SESSIONS = 10;
10
14
  function cloneJson(value) {
11
15
  return JSON.parse(JSON.stringify(value ?? null));
12
16
  }
@@ -77,6 +81,45 @@ function listSessionFiles(rootDir, env = process.env) {
77
81
  .filter((name) => name.endsWith('.json'))
78
82
  .map((name) => path.join(dir, name));
79
83
  }
84
+ function normalizeBranch(value) {
85
+ const text = String(value ?? '')
86
+ .replace(/[\r\n\t]/g, ' ')
87
+ .trim();
88
+ return text ? text.slice(0, 120) : null;
89
+ }
90
+ function dehydrateHistoryImages(history) {
91
+ return history.map((entry) => ({
92
+ ...entry,
93
+ parts: (entry.parts ?? []).map((part) => {
94
+ if (!part?.inlineData?.data)
95
+ return part;
96
+ const cachePath = part.imageCachePath ?? findStoredImageByContent(part.inlineData.data);
97
+ if (!cachePath)
98
+ return part;
99
+ return {
100
+ imageRef: { cachePath, mimeType: part.inlineData.mimeType },
101
+ text: '[image stored in this session]',
102
+ };
103
+ }),
104
+ }));
105
+ }
106
+ function rehydrateHistoryImages(history) {
107
+ return history.map((entry) => ({
108
+ ...entry,
109
+ parts: (entry.parts ?? []).map((part) => {
110
+ const ref = part?.imageRef;
111
+ if (!ref?.cachePath)
112
+ return part;
113
+ const stored = readSessionImage(String(ref.cachePath));
114
+ if (!stored)
115
+ return { text: part.text ?? '[image no longer available]' };
116
+ return {
117
+ inlineData: { mimeType: stored.mimeType, data: stored.base64Data },
118
+ imageCachePath: ref.cachePath,
119
+ };
120
+ }),
121
+ }));
122
+ }
80
123
  function normalizeHistory(value) {
81
124
  if (!Array.isArray(value))
82
125
  return [];
@@ -107,6 +150,7 @@ function normalizeSnapshot(raw, rootDir) {
107
150
  createdAt: normalizeIsoDate(raw.createdAt),
108
151
  updatedAt: normalizeIsoDate(raw.updatedAt),
109
152
  modelId,
153
+ branch: normalizeBranch(raw.branch),
110
154
  history: cloneJson(normalizeHistory(raw.history)),
111
155
  clientState: sanitizeClientState(raw.clientState),
112
156
  serverState: sanitizeOpaqueState(raw.serverState),
@@ -145,14 +189,58 @@ function assertSessionNameAvailable(rootDir, name, sessionId, env = process.env)
145
189
  throw new Error(`Session name "${name}" is already used by ${duplicate.id}. Use --session "${name}" to resume it or choose a different name.`);
146
190
  }
147
191
  }
192
+ function listAllSessionIds(env = process.env) {
193
+ const ids = new Set();
194
+ const projectsDir = path.join(getSessionBaseDir(env), 'sessions', 'projects');
195
+ if (!existsSync(projectsDir))
196
+ return ids;
197
+ try {
198
+ for (const project of readdirSync(projectsDir)) {
199
+ const dir = path.join(projectsDir, project);
200
+ try {
201
+ for (const file of readdirSync(dir)) {
202
+ if (file.endsWith('.json'))
203
+ ids.add(path.basename(file, '.json'));
204
+ }
205
+ }
206
+ catch {
207
+ }
208
+ }
209
+ }
210
+ catch {
211
+ return ids;
212
+ }
213
+ return ids;
214
+ }
148
215
  export function pruneSavedSessions(rootDir, env = process.env) {
149
216
  const snapshots = loadAllSnapshots(rootDir, env);
150
217
  const keep = new Set(snapshots.slice(0, MAX_RECENT_SESSIONS).map((snapshot) => snapshot.id));
151
218
  for (const snapshot of snapshots.slice(MAX_RECENT_SESSIONS)) {
152
219
  if (!keep.has(snapshot.id)) {
153
220
  rmSync(getSessionPath(rootDir, snapshot.id, env), { force: true });
221
+ pruneSessionImages(snapshot.id, env);
154
222
  }
155
223
  }
224
+ sweepOrphanSessionImages({
225
+ activeSessionIds: listAllSessionIds(env),
226
+ env,
227
+ });
228
+ sweepGeneratedImages({ env });
229
+ }
230
+ export function readGitBranch(rootDir) {
231
+ try {
232
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
233
+ cwd: rootDir,
234
+ encoding: 'utf8',
235
+ timeout: 1500,
236
+ stdio: ['ignore', 'pipe', 'ignore'],
237
+ });
238
+ const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
239
+ return branch && branch !== 'HEAD' ? branch : null;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
156
244
  }
157
245
  export function snapshotFromSession(session) {
158
246
  const now = new Date().toISOString();
@@ -168,6 +256,7 @@ export function snapshotFromSession(session) {
168
256
  createdAt,
169
257
  updatedAt: now,
170
258
  modelId: session.modelId,
259
+ branch: readGitBranch(session.rootDir),
171
260
  history: cloneJson(session.history),
172
261
  clientState: {
173
262
  editCounter: session.clientState.editCounter,
@@ -187,6 +276,7 @@ export function saveSessionState(session, env = process.env) {
187
276
  }
188
277
  export function applySessionSnapshot(session, snapshot, options = {}) {
189
278
  const currentAgentMode = session.agentMode;
279
+ const previousSessionId = session.sessionId;
190
280
  session.sessionId = snapshot.id;
191
281
  session.sessionName = snapshot.name;
192
282
  session.sessionCreatedAt = snapshot.createdAt;
@@ -196,6 +286,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
196
286
  session.serverState = sanitizeOpaqueState(snapshot.serverState);
197
287
  session.agentMode = options.preserveAgentMode ? currentAgentMode : 'default';
198
288
  session.autoYes = session.agentMode === 'auto-accept';
289
+ if (previousSessionId !== session.sessionId) {
290
+ session.grants = createSessionGrants();
291
+ }
199
292
  session.turnState = {
200
293
  id: null,
201
294
  historyStartIndex: session.history.length,
@@ -210,6 +303,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
210
303
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
211
304
  };
212
305
  }
306
+ export function sessionHasUserMessage(session) {
307
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
308
+ }
213
309
  function extractMarkedSection(text, marker) {
214
310
  const index = text.indexOf(marker);
215
311
  if (index === -1)
@@ -218,23 +314,27 @@ function extractMarkedSection(text, marker) {
218
314
  const end = after.indexOf('\n\n');
219
315
  return (end === -1 ? after : after.slice(0, end)).trim();
220
316
  }
221
- function extractLastUserMessage(history) {
222
- for (let i = history.length - 1; i >= 0; i--) {
223
- const entry = history[i];
224
- if (!entry || entry.role !== 'user')
225
- continue;
226
- const text = (entry.parts ?? [])
227
- .map((part) => (typeof part?.text === 'string' ? part.text : ''))
228
- .filter(Boolean)
229
- .join('\n')
230
- .trim();
231
- if (!text)
232
- continue;
233
- const request = extractMarkedSection(text, 'Current user request:') ||
234
- extractMarkedSection(text, 'User request:');
235
- const message = extractMarkedSection(text, 'Current user message:') ||
236
- extractMarkedSection(text, 'User message:');
237
- return truncate(request || message || text, 120);
317
+ export function userPromptText(entry) {
318
+ if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
319
+ return '';
320
+ const text = (entry.parts ?? [])
321
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
322
+ .filter(Boolean)
323
+ .join('\n')
324
+ .trim();
325
+ if (!text)
326
+ return '';
327
+ const request = extractMarkedSection(text, 'Current user request:') ||
328
+ extractMarkedSection(text, 'User request:');
329
+ const message = extractMarkedSection(text, 'Current user message:') ||
330
+ extractMarkedSection(text, 'User message:');
331
+ return (request || message || text).trim();
332
+ }
333
+ function extractFirstUserPrompt(history) {
334
+ for (const entry of history) {
335
+ const text = userPromptText(entry);
336
+ if (text)
337
+ return singleLinePreview(text, 120);
238
338
  }
239
339
  return '';
240
340
  }
@@ -247,8 +347,9 @@ function metadataFromSnapshot(snapshot) {
247
347
  updatedAt: snapshot.updatedAt,
248
348
  modelId: snapshot.modelId,
249
349
  messageCount: snapshot.history.length,
250
- lastUserMessage: extractLastUserMessage(snapshot.history),
350
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
251
351
  summaryPreview: '',
352
+ branch: snapshot.branch ?? null,
252
353
  };
253
354
  }
254
355
  export function listSessionMetadata(rootDir, env = process.env) {