@markjaquith/agency 3.9.2 → 3.9.4
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/package.json
CHANGED
package/pi-extensions/agency.ts
CHANGED
|
@@ -7,10 +7,20 @@ type ExtensionAPI = {
|
|
|
7
7
|
args: string[],
|
|
8
8
|
options: { timeout: number },
|
|
9
9
|
) => Promise<{ code: number; stdout: string }>
|
|
10
|
-
on: (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
on(name: "session_start", handler: () => void): void
|
|
11
|
+
on(
|
|
12
|
+
name: "resources_discover",
|
|
13
|
+
handler: (event: {
|
|
14
|
+
cwd: string
|
|
15
|
+
}) => Promise<{ skillPaths: string[] } | undefined>,
|
|
16
|
+
): void
|
|
17
|
+
on(
|
|
18
|
+
name: "before_agent_start",
|
|
19
|
+
handler: (
|
|
20
|
+
event: { systemPrompt: string },
|
|
21
|
+
ctx: { cwd: string },
|
|
22
|
+
) => Promise<{ systemPrompt: string } | undefined>,
|
|
23
|
+
): void
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
type AgencyContext = {
|
|
@@ -80,15 +90,38 @@ const agencyContext = async (
|
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
export default function agencyExtension(pi: ExtensionAPI) {
|
|
83
|
-
const contexts = new Map<
|
|
93
|
+
const contexts = new Map<
|
|
94
|
+
string,
|
|
95
|
+
{
|
|
96
|
+
promise: Promise<AgencyContext | undefined>
|
|
97
|
+
expiresAt: number
|
|
98
|
+
retryDelay: number
|
|
99
|
+
}
|
|
100
|
+
>()
|
|
101
|
+
// Pi emits session_start for startup, reload, new, resume, and fork.
|
|
102
|
+
// Resource discovery follows it and must share the same cached lookup.
|
|
103
|
+
pi.on("session_start", () => contexts.clear())
|
|
104
|
+
|
|
84
105
|
const runtimeContext = (directory: string) => {
|
|
85
106
|
const key = resolve(directory)
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
const previous = contexts.get(key)
|
|
108
|
+
if (previous && Date.now() < previous.expiresAt) return previous.promise
|
|
109
|
+
|
|
110
|
+
const entry = {
|
|
111
|
+
promise: agencyContext(pi, key)
|
|
112
|
+
.catch(() => undefined)
|
|
113
|
+
.then((context) => {
|
|
114
|
+
entry.retryDelay = context
|
|
115
|
+
? 0
|
|
116
|
+
: Math.min((previous?.retryDelay || 500) * 2, 30_000)
|
|
117
|
+
entry.expiresAt = Date.now() + (context ? 60_000 : entry.retryDelay)
|
|
118
|
+
return context
|
|
119
|
+
}),
|
|
120
|
+
expiresAt: Infinity,
|
|
121
|
+
retryDelay: 0,
|
|
90
122
|
}
|
|
91
|
-
|
|
123
|
+
contexts.set(key, entry)
|
|
124
|
+
return entry.promise
|
|
92
125
|
}
|
|
93
126
|
|
|
94
127
|
pi.on("resources_discover", async (event: { cwd: string }) => {
|
|
@@ -1,25 +1,146 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { constants } from "node:fs"
|
|
3
|
+
import {
|
|
4
|
+
copyFile,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises"
|
|
2
11
|
import { homedir } from "node:os"
|
|
3
12
|
import { dirname, join } from "node:path"
|
|
4
13
|
|
|
14
|
+
const ownershipVersion = 1
|
|
15
|
+
|
|
16
|
+
type LifecycleResult = {
|
|
17
|
+
status: "installed" | "updated" | "removed" | "absent" | "skipped"
|
|
18
|
+
destination: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Ownership = {
|
|
22
|
+
version: typeof ownershipVersion
|
|
23
|
+
sha256: string
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
export const piExtensionPath = (home = homedir()) =>
|
|
6
27
|
join(home, ".pi", "agent", "extensions", "agency.ts")
|
|
7
28
|
|
|
29
|
+
export const piExtensionOwnershipPath = (destination = piExtensionPath()) =>
|
|
30
|
+
`${destination}.agency-managed.json`
|
|
31
|
+
|
|
32
|
+
const digest = (contents: Uint8Array) =>
|
|
33
|
+
createHash("sha256").update(contents).digest("hex")
|
|
34
|
+
|
|
35
|
+
const readOwnership = async (
|
|
36
|
+
destination: string,
|
|
37
|
+
): Promise<Ownership | undefined> => {
|
|
38
|
+
try {
|
|
39
|
+
const marker = await lstat(piExtensionOwnershipPath(destination))
|
|
40
|
+
if (!marker.isFile() || marker.isSymbolicLink()) return
|
|
41
|
+
const ownership = JSON.parse(
|
|
42
|
+
await readFile(piExtensionOwnershipPath(destination), "utf8"),
|
|
43
|
+
) as Partial<Ownership>
|
|
44
|
+
if (
|
|
45
|
+
ownership.version === ownershipVersion &&
|
|
46
|
+
typeof ownership.sha256 === "string"
|
|
47
|
+
)
|
|
48
|
+
return ownership as Ownership
|
|
49
|
+
} catch {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const writeOwnership = async (
|
|
55
|
+
destination: string,
|
|
56
|
+
sha256: string,
|
|
57
|
+
exclusive = false,
|
|
58
|
+
) => {
|
|
59
|
+
await writeFile(
|
|
60
|
+
piExtensionOwnershipPath(destination),
|
|
61
|
+
`${JSON.stringify({ version: ownershipVersion, sha256 })}\n`,
|
|
62
|
+
exclusive ? { flag: "wx" } : undefined,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
8
66
|
export const installPiExtension = async (
|
|
9
67
|
source = join(import.meta.dir, "..", "pi-extensions", "agency.ts"),
|
|
10
68
|
destination = piExtensionPath(),
|
|
11
|
-
) => {
|
|
69
|
+
): Promise<LifecycleResult> => {
|
|
12
70
|
await mkdir(dirname(destination), { recursive: true })
|
|
13
|
-
await
|
|
71
|
+
const sourceContents = await readFile(source)
|
|
72
|
+
const sourceDigest = digest(sourceContents)
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await copyFile(source, destination, constants.COPYFILE_EXCL)
|
|
76
|
+
try {
|
|
77
|
+
await writeOwnership(destination, sourceDigest, true)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
await rm(destination, { force: true })
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
81
|
+
return { status: "skipped", destination }
|
|
82
|
+
}
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
return { status: "installed", destination }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ownership = await readOwnership(destination)
|
|
91
|
+
const destinationFile = await lstat(destination)
|
|
92
|
+
if (!destinationFile.isFile() || destinationFile.isSymbolicLink()) {
|
|
93
|
+
return { status: "skipped", destination }
|
|
94
|
+
}
|
|
95
|
+
const destinationDigest = digest(await readFile(destination))
|
|
96
|
+
if (!ownership || ownership.sha256 !== destinationDigest) {
|
|
97
|
+
return { status: "skipped", destination }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await copyFile(source, destination)
|
|
101
|
+
await writeOwnership(destination, sourceDigest)
|
|
102
|
+
return { status: "updated", destination }
|
|
14
103
|
}
|
|
15
104
|
|
|
16
|
-
export const uninstallPiExtension = async (
|
|
17
|
-
|
|
105
|
+
export const uninstallPiExtension = async (
|
|
106
|
+
destination = piExtensionPath(),
|
|
107
|
+
): Promise<LifecycleResult> => {
|
|
108
|
+
const ownership = await readOwnership(destination)
|
|
109
|
+
let destinationContents: Buffer
|
|
110
|
+
try {
|
|
111
|
+
const destinationFile = await lstat(destination)
|
|
112
|
+
if (!destinationFile.isFile() || destinationFile.isSymbolicLink()) {
|
|
113
|
+
return { status: "skipped", destination }
|
|
114
|
+
}
|
|
115
|
+
destinationContents = await readFile(destination)
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
118
|
+
await rm(piExtensionOwnershipPath(destination), { force: true })
|
|
119
|
+
return { status: "absent", destination }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!ownership || ownership.sha256 !== digest(destinationContents)) {
|
|
123
|
+
return { status: "skipped", destination }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await rm(destination)
|
|
127
|
+
await rm(piExtensionOwnershipPath(destination), { force: true })
|
|
128
|
+
return { status: "removed", destination }
|
|
18
129
|
}
|
|
19
130
|
|
|
20
131
|
if (import.meta.main) {
|
|
21
132
|
const command = process.argv[2] ?? "install"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
133
|
+
const result =
|
|
134
|
+
command === "install"
|
|
135
|
+
? await installPiExtension()
|
|
136
|
+
: command === "uninstall"
|
|
137
|
+
? await uninstallPiExtension()
|
|
138
|
+
: undefined
|
|
139
|
+
if (!result)
|
|
140
|
+
throw new Error(`Unknown Pi extension lifecycle command: ${command}`)
|
|
141
|
+
if (result.status === "skipped") {
|
|
142
|
+
console.warn(
|
|
143
|
+
`Agency left the existing Pi extension unchanged because it cannot prove ownership: ${result.destination}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
25
146
|
}
|
|
@@ -5,6 +5,7 @@ import { PhaseService } from "./PhaseService"
|
|
|
5
5
|
import { TaskService } from "./TaskService"
|
|
6
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
7
|
import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
|
|
8
|
+
import { isDirtyGitStatus } from "./VersionControlService"
|
|
8
9
|
|
|
9
10
|
type PushCategory =
|
|
10
11
|
| "precondition"
|
|
@@ -374,11 +375,11 @@ const publishGit = (
|
|
|
374
375
|
const status = yield* git(
|
|
375
376
|
fs,
|
|
376
377
|
checkout,
|
|
377
|
-
["status", "--porcelain=v1"],
|
|
378
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
378
379
|
"Failed to inspect Git status",
|
|
379
380
|
"inspect",
|
|
380
381
|
)
|
|
381
|
-
if (status.stdout
|
|
382
|
+
if (isDirtyGitStatus(status.stdout)) {
|
|
382
383
|
return yield* pushError(
|
|
383
384
|
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
384
385
|
"precondition",
|
|
@@ -58,6 +58,17 @@ export interface VersionControlBackend {
|
|
|
58
58
|
) => Effect.Effect<void, unknown, any>
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
const ignoredUntrackedGitPath = /^(?:.*\/)?\.DS_Store$/
|
|
62
|
+
|
|
63
|
+
export const isDirtyGitStatus = (output: string): boolean =>
|
|
64
|
+
(output.includes("\0") ? output.split("\0") : output.split(/\r?\n/)).some(
|
|
65
|
+
(line) => {
|
|
66
|
+
if (!line) return false
|
|
67
|
+
if (!line.startsWith("?? ")) return true
|
|
68
|
+
return !ignoredUntrackedGitPath.test(line.slice(3))
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
61
72
|
class VersionControlError extends Data.TaggedError("VersionControlError")<{
|
|
62
73
|
readonly message: string
|
|
63
74
|
}> {}
|
|
@@ -267,10 +278,20 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
|
|
|
267
278
|
Effect.gen(function* () {
|
|
268
279
|
const fs = yield* FileSystemService
|
|
269
280
|
const result = yield* fs.runCommand(
|
|
270
|
-
[
|
|
281
|
+
[
|
|
282
|
+
"git",
|
|
283
|
+
"-C",
|
|
284
|
+
workspacePath,
|
|
285
|
+
"status",
|
|
286
|
+
"--porcelain=v1",
|
|
287
|
+
"-z",
|
|
288
|
+
"--untracked-files=all",
|
|
289
|
+
],
|
|
271
290
|
{ captureOutput: true },
|
|
272
291
|
)
|
|
273
|
-
return result.exitCode === 0
|
|
292
|
+
return result.exitCode === 0
|
|
293
|
+
? isDirtyGitStatus(result.stdout)
|
|
294
|
+
: null
|
|
274
295
|
}),
|
|
275
296
|
fetch: (repositoryPath, remote = "origin", branch) =>
|
|
276
297
|
Effect.gen(function* () {
|
|
@@ -18,7 +18,10 @@ import type { BaseCommandOptions } from "../utils/command"
|
|
|
18
18
|
import { createLoggers } from "../utils/effect"
|
|
19
19
|
import { createProgress } from "../utils/progress"
|
|
20
20
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isDirtyGitStatus,
|
|
23
|
+
VersionControlService,
|
|
24
|
+
} from "./VersionControlService"
|
|
22
25
|
import type {
|
|
23
26
|
RegisteredWorkspace,
|
|
24
27
|
VersionControlBackend,
|
|
@@ -75,7 +78,7 @@ interface GitWorktree {
|
|
|
75
78
|
readonly branch?: string
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
const managedWorktreeIgnorePatterns = ["/.worktree.lock"] as const
|
|
81
|
+
const managedWorktreeIgnorePatterns = ["/.worktree.lock", ".DS_Store"] as const
|
|
79
82
|
const vcs = "git" as const
|
|
80
83
|
|
|
81
84
|
interface WorktreeOwner {
|
|
@@ -550,13 +553,21 @@ const inspectExecution = (
|
|
|
550
553
|
const status =
|
|
551
554
|
actual && actualExists
|
|
552
555
|
? yield* fs.runCommand(
|
|
553
|
-
[
|
|
556
|
+
[
|
|
557
|
+
"git",
|
|
558
|
+
"-C",
|
|
559
|
+
actual.path,
|
|
560
|
+
"status",
|
|
561
|
+
"--porcelain=v1",
|
|
562
|
+
"-z",
|
|
563
|
+
"--untracked-files=all",
|
|
564
|
+
],
|
|
554
565
|
{ captureOutput: true },
|
|
555
566
|
)
|
|
556
567
|
: null
|
|
557
568
|
const dirty = status
|
|
558
569
|
? status.exitCode === 0
|
|
559
|
-
? status.stdout
|
|
570
|
+
? isDirtyGitStatus(status.stdout)
|
|
560
571
|
: null
|
|
561
572
|
: null
|
|
562
573
|
const expectedRef = "branch" in checkout ? checkout.branch : checkout.ref
|
|
@@ -2076,7 +2087,15 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2076
2087
|
}
|
|
2077
2088
|
if (checkoutExists) {
|
|
2078
2089
|
const status = yield* fs.runCommand(
|
|
2079
|
-
[
|
|
2090
|
+
[
|
|
2091
|
+
"git",
|
|
2092
|
+
"-C",
|
|
2093
|
+
checkoutPath,
|
|
2094
|
+
"status",
|
|
2095
|
+
"--porcelain=v1",
|
|
2096
|
+
"-z",
|
|
2097
|
+
"--untracked-files=all",
|
|
2098
|
+
],
|
|
2080
2099
|
{ captureOutput: true },
|
|
2081
2100
|
)
|
|
2082
2101
|
if (status.exitCode !== 0) {
|
|
@@ -2084,7 +2103,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2084
2103
|
message: `Failed to remove worktree for '${alias}': checkout cleanliness could not be verified: ${status.stderr.trim() || `git status exited with code ${status.exitCode}`}`,
|
|
2085
2104
|
})
|
|
2086
2105
|
}
|
|
2087
|
-
if (status.stdout
|
|
2106
|
+
if (isDirtyGitStatus(status.stdout)) {
|
|
2088
2107
|
return yield* new WorktreeError({
|
|
2089
2108
|
message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
|
|
2090
2109
|
})
|