@markjaquith/agency 3.9.3 → 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 +1 -1
- package/pi-extensions/agency.ts +43 -10
- package/scripts/install-pi-extension.ts +129 -8
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
|
}
|