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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_status` โ declare who you are, see who else is here.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { SNAPSHOT_VERSION } from "../core/mesh.ts"
|
|
6
|
+
import { meshStorageKey } from "../storage.ts"
|
|
7
|
+
import { formatStatus, SUMMARY_STALE_MS, relativeAge } from "../core/format.ts"
|
|
8
|
+
import { ArgError, readString, readStringArray } from "./args.ts"
|
|
9
|
+
import { jsonSchema, run, type ToolDeps, type CrosstalkToolInfo } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Health of the instance guard `src/index.ts` keeps on `globalThis` (same
|
|
13
|
+
* process, shared by every plugin load): the locations live instances own and
|
|
14
|
+
* the loads it refused. Both are the twin-install trace a log-less model
|
|
15
|
+
* cannot otherwise see; the read is strict โ anything but the exact promised
|
|
16
|
+
* shape yields `undefined`, so a refactor there costs a line, not a lie.
|
|
17
|
+
*/
|
|
18
|
+
function guardHealth(): { locations: number; refused: number } | undefined {
|
|
19
|
+
const guard = (globalThis as { __crosstalk?: { owners?: unknown; refused?: unknown } }).__crosstalk
|
|
20
|
+
if (!(guard?.owners instanceof Map) || typeof guard.refused !== "number") return undefined
|
|
21
|
+
return { locations: guard.owners.size, refused: guard.refused }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A human name is a nickname: letters, digits, spaces, `-`, `_`; 1-32 chars. */
|
|
25
|
+
const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9 _-]{0,31}$/
|
|
26
|
+
|
|
27
|
+
export function statusTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
28
|
+
return {
|
|
29
|
+
name: "status",
|
|
30
|
+
description: [
|
|
31
|
+
"Declare what this session is working on and see every other session on this channel.",
|
|
32
|
+
"Call this early in a task: peers use your role to route messages, and it is how two agents",
|
|
33
|
+
"on one repository discover each other. Give yourself a unique human name so the user and",
|
|
34
|
+
"peers can address you (\"tell Georgeโฆ\"). Fields you leave out keep their previous value.",
|
|
35
|
+
"Set `summary` โ one or two short sentences on what you are doing right now โ and refresh it",
|
|
36
|
+
"whenever your task changes: it is shown under your avatar in the sidebar and read by peers.",
|
|
37
|
+
"Returns your own record plus the peer list with each peer's status, activity age, unread mail,",
|
|
38
|
+
"and any files they have already leased.",
|
|
39
|
+
"The output carries a self-check line (snapshot format, storage key, live plugin locations)",
|
|
40
|
+
"so an install problem is visible without reading server logs.",
|
|
41
|
+
].join(" "),
|
|
42
|
+
input: jsonSchema({
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
name: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: 'Unique human name others can address you by, e.g. "George" (letters, digits, spaces, - and _)',
|
|
48
|
+
},
|
|
49
|
+
role: { type: "string", description: "Short noun for your job, e.g. reviewer, migrator, tester" },
|
|
50
|
+
goal: { type: "string", description: "What you are trying to accomplish in this session" },
|
|
51
|
+
summary: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description:
|
|
54
|
+
"What you are doing right now: 1-2 short sentences, shown under your avatar and read by peers. Refresh it when your task changes",
|
|
55
|
+
},
|
|
56
|
+
workingOn: {
|
|
57
|
+
type: "array",
|
|
58
|
+
items: { type: "string" },
|
|
59
|
+
description: "Files or areas you expect to touch, e.g. src/auth/session.ts",
|
|
60
|
+
},
|
|
61
|
+
note: { type: "string", description: "Anything peers should know (a blocker, a hand-off)" },
|
|
62
|
+
avatar: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description:
|
|
65
|
+
'Optional avatar hint for the TUI, e.g. an emoji like "๐ฉ" or "๐จ" that picks the character pool',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
}),
|
|
70
|
+
async execute(input, context) {
|
|
71
|
+
return run(() => {
|
|
72
|
+
const selfID = context.sessionID
|
|
73
|
+
const name = readString(input, "name", { max: 32 })
|
|
74
|
+
if (name !== undefined && !NAME_PATTERN.test(name)) {
|
|
75
|
+
throw new ArgError('"name" must start with a letter and contain only letters, digits, spaces, "-" or "_"')
|
|
76
|
+
}
|
|
77
|
+
const role = readString(input, "role", { max: 64 })
|
|
78
|
+
const goal = readString(input, "goal", { max: 400 })
|
|
79
|
+
// One or two short sentences: the live status line under the avatar.
|
|
80
|
+
const summary = readString(input, "summary", { max: 200 })
|
|
81
|
+
const note = readString(input, "note", { max: 400 })
|
|
82
|
+
const avatar = readString(input, "avatar", { max: 32 })
|
|
83
|
+
const workingOn = readStringArray(input, "workingOn", { max: 32, maxLength: 200 })
|
|
84
|
+
|
|
85
|
+
const declared =
|
|
86
|
+
name || role || goal || note || avatar || workingOn || summary
|
|
87
|
+
? deps.mesh.declare(selfID, { name, role, goal, summary, note, avatar, workingOn })
|
|
88
|
+
: { view: deps.mesh.view(selfID) }
|
|
89
|
+
if (declared.nameConflict) {
|
|
90
|
+
return (
|
|
91
|
+
`crosstalk: the name "${declared.nameConflict.name}" is already used by ${declared.nameConflict.holder}` +
|
|
92
|
+
` โ pick another, or address them with crosstalk_send { to: "${declared.nameConflict.holder}" }`
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const self = declared.view
|
|
96
|
+
|
|
97
|
+
const peers = deps.mesh.peers(selfID)
|
|
98
|
+
const claimsHeld = self.claims.length
|
|
99
|
+
const now = deps.mesh.now()
|
|
100
|
+
const hints: string[] = []
|
|
101
|
+
if (peers.some((peer) => peer.claims.length > 0)) {
|
|
102
|
+
hints.push("some peers hold leases โ use crosstalk_claim before editing their files")
|
|
103
|
+
}
|
|
104
|
+
if (peers.some((peer) => !peer.addressable)) {
|
|
105
|
+
hints.push("some peers are outside the communication wall and cannot be messaged")
|
|
106
|
+
}
|
|
107
|
+
if (self.unread > 0) hints.push(`you have ${self.unread} unread message(s) โ crosstalk_inbox`)
|
|
108
|
+
if (claimsHeld === 0 && peers.length > 0) {
|
|
109
|
+
hints.push("claim what you are about to edit: crosstalk_claim { resources: [...] }")
|
|
110
|
+
}
|
|
111
|
+
if (self.declared?.summary === undefined && peers.length > 0) {
|
|
112
|
+
hints.push('tell peers what you are doing right now: crosstalk_status { summary: "โฆ" }')
|
|
113
|
+
}
|
|
114
|
+
// A summary nobody refreshes reads as a promise: the sidebar dims it
|
|
115
|
+
// after the same window, so the agent hears about it at the same moment
|
|
116
|
+
// the user starts seeing it.
|
|
117
|
+
if (self.declared?.summaryAt !== undefined && now - self.declared.summaryAt > SUMMARY_STALE_MS) {
|
|
118
|
+
hints.push(
|
|
119
|
+
`your summary is ${relativeAge(self.declared.summaryAt, now)} old โ refresh it: crosstalk_status { summary: โฆ }`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Diagnostics for the calling location, through the store's own key
|
|
124
|
+
// formula so the line can never name a key state does not live under.
|
|
125
|
+
const health = guardHealth()
|
|
126
|
+
const selfCheck = {
|
|
127
|
+
snapshot: `v${SNAPSHOT_VERSION}`,
|
|
128
|
+
persist: deps.options.persist,
|
|
129
|
+
...(deps.options.persist ? { storageKey: meshStorageKey(deps.options.storageKey, self.projectID) } : {}),
|
|
130
|
+
...(health ? { locations: health.locations, refused: health.refused } : {}),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return formatStatus({ self, peers, scope: deps.options.scope, now, hints, selfCheck })
|
|
134
|
+
})
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared plumbing for the crosstalk tools.
|
|
3
|
+
*
|
|
4
|
+
* Tool definitions use plain JSON Schema (no Zod) so the plugin has no runtime
|
|
5
|
+
* dependency beyond `@opencode/plugin`, and every executor narrows its input
|
|
6
|
+
* through `src/tools/args.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool"
|
|
10
|
+
import type { CrosstalkOptions } from "../config.ts"
|
|
11
|
+
import type { Mesh } from "../core/mesh.ts"
|
|
12
|
+
import { ArgError } from "./args.ts"
|
|
13
|
+
|
|
14
|
+
export { ArgError }
|
|
15
|
+
|
|
16
|
+
export type CrosstalkToolInfo = Parameters<ToolEditor["add"]>[0]
|
|
17
|
+
export type ToolInputSchema = CrosstalkToolInfo["input"]
|
|
18
|
+
export type { Info, ToolContext }
|
|
19
|
+
|
|
20
|
+
export interface ToolDeps {
|
|
21
|
+
mesh: Mesh
|
|
22
|
+
options: CrosstalkOptions
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tool input schemas accept a JSON Schema literal, or an Effect / Standard
|
|
27
|
+
* Schema codec. This plugin only ever writes JSON Schema, so the single cast
|
|
28
|
+
* lives here instead of in every tool.
|
|
29
|
+
*/
|
|
30
|
+
export function jsonSchema(value: Record<string, unknown>): ToolInputSchema {
|
|
31
|
+
return value as unknown as ToolInputSchema
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap executor bodies: the result becomes tool content, and a bad argument
|
|
36
|
+
* comes back as readable output the model can correct itself from. Any other
|
|
37
|
+
* failure is a real bug and is left to propagate.
|
|
38
|
+
*/
|
|
39
|
+
export async function run(fn: () => Promise<string> | string): Promise<{ content: string }> {
|
|
40
|
+
try {
|
|
41
|
+
return { content: await fn() }
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof ArgError) return { content: `crosstalk: ${error.message}` }
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Call `context.progress` every `intervalMs` while a long call is blocked, so
|
|
50
|
+
* the TUI shows liveness instead of a frozen tool. `intervalMs === 0` disables
|
|
51
|
+
* it, which is what the tests use.
|
|
52
|
+
*/
|
|
53
|
+
export function startHeartbeat(
|
|
54
|
+
context: ToolContext,
|
|
55
|
+
intervalMs: number,
|
|
56
|
+
status: () => Record<string, unknown>,
|
|
57
|
+
): () => void {
|
|
58
|
+
if (intervalMs <= 0) return () => {}
|
|
59
|
+
const timer = setInterval(() => {
|
|
60
|
+
void context.progress(status()).catch(() => {})
|
|
61
|
+
}, intervalMs)
|
|
62
|
+
// Never hold the process open for a heartbeat.
|
|
63
|
+
timer.unref?.()
|
|
64
|
+
const stop = () => clearInterval(timer)
|
|
65
|
+
context.signal.addEventListener("abort", stop, { once: true })
|
|
66
|
+
return stop
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Clamp a requested wait to the configured ceiling. */
|
|
70
|
+
export function clampWait(ms: number, options: CrosstalkOptions): number {
|
|
71
|
+
return Math.min(Math.max(0, ms), options.maxWaitMs)
|
|
72
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_wait` โ block on a peer or a lease.
|
|
3
|
+
*
|
|
4
|
+
* Mail waiting is covered by `crosstalk_inbox { wait }`; this tool is the
|
|
5
|
+
* barrier between two agents that have to take turns โ one waits for the other
|
|
6
|
+
* to stop touching the code, or for a file to be released.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatWait } from "../core/format.ts"
|
|
10
|
+
import { readNumber, readString } from "./args.ts"
|
|
11
|
+
import { clampWait, jsonSchema, run, startHeartbeat, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
export function waitTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
14
|
+
return {
|
|
15
|
+
name: "wait",
|
|
16
|
+
description: [
|
|
17
|
+
"Block until another session is no longer working, or until a claimed resource is released.",
|
|
18
|
+
"Use it instead of sleeping or re-checking by hand when two sessions are taking turns on the",
|
|
19
|
+
"same code. Returns as soon as the condition holds, or reports a timeout you can act on.",
|
|
20
|
+
].join(" "),
|
|
21
|
+
input: jsonSchema({
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
for: {
|
|
25
|
+
type: "string",
|
|
26
|
+
enum: ["peer_idle", "claim_free"],
|
|
27
|
+
description: "Wait for a peer to go idle, or for a resource to become unclaimed",
|
|
28
|
+
},
|
|
29
|
+
sessionID: { type: "string", description: "Peer to wait for. Required for peer_idle." },
|
|
30
|
+
resource: { type: "string", description: "File path or named resource. Required for claim_free." },
|
|
31
|
+
timeoutSeconds: { type: "number", description: "Give up after this many seconds (default 30)" },
|
|
32
|
+
},
|
|
33
|
+
required: ["for"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
}),
|
|
36
|
+
async execute(input, context) {
|
|
37
|
+
return run(async () => {
|
|
38
|
+
const forWhat = readString(input, "for", { required: true })
|
|
39
|
+
if (forWhat !== "peer_idle" && forWhat !== "claim_free") {
|
|
40
|
+
return 'crosstalk: "for" must be "peer_idle" or "claim_free"'
|
|
41
|
+
}
|
|
42
|
+
const sessionID = readString(input, "sessionID", { max: 200 })
|
|
43
|
+
const resource = readString(input, "resource", { max: 400 })
|
|
44
|
+
const timeoutSeconds = readNumber(input, "timeoutSeconds", { min: 0, max: 600, fallback: 30 })
|
|
45
|
+
const timeoutMs = clampWait((timeoutSeconds ?? 30) * 1000, deps.options)
|
|
46
|
+
|
|
47
|
+
if (forWhat === "peer_idle" && sessionID === undefined) {
|
|
48
|
+
return 'crosstalk: waiting for a peer needs "sessionID" (see crosstalk_peers)'
|
|
49
|
+
}
|
|
50
|
+
if (forWhat === "claim_free" && resource === undefined) {
|
|
51
|
+
return 'crosstalk: waiting for a resource needs "resource" (the path you want to edit)'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stop = startHeartbeat(context, deps.options.heartbeatMs, () => ({
|
|
55
|
+
status: "waiting",
|
|
56
|
+
for: forWhat,
|
|
57
|
+
...(sessionID ? { sessionID } : {}),
|
|
58
|
+
...(resource ? { resource } : {}),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
let body: string
|
|
62
|
+
try {
|
|
63
|
+
if (forWhat === "peer_idle" && sessionID) {
|
|
64
|
+
const result = await deps.mesh.waitForPeerIdle(context.sessionID, sessionID, timeoutMs, context.signal)
|
|
65
|
+
body = formatWait({
|
|
66
|
+
forWhat,
|
|
67
|
+
reason: result.reason,
|
|
68
|
+
elapsedMs: result.elapsedMs,
|
|
69
|
+
...(result.reason === "gone" ? { detail: `${sessionID} is not on this channel any more` } : {}),
|
|
70
|
+
...(result.reason === "outside" ? { detail: `${sessionID} is outside the ${deps.options.scope} scope` } : {}),
|
|
71
|
+
})
|
|
72
|
+
} else if (resource) {
|
|
73
|
+
const result = await deps.mesh.waitForClaimFree(
|
|
74
|
+
context.sessionID,
|
|
75
|
+
resource,
|
|
76
|
+
timeoutMs,
|
|
77
|
+
deps.mesh.directoryOf(context.sessionID),
|
|
78
|
+
context.signal,
|
|
79
|
+
)
|
|
80
|
+
body = formatWait({
|
|
81
|
+
forWhat,
|
|
82
|
+
reason: result.reason,
|
|
83
|
+
elapsedMs: result.elapsedMs,
|
|
84
|
+
...(result.holder ? { detail: `still held by ${result.holder}` } : {}),
|
|
85
|
+
})
|
|
86
|
+
} else {
|
|
87
|
+
body = "crosstalk: nothing to wait for"
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
stop()
|
|
91
|
+
}
|
|
92
|
+
return body
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview How a frozen portrait is shown: the emoticon that belongs to
|
|
3
|
+
* the Persona it was given, the title that emoticon is written into, and the
|
|
4
|
+
* status summary shown under the avatar.
|
|
5
|
+
*
|
|
6
|
+
* The selection itself lives in `src/core/avatar.ts`: the server freezes one
|
|
7
|
+
* portrait per session when a task is first declared, so this side only maps a
|
|
8
|
+
* portrait path back to its manifest entry and decorates it. Pure and
|
|
9
|
+
* host-free so it is unit-testable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { hash, type AvatarEntry } from "../core/avatar.ts"
|
|
13
|
+
import { ellipsis } from "../core/format.ts"
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
chooseAvatar,
|
|
17
|
+
hasTask,
|
|
18
|
+
hash,
|
|
19
|
+
poolHint,
|
|
20
|
+
portraitFor,
|
|
21
|
+
type AvatarEntry,
|
|
22
|
+
type AvatarPool,
|
|
23
|
+
type AvatarSession,
|
|
24
|
+
} from "../core/avatar.ts"
|
|
25
|
+
|
|
26
|
+
/** The manifest entry a frozen portrait (`pool/file`) refers to. */
|
|
27
|
+
export function entryForPortrait(
|
|
28
|
+
entries: readonly AvatarEntry[],
|
|
29
|
+
portrait: string | undefined,
|
|
30
|
+
): AvatarEntry | undefined {
|
|
31
|
+
if (portrait === undefined) return undefined
|
|
32
|
+
return entries.find((entry) => `${entry.pool}/${entry.file}` === portrait)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One emoticon per Persona role, curated from tools, objects, and people.
|
|
37
|
+
*
|
|
38
|
+
* Roles with several entries in the same pool carry alternates so two sessions
|
|
39
|
+
* wearing the same trade do not collapse into one glyph; which alternate an
|
|
40
|
+
* avatar gets is decided by its file name, so it never changes between renders.
|
|
41
|
+
* A role missing from this table falls back to `FALLBACK_EMOTICONS`, which keeps
|
|
42
|
+
* a regenerated manifest working before anyone curates the new roles.
|
|
43
|
+
*/
|
|
44
|
+
export const EMOTICONS: Record<string, readonly string[]> = {
|
|
45
|
+
analyst: ["๐", "๐"],
|
|
46
|
+
architect: ["๐๏ธ", "๐"],
|
|
47
|
+
auditor: ["๐งฎ", "๐งพ"],
|
|
48
|
+
builder: ["๐งฑ", "๐ช"],
|
|
49
|
+
coder: ["๐ป", "โจ๏ธ", "๐ฅ๏ธ"],
|
|
50
|
+
compiler: ["๐ ๏ธ", "๐๏ธ"],
|
|
51
|
+
coordinator: ["๐ก", "๐งญ"],
|
|
52
|
+
debugger: ["๐ช", "๐ฆ"],
|
|
53
|
+
deployer: ["๐", "๐ค"],
|
|
54
|
+
designer: ["๐จ", "๐"],
|
|
55
|
+
editor: ["๐", "โ๏ธ"],
|
|
56
|
+
explorer: ["๐งญ", "๐บ๏ธ"],
|
|
57
|
+
fixer: ["๐ง", "๐ฉน"],
|
|
58
|
+
formatter: ["๐", "๐"],
|
|
59
|
+
gardener: ["๐ชด", "๐งค"],
|
|
60
|
+
guardian: ["๐ก๏ธ", "๐"],
|
|
61
|
+
hacker: ["๐ถ๏ธ", "๐ป"],
|
|
62
|
+
integrator: ["๐", "๐งฒ"],
|
|
63
|
+
librarian: ["๐", "๐๏ธ"],
|
|
64
|
+
linter: ["๐งน", "๐จ"],
|
|
65
|
+
maintainer: ["๐ข๏ธ", "๐ง"],
|
|
66
|
+
manager: ["๐ผ", "๐๏ธ"],
|
|
67
|
+
mentor: ["๐", "๐"],
|
|
68
|
+
migrator: ["๐", "๐งณ"],
|
|
69
|
+
operator: ["๐๏ธ", "๐น๏ธ"],
|
|
70
|
+
optimizer: ["โ๏ธ", "๐ข๏ธ"],
|
|
71
|
+
parser: ["๐งฉ", "๐ค"],
|
|
72
|
+
patcher: ["๐ฉน", "๐งต"],
|
|
73
|
+
pioneer: ["๐๏ธ", "โบ"],
|
|
74
|
+
planner: ["๐
", "๐๏ธ"],
|
|
75
|
+
profiler: ["๐", "โฑ๏ธ"],
|
|
76
|
+
refactorer: ["๐จ", "๐๏ธ"],
|
|
77
|
+
researcher: ["๐ฌ", "๐"],
|
|
78
|
+
reviewer: ["๐", "๐ง"],
|
|
79
|
+
scout: ["๐ญ", "๐ก"],
|
|
80
|
+
sentinel: ["๐จ", "๐น"],
|
|
81
|
+
shipper: ["๐ฆ", "๐ณ๏ธ"],
|
|
82
|
+
shepherd: ["๐งถ", "๐ชข"],
|
|
83
|
+
simplifier: ["โ๏ธ", "๐งน"],
|
|
84
|
+
strategist: ["๐ฏ", "โ๏ธ"],
|
|
85
|
+
steward: ["๐๏ธ", "๐งพ"],
|
|
86
|
+
tester: ["๐งช", "๐งซ"],
|
|
87
|
+
tinkerer: ["๐ฉ", "๐ช"],
|
|
88
|
+
tracker: ["๐", "๐"],
|
|
89
|
+
wrangler: ["๐ฃ", "๐ช"],
|
|
90
|
+
writer: ["โ๏ธ", "๐"],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** For manifest roles this table has not caught up with yet. */
|
|
94
|
+
export const FALLBACK_EMOTICONS: readonly string[] = ["๐งฐ", "๐งฉ", "๐ฆ", "๐งญ"]
|
|
95
|
+
|
|
96
|
+
const MANAGED_GLYPHS: readonly string[] = [
|
|
97
|
+
...new Set([...Object.values(EMOTICONS).flat(), ...FALLBACK_EMOTICONS]),
|
|
98
|
+
].sort((a, b) => b.length - a.length)
|
|
99
|
+
|
|
100
|
+
/** The emoticon one avatar was given. Always defined: an unknown role degrades. */
|
|
101
|
+
export function emoticonFor(entry: AvatarEntry | undefined): string {
|
|
102
|
+
const candidates = entry ? (EMOTICONS[entry.role.toLowerCase()] ?? FALLBACK_EMOTICONS) : FALLBACK_EMOTICONS
|
|
103
|
+
return candidates[hash(entry?.file ?? "crosstalk") % candidates.length] ?? FALLBACK_EMOTICONS[0]!
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Drop a leading emoticon this plugin manages.
|
|
108
|
+
*
|
|
109
|
+
* Only glyphs from the curated table count, so a glyph the user typed into a
|
|
110
|
+
* title themselves is left alone.
|
|
111
|
+
*/
|
|
112
|
+
export function stripEmoticon(title: string): string {
|
|
113
|
+
const lead = title.replace(/^\s+/, "")
|
|
114
|
+
for (const glyph of MANAGED_GLYPHS) {
|
|
115
|
+
if (lead.startsWith(glyph)) return lead.slice(glyph.length).replace(/^[\s\u00a0]+/, "")
|
|
116
|
+
}
|
|
117
|
+
return title
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Drop a leading `Name: ` prefix this plugin manages.
|
|
122
|
+
*
|
|
123
|
+
* Only a prefix matching the declared name counts, so a name the user typed
|
|
124
|
+
* into a title themselves is left alone.
|
|
125
|
+
*/
|
|
126
|
+
function stripNamePrefix(title: string, name: string): string {
|
|
127
|
+
const lead = title.replace(/^\s+/, "")
|
|
128
|
+
const prefix = `${name}:`
|
|
129
|
+
if (lead.startsWith(prefix)) return lead.slice(prefix.length).replace(/^[\s\u00a0]+/, "")
|
|
130
|
+
return title
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The tab-header title for one session, or `undefined` when nothing should be
|
|
135
|
+
* written โ either there is no title to decorate or it is already correct, so a
|
|
136
|
+
* caller can compare instead of guessing and the write stays idempotent.
|
|
137
|
+
*
|
|
138
|
+
* With a declared name the title becomes `Name: base`; without one it stays
|
|
139
|
+
* `base`, so sessions that never declared a name are unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function titleWithEmoticon(
|
|
142
|
+
title: string | undefined,
|
|
143
|
+
glyph: string | undefined,
|
|
144
|
+
name: string | undefined,
|
|
145
|
+
): string | undefined {
|
|
146
|
+
if (title === undefined || glyph === undefined) return undefined
|
|
147
|
+
let base = title
|
|
148
|
+
if (name !== undefined && name.length > 0) base = stripNamePrefix(base, name)
|
|
149
|
+
base = stripEmoticon(base)
|
|
150
|
+
if (base.length === 0) return undefined
|
|
151
|
+
const wanted = name !== undefined && name.length > 0 ? `${name}: ${base}` : base
|
|
152
|
+
return wanted === title ? undefined : wanted
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Widest status summary the sidebar shows; matches the tool's write limit. */
|
|
156
|
+
export const SUMMARY_MAX = 200
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A summary older than this is dimmed in the sidebar. The session probably
|
|
160
|
+
* moved on and has not said so, and an undimmed stale line reads as a promise
|
|
161
|
+
* the session did not make. Mirrors the peer-line rule: an agent-facing age at
|
|
162
|
+
* five minutes, a human-facing dim at fifteen.
|
|
163
|
+
*/
|
|
164
|
+
export const STALE_SUMMARY_MS = 15 * 60_000
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether a summary is old enough to dim. A missing timestamp โ a declaration
|
|
168
|
+
* from before the feature, or a snapshot that predates it โ never dims: there
|
|
169
|
+
* is no evidence it is stale, only an absence of evidence.
|
|
170
|
+
*/
|
|
171
|
+
export function isSummaryStale(at: number | undefined, now: number): boolean {
|
|
172
|
+
return at !== undefined && now - at > STALE_SUMMARY_MS
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The status summary under one avatar: what the session says it is doing right
|
|
177
|
+
* now, flattened onto a single line so the renderer wraps it itself. The
|
|
178
|
+
* declared summary wins; a session that never wrote one falls back to its goal,
|
|
179
|
+
* so a declaration made before the summary existed still says something.
|
|
180
|
+
* Undefined when neither is there โ the avatar block then shows only the name
|
|
181
|
+
* and the role, and the caller renders nothing.
|
|
182
|
+
*/
|
|
183
|
+
export function summaryText(entry: { summary?: string; goal?: string } | undefined): string | undefined {
|
|
184
|
+
const raw = entry?.summary ?? entry?.goal
|
|
185
|
+
if (raw === undefined) return undefined
|
|
186
|
+
const flat = ellipsis(raw, SUMMARY_MAX)
|
|
187
|
+
return flat.length === 0 ? undefined : flat
|
|
188
|
+
}
|