@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.
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.9.52",
3
+ "version": "0.9.54",
4
4
  "description": "ADHDev local session host core \u2014 session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.52",
3
+ "version": "0.9.54",
4
4
  "description": "ADHDev daemon core \u2014 CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- let excludeRecentCount = Math.max(0, Number(args?.excludeRecentCount || 0));
903
- if (isCliLikeTransport(transport)) {
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 status = adapter?.getStatus?.();
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'
@@ -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 = Math.max(1, limit);
1167
- const boundedOffset = Math.max(0, offset);
1168
- const boundedExclude = Math.max(0, Math.min(excludeRecentCount, collapsed.length));
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
+ }