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.
- package/LICENSE +21 -0
- package/README.md +460 -2
- package/assets/CursorGuard.png +0 -0
- package/assets/Wren.png +0 -0
- package/assets/avatars/ATTRIBUTION.md +18 -0
- package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
- package/assets/avatars/female/02-manager-b16a5b.png +0 -0
- package/assets/avatars/female/02-manager-e7a391.png +0 -0
- package/assets/avatars/female/03-coder-92594b.png +0 -0
- package/assets/avatars/female/03-coder-e5a07e.png +0 -0
- package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
- package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
- package/assets/avatars/female/08-tester-d78774.png +0 -0
- package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
- package/assets/avatars/female/10-builder-92594b.png +0 -0
- package/assets/avatars/female/10-builder-e5a07e.png +0 -0
- package/assets/avatars/female/13-researcher-92594b.png +0 -0
- package/assets/avatars/female/13-researcher-e7a391.png +0 -0
- package/assets/avatars/female/14-writer-623d36.png +0 -0
- package/assets/avatars/female/14-writer-e5a07e.png +0 -0
- package/assets/avatars/female/16-fixer-e7a391.png +0 -0
- package/assets/avatars/female/17-deployer-92594b.png +0 -0
- package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
- package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
- package/assets/avatars/female/23-mentor-e7a391.png +0 -0
- package/assets/avatars/female/25-pioneer-d78774.png +0 -0
- package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
- package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
- package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
- package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
- package/assets/avatars/female/34-optimizer-92594b.png +0 -0
- package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
- package/assets/avatars/female/36-integrator-d78774.png +0 -0
- package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
- package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
- package/assets/avatars/female/37-compiler-e7a391.png +0 -0
- package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
- package/assets/avatars/female/42-shipper-623d36.png +0 -0
- package/assets/avatars/female/46-steward-623d36.png +0 -0
- package/assets/avatars/female/46-steward-d78774.png +0 -0
- package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
- package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
- package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
- package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
- package/assets/avatars/index.html +4 -0
- package/assets/avatars/male/01-explorer-d78774.png +0 -0
- package/assets/avatars/male/06-planner-92594b.png +0 -0
- package/assets/avatars/male/06-planner-e7a391.png +0 -0
- package/assets/avatars/male/07-reviewer-623d36.png +0 -0
- package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
- package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
- package/assets/avatars/male/09-migrator-e7a391.png +0 -0
- package/assets/avatars/male/11-debugger-623d36.png +0 -0
- package/assets/avatars/male/11-debugger-d78774.png +0 -0
- package/assets/avatars/male/12-designer-b16a5b.png +0 -0
- package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
- package/assets/avatars/male/15-editor-d78774.png +0 -0
- package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
- package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
- package/assets/avatars/male/18-scout-623d36.png +0 -0
- package/assets/avatars/male/18-scout-d78774.png +0 -0
- package/assets/avatars/male/19-architect-b16a5b.png +0 -0
- package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
- package/assets/avatars/male/20-operator-92594b.png +0 -0
- package/assets/avatars/male/20-operator-e7a391.png +0 -0
- package/assets/avatars/male/21-coordinator-623d36.png +0 -0
- package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
- package/assets/avatars/male/22-auditor-d78774.png +0 -0
- package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
- package/assets/avatars/male/25-pioneer-623d36.png +0 -0
- package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
- package/assets/avatars/male/28-librarian-623d36.png +0 -0
- package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
- package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
- package/assets/avatars/male/30-tracker-e7a391.png +0 -0
- package/assets/avatars/male/31-gardener-92594b.png +0 -0
- package/assets/avatars/male/32-refactorer-623d36.png +0 -0
- package/assets/avatars/male/32-refactorer-d78774.png +0 -0
- package/assets/avatars/male/35-profiler-623d36.png +0 -0
- package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
- package/assets/avatars/male/38-parser-92594b.png +0 -0
- package/assets/avatars/male/38-parser-e5a07e.png +0 -0
- package/assets/avatars/male/39-linter-623d36.png +0 -0
- package/assets/avatars/male/39-linter-d78774.png +0 -0
- package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
- package/assets/avatars/male/41-patcher-92594b.png +0 -0
- package/assets/avatars/male/41-patcher-e7a391.png +0 -0
- package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
- package/assets/avatars/male/43-maintainer-d78774.png +0 -0
- package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
- package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
- package/assets/avatars/male/44-guardian-e7a391.png +0 -0
- package/assets/avatars/male/45-sentinel-92594b.png +0 -0
- package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
- package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
- package/assets/avatars/male/49-hacker-623d36.png +0 -0
- package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
- package/assets/avatars/male/50-wrangler-d78774.png +0 -0
- package/assets/avatars/manifest.json +1202 -0
- package/index.ts +11 -0
- package/package.json +65 -4
- package/src/config.ts +176 -0
- package/src/core/avatar.ts +92 -0
- package/src/core/claims.ts +302 -0
- package/src/core/clock.ts +31 -0
- package/src/core/directory.ts +48 -0
- package/src/core/format.ts +382 -0
- package/src/core/mailbox.ts +239 -0
- package/src/core/mesh.ts +781 -0
- package/src/core/registry.ts +306 -0
- package/src/deliverer.ts +86 -0
- package/src/events.ts +66 -0
- package/src/index.ts +323 -0
- package/src/rpc.ts +52 -0
- package/src/storage.ts +105 -0
- package/src/tools/args.ts +132 -0
- package/src/tools/claim.ts +87 -0
- package/src/tools/heartbeat.ts +152 -0
- package/src/tools/inbox.ts +76 -0
- package/src/tools/index.ts +62 -0
- package/src/tools/peers.ts +101 -0
- package/src/tools/send.ts +84 -0
- package/src/tools/status.ts +137 -0
- package/src/tools/types.ts +72 -0
- package/src/tools/wait.ts +96 -0
- package/src/tui/avatar.ts +188 -0
- package/src/tui/index.tsx +216 -0
- package/src/types.ts +147 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|