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
@@ -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
+ }