@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.31

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 (58) hide show
  1. package/README.md +39 -6
  2. package/dist/bin/ai.js +142 -383
  3. package/dist/src/agent-mode.js +1 -6
  4. package/dist/src/api/auth.js +6 -4
  5. package/dist/src/api/browser-login.js +152 -37
  6. package/dist/src/api/chat.js +258 -38
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/default-host.js +1 -0
  9. package/dist/src/api/http.js +69 -7
  10. package/dist/src/api/models.js +19 -10
  11. package/dist/src/background-jobs.js +2 -2
  12. package/dist/src/cli-args.js +19 -5
  13. package/dist/src/core/clipboard.js +7 -13
  14. package/dist/src/core/image-limits.js +56 -0
  15. package/dist/src/core/image-path-extractor.js +70 -3
  16. package/dist/src/core/session-image-store.js +199 -0
  17. package/dist/src/executor.js +25 -3
  18. package/dist/src/help-text.js +67 -18
  19. package/dist/src/permissions.js +243 -0
  20. package/dist/src/session-safety.js +0 -12
  21. package/dist/src/session-store.js +121 -20
  22. package/dist/src/session.js +14 -3
  23. package/dist/src/signin.js +58 -0
  24. package/dist/src/tool-executor.js +11 -46
  25. package/dist/src/tools/delete-file.js +15 -3
  26. package/dist/src/tools/index.js +13 -10
  27. package/dist/src/tools/patch-file.js +12 -26
  28. package/dist/src/tools/read-image-file.js +85 -0
  29. package/dist/src/tools/replace-document-text.js +28 -18
  30. package/dist/src/tools/restore-checkpoint.js +0 -1
  31. package/dist/src/tools/run-command.js +14 -71
  32. package/dist/src/tools/run-node-script.js +12 -81
  33. package/dist/src/tools/save-generated-image.js +120 -0
  34. package/dist/src/tools/str-replace.js +12 -26
  35. package/dist/src/tools/undo-edit.js +1 -6
  36. package/dist/src/tools/write-file.js +67 -11
  37. package/dist/src/turn-failure-marker.js +11 -0
  38. package/dist/src/ui/prompt-history-store.js +1 -1
  39. package/dist/src/ui/repl.js +649 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +453 -115
  42. package/dist/src/ui/tui/markdown-render.js +81 -73
  43. package/dist/src/ui/tui/shell-input.js +206 -63
  44. package/dist/src/ui/tui/terminal-theme.js +28 -0
  45. package/dist/src/ui/tui/terminal-title.js +3 -0
  46. package/dist/src/ui/tui/terminal-writes.js +48 -0
  47. package/dist/src/ui/tui/text.js +158 -4
  48. package/dist/src/ui/tui/user-input.js +568 -0
  49. package/dist/src/utils.js +9 -0
  50. package/package.json +29 -6
  51. package/dist/src/markdown-renderer.js +0 -112
  52. package/dist/src/project-index.js +0 -221
  53. package/dist/src/tools/code-intel.js +0 -472
  54. package/dist/src/tools/find-symbol.js +0 -70
  55. package/dist/src/tools/hover-symbol.js +0 -95
  56. package/dist/src/tools/list-symbols.js +0 -55
  57. package/dist/src/tools/search-code.js +0 -37
  58. package/dist/src/tools/signature-help.js +0 -118
@@ -14,7 +14,7 @@ const PASTE_SHORTCUT = pasteShortcutForPlatform();
14
14
  const HELP_MARKDOWN = [
15
15
  '# TheGitAI',
16
16
  '',
17
- 'Interactive terminal coding agent. Local repo indexing, file edits, and',
17
+ 'Interactive terminal coding agent. Local repo search, file edits, and',
18
18
  'shell commands run on your machine. Model inference and server-executed',
19
19
  'tools run on the server.',
20
20
  '',
@@ -22,11 +22,16 @@ const HELP_MARKDOWN = [
22
22
  '',
23
23
  '- `ai` — start an interactive chat session in the current repo',
24
24
  '- `ai "<request>"` — start an interactive session with `<request>` as the first message',
25
+ '- Coding sessions require terminal stdin and stdout; piped prompts are not supported.',
25
26
  '',
26
27
  '## Auth',
27
28
  '',
28
- '- `ai login` — sign in to TheGitAI (opens your browser to the website)',
29
- '- `ai login --no-browser` — print a URL and paste the code (for SSH/headless)',
29
+ '- `ai` — signs you in if you are not already, then starts a session',
30
+ '- `ai login` — the same thing; kept for muscle memory',
31
+ '- Signing in opens your browser. When it cannot open one — over SSH, in a',
32
+ ' container, or when the browser you want is on another device — the same',
33
+ ' screen prints a URL you can open anywhere and takes the code that page',
34
+ ' gives you back. There is no mode to pick and no flag to remember.',
30
35
  '- `ai whoami` — show the signed-in account',
31
36
  '- `ai --usage` — show account usage percentage and reset times',
32
37
  '- `ai logout` — sign out',
@@ -35,8 +40,8 @@ const HELP_MARKDOWN = [
35
40
  '',
36
41
  '- `ai --list-sessions` — list saved sessions for this repo',
37
42
  '- `ai --session <id|name>` — resume a saved session by id or name',
38
- '- Sessions are stored locally and scoped to the current repo. The five',
39
- ' most recent sessions per repo are kept.',
43
+ '- Sessions are stored locally and can be listed or resumed in the same repo.',
44
+ ' Continuing one requires the TheGitAI account used for that session.',
40
45
  '',
41
46
  '## Options',
42
47
  '',
@@ -46,7 +51,9 @@ const HELP_MARKDOWN = [
46
51
  '',
47
52
  '## Modes',
48
53
  '',
49
- '- Default — asks before shell commands and file edits.',
54
+ '- Default — asks before creating, editing or deleting files and before',
55
+ ' running commands. Each answer can be remembered for the rest of the',
56
+ ' session; see Safety & approvals.',
50
57
  '- Auto-Accept — approves shell commands and file edits for the session.',
51
58
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
52
59
  ' with file-reading shell commands only. It will not edit files or run tests,',
@@ -56,7 +63,15 @@ const HELP_MARKDOWN = [
56
63
  '## Keys & clipboard',
57
64
  '',
58
65
  '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
59
- ' **Ctrl+C** quits. These are the same on macOS, Linux, and Windows.',
66
+ ' **Ctrl+C** clears the composer or the queued message, and quits once there',
67
+ ' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
68
+ '- **While the agent is working**, Enter queues your message and locks the',
69
+ ' composer. Press **Enter** again to send it into the running turn: the agent',
70
+ ' picks it up at its next step instead of waiting for the turn to finish, and',
71
+ ' the Your messages panel tracks it from queued to delivered. **↑** brings it',
72
+ ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
73
+ ' composer for the next message. **Ctrl+V** pastes a screenshot onto the',
74
+ ' queued message, and it is delivered into the running turn with the text.',
60
75
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
61
76
  ' on this system) or by right-clicking the composer.',
62
77
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -69,6 +84,8 @@ const HELP_MARKDOWN = [
69
84
  '',
70
85
  '- `/help` — show this help',
71
86
  '- `/about` — show version and platform info',
87
+ '- `/report` — file a bug: opens the public GitHub issue form in your',
88
+ ' browser (the tracker is public — do not paste secrets)',
72
89
  '- `/usage` — show account usage percentage and reset times',
73
90
  '- `/model` — list supported models and pick one',
74
91
  '- `/model <id>` — switch the active model without clearing history',
@@ -77,14 +94,34 @@ const HELP_MARKDOWN = [
77
94
  ' browse them, press Enter to expand one and read its output, k to stop it',
78
95
  '- `/jobs output <id>` — print one job\'s full captured output',
79
96
  '- `/jobs kill <id>` — stop one background job',
80
- '- `/clear` — clear the current conversation history',
97
+ '- `/new` — start a new conversation; this session remains saved',
98
+ '- `/logout` — sign out and quit',
81
99
  '- `/exit` — quit the session',
82
100
  '',
83
101
  '## Safety & approvals',
84
102
  '',
85
- '- TheGitAI asks before running shell commands or applying file edits.',
86
- '- At each prompt: **y** approves once, **a** approves the rest of the',
87
- ' session, **n** denies.',
103
+ '- In Default mode TheGitAI asks before it creates a file, edits an existing',
104
+ ' file, deletes a file, or runs a command. These are four separate',
105
+ ' permissions: allowing edits does not allow deletions, and allowing file',
106
+ ' changes does not allow commands.',
107
+ '- At each prompt: **↑/↓** moves between choices, **Enter** confirms the',
108
+ ' highlighted one, and **Esc** denies. Deny is selected by default.',
109
+ ' Single-letter shortcuts were removed on purpose: a prompt can appear',
110
+ ' while you are typing, and a stray letter must never approve anything.',
111
+ '- Every prompt offers Approve once, an "Always allow" for that kind of',
112
+ ' action, and Deny. A command also offers to remember just its prefix — for',
113
+ ' example approving `npm test -- --watch=false` can allow `npm test`.',
114
+ '- A prefix is only offered when the command shows a plain verb, as in',
115
+ ' `npm test` or `git status`. Commands with no verb (`ls -la`), interpreters',
116
+ ' and wrappers (`bash -c`, `python -c`, `env`, `timeout`, `make`), and',
117
+ ' destructive or network binaries (`rm`, `sudo`, `curl`) are never offered',
118
+ ' one — a bare binary grant would mean "anything this program can do".',
119
+ ' `npm run` must name its script, so the grant is `npm run build`.',
120
+ '- A remembered prefix never covers a command that chains, pipes, redirects,',
121
+ ' or substitutes, so allowing `npm test` can never green-light',
122
+ ' `npm test && rm -rf build`.',
123
+ '- Everything you allow lasts for the current session only and is forgotten',
124
+ ' when it ends. `/new` clears it immediately.',
88
125
  '- If an approved `sudo` command needs a password, the TUI shows the exact',
89
126
  ' command and keeps the password masked and local.',
90
127
  '- `-y` / `--yes` at startup auto-approves every shell command and file',
@@ -97,17 +134,22 @@ const HELP_MARKDOWN = [
97
134
  ' ends.',
98
135
  '- File and shell operations are confined to the target repo root.',
99
136
  '- Sensitive directories (`.git`, `node_modules`, build output) are',
100
- ' never indexed.',
137
+ ' excluded from search and listing.',
101
138
  '',
102
139
  '## Troubleshooting',
103
140
  '',
104
- '- "Not logged in" → run `ai login`.',
105
141
  '- Auth or permission errors → run `ai whoami` to confirm the signed-in',
106
142
  ' account.',
107
143
  '- Usage or quota errors → run `ai --usage`.',
108
- '- Stuck on the wrong account → `ai logout`, then `ai login` again.',
109
- '- For anything else, re-run the command and report the printed error',
110
- ' message there is no client-side debug mode by design.',
144
+ '- Signed in with the wrong account → `/logout` (or `ai logout`), then run',
145
+ ' `ai` and sign in as the account you intended to use.',
146
+ '- The browser did not open, or opened on the wrong machine → open the URL',
147
+ ' printed on the sign-in screen anywhere you like, choose "On another',
148
+ ' computer" on that page, and paste the code back into the terminal.',
149
+ '- A local session was used with a different sign-in → sign in with the',
150
+ ' account you used for that session or start a new session.',
151
+ '- For anything else, re-run the command and file the printed error',
152
+ ' message with `/report` — there is no client-side debug mode by design.',
111
153
  ].join('\n');
112
154
  export function formatAboutCard() {
113
155
  return [
@@ -126,8 +168,15 @@ export function formatInteractiveHelpText() {
126
168
  return HELP_MARKDOWN;
127
169
  }
128
170
  export function formatCliHelpText({ color = false } = {}) {
129
- if (!color)
130
- return HELP_MARKDOWN;
171
+ if (!color) {
172
+ return HELP_MARKDOWN.split('\n')
173
+ .map((line) => line
174
+ .replace(/^#{1,6}\s+/, '')
175
+ .replace(/^-\s+/, ' ')
176
+ .replace(/`([^`]+)`/g, '$1')
177
+ .replace(/\*\*([^*]+)\*\*/g, '$1'))
178
+ .join('\n');
179
+ }
131
180
  return HELP_MARKDOWN.split('\n')
132
181
  .map((line) => {
133
182
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
@@ -0,0 +1,243 @@
1
+ import { getUnquotedShellText } from './agent-mode.js';
2
+ export const PERMISSION_BUCKETS = ['create', 'edit', 'delete', 'run'];
3
+ export function createSessionGrants() {
4
+ return { buckets: [], commandPrefixes: [] };
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
+ return 'run commands';
14
+ }
15
+ const NO_PREFIX_GRANT_BINARIES = new Set([
16
+ 'rm',
17
+ 'rmdir',
18
+ 'mv',
19
+ 'dd',
20
+ 'mkfs',
21
+ 'shred',
22
+ 'sudo',
23
+ 'doas',
24
+ 'su',
25
+ 'chmod',
26
+ 'chown',
27
+ 'chgrp',
28
+ 'kill',
29
+ 'pkill',
30
+ 'killall',
31
+ 'shutdown',
32
+ 'reboot',
33
+ 'curl',
34
+ 'wget',
35
+ 'ssh',
36
+ 'scp',
37
+ 'nc',
38
+ 'eval',
39
+ 'exec',
40
+ 'source',
41
+ ]);
42
+ const INTERPRETER_OR_WRAPPER_BINARIES = new Set([
43
+ 'bash',
44
+ 'sh',
45
+ 'zsh',
46
+ 'fish',
47
+ 'dash',
48
+ 'ksh',
49
+ 'csh',
50
+ 'tcsh',
51
+ 'python',
52
+ 'python2',
53
+ 'python3',
54
+ 'node',
55
+ 'deno',
56
+ 'bun',
57
+ 'ruby',
58
+ 'perl',
59
+ 'php',
60
+ 'lua',
61
+ 'osascript',
62
+ 'awk',
63
+ 'gawk',
64
+ 'sed',
65
+ 'env',
66
+ 'timeout',
67
+ 'xargs',
68
+ 'nohup',
69
+ 'setsid',
70
+ 'watch',
71
+ 'script',
72
+ 'nice',
73
+ 'ionice',
74
+ 'stdbuf',
75
+ 'time',
76
+ 'sudo',
77
+ 'doas',
78
+ 'su',
79
+ 'find',
80
+ 'make',
81
+ ]);
82
+ const SCRIPT_RUNNER_VERBS = new Set(['run', 'exec', 'run-script', 'x', 'dlx']);
83
+ const VERB_PATTERN = /^[A-Za-z][A-Za-z0-9_:-]*$/;
84
+ const BINARY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
85
+ const SHELL_CONTROL_OPERATOR_PATTERN = /[&|;<>\n]/;
86
+ const COMMAND_SUBSTITUTION_PATTERN = /`|\$\(/;
87
+ function normalizeCommand(command) {
88
+ return String(command ?? '').trim().replace(/\s+/g, ' ');
89
+ }
90
+ function parseCommand(command) {
91
+ const raw = String(command ?? '');
92
+ if (!raw.trim())
93
+ return null;
94
+ if (COMMAND_SUBSTITUTION_PATTERN.test(raw))
95
+ return null;
96
+ if (SHELL_CONTROL_OPERATOR_PATTERN.test(getUnquotedShellText(raw)))
97
+ return null;
98
+ const all = normalizeCommand(raw).match(/\S+/g) ?? [];
99
+ const tokens = [];
100
+ for (const token of all) {
101
+ if (!tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
102
+ continue;
103
+ if (!tokens.length && token === 'command')
104
+ continue;
105
+ tokens.push(token);
106
+ }
107
+ if (!tokens.length)
108
+ return null;
109
+ const binary = (tokens[0].split('/').pop() ?? '').trim();
110
+ if (!binary)
111
+ return null;
112
+ tokens[0] = binary;
113
+ return { canonical: tokens.join(' '), tokens };
114
+ }
115
+ function grantPrefixFromTokens(tokens) {
116
+ const binary = tokens[0];
117
+ if (!BINARY_PATTERN.test(binary))
118
+ return null;
119
+ if (NO_PREFIX_GRANT_BINARIES.has(binary))
120
+ return null;
121
+ if (INTERPRETER_OR_WRAPPER_BINARIES.has(binary))
122
+ return null;
123
+ const verb = tokens[1];
124
+ if (!verb || !VERB_PATTERN.test(verb))
125
+ return null;
126
+ if (SCRIPT_RUNNER_VERBS.has(verb)) {
127
+ const script = tokens[2];
128
+ if (!script || !VERB_PATTERN.test(script))
129
+ return null;
130
+ return `${binary} ${verb} ${script}`;
131
+ }
132
+ return `${binary} ${verb}`;
133
+ }
134
+ export function commandGrantPrefix(command) {
135
+ const parsed = parseCommand(command);
136
+ return parsed ? grantPrefixFromTokens(parsed.tokens) : null;
137
+ }
138
+ export function isBucketGranted(grants, bucket) {
139
+ return Boolean(grants?.buckets.includes(bucket));
140
+ }
141
+ export function isCommandGranted(grants, command) {
142
+ if (!grants)
143
+ return false;
144
+ if (grants.buckets.includes('run'))
145
+ return true;
146
+ if (!grants.commandPrefixes.length)
147
+ return false;
148
+ const parsed = parseCommand(command);
149
+ if (!parsed)
150
+ return false;
151
+ if (grantPrefixFromTokens(parsed.tokens) === null)
152
+ return false;
153
+ return grants.commandPrefixes.some((prefix) => parsed.canonical === prefix || parsed.canonical.startsWith(`${prefix} `));
154
+ }
155
+ export function grantBucket(grants, bucket) {
156
+ if (!grants.buckets.includes(bucket))
157
+ grants.buckets.push(bucket);
158
+ }
159
+ export function grantCommandPrefix(grants, prefix) {
160
+ const normalized = normalizeCommand(prefix);
161
+ if (!normalized)
162
+ return;
163
+ if (!grants.commandPrefixes.includes(normalized)) {
164
+ grants.commandPrefixes.push(normalized);
165
+ }
166
+ }
167
+ export function buildPermissionOptions(bucket, command) {
168
+ const options = [
169
+ { label: 'Approve once', decision: { kind: 'once' } },
170
+ ];
171
+ const prefix = bucket === 'run' && command ? commandGrantPrefix(command) : null;
172
+ if (prefix) {
173
+ options.push({
174
+ label: `Always allow: ${prefix}`,
175
+ decision: { kind: 'always-prefix', prefix },
176
+ });
177
+ }
178
+ options.push({
179
+ label: `Always allow: ${bucketActionLabel(bucket)}`,
180
+ decision: { kind: 'always-bucket' },
181
+ });
182
+ options.push({ label: 'Deny', decision: { kind: 'deny' } });
183
+ return options;
184
+ }
185
+ function declinedSubject(bucket) {
186
+ if (bucket === 'run') {
187
+ return { noun: 'command', effect: 'Nothing was executed' };
188
+ }
189
+ if (bucket === 'create') {
190
+ return { noun: 'file creation', effect: 'Nothing was created' };
191
+ }
192
+ if (bucket === 'delete') {
193
+ return { noun: 'file deletion', effect: 'Nothing was deleted' };
194
+ }
195
+ return { noun: 'edit', effect: 'Nothing was changed' };
196
+ }
197
+ function declinedResponse(bucket, toolName, extra) {
198
+ const { noun, effect } = declinedSubject(bucket);
199
+ return {
200
+ ok: false,
201
+ skipped: true,
202
+ ...extra,
203
+ failureCategory: 'user_declined',
204
+ failureDetails: {
205
+ category: 'user_declined',
206
+ tool: toolName,
207
+ 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.',
208
+ },
209
+ error: `The real user rejected this proposed ${noun}. ${effect}; this was not a tool failure or an automated system skip.`,
210
+ };
211
+ }
212
+ export async function ensurePermission(context, request, toolName, extra = {}) {
213
+ if (context.autoYes)
214
+ return null;
215
+ if (isBucketGranted(context.grants, request.bucket))
216
+ return null;
217
+ if (request.bucket === 'run' &&
218
+ request.command &&
219
+ isCommandGranted(context.grants, request.command)) {
220
+ return null;
221
+ }
222
+ if (!context.requestPermission) {
223
+ return {
224
+ ok: false,
225
+ ...extra,
226
+ error: `requestPermission is required when autoYes is false (${toolName})`,
227
+ };
228
+ }
229
+ const options = buildPermissionOptions(request.bucket, request.command);
230
+ const decision = await context.requestPermission({ ...request, options });
231
+ if (decision.kind === 'deny') {
232
+ return declinedResponse(request.bucket, toolName, extra);
233
+ }
234
+ if (context.grants) {
235
+ if (decision.kind === 'always-bucket') {
236
+ grantBucket(context.grants, request.bucket);
237
+ }
238
+ else if (decision.kind === 'always-prefix') {
239
+ grantCommandPrefix(context.grants, decision.prefix);
240
+ }
241
+ }
242
+ return null;
243
+ }
@@ -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) {