@adhdev/daemon-core 0.9.54 → 0.9.56
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/boot/daemon-lifecycle.d.ts +5 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +9 -0
- package/dist/cli-adapters/provider-cli-config.d.ts +1 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +24 -0
- package/dist/commands/handler.d.ts +5 -0
- package/dist/git/git-commands.d.ts +53 -0
- package/dist/git/git-types.d.ts +1 -1
- package/dist/git/index.d.ts +2 -0
- package/dist/git/turn-snapshot-tracker.d.ts +16 -0
- package/dist/index.js +249 -18
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +248 -18
- package/dist/index.mjs.map +1 -1
- package/dist/providers/contracts.d.ts +2 -0
- package/node_modules/@adhdev/session-host-core/package.json +1 -1
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +6 -0
- package/src/cli-adapters/provider-cli-adapter.ts +62 -4
- package/src/cli-adapters/provider-cli-config.d.ts +1 -0
- package/src/cli-adapters/provider-cli-config.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.ts +8 -0
- package/src/commands/handler.ts +16 -0
- package/src/git/git-commands.ts +209 -12
- package/src/git/git-types.ts +2 -1
- package/src/git/index.ts +3 -0
- package/src/git/turn-snapshot-tracker.ts +31 -0
- package/src/providers/contracts.d.ts +8 -0
- package/src/providers/contracts.ts +2 -0
- package/src/providers/provider-schema.ts +1 -0
|
@@ -358,6 +358,8 @@ export interface ProviderModule {
|
|
|
358
358
|
sendKey?: string;
|
|
359
359
|
/** How the CLI adapter decides when to submit typed input */
|
|
360
360
|
submitStrategy?: 'wait_for_echo' | 'immediate';
|
|
361
|
+
/** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
|
|
362
|
+
requirePromptEchoBeforeSubmit?: boolean;
|
|
361
363
|
/** Keep this provider out of the upstream auto-updated bundle */
|
|
362
364
|
/** @deprecated Machine-level provider source policy now lives in config.providerSourceMode. Local overrides shadow upstream by root precedence and should not rely on provider-level disableUpstream. */
|
|
363
365
|
disableUpstream?: boolean;
|
package/package.json
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { loadConfig } from '../config/config.js';
|
|
36
36
|
import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
37
37
|
import type { IdeProviderInstance } from '../providers/ide-provider-instance.js';
|
|
38
|
+
import { createDefaultGitCommandServices } from '../git/git-commands.js';
|
|
38
39
|
|
|
39
40
|
// ─── Init Config ───
|
|
40
41
|
|
|
@@ -78,6 +79,9 @@ export interface DaemonInitConfig {
|
|
|
78
79
|
statusInstanceId?: string;
|
|
79
80
|
statusVersion?: string;
|
|
80
81
|
statusDaemonMode?: boolean;
|
|
82
|
+
|
|
83
|
+
/** Fired before send_chat is dispatched — used for turn snapshot hooks */
|
|
84
|
+
onBeforeSendChat?: (params: { workspace: string; sessionId: string }) => void;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// ─── Result ───
|
|
@@ -256,6 +260,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
|
|
|
256
260
|
providerLoader,
|
|
257
261
|
instanceManager,
|
|
258
262
|
sessionRegistry,
|
|
263
|
+
gitCommandServices: createDefaultGitCommandServices(),
|
|
259
264
|
onProviderSettingChanged: async (providerType) => {
|
|
260
265
|
await refreshProviderAvailability(providerType);
|
|
261
266
|
config.onStatusChange?.();
|
|
@@ -264,6 +269,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
|
|
|
264
269
|
await refreshProviderAvailability();
|
|
265
270
|
config.onStatusChange?.();
|
|
266
271
|
},
|
|
272
|
+
onBeforeSendChat: config.onBeforeSendChat,
|
|
267
273
|
});
|
|
268
274
|
|
|
269
275
|
// 8. AgentStreamManager
|
|
@@ -341,6 +341,12 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
341
341
|
private accumulatedRawBuffer: string = '';
|
|
342
342
|
/** Current visible terminal screen snapshot */
|
|
343
343
|
private terminalScreen = new TerminalScreen(24, 80);
|
|
344
|
+
private static readonly MAX_RESPONSE_BUFFER = 8000;
|
|
345
|
+
private static readonly MAX_RECENT_OUTPUT_BUFFER = 1000;
|
|
346
|
+
private responseBufferDroppedChars = 0;
|
|
347
|
+
private recentOutputDroppedChars = 0;
|
|
348
|
+
private accumulatedBufferDroppedChars = 0;
|
|
349
|
+
private accumulatedRawBufferDroppedChars = 0;
|
|
344
350
|
/** Max accumulated buffer size. Sized to comfortably hold a single long
|
|
345
351
|
* Hermes turn (tool calls + reasoning + final bubble) without the
|
|
346
352
|
* rolling window pushing the turn's ╭─ opening line out of view. */
|
|
@@ -371,6 +377,27 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
371
377
|
private static readonly FINISH_RETRY_DELAY_MS = 300;
|
|
372
378
|
private static readonly MAX_FINISH_RETRIES = 2;
|
|
373
379
|
|
|
380
|
+
private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
|
|
381
|
+
const build = (droppedChars: number, maxChars: number) => droppedChars > 0
|
|
382
|
+
? { truncated: true, droppedChars, maxChars }
|
|
383
|
+
: undefined;
|
|
384
|
+
const responseBuffer = build(this.responseBufferDroppedChars, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
|
|
385
|
+
const recentOutputBuffer = build(this.recentOutputDroppedChars, ProviderCliAdapter.MAX_RECENT_OUTPUT_BUFFER);
|
|
386
|
+
const accumulatedBuffer = build(this.accumulatedBufferDroppedChars, ProviderCliAdapter.MAX_ACCUMULATED_BUFFER);
|
|
387
|
+
const accumulatedRawBuffer = build(this.accumulatedRawBufferDroppedChars, ProviderCliAdapter.MAX_ACCUMULATED_BUFFER);
|
|
388
|
+
if (!responseBuffer && !recentOutputBuffer && !accumulatedBuffer && !accumulatedRawBuffer) return undefined;
|
|
389
|
+
return {
|
|
390
|
+
...(responseBuffer ? { responseBuffer } : {}),
|
|
391
|
+
...(recentOutputBuffer ? { recentOutputBuffer } : {}),
|
|
392
|
+
...(accumulatedBuffer ? { accumulatedBuffer } : {}),
|
|
393
|
+
...(accumulatedRawBuffer ? { accumulatedRawBuffer } : {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private recordBoundedAppendDrop(previousLength: number, appendedLength: number, nextLength: number): number {
|
|
398
|
+
return Math.max(0, (previousLength + appendedLength) - nextLength);
|
|
399
|
+
}
|
|
400
|
+
|
|
374
401
|
private buildCommittedMessagesActivitySignature(): string {
|
|
375
402
|
const last = this.committedMessages[this.committedMessages.length - 1];
|
|
376
403
|
return [
|
|
@@ -598,6 +625,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
598
625
|
private readonly sendDelayMs: number;
|
|
599
626
|
private readonly sendKey: string;
|
|
600
627
|
private readonly submitStrategy: 'wait_for_echo' | 'immediate';
|
|
628
|
+
private readonly requirePromptEchoBeforeSubmit: boolean;
|
|
601
629
|
private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
|
|
602
630
|
|
|
603
631
|
constructor(
|
|
@@ -620,6 +648,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
620
648
|
this.sendDelayMs = resolvedConfig.sendDelayMs;
|
|
621
649
|
this.sendKey = resolvedConfig.sendKey;
|
|
622
650
|
this.submitStrategy = resolvedConfig.submitStrategy;
|
|
651
|
+
this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
|
|
623
652
|
this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
|
|
624
653
|
|
|
625
654
|
// Scripts are required — loaded by ProviderLoader via compatibility array
|
|
@@ -845,7 +874,9 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
845
874
|
}
|
|
846
875
|
|
|
847
876
|
if (this.isWaitingForResponse && cleanData) {
|
|
848
|
-
|
|
877
|
+
const previousResponseLen = this.responseBuffer.length;
|
|
878
|
+
this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
|
|
879
|
+
this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
|
|
849
880
|
}
|
|
850
881
|
|
|
851
882
|
// Server log forwarding
|
|
@@ -858,17 +889,22 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
858
889
|
}
|
|
859
890
|
|
|
860
891
|
// Rolling buffers
|
|
892
|
+
const prevRecentLen = this.recentOutputBuffer.length;
|
|
861
893
|
const prevAccumulatedLen = this.accumulatedBuffer.length;
|
|
862
894
|
const prevAccumulatedRawLen = this.accumulatedRawBuffer.length;
|
|
863
|
-
this.recentOutputBuffer = appendBoundedText(this.recentOutputBuffer, cleanData,
|
|
895
|
+
this.recentOutputBuffer = appendBoundedText(this.recentOutputBuffer, cleanData, ProviderCliAdapter.MAX_RECENT_OUTPUT_BUFFER);
|
|
864
896
|
this.accumulatedBuffer = appendBoundedText(this.accumulatedBuffer, cleanData, ProviderCliAdapter.MAX_ACCUMULATED_BUFFER);
|
|
865
897
|
this.accumulatedRawBuffer = appendBoundedText(this.accumulatedRawBuffer, rawData, ProviderCliAdapter.MAX_ACCUMULATED_BUFFER);
|
|
898
|
+
const droppedRecent = this.recordBoundedAppendDrop(prevRecentLen, cleanData.length, this.recentOutputBuffer.length);
|
|
899
|
+
const droppedClean = this.recordBoundedAppendDrop(prevAccumulatedLen, cleanData.length, this.accumulatedBuffer.length);
|
|
900
|
+
const droppedRaw = this.recordBoundedAppendDrop(prevAccumulatedRawLen, rawData.length, this.accumulatedRawBuffer.length);
|
|
901
|
+
this.recentOutputDroppedChars += droppedRecent;
|
|
902
|
+
this.accumulatedBufferDroppedChars += droppedClean;
|
|
903
|
+
this.accumulatedRawBufferDroppedChars += droppedRaw;
|
|
866
904
|
// Keep turn-scope offsets aligned with the truncated buffer so scoped
|
|
867
905
|
// parses don't lose the beginning of a long turn (e.g. the Hermes
|
|
868
906
|
// ╭─ opening line) when the rolling window sheds bytes.
|
|
869
907
|
if (this.currentTurnScope) {
|
|
870
|
-
const droppedClean = (prevAccumulatedLen + cleanData.length) - this.accumulatedBuffer.length;
|
|
871
|
-
const droppedRaw = (prevAccumulatedRawLen + rawData.length) - this.accumulatedRawBuffer.length;
|
|
872
908
|
if (droppedClean > 0) {
|
|
873
909
|
this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
|
|
874
910
|
}
|
|
@@ -1803,6 +1839,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1803
1839
|
effectiveModal = parsedModal;
|
|
1804
1840
|
}
|
|
1805
1841
|
}
|
|
1842
|
+
const bufferState = this.getBufferState();
|
|
1806
1843
|
return {
|
|
1807
1844
|
status: effectiveStatus,
|
|
1808
1845
|
messages: [...this.committedMessages],
|
|
@@ -1810,6 +1847,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
1810
1847
|
activeModal: effectiveModal,
|
|
1811
1848
|
errorMessage: this.parseErrorMessage || undefined,
|
|
1812
1849
|
errorReason: this.parseErrorMessage ? 'parse_error' : undefined,
|
|
1850
|
+
...(bufferState ? { bufferState } : {}),
|
|
1813
1851
|
};
|
|
1814
1852
|
}
|
|
1815
1853
|
|
|
@@ -2054,10 +2092,12 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
2054
2092
|
messages: hydratedMessages,
|
|
2055
2093
|
activeModal: parsed.activeModal ?? this.activeModal,
|
|
2056
2094
|
providerSessionId: typeof parsed.providerSessionId === 'string' ? parsed.providerSessionId : undefined,
|
|
2095
|
+
...(this.getBufferState() ? { bufferState: this.getBufferState() } : {}),
|
|
2057
2096
|
...(this.providerOwnsTranscript() ? { transcriptAuthority: 'provider', coverage: this.shouldUseFullProviderTranscriptContext() ? 'full' : 'tail' } : {}),
|
|
2058
2097
|
};
|
|
2059
2098
|
} else {
|
|
2060
2099
|
const messages = [...this.committedMessages];
|
|
2100
|
+
const bufferState = this.getBufferState();
|
|
2061
2101
|
result = {
|
|
2062
2102
|
id: 'cli_session',
|
|
2063
2103
|
status: this.currentStatus,
|
|
@@ -2071,6 +2111,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
2071
2111
|
: message.timestamp,
|
|
2072
2112
|
})),
|
|
2073
2113
|
activeModal: this.activeModal,
|
|
2114
|
+
...(bufferState ? { bufferState } : {}),
|
|
2074
2115
|
};
|
|
2075
2116
|
}
|
|
2076
2117
|
|
|
@@ -2392,6 +2433,22 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
2392
2433
|
}
|
|
2393
2434
|
|
|
2394
2435
|
if (elapsed >= state.maxEchoWaitMs) {
|
|
2436
|
+
const diagnostic = {
|
|
2437
|
+
elapsed,
|
|
2438
|
+
maxEchoWaitMs: state.maxEchoWaitMs,
|
|
2439
|
+
submitDelayMs: state.submitDelayMs,
|
|
2440
|
+
promptSnippet: state.normalizedPromptSnippet,
|
|
2441
|
+
requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
|
|
2442
|
+
screenText: summarizeCliTraceText(screenText, 1000),
|
|
2443
|
+
};
|
|
2444
|
+
this.recordTrace('submit_echo_missing', diagnostic);
|
|
2445
|
+
if (this.requirePromptEchoBeforeSubmit) {
|
|
2446
|
+
const message = `${this.cliName} prompt echo was not observed on the PTY screen before submit`;
|
|
2447
|
+
LOG.warn('CLI', `[${this.cliType}] ${message} elapsed=${elapsed}ms maxEchoWaitMs=${state.maxEchoWaitMs} screen=${JSON.stringify(diagnostic.screenText).slice(0, 240)}`);
|
|
2448
|
+
completion.rejectOnce(new Error(message));
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
LOG.warn('CLI', `[${this.cliType}] prompt echo was not observed before submit; sending submit key anyway elapsed=${elapsed}ms maxEchoWaitMs=${state.maxEchoWaitMs}`);
|
|
2395
2452
|
this.submitSendKey(state, completion);
|
|
2396
2453
|
return;
|
|
2397
2454
|
}
|
|
@@ -2880,6 +2937,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
2880
2937
|
sendDelayMs: this.sendDelayMs,
|
|
2881
2938
|
sendKey: this.sendKey,
|
|
2882
2939
|
submitStrategy: this.submitStrategy,
|
|
2940
|
+
requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
|
|
2883
2941
|
submitPendingUntil: this.submitPendingUntil,
|
|
2884
2942
|
responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
|
|
2885
2943
|
resizeSuppressUntil: this.resizeSuppressUntil,
|
|
@@ -25,6 +25,7 @@ export interface ResolvedCliAdapterConfig {
|
|
|
25
25
|
sendDelayMs: number;
|
|
26
26
|
sendKey: string;
|
|
27
27
|
submitStrategy: 'wait_for_echo' | 'immediate';
|
|
28
|
+
requirePromptEchoBeforeSubmit: boolean;
|
|
28
29
|
providerResolutionMeta: ProviderResolutionMeta;
|
|
29
30
|
}
|
|
30
31
|
export declare function resolveCliAdapterConfig(provider: CliProviderModule): ResolvedCliAdapterConfig;
|
|
@@ -29,6 +29,7 @@ export interface ResolvedCliAdapterConfig {
|
|
|
29
29
|
sendDelayMs: number;
|
|
30
30
|
sendKey: string;
|
|
31
31
|
submitStrategy: 'wait_for_echo' | 'immediate';
|
|
32
|
+
requirePromptEchoBeforeSubmit: boolean;
|
|
32
33
|
providerResolutionMeta: ProviderResolutionMeta;
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -55,6 +56,7 @@ export function resolveCliAdapterConfig(provider: CliProviderModule): ResolvedCl
|
|
|
55
56
|
? provider.sendKey
|
|
56
57
|
: '\r',
|
|
57
58
|
submitStrategy: provider.submitStrategy === 'immediate' ? 'immediate' : 'wait_for_echo',
|
|
59
|
+
requirePromptEchoBeforeSubmit: provider.requirePromptEchoBeforeSubmit === true,
|
|
58
60
|
providerResolutionMeta: {
|
|
59
61
|
type: provider.type,
|
|
60
62
|
name: provider.name,
|
|
@@ -104,6 +104,8 @@ export interface CliProviderModule {
|
|
|
104
104
|
sendDelayMs?: number;
|
|
105
105
|
sendKey?: string;
|
|
106
106
|
submitStrategy?: 'wait_for_echo' | 'immediate';
|
|
107
|
+
/** Require the typed prompt to be visible on the PTY screen before sending Enter. */
|
|
108
|
+
requirePromptEchoBeforeSubmit?: boolean;
|
|
107
109
|
/** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
|
|
108
110
|
allowInputDuringGeneration?: boolean;
|
|
109
111
|
scripts?: CliScripts;
|
|
@@ -24,6 +24,12 @@ export interface CliSessionStatus {
|
|
|
24
24
|
activeModal: { message: string; buttons: string[] } | null;
|
|
25
25
|
errorMessage?: string;
|
|
26
26
|
errorReason?: string;
|
|
27
|
+
bufferState?: {
|
|
28
|
+
responseBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
|
29
|
+
recentOutputBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
|
30
|
+
accumulatedBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
|
31
|
+
accumulatedRawBuffer?: { truncated: boolean; droppedChars: number; maxChars: number };
|
|
32
|
+
};
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface ParsedSession {
|
|
@@ -122,6 +128,8 @@ export interface CliProviderModule {
|
|
|
122
128
|
sendDelayMs?: number;
|
|
123
129
|
sendKey?: string;
|
|
124
130
|
submitStrategy?: 'wait_for_echo' | 'immediate';
|
|
131
|
+
/** Require the typed prompt to be visible on the PTY screen before sending Enter. */
|
|
132
|
+
requirePromptEchoBeforeSubmit?: boolean;
|
|
125
133
|
/** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
|
|
126
134
|
allowInputDuringGeneration?: boolean;
|
|
127
135
|
/** When provider-owned, daemon treats provider parser output as canonical transcript authority. */
|
package/src/commands/handler.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface CommandContext {
|
|
|
50
50
|
onProviderSettingChanged?: (providerType: string, key: string, value: any) => Promise<void> | void;
|
|
51
51
|
onProviderSourceConfigChanged?: () => Promise<void> | void;
|
|
52
52
|
gitCommandServices?: GitCommandServices;
|
|
53
|
+
/** Fired synchronously before send_chat is dispatched; fire-and-forget for callers */
|
|
54
|
+
onBeforeSendChat?: (params: { workspace: string; sessionId: string }) => void;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
@@ -424,6 +426,20 @@ export class DaemonCommandHandler implements CommandHelpers {
|
|
|
424
426
|
}
|
|
425
427
|
}
|
|
426
428
|
|
|
429
|
+
if (cmd === 'send_chat' && this._ctx.onBeforeSendChat) {
|
|
430
|
+
const sessionId = this._currentRoute.session?.sessionId;
|
|
431
|
+
const workspace = sessionId
|
|
432
|
+
? (this._ctx.instanceManager?.getInstance(sessionId) as any)?.getState?.()?.workspace
|
|
433
|
+
: undefined;
|
|
434
|
+
if (workspace && sessionId) {
|
|
435
|
+
try {
|
|
436
|
+
this._ctx.onBeforeSendChat({ workspace, sessionId });
|
|
437
|
+
} catch {
|
|
438
|
+
// hook must not block send_chat
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
427
443
|
try {
|
|
428
444
|
result = await this.dispatch(cmd, args);
|
|
429
445
|
this.logCommandEnd(cmd, result, startedAt);
|
package/src/git/git-commands.ts
CHANGED
|
@@ -41,6 +41,18 @@ export interface GitLogResult extends GitRepoIdentity {
|
|
|
41
41
|
lastCheckedAt: number;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface GitCheckpointResult extends GitRepoIdentity {
|
|
45
|
+
commit: string;
|
|
46
|
+
message: string;
|
|
47
|
+
lastCheckedAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GitStashPushResult extends GitRepoIdentity {
|
|
51
|
+
stashRef: string;
|
|
52
|
+
message: string;
|
|
53
|
+
lastCheckedAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
export interface GitCommandServices {
|
|
45
57
|
getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
46
58
|
getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
|
|
@@ -63,6 +75,19 @@ export interface GitCommandServices {
|
|
|
63
75
|
since?: string;
|
|
64
76
|
until?: string;
|
|
65
77
|
}) => Promise<GitLogResult> | GitLogResult;
|
|
78
|
+
checkpoint?: (params: {
|
|
79
|
+
workspace: string;
|
|
80
|
+
message: string;
|
|
81
|
+
includeUntracked?: boolean;
|
|
82
|
+
}) => Promise<GitCheckpointResult> | GitCheckpointResult;
|
|
83
|
+
stashPush?: (params: {
|
|
84
|
+
workspace: string;
|
|
85
|
+
message: string;
|
|
86
|
+
includeUntracked?: boolean;
|
|
87
|
+
}) => Promise<GitStashPushResult> | GitStashPushResult;
|
|
88
|
+
stashPop?: (params: { workspace: string; stashRef?: string }) => Promise<void>;
|
|
89
|
+
checkoutFiles?: (params: { workspace: string; paths: string[] }) => Promise<{ checkedOut: string[] }>;
|
|
90
|
+
getRemoteUrl?: (params: { workspace: string; remote?: string }) => Promise<{ remoteUrl: string; remote: string }>;
|
|
66
91
|
}
|
|
67
92
|
|
|
68
93
|
type GitCommandFailure = {
|
|
@@ -77,7 +102,12 @@ type GitCommandSuccess =
|
|
|
77
102
|
| { success: true; diff: GitFileDiff }
|
|
78
103
|
| { success: true; snapshot: GitSnapshot }
|
|
79
104
|
| { success: true; compare: GitSnapshotCompareSummary }
|
|
80
|
-
| { success: true; log: GitLogResult }
|
|
105
|
+
| { success: true; log: GitLogResult }
|
|
106
|
+
| { success: true; checkpoint: GitCheckpointResult }
|
|
107
|
+
| { success: true; stash: GitStashPushResult }
|
|
108
|
+
| { success: true; stashPopped: true }
|
|
109
|
+
| { success: true; checkedOut: string[] }
|
|
110
|
+
| { success: true; remoteUrl: string; remote: string };
|
|
81
111
|
|
|
82
112
|
export type GitCommandResult = GitCommandSuccess | GitCommandFailure;
|
|
83
113
|
|
|
@@ -92,13 +122,7 @@ const GIT_COMMAND_NAMES = new Set<GitCommandName>([
|
|
|
92
122
|
'git_stash_push',
|
|
93
123
|
'git_stash_pop',
|
|
94
124
|
'git_checkout_files',
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const MUTATING_COMMAND_NAMES = new Set<GitCommandName>([
|
|
98
|
-
'git_checkpoint',
|
|
99
|
-
'git_stash_push',
|
|
100
|
-
'git_stash_pop',
|
|
101
|
-
'git_checkout_files',
|
|
125
|
+
'git_remote_url',
|
|
102
126
|
]);
|
|
103
127
|
|
|
104
128
|
const SNAPSHOT_REASONS = new Set<GitSnapshotReason>([
|
|
@@ -146,6 +170,11 @@ export function createDefaultGitCommandServices(): GitCommandServices {
|
|
|
146
170
|
}),
|
|
147
171
|
compareSnapshots: ({ beforeSnapshotId, afterSnapshotId }) => defaultSnapshotStore.compare(beforeSnapshotId, afterSnapshotId),
|
|
148
172
|
getLog: ({ workspace, limit, path: filePath, since, until }) => getGitLog(workspace, { limit, path: filePath, since, until }),
|
|
173
|
+
checkpoint: async ({ workspace, message, includeUntracked = false }) => gitCheckpoint(workspace, message, includeUntracked),
|
|
174
|
+
stashPush: async ({ workspace, message, includeUntracked = false }) => gitStashPush(workspace, message, includeUntracked),
|
|
175
|
+
stashPop: async ({ workspace, stashRef }) => gitStashPop(workspace, stashRef),
|
|
176
|
+
checkoutFiles: async ({ workspace, paths }) => gitCheckoutFiles(workspace, paths),
|
|
177
|
+
getRemoteUrl: async ({ workspace, remote = 'origin' }) => gitGetRemoteUrl(workspace, remote),
|
|
149
178
|
};
|
|
150
179
|
}
|
|
151
180
|
|
|
@@ -240,10 +269,6 @@ export async function handleGitCommand(
|
|
|
240
269
|
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
241
270
|
}
|
|
242
271
|
|
|
243
|
-
if (MUTATING_COMMAND_NAMES.has(command)) {
|
|
244
|
-
return failure('invalid_args', `${command} is not implemented in daemon-core read-only Git routing`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
272
|
const workspaceResult = validateWorkspace(args);
|
|
248
273
|
if ('success' in workspaceResult) return workspaceResult;
|
|
249
274
|
const { workspace } = workspaceResult;
|
|
@@ -310,11 +335,183 @@ export async function handleGitCommand(
|
|
|
310
335
|
return 'success' in log ? log : { success: true, log };
|
|
311
336
|
}
|
|
312
337
|
|
|
338
|
+
case 'git_checkpoint': {
|
|
339
|
+
if (!services.checkpoint) return serviceNotImplemented(command);
|
|
340
|
+
const msg = validateMutatingMessage(args?.message);
|
|
341
|
+
if (typeof msg !== 'string') return msg;
|
|
342
|
+
const includeUntracked = Boolean(args?.includeUntracked);
|
|
343
|
+
const checkpoint = await runService(() => services.checkpoint!({ workspace, message: msg, includeUntracked }));
|
|
344
|
+
return 'success' in checkpoint ? checkpoint : { success: true, checkpoint };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'git_stash_push': {
|
|
348
|
+
if (!services.stashPush) return serviceNotImplemented(command);
|
|
349
|
+
const msg = validateMutatingMessage(args?.message);
|
|
350
|
+
if (typeof msg !== 'string') return msg;
|
|
351
|
+
const includeUntracked = Boolean(args?.includeUntracked);
|
|
352
|
+
const stash = await runService(() => services.stashPush!({ workspace, message: msg, includeUntracked }));
|
|
353
|
+
return 'success' in stash ? stash : { success: true, stash };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'git_stash_pop': {
|
|
357
|
+
if (!services.stashPop) return serviceNotImplemented(command);
|
|
358
|
+
const stashRef = optionalString(args?.stashRef);
|
|
359
|
+
if (stashRef !== undefined && !/^stash@\{\d+\}$/.test(stashRef)) {
|
|
360
|
+
return failure('invalid_args', 'stashRef must match stash@{N} format');
|
|
361
|
+
}
|
|
362
|
+
const popResult = await runService(() => services.stashPop!({ workspace, stashRef }));
|
|
363
|
+
if (popResult !== undefined && 'success' in (popResult as object)) return popResult as GitCommandFailure;
|
|
364
|
+
return { success: true, stashPopped: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case 'git_checkout_files': {
|
|
368
|
+
if (!services.checkoutFiles) return serviceNotImplemented(command);
|
|
369
|
+
const paths = args?.paths;
|
|
370
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
371
|
+
return failure('invalid_args', 'paths must be a non-empty array');
|
|
372
|
+
}
|
|
373
|
+
if (paths.length > 50) {
|
|
374
|
+
return failure('invalid_args', 'paths array exceeds maximum of 50 entries');
|
|
375
|
+
}
|
|
376
|
+
const checkoutResult = await runService(() => services.checkoutFiles!({ workspace, paths }));
|
|
377
|
+
return 'success' in checkoutResult ? checkoutResult : { success: true, checkedOut: (checkoutResult as { checkedOut: string[] }).checkedOut };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case 'git_remote_url': {
|
|
381
|
+
if (!services.getRemoteUrl) return serviceNotImplemented(command);
|
|
382
|
+
const remote = typeof args?.remote === 'string' && args.remote.trim() ? args.remote.trim() : 'origin';
|
|
383
|
+
const remoteResult = await runService(() => services.getRemoteUrl!({ workspace, remote }));
|
|
384
|
+
if ('success' in remoteResult) return remoteResult;
|
|
385
|
+
return { success: true, remoteUrl: remoteResult.remoteUrl, remote: remoteResult.remote };
|
|
386
|
+
}
|
|
387
|
+
|
|
313
388
|
default:
|
|
314
389
|
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
315
390
|
}
|
|
316
391
|
}
|
|
317
392
|
|
|
393
|
+
function validateMutatingMessage(value: unknown): string | GitCommandFailure {
|
|
394
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
395
|
+
return failure('invalid_args', 'message must be a non-empty string');
|
|
396
|
+
}
|
|
397
|
+
const msg = value.trim();
|
|
398
|
+
if (msg.length > 200) {
|
|
399
|
+
return failure('invalid_args', 'message must be 200 characters or fewer');
|
|
400
|
+
}
|
|
401
|
+
return msg;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function gitCheckpoint(
|
|
405
|
+
workspace: string,
|
|
406
|
+
message: string,
|
|
407
|
+
includeUntracked: boolean,
|
|
408
|
+
): Promise<GitCheckpointResult> {
|
|
409
|
+
const repo = await resolveGitRepository(workspace);
|
|
410
|
+
const repoRoot = repo.repoRoot!;
|
|
411
|
+
|
|
412
|
+
const statusResult = await getGitRepoStatus(workspace);
|
|
413
|
+
if (statusResult.hasConflicts) {
|
|
414
|
+
throw new GitCommandError('conflict', 'Repository has conflicts — resolve before checkpointing');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const addArgs = includeUntracked ? ['-A'] : ['-u'];
|
|
418
|
+
await runGit(repo, ['add', ...addArgs], { cwd: repoRoot });
|
|
419
|
+
|
|
420
|
+
const fullMsg = `adhdev: checkpoint ${message}`;
|
|
421
|
+
let commitSha: string;
|
|
422
|
+
try {
|
|
423
|
+
await runGit(repo, ['commit', '-m', fullMsg], { cwd: repoRoot });
|
|
424
|
+
const revResult = await runGit(repo, ['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
425
|
+
commitSha = revResult.stdout.trim();
|
|
426
|
+
} catch (err: any) {
|
|
427
|
+
const output = (err?.stdout || '') + (err?.stderr || '');
|
|
428
|
+
if (/nothing to commit/i.test(output)) {
|
|
429
|
+
throw new GitCommandError('git_command_failed', 'Nothing to commit');
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
workspace: repo.workspace,
|
|
436
|
+
repoRoot,
|
|
437
|
+
isGitRepo: true,
|
|
438
|
+
commit: commitSha,
|
|
439
|
+
message: fullMsg,
|
|
440
|
+
lastCheckedAt: Date.now(),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function gitStashPush(
|
|
445
|
+
workspace: string,
|
|
446
|
+
message: string,
|
|
447
|
+
includeUntracked: boolean,
|
|
448
|
+
): Promise<GitStashPushResult> {
|
|
449
|
+
const repo = await resolveGitRepository(workspace);
|
|
450
|
+
const repoRoot = repo.repoRoot!;
|
|
451
|
+
|
|
452
|
+
const stashArgs = ['stash', 'push', '-m', message];
|
|
453
|
+
if (includeUntracked) stashArgs.push('--include-untracked');
|
|
454
|
+
|
|
455
|
+
const result = await runGit(repo, stashArgs, { cwd: repoRoot });
|
|
456
|
+
if (/No local changes to save/i.test(result.stdout + result.stderr)) {
|
|
457
|
+
throw new GitCommandError('git_command_failed', 'Nothing to stash');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
workspace: repo.workspace,
|
|
462
|
+
repoRoot,
|
|
463
|
+
isGitRepo: true,
|
|
464
|
+
stashRef: 'stash@{0}',
|
|
465
|
+
message,
|
|
466
|
+
lastCheckedAt: Date.now(),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function gitStashPop(workspace: string, stashRef?: string): Promise<void> {
|
|
471
|
+
const repo = await resolveGitRepository(workspace);
|
|
472
|
+
const repoRoot = repo.repoRoot!;
|
|
473
|
+
|
|
474
|
+
const popArgs = stashRef ? ['stash', 'pop', stashRef] : ['stash', 'pop'];
|
|
475
|
+
await runGit(repo, popArgs, { cwd: repoRoot });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function gitCheckoutFiles(workspace: string, paths: string[]): Promise<{ checkedOut: string[] }> {
|
|
479
|
+
const repo = await resolveGitRepository(workspace);
|
|
480
|
+
const repoRoot = repo.repoRoot!;
|
|
481
|
+
|
|
482
|
+
const normalizedPaths: string[] = [];
|
|
483
|
+
for (const p of paths) {
|
|
484
|
+
if (typeof p !== 'string' || !p.trim() || p.includes('\0')) {
|
|
485
|
+
throw new GitCommandError('invalid_args', `Invalid path: ${String(p)}`);
|
|
486
|
+
}
|
|
487
|
+
if (path.isAbsolute(p)) {
|
|
488
|
+
throw new GitCommandError('invalid_args', `Path must be repository-relative, not absolute: ${p}`);
|
|
489
|
+
}
|
|
490
|
+
const normalized = path.normalize(p.trim()).split(path.sep).join('/');
|
|
491
|
+
if (normalized.startsWith('../') || normalized === '..') {
|
|
492
|
+
throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
|
|
493
|
+
}
|
|
494
|
+
const absolutePath = path.resolve(repoRoot, normalized);
|
|
495
|
+
if (!isPathInside(repoRoot, absolutePath)) {
|
|
496
|
+
throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
|
|
497
|
+
}
|
|
498
|
+
normalizedPaths.push(normalized);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await runGit(repo, ['checkout', '--', ...normalizedPaths], { cwd: repoRoot });
|
|
502
|
+
return { checkedOut: normalizedPaths };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function gitGetRemoteUrl(workspace: string, remote: string): Promise<{ remoteUrl: string; remote: string }> {
|
|
506
|
+
const repo = await resolveGitRepository(workspace);
|
|
507
|
+
const result = await runGit(repo, ['remote', 'get-url', remote], { cwd: repo.repoRoot! });
|
|
508
|
+
const remoteUrl = result.stdout.trim();
|
|
509
|
+
if (!remoteUrl) {
|
|
510
|
+
throw new GitCommandError('git_command_failed', `Remote '${remote}' has no URL`);
|
|
511
|
+
}
|
|
512
|
+
return { remoteUrl, remote };
|
|
513
|
+
}
|
|
514
|
+
|
|
318
515
|
function formatOptionalGitLogRangeArg(flag: '--since' | '--until', value: string | undefined): string[] {
|
|
319
516
|
return value ? [`${flag}=${value}`] : [];
|
|
320
517
|
}
|
package/src/git/git-types.ts
CHANGED
package/src/git/index.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks agent session status transitions and fires snapshot callbacks on turn completion.
|
|
3
|
+
* "Busy" = streaming | waiting_approval
|
|
4
|
+
* "Completed" = idle | error (transition from busy)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type TurnCompletedCallback = (params: { sessionId: string; workspace: string }) => void
|
|
8
|
+
|
|
9
|
+
const BUSY_STATUSES = new Set(['streaming', 'waiting_approval'])
|
|
10
|
+
const TERMINAL_STATUSES = new Set(['idle', 'error'])
|
|
11
|
+
|
|
12
|
+
export class TurnSnapshotTracker {
|
|
13
|
+
private lastStatus = new Map<string, string>()
|
|
14
|
+
private onTurnCompleted: TurnCompletedCallback
|
|
15
|
+
|
|
16
|
+
constructor(onTurnCompleted: TurnCompletedCallback) {
|
|
17
|
+
this.onTurnCompleted = onTurnCompleted
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
record(sessionId: string, status: string, workspace: string | null | undefined): void {
|
|
21
|
+
const prev = this.lastStatus.get(sessionId)
|
|
22
|
+
this.lastStatus.set(sessionId, status)
|
|
23
|
+
if (workspace && prev && BUSY_STATUSES.has(prev) && TERMINAL_STATUSES.has(status)) {
|
|
24
|
+
this.onTurnCompleted({ sessionId, workspace })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
forget(sessionId: string): void {
|
|
29
|
+
this.lastStatus.delete(sessionId)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -339,6 +339,14 @@ export interface ProviderModule {
|
|
|
339
339
|
sessionProbe?: ProviderSessionProbe;
|
|
340
340
|
/** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
|
|
341
341
|
allowInputDuringGeneration?: boolean;
|
|
342
|
+
/** Delay before submitting typed CLI input (provider-specific TUI tuning) */
|
|
343
|
+
sendDelayMs?: number;
|
|
344
|
+
/** Submit key used after typing into CLI PTY (default: carriage return) */
|
|
345
|
+
sendKey?: string;
|
|
346
|
+
/** How the CLI adapter decides when to submit typed input */
|
|
347
|
+
submitStrategy?: 'wait_for_echo' | 'immediate';
|
|
348
|
+
/** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
|
|
349
|
+
requirePromptEchoBeforeSubmit?: boolean;
|
|
342
350
|
/** Approval button priority hints used when auto-approve must pick a positive action */
|
|
343
351
|
approvalPositiveHints?: string[];
|
|
344
352
|
scripts?: ProviderScripts;
|
|
@@ -450,6 +450,8 @@ export interface ProviderModule {
|
|
|
450
450
|
sendKey?: string;
|
|
451
451
|
/** How the CLI adapter decides when to submit typed input */
|
|
452
452
|
submitStrategy?: 'wait_for_echo' | 'immediate';
|
|
453
|
+
/** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
|
|
454
|
+
requirePromptEchoBeforeSubmit?: boolean;
|
|
453
455
|
/** Keep this provider out of the upstream auto-updated bundle */
|
|
454
456
|
/** @deprecated Machine-level provider source policy now lives in config.providerSourceMode. Local overrides shadow upstream by root precedence and should not rely on provider-level disableUpstream. */
|
|
455
457
|
disableUpstream?: boolean;
|