@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.9.3",
3
+ "version": "3.9.4",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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
- name: "resources_discover" | "before_agent_start",
12
- handler: (...args: any[]) => unknown,
13
- ) => void
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<string, Promise<AgencyContext | undefined>>()
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
- let context = contexts.get(key)
87
- if (!context) {
88
- context = agencyContext(pi, key).catch(() => undefined)
89
- contexts.set(key, context)
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
- return context
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 { cp, mkdir, rm } from "node:fs/promises"
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 cp(source, destination)
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 (destination = piExtensionPath()) => {
17
- await rm(destination, { force: true })
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
- if (command === "install") await installPiExtension()
23
- else if (command === "uninstall") await uninstallPiExtension()
24
- else throw new Error(`Unknown Pi extension lifecycle command: ${command}`)
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
  }