codeep 3.3.3 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -14,10 +14,14 @@ import { takeRunFromPhone } from '../utils/telegramInbox.js';
|
|
|
14
14
|
import { isFlatFeeProvider } from '../config/providers.js';
|
|
15
15
|
import { raceApproval } from '../utils/approvalRace.js';
|
|
16
16
|
import { describeAuditTarget } from '../utils/auditLog.js';
|
|
17
|
+
import { trustBearingWrite, forgetHooksDirectory } from '../utils/toolExecution.js';
|
|
18
|
+
import { shellCommandEnv } from '../utils/shell.js';
|
|
19
|
+
import { charWidth } from './ansi.js';
|
|
17
20
|
import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
|
|
18
21
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
19
22
|
import { getGitStatus, isGitRepository } from '../utils/git.js';
|
|
20
23
|
import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
|
|
24
|
+
import { createFileDiff, createEditDiff, formatDiffForDisplay } from '../utils/diffPreview.js';
|
|
21
25
|
export function getActionType(toolName) {
|
|
22
26
|
return toolName.includes('write') ? 'write' :
|
|
23
27
|
toolName.includes('edit') ? 'edit' :
|
|
@@ -44,7 +48,8 @@ export function requestToolConfirmation(app, tool, parameters, onConfirm, onCanc
|
|
|
44
48
|
parameters.command ||
|
|
45
49
|
parameters.pattern ||
|
|
46
50
|
'unknown';
|
|
47
|
-
const
|
|
51
|
+
const safeTarget = showControls(target);
|
|
52
|
+
const shortTarget = safeTarget.length > 50 ? '...' + safeTarget.slice(-47) : safeTarget;
|
|
48
53
|
app.showConfirm({
|
|
49
54
|
title: '⚠️ Confirm Action',
|
|
50
55
|
message: [
|
|
@@ -61,19 +66,77 @@ export function requestToolConfirmation(app, tool, parameters, onConfirm, onCanc
|
|
|
61
66
|
onCancel,
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Model-written text shown in a permission dialog, with every character that
|
|
71
|
+
* could change how the rest of it looks spelled out: an ESC sequence would be
|
|
72
|
+
* read as a style (conceal, black on black) and a bidi override or zero-width
|
|
73
|
+
* character reorders or hides text, so the user could approve a command they
|
|
74
|
+
* were not shown. Newlines are left for the caller to lay out.
|
|
75
|
+
*/
|
|
76
|
+
export function showControls(text) {
|
|
77
|
+
return text.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, (c) => {
|
|
78
|
+
const code = c.charCodeAt(0);
|
|
79
|
+
return code <= 0xff ? `\\x${code.toString(16).padStart(2, '0')}` : `\\u${code.toString(16).padStart(4, '0')}`;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The target of a tool call as dialog lines, each `width` terminal columns at
|
|
84
|
+
* most. Shown whole where it fits: an MCP call's arguments or a long command
|
|
85
|
+
* matter from the first character. Past `maxLines` the middle gives way, and a
|
|
86
|
+
* line says how much of it is not shown.
|
|
87
|
+
*/
|
|
88
|
+
export function wrapConfirmTarget(target, width, maxLines = 6) {
|
|
89
|
+
const w = Math.max(20, Math.floor(width));
|
|
90
|
+
const lines = [];
|
|
91
|
+
for (const part of target.split(/\r?\n/)) {
|
|
92
|
+
const text = showControls(part);
|
|
93
|
+
if (text.length === 0) {
|
|
94
|
+
lines.push('');
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// By columns, not UTF-16 units: the screen drops what passes the edge, so
|
|
98
|
+
// a line of wide characters cut by length would lose its end unmarked.
|
|
99
|
+
// Iterating code points also keeps a surrogate pair whole.
|
|
100
|
+
let line = '';
|
|
101
|
+
let cols = 0;
|
|
102
|
+
for (const ch of text) {
|
|
103
|
+
const cw = charWidth(ch);
|
|
104
|
+
if (cols + cw > w && line) {
|
|
105
|
+
lines.push(line);
|
|
106
|
+
line = '';
|
|
107
|
+
cols = 0;
|
|
108
|
+
}
|
|
109
|
+
line += ch;
|
|
110
|
+
cols += cw;
|
|
111
|
+
}
|
|
112
|
+
lines.push(line);
|
|
113
|
+
}
|
|
114
|
+
const max = Math.max(3, maxLines);
|
|
115
|
+
if (lines.length <= max)
|
|
116
|
+
return lines;
|
|
117
|
+
const hidden = lines.length - (max - 1);
|
|
118
|
+
return [...lines.slice(0, max - 2), `… ${hidden} more line${hidden === 1 ? '' : 's'} …`, lines[lines.length - 1]];
|
|
119
|
+
}
|
|
120
|
+
export async function runAgentTask(task, dryRun, ctx, getPendingInteractive, setPendingInteractive, opts = {}) {
|
|
66
121
|
const { app, projectContext } = ctx;
|
|
122
|
+
const notStarted = () => opts.onFinished?.('not-started');
|
|
123
|
+
// executeAgentTask catches its own errors, so this always settles.
|
|
124
|
+
const execute = () => {
|
|
125
|
+
void executeAgentTask(task, dryRun, ctx).then(outcome => opts.onFinished?.(outcome));
|
|
126
|
+
};
|
|
67
127
|
if (!projectContext) {
|
|
68
128
|
app.notify('Agent requires project context');
|
|
129
|
+
notStarted();
|
|
69
130
|
return;
|
|
70
131
|
}
|
|
71
132
|
if (!ctx.hasWriteAccess && !dryRun) {
|
|
72
133
|
app.notify('Agent requires write access. Use /grant first.');
|
|
134
|
+
notStarted();
|
|
73
135
|
return;
|
|
74
136
|
}
|
|
75
137
|
if (ctx.isAgentRunning()) {
|
|
76
138
|
app.notify('Agent already running. Use /stop to cancel.');
|
|
139
|
+
notStarted();
|
|
77
140
|
return;
|
|
78
141
|
}
|
|
79
142
|
const interactiveMode = config.get('agentInteractive') !== false;
|
|
@@ -84,12 +147,13 @@ export async function runAgentTask(task, dryRun, ctx, getPendingInteractive, set
|
|
|
84
147
|
setPendingInteractive({ originalTask: task, context: interactiveContext, dryRun });
|
|
85
148
|
app.addMessage({ role: 'assistant', content: formatQuestions(interactiveContext) });
|
|
86
149
|
app.notify('Answer questions or type "proceed" to continue');
|
|
150
|
+
notStarted();
|
|
87
151
|
return;
|
|
88
152
|
}
|
|
89
153
|
}
|
|
90
154
|
const confirmationMode = config.get('agentConfirmation') || 'dangerous';
|
|
91
155
|
if (confirmationMode === 'never' || dryRun) {
|
|
92
|
-
|
|
156
|
+
execute();
|
|
93
157
|
return;
|
|
94
158
|
}
|
|
95
159
|
if (confirmationMode === 'always') {
|
|
@@ -106,8 +170,8 @@ export async function runAgentTask(task, dryRun, ctx, getPendingInteractive, set
|
|
|
106
170
|
],
|
|
107
171
|
confirmLabel: 'Run Agent',
|
|
108
172
|
cancelLabel: 'Cancel',
|
|
109
|
-
onConfirm:
|
|
110
|
-
onCancel: () => app.notify('Agent task cancelled'),
|
|
173
|
+
onConfirm: execute,
|
|
174
|
+
onCancel: () => { app.notify('Agent task cancelled'); notStarted(); },
|
|
111
175
|
});
|
|
112
176
|
return;
|
|
113
177
|
}
|
|
@@ -127,34 +191,40 @@ export async function runAgentTask(task, dryRun, ctx, getPendingInteractive, set
|
|
|
127
191
|
],
|
|
128
192
|
confirmLabel: 'Proceed',
|
|
129
193
|
cancelLabel: 'Cancel',
|
|
130
|
-
onConfirm:
|
|
131
|
-
onCancel: () => app.notify('Agent task cancelled'),
|
|
194
|
+
onConfirm: execute,
|
|
195
|
+
onCancel: () => { app.notify('Agent task cancelled'); notStarted(); },
|
|
132
196
|
});
|
|
133
197
|
return;
|
|
134
198
|
}
|
|
135
|
-
|
|
199
|
+
execute();
|
|
136
200
|
}
|
|
137
201
|
export async function executeAgentTask(task, dryRun, ctx) {
|
|
138
202
|
const { app, projectContext } = ctx;
|
|
139
203
|
if (!projectContext) {
|
|
140
204
|
app.notify('Agent requires project context');
|
|
141
|
-
return;
|
|
205
|
+
return 'not-started';
|
|
142
206
|
}
|
|
143
207
|
// Guard against concurrent execution — set flag immediately before any await
|
|
144
208
|
if (ctx.isAgentRunning()) {
|
|
145
209
|
app.notify('Agent already running. Use /stop to cancel.');
|
|
146
|
-
return;
|
|
210
|
+
return 'not-started';
|
|
147
211
|
}
|
|
148
212
|
ctx.setAgentRunning(true);
|
|
149
213
|
const abortController = new AbortController();
|
|
150
214
|
ctx.setAbortController(abortController);
|
|
215
|
+
// Read once, at the start: by the time the run ends the global id may name
|
|
216
|
+
// another conversation.
|
|
217
|
+
const sessionId = ctx.sessionId || getCurrentSessionId();
|
|
151
218
|
// Marker for cloud reporting: report only this run's tokens to the dashboard
|
|
152
219
|
// without wiping the session-cumulative store the status bar and `/cost` read.
|
|
153
220
|
const tokenReportStart = getRecordCount();
|
|
154
221
|
const prefix = dryRun ? '[DRY RUN] ' : '[AGENT] ';
|
|
155
|
-
|
|
222
|
+
// Kept by reference: while it is on screen, so is this run's conversation.
|
|
223
|
+
const runMessage = { role: 'user', content: prefix + task };
|
|
224
|
+
app.addMessage(runMessage);
|
|
156
225
|
app.setAgentRunning(true);
|
|
157
226
|
const context = projectContext;
|
|
227
|
+
let outcome = 'failed';
|
|
158
228
|
try {
|
|
159
229
|
const fileContext = ctx.formatAddedFilesContext();
|
|
160
230
|
const enrichedTask = fileContext ? fileContext + task : task;
|
|
@@ -162,78 +232,104 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
162
232
|
const rawIterations = config.get('agentMaxIterations') || 50;
|
|
163
233
|
app.setAgentMaxIterations(Math.max(5, rawIterations));
|
|
164
234
|
const confirmationMode = config.get('agentConfirmation') || 'dangerous';
|
|
235
|
+
// 'always' asks before every action that changes something: at least what
|
|
236
|
+
// 'dangerous' asks about, plus writes, edits and new directories.
|
|
237
|
+
const asksPerTool = confirmationMode === 'dangerous' || confirmationMode === 'always';
|
|
165
238
|
// Read the Telegram credentials once for the whole run rather than per tool
|
|
166
239
|
// call: they come from the OS keychain, and paying that on every dangerous
|
|
167
|
-
// tool would put a keychain round-trip in front of each confirmation.
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// confirmations off is exactly the one you are most likely to walk away
|
|
175
|
-
// from. Reuse the credentials already read above when there are any, so
|
|
176
|
-
// this costs a second keychain round-trip only when there are not.
|
|
177
|
-
const noticeCredentials = telegramCredentials ?? await loadTelegramCredentials();
|
|
240
|
+
// tool would put a keychain round-trip in front of each confirmation. Read
|
|
241
|
+
// in every mode, for the finish notice — and because 'never' now asks
|
|
242
|
+
// about a file that decides what runs later, which is exactly the kind of
|
|
243
|
+
// run someone has walked away from. Null means the feature is off or
|
|
244
|
+
// half-configured, and the terminal is then the only place the question
|
|
245
|
+
// appears — exactly as before.
|
|
246
|
+
const telegramCredentials = await loadTelegramCredentials();
|
|
178
247
|
const runStartedAt = Date.now();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
248
|
+
// 'never' still gets a callback. A write to a file that decides what runs
|
|
249
|
+
// later — `.git/config`, a hook, an MCP server list — is asked about in
|
|
250
|
+
// every mode (the agent gate decides which calls those are), and "never
|
|
251
|
+
// ask" is then answered here for everything else, exactly as before:
|
|
252
|
+
// without a callback the agent would have to refuse those writes instead.
|
|
253
|
+
const onRequestPermission = async (toolCall,
|
|
254
|
+
// What the agent gate already worked out about this call, passed rather
|
|
255
|
+
// than worked out twice: trustBearingWrite() stats the path, resolves a
|
|
256
|
+
// symlinked ancestor and may ask git where this repo keeps its hooks.
|
|
257
|
+
// Undefined means the question came from somewhere that has not looked
|
|
258
|
+
// — a skill's shell line asks through this same callback — so it is
|
|
259
|
+
// only then that this side looks for itself. `null` is an answer.
|
|
260
|
+
known) => {
|
|
261
|
+
const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, context.root || process.cwd());
|
|
262
|
+
if (!asksPerTool && !trustBearing)
|
|
263
|
+
return 'allow_once';
|
|
264
|
+
// `parameters.command` is the binary alone — `git`, not `git status`.
|
|
265
|
+
// Showing that asks someone to approve a command they have not been
|
|
266
|
+
// shown, which is the one thing this gate must not do. The audit
|
|
267
|
+
// record already joins the binary with its arguments; reuse it rather
|
|
268
|
+
// than writing a second, subtly different answer.
|
|
269
|
+
const target = describeAuditTarget(toolCall);
|
|
270
|
+
// Indented by two in the dialog.
|
|
271
|
+
const targetLines = wrapConfirmTarget(target, (process.stdout.columns || 80) - 4)
|
|
272
|
+
.map(line => ` ${line}`);
|
|
273
|
+
const inTerminal = {
|
|
274
|
+
answer: new Promise((resolve) => {
|
|
275
|
+
app.showConfirm({
|
|
276
|
+
title: '⚠️ Confirm Action',
|
|
277
|
+
message: [
|
|
278
|
+
'The agent wants to execute:',
|
|
279
|
+
'',
|
|
280
|
+
` ${showControls(toolCall.tool)}`,
|
|
281
|
+
...targetLines,
|
|
282
|
+
// What the file does, not that it is "sensitive": someone
|
|
283
|
+
// deciding in one second needs the consequence, not a label.
|
|
284
|
+
...(trustBearing ? ['', ...wrapConfirmTarget(`⚠️ ${trustBearing.reason}`, (process.stdout.columns || 80) - 4)] : []),
|
|
285
|
+
'',
|
|
286
|
+
telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
|
|
287
|
+
],
|
|
288
|
+
confirmLabel: 'Allow',
|
|
289
|
+
cancelLabel: 'Deny',
|
|
290
|
+
// No "Always Allow" for one of those files: the agent answers
|
|
291
|
+
// about this file only and would not remember it anyway.
|
|
292
|
+
extraOption: trustBearing ? undefined : { label: 'Always Allow', onSelect: () => resolve('allow_always') },
|
|
293
|
+
onConfirm: () => resolve('allow_once'),
|
|
294
|
+
// The one "no" there is, and it answers reject_always. For one of
|
|
295
|
+
// those files the agent remembers that against the FILE rather
|
|
296
|
+
// than the tool, so refusing a `.git/config` prompt does not also
|
|
297
|
+
// switch delete_file off for the rest of the run.
|
|
298
|
+
onCancel: () => resolve('reject_always'),
|
|
299
|
+
});
|
|
300
|
+
}),
|
|
301
|
+
// Answered on the phone: take the dialog down without running either
|
|
302
|
+
// callback, since the decision is already made and taken.
|
|
303
|
+
withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
|
|
304
|
+
};
|
|
305
|
+
let onPhone = null;
|
|
306
|
+
if (telegramCredentials) {
|
|
307
|
+
// Report a failure to *ask* once, in the terminal. Without this a
|
|
308
|
+
// wrong chat id looks exactly like a phone nobody picked up.
|
|
309
|
+
const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
|
|
310
|
+
onPhone = {
|
|
311
|
+
answer: telegram
|
|
312
|
+
// The reason goes with it: a phone showing less than the terminal
|
|
313
|
+
// asks for a decision on less than the terminal had.
|
|
314
|
+
.ask(target, toolCall.tool, true, undefined, trustBearing?.reason)
|
|
315
|
+
.then(answer => (answer ? outcomeForAnswer(answer) : null))
|
|
316
|
+
// A phone that cannot be reached is not a denial. Step aside and
|
|
317
|
+
// let the terminal decide, however long that takes.
|
|
318
|
+
.catch(() => null),
|
|
319
|
+
withdraw: (winner) => telegram.withdraw(winner),
|
|
210
320
|
};
|
|
211
|
-
let onPhone = null;
|
|
212
|
-
if (telegramCredentials) {
|
|
213
|
-
// Report a failure to *ask* once, in the terminal. Without this a
|
|
214
|
-
// wrong chat id looks exactly like a phone nobody picked up.
|
|
215
|
-
const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
|
|
216
|
-
onPhone = {
|
|
217
|
-
answer: telegram
|
|
218
|
-
.ask(target, toolCall.tool, true)
|
|
219
|
-
.then(answer => (answer ? outcomeForAnswer(answer) : null))
|
|
220
|
-
// A phone that cannot be reached is not a denial. Step aside and
|
|
221
|
-
// let the terminal decide, however long that takes.
|
|
222
|
-
.catch(() => null),
|
|
223
|
-
withdraw: (winner) => telegram.withdraw(winner),
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
|
|
227
|
-
// Nobody answered — neither side could even ask. `classifyPermissionOutcome`
|
|
228
|
-
// fails closed on anything it does not recognise, and this is spelled
|
|
229
|
-
// out rather than left to that: a question that was never put must
|
|
230
|
-
// never read as a yes.
|
|
231
|
-
return answer ?? 'reject_once';
|
|
232
321
|
}
|
|
233
|
-
|
|
322
|
+
const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
|
|
323
|
+
// Nobody answered — neither side could even ask. `classifyPermissionOutcome`
|
|
324
|
+
// fails closed on anything it does not recognise, and this is spelled
|
|
325
|
+
// out rather than left to that: a question that was never put must
|
|
326
|
+
// never read as a yes.
|
|
327
|
+
return answer ?? 'reject_once';
|
|
328
|
+
};
|
|
234
329
|
const result = await runAgent(enrichedTask, context, {
|
|
235
330
|
dryRun,
|
|
236
331
|
onRequestPermission,
|
|
332
|
+
extraDangerousTools: confirmationMode === 'always' ? ['write_file', 'edit_file', 'delete_file', 'execute_command', 'create_directory'] : undefined,
|
|
237
333
|
chatHistory: app.getChatHistory(),
|
|
238
334
|
// Route MCP-prefixed tool calls through the shared TUI session id.
|
|
239
335
|
// Servers were registered against this id at app startup (see
|
|
@@ -269,7 +365,6 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
269
365
|
if (actionType === 'write' && tool.parameters.content) {
|
|
270
366
|
const filePath = tool.parameters.path;
|
|
271
367
|
try {
|
|
272
|
-
const { createFileDiff, formatDiffForDisplay } = require('../utils/diffPreview');
|
|
273
368
|
const diff = createFileDiff(filePath, tool.parameters.content, context.root);
|
|
274
369
|
const diffText = formatDiffForDisplay(diff);
|
|
275
370
|
const additions = diff.hunks.reduce((sum, h) => sum + h.lines.filter((l) => l.type === 'add').length, 0);
|
|
@@ -290,7 +385,6 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
290
385
|
else if (actionType === 'edit' && tool.parameters.new_text) {
|
|
291
386
|
const filePath = tool.parameters.path;
|
|
292
387
|
try {
|
|
293
|
-
const { createEditDiff, formatDiffForDisplay } = require('../utils/diffPreview');
|
|
294
388
|
const diff = createEditDiff(filePath, tool.parameters.old_text, tool.parameters.new_text, context.root);
|
|
295
389
|
if (diff) {
|
|
296
390
|
const additions = diff.hunks.reduce((sum, h) => sum + h.lines.filter((l) => l.type === 'add').length, 0);
|
|
@@ -343,6 +437,10 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
343
437
|
ctx.setAgentRunning(false);
|
|
344
438
|
ctx.setAbortController(null);
|
|
345
439
|
app.setAgentRunning(false);
|
|
440
|
+
outcome = result.success ? 'success'
|
|
441
|
+
: result.aborted ? 'aborted'
|
|
442
|
+
: result.interrupted ? 'interrupted'
|
|
443
|
+
: 'failed';
|
|
346
444
|
if (result.success) {
|
|
347
445
|
const fileChanges = result.actions.filter(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete');
|
|
348
446
|
const otherActions = result.actions.filter(a => a.type !== 'write' && a.type !== 'edit' && a.type !== 'delete');
|
|
@@ -416,10 +514,23 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
416
514
|
app.addMessage({ role: 'assistant', content: `Agent could not complete the task: ${result.error || 'Unknown error'}` });
|
|
417
515
|
}
|
|
418
516
|
}
|
|
419
|
-
|
|
517
|
+
// The messages on screen belong to whatever conversation is on screen.
|
|
518
|
+
// If the user moved to another one while the run was finishing, saving
|
|
519
|
+
// them under this run's id would replace that conversation's file with
|
|
520
|
+
// the new one's contents; the terminal saves the new one itself.
|
|
521
|
+
const switched = ctx.getSessionId !== undefined && ctx.getSessionId() !== sessionId;
|
|
522
|
+
if (!switched) {
|
|
523
|
+
autoSaveSession(app.getMessages(), ctx.projectPath, sessionId);
|
|
524
|
+
}
|
|
525
|
+
else if (app.getMessages().includes(runMessage)) {
|
|
526
|
+
// /rename: the same conversation, still on screen, under its new id.
|
|
527
|
+
// /new, /sessions and the other loads replace the messages, so a run
|
|
528
|
+
// left behind by one of those never gets here. (Whether the old file
|
|
529
|
+
// is gone says nothing: a case-only rename keeps it on macOS.)
|
|
530
|
+
autoSaveSession(app.getMessages(), ctx.projectPath, ctx.getSessionId());
|
|
531
|
+
}
|
|
420
532
|
// Report stats to codeep.dev (fire-and-forget, only if github_id is set)
|
|
421
533
|
const { getCurrentVersion } = await import('../utils/update.js');
|
|
422
|
-
const sessionId = getCurrentSessionId();
|
|
423
534
|
// Auto-name from the task if no display name is set yet.
|
|
424
535
|
//
|
|
425
536
|
// The derived name is kept in a local rather than read back off ctx.
|
|
@@ -441,17 +552,22 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
441
552
|
let displayName = ctx.sessionDisplayName;
|
|
442
553
|
if (!displayName) {
|
|
443
554
|
displayName = shortLabel(task);
|
|
444
|
-
|
|
555
|
+
// Naming the conversation on screen after this run would be wrong
|
|
556
|
+
// once it is a different one.
|
|
557
|
+
if (!switched)
|
|
558
|
+
ctx.setSessionDisplayName?.(displayName);
|
|
445
559
|
}
|
|
446
560
|
if (!displayName)
|
|
447
561
|
displayName = sessionId;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
562
|
+
if (!switched) {
|
|
563
|
+
syncSession({
|
|
564
|
+
sessionId,
|
|
565
|
+
sessionName: displayName,
|
|
566
|
+
projectName: ctx.projectContext?.name,
|
|
567
|
+
projectId: ctx.projectPath ? generateProjectId(ctx.projectPath) : undefined,
|
|
568
|
+
messages: app.getMessages(),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
455
571
|
// Report per-model so tokens are attributed to the correct model/provider
|
|
456
572
|
// even if the user switched model mid-session. Only this run's delta
|
|
457
573
|
// (since tokenReportStart) is reported; the cumulative store is preserved.
|
|
@@ -464,7 +580,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
464
580
|
// Told once the run is over, and only when it ran long enough that you
|
|
465
581
|
// could plausibly have stopped watching. Awaited so the process does not
|
|
466
582
|
// exit from under the request, but never allowed to fail the run.
|
|
467
|
-
if (
|
|
583
|
+
if (telegramCredentials) {
|
|
468
584
|
const elapsedMs = Date.now() - runStartedAt;
|
|
469
585
|
const fromPhone = startedFromPhone;
|
|
470
586
|
// The one-minute threshold exists so a phone is not buzzed about work you
|
|
@@ -484,7 +600,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
484
600
|
costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
|
|
485
601
|
});
|
|
486
602
|
for (const message of messages) {
|
|
487
|
-
await sendTelegramNotice(
|
|
603
|
+
await sendTelegramNotice(telegramCredentials, message).catch(() => false);
|
|
488
604
|
}
|
|
489
605
|
}
|
|
490
606
|
}
|
|
@@ -527,6 +643,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
527
643
|
}
|
|
528
644
|
catch (error) {
|
|
529
645
|
const err = error;
|
|
646
|
+
outcome = 'failed';
|
|
530
647
|
app.addMessage({ role: 'assistant', content: `Agent error: ${err.message}` });
|
|
531
648
|
app.notify(`Agent error: ${err.message}`, 5000);
|
|
532
649
|
}
|
|
@@ -537,6 +654,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
537
654
|
app.setAgentRunning(false);
|
|
538
655
|
app.render();
|
|
539
656
|
}
|
|
657
|
+
return outcome;
|
|
540
658
|
}
|
|
541
659
|
// ─── Skill execution ──────────────────────────────────────────────────────────
|
|
542
660
|
export async function runSkill(nameOrShortcut, args, ctx) {
|
|
@@ -561,12 +679,37 @@ export async function runSkill(nameOrShortcut, args, ctx) {
|
|
|
561
679
|
try {
|
|
562
680
|
const result = await executeSkill(skill, params, {
|
|
563
681
|
onCommand: async (cmd) => {
|
|
682
|
+
const cwd = ctx.projectPath || process.cwd();
|
|
683
|
+
// A raw process.env here handed the repository's own `.git/config`
|
|
684
|
+
// back to git: `/commit` runs `git commit`, and a repo-scope
|
|
685
|
+
// `gpg.program` that Codeep's own commit path neutralises executed
|
|
686
|
+
// through this spawn instead.
|
|
687
|
+
//
|
|
688
|
+
// Built before the spawn, and caught: shellCommandEnv() refuses a git
|
|
689
|
+
// line in a repository whose config names a program no override
|
|
690
|
+
// switches off, and a refusal escaping here would abort the whole
|
|
691
|
+
// skill rather than fail the step that asked for git. A step that
|
|
692
|
+
// cannot run is reported the same way a step that failed is.
|
|
693
|
+
let env;
|
|
694
|
+
try {
|
|
695
|
+
env = shellCommandEnv(cmd, cwd);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
699
|
+
ctx.app.addMessage({ role: 'system', content: `\`${cmd}\` was not run:\n\`\`\`\n${why}\n\`\`\`` });
|
|
700
|
+
throw new Error(why);
|
|
701
|
+
}
|
|
702
|
+
// A command line is the one thing in a skill that can move this
|
|
703
|
+
// repository's hooks (`git config core.hooksPath .evil`), and the
|
|
704
|
+
// write gate caches where they are for the run.
|
|
705
|
+
forgetHooksDirectory();
|
|
564
706
|
const proc = spawnSync(cmd, {
|
|
565
|
-
cwd
|
|
707
|
+
cwd,
|
|
566
708
|
encoding: 'utf-8',
|
|
567
709
|
timeout: 60000,
|
|
568
710
|
shell: true,
|
|
569
711
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
712
|
+
env,
|
|
570
713
|
});
|
|
571
714
|
const stdout = (proc.stdout || '').trim();
|
|
572
715
|
const stderr = (proc.stderr || '').trim();
|
|
@@ -604,7 +747,20 @@ export async function runSkill(nameOrShortcut, args, ctx) {
|
|
|
604
747
|
reject(new Error('Agent requires project context'));
|
|
605
748
|
return;
|
|
606
749
|
}
|
|
607
|
-
|
|
750
|
+
// A later step (commit, push, deploy) must not run after an agent
|
|
751
|
+
// step that failed, was stopped or left checks failing.
|
|
752
|
+
executeAgentTask(task, false, ctx).then((outcome) => {
|
|
753
|
+
if (outcome === 'success') {
|
|
754
|
+
resolve('Agent completed');
|
|
755
|
+
}
|
|
756
|
+
else if (outcome === 'aborted') {
|
|
757
|
+
// The wording runSkill already treats as the user's own stop.
|
|
758
|
+
reject(new Error('Cancelled by user'));
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
reject(new Error(outcome === 'not-started' ? 'Agent step did not run' : 'Agent step did not finish successfully'));
|
|
762
|
+
}
|
|
763
|
+
}).catch(reject);
|
|
608
764
|
});
|
|
609
765
|
},
|
|
610
766
|
onConfirm: (message) => {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* alongside `ctx.app.*` calls. Pulling them here gives them direct unit
|
|
8
8
|
* coverage.
|
|
9
9
|
*/
|
|
10
|
+
import type { SyncFailure, SyncResult } from '../../utils/codeepCloud';
|
|
10
11
|
export interface SearchSnippet {
|
|
11
12
|
role: string;
|
|
12
13
|
messageIndex: number;
|
|
@@ -230,8 +231,23 @@ export declare function formatCloudSessionLabel(s: {
|
|
|
230
231
|
messageCount: number;
|
|
231
232
|
projectName?: string | null;
|
|
232
233
|
}): string;
|
|
233
|
-
/**
|
|
234
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Format the `/me sync` result list. For both results `count` is 1 when the
|
|
236
|
+
* profile moved and 0 when there was nothing to move (no local profile to
|
|
237
|
+
* push; nothing new to pull). `describe` turns a failure into a sentence —
|
|
238
|
+
* codeepCloud's describeSyncFailure, passed in so this module stays free of
|
|
239
|
+
* the config it loads.
|
|
240
|
+
*/
|
|
241
|
+
export declare function formatMeSyncReport(pushed: SyncResult, pulled: SyncResult, describe: (reason: SyncFailure) => string): string;
|
|
242
|
+
/**
|
|
243
|
+
* Format what `/undo-all` did, one line per action. The results mix restored
|
|
244
|
+
* files with actions that cannot be undone (a shell command), so a count of
|
|
245
|
+
* them says nothing about how much was put back.
|
|
246
|
+
*/
|
|
247
|
+
export declare function formatUndoAllReport(result: {
|
|
248
|
+
success: boolean;
|
|
249
|
+
results: string[];
|
|
250
|
+
}): string;
|
|
235
251
|
/**
|
|
236
252
|
* Format the `/me learn` result. `updated` distinguishes "new facts written"
|
|
237
253
|
* from "already covered"; `file` is the human-readable path.
|
|
@@ -426,17 +426,40 @@ export function formatCloudSessionLabel(s) {
|
|
|
426
426
|
return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
|
|
427
427
|
}
|
|
428
428
|
// ─── /me sync + learn + init formatters ───────────────────────────────────────
|
|
429
|
-
/**
|
|
430
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Format the `/me sync` result list. For both results `count` is 1 when the
|
|
431
|
+
* profile moved and 0 when there was nothing to move (no local profile to
|
|
432
|
+
* push; nothing new to pull). `describe` turns a failure into a sentence —
|
|
433
|
+
* codeepCloud's describeSyncFailure, passed in so this module stays free of
|
|
434
|
+
* the config it loads.
|
|
435
|
+
*/
|
|
436
|
+
export function formatMeSyncReport(pushed, pulled, describe) {
|
|
431
437
|
const lines = [];
|
|
432
|
-
if (pushed)
|
|
438
|
+
if (!pushed.ok)
|
|
439
|
+
lines.push(`✗ Could not push your profile to the dashboard — ${describe(pushed.reason)}`);
|
|
440
|
+
else if (pushed.count > 0)
|
|
433
441
|
lines.push('✓ Profile pushed to the dashboard');
|
|
434
|
-
if (pulled
|
|
442
|
+
if (!pulled.ok)
|
|
443
|
+
lines.push(`✗ Could not fetch your profile from the dashboard — ${describe(pulled.reason)}`);
|
|
444
|
+
else if (pulled.count > 0)
|
|
435
445
|
lines.push('✓ Profile pulled to this machine');
|
|
436
|
-
if (lines.length === 0)
|
|
446
|
+
if (lines.length === 0) {
|
|
437
447
|
lines.push('Nothing to sync yet — run `/me init` and fill in your profile first.');
|
|
448
|
+
}
|
|
438
449
|
return `## Profile sync\n\n${lines.join('\n')}`;
|
|
439
450
|
}
|
|
451
|
+
// ─── /undo-all ────────────────────────────────────────────────────────────────
|
|
452
|
+
/**
|
|
453
|
+
* Format what `/undo-all` did, one line per action. The results mix restored
|
|
454
|
+
* files with actions that cannot be undone (a shell command), so a count of
|
|
455
|
+
* them says nothing about how much was put back.
|
|
456
|
+
*/
|
|
457
|
+
export function formatUndoAllReport(result) {
|
|
458
|
+
if (result.results.length === 0)
|
|
459
|
+
return 'Nothing to undo';
|
|
460
|
+
const heading = result.success ? '## Undo all' : '## Nothing was undone';
|
|
461
|
+
return `${heading}\n\n${result.results.map(r => `- ${r}`).join('\n')}`;
|
|
462
|
+
}
|
|
440
463
|
/**
|
|
441
464
|
* Format the `/me learn` result. `updated` distinguishes "new facts written"
|
|
442
465
|
* from "already covered"; `file` is the human-readable path.
|
|
@@ -9,6 +9,8 @@ import { getProjectContext } from '../utils/project';
|
|
|
9
9
|
import { AppExecutionContext } from './agentExecution';
|
|
10
10
|
export interface AppCommandContext extends AppExecutionContext {
|
|
11
11
|
sessionId: string;
|
|
12
|
+
/** Switch the conversation. Also records it as config's currentSessionId,
|
|
13
|
+
* which autosave and the next launch read. */
|
|
12
14
|
setSessionId: (id: string) => void;
|
|
13
15
|
setProjectContext: (ctx: ReturnType<typeof getProjectContext>) => void;
|
|
14
16
|
setHasWriteAccess: (v: boolean) => void;
|