@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.
- package/README.md +39 -6
- package/dist/bin/ai.js +142 -383
- package/dist/src/agent-mode.js +1 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +258 -38
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +69 -7
- package/dist/src/api/models.js +19 -10
- package/dist/src/background-jobs.js +2 -2
- package/dist/src/cli-args.js +19 -5
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +67 -18
- package/dist/src/permissions.js +243 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +14 -3
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +11 -46
- package/dist/src/tools/delete-file.js +15 -3
- package/dist/src/tools/index.js +13 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +649 -164
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +453 -115
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +206 -63
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +29 -6
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
package/dist/src/help-text.js
CHANGED
|
@@ -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
|
|
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
|
|
29
|
-
'- `ai login
|
|
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
|
|
39
|
-
'
|
|
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
|
|
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**
|
|
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
|
-
'- `/
|
|
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
|
|
86
|
-
'
|
|
87
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'-
|
|
109
|
-
'
|
|
110
|
-
'
|
|
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 {
|
|
11
|
+
import { singleLinePreview } from './utils.js';
|
|
8
12
|
const SESSION_STORE_VERSION = 1;
|
|
9
|
-
const MAX_RECENT_SESSIONS =
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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) {
|