opencode-crosstalk 0.0.0-stage → 0.2.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +460 -2
  3. package/assets/CursorGuard.png +0 -0
  4. package/assets/Wren.png +0 -0
  5. package/assets/avatars/ATTRIBUTION.md +18 -0
  6. package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
  7. package/assets/avatars/female/02-manager-b16a5b.png +0 -0
  8. package/assets/avatars/female/02-manager-e7a391.png +0 -0
  9. package/assets/avatars/female/03-coder-92594b.png +0 -0
  10. package/assets/avatars/female/03-coder-e5a07e.png +0 -0
  11. package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
  12. package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
  13. package/assets/avatars/female/08-tester-d78774.png +0 -0
  14. package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
  15. package/assets/avatars/female/10-builder-92594b.png +0 -0
  16. package/assets/avatars/female/10-builder-e5a07e.png +0 -0
  17. package/assets/avatars/female/13-researcher-92594b.png +0 -0
  18. package/assets/avatars/female/13-researcher-e7a391.png +0 -0
  19. package/assets/avatars/female/14-writer-623d36.png +0 -0
  20. package/assets/avatars/female/14-writer-e5a07e.png +0 -0
  21. package/assets/avatars/female/16-fixer-e7a391.png +0 -0
  22. package/assets/avatars/female/17-deployer-92594b.png +0 -0
  23. package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
  24. package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
  25. package/assets/avatars/female/23-mentor-e7a391.png +0 -0
  26. package/assets/avatars/female/25-pioneer-d78774.png +0 -0
  27. package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
  28. package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
  29. package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
  30. package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
  31. package/assets/avatars/female/34-optimizer-92594b.png +0 -0
  32. package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
  33. package/assets/avatars/female/36-integrator-d78774.png +0 -0
  34. package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
  35. package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
  36. package/assets/avatars/female/37-compiler-e7a391.png +0 -0
  37. package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
  38. package/assets/avatars/female/42-shipper-623d36.png +0 -0
  39. package/assets/avatars/female/46-steward-623d36.png +0 -0
  40. package/assets/avatars/female/46-steward-d78774.png +0 -0
  41. package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
  42. package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
  43. package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
  44. package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
  45. package/assets/avatars/index.html +4 -0
  46. package/assets/avatars/male/01-explorer-d78774.png +0 -0
  47. package/assets/avatars/male/06-planner-92594b.png +0 -0
  48. package/assets/avatars/male/06-planner-e7a391.png +0 -0
  49. package/assets/avatars/male/07-reviewer-623d36.png +0 -0
  50. package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
  51. package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
  52. package/assets/avatars/male/09-migrator-e7a391.png +0 -0
  53. package/assets/avatars/male/11-debugger-623d36.png +0 -0
  54. package/assets/avatars/male/11-debugger-d78774.png +0 -0
  55. package/assets/avatars/male/12-designer-b16a5b.png +0 -0
  56. package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
  57. package/assets/avatars/male/15-editor-d78774.png +0 -0
  58. package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
  59. package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
  60. package/assets/avatars/male/18-scout-623d36.png +0 -0
  61. package/assets/avatars/male/18-scout-d78774.png +0 -0
  62. package/assets/avatars/male/19-architect-b16a5b.png +0 -0
  63. package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
  64. package/assets/avatars/male/20-operator-92594b.png +0 -0
  65. package/assets/avatars/male/20-operator-e7a391.png +0 -0
  66. package/assets/avatars/male/21-coordinator-623d36.png +0 -0
  67. package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
  68. package/assets/avatars/male/22-auditor-d78774.png +0 -0
  69. package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
  70. package/assets/avatars/male/25-pioneer-623d36.png +0 -0
  71. package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
  72. package/assets/avatars/male/28-librarian-623d36.png +0 -0
  73. package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
  74. package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
  75. package/assets/avatars/male/30-tracker-e7a391.png +0 -0
  76. package/assets/avatars/male/31-gardener-92594b.png +0 -0
  77. package/assets/avatars/male/32-refactorer-623d36.png +0 -0
  78. package/assets/avatars/male/32-refactorer-d78774.png +0 -0
  79. package/assets/avatars/male/35-profiler-623d36.png +0 -0
  80. package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
  81. package/assets/avatars/male/38-parser-92594b.png +0 -0
  82. package/assets/avatars/male/38-parser-e5a07e.png +0 -0
  83. package/assets/avatars/male/39-linter-623d36.png +0 -0
  84. package/assets/avatars/male/39-linter-d78774.png +0 -0
  85. package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
  86. package/assets/avatars/male/41-patcher-92594b.png +0 -0
  87. package/assets/avatars/male/41-patcher-e7a391.png +0 -0
  88. package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
  89. package/assets/avatars/male/43-maintainer-d78774.png +0 -0
  90. package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
  91. package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
  92. package/assets/avatars/male/44-guardian-e7a391.png +0 -0
  93. package/assets/avatars/male/45-sentinel-92594b.png +0 -0
  94. package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
  95. package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
  96. package/assets/avatars/male/49-hacker-623d36.png +0 -0
  97. package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
  98. package/assets/avatars/male/50-wrangler-d78774.png +0 -0
  99. package/assets/avatars/manifest.json +1202 -0
  100. package/index.ts +11 -0
  101. package/package.json +65 -4
  102. package/src/config.ts +176 -0
  103. package/src/core/avatar.ts +92 -0
  104. package/src/core/claims.ts +302 -0
  105. package/src/core/clock.ts +31 -0
  106. package/src/core/directory.ts +48 -0
  107. package/src/core/format.ts +382 -0
  108. package/src/core/mailbox.ts +239 -0
  109. package/src/core/mesh.ts +781 -0
  110. package/src/core/registry.ts +306 -0
  111. package/src/deliverer.ts +86 -0
  112. package/src/events.ts +66 -0
  113. package/src/index.ts +323 -0
  114. package/src/rpc.ts +52 -0
  115. package/src/storage.ts +105 -0
  116. package/src/tools/args.ts +132 -0
  117. package/src/tools/claim.ts +87 -0
  118. package/src/tools/heartbeat.ts +152 -0
  119. package/src/tools/inbox.ts +76 -0
  120. package/src/tools/index.ts +62 -0
  121. package/src/tools/peers.ts +101 -0
  122. package/src/tools/send.ts +84 -0
  123. package/src/tools/status.ts +137 -0
  124. package/src/tools/types.ts +72 -0
  125. package/src/tools/wait.ts +96 -0
  126. package/src/tui/avatar.ts +188 -0
  127. package/src/tui/index.tsx +216 -0
  128. package/src/types.ts +147 -0
  129. package/tui.ts +8 -0
package/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @fileoverview Host directory entrypoint.
3
+ *
4
+ * OpenCode resolves a plugin directory through a root `index.ts`/`index.js`
5
+ * file — it does not read `package.json` `main`/`exports` for plugin
6
+ * directories. `src/index.ts` remains the real entry; installed directories
7
+ * (the `npm run setup` symlink, or a copy under `.opencode/plugins/`) reach it
8
+ * through this re-export.
9
+ */
10
+
11
+ export { default } from "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "opencode-crosstalk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "OpenCode plugin that lets sessions on the same server see each other, talk to each other, and lease the files they are editing",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Christoph Döllinger <christoph@christoph.cx>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MrDoe/opencode-crosstalk.git"
11
+ },
12
+ "homepage": "https://github.com/MrDoe/opencode-crosstalk#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/MrDoe/opencode-crosstalk/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "agents",
20
+ "coordination",
21
+ "multi-agent"
22
+ ],
23
+ "main": "./src/index.ts",
24
+ "types": "./src/index.ts",
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./core": "./src/core/mesh.ts",
28
+ "./rpc": "./src/rpc.ts",
29
+ "./tui": "./tui.ts"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "index.ts",
34
+ "tui.ts",
35
+ "assets",
36
+ "README.md"
37
+ ],
38
+ "scripts": {
39
+ "setup": "node scripts/setup.mjs",
40
+ "uninstall": "node scripts/setup.mjs uninstall",
41
+ "test": "node --test test/*.test.ts",
42
+ "test:e2e": "OPENCODE_E2E=1 node --test test/e2e/*.test.ts",
43
+ "typecheck": "tsc --noEmit",
44
+ "check": "node scripts/check.mjs"
45
+ },
46
+ "dependencies": {
47
+ "@opencode/plugin": "^2.0.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@opencode/plugin": "^2.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@opentui/core": "^0.5.10",
54
+ "@opentui/solid": "^0.5.10",
55
+ "@types/node": "^22.20.0",
56
+ "solid-js": "^1.9.12",
57
+ "typescript": "^5.9.3"
58
+ },
59
+ "overrides": {
60
+ "@babel/core": "^7.29.7",
61
+ "seroval": "^1.6.8",
62
+ "seroval-plugins": "^1.6.8"
63
+ },
64
+ "engines": {
65
+ "node": ">=22.18"
66
+ }
67
+ }
package/src/config.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @fileoverview Plugin option parsing.
3
+ *
4
+ * Options come from `opencode.jsonc` and are untrusted, so every field is
5
+ * validated and clamped. Bad values degrade to a documented default and emit a
6
+ * warning instead of failing plugin setup — a typo should not take an agent's
7
+ * coordination tools away mid-session.
8
+ */
9
+
10
+ import type { Scope } from "./types.ts"
11
+
12
+ export interface CrosstalkOptions {
13
+ /** Tool namespace. Defaults to `crosstalk`. */
14
+ namespace: string
15
+ /** Permission action guarding every tool. Defaults to `crosstalk`. */
16
+ permission: string
17
+ /** Expose the tools through Code Mode instead of directly. */
18
+ codemode: boolean
19
+ /** Which peers are visible: same project, same directory, or whole server. */
20
+ scope: Scope
21
+ /** A peer with no event for this long is reported as stale. */
22
+ staleAfterMs: number
23
+ /** A peer with no event for this long is dropped from the registry. */
24
+ evictAfterMs: number
25
+ /** Maximum retained messages per mailbox; oldest read messages go first. */
26
+ maxMessages: number
27
+ /** Messages older than this are pruned. */
28
+ messageTtlMs: number
29
+ /** Default lease duration for `crosstalk_claim`. */
30
+ claimTtlMs: number
31
+ /** Upper bound for any blocking tool call. */
32
+ maxWaitMs: number
33
+ /** Poll interval for the barrier waits. */
34
+ pollMs: number
35
+ /** Progress heartbeat interval while blocked; `0` disables heartbeats. */
36
+ heartbeatMs: number
37
+ /** Persist peers and claims through `ctx.storage`. */
38
+ persist: boolean
39
+ /** Storage key prefix. */
40
+ storageKey: string
41
+ /** Append a short briefing to the agent's system prompt. */
42
+ announce: boolean
43
+ }
44
+
45
+ export const DEFAULTS: CrosstalkOptions = {
46
+ namespace: "crosstalk",
47
+ permission: "crosstalk",
48
+ codemode: false,
49
+ scope: "project",
50
+ staleAfterMs: 600_000,
51
+ evictAfterMs: 3_600_000,
52
+ maxMessages: 100,
53
+ messageTtlMs: 86_400_000,
54
+ claimTtlMs: 300_000,
55
+ maxWaitMs: 120_000,
56
+ pollMs: 1_000,
57
+ heartbeatMs: 10_000,
58
+ persist: true,
59
+ storageKey: "crosstalk",
60
+ announce: true,
61
+ }
62
+
63
+ const SCOPES: readonly Scope[] = ["project", "location", "server"]
64
+
65
+ export interface ParsedOptions {
66
+ options: CrosstalkOptions
67
+ warnings: string[]
68
+ }
69
+
70
+ /** Read a finite number, clamped to `[min, max]`. */
71
+ function numberOption(
72
+ raw: Record<string, unknown>,
73
+ key: string,
74
+ fallback: number,
75
+ min: number,
76
+ max: number,
77
+ warnings: string[],
78
+ ): number {
79
+ const value = raw[key]
80
+ if (value === undefined) return fallback
81
+ if (typeof value !== "number" || !Number.isFinite(value)) {
82
+ warnings.push(`option "${key}" must be a finite number; using ${fallback}`)
83
+ return fallback
84
+ }
85
+ if (value < min) {
86
+ warnings.push(`option "${key}" (${value}) is below ${min}; clamped`)
87
+ return min
88
+ }
89
+ if (value > max) {
90
+ warnings.push(`option "${key}" (${value}) is above ${max}; clamped`)
91
+ return max
92
+ }
93
+ return value
94
+ }
95
+
96
+ function booleanOption(raw: Record<string, unknown>, key: string, fallback: boolean, warnings: string[]): boolean {
97
+ const value = raw[key]
98
+ if (value === undefined) return fallback
99
+ if (typeof value !== "boolean") {
100
+ warnings.push(`option "${key}" must be a boolean; using ${fallback}`)
101
+ return fallback
102
+ }
103
+ return value
104
+ }
105
+
106
+ function stringOption(
107
+ raw: Record<string, unknown>,
108
+ key: string,
109
+ fallback: string,
110
+ pattern: RegExp,
111
+ warnings: string[],
112
+ ): string {
113
+ const value = raw[key]
114
+ if (value === undefined) return fallback
115
+ if (typeof value !== "string" || value.length === 0) {
116
+ warnings.push(`option "${key}" must be a non-empty string; using ${fallback}`)
117
+ return fallback
118
+ }
119
+ if (!pattern.test(value)) {
120
+ warnings.push(`option "${key}" (${JSON.stringify(value)}) has unsupported characters; using ${fallback}`)
121
+ return fallback
122
+ }
123
+ return value
124
+ }
125
+
126
+ /**
127
+ * Validate raw plugin options. Unknown keys are reported once so typos are
128
+ * visible without being fatal.
129
+ */
130
+ export function parseOptions(raw: unknown): ParsedOptions {
131
+ const warnings: string[] = []
132
+ if (raw === undefined || raw === null) return { options: { ...DEFAULTS }, warnings }
133
+ if (typeof raw !== "object" || Array.isArray(raw)) {
134
+ return { options: { ...DEFAULTS }, warnings: ["plugin options must be an object; using defaults"] }
135
+ }
136
+
137
+ const input = raw as Record<string, unknown>
138
+ for (const key of Object.keys(input)) {
139
+ if (!(key in DEFAULTS)) warnings.push(`unknown option "${key}" ignored`)
140
+ }
141
+
142
+ const scopeInput = input.scope
143
+ let scope: Scope = DEFAULTS.scope
144
+ if (scopeInput !== undefined) {
145
+ if (typeof scopeInput === "string" && (SCOPES as readonly string[]).includes(scopeInput)) {
146
+ scope = scopeInput as Scope
147
+ } else {
148
+ warnings.push(`option "scope" must be one of ${SCOPES.join(", ")}; using ${DEFAULTS.scope}`)
149
+ }
150
+ }
151
+
152
+ const staleAfterMs = numberOption(input, "staleAfterMs", DEFAULTS.staleAfterMs, 1_000, 86_400_000, warnings)
153
+ const evictRaw = numberOption(input, "evictAfterMs", DEFAULTS.evictAfterMs, 1_000, 604_800_000, warnings)
154
+
155
+ return {
156
+ warnings,
157
+ options: {
158
+ namespace: stringOption(input, "namespace", DEFAULTS.namespace, /^[A-Za-z0-9_-]+$/, warnings),
159
+ permission: stringOption(input, "permission", DEFAULTS.permission, /^[A-Za-z0-9_*-]+$/, warnings),
160
+ codemode: booleanOption(input, "codemode", DEFAULTS.codemode, warnings),
161
+ scope,
162
+ staleAfterMs,
163
+ // Eviction can never be more eager than the stale threshold.
164
+ evictAfterMs: Math.max(evictRaw, staleAfterMs),
165
+ maxMessages: numberOption(input, "maxMessages", DEFAULTS.maxMessages, 1, 10_000, warnings),
166
+ messageTtlMs: numberOption(input, "messageTtlMs", DEFAULTS.messageTtlMs, 1_000, 2_592_000_000, warnings),
167
+ claimTtlMs: numberOption(input, "claimTtlMs", DEFAULTS.claimTtlMs, 1_000, 86_400_000, warnings),
168
+ maxWaitMs: numberOption(input, "maxWaitMs", DEFAULTS.maxWaitMs, 1_000, 600_000, warnings),
169
+ pollMs: numberOption(input, "pollMs", DEFAULTS.pollMs, 50, 30_000, warnings),
170
+ heartbeatMs: numberOption(input, "heartbeatMs", DEFAULTS.heartbeatMs, 0, 600_000, warnings),
171
+ persist: booleanOption(input, "persist", DEFAULTS.persist, warnings),
172
+ storageKey: stringOption(input, "storageKey", DEFAULTS.storageKey, /^[A-Za-z0-9_./-]+$/, warnings),
173
+ announce: booleanOption(input, "announce", DEFAULTS.announce, warnings),
174
+ },
175
+ }
176
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @fileoverview Avatar selection: map a session to one of the bundled Personas.
3
+ *
4
+ * Host-free and shared. The server plugin runs it once, the first time a
5
+ * session declares a task, and freezes the result on the peer record — the
6
+ * artwork never changes afterwards. The TUI only renders what it is given.
7
+ * Unit tests hand it small entry arrays, so no manifest has to be on disk.
8
+ */
9
+
10
+ /** One entry of `assets/avatars/manifest.json`. */
11
+ export interface AvatarEntry {
12
+ file: string
13
+ pool: string
14
+ role: string
15
+ skin?: string
16
+ mouth?: string
17
+ hair?: string
18
+ eyes?: string
19
+ clothes?: string
20
+ nose?: string
21
+ hairColor?: string
22
+ clothingColor?: string
23
+ }
24
+
25
+ export type AvatarPool = "female" | "male"
26
+
27
+ /** The declared fields a portrait is derived from. */
28
+ export interface AvatarSession {
29
+ sessionID: string
30
+ name?: string
31
+ role?: string
32
+ avatar?: string
33
+ }
34
+
35
+ /**
36
+ * Gender hint from the declared avatar emoji. Personas are hand-curated into
37
+ * two pools, so the emoji only steers the pool; the tone is baked into the art.
38
+ */
39
+ export function poolHint(avatar: string | undefined): AvatarPool | undefined {
40
+ if (!avatar) return undefined
41
+ if (avatar.includes("👩") || avatar.includes("👧") || avatar.includes("👵")) return "female"
42
+ if (avatar.includes("👨") || avatar.includes("👦") || avatar.includes("👴") || avatar.includes("🧔")) return "male"
43
+ return undefined
44
+ }
45
+
46
+ /** FNV-1a, so a session's avatar is stable across reloads and processes. */
47
+ export function hash(input: string): number {
48
+ let value = 2166136261
49
+ for (let index = 0; index < input.length; index += 1) {
50
+ value ^= input.charCodeAt(index)
51
+ value = Math.imul(value, 16777619)
52
+ }
53
+ return value >>> 0
54
+ }
55
+
56
+ /**
57
+ * Pick the avatar for one session:
58
+ *
59
+ * 1. the declared emoji picks a pool when it names a gender;
60
+ * 2. a role that matches a seed wins inside that pool;
61
+ * 3. otherwise the name (or session id) hashes to a stable entry.
62
+ */
63
+ export function chooseAvatar(
64
+ entries: readonly AvatarEntry[],
65
+ session: AvatarSession,
66
+ ): AvatarEntry | undefined {
67
+ if (entries.length === 0) return undefined
68
+ const pool = poolHint(session.avatar)
69
+ const pooled = pool ? entries.filter((entry) => entry.pool === pool) : entries
70
+ const role = session.role?.toLowerCase()
71
+ const byRole = role ? pooled.filter((entry) => entry.role.toLowerCase() === role) : []
72
+ const candidates = byRole.length > 0 ? byRole : pooled.length > 0 ? pooled : entries
73
+ return candidates[hash(session.name ?? session.sessionID) % candidates.length]
74
+ }
75
+
76
+ /**
77
+ * Whether a declaration carries work: a role, a goal, or a current summary. A
78
+ * bare name is a nickname, not a task — a session that only named itself has
79
+ * none yet.
80
+ */
81
+ export function hasTask(declared: { role?: string; goal?: string; summary?: string } | undefined): boolean {
82
+ return declared?.role !== undefined || declared?.goal !== undefined || declared?.summary !== undefined
83
+ }
84
+
85
+ /**
86
+ * The frozen portrait path (`pool/file` inside `assets/avatars/`) for one
87
+ * session, or `undefined` when there is no pool to pick from.
88
+ */
89
+ export function portraitFor(entries: readonly AvatarEntry[], session: AvatarSession): string | undefined {
90
+ const entry = chooseAvatar(entries, session)
91
+ return entry === undefined ? undefined : `${entry.pool}/${entry.file}`
92
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * @fileoverview Resource leases.
3
+ *
4
+ * Two agents editing the same file is the coordination failure that actually
5
+ * bites in a shared repository, so a claim is a hard, exclusive, expiring
6
+ * lease rather than a convention. Keys are normalized paths (or any named
7
+ * resource string) so `"./src/a.ts"`, `"src//a.ts"` and `"src/a.ts/"` all
8
+ * collide, and relative paths resolve against the holder's directory so two
9
+ * worktrees of the same project do not shadow each other.
10
+ *
11
+ * Keys are compared exactly — there is no pattern matching, so `*` and `**`
12
+ * are ordinary characters inside a segment. Leasing a directory covers only
13
+ * that key, never its children; the tool text says so to the model.
14
+ */
15
+
16
+ import type { Claim, ClaimConflict, ClaimOutcome } from "../types.ts"
17
+
18
+ export interface ClaimOptions {
19
+ now(): number
20
+ defaultTtlMs: number
21
+ }
22
+
23
+ export interface ClaimRequest {
24
+ holder: string
25
+ resources: readonly string[]
26
+ ttlMs?: number
27
+ note?: string
28
+ /** Steal leases held by others. */
29
+ force?: boolean
30
+ /** Default true: one conflict aborts the whole request. */
31
+ atomic?: boolean
32
+ baseDir?: string
33
+ }
34
+
35
+ export interface ListFilter {
36
+ holder?: string
37
+ /** Only claims whose key matches this resource. */
38
+ resource?: string
39
+ includeExpired?: boolean
40
+ baseDir?: string
41
+ }
42
+
43
+ /**
44
+ * Normalize a resource string into a comparison key.
45
+ *
46
+ * Windows-looking paths (drive letter or backslashes) are case-folded because
47
+ * that filesystem is; POSIX paths keep their case. Glob metacharacters carry
48
+ * no meaning here: `src/auth/**` normalizes to the literal `src/auth/**`.
49
+ */
50
+ export function normalizeResource(raw: string, baseDir?: string): string {
51
+ const original = raw.trim()
52
+ if (original === "") return ""
53
+ const windows = original.includes("\\") || /^[a-zA-Z]:/.test(original)
54
+ const slashed = original.replaceAll("\\", "/")
55
+ // A base directory can make a relative resource absolute, so `absolute` has
56
+ // to be decided from the combined path, not from the raw input.
57
+ const source = slashed.startsWith("/")
58
+ ? slashed
59
+ : baseDir
60
+ ? `${baseDir.replaceAll("\\", "/").replace(/\/+$/, "")}/${slashed}`
61
+ : slashed
62
+ const absolute = source.startsWith("/")
63
+
64
+ const out: string[] = []
65
+ for (const segment of source.split("/")) {
66
+ if (segment === "" || segment === ".") continue
67
+ if (segment === "..") {
68
+ const last = out.at(-1)
69
+ if (last !== undefined && last !== "..") out.pop()
70
+ else if (!absolute) out.push("..")
71
+ continue
72
+ }
73
+ out.push(segment)
74
+ }
75
+
76
+ const joined = out.join("/")
77
+ const result = absolute ? `/${joined}` : joined
78
+ return windows ? result.toLowerCase() : result
79
+ }
80
+
81
+ export class ClaimTable {
82
+ readonly #claims = new Map<string, Claim>()
83
+ readonly #now: () => number
84
+ readonly #defaultTtlMs: number
85
+
86
+ constructor(options: ClaimOptions) {
87
+ this.#now = options.now
88
+ this.#defaultTtlMs = Math.max(1, options.defaultTtlMs)
89
+ }
90
+
91
+ get size(): number {
92
+ return this.#claims.size
93
+ }
94
+
95
+ normalize(raw: string, baseDir?: string): string {
96
+ return normalizeResource(raw, baseDir)
97
+ }
98
+
99
+ /**
100
+ * Take leases.
101
+ *
102
+ * Two phases on purpose: the whole request is planned against current state
103
+ * before anything is written, so an atomic request that hits one conflict
104
+ * leaves the table exactly as it found it — including keys it had already
105
+ * staged earlier in the same call.
106
+ */
107
+ claim(request: ClaimRequest): ClaimOutcome {
108
+ const atomic = request.atomic !== false
109
+ const ttl = this.#resolveTtl(request.ttlMs)
110
+ const now = this.#now()
111
+
112
+ type Step =
113
+ | { action: "take"; key: string; raw: string; previous: Claim | undefined }
114
+ | { action: "renew"; key: string; previous: Claim }
115
+ | { action: "skip" }
116
+
117
+ const outcome: ClaimOutcome = {
118
+ claimed: [],
119
+ renewed: [],
120
+ released: [],
121
+ stolen: [],
122
+ conflicts: [],
123
+ rolledBack: false,
124
+ }
125
+ const plan: Step[] = []
126
+ const seen = new Set<string>()
127
+
128
+ for (const raw of request.resources) {
129
+ const key = this.normalize(raw, request.baseDir)
130
+ if (key === "" || seen.has(key)) continue
131
+ seen.add(key)
132
+
133
+ const existing = this.#claims.get(key)
134
+ const live = existing !== undefined && existing.expires > now
135
+ const mine = live && existing.holder === request.holder
136
+
137
+ if (live && !mine && !request.force) {
138
+ outcome.conflicts.push(this.#conflict(key, raw, existing))
139
+ if (atomic) {
140
+ outcome.rolledBack = true
141
+ return outcome
142
+ }
143
+ plan.push({ action: "skip" })
144
+ continue
145
+ }
146
+
147
+ if (live && !mine) outcome.stolen.push(key)
148
+
149
+ if (mine && existing) {
150
+ plan.push({ action: "renew", key, previous: existing })
151
+ continue
152
+ }
153
+
154
+ // Not live, or ours to take: a lapsed lease is not a conflict, because
155
+ // the previous holder let it expire.
156
+ plan.push({ action: "take", key, raw, previous: existing })
157
+ }
158
+
159
+ for (const step of plan) {
160
+ if (step.action === "skip") continue
161
+ if (step.action === "renew") {
162
+ step.previous.expires = now + ttl
163
+ step.previous.note = request.note ?? step.previous.note
164
+ outcome.renewed.push(step.key)
165
+ continue
166
+ }
167
+ const acquired = step.previous?.holder === request.holder ? (step.previous.acquired ?? now) : now
168
+ this.#claims.set(step.key, {
169
+ key: step.key,
170
+ raw: step.raw,
171
+ holder: request.holder,
172
+ note: request.note,
173
+ acquired,
174
+ expires: now + ttl,
175
+ })
176
+ outcome.claimed.push(step.key)
177
+ }
178
+
179
+ return outcome
180
+ }
181
+
182
+ /** Extend leases the caller already holds. Unknown or foreign keys are reported. */
183
+ renew(request: ClaimRequest): ClaimOutcome {
184
+ const now = this.#now()
185
+ const ttl = this.#resolveTtl(request.ttlMs)
186
+ const outcome: ClaimOutcome = { claimed: [], renewed: [], released: [], stolen: [], conflicts: [], rolledBack: false }
187
+ const atomic = request.atomic !== false
188
+
189
+ for (const raw of request.resources) {
190
+ const key = this.normalize(raw, request.baseDir)
191
+ if (key === "") continue
192
+ const existing = this.#claims.get(key)
193
+ if (!existing || existing.expires <= now) {
194
+ outcome.conflicts.push({ key, raw, holder: existing?.holder ?? "", expires: existing?.expires ?? 0 })
195
+ if (atomic) {
196
+ outcome.rolledBack = true
197
+ return outcome
198
+ }
199
+ continue
200
+ }
201
+ if (existing.holder !== request.holder) {
202
+ outcome.conflicts.push(this.#conflict(key, raw, existing))
203
+ if (atomic) {
204
+ outcome.rolledBack = true
205
+ return outcome
206
+ }
207
+ continue
208
+ }
209
+ existing.expires = now + ttl
210
+ existing.note = request.note ?? existing.note
211
+ outcome.renewed.push(key)
212
+ }
213
+
214
+ return outcome
215
+ }
216
+
217
+ /** Release leases. Only the holder may release, unless `force` is set. */
218
+ release(request: ClaimRequest): ClaimOutcome {
219
+ const now = this.#now()
220
+ const outcome: ClaimOutcome = { claimed: [], renewed: [], released: [], stolen: [], conflicts: [], rolledBack: false }
221
+
222
+ for (const raw of request.resources) {
223
+ const key = this.normalize(raw, request.baseDir)
224
+ if (key === "") continue
225
+ const existing = this.#claims.get(key)
226
+ if (!existing || existing.expires <= now) {
227
+ this.#claims.delete(key)
228
+ continue
229
+ }
230
+ if (existing.holder !== request.holder && !request.force) {
231
+ outcome.conflicts.push(this.#conflict(key, raw, existing))
232
+ continue
233
+ }
234
+ if (existing.holder !== request.holder) outcome.stolen.push(key)
235
+ this.#claims.delete(key)
236
+ outcome.released.push(key)
237
+ }
238
+
239
+ return outcome
240
+ }
241
+
242
+ list(filter: ListFilter = {}): Claim[] {
243
+ const now = this.#now()
244
+ const resourceKey = filter.resource === undefined ? undefined : this.normalize(filter.resource, filter.baseDir)
245
+ const claims: Claim[] = []
246
+ for (const claim of this.#claims.values()) {
247
+ if (claim.expires <= now && !filter.includeExpired) continue
248
+ if (filter.holder !== undefined && claim.holder !== filter.holder) continue
249
+ if (resourceKey !== undefined && claim.key !== resourceKey) continue
250
+ claims.push(claim)
251
+ }
252
+ claims.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
253
+ return claims
254
+ }
255
+
256
+ /** Keys currently held by a session. */
257
+ heldBy(holder: string): string[] {
258
+ return this.list({ holder }).map((claim) => claim.key)
259
+ }
260
+
261
+ /** True when nothing live holds the resource. */
262
+ isFree(raw: string, baseDir?: string): boolean {
263
+ const key = this.normalize(raw, baseDir)
264
+ if (key === "") return true
265
+ const existing = this.#claims.get(key)
266
+ return !existing || existing.expires <= this.#now()
267
+ }
268
+
269
+ /** Delete expired leases. Returns how many were removed. */
270
+ prune(): number {
271
+ const now = this.#now()
272
+ let removed = 0
273
+ for (const [key, claim] of this.#claims) {
274
+ if (claim.expires <= now) {
275
+ this.#claims.delete(key)
276
+ removed += 1
277
+ }
278
+ }
279
+ return removed
280
+ }
281
+
282
+ /** Every live claim, for snapshotting. */
283
+ entries(): Claim[] {
284
+ return this.list({ includeExpired: false })
285
+ }
286
+
287
+ /**
288
+ * Re-insert a claim verbatim, used when restoring a snapshot where the key
289
+ * has already been normalized. Callers must check `isFree` first.
290
+ */
291
+ adopt(claim: Claim): void {
292
+ this.#claims.set(claim.key, { ...claim })
293
+ }
294
+
295
+ #resolveTtl(ttlMs: number | undefined): number {
296
+ return Math.max(1, ttlMs ?? this.#defaultTtlMs)
297
+ }
298
+
299
+ #conflict(key: string, raw: string, claim: Claim): ClaimConflict {
300
+ return { key, raw, holder: claim.holder, note: claim.note, expires: claim.expires }
301
+ }
302
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview Injectable time source and an abortable sleep.
3
+ *
4
+ * Every time-dependent code path in `src/core` takes a `now()` function so TTL
5
+ * and long-poll behaviour is deterministic under test instead of timer-bound.
6
+ */
7
+
8
+ export interface Clock {
9
+ now(): number
10
+ }
11
+
12
+ export const systemClock: Clock = { now: () => Date.now() }
13
+
14
+ /** Sleep that resolves early (without throwing) when `signal` aborts. */
15
+ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
16
+ return new Promise<void>((resolve) => {
17
+ if (signal?.aborted || ms <= 0) {
18
+ resolve()
19
+ return
20
+ }
21
+ const onAbort = () => {
22
+ clearTimeout(timer)
23
+ resolve()
24
+ }
25
+ const timer = setTimeout(() => {
26
+ signal?.removeEventListener("abort", onAbort)
27
+ resolve()
28
+ }, ms)
29
+ signal?.addEventListener("abort", onAbort, { once: true })
30
+ })
31
+ }