@thegitai/cli 1.0.0-preview.15 → 1.0.0-preview.17

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.
@@ -1,5 +1,6 @@
1
1
  import chalk from '../colors.js';
2
2
  import { execFileSync, spawn } from 'node:child_process';
3
+ import { ensurePermission } from '../permissions.js';
3
4
  import { syncIndexFromDisk } from '../project-index.js';
4
5
  import { isTuiMode } from '../runtime-mode.js';
5
6
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
@@ -148,7 +149,7 @@ function executeNodeScript(rootDir, script, timeout) {
148
149
  });
149
150
  }
150
151
  export async function runNodeScript(context, args) {
151
- const { rootDir, autoYes, confirmCommand } = context;
152
+ const { rootDir } = context;
152
153
  const script = typeof args.script === 'string' ? args.script : '';
153
154
  if (!script.trim()) {
154
155
  return { ok: false, error: 'script is required' };
@@ -158,32 +159,16 @@ export async function runNodeScript(context, args) {
158
159
  console.log(chalk.bold.yellow(`\n ⚡ Node script:\n${commandForApproval}\n`));
159
160
  console.log(chalk.dim(` in: ${rootDir}\n`));
160
161
  }
161
- if (!autoYes) {
162
- if (confirmCommand == null) {
163
- return {
164
- ok: false,
165
- command: COMMAND_LABEL,
166
- error: 'confirmCommand is required when autoYes is false',
167
- };
168
- }
169
- const approved = await confirmCommand(commandForApproval);
170
- if (!approved) {
171
- if (!isTuiMode()) {
172
- console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
173
- }
174
- return {
175
- ok: false,
176
- skipped: true,
177
- command: COMMAND_LABEL,
178
- failureCategory: 'user_declined',
179
- failureDetails: {
180
- category: 'user_declined',
181
- tool: 'run_node_script',
182
- 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.',
183
- },
184
- error: 'The real user rejected this proposed script. Nothing was executed; this was not a tool failure or an automated system skip.',
185
- };
162
+ const denied = await ensurePermission(context, {
163
+ bucket: 'run',
164
+ title: 'Approve node script?',
165
+ body: commandForApproval,
166
+ }, 'run_node_script', { command: COMMAND_LABEL });
167
+ if (denied) {
168
+ if (!isTuiMode()) {
169
+ console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
186
170
  }
171
+ return denied;
187
172
  }
188
173
  const beforeGitStatus = readGitStatusSignature(rootDir);
189
174
  const result = await executeNodeScript(rootDir, script, typeof args.timeout_ms === 'number' && args.timeout_ms > 0
@@ -7,6 +7,7 @@ import { upsertIndexFile } from '../project-index.js';
7
7
  import { isTuiMode } from '../runtime-mode.js';
8
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
9
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
10
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
11
12
  function countOccurrences(haystack, needle) {
12
13
  if (needle.length === 0)
@@ -76,7 +77,7 @@ function buildStrReplacePreview(oldString, newString) {
76
77
  return `@@ str_replace @@\n${minus}\n${plus}`;
77
78
  }
78
79
  export async function strReplace(context, args) {
79
- const { rootDir, projectIndex, autoYes, confirmPatch } = context;
80
+ const { rootDir, projectIndex } = context;
80
81
  const filePath = repairFilePath(rootDir, String(args.filePath ?? args.file_path ?? '').trim());
81
82
  let oldString = typeof args.old_string === 'string'
82
83
  ? args.old_string
@@ -175,23 +176,18 @@ export async function strReplace(context, args) {
175
176
  }
176
177
  console.log();
177
178
  }
178
- if (!autoYes && confirmPatch) {
179
- const confirmed = await confirmPatch(filePath, preview);
180
- if (!confirmed) {
179
+ if (!scratchPath) {
180
+ const denied = await ensurePermission(context, {
181
+ bucket: 'edit',
182
+ title: 'Approve patch?',
183
+ body: 'Review changes before applying.',
184
+ filePath,
185
+ diff: preview,
186
+ }, 'str_replace', { filePath });
187
+ if (denied) {
181
188
  if (!isTuiMode())
182
189
  console.log(chalk.dim(` ⏭ str_replace skipped: ${filePath}`));
183
- return {
184
- ok: false,
185
- skipped: true,
186
- filePath,
187
- failureCategory: 'user_declined',
188
- failureDetails: {
189
- category: 'user_declined',
190
- tool: 'str_replace',
191
- action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
192
- },
193
- error: 'The real user rejected this proposed edit. Nothing was changed; this was not a tool failure or an automated system skip.',
194
- };
190
+ return denied;
195
191
  }
196
192
  }
197
193
  const nextContent = originalContent.split(oldString).join(newString);
@@ -2,11 +2,47 @@ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { classifyProjectPath, writeProjectFile } from '../patcher.js';
5
+ import { readFileEditSnapshot } from '../edit-journal.js';
5
6
  import { upsertIndexFile } from '../project-index.js';
6
7
  import { isTuiMode } from '../runtime-mode.js';
7
8
  import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
8
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
10
+ import { ensurePermission } from '../permissions.js';
9
11
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
12
+ const MAX_WRITE_PREVIEW_LINES = 400;
13
+ function boundedLines(text, limit) {
14
+ const lines = [];
15
+ let omitted = 0;
16
+ let start = 0;
17
+ for (;;) {
18
+ const newline = text.indexOf('\n', start);
19
+ if (lines.length < limit) {
20
+ lines.push(text.slice(start, newline === -1 ? undefined : newline));
21
+ }
22
+ else {
23
+ omitted += 1;
24
+ }
25
+ if (newline === -1)
26
+ break;
27
+ start = newline + 1;
28
+ }
29
+ return { lines, omitted };
30
+ }
31
+ function buildWritePreview(previous, next) {
32
+ const rows = ['@@ write_file @@'];
33
+ const append = (text, sign, marker) => {
34
+ const { lines, omitted } = boundedLines(text, MAX_WRITE_PREVIEW_LINES);
35
+ for (const line of lines)
36
+ rows.push(`${sign}${line}`);
37
+ if (omitted > 0) {
38
+ rows.push(`@@ ${omitted} more ${marker} line(s) not shown @@`);
39
+ }
40
+ };
41
+ if (previous !== null)
42
+ append(previous, '-', 'removed');
43
+ append(next, '+', 'added');
44
+ return rows.join('\n');
45
+ }
10
46
  export async function writeFile(context, args) {
11
47
  const { rootDir, projectIndex } = context;
12
48
  const filePath = String(args.filePath ?? '').trim();
@@ -60,6 +96,36 @@ export async function writeFile(context, args) {
60
96
  };
61
97
  }
62
98
  content = resolveRedactionTokens(context.safety, content, coveragePath, currentHash);
99
+ if (!scratchPath) {
100
+ const before = currentHash === null ? null : readFileEditSnapshot(rootDir, filePath);
101
+ const existing = before && before.contentEncoding === 'utf8' ? before.content : null;
102
+ const denied = await ensurePermission(context, {
103
+ bucket: currentHash === null ? 'create' : 'edit',
104
+ title: currentHash === null ? 'Approve new file?' : 'Approve patch?',
105
+ body: 'Review changes before applying.',
106
+ filePath,
107
+ diff: buildWritePreview(existing, content),
108
+ }, 'write_file', { filePath });
109
+ if (denied) {
110
+ if (!isTuiMode())
111
+ console.log(chalk.dim(` ⏭ write_file skipped: ${filePath}`));
112
+ return denied;
113
+ }
114
+ if (getCurrentFileHash(rootDir, filePath) !== currentHash) {
115
+ return {
116
+ ok: false,
117
+ filePath,
118
+ failureCategory: 'conflict',
119
+ error: `write_file refused: ${filePath} changed on disk while the approval prompt was open.`,
120
+ failureDetails: {
121
+ category: 'conflict',
122
+ tool: 'write_file',
123
+ action: 'Re-read the file to see its current contents, then decide whether the write is still correct and retry.',
124
+ },
125
+ currentHash,
126
+ };
127
+ }
128
+ }
63
129
  const { changed } = writeProjectFile(rootDir, filePath, content);
64
130
  let indexedChunks = 0;
65
131
  let retrievalTokensUsed = 0;
@@ -1,4 +1,5 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
+ import { bucketActionLabel, } from '../permissions.js';
2
3
  import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
3
4
  import { createTerminalTitleController } from './tui/terminal-title.js';
4
5
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
@@ -74,23 +75,6 @@ const WORKING_TOOL_PREVIEW_ITEMS = 4;
74
75
  const THINKING_NOTE_PREVIEW_ROWS = 3;
75
76
  const WORKING_TOOL_PREVIEW_ROWS = 3;
76
77
  const AGENT_MODE_LABEL_WIDTH = 16;
77
- const APPROVAL_OPTIONS = [
78
- {
79
- value: 'y',
80
- label: 'Approve once',
81
- description: 'Run this action only this time',
82
- },
83
- {
84
- value: 'a',
85
- label: 'Approve all remaining actions',
86
- description: 'Turn on auto-approve for the rest of the session',
87
- },
88
- {
89
- value: 'n',
90
- label: 'Deny',
91
- description: 'Reject this action',
92
- },
93
- ];
94
78
  export const SLASH_COMMANDS = [
95
79
  {
96
80
  command: '/help',
@@ -852,6 +836,13 @@ export function buildTranscriptFromSessionHistory(history) {
852
836
  }
853
837
  continue;
854
838
  }
839
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
840
+ const text = String(entry.userInput ?? '').trim();
841
+ if (text) {
842
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
843
+ }
844
+ continue;
845
+ }
855
846
  const text = textFromHistoryEntry(entry);
856
847
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
857
848
  if (isTurnFailureMarker(text)) {
@@ -988,7 +979,7 @@ function createInitialShellState(session, serverModels, debugUi) {
988
979
  activeTurnInputPreformatted: false,
989
980
  agentMode: session.agentMode,
990
981
  analyzingImages: 0,
991
- approvalCursor: getDefaultApprovalCursor(),
982
+ approvalCursor: 0,
992
983
  approvalPrompt: null,
993
984
  approvalScrollOffset: 0,
994
985
  autoYes: session.autoYes,
@@ -1022,6 +1013,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1022
1013
  resumePickerSessions: [],
1023
1014
  transcriptScrollOffset: 0,
1024
1015
  queuedMessage: null,
1016
+ turnMessages: [],
1025
1017
  serverModels: serverModels.models,
1026
1018
  sudoPrompt: null,
1027
1019
  status: 'Ready',
@@ -1271,26 +1263,13 @@ export function navigatePromptHistory(state, direction) {
1271
1263
  promptHistoryCursor: nextCursor,
1272
1264
  };
1273
1265
  }
1274
- function getDefaultApprovalCursor() {
1275
- const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1276
- return denyIndex === -1 ? 0 : denyIndex;
1277
- }
1278
- export function getNextApprovalCursor(currentIndex, direction) {
1279
- return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1280
- }
1281
- export function getApprovalChoiceForCursor(cursor) {
1282
- return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1283
- ?.value ?? 'n');
1284
- }
1285
- export function resolveApprovalChoiceFromInput(input) {
1286
- const normalizedInput = String(input ?? '').trim().toLowerCase();
1287
- if (normalizedInput === 'y')
1288
- return 'y';
1289
- if (normalizedInput === 'a')
1290
- return 'a';
1291
- if (normalizedInput === 'n')
1292
- return 'n';
1293
- return null;
1266
+ export function getDefaultApprovalCursor(optionCount) {
1267
+ return Math.max(0, optionCount - 1);
1268
+ }
1269
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1270
+ if (optionCount <= 0)
1271
+ return 0;
1272
+ return (currentIndex + direction + optionCount) % optionCount;
1294
1273
  }
1295
1274
  export function pauseBusyClock(state, nowMs) {
1296
1275
  if (state.busySince === null || state.busyPausedAt !== null)
@@ -1374,7 +1353,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1374
1353
  let currentServerModels = serverModels;
1375
1354
  let sessionAutoYes = session.autoYes;
1376
1355
  let resolveDone = null;
1377
- let resolveApprovalChoice = null;
1356
+ let resolvePermissionDecision = null;
1378
1357
  let resolveSudoPassword = null;
1379
1358
  let pendingUserInput = null;
1380
1359
  let cleanupSudoPasswordPrompt = null;
@@ -1393,6 +1372,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1393
1372
  let latestUsageSummary = null;
1394
1373
  let pendingTurnEntries = [];
1395
1374
  let activeTurnAbort = null;
1375
+ let activeServerTurnId = null;
1376
+ let unacknowledged = new Map();
1377
+ const blockedRows = new WeakMap();
1396
1378
  let newConversationInFlight = false;
1397
1379
  let todosTouchedThisTurn = false;
1398
1380
  const syncTodosState = () => {
@@ -1597,14 +1579,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1597
1579
  }
1598
1580
  };
1599
1581
  const dismissPendingApproval = () => {
1600
- if (!resolveApprovalChoice)
1582
+ if (!resolvePermissionDecision)
1601
1583
  return;
1602
- const pendingResolve = resolveApprovalChoice;
1603
- resolveApprovalChoice = null;
1604
- pendingResolve('n');
1584
+ const pendingResolve = resolvePermissionDecision;
1585
+ resolvePermissionDecision = null;
1586
+ pendingResolve({ kind: 'deny' });
1605
1587
  store.update((current) => ({
1606
1588
  ...resumeBusyClock(current, Date.now()),
1607
- approvalCursor: getDefaultApprovalCursor(),
1589
+ approvalCursor: 0,
1608
1590
  approvalPrompt: null,
1609
1591
  approvalScrollOffset: 0,
1610
1592
  }));
@@ -1677,6 +1659,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1677
1659
  status: 'Ready',
1678
1660
  thinkingTitle: '',
1679
1661
  thinkingNotes: [],
1662
+ turnMessages: [],
1680
1663
  workingTools: [],
1681
1664
  tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1682
1665
  }));
@@ -1927,40 +1910,45 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1927
1910
  pending.resolve(result);
1928
1911
  scheduleLiveFrameRemount();
1929
1912
  };
1930
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1913
+ const openApprovalPrompt = (request) => new Promise((resolve) => {
1914
+ const deny = { kind: 'deny' };
1931
1915
  if (exiting) {
1932
- resolve('n');
1916
+ resolve(deny);
1933
1917
  return;
1934
1918
  }
1935
- resolveApprovalChoice = resolve;
1919
+ resolvePermissionDecision = resolve;
1936
1920
  store.update((current) => ({
1937
1921
  ...pauseBusyClock(current, Date.now()),
1938
- approvalCursor: getDefaultApprovalCursor(),
1922
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
1923
+ approvalOpenedAt: Date.now(),
1939
1924
  approvalScrollOffset: 0,
1940
1925
  approvalPrompt: {
1941
- title,
1942
- body,
1943
- diffPreview: options.diff && options.filePath
1944
- ? parseDiffPreview(options.diff)
1926
+ title: request.title,
1927
+ body: request.body,
1928
+ diffPreview: request.diff && request.filePath
1929
+ ? parseDiffPreview(request.diff)
1945
1930
  : undefined,
1946
- filePath: options.filePath,
1931
+ filePath: request.filePath,
1932
+ options: request.options,
1947
1933
  returnStatus: current.status,
1948
1934
  },
1949
- status: title,
1935
+ status: request.title,
1950
1936
  }));
1951
1937
  });
1952
- const handleInlineApprovalChoice = async (choice) => {
1938
+ const handleInlineApprovalChoice = async (index) => {
1953
1939
  const current = store.getState();
1954
- const pendingResolve = resolveApprovalChoice;
1955
- resolveApprovalChoice = null;
1940
+ const options = current.approvalPrompt?.options ?? [];
1941
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
1942
+ const pendingResolve = resolvePermissionDecision;
1943
+ resolvePermissionDecision = null;
1956
1944
  store.update((next) => ({
1957
1945
  ...resumeBusyClock(next, Date.now()),
1958
- approvalCursor: getDefaultApprovalCursor(),
1946
+ approvalCursor: getDefaultApprovalCursor(options.length),
1959
1947
  approvalPrompt: null,
1960
1948
  approvalScrollOffset: 0,
1961
1949
  status: current.approvalPrompt?.returnStatus ?? next.status,
1962
1950
  }));
1963
- pendingResolve?.(choice);
1951
+ pendingResolve?.(chosen);
1964
1952
  };
1965
1953
  const refreshServerModels = async () => {
1966
1954
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -2230,6 +2218,129 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2230
2218
  }));
2231
2219
  await handleSubmit(queued.body);
2232
2220
  };
2221
+ let turnMessageCounter = 0;
2222
+ const upsertTurnMessage = (id, patch) => {
2223
+ store.update((current) => ({
2224
+ ...current,
2225
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2226
+ }));
2227
+ };
2228
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2229
+ if (unacknowledged.size === 0)
2230
+ return;
2231
+ const stranded = [...unacknowledged.values()];
2232
+ unacknowledged = new Map();
2233
+ for (const { row } of stranded) {
2234
+ upsertTurnMessage(row, {
2235
+ state: 'queued',
2236
+ note: autoSubmits
2237
+ ? 'not read — sending as the next prompt'
2238
+ : 'not read — left in the composer',
2239
+ });
2240
+ }
2241
+ const existing = store.getState().queuedMessage;
2242
+ const combined = [
2243
+ ...stranded.map((entry) => entry.queued),
2244
+ ...(existing ? [existing] : []),
2245
+ ];
2246
+ const body = combined
2247
+ .map((entry) => entry.body)
2248
+ .filter((entry) => entry.trim())
2249
+ .join('\n\n');
2250
+ if (!body.trim())
2251
+ return;
2252
+ scheduleLiveFrameRemount();
2253
+ store.update((current) => ({
2254
+ ...current,
2255
+ queuedMessage: {
2256
+ body,
2257
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2258
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2259
+ },
2260
+ }));
2261
+ };
2262
+ const fireQueuedMessage = async () => {
2263
+ const state = store.getState();
2264
+ const queued = state.queuedMessage;
2265
+ if (!queued || !state.busy || exiting)
2266
+ return;
2267
+ const text = (queued.pastedChunks.length
2268
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2269
+ : queued.body).trim();
2270
+ if (!text)
2271
+ return;
2272
+ const turnId = activeServerTurnId;
2273
+ const blockedReason = queued.imageAttachments.length > 0
2274
+ ? 'waiting — images go with the next prompt'
2275
+ : !turnId
2276
+ ? 'waiting for the turn to end'
2277
+ : null;
2278
+ const existingRow = blockedRows.get(queued);
2279
+ if (existingRow !== undefined) {
2280
+ upsertTurnMessage(existingRow, {
2281
+ state: 'queued',
2282
+ ...(blockedReason ? { note: blockedReason } : {}),
2283
+ });
2284
+ if (blockedReason || !turnId)
2285
+ return;
2286
+ }
2287
+ const id = existingRow ?? ++turnMessageCounter;
2288
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2289
+ if (existingRow === undefined) {
2290
+ scheduleLiveFrameRemount();
2291
+ store.update((current) => ({
2292
+ ...current,
2293
+ turnMessages: [
2294
+ ...current.turnMessages,
2295
+ {
2296
+ id,
2297
+ text,
2298
+ state: blockedReason ? 'queued' : 'sending',
2299
+ ...(blockedReason ? { note: blockedReason } : {}),
2300
+ },
2301
+ ],
2302
+ }));
2303
+ }
2304
+ if (blockedReason || !turnId) {
2305
+ blockedRows.set(queued, id);
2306
+ return;
2307
+ }
2308
+ blockedRows.delete(queued);
2309
+ unacknowledged.set(messageId, { row: id, queued });
2310
+ scheduleLiveFrameRemount();
2311
+ store.update((current) => current.queuedMessage === queued
2312
+ ? { ...current, queuedMessage: null }
2313
+ : current);
2314
+ queueTurnEntry({
2315
+ body: text,
2316
+ kind: 'user',
2317
+ preformatted: queued.pastedChunks.length > 0,
2318
+ title: 'You · sent mid-turn',
2319
+ });
2320
+ const rollback = (note) => {
2321
+ if (!unacknowledged.delete(messageId))
2322
+ return;
2323
+ upsertTurnMessage(id, { state: 'queued', note });
2324
+ scheduleLiveFrameRemount();
2325
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2326
+ };
2327
+ let outcome;
2328
+ try {
2329
+ outcome = await chat.postInterjection({
2330
+ config: authConfig,
2331
+ turnId,
2332
+ text,
2333
+ messageId,
2334
+ });
2335
+ }
2336
+ catch (error) {
2337
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2338
+ return;
2339
+ }
2340
+ if (outcome === 'stale') {
2341
+ rollback('turn ended — sending as the next prompt');
2342
+ }
2343
+ };
2233
2344
  const handleSubmit = async (rawInput) => {
2234
2345
  const chunks = store.getState().pastedChunks;
2235
2346
  const expanded = chunks.length
@@ -2481,6 +2592,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2481
2592
  }
2482
2593
  latestUsageSummary = null;
2483
2594
  disarmExitConfirm();
2595
+ unacknowledged = new Map();
2484
2596
  const turnStartedAt = Date.now();
2485
2597
  const turnGeneration = ++activeTurnGeneration;
2486
2598
  const turnAbort = new AbortController();
@@ -2513,6 +2625,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2513
2625
  transcriptScrollOffset: 0,
2514
2626
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2515
2627
  turnCounter: current.turnCounter + 1,
2628
+ turnMessages: [],
2516
2629
  workingTools: [],
2517
2630
  }));
2518
2631
  try {
@@ -2523,6 +2636,25 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2523
2636
  input,
2524
2637
  imageAttachments,
2525
2638
  signal: turnAbort.signal,
2639
+ onTurnStart: (serverTurnId) => {
2640
+ if (turnGeneration !== activeTurnGeneration)
2641
+ return;
2642
+ activeServerTurnId = serverTurnId;
2643
+ },
2644
+ onInterjectionDelivered: (event) => {
2645
+ if (turnGeneration !== activeTurnGeneration)
2646
+ return;
2647
+ for (const messageId of event.messageIds ?? []) {
2648
+ const pending = unacknowledged.get(messageId);
2649
+ if (!pending)
2650
+ continue;
2651
+ unacknowledged.delete(messageId);
2652
+ upsertTurnMessage(pending.row, {
2653
+ state: 'delivered',
2654
+ note: undefined,
2655
+ });
2656
+ }
2657
+ },
2526
2658
  });
2527
2659
  if (turnGeneration !== activeTurnGeneration)
2528
2660
  return;
@@ -2571,6 +2703,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2571
2703
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2572
2704
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2573
2705
  }));
2706
+ recoverUnacknowledgedMessages();
2574
2707
  await flushQueuedMessage();
2575
2708
  }
2576
2709
  catch (error) {
@@ -2616,6 +2749,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2616
2749
  appendError(error.message);
2617
2750
  }
2618
2751
  await remountTui();
2752
+ recoverUnacknowledgedMessages(!cancelled);
2619
2753
  if (!cancelled && turnGeneration === activeTurnGeneration) {
2620
2754
  await flushQueuedMessage();
2621
2755
  }
@@ -2626,7 +2760,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2626
2760
  }
2627
2761
  if (turnGeneration === activeTurnGeneration) {
2628
2762
  lastTurnStartedAt = null;
2763
+ activeServerTurnId = null;
2629
2764
  }
2765
+ recoverUnacknowledgedMessages();
2630
2766
  }
2631
2767
  };
2632
2768
  session.onImageAnalysis = (activeImageCount) => {
@@ -2721,44 +2857,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2721
2857
  projectIndex.onContextLog = session.onContextLog;
2722
2858
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2723
2859
  session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
2724
- session.confirmCommand = async (command) => {
2860
+ session.requestPermission = async (request) => {
2861
+ const deny = { kind: 'deny' };
2725
2862
  if (exiting)
2726
- return false;
2863
+ return deny;
2727
2864
  if (sessionAutoYes)
2728
- return true;
2729
- const choice = await openApprovalPrompt('Approve command?', command);
2730
- if (choice === 'a') {
2731
- setAgentMode('auto-accept');
2732
- syncShellStateFromSession();
2865
+ return { kind: 'once' };
2866
+ const decision = await openApprovalPrompt(request);
2867
+ if (decision.kind === 'always-bucket') {
2733
2868
  appendTurnAwareEntry({
2734
- body: 'Auto-approve enabled for the rest of this session.',
2869
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2735
2870
  kind: 'system',
2736
2871
  title: 'Approvals',
2737
2872
  });
2738
- return true;
2739
2873
  }
2740
- return choice === 'y';
2741
- };
2742
- session.confirmPatch = async (filePath, patch) => {
2743
- if (exiting)
2744
- return false;
2745
- if (sessionAutoYes)
2746
- return true;
2747
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2748
- diff: patch,
2749
- filePath,
2750
- });
2751
- if (choice === 'a') {
2752
- setAgentMode('auto-accept');
2753
- syncShellStateFromSession();
2874
+ else if (decision.kind === 'always-prefix') {
2754
2875
  appendTurnAwareEntry({
2755
- body: 'Auto-approve enabled for the rest of this session.',
2876
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
2756
2877
  kind: 'system',
2757
2878
  title: 'Approvals',
2758
2879
  });
2759
- return true;
2760
2880
  }
2761
- return choice === 'y';
2881
+ return decision;
2762
2882
  };
2763
2883
  const shellInputHandlers = {
2764
2884
  getApprovalScrollLimit: () => {
@@ -2791,6 +2911,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2791
2911
  onJobsPickerKill: handleJobsPickerKill,
2792
2912
  onSudoPasswordInput: handleSudoPasswordInput,
2793
2913
  onSubmit: handleSubmit,
2914
+ onFireQueuedMessage: fireQueuedMessage,
2794
2915
  };
2795
2916
  const unsubscribe = store.subscribe(() => {
2796
2917
  renderCurrentFrame();