@agimon-ai/doompi-git 0.0.1-alpha.0

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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/adapters/channel/worktreeChannel.cjs +2 -0
  5. package/dist/adapters/channel/worktreeChannel.cjs.map +1 -0
  6. package/dist/adapters/channel/worktreeChannel.d.cts +40 -0
  7. package/dist/adapters/channel/worktreeChannel.d.cts.map +1 -0
  8. package/dist/adapters/channel/worktreeChannel.d.mts +40 -0
  9. package/dist/adapters/channel/worktreeChannel.d.mts.map +1 -0
  10. package/dist/adapters/channel/worktreeChannel.mjs +2 -0
  11. package/dist/adapters/channel/worktreeChannel.mjs.map +1 -0
  12. package/dist/adapters/filesystem/atomicJson.cjs +2 -0
  13. package/dist/adapters/filesystem/atomicJson.cjs.map +1 -0
  14. package/dist/adapters/filesystem/atomicJson.mjs +2 -0
  15. package/dist/adapters/filesystem/atomicJson.mjs.map +1 -0
  16. package/dist/adapters/filesystem/paths.cjs +2 -0
  17. package/dist/adapters/filesystem/paths.cjs.map +1 -0
  18. package/dist/adapters/filesystem/paths.mjs +2 -0
  19. package/dist/adapters/filesystem/paths.mjs.map +1 -0
  20. package/dist/adapters/hub/hubClient.cjs +2 -0
  21. package/dist/adapters/hub/hubClient.cjs.map +1 -0
  22. package/dist/adapters/hub/hubClient.d.cts +1 -0
  23. package/dist/adapters/hub/hubClient.d.mts +1 -0
  24. package/dist/adapters/hub/hubClient.mjs +2 -0
  25. package/dist/adapters/hub/hubClient.mjs.map +1 -0
  26. package/dist/adapters/pi/extension.cjs +2 -0
  27. package/dist/adapters/pi/extension.cjs.map +1 -0
  28. package/dist/adapters/pi/extension.d.cts +9 -0
  29. package/dist/adapters/pi/extension.d.cts.map +1 -0
  30. package/dist/adapters/pi/extension.d.mts +9 -0
  31. package/dist/adapters/pi/extension.d.mts.map +1 -0
  32. package/dist/adapters/pi/extension.mjs +2 -0
  33. package/dist/adapters/pi/extension.mjs.map +1 -0
  34. package/dist/adapters/pi/extensions/runWorktreeTool.cjs +15 -0
  35. package/dist/adapters/pi/extensions/runWorktreeTool.cjs.map +1 -0
  36. package/dist/adapters/pi/extensions/runWorktreeTool.mjs +15 -0
  37. package/dist/adapters/pi/extensions/runWorktreeTool.mjs.map +1 -0
  38. package/dist/adapters/web/worktreesChannel.cjs +2 -0
  39. package/dist/adapters/web/worktreesChannel.cjs.map +1 -0
  40. package/dist/adapters/web/worktreesChannel.d.cts +12 -0
  41. package/dist/adapters/web/worktreesChannel.d.cts.map +1 -0
  42. package/dist/adapters/web/worktreesChannel.d.mts +12 -0
  43. package/dist/adapters/web/worktreesChannel.d.mts.map +1 -0
  44. package/dist/adapters/web/worktreesChannel.mjs +2 -0
  45. package/dist/adapters/web/worktreesChannel.mjs.map +1 -0
  46. package/dist/adapters/worktree/dependencyInstall.cjs +2 -0
  47. package/dist/adapters/worktree/dependencyInstall.cjs.map +1 -0
  48. package/dist/adapters/worktree/dependencyInstall.mjs +2 -0
  49. package/dist/adapters/worktree/dependencyInstall.mjs.map +1 -0
  50. package/dist/adapters/worktree/gitCli.cjs +4 -0
  51. package/dist/adapters/worktree/gitCli.cjs.map +1 -0
  52. package/dist/adapters/worktree/gitCli.mjs +4 -0
  53. package/dist/adapters/worktree/gitCli.mjs.map +1 -0
  54. package/dist/adapters/worktree/repositoryIdentity.cjs +2 -0
  55. package/dist/adapters/worktree/repositoryIdentity.cjs.map +1 -0
  56. package/dist/adapters/worktree/repositoryIdentity.mjs +2 -0
  57. package/dist/adapters/worktree/repositoryIdentity.mjs.map +1 -0
  58. package/dist/adapters/worktree/worktreeOperations.cjs +2 -0
  59. package/dist/adapters/worktree/worktreeOperations.cjs.map +1 -0
  60. package/dist/adapters/worktree/worktreeOperations.d.cts +32 -0
  61. package/dist/adapters/worktree/worktreeOperations.d.cts.map +1 -0
  62. package/dist/adapters/worktree/worktreeOperations.d.mts +32 -0
  63. package/dist/adapters/worktree/worktreeOperations.d.mts.map +1 -0
  64. package/dist/adapters/worktree/worktreeOperations.mjs +2 -0
  65. package/dist/adapters/worktree/worktreeOperations.mjs.map +1 -0
  66. package/dist/adapters/worktree/worktreeRegistry.cjs +2 -0
  67. package/dist/adapters/worktree/worktreeRegistry.cjs.map +1 -0
  68. package/dist/adapters/worktree/worktreeRegistry.mjs +2 -0
  69. package/dist/adapters/worktree/worktreeRegistry.mjs.map +1 -0
  70. package/dist/commands/doomGitCommand.cjs +2 -0
  71. package/dist/commands/doomGitCommand.cjs.map +1 -0
  72. package/dist/commands/doomGitCommand.mjs +2 -0
  73. package/dist/commands/doomGitCommand.mjs.map +1 -0
  74. package/dist/container/index.cjs +2 -0
  75. package/dist/container/index.cjs.map +1 -0
  76. package/dist/container/index.d.cts +6 -0
  77. package/dist/container/index.d.cts.map +1 -0
  78. package/dist/container/index.d.mts +6 -0
  79. package/dist/container/index.d.mts.map +1 -0
  80. package/dist/container/index.mjs +2 -0
  81. package/dist/container/index.mjs.map +1 -0
  82. package/dist/extensions/pi.cjs +1 -0
  83. package/dist/extensions/pi.d.cts +2 -0
  84. package/dist/extensions/pi.d.mts +2 -0
  85. package/dist/extensions/pi.mjs +1 -0
  86. package/dist/hubApi.cjs +2 -0
  87. package/dist/hubApi.cjs.map +1 -0
  88. package/dist/hubApi.d.cts +6 -0
  89. package/dist/hubApi.d.cts.map +1 -0
  90. package/dist/hubApi.d.mts +6 -0
  91. package/dist/hubApi.d.mts.map +1 -0
  92. package/dist/hubApi.mjs +2 -0
  93. package/dist/hubApi.mjs.map +1 -0
  94. package/dist/index.cjs +1 -0
  95. package/dist/index.d.cts +5 -0
  96. package/dist/index.d.mts +5 -0
  97. package/dist/index.mjs +1 -0
  98. package/dist/schemas/runWorktreeTool.cjs +2 -0
  99. package/dist/schemas/runWorktreeTool.cjs.map +1 -0
  100. package/dist/schemas/runWorktreeTool.mjs +2 -0
  101. package/dist/schemas/runWorktreeTool.mjs.map +1 -0
  102. package/dist/services/extensionService.cjs +2 -0
  103. package/dist/services/extensionService.cjs.map +1 -0
  104. package/dist/services/extensionService.d.cts +8 -0
  105. package/dist/services/extensionService.d.cts.map +1 -0
  106. package/dist/services/extensionService.d.mts +8 -0
  107. package/dist/services/extensionService.d.mts.map +1 -0
  108. package/dist/services/extensionService.mjs +2 -0
  109. package/dist/services/extensionService.mjs.map +1 -0
  110. package/dist/services/support/errors.cjs +2 -0
  111. package/dist/services/support/errors.cjs.map +1 -0
  112. package/dist/services/support/errors.mjs +2 -0
  113. package/dist/services/support/errors.mjs.map +1 -0
  114. package/dist/services/worktreeNaming.cjs +2 -0
  115. package/dist/services/worktreeNaming.cjs.map +1 -0
  116. package/dist/services/worktreeNaming.d.cts +27 -0
  117. package/dist/services/worktreeNaming.d.cts.map +1 -0
  118. package/dist/services/worktreeNaming.d.mts +27 -0
  119. package/dist/services/worktreeNaming.d.mts.map +1 -0
  120. package/dist/services/worktreeNaming.mjs +2 -0
  121. package/dist/services/worktreeNaming.mjs.map +1 -0
  122. package/dist/types/extension.d.cts +17 -0
  123. package/dist/types/extension.d.cts.map +1 -0
  124. package/dist/types/extension.d.mts +17 -0
  125. package/dist/types/extension.d.mts.map +1 -0
  126. package/dist/types/webWorktrees.cjs +2 -0
  127. package/dist/types/webWorktrees.cjs.map +1 -0
  128. package/dist/types/webWorktrees.mjs +2 -0
  129. package/dist/types/webWorktrees.mjs.map +1 -0
  130. package/dist/types/worktreeRegistry.d.cts +41 -0
  131. package/dist/types/worktreeRegistry.d.cts.map +1 -0
  132. package/dist/types/worktreeRegistry.d.mts +41 -0
  133. package/dist/types/worktreeRegistry.d.mts.map +1 -0
  134. package/dist/webHub.cjs +1 -0
  135. package/dist/webHub.d.cts +2 -0
  136. package/dist/webHub.d.mts +2 -0
  137. package/dist/webHub.mjs +1 -0
  138. package/llms.txt +11 -0
  139. package/package.json +128 -0
  140. package/src/exports/webClient.ts +1 -0
  141. package/src/prompts/doompi-use-git/SKILL.md +64 -0
  142. package/src/types/webWorktrees.ts +31 -0
  143. package/src/types/worktreeRegistry.ts +69 -0
  144. package/src/web/components/RunWorktreeToolMessage.tsx +37 -0
  145. package/src/web/components/WorktreesActivitySection.tsx +62 -0
  146. package/src/web/components/WorktreesPanel.tsx +109 -0
  147. package/src/web/components/toolRenderers.ts +7 -0
  148. package/src/web/index.ts +27 -0
  149. package/src/web/stores/worktreesActivityStore.ts +78 -0
  150. package/src/web/stores/worktreesStore.ts +114 -0
  151. package/src/web/tsconfig.json +22 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vuong Ngo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @agimon-ai/doompi-git
2
+
3
+ Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent
4
+
5
+ This package is a composable [DoomPi](https://www.npmjs.com/package/@agimon-ai/doompi) subsystem. Use it with the distribution or install it independently in [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22.19.0 or newer
10
+ - `@earendil-works/pi-coding-agent` 0.85.0
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:@agimon-ai/doompi-git
16
+ ```
17
+
18
+ The package declares its Pi extension entry, so Pi loads it after installation. DoomPi users can include the package through their normal profile and domain composition instead.
19
+
20
+ ## Use
21
+
22
+ Run the registered command in Pi:
23
+
24
+ ```text
25
+ /doom-git
26
+ ```
27
+
28
+ Show git worktrees owned by this session
29
+
30
+ ## Package behavior
31
+
32
+ > Before publishing, replace this paragraph with one concrete, copy-pasteable example of the package-specific input, output, configuration, or workflow that is not already demonstrated by the generated command.
33
+
34
+ ## Help
35
+
36
+ The published package includes `llms.txt` and the package-owned `src/prompts/doompi-use-git/SKILL.md`. When the DoomPi Help minor mode is active, the extension contributes `doompi-use-git` to its live Help catalog. The contribution follows Help provider replacement and is withdrawn when the extension shuts down.
37
+
38
+ Help remains optional. Loading the standalone Pi extension does not require DoomPi Help or any other DoomPi runtime service.
39
+
40
+ To add another package-owned Help prompt, use the `scaffold-doom-prompt` feature with a `doompi-author-*` or `doompi-use-*` name and a concise description. Then link the generated `SKILL.md` from `llms.txt` and register a matching descriptor through the optional `DOOM_HELP_SERVICE` injection in the Pi adapter. Keep prompts as published resources under `src/prompts`; do not export them or copy them into `dist`.
41
+
42
+ ## Public API
43
+
44
+ ```ts
45
+ import { DefaultGitExtensionService, activateGitExtension } from '@agimon-ai/doompi-git';
46
+ ```
47
+
48
+ The Pi host entry is also available at:
49
+
50
+ ```text
51
+ @agimon-ai/doompi-git/extensions/pi
52
+ ```
53
+
54
+ The service layer is host-neutral. The Pi entrypoint owns only command registration and its runtime-scoped installation guard.
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ pnpm build
60
+ pnpm typecheck
61
+ pnpm test
62
+ pnpm lint
63
+ pnpm exec vibe-lint check .
64
+ npm pack --dry-run
65
+ ```
66
+
67
+ Maintained by [Agimon](https://agimon.ai/about).
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1 @@
1
+ var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},s=(n,r,s)=>(s=n==null?{}:e(i(n)),o(r||!n||!n.__esModule||!a.call(n,`default`)?t(s,`default`,{value:n,enumerable:!0}):s,n));exports.__toESM=s;
@@ -0,0 +1,2 @@
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:crypto"),r=require("node:path");r=e.__toESM(r,1);const i=`.json`,a=`.claim-`;let o=-1,s=0;function c(e,t){return r.default.join(e,t)}function l(e){let t;try{t=JSON.parse(e)}catch{return}if(typeof t!=`object`||!t)return;let n=t;if(n.version===1&&(n.from===`parent`||n.from===`child`)&&typeof n.text==`string`&&n.text!==``&&typeof n.sentAt==`string`)return{version:1,from:n.from,text:n.text,sentAt:n.sentAt}}function u(){let e=Date.now();return e===o?s+=1:(o=e,s=0),`${String(e).padStart(14,`0`)}-${String(s).padStart(6,`0`)}-${(0,n.randomBytes)(6).toString(`hex`)}`}function d(e){return{send(n,a){if(Buffer.byteLength(a,`utf8`)>65536)throw Error(`A message may not exceed 65536 bytes.`);let o=c(e,n);t.default.mkdirSync(o,{recursive:!0,mode:448});let s={version:1,from:n===`parent`?`child`:`parent`,text:a,sentAt:new Date().toISOString()},l=u(),d=r.default.join(o,`.tmp-${l}`);t.default.writeFileSync(d,JSON.stringify(s),{mode:384}),t.default.renameSync(d,r.default.join(o,`${l}${i}`))},receive(n){let o=c(e,n),s;try{s=t.default.readdirSync(o)}catch{return[]}let u=[];for(let e of s.filter(e=>e.endsWith(i)).sort()){let n=r.default.join(o,`${a}${String(process.pid)}-${e}`);try{t.default.renameSync(r.default.join(o,e),n)}catch{continue}let i;try{i=l(t.default.readFileSync(n,`utf8`))}catch{i=void 0}i!==void 0&&(u.push(i),t.default.rmSync(n,{force:!0}))}return u},sweep(n=Date.now()){let i=0;for(let o of[`parent`,`child`]){let s=c(e,o),l;try{l=t.default.readdirSync(s)}catch{continue}for(let e of l.filter(e=>e.startsWith(a))){let a=r.default.join(s,e);try{if(n-t.default.statSync(a).mtimeMs<6e4)continue;let o=e.slice(e.indexOf(`-`,7)+1);t.default.renameSync(a,r.default.join(s,o)),i+=1}catch{}}}return i}}}exports.createWorktreeChannel=d;
2
+ //# sourceMappingURL=worktreeChannel.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktreeChannel.cjs","names":["path","randomBytes","fs"],"sources":["../../../src/adapters/channel/worktreeChannel.ts"],"sourcesContent":["/**\n * Messages between a parent session and one worktree session.\n *\n * A two-party mirror of doompi-team's channel, kept private to this package\n * rather than imported: a layer must not depend on another layer, and two\n * parties need none of the membership, heartbeat or fanout machinery that makes\n * that module large.\n *\n * THE INVARIANTS WORTH COPYING, AND WHAT BREAKS WITHOUT THEM:\n *\n * 1. Write to a temp name and rename into place. A reader polling a directory\n * will otherwise read a file that is still being written and parse half a\n * message.\n * 2. Claim by rename before reading. Rename is atomic, so exactly one reader\n * wins a message even when a session polls twice concurrently. Reading first\n * and deleting after delivers the same message twice.\n * 3. Deliver, then delete. Deleting first loses the message permanently if the\n * delivery throws; a duplicate is recoverable, a loss is not.\n * 4. A claim that outlives its reader is swept. Otherwise a crash mid-delivery\n * strands the message in a claimed state nobody will ever look at again.\n *\n * AVOID:\n * - Treating an atomic write as a lock. It prevents a torn read, nothing else.\n * - Deleting a file that failed to parse. It may belong to a newer build, and\n * destroying it turns a version skew into data loss.\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { randomBytes } from 'node:crypto';\n\nexport const CHANNEL_MESSAGE_VERSION = 1;\n\nconst PRIVATE_DIRECTORY_MODE = 0o700;\nconst PRIVATE_FILE_MODE = 0o600;\nconst MESSAGE_SUFFIX = '.json';\nconst CLAIM_PREFIX = '.claim-';\nconst MAX_MESSAGE_BYTES = 64 * 1024;\n/** A claim older than this belonged to a reader that is not coming back. */\nconst CLAIM_STALE_MS = 60_000;\nconst MESSAGE_SEQUENCE_WIDTH = 6;\nlet lastMessageTimestamp = -1;\nlet messageSequence = 0;\n\n/** Which side of the pair a message is for. */\nexport type ChannelParty = 'parent' | 'child';\n\nexport interface ChannelMessage {\n version: typeof CHANNEL_MESSAGE_VERSION;\n /** Who sent it, so a reader never has to infer it from the directory. */\n from: ChannelParty;\n text: string;\n sentAt: string;\n}\n\nexport interface WorktreeChannel {\n send(to: ChannelParty, text: string): void;\n /** Claims and returns everything waiting for a party, oldest first. */\n receive(as: ChannelParty): ChannelMessage[];\n /** Releases claims abandoned by a reader that died mid-delivery. */\n sweep(now?: number): number;\n}\n\nfunction inboxDirectory(root: string, party: ChannelParty): string {\n return path.join(root, party);\n}\n\nfunction parse(raw: string): ChannelMessage | undefined {\n let value: unknown;\n try {\n value = JSON.parse(raw);\n } catch {\n return undefined;\n }\n if (typeof value !== 'object' || value === null) return undefined;\n const record = value as Partial<ChannelMessage>;\n if (record.version !== CHANNEL_MESSAGE_VERSION) return undefined;\n if (record.from !== 'parent' && record.from !== 'child') return undefined;\n if (typeof record.text !== 'string' || record.text === '') return undefined;\n if (typeof record.sentAt !== 'string') return undefined;\n return { version: CHANNEL_MESSAGE_VERSION, from: record.from, text: record.text, sentAt: record.sentAt };\n}\n\nfunction nextMessageName(): string {\n const timestamp = Date.now();\n if (timestamp === lastMessageTimestamp) {\n messageSequence += 1;\n } else {\n lastMessageTimestamp = timestamp;\n messageSequence = 0;\n }\n return `${String(timestamp).padStart(14, '0')}-${String(messageSequence).padStart(MESSAGE_SEQUENCE_WIDTH, '0')}-${randomBytes(6).toString('hex')}`;\n}\n\n/** The channel rooted at a directory, created on demand. */\nexport function createWorktreeChannel(root: string): WorktreeChannel {\n return {\n send(to, text) {\n if (Buffer.byteLength(text, 'utf8') > MAX_MESSAGE_BYTES) {\n throw new Error(`A message may not exceed ${String(MAX_MESSAGE_BYTES)} bytes.`);\n }\n const directory = inboxDirectory(root, to);\n fs.mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });\n const message: ChannelMessage = {\n version: CHANNEL_MESSAGE_VERSION,\n from: to === 'parent' ? 'child' : 'parent',\n text,\n sentAt: new Date().toISOString(),\n };\n // A process-local sequence preserves send order when several messages share\n // a millisecond. The random suffix still prevents cross-process collisions.\n const name = nextMessageName();\n const temporary = path.join(directory, `.tmp-${name}`);\n fs.writeFileSync(temporary, JSON.stringify(message), { mode: PRIVATE_FILE_MODE });\n fs.renameSync(temporary, path.join(directory, `${name}${MESSAGE_SUFFIX}`));\n },\n\n receive(as) {\n const directory = inboxDirectory(root, as);\n let names: string[];\n try {\n names = fs.readdirSync(directory);\n } catch {\n return [];\n }\n const delivered: ChannelMessage[] = [];\n for (const name of names.filter((entry) => entry.endsWith(MESSAGE_SUFFIX)).sort()) {\n const claim = path.join(directory, `${CLAIM_PREFIX}${String(process.pid)}-${name}`);\n try {\n // Whoever wins this rename owns the message. A loser gets ENOENT and\n // simply moves on, which is why this is not wrapped in an existence\n // check: the check and the rename would not be atomic together.\n fs.renameSync(path.join(directory, name), claim);\n } catch {\n continue;\n }\n let message: ChannelMessage | undefined;\n try {\n message = parse(fs.readFileSync(claim, 'utf8'));\n } catch {\n message = undefined;\n }\n if (message === undefined) {\n // Left claimed rather than deleted. An unreadable message may come\n // from a newer build, and the sweep will surface it again rather than\n // this reader destroying something it did not understand.\n continue;\n }\n delivered.push(message);\n fs.rmSync(claim, { force: true });\n }\n return delivered;\n },\n\n sweep(now = Date.now()) {\n let released = 0;\n for (const party of ['parent', 'child'] as const) {\n const directory = inboxDirectory(root, party);\n let names: string[];\n try {\n names = fs.readdirSync(directory);\n } catch {\n continue;\n }\n for (const name of names.filter((entry) => entry.startsWith(CLAIM_PREFIX))) {\n const claimed = path.join(directory, name);\n try {\n if (now - fs.statSync(claimed).mtimeMs < CLAIM_STALE_MS) continue;\n // Returned to the inbox rather than deleted, because the message\n // was claimed but never delivered.\n const original = name.slice(name.indexOf('-', CLAIM_PREFIX.length) + 1);\n fs.renameSync(claimed, path.join(directory, original));\n released += 1;\n } catch {\n // Another sweeper got there first.\n }\n }\n }\n return released;\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ //#region src/adapters/channel/worktreeChannel.d.ts
2
+ /**
3
+ * Messages between a parent session and one worktree session.
4
+ *
5
+ * A two-party mirror of doompi-team's channel, kept private to this package
6
+ * rather than imported: a layer must not depend on another layer, and two
7
+ * parties need none of the membership, heartbeat or fanout machinery that makes
8
+ * that module large.
9
+ *
10
+ * THE INVARIANTS WORTH COPYING, AND WHAT BREAKS WITHOUT THEM:
11
+ *
12
+ * 1. Write to a temp name and rename into place. A reader polling a directory
13
+ * will otherwise read a file that is still being written and parse half a
14
+ * message.
15
+ * 2. Claim by rename before reading. Rename is atomic, so exactly one reader
16
+ * wins a message even when a session polls twice concurrently. Reading first
17
+ * and deleting after delivers the same message twice.
18
+ * 3. Deliver, then delete. Deleting first loses the message permanently if the
19
+ * delivery throws; a duplicate is recoverable, a loss is not.
20
+ * 4. A claim that outlives its reader is swept. Otherwise a crash mid-delivery
21
+ * strands the message in a claimed state nobody will ever look at again.
22
+ *
23
+ * AVOID:
24
+ * - Treating an atomic write as a lock. It prevents a torn read, nothing else.
25
+ * - Deleting a file that failed to parse. It may belong to a newer build, and
26
+ * destroying it turns a version skew into data loss.
27
+ */
28
+ declare const CHANNEL_MESSAGE_VERSION = 1;
29
+ /** Which side of the pair a message is for. */
30
+ type ChannelParty = 'parent' | 'child';
31
+ interface ChannelMessage {
32
+ version: typeof CHANNEL_MESSAGE_VERSION;
33
+ /** Who sent it, so a reader never has to infer it from the directory. */
34
+ from: ChannelParty;
35
+ text: string;
36
+ sentAt: string;
37
+ }
38
+ //#endregion
39
+ export { CHANNEL_MESSAGE_VERSION, ChannelMessage, ChannelParty };
40
+ //# sourceMappingURL=worktreeChannel.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktreeChannel.d.cts","names":[],"sources":["../../../src/adapters/channel/worktreeChannel.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;cA+Ba;;KAcD;UAEK;EACf,gBAAgB;;EAEhB,MAAM;EACN;EACA"}
@@ -0,0 +1,40 @@
1
+ //#region src/adapters/channel/worktreeChannel.d.ts
2
+ /**
3
+ * Messages between a parent session and one worktree session.
4
+ *
5
+ * A two-party mirror of doompi-team's channel, kept private to this package
6
+ * rather than imported: a layer must not depend on another layer, and two
7
+ * parties need none of the membership, heartbeat or fanout machinery that makes
8
+ * that module large.
9
+ *
10
+ * THE INVARIANTS WORTH COPYING, AND WHAT BREAKS WITHOUT THEM:
11
+ *
12
+ * 1. Write to a temp name and rename into place. A reader polling a directory
13
+ * will otherwise read a file that is still being written and parse half a
14
+ * message.
15
+ * 2. Claim by rename before reading. Rename is atomic, so exactly one reader
16
+ * wins a message even when a session polls twice concurrently. Reading first
17
+ * and deleting after delivers the same message twice.
18
+ * 3. Deliver, then delete. Deleting first loses the message permanently if the
19
+ * delivery throws; a duplicate is recoverable, a loss is not.
20
+ * 4. A claim that outlives its reader is swept. Otherwise a crash mid-delivery
21
+ * strands the message in a claimed state nobody will ever look at again.
22
+ *
23
+ * AVOID:
24
+ * - Treating an atomic write as a lock. It prevents a torn read, nothing else.
25
+ * - Deleting a file that failed to parse. It may belong to a newer build, and
26
+ * destroying it turns a version skew into data loss.
27
+ */
28
+ declare const CHANNEL_MESSAGE_VERSION = 1;
29
+ /** Which side of the pair a message is for. */
30
+ type ChannelParty = 'parent' | 'child';
31
+ interface ChannelMessage {
32
+ version: typeof CHANNEL_MESSAGE_VERSION;
33
+ /** Who sent it, so a reader never has to infer it from the directory. */
34
+ from: ChannelParty;
35
+ text: string;
36
+ sentAt: string;
37
+ }
38
+ //#endregion
39
+ export { CHANNEL_MESSAGE_VERSION, ChannelMessage, ChannelParty };
40
+ //# sourceMappingURL=worktreeChannel.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktreeChannel.d.mts","names":[],"sources":["../../../src/adapters/channel/worktreeChannel.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;cA+Ba;;KAcD;UAEK;EACf,gBAAgB;;EAEhB,MAAM;EACN;EACA"}
@@ -0,0 +1,2 @@
1
+ import e from"node:fs";import{randomBytes as t}from"node:crypto";import n from"node:path";const r=`.json`,i=`.claim-`;let a=-1,o=0;function s(e,t){return n.join(e,t)}function c(e){let t;try{t=JSON.parse(e)}catch{return}if(typeof t!=`object`||!t)return;let n=t;if(n.version===1&&(n.from===`parent`||n.from===`child`)&&typeof n.text==`string`&&n.text!==``&&typeof n.sentAt==`string`)return{version:1,from:n.from,text:n.text,sentAt:n.sentAt}}function l(){let e=Date.now();return e===a?o+=1:(a=e,o=0),`${String(e).padStart(14,`0`)}-${String(o).padStart(6,`0`)}-${t(6).toString(`hex`)}`}function u(t){return{send(i,a){if(Buffer.byteLength(a,`utf8`)>65536)throw Error(`A message may not exceed 65536 bytes.`);let o=s(t,i);e.mkdirSync(o,{recursive:!0,mode:448});let c={version:1,from:i===`parent`?`child`:`parent`,text:a,sentAt:new Date().toISOString()},u=l(),d=n.join(o,`.tmp-${u}`);e.writeFileSync(d,JSON.stringify(c),{mode:384}),e.renameSync(d,n.join(o,`${u}${r}`))},receive(a){let o=s(t,a),l;try{l=e.readdirSync(o)}catch{return[]}let u=[];for(let t of l.filter(e=>e.endsWith(r)).sort()){let r=n.join(o,`${i}${String(process.pid)}-${t}`);try{e.renameSync(n.join(o,t),r)}catch{continue}let a;try{a=c(e.readFileSync(r,`utf8`))}catch{a=void 0}a!==void 0&&(u.push(a),e.rmSync(r,{force:!0}))}return u},sweep(r=Date.now()){let a=0;for(let o of[`parent`,`child`]){let c=s(t,o),l;try{l=e.readdirSync(c)}catch{continue}for(let t of l.filter(e=>e.startsWith(i))){let i=n.join(c,t);try{if(r-e.statSync(i).mtimeMs<6e4)continue;let o=t.slice(t.indexOf(`-`,7)+1);e.renameSync(i,n.join(c,o)),a+=1}catch{}}}return a}}}export{u as createWorktreeChannel};
2
+ //# sourceMappingURL=worktreeChannel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktreeChannel.mjs","names":[],"sources":["../../../src/adapters/channel/worktreeChannel.ts"],"sourcesContent":["/**\n * Messages between a parent session and one worktree session.\n *\n * A two-party mirror of doompi-team's channel, kept private to this package\n * rather than imported: a layer must not depend on another layer, and two\n * parties need none of the membership, heartbeat or fanout machinery that makes\n * that module large.\n *\n * THE INVARIANTS WORTH COPYING, AND WHAT BREAKS WITHOUT THEM:\n *\n * 1. Write to a temp name and rename into place. A reader polling a directory\n * will otherwise read a file that is still being written and parse half a\n * message.\n * 2. Claim by rename before reading. Rename is atomic, so exactly one reader\n * wins a message even when a session polls twice concurrently. Reading first\n * and deleting after delivers the same message twice.\n * 3. Deliver, then delete. Deleting first loses the message permanently if the\n * delivery throws; a duplicate is recoverable, a loss is not.\n * 4. A claim that outlives its reader is swept. Otherwise a crash mid-delivery\n * strands the message in a claimed state nobody will ever look at again.\n *\n * AVOID:\n * - Treating an atomic write as a lock. It prevents a torn read, nothing else.\n * - Deleting a file that failed to parse. It may belong to a newer build, and\n * destroying it turns a version skew into data loss.\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { randomBytes } from 'node:crypto';\n\nexport const CHANNEL_MESSAGE_VERSION = 1;\n\nconst PRIVATE_DIRECTORY_MODE = 0o700;\nconst PRIVATE_FILE_MODE = 0o600;\nconst MESSAGE_SUFFIX = '.json';\nconst CLAIM_PREFIX = '.claim-';\nconst MAX_MESSAGE_BYTES = 64 * 1024;\n/** A claim older than this belonged to a reader that is not coming back. */\nconst CLAIM_STALE_MS = 60_000;\nconst MESSAGE_SEQUENCE_WIDTH = 6;\nlet lastMessageTimestamp = -1;\nlet messageSequence = 0;\n\n/** Which side of the pair a message is for. */\nexport type ChannelParty = 'parent' | 'child';\n\nexport interface ChannelMessage {\n version: typeof CHANNEL_MESSAGE_VERSION;\n /** Who sent it, so a reader never has to infer it from the directory. */\n from: ChannelParty;\n text: string;\n sentAt: string;\n}\n\nexport interface WorktreeChannel {\n send(to: ChannelParty, text: string): void;\n /** Claims and returns everything waiting for a party, oldest first. */\n receive(as: ChannelParty): ChannelMessage[];\n /** Releases claims abandoned by a reader that died mid-delivery. */\n sweep(now?: number): number;\n}\n\nfunction inboxDirectory(root: string, party: ChannelParty): string {\n return path.join(root, party);\n}\n\nfunction parse(raw: string): ChannelMessage | undefined {\n let value: unknown;\n try {\n value = JSON.parse(raw);\n } catch {\n return undefined;\n }\n if (typeof value !== 'object' || value === null) return undefined;\n const record = value as Partial<ChannelMessage>;\n if (record.version !== CHANNEL_MESSAGE_VERSION) return undefined;\n if (record.from !== 'parent' && record.from !== 'child') return undefined;\n if (typeof record.text !== 'string' || record.text === '') return undefined;\n if (typeof record.sentAt !== 'string') return undefined;\n return { version: CHANNEL_MESSAGE_VERSION, from: record.from, text: record.text, sentAt: record.sentAt };\n}\n\nfunction nextMessageName(): string {\n const timestamp = Date.now();\n if (timestamp === lastMessageTimestamp) {\n messageSequence += 1;\n } else {\n lastMessageTimestamp = timestamp;\n messageSequence = 0;\n }\n return `${String(timestamp).padStart(14, '0')}-${String(messageSequence).padStart(MESSAGE_SEQUENCE_WIDTH, '0')}-${randomBytes(6).toString('hex')}`;\n}\n\n/** The channel rooted at a directory, created on demand. */\nexport function createWorktreeChannel(root: string): WorktreeChannel {\n return {\n send(to, text) {\n if (Buffer.byteLength(text, 'utf8') > MAX_MESSAGE_BYTES) {\n throw new Error(`A message may not exceed ${String(MAX_MESSAGE_BYTES)} bytes.`);\n }\n const directory = inboxDirectory(root, to);\n fs.mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });\n const message: ChannelMessage = {\n version: CHANNEL_MESSAGE_VERSION,\n from: to === 'parent' ? 'child' : 'parent',\n text,\n sentAt: new Date().toISOString(),\n };\n // A process-local sequence preserves send order when several messages share\n // a millisecond. The random suffix still prevents cross-process collisions.\n const name = nextMessageName();\n const temporary = path.join(directory, `.tmp-${name}`);\n fs.writeFileSync(temporary, JSON.stringify(message), { mode: PRIVATE_FILE_MODE });\n fs.renameSync(temporary, path.join(directory, `${name}${MESSAGE_SUFFIX}`));\n },\n\n receive(as) {\n const directory = inboxDirectory(root, as);\n let names: string[];\n try {\n names = fs.readdirSync(directory);\n } catch {\n return [];\n }\n const delivered: ChannelMessage[] = [];\n for (const name of names.filter((entry) => entry.endsWith(MESSAGE_SUFFIX)).sort()) {\n const claim = path.join(directory, `${CLAIM_PREFIX}${String(process.pid)}-${name}`);\n try {\n // Whoever wins this rename owns the message. A loser gets ENOENT and\n // simply moves on, which is why this is not wrapped in an existence\n // check: the check and the rename would not be atomic together.\n fs.renameSync(path.join(directory, name), claim);\n } catch {\n continue;\n }\n let message: ChannelMessage | undefined;\n try {\n message = parse(fs.readFileSync(claim, 'utf8'));\n } catch {\n message = undefined;\n }\n if (message === undefined) {\n // Left claimed rather than deleted. An unreadable message may come\n // from a newer build, and the sweep will surface it again rather than\n // this reader destroying something it did not understand.\n continue;\n }\n delivered.push(message);\n fs.rmSync(claim, { force: true });\n }\n return delivered;\n },\n\n sweep(now = Date.now()) {\n let released = 0;\n for (const party of ['parent', 'child'] as const) {\n const directory = inboxDirectory(root, party);\n let names: string[];\n try {\n names = fs.readdirSync(directory);\n } catch {\n continue;\n }\n for (const name of names.filter((entry) => entry.startsWith(CLAIM_PREFIX))) {\n const claimed = path.join(directory, name);\n try {\n if (now - fs.statSync(claimed).mtimeMs < CLAIM_STALE_MS) continue;\n // Returned to the inbox rather than deleted, because the message\n // was claimed but never delivered.\n const original = name.slice(name.indexOf('-', CLAIM_PREFIX.length) + 1);\n fs.renameSync(claimed, path.join(directory, original));\n released += 1;\n } catch {\n // Another sweeper got there first.\n }\n }\n }\n return released;\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);function r(e,r){let i=n.default.dirname(e);t.default.mkdirSync(i,{recursive:!0});let a=n.default.join(i,`.${n.default.basename(e)}.${String(process.pid)}.tmp`);t.default.writeFileSync(a,`${JSON.stringify(r,null,2)}\n`,{mode:384}),t.default.renameSync(a,e)}function i(e){try{return JSON.parse(t.default.readFileSync(e,`utf8`))}catch{return}}exports.readJson=i,exports.writeJsonAtomic=r;
2
+ //# sourceMappingURL=atomicJson.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"atomicJson.cjs","names":["path","fs"],"sources":["../../../src/adapters/filesystem/atomicJson.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nconst OWNER_ONLY_FILE = 0o600;\n\n/**\n * Writes JSON so a reader never sees a half-written file.\n *\n * The registry is read by the same process that writes it, and by a later\n * process after a crash. A plain write can be interrupted between the truncate\n * and the last byte, which leaves a file that parses as nothing and loses every\n * record in it. Writing beside the target and renaming makes the swap atomic on\n * the same filesystem, so a reader sees either the old file or the new one.\n */\nexport function writeJsonAtomic(file: string, value: unknown): void {\n const directory = path.dirname(file);\n fs.mkdirSync(directory, { recursive: true });\n const temporary = path.join(directory, `.${path.basename(file)}.${String(process.pid)}.tmp`);\n fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\\n`, { mode: OWNER_ONLY_FILE });\n fs.renameSync(temporary, file);\n}\n\n/**\n * Reads JSON, treating every failure as absent.\n *\n * A missing file and an unparseable one mean the same thing to every caller\n * here: there is nothing to act on. Distinguishing them would only offer a\n * choice no caller wants to make.\n */\nexport function readJson<T>(file: string): T | undefined {\n try {\n return JSON.parse(fs.readFileSync(file, 'utf8')) as T;\n } catch {\n return undefined;\n }\n}\n"],"mappings":"8IAcA,SAAgB,EAAgB,EAAc,EAAsB,CAClE,IAAM,EAAYA,EAAAA,QAAK,QAAQ,CAAI,EACnC,EAAA,QAAG,UAAU,EAAW,CAAE,UAAW,EAAK,CAAC,EAC3C,IAAM,EAAYA,EAAAA,QAAK,KAAK,EAAW,IAAIA,EAAAA,QAAK,SAAS,CAAI,EAAE,GAAG,OAAO,QAAQ,GAAG,EAAE,KAAK,EAC3F,EAAA,QAAG,cAAc,EAAW,GAAG,KAAK,UAAU,EAAO,KAAM,CAAC,EAAE,IAAK,CAAE,KAAM,GAAgB,CAAC,EAC5F,EAAA,QAAG,WAAW,EAAW,CAAI,CAC/B,CASA,SAAgB,EAAY,EAA6B,CACvD,GAAI,CACF,OAAO,KAAK,MAAMC,EAAAA,QAAG,aAAa,EAAM,MAAM,CAAC,CACjD,MAAQ,CACN,MACF,CACF"}
@@ -0,0 +1,2 @@
1
+ import e from"node:fs";import t from"node:path";function n(n,r){let i=t.dirname(n);e.mkdirSync(i,{recursive:!0});let a=t.join(i,`.${t.basename(n)}.${String(process.pid)}.tmp`);e.writeFileSync(a,`${JSON.stringify(r,null,2)}\n`,{mode:384}),e.renameSync(a,n)}function r(t){try{return JSON.parse(e.readFileSync(t,`utf8`))}catch{return}}export{r as readJson,n as writeJsonAtomic};
2
+ //# sourceMappingURL=atomicJson.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"atomicJson.mjs","names":[],"sources":["../../../src/adapters/filesystem/atomicJson.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nconst OWNER_ONLY_FILE = 0o600;\n\n/**\n * Writes JSON so a reader never sees a half-written file.\n *\n * The registry is read by the same process that writes it, and by a later\n * process after a crash. A plain write can be interrupted between the truncate\n * and the last byte, which leaves a file that parses as nothing and loses every\n * record in it. Writing beside the target and renaming makes the swap atomic on\n * the same filesystem, so a reader sees either the old file or the new one.\n */\nexport function writeJsonAtomic(file: string, value: unknown): void {\n const directory = path.dirname(file);\n fs.mkdirSync(directory, { recursive: true });\n const temporary = path.join(directory, `.${path.basename(file)}.${String(process.pid)}.tmp`);\n fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\\n`, { mode: OWNER_ONLY_FILE });\n fs.renameSync(temporary, file);\n}\n\n/**\n * Reads JSON, treating every failure as absent.\n *\n * A missing file and an unparseable one mean the same thing to every caller\n * here: there is nothing to act on. Distinguishing them would only offer a\n * choice no caller wants to make.\n */\nexport function readJson<T>(file: string): T | undefined {\n try {\n return JSON.parse(fs.readFileSync(file, 'utf8')) as T;\n } catch {\n return undefined;\n }\n}\n"],"mappings":"gDAcA,SAAgB,EAAgB,EAAc,EAAsB,CAClE,IAAM,EAAY,EAAK,QAAQ,CAAI,EACnC,EAAG,UAAU,EAAW,CAAE,UAAW,EAAK,CAAC,EAC3C,IAAM,EAAY,EAAK,KAAK,EAAW,IAAI,EAAK,SAAS,CAAI,EAAE,GAAG,OAAO,QAAQ,GAAG,EAAE,KAAK,EAC3F,EAAG,cAAc,EAAW,GAAG,KAAK,UAAU,EAAO,KAAM,CAAC,EAAE,IAAK,CAAE,KAAM,GAAgB,CAAC,EAC5F,EAAG,WAAW,EAAW,CAAI,CAC/B,CASA,SAAgB,EAAY,EAA6B,CACvD,GAAI,CACF,OAAO,KAAK,MAAM,EAAG,aAAa,EAAM,MAAM,CAAC,CACjD,MAAQ,CACN,MACF,CACF"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../worktree/repositoryIdentity.cjs");let n=require("node:path");n=e.__toESM(n,1);let r=require("@agimon-ai/doompi-web-contracts"),i=require("node:os");i=e.__toESM(i,1);function a(e=i.default.homedir()){return n.default.join(e,`.pi`,`.doom`,`git`)}function o(e=i.default.homedir()){return n.default.join(a(e),`worktrees`)}function s(e,t=i.default.homedir()){return`${a(t)}/channels/${e}`}function c(e,r=i.default.homedir()){let o=`${t.repositoryLabel(e)}--${t.repositoryId(e)}`;return n.default.join(a(r),`registry`,o,`worktrees.json`)}function l(e=process.argv,t=process.env,n=i.default.homedir()){let a=e.indexOf(`--registry-dir`),o=a>=0?e[a+1]:void 0;return(0,r.resolveRegistryDir)({...o===void 0?{}:{flagValue:o},...t[r.REGISTRY_DIR_ENV]===void 0?{}:{envValue:t[r.REGISTRY_DIR_ENV]},homeDir:n})}exports.channelRoot=s,exports.doomGitRoot=a,exports.hubRegistryDir=l,exports.registryFile=c,exports.worktreesRoot=o;
2
+ //# sourceMappingURL=paths.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.cjs","names":["os","path","repositoryLabel","repositoryId","resolveRegistryDir","REGISTRY_DIR_ENV"],"sources":["../../../src/adapters/filesystem/paths.ts"],"sourcesContent":["import os from 'node:os';\nimport path from 'node:path';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '@agimon-ai/doompi-web-contracts';\nimport { repositoryId, repositoryLabel } from '../worktree/repositoryIdentity.ts';\n\nconst REGISTRY_DIR_FLAG = '--registry-dir';\n\n/**\n * Where this package keeps its own state.\n *\n * Outside the repository on purpose. A worktree directory inside the checkout\n * would be found by every glob the repository's own tooling runs, and would\n * resolve workspace imports against the parent checkout rather than the\n * worktree, so a session there would build the wrong source while appearing to\n * work.\n */\nexport function doomGitRoot(homeDir: string = os.homedir()): string {\n return path.join(homeDir, '.pi', '.doom', 'git');\n}\n\n/** The directory every worktree for every repository is created under. */\nexport function worktreesRoot(homeDir: string = os.homedir()): string {\n return path.join(doomGitRoot(homeDir), 'worktrees');\n}\n\n/** One registry file per repository, keyed by the shared git directory. */\n/** Where a worktree's parent/child channel lives, outside the worktree itself. */\nexport function channelRoot(worktreeId: string, homeDir: string = os.homedir()): string {\n // Outside the worktree on purpose: closing a worktree deletes its directory,\n // and a channel inside it would take undelivered messages down with it.\n return `${doomGitRoot(homeDir)}/channels/${worktreeId}`;\n}\n\nexport function registryFile(repositoryRoot: string, homeDir: string = os.homedir()): string {\n const key = `${repositoryLabel(repositoryRoot)}--${repositoryId(repositoryRoot)}`;\n return path.join(doomGitRoot(homeDir), 'registry', key, 'worktrees.json');\n}\n\n/**\n * The session registry directory this process's hub is watching.\n *\n * A spawned session only appears in the rail if it registers in the same\n * directory the hub watches, and the hub passes that directory to its children\n * as `--registry-dir` while also inheriting the env var. Recovering it from\n * this process's own argv and env, in the same precedence the hub and the\n * session server use, is what makes a worktree session visible rather than\n * silently invisible.\n */\nexport function hubRegistryDir(\n argv: readonly string[] = process.argv,\n env: NodeJS.ProcessEnv = process.env,\n homeDir: string = os.homedir(),\n): string {\n const index = argv.indexOf(REGISTRY_DIR_FLAG);\n const flagValue = index >= 0 ? argv[index + 1] : undefined;\n return resolveRegistryDir({\n ...(flagValue === undefined ? {} : { flagValue }),\n ...(env[REGISTRY_DIR_ENV] === undefined ? {} : { envValue: env[REGISTRY_DIR_ENV] }),\n homeDir,\n });\n}\n"],"mappings":"2OAgBA,SAAgB,EAAY,EAAkBA,EAAAA,QAAG,QAAQ,EAAW,CAClE,OAAOC,EAAAA,QAAK,KAAK,EAAS,MAAO,QAAS,KAAK,CACjD,CAGA,SAAgB,EAAc,EAAkBD,EAAAA,QAAG,QAAQ,EAAW,CACpE,OAAOC,EAAAA,QAAK,KAAK,EAAY,CAAO,EAAG,WAAW,CACpD,CAIA,SAAgB,EAAY,EAAoB,EAAkBD,EAAAA,QAAG,QAAQ,EAAW,CAGtF,MAAO,GAAG,EAAY,CAAO,EAAE,YAAY,GAC7C,CAEA,SAAgB,EAAa,EAAwB,EAAkBA,EAAAA,QAAG,QAAQ,EAAW,CAC3F,IAAM,EAAM,GAAGE,EAAAA,gBAAgB,CAAc,EAAE,IAAIC,EAAAA,aAAa,CAAc,IAC9E,OAAOF,EAAAA,QAAK,KAAK,EAAY,CAAO,EAAG,WAAY,EAAK,gBAAgB,CAC1E,CAYA,SAAgB,EACd,EAA0B,QAAQ,KAClC,EAAyB,QAAQ,IACjC,EAAkBD,EAAAA,QAAG,QAAQ,EACrB,CACR,IAAM,EAAQ,EAAK,QAAQ,gBAAiB,EACtC,EAAY,GAAS,EAAI,EAAK,EAAQ,GAAK,IAAA,GACjD,OAAA,EAAOI,EAAAA,mBAAAA,CAAmB,CACxB,GAAI,IAAc,IAAA,GAAY,CAAC,EAAI,CAAE,WAAU,EAC/C,GAAI,EAAIC,EAAAA,oBAAsB,IAAA,GAAY,CAAC,EAAI,CAAE,SAAU,EAAIA,EAAAA,iBAAkB,EACjF,SACF,CAAC,CACH"}
@@ -0,0 +1,2 @@
1
+ import{repositoryId as e,repositoryLabel as t}from"../worktree/repositoryIdentity.mjs";import n from"node:path";import{REGISTRY_DIR_ENV as r,resolveRegistryDir as i}from"@agimon-ai/doompi-web-contracts";import a from"node:os";function o(e=a.homedir()){return n.join(e,`.pi`,`.doom`,`git`)}function s(e=a.homedir()){return n.join(o(e),`worktrees`)}function c(e,t=a.homedir()){return`${o(t)}/channels/${e}`}function l(r,i=a.homedir()){let s=`${t(r)}--${e(r)}`;return n.join(o(i),`registry`,s,`worktrees.json`)}function u(e=process.argv,t=process.env,n=a.homedir()){let o=e.indexOf(`--registry-dir`),s=o>=0?e[o+1]:void 0;return i({...s===void 0?{}:{flagValue:s},...t[r]===void 0?{}:{envValue:t[r]},homeDir:n})}export{c as channelRoot,o as doomGitRoot,u as hubRegistryDir,l as registryFile,s as worktreesRoot};
2
+ //# sourceMappingURL=paths.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.mjs","names":[],"sources":["../../../src/adapters/filesystem/paths.ts"],"sourcesContent":["import os from 'node:os';\nimport path from 'node:path';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '@agimon-ai/doompi-web-contracts';\nimport { repositoryId, repositoryLabel } from '../worktree/repositoryIdentity.ts';\n\nconst REGISTRY_DIR_FLAG = '--registry-dir';\n\n/**\n * Where this package keeps its own state.\n *\n * Outside the repository on purpose. A worktree directory inside the checkout\n * would be found by every glob the repository's own tooling runs, and would\n * resolve workspace imports against the parent checkout rather than the\n * worktree, so a session there would build the wrong source while appearing to\n * work.\n */\nexport function doomGitRoot(homeDir: string = os.homedir()): string {\n return path.join(homeDir, '.pi', '.doom', 'git');\n}\n\n/** The directory every worktree for every repository is created under. */\nexport function worktreesRoot(homeDir: string = os.homedir()): string {\n return path.join(doomGitRoot(homeDir), 'worktrees');\n}\n\n/** One registry file per repository, keyed by the shared git directory. */\n/** Where a worktree's parent/child channel lives, outside the worktree itself. */\nexport function channelRoot(worktreeId: string, homeDir: string = os.homedir()): string {\n // Outside the worktree on purpose: closing a worktree deletes its directory,\n // and a channel inside it would take undelivered messages down with it.\n return `${doomGitRoot(homeDir)}/channels/${worktreeId}`;\n}\n\nexport function registryFile(repositoryRoot: string, homeDir: string = os.homedir()): string {\n const key = `${repositoryLabel(repositoryRoot)}--${repositoryId(repositoryRoot)}`;\n return path.join(doomGitRoot(homeDir), 'registry', key, 'worktrees.json');\n}\n\n/**\n * The session registry directory this process's hub is watching.\n *\n * A spawned session only appears in the rail if it registers in the same\n * directory the hub watches, and the hub passes that directory to its children\n * as `--registry-dir` while also inheriting the env var. Recovering it from\n * this process's own argv and env, in the same precedence the hub and the\n * session server use, is what makes a worktree session visible rather than\n * silently invisible.\n */\nexport function hubRegistryDir(\n argv: readonly string[] = process.argv,\n env: NodeJS.ProcessEnv = process.env,\n homeDir: string = os.homedir(),\n): string {\n const index = argv.indexOf(REGISTRY_DIR_FLAG);\n const flagValue = index >= 0 ? argv[index + 1] : undefined;\n return resolveRegistryDir({\n ...(flagValue === undefined ? {} : { flagValue }),\n ...(env[REGISTRY_DIR_ENV] === undefined ? {} : { envValue: env[REGISTRY_DIR_ENV] }),\n homeDir,\n });\n}\n"],"mappings":"kOAgBA,SAAgB,EAAY,EAAkB,EAAG,QAAQ,EAAW,CAClE,OAAO,EAAK,KAAK,EAAS,MAAO,QAAS,KAAK,CACjD,CAGA,SAAgB,EAAc,EAAkB,EAAG,QAAQ,EAAW,CACpE,OAAO,EAAK,KAAK,EAAY,CAAO,EAAG,WAAW,CACpD,CAIA,SAAgB,EAAY,EAAoB,EAAkB,EAAG,QAAQ,EAAW,CAGtF,MAAO,GAAG,EAAY,CAAO,EAAE,YAAY,GAC7C,CAEA,SAAgB,EAAa,EAAwB,EAAkB,EAAG,QAAQ,EAAW,CAC3F,IAAM,EAAM,GAAG,EAAgB,CAAc,EAAE,IAAI,EAAa,CAAc,IAC9E,OAAO,EAAK,KAAK,EAAY,CAAO,EAAG,WAAY,EAAK,gBAAgB,CAC1E,CAYA,SAAgB,EACd,EAA0B,QAAQ,KAClC,EAAyB,QAAQ,IACjC,EAAkB,EAAG,QAAQ,EACrB,CACR,IAAM,EAAQ,EAAK,QAAQ,gBAAiB,EACtC,EAAY,GAAS,EAAI,EAAK,EAAQ,GAAK,IAAA,GACjD,OAAO,EAAmB,CACxB,GAAI,IAAc,IAAA,GAAY,CAAC,EAAI,CAAE,WAAU,EAC/C,GAAI,EAAI,KAAsB,IAAA,GAAY,CAAC,EAAI,CAAE,SAAU,EAAI,EAAkB,EACjF,SACF,CAAC,CACH"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("@agimon-ai/doompi-web-contracts");const r=`/api/sessions`;var i=class extends Error{constructor(e){super(e),this.name=`HubUnavailableError`}};function a(e,n){return t.default.existsSync(`${e}/sessions/${n}.json`)}async function o(e,t){let n=s(e);if(n!==void 0&&await c(n.url))try{let e=await fetch(`${n.url}${r}/${encodeURIComponent(t)}`,{method:`DELETE`,signal:AbortSignal.timeout(3e4)});if(!e.ok&&e.status!==404)throw new i((await e.json().catch(()=>({}))).error??`The cockpit could not stop the session (${String(e.status)}).`)}catch(e){throw e instanceof i?e:new i(`Could not reach the cockpit: ${e.message}`)}}function s(e){let r;try{r=t.default.readFileSync((0,n.hubAdvertisementPath)(e),`utf8`)}catch{return}return(0,n.parseHubAdvertisement)(r)}async function c(e){try{let t=await fetch(`${e}/api/health`,{signal:AbortSignal.timeout(1e3)});if(!t.ok)return!1;let n=await t.json();return n.ok===!0&&n.role===`hub`}catch{return!1}}async function l(e){let t=s(e.registryDir);if(t===void 0)throw new i(`No cockpit is running. Worktree sessions are created by the cockpit, so start it and try again.`);if(!await c(t.url))throw new i(`The cockpit that advertised ${t.url} is no longer answering. Start it and try again.`);let n;try{n=await fetch(`${t.url}${r}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({cwd:e.cwd,name:e.name,parentSessionId:e.parentSessionId,provenance:`worktree`}),signal:AbortSignal.timeout(6e5)})}catch(e){throw new i(`Could not reach the cockpit: ${e.message}`)}let a=await n.json().catch(()=>({}));if(!n.ok)throw new i(a.error??`The cockpit refused the session (${String(n.status)}).`);if(typeof a.sessionId!=`string`||a.sessionId===``)throw new i(`The cockpit accepted the session but did not name it.`);return a.sessionId}exports.HubUnavailableError=i,exports.createWorktreeSession=l,exports.readHubAdvertisement=s,exports.sessionIsLive=a,exports.stopWorktreeSession=o;
2
+ //# sourceMappingURL=hubClient.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hubClient.cjs","names":["fs","hubAdvertisementPath","parseHubAdvertisement"],"sources":["../../../src/adapters/hub/hubClient.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { hubAdvertisementPath, parseHubAdvertisement } from '@agimon-ai/doompi-web-contracts';\nimport type { HubAdvertisement } from '@agimon-ai/doompi-web-contracts';\n\nconst HEALTH_ROUTE = '/api/health';\nconst SESSIONS_ROUTE = '/api/sessions';\nconst PROBE_TIMEOUT_MS = 1000;\n/**\n * Creating a session is not a quick call.\n *\n * The hub validates, then runs `ensureSessionSynced`, then spawns. A cockpit\n * that has never synced the composition builds it first, which is minutes on a\n * cold cache, and a fresh worktree also installs its dependencies. A 30s\n * timeout was measured aborting a create that the hub then completed anyway,\n * leaving the caller told it failed while a session started behind it.\n */\nconst CREATE_TIMEOUT_MS = 600_000;\n/** Stopping is just a signal and a record removal, so it stays short. */\nconst STOP_TIMEOUT_MS = 30_000;\nconst HUB_ROLE = 'hub';\n\n/** Raised when the cockpit that owns session lifecycle is not reachable. */\nexport class HubUnavailableError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'HubUnavailableError';\n }\n}\n\n/**\n * Whether the hub still has a record for a session.\n *\n * The session server writes its record into the shared registry directory and\n * removes it on exit, so the file's presence is the liveness signal. A pid is\n * not available here: the hub spawns the process, so this package never learns\n * one, and a pid it invented would be a field that always lies.\n */\nexport function sessionIsLive(registryDir: string, sessionId: string): boolean {\n return fs.existsSync(`${registryDir}/sessions/${sessionId}.json`);\n}\n\n/**\n * Asks the hub to stop a session it started.\n *\n * Stopping is the hub's job for the same reason starting is. Signalling the\n * process directly would leave the hub's own record of it behind and race the\n * cleanup it does on the way down.\n *\n * A session the hub does not know about is already stopped, which is the\n * outcome the caller wanted, so a 404 is success.\n */\nexport async function stopWorktreeSession(registryDir: string, sessionId: string): Promise<void> {\n const advertisement = readHubAdvertisement(registryDir);\n if (advertisement === undefined) return;\n if (!(await hubAnswers(advertisement.url))) return;\n try {\n const response = await fetch(`${advertisement.url}${SESSIONS_ROUTE}/${encodeURIComponent(sessionId)}`, {\n method: 'DELETE',\n signal: AbortSignal.timeout(STOP_TIMEOUT_MS),\n });\n if (!response.ok && response.status !== 404) {\n const body = (await response.json().catch(() => ({}))) as { error?: string };\n throw new HubUnavailableError(\n body.error ?? `The cockpit could not stop the session (${String(response.status)}).`,\n );\n }\n } catch (error) {\n if (error instanceof HubUnavailableError) throw error;\n throw new HubUnavailableError(`Could not reach the cockpit: ${(error as Error).message}`);\n }\n}\n\nexport interface CreateWorktreeSessionInput {\n /** The worktree directory; must already exist. */\n cwd: string;\n /** Rail display name for the new session. */\n name: string;\n /** The session asking for this one, so the rail can nest them. */\n parentSessionId: string;\n registryDir: string;\n}\n\n/** Reads the hub's published address, if it published one. */\nexport function readHubAdvertisement(registryDir: string): HubAdvertisement | undefined {\n let raw: string;\n try {\n raw = fs.readFileSync(hubAdvertisementPath(registryDir), 'utf8');\n } catch {\n return undefined;\n }\n return parseHubAdvertisement(raw);\n}\n\n/**\n * Confirms a live cockpit hub answers at the address, rather than a leftover.\n *\n * The advertisement file outlives a crash and a port gets reused, so finding it\n * proves nothing. Only a hub that answers /api/health and calls itself a hub is\n * worth sending a session-create to; anything else is something else's port.\n */\nasync function hubAnswers(url: string): Promise<boolean> {\n try {\n const response = await fetch(`${url}${HEALTH_ROUTE}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });\n if (!response.ok) return false;\n const body = (await response.json()) as { ok?: boolean; role?: string };\n return body.ok === true && body.role === HUB_ROLE;\n } catch {\n return false;\n }\n}\n\n/**\n * Asks the cockpit hub to start a session in a worktree.\n *\n * The hub owns session lifecycle: it validates the directory, syncs the\n * composition, spawns the server and records the lineage. This package only\n * asks. Creating the process here instead would put a second spawn path beside\n * the hub's, with its own idea of what a session needs, and a session server is\n * bound to one session anyway so it has no business starting another.\n *\n * The panel and the agent both come through here, so there is one route to keep\n * correct rather than two that drift.\n */\nexport async function createWorktreeSession(input: CreateWorktreeSessionInput): Promise<string> {\n const advertisement = readHubAdvertisement(input.registryDir);\n if (advertisement === undefined) {\n throw new HubUnavailableError(\n 'No cockpit is running. Worktree sessions are created by the cockpit, so start it and try again.',\n );\n }\n if (!(await hubAnswers(advertisement.url))) {\n throw new HubUnavailableError(\n `The cockpit that advertised ${advertisement.url} is no longer answering. Start it and try again.`,\n );\n }\n\n let response: Response;\n try {\n response = await fetch(`${advertisement.url}${SESSIONS_ROUTE}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n cwd: input.cwd,\n name: input.name,\n parentSessionId: input.parentSessionId,\n provenance: 'worktree',\n }),\n signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),\n });\n } catch (error) {\n throw new HubUnavailableError(`Could not reach the cockpit: ${(error as Error).message}`);\n }\n\n const body = (await response.json().catch(() => ({}))) as { sessionId?: string; error?: string };\n if (!response.ok) {\n throw new HubUnavailableError(body.error ?? `The cockpit refused the session (${String(response.status)}).`);\n }\n if (typeof body.sessionId !== 'string' || body.sessionId === '') {\n throw new HubUnavailableError('The cockpit accepted the session but did not name it.');\n }\n return body.sessionId;\n}\n"],"mappings":"mJAIA,MACM,EAAiB,gBAiBvB,IAAa,EAAb,cAAyC,KAAM,CAC7C,YAAY,EAAiB,CAC3B,MAAM,CAAO,EACb,KAAK,KAAO,qBACd,CACF,EAUA,SAAgB,EAAc,EAAqB,EAA4B,CAC7E,OAAOA,EAAAA,QAAG,WAAW,GAAG,EAAY,YAAY,EAAU,MAAM,CAClE,CAYA,eAAsB,EAAoB,EAAqB,EAAkC,CAC/F,IAAM,EAAgB,EAAqB,CAAW,EAClD,OAAkB,IAAA,IAChB,MAAM,EAAW,EAAc,GAAG,EACxC,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,GAAG,EAAc,MAAM,EAAe,GAAG,mBAAmB,CAAS,IAAK,CACrG,OAAQ,SACR,OAAQ,YAAY,QAAQ,GAAe,CAC7C,CAAC,EACD,GAAI,CAAC,EAAS,IAAM,EAAS,SAAW,IAEtC,MAAM,IAAI,GACR,MAFkB,EAAS,KAAK,CAAC,CAAC,WAAa,CAAC,EAAE,EAElD,CAAK,OAAS,2CAA2C,OAAO,EAAS,MAAM,EAAE,GACnF,CAEJ,OAAS,EAAO,CAEd,MADI,aAAiB,EAA2B,EAC1C,IAAI,EAAoB,gCAAiC,EAAgB,SAAS,CAC1F,CACF,CAaA,SAAgB,EAAqB,EAAmD,CACtF,IAAI,EACJ,GAAI,CACF,EAAMA,EAAAA,QAAG,cAAA,EAAaC,EAAAA,qBAAAA,CAAqB,CAAW,EAAG,MAAM,CACjE,MAAQ,CACN,MACF,CACA,OAAA,EAAOC,EAAAA,sBAAAA,CAAsB,CAAG,CAClC,CASA,eAAe,EAAW,EAA+B,CACvD,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,GAAG,eAAsB,CAAE,OAAQ,YAAY,QAAQ,GAAgB,CAAE,CAAC,EACvG,GAAI,CAAC,EAAS,GAAI,MAAO,GACzB,IAAM,EAAQ,MAAM,EAAS,KAAK,EAClC,OAAO,EAAK,KAAO,IAAQ,EAAK,OAAS,KAC3C,MAAQ,CACN,MAAO,EACT,CACF,CAcA,eAAsB,EAAsB,EAAoD,CAC9F,IAAM,EAAgB,EAAqB,EAAM,WAAW,EAC5D,GAAI,IAAkB,IAAA,GACpB,MAAM,IAAI,EACR,iGACF,EAEF,GAAI,CAAE,MAAM,EAAW,EAAc,GAAG,EACtC,MAAM,IAAI,EACR,+BAA+B,EAAc,IAAI,iDACnD,EAGF,IAAI,EACJ,GAAI,CACF,EAAW,MAAM,MAAM,GAAG,EAAc,MAAM,IAAkB,CAC9D,OAAQ,OACR,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,KAAM,KAAK,UAAU,CACnB,IAAK,EAAM,IACX,KAAM,EAAM,KACZ,gBAAiB,EAAM,gBACvB,WAAY,UACd,CAAC,EACD,OAAQ,YAAY,QAAQ,GAAiB,CAC/C,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAI,EAAoB,gCAAiC,EAAgB,SAAS,CAC1F,CAEA,IAAM,EAAQ,MAAM,EAAS,KAAK,CAAC,CAAC,WAAa,CAAC,EAAE,EACpD,GAAI,CAAC,EAAS,GACZ,MAAM,IAAI,EAAoB,EAAK,OAAS,oCAAoC,OAAO,EAAS,MAAM,EAAE,GAAG,EAE7G,GAAI,OAAO,EAAK,WAAc,UAAY,EAAK,YAAc,GAC3D,MAAM,IAAI,EAAoB,uDAAuD,EAEvF,OAAO,EAAK,SACd"}
@@ -0,0 +1 @@
1
+ import "@agimon-ai/doompi-web-contracts";
@@ -0,0 +1 @@
1
+ import "@agimon-ai/doompi-web-contracts";
@@ -0,0 +1,2 @@
1
+ import e from"node:fs";import{hubAdvertisementPath as t,parseHubAdvertisement as n}from"@agimon-ai/doompi-web-contracts";const r=`/api/sessions`;var i=class extends Error{constructor(e){super(e),this.name=`HubUnavailableError`}};function a(t,n){return e.existsSync(`${t}/sessions/${n}.json`)}async function o(e,t){let n=s(e);if(n!==void 0&&await c(n.url))try{let e=await fetch(`${n.url}${r}/${encodeURIComponent(t)}`,{method:`DELETE`,signal:AbortSignal.timeout(3e4)});if(!e.ok&&e.status!==404)throw new i((await e.json().catch(()=>({}))).error??`The cockpit could not stop the session (${String(e.status)}).`)}catch(e){throw e instanceof i?e:new i(`Could not reach the cockpit: ${e.message}`)}}function s(r){let i;try{i=e.readFileSync(t(r),`utf8`)}catch{return}return n(i)}async function c(e){try{let t=await fetch(`${e}/api/health`,{signal:AbortSignal.timeout(1e3)});if(!t.ok)return!1;let n=await t.json();return n.ok===!0&&n.role===`hub`}catch{return!1}}async function l(e){let t=s(e.registryDir);if(t===void 0)throw new i(`No cockpit is running. Worktree sessions are created by the cockpit, so start it and try again.`);if(!await c(t.url))throw new i(`The cockpit that advertised ${t.url} is no longer answering. Start it and try again.`);let n;try{n=await fetch(`${t.url}${r}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({cwd:e.cwd,name:e.name,parentSessionId:e.parentSessionId,provenance:`worktree`}),signal:AbortSignal.timeout(6e5)})}catch(e){throw new i(`Could not reach the cockpit: ${e.message}`)}let a=await n.json().catch(()=>({}));if(!n.ok)throw new i(a.error??`The cockpit refused the session (${String(n.status)}).`);if(typeof a.sessionId!=`string`||a.sessionId===``)throw new i(`The cockpit accepted the session but did not name it.`);return a.sessionId}export{i as HubUnavailableError,l as createWorktreeSession,s as readHubAdvertisement,a as sessionIsLive,o as stopWorktreeSession};
2
+ //# sourceMappingURL=hubClient.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hubClient.mjs","names":[],"sources":["../../../src/adapters/hub/hubClient.ts"],"sourcesContent":["import fs from 'node:fs';\nimport { hubAdvertisementPath, parseHubAdvertisement } from '@agimon-ai/doompi-web-contracts';\nimport type { HubAdvertisement } from '@agimon-ai/doompi-web-contracts';\n\nconst HEALTH_ROUTE = '/api/health';\nconst SESSIONS_ROUTE = '/api/sessions';\nconst PROBE_TIMEOUT_MS = 1000;\n/**\n * Creating a session is not a quick call.\n *\n * The hub validates, then runs `ensureSessionSynced`, then spawns. A cockpit\n * that has never synced the composition builds it first, which is minutes on a\n * cold cache, and a fresh worktree also installs its dependencies. A 30s\n * timeout was measured aborting a create that the hub then completed anyway,\n * leaving the caller told it failed while a session started behind it.\n */\nconst CREATE_TIMEOUT_MS = 600_000;\n/** Stopping is just a signal and a record removal, so it stays short. */\nconst STOP_TIMEOUT_MS = 30_000;\nconst HUB_ROLE = 'hub';\n\n/** Raised when the cockpit that owns session lifecycle is not reachable. */\nexport class HubUnavailableError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'HubUnavailableError';\n }\n}\n\n/**\n * Whether the hub still has a record for a session.\n *\n * The session server writes its record into the shared registry directory and\n * removes it on exit, so the file's presence is the liveness signal. A pid is\n * not available here: the hub spawns the process, so this package never learns\n * one, and a pid it invented would be a field that always lies.\n */\nexport function sessionIsLive(registryDir: string, sessionId: string): boolean {\n return fs.existsSync(`${registryDir}/sessions/${sessionId}.json`);\n}\n\n/**\n * Asks the hub to stop a session it started.\n *\n * Stopping is the hub's job for the same reason starting is. Signalling the\n * process directly would leave the hub's own record of it behind and race the\n * cleanup it does on the way down.\n *\n * A session the hub does not know about is already stopped, which is the\n * outcome the caller wanted, so a 404 is success.\n */\nexport async function stopWorktreeSession(registryDir: string, sessionId: string): Promise<void> {\n const advertisement = readHubAdvertisement(registryDir);\n if (advertisement === undefined) return;\n if (!(await hubAnswers(advertisement.url))) return;\n try {\n const response = await fetch(`${advertisement.url}${SESSIONS_ROUTE}/${encodeURIComponent(sessionId)}`, {\n method: 'DELETE',\n signal: AbortSignal.timeout(STOP_TIMEOUT_MS),\n });\n if (!response.ok && response.status !== 404) {\n const body = (await response.json().catch(() => ({}))) as { error?: string };\n throw new HubUnavailableError(\n body.error ?? `The cockpit could not stop the session (${String(response.status)}).`,\n );\n }\n } catch (error) {\n if (error instanceof HubUnavailableError) throw error;\n throw new HubUnavailableError(`Could not reach the cockpit: ${(error as Error).message}`);\n }\n}\n\nexport interface CreateWorktreeSessionInput {\n /** The worktree directory; must already exist. */\n cwd: string;\n /** Rail display name for the new session. */\n name: string;\n /** The session asking for this one, so the rail can nest them. */\n parentSessionId: string;\n registryDir: string;\n}\n\n/** Reads the hub's published address, if it published one. */\nexport function readHubAdvertisement(registryDir: string): HubAdvertisement | undefined {\n let raw: string;\n try {\n raw = fs.readFileSync(hubAdvertisementPath(registryDir), 'utf8');\n } catch {\n return undefined;\n }\n return parseHubAdvertisement(raw);\n}\n\n/**\n * Confirms a live cockpit hub answers at the address, rather than a leftover.\n *\n * The advertisement file outlives a crash and a port gets reused, so finding it\n * proves nothing. Only a hub that answers /api/health and calls itself a hub is\n * worth sending a session-create to; anything else is something else's port.\n */\nasync function hubAnswers(url: string): Promise<boolean> {\n try {\n const response = await fetch(`${url}${HEALTH_ROUTE}`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });\n if (!response.ok) return false;\n const body = (await response.json()) as { ok?: boolean; role?: string };\n return body.ok === true && body.role === HUB_ROLE;\n } catch {\n return false;\n }\n}\n\n/**\n * Asks the cockpit hub to start a session in a worktree.\n *\n * The hub owns session lifecycle: it validates the directory, syncs the\n * composition, spawns the server and records the lineage. This package only\n * asks. Creating the process here instead would put a second spawn path beside\n * the hub's, with its own idea of what a session needs, and a session server is\n * bound to one session anyway so it has no business starting another.\n *\n * The panel and the agent both come through here, so there is one route to keep\n * correct rather than two that drift.\n */\nexport async function createWorktreeSession(input: CreateWorktreeSessionInput): Promise<string> {\n const advertisement = readHubAdvertisement(input.registryDir);\n if (advertisement === undefined) {\n throw new HubUnavailableError(\n 'No cockpit is running. Worktree sessions are created by the cockpit, so start it and try again.',\n );\n }\n if (!(await hubAnswers(advertisement.url))) {\n throw new HubUnavailableError(\n `The cockpit that advertised ${advertisement.url} is no longer answering. Start it and try again.`,\n );\n }\n\n let response: Response;\n try {\n response = await fetch(`${advertisement.url}${SESSIONS_ROUTE}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n cwd: input.cwd,\n name: input.name,\n parentSessionId: input.parentSessionId,\n provenance: 'worktree',\n }),\n signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),\n });\n } catch (error) {\n throw new HubUnavailableError(`Could not reach the cockpit: ${(error as Error).message}`);\n }\n\n const body = (await response.json().catch(() => ({}))) as { sessionId?: string; error?: string };\n if (!response.ok) {\n throw new HubUnavailableError(body.error ?? `The cockpit refused the session (${String(response.status)}).`);\n }\n if (typeof body.sessionId !== 'string' || body.sessionId === '') {\n throw new HubUnavailableError('The cockpit accepted the session but did not name it.');\n }\n return body.sessionId;\n}\n"],"mappings":"yHAIA,MACM,EAAiB,gBAiBvB,IAAa,EAAb,cAAyC,KAAM,CAC7C,YAAY,EAAiB,CAC3B,MAAM,CAAO,EACb,KAAK,KAAO,qBACd,CACF,EAUA,SAAgB,EAAc,EAAqB,EAA4B,CAC7E,OAAO,EAAG,WAAW,GAAG,EAAY,YAAY,EAAU,MAAM,CAClE,CAYA,eAAsB,EAAoB,EAAqB,EAAkC,CAC/F,IAAM,EAAgB,EAAqB,CAAW,EAClD,OAAkB,IAAA,IAChB,MAAM,EAAW,EAAc,GAAG,EACxC,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,GAAG,EAAc,MAAM,EAAe,GAAG,mBAAmB,CAAS,IAAK,CACrG,OAAQ,SACR,OAAQ,YAAY,QAAQ,GAAe,CAC7C,CAAC,EACD,GAAI,CAAC,EAAS,IAAM,EAAS,SAAW,IAEtC,MAAM,IAAI,GACR,MAFkB,EAAS,KAAK,CAAC,CAAC,WAAa,CAAC,EAAE,EAElD,CAAK,OAAS,2CAA2C,OAAO,EAAS,MAAM,EAAE,GACnF,CAEJ,OAAS,EAAO,CAEd,MADI,aAAiB,EAA2B,EAC1C,IAAI,EAAoB,gCAAiC,EAAgB,SAAS,CAC1F,CACF,CAaA,SAAgB,EAAqB,EAAmD,CACtF,IAAI,EACJ,GAAI,CACF,EAAM,EAAG,aAAa,EAAqB,CAAW,EAAG,MAAM,CACjE,MAAQ,CACN,MACF,CACA,OAAO,EAAsB,CAAG,CAClC,CASA,eAAe,EAAW,EAA+B,CACvD,GAAI,CACF,IAAM,EAAW,MAAM,MAAM,GAAG,eAAsB,CAAE,OAAQ,YAAY,QAAQ,GAAgB,CAAE,CAAC,EACvG,GAAI,CAAC,EAAS,GAAI,MAAO,GACzB,IAAM,EAAQ,MAAM,EAAS,KAAK,EAClC,OAAO,EAAK,KAAO,IAAQ,EAAK,OAAS,KAC3C,MAAQ,CACN,MAAO,EACT,CACF,CAcA,eAAsB,EAAsB,EAAoD,CAC9F,IAAM,EAAgB,EAAqB,EAAM,WAAW,EAC5D,GAAI,IAAkB,IAAA,GACpB,MAAM,IAAI,EACR,iGACF,EAEF,GAAI,CAAE,MAAM,EAAW,EAAc,GAAG,EACtC,MAAM,IAAI,EACR,+BAA+B,EAAc,IAAI,iDACnD,EAGF,IAAI,EACJ,GAAI,CACF,EAAW,MAAM,MAAM,GAAG,EAAc,MAAM,IAAkB,CAC9D,OAAQ,OACR,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,KAAM,KAAK,UAAU,CACnB,IAAK,EAAM,IACX,KAAM,EAAM,KACZ,gBAAiB,EAAM,gBACvB,WAAY,UACd,CAAC,EACD,OAAQ,YAAY,QAAQ,GAAiB,CAC/C,CAAC,CACH,OAAS,EAAO,CACd,MAAM,IAAI,EAAoB,gCAAiC,EAAgB,SAAS,CAC1F,CAEA,IAAM,EAAQ,MAAM,EAAS,KAAK,CAAC,CAAC,WAAa,CAAC,EAAE,EACpD,GAAI,CAAC,EAAS,GACZ,MAAM,IAAI,EAAoB,EAAK,OAAS,oCAAoC,OAAO,EAAS,MAAM,EAAE,GAAG,EAE7G,GAAI,OAAO,EAAK,WAAc,UAAY,EAAK,YAAc,GAC3D,MAAM,IAAI,EAAoB,uDAAuD,EAEvF,OAAO,EAAK,SACd"}
@@ -0,0 +1,2 @@
1
+ const e=require("../../commands/doomGitCommand.cjs"),t=require("./extensions/runWorktreeTool.cjs"),n=require("../../container/index.cjs");let r=require("@agimon-ai/doompi-extension-contracts/cordis-host"),i=require("@agimon-ai/doompi-extension-contracts/help");const a=`@agimon-ai/doompi-git`;function o(r,o,s=n.createGitContainer()){r.inject([i.DOOM_HELP_SERVICE],e=>{let t=(0,i.requireDoomHelpService)(e).register({source:a,moduleUrl:require("url").pathToFileURL(__filename).href,skills:[{name:`doompi-use-git`,description:`Use @agimon-ai/doompi-git: Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent`}]});return()=>t.dispose()}),e.registerGitCommand(o,s.service),t.registerRunWorktreeTool(o,s.operations)}function s(e,t){o(e,t.pi,t.dependencies)}async function c(e,t={}){let i=n.createGitContainer(t),o=await(0,r.connectDoomCordisHost)(e,a),c=o.root.plugin(s,{pi:e,dependencies:i});try{await c}catch(e){try{await c.dispose()}finally{await o.dispose()}throw e}let l;e.on(`session_shutdown`,()=>l??=(async()=>{try{await c.dispose()}finally{await o.dispose()}})())}exports.activateGitExtension=c,exports.default=c,exports.installGitRuntime=o;
2
+ //# sourceMappingURL=extension.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extension.cjs","names":["createGitContainer","DOOM_HELP_SERVICE","requireDoomHelpService","connectDoomCordisHost"],"sources":["../../../src/adapters/pi/extension.ts"],"sourcesContent":["// @scaffold-generated\nimport { connectDoomCordisHost } from '@agimon-ai/doompi-extension-contracts/cordis-host';\nimport { DOOM_HELP_SERVICE, requireDoomHelpService } from '@agimon-ai/doompi-extension-contracts/help';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionAPI } from '@earendil-works/pi-coding-agent';\nimport { registerGitCommand } from '../../commands/doomGitCommand.ts';\nimport { registerRunWorktreeTool } from './extensions/runWorktreeTool.ts';\nimport { createGitContainer } from '../../container';\nimport type { GitExtensionDependencies } from '../../types/extension';\n\nconst PACKAGE_SOURCE = '@agimon-ai/doompi-git';\n\n/**\n * Installs the package runtime inside its host-owned Cordis plugin fiber.\n *\n * Everything registered inside the effect is owned by the cordis fiber, so one\n * dispose on session shutdown unwinds it in reverse order. Yield a disposer for\n * each registration rather than tracking cleanup by hand; Pi can reload an\n * extension in-process, and a leaked listener duplicates work against stale\n * session state.\n */\nexport function installGitRuntime(\n cordis: Context,\n pi: ExtensionAPI,\n dependencies: GitExtensionDependencies = createGitContainer(),\n): void {\n cordis.inject([DOOM_HELP_SERVICE], (helpContext) => {\n const contribution = requireDoomHelpService(helpContext).register({\n source: PACKAGE_SOURCE,\n moduleUrl: import.meta.url,\n skills: [\n {\n name: 'doompi-use-git',\n description:\n 'Use @agimon-ai/doompi-git: Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent',\n },\n ],\n });\n return () => contribution.dispose();\n });\n\n registerGitCommand(pi, dependencies.service);\n registerRunWorktreeTool(pi, dependencies.operations);\n}\n\ninterface GitPluginConfig {\n readonly pi: ExtensionAPI;\n readonly dependencies: GitExtensionDependencies;\n}\n\nfunction gitPlugin(cordis: Context, config: GitPluginConfig): void {\n installGitRuntime(cordis, config.pi, config.dependencies);\n}\n\n/** Connects the package's standard Pi entry to DoomPi's shared Cordis host. */\nexport async function activateGitExtension(\n pi: ExtensionAPI,\n // Partial so a caller, and every test, can stand in one collaborator without\n // having to construct the rest of the graph it does not care about.\n overrides: Partial<GitExtensionDependencies> = {},\n): Promise<void> {\n const dependencies = createGitContainer(overrides);\n const connection = await connectDoomCordisHost(pi, PACKAGE_SOURCE);\n const fiber = connection.root.plugin(gitPlugin, { pi, dependencies });\n\n try {\n await fiber;\n } catch (error) {\n try {\n await fiber.dispose();\n } finally {\n await connection.dispose();\n }\n throw error;\n }\n\n let disposal: Promise<void> | undefined;\n pi.on(\n 'session_shutdown',\n () =>\n (disposal ??= (async () => {\n try {\n await fiber.dispose();\n } finally {\n await connection.dispose();\n }\n })()),\n );\n}\n\nexport default activateGitExtension;\n"],"mappings":"qQAUA,MAAM,EAAiB,wBAWvB,SAAgB,EACd,EACA,EACA,EAAyCA,EAAAA,mBAAmB,EACtD,CACN,EAAO,OAAO,CAACC,EAAAA,iBAAiB,EAAI,GAAgB,CAClD,IAAM,GAAA,EAAeC,EAAAA,uBAAAA,CAAuB,CAAW,CAAC,CAAC,SAAS,CAChE,OAAQ,EACR,UAAA,QAAA,KAAA,CAAA,CAAA,cAAA,UAAA,CAAA,CAAA,KACA,OAAQ,CACN,CACE,KAAM,iBACN,YACE,4IACJ,CACF,CACF,CAAC,EACD,UAAa,EAAa,QAAQ,CACpC,CAAC,EAED,EAAA,mBAAmB,EAAI,EAAa,OAAO,EAC3C,EAAA,wBAAwB,EAAI,EAAa,UAAU,CACrD,CAOA,SAAS,EAAU,EAAiB,EAA+B,CACjE,EAAkB,EAAQ,EAAO,GAAI,EAAO,YAAY,CAC1D,CAGA,eAAsB,EACpB,EAGA,EAA+C,CAAC,EACjC,CACf,IAAM,EAAeF,EAAAA,mBAAmB,CAAS,EAC3C,EAAa,MAAA,EAAMG,EAAAA,sBAAAA,CAAsB,EAAI,CAAc,EAC3D,EAAQ,EAAW,KAAK,OAAO,EAAW,CAAE,KAAI,cAAa,CAAC,EAEpE,GAAI,CACF,MAAM,CACR,OAAS,EAAO,CACd,GAAI,CACF,MAAM,EAAM,QAAQ,CACtB,QAAU,CACR,MAAM,EAAW,QAAQ,CAC3B,CACA,MAAM,CACR,CAEA,IAAI,EACJ,EAAG,GACD,uBAEG,KAAc,SAAY,CACzB,GAAI,CACF,MAAM,EAAM,QAAQ,CACtB,QAAU,CACR,MAAM,EAAW,QAAQ,CAC3B,CACF,EAAA,CAAG,CACP,CACF"}
@@ -0,0 +1,9 @@
1
+ import { GitExtensionDependencies } from "../../types/extension.cjs";
2
+ import "@deepseek-ai/cordis";
3
+ import { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ //#region src/adapters/pi/extension.d.ts
5
+ /** Connects the package's standard Pi entry to DoomPi's shared Cordis host. */
6
+ declare function activateGitExtension(pi: ExtensionAPI, overrides?: Partial<GitExtensionDependencies>): Promise<void>;
7
+ //#endregion
8
+ export { activateGitExtension, activateGitExtension as default };
9
+ //# sourceMappingURL=extension.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extension.d.cts","names":[],"sources":["../../../src/adapters/pi/extension.ts"],"mappings":";;;;;iBAuDsB,qBACpB,IAAI,cAGJ,YAAW,QAAQ,4BAClB"}
@@ -0,0 +1,9 @@
1
+ import { GitExtensionDependencies } from "../../types/extension.mjs";
2
+ import "@deepseek-ai/cordis";
3
+ import { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ //#region src/adapters/pi/extension.d.ts
5
+ /** Connects the package's standard Pi entry to DoomPi's shared Cordis host. */
6
+ declare function activateGitExtension(pi: ExtensionAPI, overrides?: Partial<GitExtensionDependencies>): Promise<void>;
7
+ //#endregion
8
+ export { activateGitExtension, activateGitExtension as default };
9
+ //# sourceMappingURL=extension.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extension.d.mts","names":[],"sources":["../../../src/adapters/pi/extension.ts"],"mappings":";;;;;iBAuDsB,qBACpB,IAAI,cAGJ,YAAW,QAAQ,4BAClB"}
@@ -0,0 +1,2 @@
1
+ import{registerGitCommand as e}from"../../commands/doomGitCommand.mjs";import{registerRunWorktreeTool as t}from"./extensions/runWorktreeTool.mjs";import{createGitContainer as n}from"../../container/index.mjs";import{connectDoomCordisHost as r}from"@agimon-ai/doompi-extension-contracts/cordis-host";import{DOOM_HELP_SERVICE as i,requireDoomHelpService as a}from"@agimon-ai/doompi-extension-contracts/help";const o=`@agimon-ai/doompi-git`;function s(r,s,c=n()){r.inject([i],e=>{let t=a(e).register({source:o,moduleUrl:import.meta.url,skills:[{name:`doompi-use-git`,description:`Use @agimon-ai/doompi-git: Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent`}]});return()=>t.dispose()}),e(s,c.service),t(s,c.operations)}function c(e,t){s(e,t.pi,t.dependencies)}async function l(e,t={}){let i=n(t),a=await r(e,o),s=a.root.plugin(c,{pi:e,dependencies:i});try{await s}catch(e){try{await s.dispose()}finally{await a.dispose()}throw e}let l;e.on(`session_shutdown`,()=>l??=(async()=>{try{await s.dispose()}finally{await a.dispose()}})())}export{l as activateGitExtension,l as default,s as installGitRuntime};
2
+ //# sourceMappingURL=extension.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extension.mjs","names":[],"sources":["../../../src/adapters/pi/extension.ts"],"sourcesContent":["// @scaffold-generated\nimport { connectDoomCordisHost } from '@agimon-ai/doompi-extension-contracts/cordis-host';\nimport { DOOM_HELP_SERVICE, requireDoomHelpService } from '@agimon-ai/doompi-extension-contracts/help';\nimport type { Context } from '@deepseek-ai/cordis';\nimport type { ExtensionAPI } from '@earendil-works/pi-coding-agent';\nimport { registerGitCommand } from '../../commands/doomGitCommand.ts';\nimport { registerRunWorktreeTool } from './extensions/runWorktreeTool.ts';\nimport { createGitContainer } from '../../container';\nimport type { GitExtensionDependencies } from '../../types/extension';\n\nconst PACKAGE_SOURCE = '@agimon-ai/doompi-git';\n\n/**\n * Installs the package runtime inside its host-owned Cordis plugin fiber.\n *\n * Everything registered inside the effect is owned by the cordis fiber, so one\n * dispose on session shutdown unwinds it in reverse order. Yield a disposer for\n * each registration rather than tracking cleanup by hand; Pi can reload an\n * extension in-process, and a leaked listener duplicates work against stale\n * session state.\n */\nexport function installGitRuntime(\n cordis: Context,\n pi: ExtensionAPI,\n dependencies: GitExtensionDependencies = createGitContainer(),\n): void {\n cordis.inject([DOOM_HELP_SERVICE], (helpContext) => {\n const contribution = requireDoomHelpService(helpContext).register({\n source: PACKAGE_SOURCE,\n moduleUrl: import.meta.url,\n skills: [\n {\n name: 'doompi-use-git',\n description:\n 'Use @agimon-ai/doompi-git: Git worktree sessions for DoomPi: spawn an isolated worktree with its own session and manage it from the parent',\n },\n ],\n });\n return () => contribution.dispose();\n });\n\n registerGitCommand(pi, dependencies.service);\n registerRunWorktreeTool(pi, dependencies.operations);\n}\n\ninterface GitPluginConfig {\n readonly pi: ExtensionAPI;\n readonly dependencies: GitExtensionDependencies;\n}\n\nfunction gitPlugin(cordis: Context, config: GitPluginConfig): void {\n installGitRuntime(cordis, config.pi, config.dependencies);\n}\n\n/** Connects the package's standard Pi entry to DoomPi's shared Cordis host. */\nexport async function activateGitExtension(\n pi: ExtensionAPI,\n // Partial so a caller, and every test, can stand in one collaborator without\n // having to construct the rest of the graph it does not care about.\n overrides: Partial<GitExtensionDependencies> = {},\n): Promise<void> {\n const dependencies = createGitContainer(overrides);\n const connection = await connectDoomCordisHost(pi, PACKAGE_SOURCE);\n const fiber = connection.root.plugin(gitPlugin, { pi, dependencies });\n\n try {\n await fiber;\n } catch (error) {\n try {\n await fiber.dispose();\n } finally {\n await connection.dispose();\n }\n throw error;\n }\n\n let disposal: Promise<void> | undefined;\n pi.on(\n 'session_shutdown',\n () =>\n (disposal ??= (async () => {\n try {\n await fiber.dispose();\n } finally {\n await connection.dispose();\n }\n })()),\n );\n}\n\nexport default activateGitExtension;\n"],"mappings":"sZAUA,MAAM,EAAiB,wBAWvB,SAAgB,EACd,EACA,EACA,EAAyC,EAAmB,EACtD,CACN,EAAO,OAAO,CAAC,CAAiB,EAAI,GAAgB,CAClD,IAAM,EAAe,EAAuB,CAAW,CAAC,CAAC,SAAS,CAChE,OAAQ,EACR,UAAW,YAAY,IACvB,OAAQ,CACN,CACE,KAAM,iBACN,YACE,4IACJ,CACF,CACF,CAAC,EACD,UAAa,EAAa,QAAQ,CACpC,CAAC,EAED,EAAmB,EAAI,EAAa,OAAO,EAC3C,EAAwB,EAAI,EAAa,UAAU,CACrD,CAOA,SAAS,EAAU,EAAiB,EAA+B,CACjE,EAAkB,EAAQ,EAAO,GAAI,EAAO,YAAY,CAC1D,CAGA,eAAsB,EACpB,EAGA,EAA+C,CAAC,EACjC,CACf,IAAM,EAAe,EAAmB,CAAS,EAC3C,EAAa,MAAM,EAAsB,EAAI,CAAc,EAC3D,EAAQ,EAAW,KAAK,OAAO,EAAW,CAAE,KAAI,cAAa,CAAC,EAEpE,GAAI,CACF,MAAM,CACR,OAAS,EAAO,CACd,GAAI,CACF,MAAM,EAAM,QAAQ,CACtB,QAAU,CACR,MAAM,EAAW,QAAQ,CAC3B,CACA,MAAM,CACR,CAEA,IAAI,EACJ,EAAG,GACD,uBAEG,KAAc,SAAY,CACzB,GAAI,CACF,MAAM,EAAM,QAAQ,CACtB,QAAU,CACR,MAAM,EAAW,QAAQ,CAC3B,CACF,EAAA,CAAG,CACP,CACF"}
@@ -0,0 +1,15 @@
1
+ const e=require("../../../services/support/errors.cjs"),t=require("../../../schemas/runWorktreeTool.cjs"),n=`run_worktree`,r=new Set(Object.values(t.WORKTREE_ACTIONS));function i(n){if(typeof n!=`object`||!n||Array.isArray(n))throw e.invalidRequest(`run_worktree parameters must be an object.`,`Call it with one documented action shape.`);let i=n,a=i.action;if(typeof a!=`string`||!r.has(a))throw e.invalidRequest(`Unknown action ${typeof a==`string`?`'${a}'`:`value`}.`,`Use one of: ${[...r].join(`, `)}.`);let o=t.WORKTREE_ACTION_FIELDS[a],s=Object.keys(i).filter(e=>!o.includes(e));if(s.length>0)throw e.invalidRequest(`action='${a}' does not accept: ${s.join(`, `)}.`,`Fields for '${a}': ${o.join(`, `)}.`);for(let t of[`branch`,`id`])if(o.includes(t)&&t in i){let n=i[t];if(typeof n!=`string`||n.trim()===``)throw e.invalidRequest(`action='${a}' requires a nonblank '${t}'.`,`Pass a ${t}.`)}if(a===t.WORKTREE_ACTIONS.spawn_worktree&&typeof i.branch!=`string`)throw e.invalidRequest(`action='spawn_worktree' requires 'branch'.`,`Name the branch to create.`);if((a===t.WORKTREE_ACTIONS.close_worktree||a===t.WORKTREE_ACTIONS.status||a===t.WORKTREE_ACTIONS.merge)&&typeof i.id!=`string`)throw e.invalidRequest(`action='${a}' requires 'id'.`,`Call list to see the current ids.`);return i}function a(e){return`${e.id} ${e.branch} ${e.status} ${e.path}`}function o(...e){return[{type:`text`,text:e.join(`
2
+ `)}]}async function s(e,n,r){let i={cwd:r.cwd,sessionId:r.sessionManager.getSessionId()};switch(e.action){case t.WORKTREE_ACTIONS.spawn_worktree:{let t=await n.spawn(i,{branch:e.branch,...e.baseRef===void 0?{}:{baseRef:e.baseRef},...e.name===void 0?{}:{name:e.name}});return{content:o(`Worktree ${t.id} created on ${t.branch} from ${t.baseRef}.`,`Path: ${t.path}`,`Session ${t.sessionId} is starting; it appears nested under this one in the rail.`),details:{action:e.action,records:[t]}}}case t.WORKTREE_ACTIONS.close_worktree:{let t=await n.close(i,e.id,e.force??!1);return{content:o(`Worktree ${t.id} (${t.branch}) closed and removed.`),details:{action:e.action,records:[t]}}}case t.WORKTREE_ACTIONS.list:{let t=await n.list(i);return{content:t.length===0?o(`No worktrees for this repository.`):o(`${String(t.length)} worktree(s):`,...t.map(a)),details:{action:e.action,records:t}}}case t.WORKTREE_ACTIONS.status:{let{record:t,dirtyFiles:r}=await n.status(i,e.id);return{content:o(a(t),`Session: ${t.sessionId}`,r.length===0?`Clean.`:`${String(r.length)} uncommitted file(s): ${r.slice(0,20).join(`, `)}`),details:{action:e.action,records:[t]}}}case t.WORKTREE_ACTIONS.merge:{let t=await n.merge(i,e.id,...e.message===void 0?[]:[e.message]);return{content:o(`Merged ${t.branch} into this checkout.`),details:{action:e.action,records:[t]}}}case t.WORKTREE_ACTIONS.send:return await n.send(i,e.id,e.message),{content:o(`Message sent to worktree ${e.id}.`),details:{action:e.action}};case t.WORKTREE_ACTIONS.messages:{let t=await n.messages(i,e.id);return{content:t.length===0?o(`No messages.`):o(`${String(t.length)} message(s):`,...t.map(e=>`[${e.sentAt}] ${e.from}: ${e.text}`)),details:{action:e.action}}}case t.WORKTREE_ACTIONS.prune:{let t=e.dryRun??!1,r=await n.prune(i,t);return{content:o(t?`Prune plan (nothing was changed):`:`Pruned:`,`Remove: ${r.remove.length===0?`none`:r.remove.map(e=>e.id).join(`, `)}`,`Forget: ${r.forget.length===0?`none`:r.forget.map(e=>e.id).join(`, `)}`,`Kept, uncommitted work: ${r.keptDirty.length===0?`none`:r.keptDirty.map(e=>e.id).join(`, `)}`,`Untracked directories, left alone: ${r.untracked.length===0?`none`:r.untracked.join(`, `)}`),details:{action:e.action}}}}}function c(e,r){let a={name:n,label:`Worktree`,description:`Create and manage git worktrees, each with its own DoomPi session nested under this one.
3
+
4
+ Actions:
5
+ - spawn_worktree: create a worktree on a new branch and start a session in it. Requires 'branch'. Optional 'baseRef' (defaults to the current branch), 'name' for the rail, 'task' as the first message.
6
+ - close_worktree: stop a worktree's session and delete its directory. Requires 'id'. Refuses a dirty tree unless 'force' is true.
7
+ - list: every worktree for this repository, with status.
8
+ - status: one worktree and its uncommitted files. Requires 'id'.
9
+ - merge: merge a worktree's branch into this checkout. Requires 'id'. Refuses when this checkout is dirty.
10
+ - prune: remove orphaned worktrees. Pass 'dryRun' to see the plan without destroying anything.
11
+ - send: send a message to the other side of a worktree. Requires 'id' and 'message'.
12
+ - messages: read messages waiting for you from a worktree. Requires 'id'.
13
+
14
+ Worktrees live outside the repository, under ~/.pi/.doom/git/worktrees.`,parameters:t.RunWorktreeParams,prepareArguments:i,execute:async(e,t,n,a,o)=>s(i(t),r,o)};e.registerTool(a)}exports.RUN_WORKTREE_TOOL_NAME=n,exports.registerRunWorktreeTool=c,exports.validateParams=i;
15
+ //# sourceMappingURL=runWorktreeTool.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runWorktreeTool.cjs","names":["WORKTREE_ACTIONS","invalidRequest","WORKTREE_ACTION_FIELDS","RunWorktreeParams"],"sources":["../../../../src/adapters/pi/extensions/runWorktreeTool.ts"],"sourcesContent":["import type { AgentToolResult } from '@earendil-works/pi-agent-core';\nimport type { ExtensionAPI, ExtensionContext, ToolDefinition } from '@earendil-works/pi-coding-agent';\nimport {\n RunWorktreeParams,\n WORKTREE_ACTION_FIELDS,\n WORKTREE_ACTIONS,\n type WorktreeAction,\n type RunWorktreeToolParams,\n} from '../../../schemas/runWorktreeTool.ts';\nimport { invalidRequest } from '../../../services/support/errors.ts';\nimport type { WorktreeOperations } from '../../worktree/worktreeOperations.ts';\nimport type { WorktreeRecord } from '../../../types/worktreeRegistry.ts';\n\nexport const RUN_WORKTREE_TOOL_NAME = 'run_worktree';\n\nconst IMPLEMENTED_ACTIONS: ReadonlySet<string> = new Set(Object.values(WORKTREE_ACTIONS));\n\nconst DESCRIPTION = `Create and manage git worktrees, each with its own DoomPi session nested under this one.\n\nActions:\n- spawn_worktree: create a worktree on a new branch and start a session in it. Requires 'branch'. Optional 'baseRef' (defaults to the current branch), 'name' for the rail, 'task' as the first message.\n- close_worktree: stop a worktree's session and delete its directory. Requires 'id'. Refuses a dirty tree unless 'force' is true.\n- list: every worktree for this repository, with status.\n- status: one worktree and its uncommitted files. Requires 'id'.\n- merge: merge a worktree's branch into this checkout. Requires 'id'. Refuses when this checkout is dirty.\n- prune: remove orphaned worktrees. Pass 'dryRun' to see the plan without destroying anything.\n- send: send a message to the other side of a worktree. Requires 'id' and 'message'.\n- messages: read messages waiting for you from a worktree. Requires 'id'.\n\nWorktrees live outside the repository, under ~/.pi/.doom/git/worktrees.`;\n\nexport interface RunWorktreeToolDetails {\n action: WorktreeAction;\n records?: WorktreeRecord[];\n}\n\n/**\n * Rejects a call that is not exactly one documented action shape.\n *\n * Unknown fields are refused rather than ignored. A caller that passes 'branch'\n * to close_worktree has a different operation in mind than the one that would\n * run, and silently dropping the field would carry it out anyway.\n */\nexport function validateParams(input: unknown): RunWorktreeToolParams {\n if (typeof input !== 'object' || input === null || Array.isArray(input)) {\n throw invalidRequest('run_worktree parameters must be an object.', 'Call it with one documented action shape.');\n }\n const record = input as Record<string, unknown>;\n const action = record.action;\n if (typeof action !== 'string' || !IMPLEMENTED_ACTIONS.has(action)) {\n throw invalidRequest(\n `Unknown action ${typeof action === 'string' ? `'${action}'` : 'value'}.`,\n `Use one of: ${[...IMPLEMENTED_ACTIONS].join(', ')}.`,\n );\n }\n const allowed = WORKTREE_ACTION_FIELDS[action as WorktreeAction];\n const unexpected = Object.keys(record).filter((key) => !allowed.includes(key));\n if (unexpected.length > 0) {\n throw invalidRequest(\n `action='${action}' does not accept: ${unexpected.join(', ')}.`,\n `Fields for '${action}': ${allowed.join(', ')}.`,\n );\n }\n for (const field of ['branch', 'id'] as const) {\n if (allowed.includes(field) && field in record) {\n const value = record[field];\n if (typeof value !== 'string' || value.trim() === '') {\n throw invalidRequest(`action='${action}' requires a nonblank '${field}'.`, `Pass a ${field}.`);\n }\n }\n }\n if (action === WORKTREE_ACTIONS.spawn_worktree && typeof record.branch !== 'string') {\n throw invalidRequest(\"action='spawn_worktree' requires 'branch'.\", 'Name the branch to create.');\n }\n if (\n (action === WORKTREE_ACTIONS.close_worktree ||\n action === WORKTREE_ACTIONS.status ||\n action === WORKTREE_ACTIONS.merge) &&\n typeof record.id !== 'string'\n ) {\n throw invalidRequest(`action='${action}' requires 'id'.`, 'Call list to see the current ids.');\n }\n return record as RunWorktreeToolParams;\n}\n\nfunction describe(record: WorktreeRecord): string {\n return `${record.id} ${record.branch} ${record.status} ${record.path}`;\n}\n\n/** A tool result's content, which Pi expects as content blocks rather than a string. */\nfunction text(...lines: readonly string[]): [{ type: 'text'; text: string }] {\n return [{ type: 'text', text: lines.join('\\n') }];\n}\n\nasync function run(\n params: RunWorktreeToolParams,\n operations: WorktreeOperations,\n ctx: ExtensionContext,\n): Promise<AgentToolResult<RunWorktreeToolDetails>> {\n const context = { cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() };\n switch (params.action) {\n case WORKTREE_ACTIONS.spawn_worktree: {\n const record = await operations.spawn(context, {\n branch: params.branch,\n ...(params.baseRef === undefined ? {} : { baseRef: params.baseRef }),\n ...(params.name === undefined ? {} : { name: params.name }),\n });\n return {\n content: text(\n `Worktree ${record.id} created on ${record.branch} from ${record.baseRef}.`,\n `Path: ${record.path}`,\n `Session ${record.sessionId} is starting; it appears nested under this one in the rail.`,\n ),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.close_worktree: {\n const record = await operations.close(context, params.id, params.force ?? false);\n return {\n content: text(`Worktree ${record.id} (${record.branch}) closed and removed.`),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.list: {\n const records = await operations.list(context);\n return {\n content:\n records.length === 0\n ? text('No worktrees for this repository.')\n : text(`${String(records.length)} worktree(s):`, ...records.map(describe)),\n details: { action: params.action, records },\n };\n }\n case WORKTREE_ACTIONS.status: {\n const { record, dirtyFiles } = await operations.status(context, params.id);\n return {\n content: text(\n describe(record),\n `Session: ${record.sessionId}`,\n dirtyFiles.length === 0\n ? 'Clean.'\n : `${String(dirtyFiles.length)} uncommitted file(s): ${dirtyFiles.slice(0, 20).join(', ')}`,\n ),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.merge: {\n const record = await operations.merge(\n context,\n params.id,\n ...(params.message === undefined ? [] : ([params.message] as const)),\n );\n return {\n content: text(`Merged ${record.branch} into this checkout.`),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.send: {\n await operations.send(context, params.id, params.message);\n return { content: text(`Message sent to worktree ${params.id}.`), details: { action: params.action } };\n }\n case WORKTREE_ACTIONS.messages: {\n const waiting = await operations.messages(context, params.id);\n return {\n content:\n waiting.length === 0\n ? text('No messages.')\n : text(\n `${String(waiting.length)} message(s):`,\n ...waiting.map((entry) => `[${entry.sentAt}] ${entry.from}: ${entry.text}`),\n ),\n details: { action: params.action },\n };\n }\n case WORKTREE_ACTIONS.prune: {\n const dryRun = params.dryRun ?? false;\n const plan = await operations.prune(context, dryRun);\n const lines = [\n dryRun ? 'Prune plan (nothing was changed):' : 'Pruned:',\n `Remove: ${plan.remove.length === 0 ? 'none' : plan.remove.map((record) => record.id).join(', ')}`,\n `Forget: ${plan.forget.length === 0 ? 'none' : plan.forget.map((record) => record.id).join(', ')}`,\n `Kept, uncommitted work: ${plan.keptDirty.length === 0 ? 'none' : plan.keptDirty.map((record) => record.id).join(', ')}`,\n `Untracked directories, left alone: ${plan.untracked.length === 0 ? 'none' : plan.untracked.join(', ')}`,\n ];\n return { content: text(...lines), details: { action: params.action } };\n }\n }\n}\n\n/** Registers `run_worktree` on a Pi host, once. */\nexport function registerRunWorktreeTool(pi: ExtensionAPI, operations: WorktreeOperations): void {\n const tool: ToolDefinition<typeof RunWorktreeParams, RunWorktreeToolDetails> = {\n name: RUN_WORKTREE_TOOL_NAME,\n label: 'Worktree',\n description: DESCRIPTION,\n parameters: RunWorktreeParams,\n prepareArguments: validateParams,\n execute: async (_id, rawParams, _signal, _onUpdate, ctx) => run(validateParams(rawParams), operations, ctx),\n };\n pi.registerTool(tool);\n}\n"],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import{invalidRequest as e}from"../../../services/support/errors.mjs";import{RunWorktreeParams as t,WORKTREE_ACTIONS as n,WORKTREE_ACTION_FIELDS as r}from"../../../schemas/runWorktreeTool.mjs";const i=`run_worktree`,a=new Set(Object.values(n));function o(t){if(typeof t!=`object`||!t||Array.isArray(t))throw e(`run_worktree parameters must be an object.`,`Call it with one documented action shape.`);let i=t,o=i.action;if(typeof o!=`string`||!a.has(o))throw e(`Unknown action ${typeof o==`string`?`'${o}'`:`value`}.`,`Use one of: ${[...a].join(`, `)}.`);let s=r[o],c=Object.keys(i).filter(e=>!s.includes(e));if(c.length>0)throw e(`action='${o}' does not accept: ${c.join(`, `)}.`,`Fields for '${o}': ${s.join(`, `)}.`);for(let t of[`branch`,`id`])if(s.includes(t)&&t in i){let n=i[t];if(typeof n!=`string`||n.trim()===``)throw e(`action='${o}' requires a nonblank '${t}'.`,`Pass a ${t}.`)}if(o===n.spawn_worktree&&typeof i.branch!=`string`)throw e(`action='spawn_worktree' requires 'branch'.`,`Name the branch to create.`);if((o===n.close_worktree||o===n.status||o===n.merge)&&typeof i.id!=`string`)throw e(`action='${o}' requires 'id'.`,`Call list to see the current ids.`);return i}function s(e){return`${e.id} ${e.branch} ${e.status} ${e.path}`}function c(...e){return[{type:`text`,text:e.join(`
2
+ `)}]}async function l(e,t,r){let i={cwd:r.cwd,sessionId:r.sessionManager.getSessionId()};switch(e.action){case n.spawn_worktree:{let n=await t.spawn(i,{branch:e.branch,...e.baseRef===void 0?{}:{baseRef:e.baseRef},...e.name===void 0?{}:{name:e.name}});return{content:c(`Worktree ${n.id} created on ${n.branch} from ${n.baseRef}.`,`Path: ${n.path}`,`Session ${n.sessionId} is starting; it appears nested under this one in the rail.`),details:{action:e.action,records:[n]}}}case n.close_worktree:{let n=await t.close(i,e.id,e.force??!1);return{content:c(`Worktree ${n.id} (${n.branch}) closed and removed.`),details:{action:e.action,records:[n]}}}case n.list:{let n=await t.list(i);return{content:n.length===0?c(`No worktrees for this repository.`):c(`${String(n.length)} worktree(s):`,...n.map(s)),details:{action:e.action,records:n}}}case n.status:{let{record:n,dirtyFiles:r}=await t.status(i,e.id);return{content:c(s(n),`Session: ${n.sessionId}`,r.length===0?`Clean.`:`${String(r.length)} uncommitted file(s): ${r.slice(0,20).join(`, `)}`),details:{action:e.action,records:[n]}}}case n.merge:{let n=await t.merge(i,e.id,...e.message===void 0?[]:[e.message]);return{content:c(`Merged ${n.branch} into this checkout.`),details:{action:e.action,records:[n]}}}case n.send:return await t.send(i,e.id,e.message),{content:c(`Message sent to worktree ${e.id}.`),details:{action:e.action}};case n.messages:{let n=await t.messages(i,e.id);return{content:n.length===0?c(`No messages.`):c(`${String(n.length)} message(s):`,...n.map(e=>`[${e.sentAt}] ${e.from}: ${e.text}`)),details:{action:e.action}}}case n.prune:{let n=e.dryRun??!1,r=await t.prune(i,n);return{content:c(n?`Prune plan (nothing was changed):`:`Pruned:`,`Remove: ${r.remove.length===0?`none`:r.remove.map(e=>e.id).join(`, `)}`,`Forget: ${r.forget.length===0?`none`:r.forget.map(e=>e.id).join(`, `)}`,`Kept, uncommitted work: ${r.keptDirty.length===0?`none`:r.keptDirty.map(e=>e.id).join(`, `)}`,`Untracked directories, left alone: ${r.untracked.length===0?`none`:r.untracked.join(`, `)}`),details:{action:e.action}}}}}function u(e,n){let r={name:i,label:`Worktree`,description:`Create and manage git worktrees, each with its own DoomPi session nested under this one.
3
+
4
+ Actions:
5
+ - spawn_worktree: create a worktree on a new branch and start a session in it. Requires 'branch'. Optional 'baseRef' (defaults to the current branch), 'name' for the rail, 'task' as the first message.
6
+ - close_worktree: stop a worktree's session and delete its directory. Requires 'id'. Refuses a dirty tree unless 'force' is true.
7
+ - list: every worktree for this repository, with status.
8
+ - status: one worktree and its uncommitted files. Requires 'id'.
9
+ - merge: merge a worktree's branch into this checkout. Requires 'id'. Refuses when this checkout is dirty.
10
+ - prune: remove orphaned worktrees. Pass 'dryRun' to see the plan without destroying anything.
11
+ - send: send a message to the other side of a worktree. Requires 'id' and 'message'.
12
+ - messages: read messages waiting for you from a worktree. Requires 'id'.
13
+
14
+ Worktrees live outside the repository, under ~/.pi/.doom/git/worktrees.`,parameters:t,prepareArguments:o,execute:async(e,t,r,i,a)=>l(o(t),n,a)};e.registerTool(r)}export{i as RUN_WORKTREE_TOOL_NAME,u as registerRunWorktreeTool,o as validateParams};
15
+ //# sourceMappingURL=runWorktreeTool.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runWorktreeTool.mjs","names":[],"sources":["../../../../src/adapters/pi/extensions/runWorktreeTool.ts"],"sourcesContent":["import type { AgentToolResult } from '@earendil-works/pi-agent-core';\nimport type { ExtensionAPI, ExtensionContext, ToolDefinition } from '@earendil-works/pi-coding-agent';\nimport {\n RunWorktreeParams,\n WORKTREE_ACTION_FIELDS,\n WORKTREE_ACTIONS,\n type WorktreeAction,\n type RunWorktreeToolParams,\n} from '../../../schemas/runWorktreeTool.ts';\nimport { invalidRequest } from '../../../services/support/errors.ts';\nimport type { WorktreeOperations } from '../../worktree/worktreeOperations.ts';\nimport type { WorktreeRecord } from '../../../types/worktreeRegistry.ts';\n\nexport const RUN_WORKTREE_TOOL_NAME = 'run_worktree';\n\nconst IMPLEMENTED_ACTIONS: ReadonlySet<string> = new Set(Object.values(WORKTREE_ACTIONS));\n\nconst DESCRIPTION = `Create and manage git worktrees, each with its own DoomPi session nested under this one.\n\nActions:\n- spawn_worktree: create a worktree on a new branch and start a session in it. Requires 'branch'. Optional 'baseRef' (defaults to the current branch), 'name' for the rail, 'task' as the first message.\n- close_worktree: stop a worktree's session and delete its directory. Requires 'id'. Refuses a dirty tree unless 'force' is true.\n- list: every worktree for this repository, with status.\n- status: one worktree and its uncommitted files. Requires 'id'.\n- merge: merge a worktree's branch into this checkout. Requires 'id'. Refuses when this checkout is dirty.\n- prune: remove orphaned worktrees. Pass 'dryRun' to see the plan without destroying anything.\n- send: send a message to the other side of a worktree. Requires 'id' and 'message'.\n- messages: read messages waiting for you from a worktree. Requires 'id'.\n\nWorktrees live outside the repository, under ~/.pi/.doom/git/worktrees.`;\n\nexport interface RunWorktreeToolDetails {\n action: WorktreeAction;\n records?: WorktreeRecord[];\n}\n\n/**\n * Rejects a call that is not exactly one documented action shape.\n *\n * Unknown fields are refused rather than ignored. A caller that passes 'branch'\n * to close_worktree has a different operation in mind than the one that would\n * run, and silently dropping the field would carry it out anyway.\n */\nexport function validateParams(input: unknown): RunWorktreeToolParams {\n if (typeof input !== 'object' || input === null || Array.isArray(input)) {\n throw invalidRequest('run_worktree parameters must be an object.', 'Call it with one documented action shape.');\n }\n const record = input as Record<string, unknown>;\n const action = record.action;\n if (typeof action !== 'string' || !IMPLEMENTED_ACTIONS.has(action)) {\n throw invalidRequest(\n `Unknown action ${typeof action === 'string' ? `'${action}'` : 'value'}.`,\n `Use one of: ${[...IMPLEMENTED_ACTIONS].join(', ')}.`,\n );\n }\n const allowed = WORKTREE_ACTION_FIELDS[action as WorktreeAction];\n const unexpected = Object.keys(record).filter((key) => !allowed.includes(key));\n if (unexpected.length > 0) {\n throw invalidRequest(\n `action='${action}' does not accept: ${unexpected.join(', ')}.`,\n `Fields for '${action}': ${allowed.join(', ')}.`,\n );\n }\n for (const field of ['branch', 'id'] as const) {\n if (allowed.includes(field) && field in record) {\n const value = record[field];\n if (typeof value !== 'string' || value.trim() === '') {\n throw invalidRequest(`action='${action}' requires a nonblank '${field}'.`, `Pass a ${field}.`);\n }\n }\n }\n if (action === WORKTREE_ACTIONS.spawn_worktree && typeof record.branch !== 'string') {\n throw invalidRequest(\"action='spawn_worktree' requires 'branch'.\", 'Name the branch to create.');\n }\n if (\n (action === WORKTREE_ACTIONS.close_worktree ||\n action === WORKTREE_ACTIONS.status ||\n action === WORKTREE_ACTIONS.merge) &&\n typeof record.id !== 'string'\n ) {\n throw invalidRequest(`action='${action}' requires 'id'.`, 'Call list to see the current ids.');\n }\n return record as RunWorktreeToolParams;\n}\n\nfunction describe(record: WorktreeRecord): string {\n return `${record.id} ${record.branch} ${record.status} ${record.path}`;\n}\n\n/** A tool result's content, which Pi expects as content blocks rather than a string. */\nfunction text(...lines: readonly string[]): [{ type: 'text'; text: string }] {\n return [{ type: 'text', text: lines.join('\\n') }];\n}\n\nasync function run(\n params: RunWorktreeToolParams,\n operations: WorktreeOperations,\n ctx: ExtensionContext,\n): Promise<AgentToolResult<RunWorktreeToolDetails>> {\n const context = { cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() };\n switch (params.action) {\n case WORKTREE_ACTIONS.spawn_worktree: {\n const record = await operations.spawn(context, {\n branch: params.branch,\n ...(params.baseRef === undefined ? {} : { baseRef: params.baseRef }),\n ...(params.name === undefined ? {} : { name: params.name }),\n });\n return {\n content: text(\n `Worktree ${record.id} created on ${record.branch} from ${record.baseRef}.`,\n `Path: ${record.path}`,\n `Session ${record.sessionId} is starting; it appears nested under this one in the rail.`,\n ),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.close_worktree: {\n const record = await operations.close(context, params.id, params.force ?? false);\n return {\n content: text(`Worktree ${record.id} (${record.branch}) closed and removed.`),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.list: {\n const records = await operations.list(context);\n return {\n content:\n records.length === 0\n ? text('No worktrees for this repository.')\n : text(`${String(records.length)} worktree(s):`, ...records.map(describe)),\n details: { action: params.action, records },\n };\n }\n case WORKTREE_ACTIONS.status: {\n const { record, dirtyFiles } = await operations.status(context, params.id);\n return {\n content: text(\n describe(record),\n `Session: ${record.sessionId}`,\n dirtyFiles.length === 0\n ? 'Clean.'\n : `${String(dirtyFiles.length)} uncommitted file(s): ${dirtyFiles.slice(0, 20).join(', ')}`,\n ),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.merge: {\n const record = await operations.merge(\n context,\n params.id,\n ...(params.message === undefined ? [] : ([params.message] as const)),\n );\n return {\n content: text(`Merged ${record.branch} into this checkout.`),\n details: { action: params.action, records: [record] },\n };\n }\n case WORKTREE_ACTIONS.send: {\n await operations.send(context, params.id, params.message);\n return { content: text(`Message sent to worktree ${params.id}.`), details: { action: params.action } };\n }\n case WORKTREE_ACTIONS.messages: {\n const waiting = await operations.messages(context, params.id);\n return {\n content:\n waiting.length === 0\n ? text('No messages.')\n : text(\n `${String(waiting.length)} message(s):`,\n ...waiting.map((entry) => `[${entry.sentAt}] ${entry.from}: ${entry.text}`),\n ),\n details: { action: params.action },\n };\n }\n case WORKTREE_ACTIONS.prune: {\n const dryRun = params.dryRun ?? false;\n const plan = await operations.prune(context, dryRun);\n const lines = [\n dryRun ? 'Prune plan (nothing was changed):' : 'Pruned:',\n `Remove: ${plan.remove.length === 0 ? 'none' : plan.remove.map((record) => record.id).join(', ')}`,\n `Forget: ${plan.forget.length === 0 ? 'none' : plan.forget.map((record) => record.id).join(', ')}`,\n `Kept, uncommitted work: ${plan.keptDirty.length === 0 ? 'none' : plan.keptDirty.map((record) => record.id).join(', ')}`,\n `Untracked directories, left alone: ${plan.untracked.length === 0 ? 'none' : plan.untracked.join(', ')}`,\n ];\n return { content: text(...lines), details: { action: params.action } };\n }\n }\n}\n\n/** Registers `run_worktree` on a Pi host, once. */\nexport function registerRunWorktreeTool(pi: ExtensionAPI, operations: WorktreeOperations): void {\n const tool: ToolDefinition<typeof RunWorktreeParams, RunWorktreeToolDetails> = {\n name: RUN_WORKTREE_TOOL_NAME,\n label: 'Worktree',\n description: DESCRIPTION,\n parameters: RunWorktreeParams,\n prepareArguments: validateParams,\n execute: async (_id, rawParams, _signal, _onUpdate, ctx) => run(validateParams(rawParams), operations, ctx),\n };\n pi.registerTool(tool);\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../filesystem/paths.cjs"),t=require("../worktree/worktreeRegistry.cjs"),n=require("../../types/webWorktrees.cjs");function r(n){try{return t.createWorktreeRegistry(e.registryFile(n)).list().map(e=>({id:e.id,branch:e.branch,path:e.path,sessionId:e.status===`orphaned`?null:e.sessionId,orphaned:e.status===`orphaned`}))}catch{return[]}}function i(e,t){return e.length===t.length&&e.every((e,n)=>{let r=t[n];return r!==void 0&&e.id===r.id&&e.branch===r.branch&&e.sessionId===r.sessionId&&e.orphaned===r.orphaned})}function a(e=4e3){return{frameType:n.GIT_WORKTREES_TYPE,start(t){let n=new Map,a=new Map;return{payloadFor(e){let t=n.get(e.sessionId);return t===void 0?void 0:{worktrees:t}},sessionAdded(o){let s=()=>{let e=r(o.cwd),a=n.get(o.sessionId);a!==void 0&&i(a,e)||(n.set(o.sessionId,e),t.publish(o.sessionId,{worktrees:e}))};s();let c=setInterval(s,e);c.unref?.(),a.set(o.sessionId,c)},sessionRemoved(e){let t=a.get(e);t!==void 0&&clearInterval(t),a.delete(e),n.delete(e)},close(){for(let e of a.values())clearInterval(e);a.clear(),n.clear()}}}}}const o=[a()];exports.createWorktreesChannel=a,exports.webHubChannels=o;
2
+ //# sourceMappingURL=worktreesChannel.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktreesChannel.cjs","names":["createWorktreeRegistry","registryFile","GIT_WORKTREES_TYPE"],"sources":["../../../src/adapters/web/worktreesChannel.ts"],"sourcesContent":["/**\n * The worktrees data channel.\n *\n * DESIGN PATTERNS:\n * - Hub-scoped: it reads this machine's registry files, which a browser cannot.\n * One entry per session, keyed by the repository that session sits in, so two\n * sessions in the same checkout see the same list.\n * - Poll, do not watch. The registry is a single small JSON file written by\n * this package alone, and a watcher on a path that may not exist yet costs\n * more than re-reading a few hundred bytes.\n * - Publish only what the dock renders. Paths and provenance stay in the\n * registry; the page gets a view.\n *\n * AVOID:\n * - Throwing out of a poll. A registry that cannot be read is an empty list,\n * never a broken channel: the dock degrades to \"no worktrees\", which is the\n * truth as far as the page can tell.\n */\nimport type { HubChannelSource, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { GIT_WORKTREES_TYPE, type WorktreeView } from '../../types/webWorktrees.ts';\nimport { registryFile } from '../filesystem/paths.ts';\nimport { createWorktreeRegistry } from '../worktree/worktreeRegistry.ts';\n\n/** How often a session's registry is re-read. Worktrees change by hand, not by the second. */\nconst POLL_MS = 4000;\n\nfunction viewsFor(cwd: string): WorktreeView[] {\n try {\n return createWorktreeRegistry(registryFile(cwd))\n .list()\n .map((record) => ({\n id: record.id,\n branch: record.branch,\n path: record.path,\n sessionId: record.status === 'orphaned' ? null : record.sessionId,\n orphaned: record.status === 'orphaned',\n }));\n } catch {\n return [];\n }\n}\n\nfunction sameList(left: readonly WorktreeView[], right: readonly WorktreeView[]): boolean {\n if (left.length !== right.length) return false;\n return left.every((entry, index) => {\n const other = right[index];\n return (\n other !== undefined &&\n entry.id === other.id &&\n entry.branch === other.branch &&\n entry.sessionId === other.sessionId &&\n entry.orphaned === other.orphaned\n );\n });\n}\n\n/**\n * One poller per managed session, publishing only when the list actually\n * changed so an idle cockpit stays quiet.\n */\nexport function createWorktreesChannel(intervalMs: number = POLL_MS): WebHubChannel {\n return {\n frameType: GIT_WORKTREES_TYPE,\n start(host) {\n const latest = new Map<string, WorktreeView[]>();\n const timers = new Map<string, ReturnType<typeof setInterval>>();\n const source: HubChannelSource = {\n payloadFor(scope) {\n const worktrees = latest.get(scope.sessionId);\n return worktrees === undefined ? undefined : { worktrees };\n },\n sessionAdded(scope) {\n const publish = (): void => {\n const worktrees = viewsFor(scope.cwd);\n const previous = latest.get(scope.sessionId);\n if (previous !== undefined && sameList(previous, worktrees)) return;\n latest.set(scope.sessionId, worktrees);\n host.publish(scope.sessionId, { worktrees });\n };\n publish();\n const timer = setInterval(publish, intervalMs);\n timer.unref?.();\n timers.set(scope.sessionId, timer);\n },\n sessionRemoved(sessionId) {\n const timer = timers.get(sessionId);\n if (timer !== undefined) clearInterval(timer);\n timers.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const timer of timers.values()) clearInterval(timer);\n timers.clear();\n latest.clear();\n },\n };\n return source;\n },\n };\n}\n\n/** The named export the generated hub channel registry imports. */\nexport const webHubChannels: readonly WebHubChannel[] = [createWorktreesChannel()];\n"],"mappings":"mIA0BA,SAAS,EAAS,EAA6B,CAC7C,GAAI,CACF,OAAOA,EAAAA,uBAAuBC,EAAAA,aAAa,CAAG,CAAC,CAAC,CAC7C,KAAK,CAAC,CACN,IAAK,IAAY,CAChB,GAAI,EAAO,GACX,OAAQ,EAAO,OACf,KAAM,EAAO,KACb,UAAW,EAAO,SAAW,WAAa,KAAO,EAAO,UACxD,SAAU,EAAO,SAAW,UAC9B,EAAE,CACN,MAAQ,CACN,MAAO,CAAC,CACV,CACF,CAEA,SAAS,EAAS,EAA+B,EAAyC,CAExF,OADI,EAAK,SAAW,EAAM,QACnB,EAAK,OAAO,EAAO,IAAU,CAClC,IAAM,EAAQ,EAAM,GACpB,OACE,IAAU,IAAA,IACV,EAAM,KAAO,EAAM,IACnB,EAAM,SAAW,EAAM,QACvB,EAAM,YAAc,EAAM,WAC1B,EAAM,WAAa,EAAM,QAE7B,CAAC,CACH,CAMA,SAAgB,EAAuB,EAAqB,IAAwB,CAClF,MAAO,CACL,UAAWC,EAAAA,mBACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAS,IAAI,IA+BnB,MAAO,CA7BL,WAAW,EAAO,CAChB,IAAM,EAAY,EAAO,IAAI,EAAM,SAAS,EAC5C,OAAO,IAAc,IAAA,GAAY,IAAA,GAAY,CAAE,WAAU,CAC3D,EACA,aAAa,EAAO,CAClB,IAAM,MAAsB,CAC1B,IAAM,EAAY,EAAS,EAAM,GAAG,EAC9B,EAAW,EAAO,IAAI,EAAM,SAAS,EACvC,IAAa,IAAA,IAAa,EAAS,EAAU,CAAS,IAC1D,EAAO,IAAI,EAAM,UAAW,CAAS,EACrC,EAAK,QAAQ,EAAM,UAAW,CAAE,WAAU,CAAC,EAC7C,EACA,EAAQ,EACR,IAAM,EAAQ,YAAY,EAAS,CAAU,EAC7C,EAAM,QAAQ,EACd,EAAO,IAAI,EAAM,UAAW,CAAK,CACnC,EACA,eAAe,EAAW,CACxB,IAAM,EAAQ,EAAO,IAAI,CAAS,EAC9B,IAAU,IAAA,IAAW,cAAc,CAAK,EAC5C,EAAO,OAAO,CAAS,EACvB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAS,EAAO,OAAO,EAAG,cAAc,CAAK,EACxD,EAAO,MAAM,EACb,EAAO,MAAM,CACf,CAEU,CACd,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAuB,CAAC"}