@adhdev/daemon-core 0.9.52 → 0.9.54
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/commands/handler.d.ts +2 -0
- package/dist/git/git-commands.d.ts +86 -0
- package/dist/git/git-diff.d.ts +17 -0
- package/dist/git/git-executor.d.ts +34 -0
- package/dist/git/git-monitor.d.ts +57 -0
- package/dist/git/git-snapshot-store.d.ts +50 -0
- package/dist/git/git-status.d.ts +19 -0
- package/dist/git/git-summary.d.ts +10 -0
- package/dist/git/git-types.d.ts +113 -0
- package/dist/git/index.d.ts +14 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1611 -392
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1584 -389
- package/dist/index.mjs.map +1 -1
- package/dist/shared-types.d.ts +7 -1
- package/dist/status/builders.d.ts +2 -0
- package/dist/status/snapshot.d.ts +2 -0
- package/node_modules/@adhdev/session-host-core/package.json +1 -1
- package/package.json +1 -1
- package/src/commands/chat-commands.ts +31 -4
- package/src/commands/handler.ts +9 -1
- package/src/config/chat-history.ts +11 -3
- package/src/git/git-commands.ts +385 -0
- package/src/git/git-diff.ts +303 -0
- package/src/git/git-executor.ts +268 -0
- package/src/git/git-monitor.ts +194 -0
- package/src/git/git-snapshot-store.ts +238 -0
- package/src/git/git-status.ts +193 -0
- package/src/git/git-summary.ts +43 -0
- package/src/git/git-types.ts +153 -0
- package/src/git/index.ts +72 -0
- package/src/index.ts +4 -0
- package/src/shared-types.ts +33 -1
- package/src/status/builders.ts +26 -4
- package/src/status/snapshot.ts +10 -2
package/dist/shared-types.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type { ProviderErrorReason } from './providers/provider-instance.js';
|
|
|
13
13
|
import type { ActiveChatData as _ActiveChatData, ProviderErrorReason as _ProviderErrorReason } from './providers/provider-instance.js';
|
|
14
14
|
import type { WorkspaceEntry } from './config/workspaces.js';
|
|
15
15
|
import type { ProviderResumeCapability } from './providers/contracts.js';
|
|
16
|
+
import type { GitCompactSummary, GitWorkspaceUpdate, WorkspaceGitSubscriptionParams } from './git/git-types.js';
|
|
17
|
+
export type { GitCommandName, GitCompactSummary, GitDiffSummary, GitFailureReason, GitFileChange, GitFileChangeStatus, GitRepoIdentity, GitRepoStatus, GitSnapshot, GitSnapshotCompareSummary, GitSnapshotReason, GitWorkspaceUpdate, WorkspaceGitSubscriptionParams, } from './git/git-types.js';
|
|
16
18
|
export interface SessionActiveChatData extends Omit<_ActiveChatData, 'messages'> {
|
|
17
19
|
messages?: _ActiveChatData['messages'];
|
|
18
20
|
}
|
|
@@ -132,7 +134,7 @@ export interface SessionHostDiagnosticsSnapshot {
|
|
|
132
134
|
recentRequests: SessionHostRequestTrace[];
|
|
133
135
|
recentTransitions: SessionHostRuntimeTransition[];
|
|
134
136
|
}
|
|
135
|
-
export type TransportTopic = 'session.chat_tail' | 'session.runtime_output' | 'machine.runtime' | 'session_host.diagnostics' | 'session.modal' | 'daemon.metadata';
|
|
137
|
+
export type TransportTopic = 'session.chat_tail' | 'session.runtime_output' | 'machine.runtime' | 'session_host.diagnostics' | 'session.modal' | 'daemon.metadata' | 'workspace.git';
|
|
136
138
|
export interface SessionChatTailSubscriptionParams extends ReadChatCursor {
|
|
137
139
|
targetSessionId: string;
|
|
138
140
|
historySessionId?: string;
|
|
@@ -213,6 +215,7 @@ export interface TopicUpdateEnvelopeMap {
|
|
|
213
215
|
'session_host.diagnostics': SessionHostDiagnosticsUpdate;
|
|
214
216
|
'session.modal': SessionModalUpdate;
|
|
215
217
|
'daemon.metadata': DaemonMetadataUpdate;
|
|
218
|
+
'workspace.git': GitWorkspaceUpdate;
|
|
216
219
|
}
|
|
217
220
|
export type TopicUpdateEnvelope = TopicUpdateEnvelopeMap[TransportTopic];
|
|
218
221
|
export interface SubscribeRequestMap {
|
|
@@ -222,6 +225,7 @@ export interface SubscribeRequestMap {
|
|
|
222
225
|
'session_host.diagnostics': SessionHostDiagnosticsSubscriptionParams;
|
|
223
226
|
'session.modal': SessionModalSubscriptionParams;
|
|
224
227
|
'daemon.metadata': DaemonMetadataSubscriptionParams;
|
|
228
|
+
'workspace.git': WorkspaceGitSubscriptionParams;
|
|
225
229
|
}
|
|
226
230
|
export type SubscribeRequest = {
|
|
227
231
|
[K in TransportTopic]: {
|
|
@@ -255,6 +259,7 @@ export interface SessionEntry {
|
|
|
255
259
|
status: SessionStatus;
|
|
256
260
|
title: string;
|
|
257
261
|
workspace?: string | null;
|
|
262
|
+
git?: GitCompactSummary;
|
|
258
263
|
runtimeKey?: string;
|
|
259
264
|
runtimeDisplayName?: string;
|
|
260
265
|
runtimeWorkspaceLabel?: string;
|
|
@@ -305,6 +310,7 @@ export interface CompactSessionEntry {
|
|
|
305
310
|
status: SessionStatus;
|
|
306
311
|
title: string;
|
|
307
312
|
workspace: string | null;
|
|
313
|
+
git?: GitCompactSummary;
|
|
308
314
|
cdpConnected?: boolean;
|
|
309
315
|
runtimeKey?: string;
|
|
310
316
|
runtimeDisplayName?: string;
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* Consolidates ProviderState→ManagedEntry mapping logic.
|
|
9
9
|
*/
|
|
10
10
|
import type { DaemonCdpManager } from '../cdp/manager.js';
|
|
11
|
+
import type { GitCompactSummary } from '../git/git-types.js';
|
|
11
12
|
import type { SessionEntry } from '../shared-types.js';
|
|
12
13
|
import type { ProviderState } from '../providers/provider-instance.js';
|
|
13
14
|
export type SessionEntryProfile = 'full' | 'live' | 'metadata';
|
|
14
15
|
export interface SessionEntryBuildOptions {
|
|
15
16
|
profile?: SessionEntryProfile;
|
|
17
|
+
getGitSummaryForWorkspace?: (workspace: string) => GitCompactSummary | null | undefined;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* Find a CDP manager by key. Supports single-window (`cursor`) and full multi-window keys (`cursor_<targetId>`).
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - daemon-standalone HTTP/WS status responses
|
|
7
7
|
*/
|
|
8
8
|
import type { DaemonCdpManager } from '../cdp/manager.js';
|
|
9
|
+
import type { GitCompactSummary } from '../git/git-types.js';
|
|
9
10
|
import { type SessionEntryProfile } from './builders.js';
|
|
10
11
|
import type { ProviderState } from '../providers/provider-instance.js';
|
|
11
12
|
import type { AvailableProviderInfo, MachineInfo, RecentSessionBucket, StatusReportPayload } from '../shared-types.js';
|
|
@@ -45,6 +46,7 @@ export interface StatusSnapshotOptions {
|
|
|
45
46
|
p2p?: StatusReportPayload['p2p'];
|
|
46
47
|
machineNickname?: string | null;
|
|
47
48
|
profile?: SessionEntryProfile;
|
|
49
|
+
getGitSummaryForWorkspace?: (workspace: string) => GitCompactSummary | null | undefined;
|
|
48
50
|
}
|
|
49
51
|
export type StatusSnapshot = StatusReportPayload;
|
|
50
52
|
export interface RecentReadDebugSnapshot {
|
package/package.json
CHANGED
|
@@ -869,6 +869,33 @@ function getStateLastSignature(state: any): string {
|
|
|
869
869
|
return `${last.role || ''}:${String(last.content || '').replace(/\s+/g, ' ').trim()}`;
|
|
870
870
|
}
|
|
871
871
|
|
|
872
|
+
function toNonNegativeNumber(value: any): number {
|
|
873
|
+
const numeric = Number(value ?? 0);
|
|
874
|
+
return Number.isFinite(numeric) ? Math.max(0, numeric) : 0;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function getCliVisibleTranscriptCount(adapter: any): number {
|
|
878
|
+
const adapterStatus = adapter?.getStatus?.() || {};
|
|
879
|
+
const adapterMessages = Array.isArray(adapterStatus.messages) ? adapterStatus.messages : [];
|
|
880
|
+
let parsedRecord: Record<string, any> | null = null;
|
|
881
|
+
if (typeof adapter?.getScriptParsedStatus === 'function') {
|
|
882
|
+
try {
|
|
883
|
+
const parsed = parseMaybeJson(adapter.getScriptParsedStatus());
|
|
884
|
+
parsedRecord = parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
|
|
885
|
+
} catch {
|
|
886
|
+
parsedRecord = null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const parsedMessages = Array.isArray(parsedRecord?.messages) ? parsedRecord.messages : [];
|
|
890
|
+
if (!parsedRecord) return adapterMessages.length;
|
|
891
|
+
const parsedIsProviderAuthoritative = parsedRecord.transcriptAuthority === 'provider'
|
|
892
|
+
|| parsedRecord.coverage === 'full';
|
|
893
|
+
const shouldPreferAdapterMessages = !parsedIsProviderAuthoritative
|
|
894
|
+
&& adapterMessages.length > 0
|
|
895
|
+
&& adapterMessages.length > parsedMessages.length;
|
|
896
|
+
return shouldPreferAdapterMessages ? adapterMessages.length : parsedMessages.length;
|
|
897
|
+
}
|
|
898
|
+
|
|
872
899
|
async function getStableExtensionBaseline(h: CommandHelpers): Promise<any | null> {
|
|
873
900
|
const first = await readExtensionChatState(h);
|
|
874
901
|
if (getStateMessageCount(first) > 0 || getStateLastSignature(first)) return first;
|
|
@@ -899,11 +926,11 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
|
|
|
899
926
|
const provider = h.getProvider(agentType);
|
|
900
927
|
const agentStr = provider?.type || agentType || getCurrentProviderType(h);
|
|
901
928
|
const transport = getTargetTransport(h, provider);
|
|
902
|
-
|
|
903
|
-
|
|
929
|
+
const hasExplicitExcludeRecentCount = args?.excludeRecentCount !== undefined && args?.excludeRecentCount !== null;
|
|
930
|
+
let excludeRecentCount = toNonNegativeNumber(args?.excludeRecentCount);
|
|
931
|
+
if (!hasExplicitExcludeRecentCount && isCliLikeTransport(transport)) {
|
|
904
932
|
const adapter = getTargetedCliAdapter(h, args, provider?.type);
|
|
905
|
-
const
|
|
906
|
-
const visibleCount = Array.isArray(status?.messages) ? status.messages.length : 0;
|
|
933
|
+
const visibleCount = getCliVisibleTranscriptCount(adapter);
|
|
907
934
|
if (visibleCount > excludeRecentCount) excludeRecentCount = visibleCount;
|
|
908
935
|
}
|
|
909
936
|
const workspace = typeof args?.workspace === 'string'
|
package/src/commands/handler.ts
CHANGED
|
@@ -32,6 +32,7 @@ import * as Cdp from './cdp-commands.js';
|
|
|
32
32
|
import * as Stream from './stream-commands.js';
|
|
33
33
|
import * as WorkspaceCmd from './workspace-commands.js';
|
|
34
34
|
import { getWorkspaceState } from '../config/workspaces.js';
|
|
35
|
+
import { handleGitCommand, isGitCommandName, type GitCommandServices } from '../git/git-commands.js';
|
|
35
36
|
|
|
36
37
|
export interface CommandResult {
|
|
37
38
|
success: boolean;
|
|
@@ -48,6 +49,7 @@ export interface CommandContext {
|
|
|
48
49
|
sessionRegistry?: SessionRegistry;
|
|
49
50
|
onProviderSettingChanged?: (providerType: string, key: string, value: any) => Promise<void> | void;
|
|
50
51
|
onProviderSourceConfigChanged?: () => Promise<void> | void;
|
|
52
|
+
gitCommandServices?: GitCommandServices;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
@@ -377,6 +379,13 @@ export class DaemonCommandHandler implements CommandHelpers {
|
|
|
377
379
|
this._currentRoute = this.resolveRoute(args);
|
|
378
380
|
const startedAt = Date.now();
|
|
379
381
|
this.logCommandStart(cmd, args);
|
|
382
|
+
let result: CommandResult;
|
|
383
|
+
|
|
384
|
+
if (isGitCommandName(cmd)) {
|
|
385
|
+
result = await handleGitCommand(cmd, args, this._ctx.gitCommandServices);
|
|
386
|
+
this.logCommandEnd(cmd, result, startedAt);
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
380
389
|
|
|
381
390
|
const sessionScopedCommands = new Set([
|
|
382
391
|
'read_chat',
|
|
@@ -406,7 +415,6 @@ export class DaemonCommandHandler implements CommandHelpers {
|
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
// Commands without ideType CDP silently fail (prevent P2P retry spam)
|
|
409
|
-
let result: CommandResult;
|
|
410
418
|
if (!this._currentRoute.session && !this._currentRoute.managerKey && !this._currentRoute.providerType) {
|
|
411
419
|
const cdpCommands = ['send_chat', 'read_chat', 'list_chats', 'new_chat', 'switch_chat', 'set_mode', 'change_model', 'set_thought_level', 'resolve_action'];
|
|
412
420
|
if (cdpCommands.includes(cmd)) {
|
|
@@ -1141,6 +1141,11 @@ export class ChatHistoryWriter {
|
|
|
1141
1141
|
* the newest N messages are skipped so older-history pagination can avoid
|
|
1142
1142
|
* duplicating the live transcript tail already shown in the UI.
|
|
1143
1143
|
*/
|
|
1144
|
+
function normalizePaginationNumber(value: number, fallback: number, min: number): number {
|
|
1145
|
+
const numeric = Number(value);
|
|
1146
|
+
return Number.isFinite(numeric) ? Math.max(min, numeric) : fallback;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1144
1149
|
function pageHistoryRecords(
|
|
1145
1150
|
agentType: string,
|
|
1146
1151
|
records: HistoryMessage[],
|
|
@@ -1163,9 +1168,12 @@ function pageHistoryRecords(
|
|
|
1163
1168
|
if (message.role !== 'system') lastTurn = message;
|
|
1164
1169
|
}
|
|
1165
1170
|
const collapsed = collapseReplayAssistantTurns(chronological, historyBehavior);
|
|
1166
|
-
const boundedLimit =
|
|
1167
|
-
const boundedOffset =
|
|
1168
|
-
const boundedExclude = Math.
|
|
1171
|
+
const boundedLimit = normalizePaginationNumber(limit, 30, 1);
|
|
1172
|
+
const boundedOffset = normalizePaginationNumber(offset, 0, 0);
|
|
1173
|
+
const boundedExclude = Math.min(
|
|
1174
|
+
normalizePaginationNumber(excludeRecentCount, 0, 0),
|
|
1175
|
+
collapsed.length,
|
|
1176
|
+
);
|
|
1169
1177
|
const endExclusive = Math.max(0, collapsed.length - boundedExclude - boundedOffset);
|
|
1170
1178
|
const startInclusive = Math.max(0, endExclusive - boundedLimit);
|
|
1171
1179
|
const sliced = collapsed.slice(startInclusive, endExclusive);
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { getGitDiffSummary, getGitFileDiff } from './git-diff.js';
|
|
4
|
+
import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
|
|
5
|
+
import { createGitSnapshotStore } from './git-snapshot-store.js';
|
|
6
|
+
import { getGitRepoStatus } from './git-status.js';
|
|
7
|
+
import type {
|
|
8
|
+
GitCommandName,
|
|
9
|
+
GitDiffSummary,
|
|
10
|
+
GitFailureReason,
|
|
11
|
+
GitRepoIdentity,
|
|
12
|
+
GitRepoStatus,
|
|
13
|
+
GitSnapshot,
|
|
14
|
+
GitSnapshotCompareSummary,
|
|
15
|
+
GitSnapshotReason,
|
|
16
|
+
} from './git-types.js';
|
|
17
|
+
|
|
18
|
+
export interface GitFileDiff extends GitRepoIdentity {
|
|
19
|
+
path: string;
|
|
20
|
+
oldPath?: string;
|
|
21
|
+
staged?: boolean;
|
|
22
|
+
diff: string;
|
|
23
|
+
binary?: boolean;
|
|
24
|
+
truncated?: boolean;
|
|
25
|
+
lastCheckedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitLogEntry {
|
|
29
|
+
commit: string;
|
|
30
|
+
message: string;
|
|
31
|
+
authorName?: string;
|
|
32
|
+
authorEmail?: string;
|
|
33
|
+
authoredAt?: number;
|
|
34
|
+
committedAt?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GitLogResult extends GitRepoIdentity {
|
|
38
|
+
entries: GitLogEntry[];
|
|
39
|
+
limit: number;
|
|
40
|
+
truncated: boolean;
|
|
41
|
+
lastCheckedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GitCommandServices {
|
|
45
|
+
getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
46
|
+
getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
|
|
47
|
+
getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
|
|
48
|
+
createSnapshot?: (params: {
|
|
49
|
+
workspace: string;
|
|
50
|
+
reason: GitSnapshotReason;
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
turnId?: string;
|
|
53
|
+
}) => Promise<GitSnapshot> | GitSnapshot;
|
|
54
|
+
compareSnapshots?: (params: {
|
|
55
|
+
workspace: string;
|
|
56
|
+
beforeSnapshotId: string;
|
|
57
|
+
afterSnapshotId: string;
|
|
58
|
+
}) => Promise<GitSnapshotCompareSummary> | GitSnapshotCompareSummary;
|
|
59
|
+
getLog?: (params: {
|
|
60
|
+
workspace: string;
|
|
61
|
+
limit: number;
|
|
62
|
+
path?: string;
|
|
63
|
+
since?: string;
|
|
64
|
+
until?: string;
|
|
65
|
+
}) => Promise<GitLogResult> | GitLogResult;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type GitCommandFailure = {
|
|
69
|
+
success: false;
|
|
70
|
+
reason: GitFailureReason;
|
|
71
|
+
error: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type GitCommandSuccess =
|
|
75
|
+
| { success: true; status: GitRepoStatus }
|
|
76
|
+
| { success: true; diffSummary: GitDiffSummary }
|
|
77
|
+
| { success: true; diff: GitFileDiff }
|
|
78
|
+
| { success: true; snapshot: GitSnapshot }
|
|
79
|
+
| { success: true; compare: GitSnapshotCompareSummary }
|
|
80
|
+
| { success: true; log: GitLogResult };
|
|
81
|
+
|
|
82
|
+
export type GitCommandResult = GitCommandSuccess | GitCommandFailure;
|
|
83
|
+
|
|
84
|
+
const GIT_COMMAND_NAMES = new Set<GitCommandName>([
|
|
85
|
+
'git_status',
|
|
86
|
+
'git_diff_summary',
|
|
87
|
+
'git_diff_file',
|
|
88
|
+
'git_snapshot_create',
|
|
89
|
+
'git_snapshot_compare',
|
|
90
|
+
'git_log',
|
|
91
|
+
'git_checkpoint',
|
|
92
|
+
'git_stash_push',
|
|
93
|
+
'git_stash_pop',
|
|
94
|
+
'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',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const SNAPSHOT_REASONS = new Set<GitSnapshotReason>([
|
|
105
|
+
'session_baseline',
|
|
106
|
+
'before_user_input_dispatch',
|
|
107
|
+
'before_agent_work',
|
|
108
|
+
'after_agent_work',
|
|
109
|
+
'manual',
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const FAILURE_REASONS = new Set<GitFailureReason>([
|
|
113
|
+
'not_git_repo',
|
|
114
|
+
'git_not_installed',
|
|
115
|
+
'timeout',
|
|
116
|
+
'path_outside_repo',
|
|
117
|
+
'dirty_index_required',
|
|
118
|
+
'conflict',
|
|
119
|
+
'invalid_args',
|
|
120
|
+
'git_command_failed',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
function failure(reason: GitFailureReason, error: string): GitCommandFailure {
|
|
124
|
+
return { success: false, reason, error };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function serviceNotImplemented(command: string): GitCommandFailure {
|
|
128
|
+
return failure('invalid_args', `${command} is not implemented: daemon-core Git service is not configured`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const defaultSnapshotStore = createGitSnapshotStore({
|
|
132
|
+
getStatus: (workspace) => getGitRepoStatus(workspace),
|
|
133
|
+
getDiffSummary: (workspace) => getGitDiffSummary(workspace),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export function createDefaultGitCommandServices(): GitCommandServices {
|
|
137
|
+
return {
|
|
138
|
+
getStatus: ({ workspace }) => getGitRepoStatus(workspace),
|
|
139
|
+
getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
|
|
140
|
+
getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
|
|
141
|
+
createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
|
|
142
|
+
workspace,
|
|
143
|
+
reason,
|
|
144
|
+
sessionId,
|
|
145
|
+
turnId,
|
|
146
|
+
}),
|
|
147
|
+
compareSnapshots: ({ beforeSnapshotId, afterSnapshotId }) => defaultSnapshotStore.compare(beforeSnapshotId, afterSnapshotId),
|
|
148
|
+
getLog: ({ workspace, limit, path: filePath, since, until }) => getGitLog(workspace, { limit, path: filePath, since, until }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const defaultGitCommandServices = createDefaultGitCommandServices();
|
|
153
|
+
|
|
154
|
+
function validateWorkspace(args: any): { workspace: string } | GitCommandFailure {
|
|
155
|
+
if (typeof args?.workspace !== 'string') {
|
|
156
|
+
return failure('invalid_args', 'workspace must be a non-empty absolute path');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const workspace = args.workspace.trim();
|
|
160
|
+
if (!workspace || !path.isAbsolute(workspace)) {
|
|
161
|
+
return failure('invalid_args', 'workspace must be a non-empty absolute path');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { workspace };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateRepoPath(args: any): { path: string } | GitCommandFailure {
|
|
168
|
+
if (typeof args?.path !== 'string' || !args.path.trim()) {
|
|
169
|
+
return failure('invalid_args', 'path must be a non-empty repository-relative path');
|
|
170
|
+
}
|
|
171
|
+
return { path: args.path.trim() };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateSnapshotId(args: any, key: 'beforeSnapshotId' | 'afterSnapshotId'): string | GitCommandFailure {
|
|
175
|
+
if (typeof args?.[key] !== 'string' || !args[key].trim()) {
|
|
176
|
+
return failure('invalid_args', `${key} must be a non-empty string`);
|
|
177
|
+
}
|
|
178
|
+
return args[key].trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseSnapshotReason(args: any): GitSnapshotReason | GitCommandFailure {
|
|
182
|
+
if (args?.reason === undefined || args?.reason === null || args?.reason === '') {
|
|
183
|
+
return 'manual';
|
|
184
|
+
}
|
|
185
|
+
if (typeof args.reason !== 'string' || !SNAPSHOT_REASONS.has(args.reason as GitSnapshotReason)) {
|
|
186
|
+
return failure('invalid_args', 'reason must be a valid GitSnapshotReason');
|
|
187
|
+
}
|
|
188
|
+
return args.reason as GitSnapshotReason;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function optionalString(value: unknown): string | undefined {
|
|
192
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function optionalBoolean(value: unknown): boolean | undefined {
|
|
196
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function boundedLogLimit(value: unknown): number {
|
|
200
|
+
if (value === undefined || value === null || value === '') return 50;
|
|
201
|
+
const numeric = typeof value === 'number' ? value : Number(value);
|
|
202
|
+
if (!Number.isFinite(numeric)) return 50;
|
|
203
|
+
return Math.max(1, Math.min(200, Math.floor(numeric)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function failureReasonFromError(error: any): GitFailureReason {
|
|
207
|
+
return typeof error?.reason === 'string' && FAILURE_REASONS.has(error.reason)
|
|
208
|
+
? error.reason
|
|
209
|
+
: 'git_command_failed';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runService<T>(fn: () => Promise<T> | T): Promise<T | GitCommandFailure> {
|
|
213
|
+
try {
|
|
214
|
+
return await fn();
|
|
215
|
+
} catch (error: any) {
|
|
216
|
+
return failure(failureReasonFromError(error), error?.message || 'Git command failed');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function isGitCommandName(command: string): command is GitCommandName {
|
|
221
|
+
return GIT_COMMAND_NAMES.has(command as GitCommandName);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function handleGitCommand(
|
|
225
|
+
command: GitCommandName,
|
|
226
|
+
args: any,
|
|
227
|
+
services?: GitCommandServices,
|
|
228
|
+
): Promise<GitCommandResult>;
|
|
229
|
+
export async function handleGitCommand(
|
|
230
|
+
command: string,
|
|
231
|
+
args: any,
|
|
232
|
+
services?: GitCommandServices,
|
|
233
|
+
): Promise<GitCommandResult>;
|
|
234
|
+
export async function handleGitCommand(
|
|
235
|
+
command: string,
|
|
236
|
+
args: any,
|
|
237
|
+
services: GitCommandServices = defaultGitCommandServices,
|
|
238
|
+
): Promise<GitCommandResult> {
|
|
239
|
+
if (!isGitCommandName(command)) {
|
|
240
|
+
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
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
|
+
const workspaceResult = validateWorkspace(args);
|
|
248
|
+
if ('success' in workspaceResult) return workspaceResult;
|
|
249
|
+
const { workspace } = workspaceResult;
|
|
250
|
+
|
|
251
|
+
switch (command) {
|
|
252
|
+
case 'git_status': {
|
|
253
|
+
if (!services.getStatus) return serviceNotImplemented(command);
|
|
254
|
+
const status = await runService(() => services.getStatus!({ workspace }));
|
|
255
|
+
return 'success' in status ? status : { success: true, status };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'git_diff_summary': {
|
|
259
|
+
if (!services.getDiffSummary) return serviceNotImplemented(command);
|
|
260
|
+
const diffSummary = await runService(() => services.getDiffSummary!({ workspace, staged: optionalBoolean(args?.staged) }));
|
|
261
|
+
return 'success' in diffSummary ? diffSummary : { success: true, diffSummary };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'git_diff_file': {
|
|
265
|
+
if (!services.getDiffFile) return serviceNotImplemented(command);
|
|
266
|
+
const pathResult = validateRepoPath(args);
|
|
267
|
+
if (typeof pathResult !== 'object' || 'success' in pathResult) return pathResult;
|
|
268
|
+
const diff = await runService(() => services.getDiffFile!({
|
|
269
|
+
workspace,
|
|
270
|
+
path: pathResult.path,
|
|
271
|
+
staged: optionalBoolean(args?.staged),
|
|
272
|
+
}));
|
|
273
|
+
return 'success' in diff ? diff : { success: true, diff };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'git_snapshot_create': {
|
|
277
|
+
if (!services.createSnapshot) return serviceNotImplemented(command);
|
|
278
|
+
const reason = parseSnapshotReason(args);
|
|
279
|
+
if (typeof reason !== 'string') return reason;
|
|
280
|
+
const snapshot = await runService(() => services.createSnapshot!({
|
|
281
|
+
workspace,
|
|
282
|
+
reason,
|
|
283
|
+
sessionId: optionalString(args?.sessionId),
|
|
284
|
+
turnId: optionalString(args?.turnId),
|
|
285
|
+
}));
|
|
286
|
+
return 'success' in snapshot ? snapshot : { success: true, snapshot };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
case 'git_snapshot_compare': {
|
|
290
|
+
if (!services.compareSnapshots) return serviceNotImplemented(command);
|
|
291
|
+
const beforeSnapshotId = validateSnapshotId(args, 'beforeSnapshotId');
|
|
292
|
+
if (typeof beforeSnapshotId !== 'string') return beforeSnapshotId;
|
|
293
|
+
const afterSnapshotId = validateSnapshotId(args, 'afterSnapshotId');
|
|
294
|
+
if (typeof afterSnapshotId !== 'string') return afterSnapshotId;
|
|
295
|
+
const compare = await runService(() => services.compareSnapshots!({ workspace, beforeSnapshotId, afterSnapshotId }));
|
|
296
|
+
return 'success' in compare ? compare : { success: true, compare };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'git_log': {
|
|
300
|
+
if (!services.getLog) {
|
|
301
|
+
return failure('invalid_args', 'git_log is not implemented: bounded daemon-core Git log service is not configured');
|
|
302
|
+
}
|
|
303
|
+
const log = await runService(() => services.getLog!({
|
|
304
|
+
workspace,
|
|
305
|
+
limit: boundedLogLimit(args?.limit),
|
|
306
|
+
path: optionalString(args?.path),
|
|
307
|
+
since: optionalString(args?.since),
|
|
308
|
+
until: optionalString(args?.until),
|
|
309
|
+
}));
|
|
310
|
+
return 'success' in log ? log : { success: true, log };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
default:
|
|
314
|
+
return failure('invalid_args', `Unknown Git command: ${command}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function formatOptionalGitLogRangeArg(flag: '--since' | '--until', value: string | undefined): string[] {
|
|
319
|
+
return value ? [`${flag}=${value}`] : [];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function getGitLog(
|
|
323
|
+
workspace: string,
|
|
324
|
+
options: { limit: number; path?: string; since?: string; until?: string },
|
|
325
|
+
): Promise<GitLogResult> {
|
|
326
|
+
const lastCheckedAt = Date.now();
|
|
327
|
+
const repo = await resolveGitRepository(workspace);
|
|
328
|
+
const repoRoot = repo.repoRoot!;
|
|
329
|
+
const boundedLimit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
|
330
|
+
const selectedPath = options.path ? validateGitLogPath(repoRoot, options.path) : undefined;
|
|
331
|
+
const result = await runGit(
|
|
332
|
+
repo,
|
|
333
|
+
[
|
|
334
|
+
'log',
|
|
335
|
+
`--max-count=${boundedLimit}`,
|
|
336
|
+
'--format=%H%x00%an%x00%ae%x00%at%x00%ct%x00%s',
|
|
337
|
+
...formatOptionalGitLogRangeArg('--since', options.since),
|
|
338
|
+
...formatOptionalGitLogRangeArg('--until', options.until),
|
|
339
|
+
'--',
|
|
340
|
+
...(selectedPath ? [selectedPath] : []),
|
|
341
|
+
],
|
|
342
|
+
{ cwd: repoRoot },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const entries = result.stdout
|
|
346
|
+
.split('\n')
|
|
347
|
+
.filter((line) => line.trim().length > 0)
|
|
348
|
+
.map((line): GitLogEntry => {
|
|
349
|
+
const [commit = '', authorName, authorEmail, authoredAt, committedAt, ...messageParts] = line.split('\0');
|
|
350
|
+
return {
|
|
351
|
+
commit,
|
|
352
|
+
message: messageParts.join('\0'),
|
|
353
|
+
authorName: authorName || undefined,
|
|
354
|
+
authorEmail: authorEmail || undefined,
|
|
355
|
+
authoredAt: authoredAt ? Number.parseInt(authoredAt, 10) * 1000 : undefined,
|
|
356
|
+
committedAt: committedAt ? Number.parseInt(committedAt, 10) * 1000 : undefined,
|
|
357
|
+
};
|
|
358
|
+
})
|
|
359
|
+
.filter((entry) => entry.commit.length > 0);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
workspace: repo.workspace,
|
|
363
|
+
repoRoot,
|
|
364
|
+
isGitRepo: true,
|
|
365
|
+
entries,
|
|
366
|
+
limit: boundedLimit,
|
|
367
|
+
truncated: entries.length >= boundedLimit,
|
|
368
|
+
lastCheckedAt,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function validateGitLogPath(repoRoot: string, filePath: string): string {
|
|
373
|
+
if (!filePath.trim() || filePath.includes('\0')) {
|
|
374
|
+
throw new GitCommandError('invalid_args', 'path must be a non-empty repository-relative path');
|
|
375
|
+
}
|
|
376
|
+
if (path.isAbsolute(filePath)) {
|
|
377
|
+
throw new GitCommandError('invalid_args', 'path must be repository-relative');
|
|
378
|
+
}
|
|
379
|
+
const normalized = path.normalize(filePath).split(path.sep).join('/');
|
|
380
|
+
const absolutePath = path.resolve(repoRoot, normalized);
|
|
381
|
+
if (!isPathInside(repoRoot, absolutePath) || normalized.startsWith('../') || normalized === '..') {
|
|
382
|
+
throw new GitCommandError('path_outside_repo', 'Git log path is outside the repository root');
|
|
383
|
+
}
|
|
384
|
+
return normalized;
|
|
385
|
+
}
|