@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.9.2",
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
  }
@@ -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.length > 0) {
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
- ["git", "-C", workspacePath, "status", "--porcelain"],
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 ? result.stdout.length > 0 : null
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 { VersionControlService } from "./VersionControlService"
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
- ["git", "-C", actual.path, "status", "--porcelain"],
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.length > 0
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
- ["git", "-C", checkoutPath, "status", "--porcelain"],
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.trim()) {
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
  })