@jameslovespancakes/pi-plus 1.0.11 → 1.0.13
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 +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { lstat, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { throwIfAborted } from "./cancellation.ts";
|
|
5
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
6
|
+
import type {
|
|
7
|
+
EffectiveAgentSessionIdentity,
|
|
8
|
+
EffectiveToolIdentity,
|
|
9
|
+
} from "./agent-session-identity.ts";
|
|
10
|
+
import { runBoundedProcess, type BoundedProcessResult } from "./process-runner.ts";
|
|
11
|
+
import {
|
|
12
|
+
BoundedFingerprint,
|
|
13
|
+
captureDeclaredInputFingerprint,
|
|
14
|
+
captureTreeFingerprint,
|
|
15
|
+
isPathWithin,
|
|
16
|
+
resolveDeclaredInputPaths,
|
|
17
|
+
validateTreeFile,
|
|
18
|
+
type FingerprintCapture,
|
|
19
|
+
} from "./tree-fingerprint.ts";
|
|
20
|
+
import type { LoadedWorkflow } from "./types.ts";
|
|
21
|
+
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
22
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
23
|
+
|
|
24
|
+
export type VerifiedRepositoryResumeContext =
|
|
25
|
+
| {
|
|
26
|
+
readonly kind: "verified";
|
|
27
|
+
readonly state: "git" | "isolated";
|
|
28
|
+
readonly head: string;
|
|
29
|
+
readonly workingTreeFingerprint: string;
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: "verified";
|
|
33
|
+
readonly state: "unborn" | "non-git" | "inaccessible";
|
|
34
|
+
readonly workingTreeFingerprint: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RepositoryResumeContext =
|
|
38
|
+
| VerifiedRepositoryResumeContext
|
|
39
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
40
|
+
|
|
41
|
+
export type VerifiedWorkflowResumeContext = {
|
|
42
|
+
readonly kind: "verified";
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly sourceFingerprint: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type WorkflowResumeContext =
|
|
48
|
+
| VerifiedWorkflowResumeContext
|
|
49
|
+
| { readonly kind: "unverifiable"; readonly name: string; readonly reason: string };
|
|
50
|
+
|
|
51
|
+
export interface ResolvedSkillIdentity {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly path: string;
|
|
54
|
+
readonly fingerprint: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AgentResumeBaseContext {
|
|
58
|
+
readonly workflow: WorkflowResumeContext;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VerifiedAgentResumeBaseContext {
|
|
62
|
+
readonly workflow: VerifiedWorkflowResumeContext;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
|
|
66
|
+
readonly repository: VerifiedRepositoryResumeContext;
|
|
67
|
+
readonly session: EffectiveAgentSessionIdentity;
|
|
68
|
+
readonly skills: readonly ResolvedSkillIdentity[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ResumeContextComparisonOptions {
|
|
72
|
+
readonly allowWorkflowSourceMismatch?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type RepositoryRevision =
|
|
76
|
+
| { readonly state: "git"; readonly head: string }
|
|
77
|
+
| { readonly state: "unborn" };
|
|
78
|
+
|
|
79
|
+
type RepositoryInspection =
|
|
80
|
+
| { readonly kind: "git"; readonly root: string; readonly revision: RepositoryRevision }
|
|
81
|
+
| { readonly kind: "non-git" }
|
|
82
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
83
|
+
|
|
84
|
+
type BoundedProcessFailureResult = Extract<BoundedProcessResult, { readonly ok: false }>;
|
|
85
|
+
|
|
86
|
+
const GIT_CONTEXT_TIMEOUT_MS = 15_000;
|
|
87
|
+
const GIT_CONTEXT_MAX_BYTES = 32 << 20;
|
|
88
|
+
const CONTENT_FINGERPRINT_MAX_BYTES = 32 << 20;
|
|
89
|
+
const SOURCE_TREE_MAX_FILES = 4096;
|
|
90
|
+
const REPOSITORY_INPUT_MAX_ENTRIES = 4096;
|
|
91
|
+
const GIT_UNTRACKED_MAX_ENTRIES = 32_768;
|
|
92
|
+
const GIT_VISIBLE_PATHS = [
|
|
93
|
+
".",
|
|
94
|
+
":(exclude).pi/.workflow-runs/**",
|
|
95
|
+
":(glob,exclude)**/.pi/.workflow-runs/**",
|
|
96
|
+
] as const;
|
|
97
|
+
export const FINGERPRINT_EXCLUDED_RELATIVE_PATHS = new Set([
|
|
98
|
+
".git",
|
|
99
|
+
".pi/.workflow-runs",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
export function unverifiableRepositoryResumeContext(reason: string): RepositoryResumeContext {
|
|
103
|
+
return { kind: "unverifiable", reason };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function unverifiableWorkflowResumeContext(name: string, reason: string): WorkflowResumeContext {
|
|
107
|
+
return { kind: "unverifiable", name, reason };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function captureRepositoryResumeContext(
|
|
111
|
+
cwd: string,
|
|
112
|
+
additionalInputs: readonly string[],
|
|
113
|
+
signal?: AbortSignal,
|
|
114
|
+
): Promise<RepositoryResumeContext> {
|
|
115
|
+
const repository = await inspectRepository(cwd, signal);
|
|
116
|
+
if (repository.kind === "unverifiable") return repository;
|
|
117
|
+
if (repository.kind === "non-git") {
|
|
118
|
+
return await captureNonGitRepositoryContext(cwd, [".", ...additionalInputs], signal);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const resolvedInputs = resolveDeclaredInputPaths(
|
|
122
|
+
repository.root,
|
|
123
|
+
additionalInputs,
|
|
124
|
+
FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
125
|
+
cwd,
|
|
126
|
+
);
|
|
127
|
+
if (resolvedInputs.kind === "unverifiable") return resolvedInputs;
|
|
128
|
+
const [gitVisible, explicit] = await Promise.all([
|
|
129
|
+
captureGitVisibleState(repository.root, signal),
|
|
130
|
+
captureRepositoryInputs(repository.root, resolvedInputs.paths, signal),
|
|
131
|
+
]);
|
|
132
|
+
if (gitVisible.kind === "unverifiable") return gitVisible;
|
|
133
|
+
if (explicit.kind === "unverifiable") return explicit;
|
|
134
|
+
return {
|
|
135
|
+
kind: "verified",
|
|
136
|
+
...repository.revision,
|
|
137
|
+
workingTreeFingerprint: combineFingerprints([
|
|
138
|
+
["git-visible", gitVisible.fingerprint],
|
|
139
|
+
["explicit-inputs", explicit.fingerprint],
|
|
140
|
+
]),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Bind isolated replay to the exact immutable commit and tree prepared for its disposable workspace. */
|
|
145
|
+
export async function captureIsolatedRepositoryContext(
|
|
146
|
+
cwd: string,
|
|
147
|
+
baselineOid: string,
|
|
148
|
+
signal?: AbortSignal,
|
|
149
|
+
): Promise<RepositoryResumeContext> {
|
|
150
|
+
try {
|
|
151
|
+
if (!isGitObjectId(baselineOid)) {
|
|
152
|
+
return { kind: "unverifiable", reason: "isolated baseline is not a full commit object ID" };
|
|
153
|
+
}
|
|
154
|
+
const [commit, tree, entries] = await Promise.all([
|
|
155
|
+
runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{commit}`], signal),
|
|
156
|
+
runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{tree}`], signal),
|
|
157
|
+
runGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", baselineOid], signal),
|
|
158
|
+
]);
|
|
159
|
+
throwIfAborted(signal);
|
|
160
|
+
if (!commit.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline commit probe", commit) };
|
|
161
|
+
if (!tree.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline tree probe", tree) };
|
|
162
|
+
if (!entries.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline entry capture", entries) };
|
|
163
|
+
|
|
164
|
+
const commitOid = commit.stdout.trim();
|
|
165
|
+
const treeOid = tree.stdout.trim();
|
|
166
|
+
if (!isGitObjectId(commitOid)) {
|
|
167
|
+
return { kind: "unverifiable", reason: "isolated baseline commit probe returned an invalid object ID" };
|
|
168
|
+
}
|
|
169
|
+
if (!isGitObjectId(treeOid)) {
|
|
170
|
+
return { kind: "unverifiable", reason: "isolated baseline tree probe returned an invalid object ID" };
|
|
171
|
+
}
|
|
172
|
+
for (const record of parseNullTerminatedRecords(entries.stdout, "isolated baseline entry capture")) {
|
|
173
|
+
const entry = parseTreeEntry(record);
|
|
174
|
+
const unsafe = unsafeTrackedModeReason(entry.mode, entry.path);
|
|
175
|
+
if (unsafe) return { kind: "unverifiable", reason: unsafe };
|
|
176
|
+
}
|
|
177
|
+
return { kind: "verified", state: "isolated", head: commitOid, workingTreeFingerprint: treeOid };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throwIfAborted(signal);
|
|
180
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function captureNonGitRepositoryContext(
|
|
185
|
+
cwd: string,
|
|
186
|
+
inputs: readonly string[],
|
|
187
|
+
signal: AbortSignal | undefined,
|
|
188
|
+
): Promise<RepositoryResumeContext> {
|
|
189
|
+
const capture = await captureRepositoryInputs(cwd, inputs, signal);
|
|
190
|
+
return capture.kind === "verified"
|
|
191
|
+
? { kind: "verified", state: "non-git", workingTreeFingerprint: capture.fingerprint }
|
|
192
|
+
: unverifiableRepositoryResumeContext(capture.reason);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function captureRepositoryInputs(
|
|
196
|
+
cwd: string,
|
|
197
|
+
inputs: readonly string[],
|
|
198
|
+
signal: AbortSignal | undefined,
|
|
199
|
+
): Promise<FingerprintCapture> {
|
|
200
|
+
return await captureDeclaredInputFingerprint({
|
|
201
|
+
root: cwd,
|
|
202
|
+
inputs,
|
|
203
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
204
|
+
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
205
|
+
maxEntries: REPOSITORY_INPUT_MAX_ENTRIES,
|
|
206
|
+
signal,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Capture Git-visible dirty state around an isolated attempt.
|
|
212
|
+
*
|
|
213
|
+
* This guard is intentionally transient rather than part of replay identity:
|
|
214
|
+
* isolated results bind to their worktree baseline, while this snapshot only
|
|
215
|
+
* detects main-checkout mutations without rereading every clean tracked file.
|
|
216
|
+
*/
|
|
217
|
+
export async function captureRepositoryMutationGuard(
|
|
218
|
+
cwd: string,
|
|
219
|
+
signal?: AbortSignal,
|
|
220
|
+
): Promise<FingerprintCapture> {
|
|
221
|
+
const repository = await inspectRepository(cwd, signal);
|
|
222
|
+
if (repository.kind === "unverifiable") return repository;
|
|
223
|
+
if (repository.kind === "non-git") {
|
|
224
|
+
return { kind: "unverifiable", reason: "isolated mutation guard requires a Git worktree" };
|
|
225
|
+
}
|
|
226
|
+
const visible = await captureGitVisibleState(repository.root, signal);
|
|
227
|
+
if (visible.kind === "unverifiable") return visible;
|
|
228
|
+
return {
|
|
229
|
+
kind: "verified",
|
|
230
|
+
fingerprint: combineFingerprints([
|
|
231
|
+
["revision-state", repository.revision.state],
|
|
232
|
+
["revision-head", repository.revision.state === "git" ? repository.revision.head : "unborn"],
|
|
233
|
+
["git-visible", visible.fingerprint],
|
|
234
|
+
]),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function inspectRepository(
|
|
239
|
+
cwd: string,
|
|
240
|
+
signal: AbortSignal | undefined,
|
|
241
|
+
): Promise<RepositoryInspection> {
|
|
242
|
+
throwIfAborted(signal);
|
|
243
|
+
const probe = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"], signal);
|
|
244
|
+
throwIfAborted(signal);
|
|
245
|
+
if (!probe.ok) {
|
|
246
|
+
if (probe.failure.kind === "exit") {
|
|
247
|
+
const marker = await findGitControlPath(cwd, signal);
|
|
248
|
+
if (marker.kind === "absent") return { kind: "non-git" };
|
|
249
|
+
if (marker.kind === "unknown") return { kind: "unverifiable", reason: marker.reason };
|
|
250
|
+
}
|
|
251
|
+
return { kind: "unverifiable", reason: processFailureReason("git repository probe", probe) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const insideWorkTree = probe.stdout.trim();
|
|
255
|
+
if (insideWorkTree === "false") return { kind: "non-git" };
|
|
256
|
+
if (insideWorkTree !== "true") {
|
|
257
|
+
return { kind: "unverifiable", reason: `git repository probe returned an unexpected value: ${insideWorkTree || "<empty>"}` };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const [rootResult, headResult] = await Promise.all([
|
|
261
|
+
runGit(cwd, ["rev-parse", "--show-toplevel"], signal),
|
|
262
|
+
runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], signal),
|
|
263
|
+
]);
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
if (!rootResult.ok) {
|
|
266
|
+
return { kind: "unverifiable", reason: processFailureReason("git top-level probe", rootResult) };
|
|
267
|
+
}
|
|
268
|
+
const root = parseGitTopLevel(rootResult.stdout, cwd);
|
|
269
|
+
if (!root) return { kind: "unverifiable", reason: "git top-level probe returned an invalid path" };
|
|
270
|
+
if (headResult.ok) {
|
|
271
|
+
const head = headResult.stdout.trim();
|
|
272
|
+
return isGitObjectId(head)
|
|
273
|
+
? { kind: "git", root, revision: { state: "git", head } }
|
|
274
|
+
: { kind: "unverifiable", reason: "git HEAD probe returned an invalid revision" };
|
|
275
|
+
}
|
|
276
|
+
return headResult.failure.kind === "exit" && headResult.failure.code === 1
|
|
277
|
+
? { kind: "git", root, revision: { state: "unborn" } }
|
|
278
|
+
: { kind: "unverifiable", reason: processFailureReason("git HEAD probe", headResult) };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function captureGitVisibleState(
|
|
282
|
+
cwd: string,
|
|
283
|
+
signal: AbortSignal | undefined,
|
|
284
|
+
): Promise<FingerprintCapture> {
|
|
285
|
+
try {
|
|
286
|
+
return await captureGitVisibleStateUnchecked(cwd, signal);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throwIfAborted(signal);
|
|
289
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function captureGitVisibleStateUnchecked(
|
|
294
|
+
cwd: string,
|
|
295
|
+
signal: AbortSignal | undefined,
|
|
296
|
+
): Promise<FingerprintCapture> {
|
|
297
|
+
const diffFlags = [
|
|
298
|
+
"--binary",
|
|
299
|
+
"--full-index",
|
|
300
|
+
"--no-ext-diff",
|
|
301
|
+
"--no-textconv",
|
|
302
|
+
"--no-color",
|
|
303
|
+
"--ignore-submodules=none",
|
|
304
|
+
] as const;
|
|
305
|
+
const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
|
|
306
|
+
runGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
307
|
+
runGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
|
|
308
|
+
runGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
|
|
309
|
+
runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
|
|
310
|
+
runGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
|
|
311
|
+
runGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
|
|
312
|
+
]);
|
|
313
|
+
throwIfAborted(signal);
|
|
314
|
+
|
|
315
|
+
const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
|
|
316
|
+
if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
|
|
317
|
+
|
|
318
|
+
const untracked = await captureDeclaredInputFingerprint({
|
|
319
|
+
root: cwd,
|
|
320
|
+
inputs: validated.untrackedPaths.filter((path) => !isFingerprintExcludedPath(path)),
|
|
321
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
322
|
+
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
323
|
+
maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
|
|
324
|
+
signal,
|
|
325
|
+
});
|
|
326
|
+
if (untracked.kind === "unverifiable") return untracked;
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
kind: "verified",
|
|
330
|
+
fingerprint: combineFingerprints([
|
|
331
|
+
["unstaged", validated.unstaged],
|
|
332
|
+
["staged", validated.staged],
|
|
333
|
+
["index", validated.indexEntries],
|
|
334
|
+
["untracked", untracked.fingerprint],
|
|
335
|
+
]),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface GitVisibleProcessOutputs {
|
|
340
|
+
readonly unstaged: BoundedProcessResult;
|
|
341
|
+
readonly unstagedRaw: BoundedProcessResult;
|
|
342
|
+
readonly staged: BoundedProcessResult;
|
|
343
|
+
readonly untrackedPaths: BoundedProcessResult;
|
|
344
|
+
readonly indexEntries: BoundedProcessResult;
|
|
345
|
+
readonly trackedFlags: BoundedProcessResult;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
type ValidatedGitVisibleOutputs =
|
|
349
|
+
| {
|
|
350
|
+
readonly ok: true;
|
|
351
|
+
readonly unstaged: string;
|
|
352
|
+
readonly staged: string;
|
|
353
|
+
readonly indexEntries: string;
|
|
354
|
+
readonly untrackedPaths: readonly string[];
|
|
355
|
+
}
|
|
356
|
+
| { readonly ok: false; readonly reason: string };
|
|
357
|
+
|
|
358
|
+
function validateGitVisibleOutputs(outputs: GitVisibleProcessOutputs): ValidatedGitVisibleOutputs {
|
|
359
|
+
const { unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags } = outputs;
|
|
360
|
+
if (!unstaged.ok) return { ok: false, reason: processFailureReason("git unstaged diff capture", unstaged) };
|
|
361
|
+
if (!unstagedRaw.ok) return { ok: false, reason: processFailureReason("git unstaged mode capture", unstagedRaw) };
|
|
362
|
+
if (!staged.ok) return { ok: false, reason: processFailureReason("git staged diff capture", staged) };
|
|
363
|
+
if (!untrackedPaths.ok) return { ok: false, reason: processFailureReason("git untracked path capture", untrackedPaths) };
|
|
364
|
+
if (!indexEntries.ok) return { ok: false, reason: processFailureReason("git index entry capture", indexEntries) };
|
|
365
|
+
if (!trackedFlags.ok) return { ok: false, reason: processFailureReason("git tracked flag capture", trackedFlags) };
|
|
366
|
+
|
|
367
|
+
const unsafeWorktreeMode = rawWorktreeModeFailure(unstagedRaw.stdout);
|
|
368
|
+
if (unsafeWorktreeMode) return { ok: false, reason: unsafeWorktreeMode };
|
|
369
|
+
|
|
370
|
+
for (const record of parseNullTerminatedRecords(indexEntries.stdout, "git index entry capture")) {
|
|
371
|
+
const entry = parseIndexEntry(record);
|
|
372
|
+
if (entry.stage !== 0) {
|
|
373
|
+
return { ok: false, reason: "git-visible repository state contains unmerged paths" };
|
|
374
|
+
}
|
|
375
|
+
const unsafe = unsafeTrackedModeReason(entry.mode, entry.path);
|
|
376
|
+
if (unsafe) return { ok: false, reason: unsafe };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const unsafeFlag = parseNullTerminatedRecords(trackedFlags.stdout, "git tracked flag capture")
|
|
380
|
+
.find((record) => record[0] !== "H");
|
|
381
|
+
if (unsafeFlag) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
reason: `git-visible repository state contains an unsupported tracked-file flag: ${unsafeFlag[0] ?? "unknown"}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
unstaged: unstaged.stdout,
|
|
391
|
+
staged: staged.stdout,
|
|
392
|
+
indexEntries: indexEntries.stdout,
|
|
393
|
+
untrackedPaths: parseNullTerminatedRecords(untrackedPaths.stdout, "git untracked path capture"),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function combineFingerprints(parts: readonly (readonly [label: string, value: string])[]): string {
|
|
398
|
+
const fingerprint = new BoundedFingerprint(0);
|
|
399
|
+
for (const [label, value] of parts) fingerprint.add(label, value);
|
|
400
|
+
return fingerprint.digest();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
interface GitIndexEntry {
|
|
404
|
+
readonly mode: string;
|
|
405
|
+
readonly stage: number;
|
|
406
|
+
readonly path: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function parseIndexEntry(record: string): GitIndexEntry {
|
|
410
|
+
const separator = record.indexOf("\t");
|
|
411
|
+
if (separator < 0) throw new Error("git index entry capture returned malformed data");
|
|
412
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])$/i.exec(record.slice(0, separator));
|
|
413
|
+
if (!match) throw new Error("git index entry capture returned malformed metadata");
|
|
414
|
+
return { mode: match[1]!, stage: Number(match[3]), path: record.slice(separator + 1) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseTreeEntry(record: string): { readonly mode: string; readonly path: string } {
|
|
418
|
+
const separator = record.indexOf("\t");
|
|
419
|
+
if (separator < 0) throw new Error("isolated baseline entry capture returned malformed data");
|
|
420
|
+
const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40,64})$/i.exec(record.slice(0, separator));
|
|
421
|
+
if (!match) throw new Error("isolated baseline entry capture returned malformed metadata");
|
|
422
|
+
return { mode: match[1]!, path: record.slice(separator + 1) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function unsafeTrackedModeReason(mode: string, path: string): string | undefined {
|
|
426
|
+
if (mode === "120000") return `repository replay does not support tracked symbolic links: ${path}`;
|
|
427
|
+
if (mode === "160000") return `repository replay does not support tracked submodules: ${path}`;
|
|
428
|
+
return mode === "100644" || mode === "100755"
|
|
429
|
+
? undefined
|
|
430
|
+
: `repository replay encountered unsupported tracked mode ${mode}: ${path}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function rawWorktreeModeFailure(output: string): string | undefined {
|
|
434
|
+
const records = parseNullTerminatedRecords(output, "git unstaged mode capture");
|
|
435
|
+
if (records.length % 2 !== 0) throw new Error("git unstaged mode capture returned incomplete data");
|
|
436
|
+
for (let index = 0; index < records.length; index += 2) {
|
|
437
|
+
const header = records[index]!;
|
|
438
|
+
const path = records[index + 1]!;
|
|
439
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40,64}) ([0-9a-f]{40,64}) ([A-Z][0-9]*)$/i.exec(header);
|
|
440
|
+
if (!match || path.length === 0) throw new Error("git unstaged mode capture returned malformed data");
|
|
441
|
+
if (match[2] === "000000") continue;
|
|
442
|
+
const unsafe = unsafeTrackedModeReason(match[2]!, path);
|
|
443
|
+
if (unsafe) return unsafe;
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function captureWorkflowResumeContext(
|
|
449
|
+
mod: LoadedWorkflow,
|
|
450
|
+
signal?: AbortSignal,
|
|
451
|
+
): Promise<WorkflowResumeContext> {
|
|
452
|
+
throwIfAborted(signal);
|
|
453
|
+
if (mod.source.kind === "unverifiable") {
|
|
454
|
+
return unverifiableWorkflowResumeContext(mod.meta.name, mod.source.reason);
|
|
455
|
+
}
|
|
456
|
+
if (mod.source.kind === "fingerprint") {
|
|
457
|
+
return mod.source.fingerprint.length > 0
|
|
458
|
+
? { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint }
|
|
459
|
+
: unverifiableWorkflowResumeContext(mod.meta.name, "workflow source fingerprint is empty");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const sourcePath = resolve(mod.source.path);
|
|
463
|
+
const sourceRoot = resolve(mod.source.root);
|
|
464
|
+
if (!isPathWithin(sourceRoot, sourcePath)) {
|
|
465
|
+
return unverifiableWorkflowResumeContext(mod.meta.name, "workflow source file is outside its declared source root");
|
|
466
|
+
}
|
|
467
|
+
const validation = await validateTreeFile({
|
|
468
|
+
root: sourceRoot,
|
|
469
|
+
path: sourcePath,
|
|
470
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
471
|
+
signal,
|
|
472
|
+
});
|
|
473
|
+
if (validation.kind === "unverifiable") {
|
|
474
|
+
return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source file is not part of its source tree: ${validation.reason}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const current = await captureTreeFingerprint({
|
|
478
|
+
root: sourceRoot,
|
|
479
|
+
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
480
|
+
maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
|
|
481
|
+
maxFiles: SOURCE_TREE_MAX_FILES,
|
|
482
|
+
signal,
|
|
483
|
+
});
|
|
484
|
+
if (current.kind === "unverifiable") {
|
|
485
|
+
return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source tree could not be verified: ${current.reason}`);
|
|
486
|
+
}
|
|
487
|
+
if (current.fingerprint !== mod.source.fingerprint) {
|
|
488
|
+
return unverifiableWorkflowResumeContext(mod.meta.name, "workflow source tree changed after the module loaded");
|
|
489
|
+
}
|
|
490
|
+
return { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function createAgentResumeContext(
|
|
494
|
+
base: VerifiedAgentResumeBaseContext,
|
|
495
|
+
repository: VerifiedRepositoryResumeContext,
|
|
496
|
+
session: EffectiveAgentSessionIdentity,
|
|
497
|
+
skills: readonly ResolvedSkillIdentity[],
|
|
498
|
+
): AgentResumeContext {
|
|
499
|
+
return {
|
|
500
|
+
...base,
|
|
501
|
+
repository,
|
|
502
|
+
session,
|
|
503
|
+
skills: [...skills],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function resumeContextMismatchReason(
|
|
508
|
+
stored: AgentResumeContext,
|
|
509
|
+
current: AgentResumeContext,
|
|
510
|
+
options: ResumeContextComparisonOptions = {},
|
|
511
|
+
): string | undefined {
|
|
512
|
+
if (stored.repository.state !== current.repository.state) return "repository state changed";
|
|
513
|
+
if (
|
|
514
|
+
(stored.repository.state === "git" || stored.repository.state === "isolated") &&
|
|
515
|
+
(current.repository.state === "git" || current.repository.state === "isolated") &&
|
|
516
|
+
stored.repository.head !== current.repository.head
|
|
517
|
+
) {
|
|
518
|
+
return stored.repository.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
|
|
519
|
+
}
|
|
520
|
+
if (stored.repository.workingTreeFingerprint !== current.repository.workingTreeFingerprint) {
|
|
521
|
+
return "working tree contents changed";
|
|
522
|
+
}
|
|
523
|
+
if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
|
|
524
|
+
if (
|
|
525
|
+
!options.allowWorkflowSourceMismatch
|
|
526
|
+
&& stored.workflow.sourceFingerprint !== current.workflow.sourceFingerprint
|
|
527
|
+
) return "workflow source changed";
|
|
528
|
+
if (stored.session.runtimeVersion !== current.session.runtimeVersion) return "coding-agent runtime changed";
|
|
529
|
+
if (stored.session.systemPromptFingerprint !== current.session.systemPromptFingerprint) return "effective system prompt changed";
|
|
530
|
+
if (
|
|
531
|
+
stored.session.model.provider !== current.session.model.provider ||
|
|
532
|
+
stored.session.model.id !== current.session.model.id
|
|
533
|
+
) {
|
|
534
|
+
return "effective model changed";
|
|
535
|
+
}
|
|
536
|
+
if (stored.session.thinkingLevel !== current.session.thinkingLevel) return "effective thinking level changed";
|
|
537
|
+
const storedSession = canonicalizeIdentity(stored.session);
|
|
538
|
+
const currentSession = canonicalizeIdentity(current.session);
|
|
539
|
+
if (storedSession.kind === "unverifiable" || currentSession.kind === "unverifiable") {
|
|
540
|
+
return "effective session state could not be verified";
|
|
541
|
+
}
|
|
542
|
+
if (storedSession.value !== currentSession.value) return "effective tools or session state changed";
|
|
543
|
+
const storedSkills = canonicalizeIdentity(stored.skills);
|
|
544
|
+
const currentSkills = canonicalizeIdentity(current.skills);
|
|
545
|
+
if (storedSkills.kind === "unverifiable" || currentSkills.kind === "unverifiable") {
|
|
546
|
+
return "resolved skills could not be verified";
|
|
547
|
+
}
|
|
548
|
+
if (storedSkills.value !== currentSkills.value) return "resolved skills changed";
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function isAgentResumeContext(value: unknown): value is AgentResumeContext {
|
|
553
|
+
if (!isRecord(value)) return false;
|
|
554
|
+
return (
|
|
555
|
+
isVerifiedRepositoryResumeContext(value.repository) &&
|
|
556
|
+
isVerifiedWorkflowResumeContext(value.workflow) &&
|
|
557
|
+
isEffectiveAgentSessionIdentity(value.session) &&
|
|
558
|
+
Array.isArray(value.skills) &&
|
|
559
|
+
value.skills.every(isResolvedSkillIdentity)
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function runGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
|
|
564
|
+
return await runBoundedProcess({
|
|
565
|
+
file: "git",
|
|
566
|
+
args,
|
|
567
|
+
cwd,
|
|
568
|
+
env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
|
|
569
|
+
signal,
|
|
570
|
+
timeoutMs: GIT_CONTEXT_TIMEOUT_MS,
|
|
571
|
+
maxBufferBytes: GIT_CONTEXT_MAX_BYTES,
|
|
572
|
+
abortError: "git resume-context capture aborted",
|
|
573
|
+
timeoutError: `git resume-context capture timed out after ${GIT_CONTEXT_TIMEOUT_MS}ms`,
|
|
574
|
+
maxBufferError: `git resume-context capture exceeded ${GIT_CONTEXT_MAX_BYTES} bytes`,
|
|
575
|
+
exitError: (stderr, code, exitSignal) => stderr.trim() || `git exited with code ${code ?? `signal ${exitSignal ?? "unknown"}`}`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Decide whether a `.git` entry found during the ancestor walk marks a repository
|
|
581
|
+
* that git may have failed to open, or a stray entry that git's own discovery
|
|
582
|
+
* also skips.
|
|
583
|
+
*
|
|
584
|
+
* The rule mirrors git's discovery loop so that "absent" means git itself would
|
|
585
|
+
* have walked past every `.git` entry and its "not a repository" answer is
|
|
586
|
+
* authoritative:
|
|
587
|
+
*
|
|
588
|
+
* - A regular file is a gitfile. Git halts on any regular `.git` file, even when
|
|
589
|
+
* its content is malformed or its `gitdir:` target is missing, so the entry is
|
|
590
|
+
* a marker regardless of content.
|
|
591
|
+
* - A directory is a repository only when `HEAD`, `objects/`, and `refs/` all
|
|
592
|
+
* exist. Git skips a directory that lacks any of them (an empty `.git` is the
|
|
593
|
+
* common case), so such a directory is not a marker.
|
|
594
|
+
* - A symbolic link is resolved first, as git uses `stat`. A dangling link is
|
|
595
|
+
* skipped by git and is therefore not a marker.
|
|
596
|
+
* - Any other entry type (socket, FIFO, device) is skipped by git.
|
|
597
|
+
*
|
|
598
|
+
* A missing path (`ENOENT`) is a negative answer, not a failure. Any other
|
|
599
|
+
* filesystem error propagates so the caller reports the probe as "unknown".
|
|
600
|
+
*/
|
|
601
|
+
async function isRepositoryMarker(entryPath: string, entry: Stats): Promise<boolean> {
|
|
602
|
+
let target = entry;
|
|
603
|
+
if (entry.isSymbolicLink()) {
|
|
604
|
+
const resolved = await statIfPresent(entryPath);
|
|
605
|
+
if (!resolved) return false;
|
|
606
|
+
target = resolved;
|
|
607
|
+
}
|
|
608
|
+
if (target.isFile()) return true;
|
|
609
|
+
if (!target.isDirectory()) return false;
|
|
610
|
+
for (const name of GIT_DIRECTORY_REQUIRED_ENTRIES) {
|
|
611
|
+
if (!(await statIfPresent(join(entryPath, name)))) return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const GIT_DIRECTORY_REQUIRED_ENTRIES = ["HEAD", "objects", "refs"] as const;
|
|
617
|
+
|
|
618
|
+
async function statIfPresent(path: string): Promise<Stats | undefined> {
|
|
619
|
+
try {
|
|
620
|
+
return await stat(path);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (isMissingPathError(error)) return undefined;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
type GitControlPathResult =
|
|
628
|
+
| { readonly kind: "present" | "absent" }
|
|
629
|
+
| { readonly kind: "unknown"; readonly reason: string };
|
|
630
|
+
|
|
631
|
+
async function findGitControlPath(cwd: string, signal: AbortSignal | undefined): Promise<GitControlPathResult> {
|
|
632
|
+
let current = resolve(cwd);
|
|
633
|
+
while (true) {
|
|
634
|
+
throwIfAborted(signal);
|
|
635
|
+
const entryPath = join(current, ".git");
|
|
636
|
+
try {
|
|
637
|
+
const entry = await lstat(entryPath);
|
|
638
|
+
if (await isRepositoryMarker(entryPath, entry)) return { kind: "present" };
|
|
639
|
+
} catch (error) {
|
|
640
|
+
if (!isMissingPathError(error)) {
|
|
641
|
+
return { kind: "unknown", reason: `git control-path probe failed: ${unknownErrorMessage(error)}` };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const parent = dirname(current);
|
|
645
|
+
if (parent === current) return { kind: "absent" };
|
|
646
|
+
current = parent;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function processFailureReason(operation: string, result: BoundedProcessFailureResult): string {
|
|
651
|
+
if (result.failure.kind === "exit") {
|
|
652
|
+
const termination = result.failure.code === null ? `signal ${result.failure.signal ?? "unknown"}` : `exit ${result.failure.code}`;
|
|
653
|
+
const message = sanitizeProcessMessage(result.failure.message);
|
|
654
|
+
return `${operation} failed (${termination}${message ? `: ${message}` : ""})`;
|
|
655
|
+
}
|
|
656
|
+
return `${operation} failed (${result.failure.kind}: ${result.failure.message})`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function isVerifiedRepositoryResumeContext(value: unknown): value is VerifiedRepositoryResumeContext {
|
|
660
|
+
if (!isRecord(value)) return false;
|
|
661
|
+
if (value.kind !== "verified" || typeof value.workingTreeFingerprint !== "string") return false;
|
|
662
|
+
if (value.state === "git" || value.state === "isolated") return typeof value.head === "string";
|
|
663
|
+
return value.state === "unborn" || value.state === "non-git" || value.state === "inaccessible";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function isVerifiedWorkflowResumeContext(value: unknown): value is VerifiedWorkflowResumeContext {
|
|
667
|
+
if (!isRecord(value) || typeof value.name !== "string") return false;
|
|
668
|
+
return value.kind === "verified" && typeof value.sourceFingerprint === "string";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function isResolvedSkillIdentity(value: unknown): value is ResolvedSkillIdentity {
|
|
672
|
+
return isRecord(value) && typeof value.name === "string" && typeof value.path === "string" && typeof value.fingerprint === "string";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isEffectiveAgentSessionIdentity(value: unknown): value is EffectiveAgentSessionIdentity {
|
|
676
|
+
if (
|
|
677
|
+
!isRecord(value) ||
|
|
678
|
+
typeof value.fingerprint !== "string" ||
|
|
679
|
+
typeof value.runtimeVersion !== "string" ||
|
|
680
|
+
typeof value.systemPromptFingerprint !== "string" ||
|
|
681
|
+
typeof value.thinkingLevel !== "string" ||
|
|
682
|
+
!isRecord(value.model) ||
|
|
683
|
+
typeof value.model.provider !== "string" ||
|
|
684
|
+
typeof value.model.id !== "string" ||
|
|
685
|
+
!Array.isArray(value.tools)
|
|
686
|
+
) {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
return value.tools.every(isEffectiveToolIdentity);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function isEffectiveToolIdentity(value: unknown): value is EffectiveToolIdentity {
|
|
693
|
+
if (
|
|
694
|
+
!isRecord(value) ||
|
|
695
|
+
typeof value.name !== "string" ||
|
|
696
|
+
typeof value.definitionFingerprint !== "string" ||
|
|
697
|
+
typeof value.implementationFingerprint !== "string" ||
|
|
698
|
+
!isRecord(value.source)
|
|
699
|
+
) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
return (
|
|
703
|
+
typeof value.source.path === "string" &&
|
|
704
|
+
typeof value.source.source === "string" &&
|
|
705
|
+
typeof value.source.scope === "string" &&
|
|
706
|
+
typeof value.source.origin === "string" &&
|
|
707
|
+
typeof value.source.fingerprint === "string" &&
|
|
708
|
+
(value.source.baseDir === undefined || typeof value.source.baseDir === "string")
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseNullTerminatedRecords(output: string, operation: string): string[] {
|
|
713
|
+
if (output.length === 0) return [];
|
|
714
|
+
if (!output.endsWith("\0")) throw new Error(`${operation} returned unterminated data`);
|
|
715
|
+
return output.slice(0, -1).split("\0");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
719
|
+
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
720
|
+
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
721
|
+
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
722
|
+
const root = resolve(cwd, value);
|
|
723
|
+
return isPathWithin(root, cwd) ? root : undefined;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function isGitObjectId(value: string): boolean {
|
|
727
|
+
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function isFingerprintExcludedPath(path: string): boolean {
|
|
731
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
732
|
+
for (const excluded of FINGERPRINT_EXCLUDED_RELATIVE_PATHS) {
|
|
733
|
+
if (
|
|
734
|
+
normalized === excluded ||
|
|
735
|
+
normalized.startsWith(`${excluded}/`) ||
|
|
736
|
+
normalized.endsWith(`/${excluded}`) ||
|
|
737
|
+
normalized.includes(`/${excluded}/`)
|
|
738
|
+
) {
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
746
|
+
return typeof value === "object" && value !== null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sanitizeProcessMessage(message: string): string {
|
|
750
|
+
return message.trim().replace(/\s+/g, " ").slice(0, 500);
|
|
751
|
+
}
|