@thegitai/cli 1.0.0-preview.48 → 1.0.0-preview.49
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 +25 -0
- package/dist/bin/ai.js +21 -11
- package/dist/src/api/chat.js +564 -214
- package/dist/src/api/startup-handshake.js +36 -0
- package/dist/src/api/turn-recovery.js +189 -0
- package/dist/src/help-text.js +7 -0
- package/dist/src/tool-executor.js +1 -0
- package/dist/src/ui/repl.js +69 -38
- package/dist/src/ui/tui/build-frame.js +17 -0
- package/package.json +6 -6
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchWhoamiResponse } from './auth.js';
|
|
2
|
+
import { STARTUP_RETRY_BUDGET, isTransientNetworkError, } from './http.js';
|
|
3
|
+
import { fetchServerModels } from './models.js';
|
|
4
|
+
import { abortableDelay, createTurnRecovery, isRetryableTurnFailure, isTurnRecoveryExhausted, } from './turn-recovery.js';
|
|
5
|
+
export async function loadStartupHandshake({ config, canStartOffline, fetchImpl, budget = STARTUP_RETRY_BUDGET, sleep = abortableDelay, onStatus = () => { }, }) {
|
|
6
|
+
let lastFailure;
|
|
7
|
+
const recovery = createTurnRecovery({
|
|
8
|
+
signal: new AbortController().signal,
|
|
9
|
+
sleep,
|
|
10
|
+
onStatus: (status) => onStatus(status, lastFailure),
|
|
11
|
+
});
|
|
12
|
+
const attempt = async () => {
|
|
13
|
+
const [models, whoami] = await Promise.allSettled([
|
|
14
|
+
fetchServerModels({ config, fetchImpl, budget }),
|
|
15
|
+
fetchWhoamiResponse({ config, fetchImpl, budget }),
|
|
16
|
+
]);
|
|
17
|
+
const unreachable = models.status === 'rejected' &&
|
|
18
|
+
isRetryableTurnFailure(models.reason) &&
|
|
19
|
+
!(canStartOffline && isTransientNetworkError(models.reason));
|
|
20
|
+
if (unreachable) {
|
|
21
|
+
lastFailure = models.reason;
|
|
22
|
+
throw models.reason;
|
|
23
|
+
}
|
|
24
|
+
return { models, whoami };
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
return await recovery.run('loading your models', attempt);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const reason = isTurnRecoveryExhausted(error) ? error.cause : error;
|
|
31
|
+
return {
|
|
32
|
+
models: { status: 'rejected', reason },
|
|
33
|
+
whoami: { status: 'rejected', reason },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { isTransientNetworkError, ServerApiError, transportFailureDetail, } from './http.js';
|
|
2
|
+
export const TURN_RECOVERY_DELAY_MS = 10_000;
|
|
3
|
+
export const TURN_RECOVERY_RETRIES = 3;
|
|
4
|
+
export const TURN_REQUEST_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const TURN_STREAM_IDLE_TIMEOUT_MS = 60_000;
|
|
6
|
+
const RETRYABLE_TURN_STATUSES = new Set([408, 500, 502, 503, 504, 520, 521, 522, 523, 524]);
|
|
7
|
+
export function isRetryableTurnStatus(status) {
|
|
8
|
+
return RETRYABLE_TURN_STATUSES.has(status);
|
|
9
|
+
}
|
|
10
|
+
export function isRetryableTurnFailure(error) {
|
|
11
|
+
if (error instanceof ServerApiError)
|
|
12
|
+
return isRetryableTurnStatus(error.status);
|
|
13
|
+
return isTransientNetworkError(error);
|
|
14
|
+
}
|
|
15
|
+
export function interruptedStream(message = 'The turn stream ended before the turn finished.') {
|
|
16
|
+
return Object.assign(new Error(message), { code: 'ECONNRESET' });
|
|
17
|
+
}
|
|
18
|
+
export function formatTurnRecoveryStatus(status) {
|
|
19
|
+
if (status.phase === 'waiting') {
|
|
20
|
+
return `Connection interrupted · retrying in ${TURN_RECOVERY_DELAY_MS / 1000}s · retry ${status.retry}/${TURN_RECOVERY_RETRIES}`;
|
|
21
|
+
}
|
|
22
|
+
if (status.phase === 'reconnecting') {
|
|
23
|
+
return `Reconnecting · retry ${status.retry}/${TURN_RECOVERY_RETRIES}`;
|
|
24
|
+
}
|
|
25
|
+
if (status.phase === 'reconnected')
|
|
26
|
+
return 'Reconnected';
|
|
27
|
+
if (status.phase === 'continuing')
|
|
28
|
+
return 'Server restarted · continuing from the finished steps';
|
|
29
|
+
if (status.phase === 'continued')
|
|
30
|
+
return 'Server restarted · continued from the finished steps';
|
|
31
|
+
return 'Connection restored · continued the same turn';
|
|
32
|
+
}
|
|
33
|
+
export const TURN_RECOVERY_EXHAUSTED = 'TURN_RECOVERY_EXHAUSTED';
|
|
34
|
+
export function isTurnRecoveryExhausted(error) {
|
|
35
|
+
return error?.code === TURN_RECOVERY_EXHAUSTED;
|
|
36
|
+
}
|
|
37
|
+
function recoveryExhausted(operation, cause) {
|
|
38
|
+
const detail = transportFailureDetail(cause);
|
|
39
|
+
const reason = detail ?? (cause instanceof ServerApiError ? `HTTP ${cause.status}` : 'no response');
|
|
40
|
+
return Object.assign(new Error(`Connection lost while ${operation} (${reason}). Reconnecting failed after ${TURN_RECOVERY_RETRIES} retries. Some actions in this turn may already have completed.`, { cause }), { code: TURN_RECOVERY_EXHAUSTED });
|
|
41
|
+
}
|
|
42
|
+
export function abortableDelay(ms, signal) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
if (signal.aborted) {
|
|
45
|
+
reject(signal.reason);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const onAbort = () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
reject(signal.reason);
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
signal.removeEventListener('abort', onAbort);
|
|
54
|
+
resolve();
|
|
55
|
+
}, ms);
|
|
56
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function untilAborted(promise, signal) {
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
return Promise.reject(signal.reason);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const onAbort = () => reject(signal.reason);
|
|
64
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
promise
|
|
66
|
+
.then(resolve, reject)
|
|
67
|
+
.finally(() => signal.removeEventListener('abort', onAbort));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function createTurnRecovery({ signal, onStatus = () => { }, sleep = abortableDelay, }) {
|
|
71
|
+
const affected = new Set();
|
|
72
|
+
let retry = 0;
|
|
73
|
+
let gate = null;
|
|
74
|
+
let exhausted = null;
|
|
75
|
+
async function retryAfter(token, operation, error, caller = signal) {
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
withdrawIfAborted(token, caller);
|
|
78
|
+
if (exhausted)
|
|
79
|
+
throw exhausted;
|
|
80
|
+
if (!isRetryableTurnFailure(error))
|
|
81
|
+
throw error;
|
|
82
|
+
affected.add(token);
|
|
83
|
+
if (!gate) {
|
|
84
|
+
if (retry === TURN_RECOVERY_RETRIES) {
|
|
85
|
+
exhausted = recoveryExhausted(operation, error);
|
|
86
|
+
throw exhausted;
|
|
87
|
+
}
|
|
88
|
+
retry += 1;
|
|
89
|
+
const current = retry;
|
|
90
|
+
onStatus({ phase: 'waiting', retry: current });
|
|
91
|
+
gate = sleep(TURN_RECOVERY_DELAY_MS, signal)
|
|
92
|
+
.then(() => {
|
|
93
|
+
signal.throwIfAborted();
|
|
94
|
+
onStatus({ phase: 'reconnecting', retry: current });
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
gate = null;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (caller === signal) {
|
|
101
|
+
await gate;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await untilAborted(gate, caller).catch((reason) => {
|
|
105
|
+
withdrawIfAborted(token, caller);
|
|
106
|
+
throw reason;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function withdrawIfAborted(token, caller) {
|
|
110
|
+
if (!caller.aborted)
|
|
111
|
+
return;
|
|
112
|
+
affected.delete(token);
|
|
113
|
+
throw caller.reason;
|
|
114
|
+
}
|
|
115
|
+
function reconnected(token) {
|
|
116
|
+
if (!affected.has(token) || gate || exhausted)
|
|
117
|
+
return;
|
|
118
|
+
onStatus({ phase: 'reconnected' });
|
|
119
|
+
}
|
|
120
|
+
function restored(token) {
|
|
121
|
+
if (!affected.delete(token))
|
|
122
|
+
return;
|
|
123
|
+
if (affected.size > 0 || gate || exhausted)
|
|
124
|
+
return;
|
|
125
|
+
retry = 0;
|
|
126
|
+
onStatus({ phase: 'restored' });
|
|
127
|
+
}
|
|
128
|
+
async function run(operation, attempt, caller = signal) {
|
|
129
|
+
const token = {};
|
|
130
|
+
for (;;) {
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
withdrawIfAborted(token, caller);
|
|
133
|
+
if (exhausted)
|
|
134
|
+
throw exhausted;
|
|
135
|
+
try {
|
|
136
|
+
const value = await attempt();
|
|
137
|
+
restored(token);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
await retryAfter(token, operation, error, caller);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { run, retryAfter, reconnected, restored };
|
|
146
|
+
}
|
|
147
|
+
export async function fetchTurnAttempt(fetchImpl, url, init, signal, { readBody = false } = {}) {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
150
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
151
|
+
if (signal.aborted)
|
|
152
|
+
onAbort();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(new DOMException('The request did not get a response in time.', 'TimeoutError')), TURN_REQUEST_TIMEOUT_MS);
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
156
|
+
if (!readBody && response.ok)
|
|
157
|
+
return response;
|
|
158
|
+
return new Response(await response.arrayBuffer(), {
|
|
159
|
+
status: response.status,
|
|
160
|
+
headers: response.headers,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
signal.removeEventListener('abort', onAbort);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function readTurnChunk(reader, signal) {
|
|
169
|
+
let timer;
|
|
170
|
+
let onAbort;
|
|
171
|
+
try {
|
|
172
|
+
return await Promise.race([
|
|
173
|
+
reader.read(),
|
|
174
|
+
new Promise((_, reject) => {
|
|
175
|
+
timer = setTimeout(() => reject(interruptedStream(`The turn stream sent nothing for ${TURN_STREAM_IDLE_TIMEOUT_MS / 1000} seconds.`)), TURN_STREAM_IDLE_TIMEOUT_MS);
|
|
176
|
+
onAbort = () => reject(signal.reason);
|
|
177
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
178
|
+
if (signal.aborted)
|
|
179
|
+
onAbort();
|
|
180
|
+
}),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (timer)
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
if (onAbort)
|
|
187
|
+
signal.removeEventListener('abort', onAbort);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/src/help-text.js
CHANGED
|
@@ -194,6 +194,13 @@ const HELP_MARKDOWN = [
|
|
|
194
194
|
' computer" on that page, and paste the code back into the terminal.',
|
|
195
195
|
'- A local session was used with a different sign-in → sign in with the',
|
|
196
196
|
' account you used for that session or start a new session.',
|
|
197
|
+
'- The connection drops mid-turn → nothing to do: the CLI reconnects to the',
|
|
198
|
+
' same turn up to three times, ten seconds apart, without running anything',
|
|
199
|
+
' twice. Esc stops it. If it cannot reconnect it says so; some actions may',
|
|
200
|
+
' already have completed, and a queued message waits in the composer.',
|
|
201
|
+
' If the service restarted mid-turn, the turn continues from the steps that',
|
|
202
|
+
' had finished. Starting `ai` while the service is briefly unavailable waits',
|
|
203
|
+
' the same way.',
|
|
197
204
|
'- For anything else, re-run the command and file the printed error',
|
|
198
205
|
' message with `/report` — there is no client-side debug mode by design.',
|
|
199
206
|
].join('\n');
|
|
@@ -213,6 +213,7 @@ function recordAssistantEdit(session, call, result, before) {
|
|
|
213
213
|
clearEditFailure(session.clientState.safety, filePath);
|
|
214
214
|
}
|
|
215
215
|
export async function executeLocalToolCall(session, call) {
|
|
216
|
+
session.turnAbortSignal?.throwIfAborted();
|
|
216
217
|
session.onStatus(formatToolCallForStatus(call));
|
|
217
218
|
try {
|
|
218
219
|
const agentModeBlocked = buildAgentModeToolBlockedResult(session.agentMode, call);
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -12,7 +12,8 @@ export { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slash
|
|
|
12
12
|
import { composerInputWidth, frameContentWidth } from './tui/composer-layout.js';
|
|
13
13
|
import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
|
|
14
14
|
import { chat, models } from '../api/index.js';
|
|
15
|
-
import { isTurnCancelledError } from '../api/chat.js';
|
|
15
|
+
import { isTurnCancelledError, isTurnOutcomeKnown } from '../api/chat.js';
|
|
16
|
+
import { formatTurnRecoveryStatus } from '../api/turn-recovery.js';
|
|
16
17
|
import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
|
|
17
18
|
import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
|
|
18
19
|
import { setScratchSession } from '../scratch-dir.js';
|
|
@@ -542,15 +543,7 @@ function getToolFilePath(call, result) {
|
|
|
542
543
|
'').trim();
|
|
543
544
|
}
|
|
544
545
|
function formatToolResultState(result) {
|
|
545
|
-
|
|
546
|
-
return 'Skipped';
|
|
547
|
-
if (result?.blocked)
|
|
548
|
-
return 'Blocked';
|
|
549
|
-
if (result?.ok === true)
|
|
550
|
-
return 'OK';
|
|
551
|
-
if (Number.isInteger(result?.httpStatus))
|
|
552
|
-
return String(result.httpStatus);
|
|
553
|
-
return 'Failed';
|
|
546
|
+
return result?.skipped ? 'Skipped' : 'OK';
|
|
554
547
|
}
|
|
555
548
|
function formatRunCommandResultState(result) {
|
|
556
549
|
if (result?.timedOut)
|
|
@@ -588,12 +581,15 @@ function buildFileChangeEntry(event) {
|
|
|
588
581
|
: call.name === 'undo_edit'
|
|
589
582
|
? 'Undo'
|
|
590
583
|
: 'Edit';
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
584
|
+
if (skipped) {
|
|
585
|
+
return {
|
|
586
|
+
body: error || `${call.name} ${filePath}`,
|
|
587
|
+
filePath,
|
|
588
|
+
kind: 'system',
|
|
589
|
+
title: `${verb} skipped: ${filePath}`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return { body: '', filePath, kind: 'system', title: `${verb}: ${filePath}` };
|
|
597
593
|
}
|
|
598
594
|
if (result?.scratch === true) {
|
|
599
595
|
const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
|
|
@@ -710,22 +706,25 @@ function buildFileChangeEntry(event) {
|
|
|
710
706
|
}
|
|
711
707
|
export function buildWorkingToolEntry(event) {
|
|
712
708
|
const { call, result } = event;
|
|
713
|
-
const
|
|
709
|
+
const failed = result?.ok !== true && result?.skipped !== true;
|
|
710
|
+
const kind = result?.ok === true ? 'tool' : 'system';
|
|
711
|
+
const state = (text) => (failed ? '' : text);
|
|
712
|
+
const error = !failed && typeof result?.error === 'string' && result.error.trim()
|
|
714
713
|
? `\n${truncate(result.error.trim(), 180)}`
|
|
715
714
|
: '';
|
|
716
715
|
if (call.name === 'run_command') {
|
|
717
716
|
const command = String(call.args?.command ?? result?.command ?? '').trim();
|
|
718
717
|
if (result?.backgrounded === true && result?.jobId) {
|
|
719
718
|
return {
|
|
720
|
-
body: `$ ${truncate(command, 180)}
|
|
721
|
-
kind
|
|
719
|
+
body: `$ ${truncate(command, 180)}${state(`\nBackground job ${String(result.jobId)} started.`)}${error}`,
|
|
720
|
+
kind,
|
|
722
721
|
preformatted: true,
|
|
723
722
|
title: 'Shell',
|
|
724
723
|
};
|
|
725
724
|
}
|
|
726
725
|
return {
|
|
727
|
-
body: `$ ${truncate(command, 180)}
|
|
728
|
-
kind
|
|
726
|
+
body: `$ ${truncate(command, 180)}${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
|
|
727
|
+
kind,
|
|
729
728
|
title: 'Shell',
|
|
730
729
|
};
|
|
731
730
|
}
|
|
@@ -737,15 +736,15 @@ export function buildWorkingToolEntry(event) {
|
|
|
737
736
|
? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
|
|
738
737
|
: formatToolResultState(result);
|
|
739
738
|
return {
|
|
740
|
-
body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
|
|
741
|
-
kind
|
|
739
|
+
body: `${jobId || '(unknown job)'}${state(` · ${stateText}`)}${error}`,
|
|
740
|
+
kind,
|
|
742
741
|
title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
|
|
743
742
|
};
|
|
744
743
|
}
|
|
745
744
|
if (call.name === 'run_node_script') {
|
|
746
745
|
return {
|
|
747
|
-
body: `node --input-type=module <script via stdin
|
|
748
|
-
kind
|
|
746
|
+
body: `node --input-type=module <script via stdin>${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
|
|
747
|
+
kind,
|
|
749
748
|
title: 'Node',
|
|
750
749
|
};
|
|
751
750
|
}
|
|
@@ -754,8 +753,8 @@ export function buildWorkingToolEntry(event) {
|
|
|
754
753
|
}
|
|
755
754
|
const args = formatToolArgs(call, result);
|
|
756
755
|
return {
|
|
757
|
-
body: `${call.name}${args ? ` ${args}` : ''} -> ${formatToolResultState(result)}${error}`,
|
|
758
|
-
kind
|
|
756
|
+
body: `${call.name}${args ? ` ${args}` : ''}${state(` -> ${formatToolResultState(result)}`)}${error}`,
|
|
757
|
+
kind,
|
|
759
758
|
title: 'Tool',
|
|
760
759
|
};
|
|
761
760
|
}
|
|
@@ -873,10 +872,10 @@ export function buildTranscriptFromSessionHistory(history) {
|
|
|
873
872
|
}
|
|
874
873
|
if (!call || !isFileChangeTool(call.name))
|
|
875
874
|
continue;
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}));
|
|
875
|
+
const result = (functionResponse.response ?? {});
|
|
876
|
+
if (result.ok !== true && result.skipped !== true)
|
|
877
|
+
continue;
|
|
878
|
+
entries.push(buildFileChangeEntry({ call, result }));
|
|
880
879
|
}
|
|
881
880
|
if (entry.role === 'user' && entry.kind === 'turnStart') {
|
|
882
881
|
const text = displayUserTextFromHistoryEntry(entry);
|
|
@@ -1751,6 +1750,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1751
1750
|
promptHistoryCursor: queued ? null : current.promptHistoryCursor,
|
|
1752
1751
|
promptHistoryDraft: queued ? '' : current.promptHistoryDraft,
|
|
1753
1752
|
queuedMessage: null,
|
|
1753
|
+
recoveryStatus: '',
|
|
1754
1754
|
status: 'Ready',
|
|
1755
1755
|
thinkingTitle: '',
|
|
1756
1756
|
thinkingNotes: [],
|
|
@@ -2009,13 +2009,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2009
2009
|
pending.resolve(result);
|
|
2010
2010
|
scheduleLiveFrameRemount();
|
|
2011
2011
|
};
|
|
2012
|
-
const openApprovalPrompt = (request) => new Promise((resolve) => {
|
|
2012
|
+
const openApprovalPrompt = (request, signal) => new Promise((resolve) => {
|
|
2013
2013
|
const deny = { kind: 'deny' };
|
|
2014
|
-
if (exiting) {
|
|
2014
|
+
if (exiting || signal?.aborted) {
|
|
2015
2015
|
resolve(deny);
|
|
2016
2016
|
return;
|
|
2017
2017
|
}
|
|
2018
|
-
|
|
2018
|
+
const onAbort = () => dismissPendingApproval();
|
|
2019
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
2020
|
+
resolvePermissionDecision = (decision) => {
|
|
2021
|
+
signal?.removeEventListener('abort', onAbort);
|
|
2022
|
+
resolve(decision);
|
|
2023
|
+
};
|
|
2019
2024
|
store.update((current) => ({
|
|
2020
2025
|
...pauseBusyClock(current, Date.now()),
|
|
2021
2026
|
...closedJobsPickerFields(current),
|
|
@@ -2816,6 +2821,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2816
2821
|
busyPausedAt: null,
|
|
2817
2822
|
busySince: turnStartedAt,
|
|
2818
2823
|
clockNow: turnStartedAt,
|
|
2824
|
+
recoveryStatus: '',
|
|
2819
2825
|
status: 'Running turn...',
|
|
2820
2826
|
thinkingTitle: '',
|
|
2821
2827
|
thinkingNotes: [],
|
|
@@ -2897,6 +2903,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2897
2903
|
busy: false,
|
|
2898
2904
|
busyPausedAt: null,
|
|
2899
2905
|
busySince: null,
|
|
2906
|
+
recoveryStatus: '',
|
|
2900
2907
|
status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
|
|
2901
2908
|
tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
|
|
2902
2909
|
}));
|
|
@@ -2913,6 +2920,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2913
2920
|
const cancelled = isTurnCancelledError(error);
|
|
2914
2921
|
if (exitForAuthenticationError(error))
|
|
2915
2922
|
return;
|
|
2923
|
+
dismissPendingApproval();
|
|
2924
|
+
dismissPendingSudoPassword();
|
|
2925
|
+
dismissPendingUserInput();
|
|
2916
2926
|
const failedTurnAttachments = activeTurnImageAttachments;
|
|
2917
2927
|
activeTurnImageAttachments = [];
|
|
2918
2928
|
const quotaNotice = cancelled ? null : buildQuotaNotice(error);
|
|
@@ -2925,6 +2935,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2925
2935
|
busySince: null,
|
|
2926
2936
|
commandLog: [],
|
|
2927
2937
|
imageAttachments: failedTurnAttachments,
|
|
2938
|
+
recoveryStatus: '',
|
|
2928
2939
|
status: cancelled
|
|
2929
2940
|
? 'Ready'
|
|
2930
2941
|
: quotaNotice
|
|
@@ -2964,8 +2975,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2964
2975
|
appendStaticEntries(buildTurnFailureHintEntries(cancelled));
|
|
2965
2976
|
}
|
|
2966
2977
|
await remountTui();
|
|
2967
|
-
|
|
2968
|
-
|
|
2978
|
+
const autoSubmits = !cancelled && isTurnOutcomeKnown(error);
|
|
2979
|
+
recoverUnacknowledgedMessages(autoSubmits);
|
|
2980
|
+
if (autoSubmits && turnGeneration === activeTurnGeneration) {
|
|
2969
2981
|
await flushQueuedMessage();
|
|
2970
2982
|
}
|
|
2971
2983
|
}
|
|
@@ -2988,6 +3000,23 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2988
3000
|
generatingImage: activeImageCount > 0 ? false : current.generatingImage,
|
|
2989
3001
|
}));
|
|
2990
3002
|
};
|
|
3003
|
+
session.onRecovery = (status) => {
|
|
3004
|
+
if (status.phase === 'restored' || status.phase === 'continued') {
|
|
3005
|
+
store.update((current) => ({ ...current, recoveryStatus: '' }));
|
|
3006
|
+
appendTurnAwareEntry(status.phase === 'restored'
|
|
3007
|
+
? { body: 'Continued the same turn.', kind: 'system', title: 'Connection restored' }
|
|
3008
|
+
: {
|
|
3009
|
+
body: 'Continued from the steps that had finished.',
|
|
3010
|
+
kind: 'system',
|
|
3011
|
+
title: 'Server restarted',
|
|
3012
|
+
});
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
store.update((current) => ({
|
|
3016
|
+
...current,
|
|
3017
|
+
recoveryStatus: status.phase === 'reconnected' ? '' : formatTurnRecoveryStatus(status),
|
|
3018
|
+
}));
|
|
3019
|
+
};
|
|
2991
3020
|
session.onImageGeneration = (active) => {
|
|
2992
3021
|
store.update((current) => ({
|
|
2993
3022
|
...current,
|
|
@@ -3026,7 +3055,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
3026
3055
|
if (isFileChangeTool(event.call.name)) {
|
|
3027
3056
|
const entry = buildFileChangeEntry(event);
|
|
3028
3057
|
store.appendWorkingTool(entry);
|
|
3029
|
-
|
|
3058
|
+
const result = event.result;
|
|
3059
|
+
if (result?.ok === true || result?.skipped === true)
|
|
3060
|
+
appendTurnAwareEntry(entry);
|
|
3030
3061
|
return;
|
|
3031
3062
|
}
|
|
3032
3063
|
store.appendWorkingTool(buildWorkingToolEntry(event));
|
|
@@ -3079,7 +3110,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
3079
3110
|
return deny;
|
|
3080
3111
|
if (sessionAutoYes)
|
|
3081
3112
|
return { kind: 'once' };
|
|
3082
|
-
const decision = await openApprovalPrompt(request);
|
|
3113
|
+
const decision = await openApprovalPrompt(request, session.turnAbortSignal);
|
|
3083
3114
|
if (decision.kind === 'always-bucket') {
|
|
3084
3115
|
appendTurnAwareEntry({
|
|
3085
3116
|
body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
|
|
@@ -691,6 +691,20 @@ function thinkingHeaderLine(spinnerFrame, title, width) {
|
|
|
691
691
|
function thinkingNoteLine(note, width) {
|
|
692
692
|
return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
|
|
693
693
|
}
|
|
694
|
+
function buildRecoveryStatusLines(status, width) {
|
|
695
|
+
const [label = '', ...rest] = status.split(' · ');
|
|
696
|
+
const detail = rest.join(' · ');
|
|
697
|
+
const labelSpan = span(fitLine(label, Math.max(1, width - 2)), { color: 'yellow', bold: true });
|
|
698
|
+
if (!detail)
|
|
699
|
+
return [line(span('● ', { color: 'yellow' }), labelSpan)];
|
|
700
|
+
if (displayWidth(`● ${label} · ${detail}`) <= width) {
|
|
701
|
+
return [line(span('● ', { color: 'yellow' }), labelSpan, span(` · ${detail}`))];
|
|
702
|
+
}
|
|
703
|
+
return [
|
|
704
|
+
line(span('● ', { color: 'yellow' }), labelSpan),
|
|
705
|
+
...wrapText(detail, Math.max(1, width - 2)).map((row) => plainLine(` ${row}`)),
|
|
706
|
+
];
|
|
707
|
+
}
|
|
694
708
|
function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
|
|
695
709
|
if (!state.busy)
|
|
696
710
|
return [];
|
|
@@ -722,6 +736,9 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
|
|
|
722
736
|
lines.push(plainLine(''));
|
|
723
737
|
}
|
|
724
738
|
lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
|
|
739
|
+
if (state.recoveryStatus) {
|
|
740
|
+
lines.push(...buildRecoveryStatusLines(state.recoveryStatus, width));
|
|
741
|
+
}
|
|
725
742
|
const turnMessages = state.turnMessages ?? [];
|
|
726
743
|
if (turnMessages.length > 0) {
|
|
727
744
|
lines.push(plainLine(''));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-preview.
|
|
3
|
+
"version": "1.0.0-preview.49",
|
|
4
4
|
"description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agentic-ai",
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"@lydell/node-pty-linux-x64": "1.1.0",
|
|
45
45
|
"@lydell/node-pty-win32-arm64": "1.1.0",
|
|
46
46
|
"@lydell/node-pty-win32-x64": "1.1.0",
|
|
47
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-preview.
|
|
48
|
-
"@thegitai/tui-darwin-x64": "1.0.0-preview.
|
|
49
|
-
"@thegitai/tui-linux-arm64": "1.0.0-preview.
|
|
50
|
-
"@thegitai/tui-linux-x64": "1.0.0-preview.
|
|
51
|
-
"@thegitai/tui-win32-x64": "1.0.0-preview.
|
|
47
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-preview.49",
|
|
48
|
+
"@thegitai/tui-darwin-x64": "1.0.0-preview.49",
|
|
49
|
+
"@thegitai/tui-linux-arm64": "1.0.0-preview.49",
|
|
50
|
+
"@thegitai/tui-linux-x64": "1.0.0-preview.49",
|
|
51
|
+
"@thegitai/tui-win32-x64": "1.0.0-preview.49",
|
|
52
52
|
"@vscode/ripgrep": "1.18.0"
|
|
53
53
|
},
|
|
54
54
|
"repository": {
|