pi-messenger-swarm 0.24.2 → 0.25.2

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 (100) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +23 -37
  3. package/action-types.ts +1 -0
  4. package/channel.ts +30 -0
  5. package/dist/action-types.js +1 -0
  6. package/dist/channel.js +353 -0
  7. package/dist/config-overlay.js +146 -0
  8. package/dist/config.js +165 -0
  9. package/dist/extension/activity.js +203 -0
  10. package/dist/extension/deliver-message.js +36 -0
  11. package/dist/extension/status.js +96 -0
  12. package/dist/feed-scroll-core.js +124 -0
  13. package/dist/feed-scroll.js +136 -0
  14. package/dist/feed.js +325 -0
  15. package/dist/handlers/auto-register.js +77 -0
  16. package/dist/handlers/coordination/index.js +7 -0
  17. package/dist/handlers/coordination/join.js +98 -0
  18. package/dist/handlers/coordination/list.js +79 -0
  19. package/dist/handlers/coordination/messaging.js +91 -0
  20. package/dist/handlers/coordination/rename.js +17 -0
  21. package/dist/handlers/coordination/reservations.js +63 -0
  22. package/dist/handlers/coordination/status.js +58 -0
  23. package/dist/handlers/coordination/whois.js +76 -0
  24. package/dist/handlers/coordination.js +2 -0
  25. package/dist/handlers/result.js +12 -0
  26. package/dist/handlers.js +3 -0
  27. package/dist/harness/cli.js +674 -0
  28. package/dist/harness/server.js +411 -0
  29. package/dist/index.js +502 -0
  30. package/dist/lib/format.js +24 -0
  31. package/dist/lib/index.js +6 -0
  32. package/dist/lib/names.js +212 -0
  33. package/dist/lib/paths.js +41 -0
  34. package/dist/lib/status.js +89 -0
  35. package/dist/lib/types.js +1 -0
  36. package/dist/lib.js +2 -0
  37. package/dist/overlay/feed-window.js +160 -0
  38. package/dist/overlay/input.js +274 -0
  39. package/dist/overlay/notifications.js +51 -0
  40. package/dist/overlay/render-detail.js +360 -0
  41. package/dist/overlay/render-feed.js +109 -0
  42. package/dist/overlay/render-status.js +189 -0
  43. package/dist/overlay/snapshot.js +94 -0
  44. package/dist/overlay-actions.js +327 -0
  45. package/dist/overlay-render.js +3 -0
  46. package/dist/overlay.js +509 -0
  47. package/dist/router.js +125 -0
  48. package/dist/store/agents.js +180 -0
  49. package/dist/store/registration.js +378 -0
  50. package/dist/store/registry.js +2 -0
  51. package/dist/store/shared.js +186 -0
  52. package/dist/store.js +1 -0
  53. package/dist/swarm/agent-loader.js +38 -0
  54. package/dist/swarm/handlers/_utils.js +5 -0
  55. package/dist/swarm/handlers/index.js +3 -0
  56. package/dist/swarm/handlers/spawn.js +180 -0
  57. package/dist/swarm/handlers/status.js +73 -0
  58. package/dist/swarm/handlers/task-archive.js +60 -0
  59. package/dist/swarm/handlers/task-block.js +46 -0
  60. package/dist/swarm/handlers/task-create.js +36 -0
  61. package/dist/swarm/handlers/task-lifecycle.js +129 -0
  62. package/dist/swarm/handlers/task-ops.js +45 -0
  63. package/dist/swarm/handlers/task-progress.js +28 -0
  64. package/dist/swarm/handlers/task-query.js +87 -0
  65. package/dist/swarm/handlers.js +2 -0
  66. package/dist/swarm/labels.js +11 -0
  67. package/dist/swarm/live-progress.js +115 -0
  68. package/dist/swarm/progress.js +80 -0
  69. package/dist/swarm/result.js +6 -0
  70. package/dist/swarm/spawn.js +530 -0
  71. package/dist/swarm/task-actions.js +126 -0
  72. package/dist/swarm/task-store/cleanup.js +70 -0
  73. package/dist/swarm/task-store/commands.js +192 -0
  74. package/dist/swarm/task-store/events.js +170 -0
  75. package/dist/swarm/task-store/index.js +5 -0
  76. package/dist/swarm/task-store/persistence.js +42 -0
  77. package/dist/swarm/task-store/queries.js +146 -0
  78. package/dist/swarm/task-store/types.js +1 -0
  79. package/dist/swarm/task-store.js +2 -0
  80. package/dist/swarm/types.js +1 -0
  81. package/extension/deliver-message.ts +1 -3
  82. package/handlers/auto-register.ts +1 -1
  83. package/handlers/coordination/join.ts +1 -1
  84. package/handlers/coordination/status.ts +1 -2
  85. package/handlers/result.ts +4 -4
  86. package/harness/cli.ts +713 -0
  87. package/harness/server.ts +495 -0
  88. package/index.ts +141 -167
  89. package/install.mjs +1 -1
  90. package/overlay/render-detail.ts +2 -10
  91. package/overlay/render-status.ts +1 -6
  92. package/overlay/snapshot.ts +1 -1
  93. package/package.json +7 -3
  94. package/router.ts +1 -1
  95. package/skills/pi-messenger-swarm/SKILL.md +93 -107
  96. package/store/registration.ts +14 -10
  97. package/swarm/handlers/spawn.ts +4 -3
  98. package/swarm/handlers/status.ts +3 -3
  99. package/swarm/handlers/task-create.ts +1 -1
  100. package/swarm/spawn.ts +28 -19
package/CHANGELOG.md CHANGED
@@ -2,6 +2,76 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.25.2](https://github.com/monotykamary/pi-messenger-swarm/compare/v0.25.1...v0.25.2) (2026-04-25)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **spawn:** always append swarm operating protocol to agent system prompt ([82e0630](https://github.com/monotykamary/pi-messenger-swarm/commit/82e0630de523acc1e8794fbfe6475be509b23e61))
11
+
12
+ ### [0.25.1](https://github.com/monotykamary/pi-messenger-swarm/compare/v0.25.0...v0.25.1) (2026-04-25)
13
+
14
+
15
+ ### Features
16
+
17
+ * **spawn:** add --agent-file, --objective, --context CLI flags ([6c577d7](https://github.com/monotykamary/pi-messenger-swarm/commit/6c577d77a387398f8ff794c6edffccb220dcec46))
18
+
19
+ ## [0.25.0](https://github.com/monotykamary/pi-messenger-swarm/compare/v0.24.2...v0.25.0) (2026-04-25)
20
+
21
+
22
+ ### ⚠ BREAKING CHANGES
23
+
24
+ * The pi_messenger tool has been removed. All actions
25
+ are now dispatched through the pi-messenger-swarm CLI, which
26
+ auto-spawns a long-lived HTTP harness server on first use. Models
27
+ call pi-messenger-swarm join, pi-messenger-swarm task claim task-1,
28
+ etc. instead of a tool invocation.
29
+
30
+ Migration:
31
+ - pi_messenger({ action: 'join' }) → pi-messenger-swarm join
32
+ - pi_messenger({ action: 'task.claim', id: 'task-1' })
33
+ → pi-messenger-swarm task claim task-1
34
+ - pi_messenger({ action: 'send', to: '#memory', message: '...' })
35
+ → pi-messenger-swarm send #memory '...'
36
+ - JSON passthrough still works:
37
+ pi-messenger-swarm '{ "action": "join" }'
38
+
39
+ New architecture:
40
+ - harness/server.ts: long-lived Node.js HTTP server with
41
+ /action, /health, /quit endpoints
42
+ - harness/cli.ts: natural subcommand CLI with auto-spawn,
43
+ process-tree identity resolution
44
+ - index.ts: tool registration removed, extension manages
45
+ lifecycle/overlay only
46
+ - Agent identity resolved via process tree (x-caller-pid
47
+ header) + disk-based registration lookup
48
+ - Session ID bridged via .pi/messenger/session-id file
49
+ (extension writes at session_start, CLI forwards as
50
+ x-session-id header)
51
+ - Shell wrapper at ~/.pi/agent/bin/pi-messenger-swarm
52
+ instead of symlink
53
+
54
+ Fixes:
55
+ - Tasks invisible in overlay: harness used empty sessionId
56
+ while extension used pi's real UUIDv7 session ID
57
+ - Registration/channel sessionId patched on subsequent
58
+ requests when session-id file becomes available (race
59
+ condition guard)
60
+ - renameAgent() used process.pid instead of callerPid in
61
+ harness context
62
+ - Swarm operating protocol in subagent prompts updated from
63
+ JSON to CLI syntax
64
+ - All hint strings across handlers, overlay, deliver-message
65
+ updated to CLI syntax
66
+ - README.md and SKILL.md updated from pi_messenger() to CLI
67
+ examples
68
+ - coverage/ added to .gitignore
69
+ - knip.json updated with harness entry points
70
+
71
+ ### Features
72
+
73
+ * replace tool-hoisting with CLI + harness server architecture ([8bf9259](https://github.com/monotykamary/pi-messenger-swarm/commit/8bf9259f1d6c60f30128ab7ece96f772b9b8fa98))
74
+
5
75
  ### [0.24.2](https://github.com/monotykamary/pi-messenger-swarm/compare/v0.24.1...v0.24.2) (2026-04-19)
6
76
 
7
77
 
package/README.md CHANGED
@@ -49,29 +49,19 @@ From git (Pi package settings):
49
49
 
50
50
  Join the messenger and start collaborating in your session channel:
51
51
 
52
- ```ts
53
- pi_messenger({ action: 'join' });
54
- pi_messenger({
55
- action: 'send',
56
- to: '#memory',
57
- message: 'Investigating auth timeout in refresh flow',
58
- });
59
- pi_messenger({ action: 'task.create', title: 'Investigate auth timeout', content: 'Repro + fix' });
60
- pi_messenger({ action: 'task.claim', id: 'task-1' });
61
- pi_messenger({ action: 'task.progress', id: 'task-1', message: 'Found race in refresh flow' });
62
- pi_messenger({ action: 'task.done', id: 'task-1', summary: 'Fixed refresh lock + tests' });
52
+ ```bash
53
+ pi-messenger-swarm join
54
+ pi-messenger-swarm send #memory "Investigating auth timeout in refresh flow"
55
+ pi-messenger-swarm task create --title "Investigate auth timeout" --content "Repro + fix"
56
+ pi-messenger-swarm task claim task-1
57
+ pi-messenger-swarm task progress task-1 "Found race in refresh flow"
58
+ pi-messenger-swarm task done task-1 "Fixed refresh lock + tests"
63
59
  ```
64
60
 
65
61
  Spawn a specialized subagent:
66
62
 
67
- ```ts
68
- pi_messenger({
69
- action: 'spawn',
70
- role: 'Packaging Gap Analyst',
71
- persona: 'Skeptical market researcher',
72
- message: 'Find productization gaps in idea aggregation tools',
73
- content: 'Focus on onboarding, monetization, and UX friction',
74
- });
63
+ ```bash
64
+ pi-messenger-swarm spawn --role "Packaging Gap Analyst" --persona "Skeptical market researcher" "Find productization gaps in idea aggregation tools"
75
65
  ```
76
66
 
77
67
  ## Channel Model
@@ -171,33 +161,29 @@ Compatibility aliases:
171
161
 
172
162
  ### Direct message an agent
173
163
 
174
- ```ts
175
- pi_messenger({ action: 'send', to: 'OtherAgent', message: 'Need your API shape before I commit' });
164
+ ```bash
165
+ pi-messenger-swarm send OtherAgent "Need your API shape before I commit"
176
166
  ```
177
167
 
178
168
  ### Post durably to a channel
179
169
 
180
- ```ts
181
- pi_messenger({
182
- action: 'send',
183
- to: '#memory',
184
- message: 'Claimed task-4, touching src/auth/session.ts',
185
- });
186
- pi_messenger({ action: 'send', to: '#memory', message: 'Nightly sync complete' });
170
+ ```bash
171
+ pi-messenger-swarm send #memory "Claimed task-4, touching src/auth/session.ts"
172
+ pi-messenger-swarm send #memory "Nightly sync complete"
187
173
  ```
188
174
 
189
175
  ### Switch channels explicitly
190
176
 
191
- ```ts
192
- pi_messenger({ action: 'join', channel: 'memory' });
193
- pi_messenger({ action: 'join', channel: 'architecture', create: true });
177
+ ```bash
178
+ pi-messenger-swarm join --channel memory
179
+ pi-messenger-swarm join --channel architecture --create
194
180
  ```
195
181
 
196
182
  ### Read a channel feed
197
183
 
198
- ```ts
199
- pi_messenger({ action: 'feed', limit: 20 });
200
- pi_messenger({ action: 'feed', channel: 'memory', limit: 20 });
184
+ ```bash
185
+ pi-messenger-swarm feed --limit 20
186
+ pi-messenger-swarm feed --channel memory --limit 20
201
187
  ```
202
188
 
203
189
  ### Notes
@@ -294,9 +280,9 @@ This design intentionally breaks older messaging assumptions.
294
280
 
295
281
  Use these patterns instead:
296
282
 
297
- ```ts
298
- pi_messenger({ action: 'send', to: 'AgentName', message: '...' });
299
- pi_messenger({ action: 'send', to: '#channel', message: '...' });
283
+ ```bash
284
+ pi-messenger-swarm send AgentName "..."
285
+ pi-messenger-swarm send #channel "..."
300
286
  ```
301
287
 
302
288
  ## Environment Variables
package/action-types.ts CHANGED
@@ -39,6 +39,7 @@ export interface MessengerActionParams {
39
39
  // Spawn
40
40
  role?: string;
41
41
  persona?: string;
42
+ objective?: string;
42
43
  context?: string;
43
44
  model?: string;
44
45
  agentFile?: string;
package/channel.ts CHANGED
@@ -268,6 +268,36 @@ export function writeChannel(dirs: Dirs, record: ChannelRecord): ChannelRecord {
268
268
  return record;
269
269
  }
270
270
 
271
+ /**
272
+ * Patch the sessionId on a channel's metadata header if it's currently empty.
273
+ * Used by the harness server when it discovers the session ID after the channel
274
+ * was already created (race condition with session-id file).
275
+ * Returns true if the channel was patched, false if no change was needed.
276
+ */
277
+ export function patchChannelSessionId(dirs: Dirs, channelId: string, sessionId: string): boolean {
278
+ if (!sessionId) return false;
279
+ const header = readChannelHeader(dirs, channelId);
280
+ if (!header || header.sessionId) return false; // already set or missing
281
+
282
+ const filePath = channelPath(dirs, channelId);
283
+ try {
284
+ const content = fs.readFileSync(filePath, 'utf-8');
285
+ const lines = content.split('\n');
286
+ if (lines.length === 0) return false;
287
+
288
+ const meta = JSON.parse(lines[0]) as ChannelMetaHeader;
289
+ meta.sessionId = sessionId;
290
+ lines[0] = JSON.stringify(meta);
291
+
292
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
293
+ fs.writeFileSync(tmp, lines.join('\n'));
294
+ fs.renameSync(tmp, filePath);
295
+ return true;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
271
301
  export function ensureNamedChannel(
272
302
  dirs: Dirs,
273
303
  channelId: string,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,353 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { generateMemorableName } from './lib.js';
4
+ export const CHANNEL_META_VERSION = 1;
5
+ export const MEMORY_CHANNEL_ID = 'memory';
6
+ export const DEFAULT_NAMED_CHANNELS = [
7
+ { id: MEMORY_CHANNEL_ID, description: 'Cross-session knowledge and insights' },
8
+ ];
9
+ function ensureDir(dir) {
10
+ if (!fs.existsSync(dir))
11
+ fs.mkdirSync(dir, { recursive: true });
12
+ }
13
+ export function getChannelsDir(dirs) {
14
+ return path.join(dirs.base, 'channels');
15
+ }
16
+ export function normalizeChannelId(value) {
17
+ const trimmed = value.trim();
18
+ const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
19
+ return (withoutHash || value).toLowerCase();
20
+ }
21
+ export function isValidChannelId(value) {
22
+ if (!value)
23
+ return false;
24
+ return /^[a-z0-9][a-z0-9._-]{0,79}$/.test(normalizeChannelId(value));
25
+ }
26
+ export function isSessionChannelId(value) {
27
+ return normalizeChannelId(value).startsWith('session-');
28
+ }
29
+ export function displayChannelLabel(channelId) {
30
+ const normalized = normalizeChannelId(channelId);
31
+ return `#${normalized}`;
32
+ }
33
+ /** Returns the path to the unified channel JSONL file (metadata + feed) */
34
+ export function channelPath(dirs, channelId) {
35
+ return path.join(getChannelsDir(dirs), `${normalizeChannelId(channelId)}.jsonl`);
36
+ }
37
+ function createMetaHeader(record) {
38
+ return {
39
+ _meta: true,
40
+ v: CHANNEL_META_VERSION,
41
+ id: record.id,
42
+ type: record.type,
43
+ createdAt: record.createdAt,
44
+ createdBy: record.createdBy,
45
+ sessionId: record.sessionId,
46
+ description: record.description,
47
+ };
48
+ }
49
+ export function isMetaHeader(obj) {
50
+ if (!obj || typeof obj !== 'object')
51
+ return false;
52
+ const o = obj;
53
+ return o._meta === true && typeof o.v === 'number' && typeof o.id === 'string';
54
+ }
55
+ function metaHeaderToRecord(header) {
56
+ return {
57
+ id: header.id,
58
+ type: header.type,
59
+ createdAt: header.createdAt,
60
+ createdBy: header.createdBy,
61
+ sessionId: header.sessionId,
62
+ description: header.description,
63
+ };
64
+ }
65
+ function normalizeChannelRecord(raw, fallbackId) {
66
+ const id = normalizeChannelId(raw?.id || fallbackId);
67
+ if (!isValidChannelId(id))
68
+ return null;
69
+ const type = raw?.type === 'session' || raw?.type === 'named'
70
+ ? raw.type
71
+ : raw?.sessionId
72
+ ? 'session'
73
+ : isSessionChannelId(id)
74
+ ? 'session'
75
+ : 'named';
76
+ return {
77
+ id,
78
+ type,
79
+ createdAt: raw?.createdAt || new Date(0).toISOString(),
80
+ createdBy: raw?.createdBy,
81
+ sessionId: raw?.sessionId,
82
+ description: raw?.description,
83
+ };
84
+ }
85
+ /**
86
+ * Read the metadata header from a channel JSONL file.
87
+ * Returns null if file doesn't exist or has invalid header.
88
+ */
89
+ export function readChannelHeader(dirs, channelId) {
90
+ const filePath = channelPath(dirs, channelId);
91
+ if (!fs.existsSync(filePath))
92
+ return null;
93
+ try {
94
+ const content = fs.readFileSync(filePath, 'utf-8');
95
+ const firstLine = content.split('\n')[0];
96
+ if (!firstLine)
97
+ return null;
98
+ const parsed = JSON.parse(firstLine);
99
+ if (isMetaHeader(parsed)) {
100
+ return parsed;
101
+ }
102
+ }
103
+ catch {
104
+ // Ignore parse errors
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * Read all event lines from a channel JSONL file (skips the metadata header).
110
+ * Returns raw JSON strings, not parsed objects.
111
+ */
112
+ export function readChannelEventLines(dirs, channelId) {
113
+ const filePath = channelPath(dirs, channelId);
114
+ if (!fs.existsSync(filePath))
115
+ return [];
116
+ try {
117
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
118
+ if (!content)
119
+ return [];
120
+ const lines = content.split('\n');
121
+ // Skip first line (metadata header)
122
+ return lines.slice(1).filter((line) => line.trim());
123
+ }
124
+ catch {
125
+ return [];
126
+ }
127
+ }
128
+ /**
129
+ * Append a single event line to a channel JSONL file.
130
+ * Creates the file with metadata header if it doesn't exist.
131
+ */
132
+ export function appendChannelEventLine(dirs, channelId, eventLine, meta) {
133
+ const filePath = channelPath(dirs, channelId);
134
+ try {
135
+ ensureDir(getChannelsDir(dirs));
136
+ if (!fs.existsSync(filePath)) {
137
+ // Create new file with minimal metadata header
138
+ const header = {
139
+ _meta: true,
140
+ v: CHANNEL_META_VERSION,
141
+ id: normalizeChannelId(channelId),
142
+ type: isSessionChannelId(channelId) ? 'session' : 'named',
143
+ createdAt: new Date().toISOString(),
144
+ createdBy: meta?.createdBy,
145
+ sessionId: meta?.sessionId,
146
+ description: meta?.description,
147
+ };
148
+ fs.writeFileSync(filePath, JSON.stringify(header) + '\n' + eventLine + '\n');
149
+ }
150
+ else {
151
+ fs.appendFileSync(filePath, eventLine + '\n');
152
+ }
153
+ }
154
+ catch {
155
+ // Best effort
156
+ }
157
+ }
158
+ /**
159
+ * Prune events in a channel JSONL file to keep only the last N events.
160
+ * Preserves the metadata header.
161
+ */
162
+ export function pruneChannelEvents(dirs, channelId, maxEvents) {
163
+ const filePath = channelPath(dirs, channelId);
164
+ if (!fs.existsSync(filePath))
165
+ return;
166
+ try {
167
+ const content = fs.readFileSync(filePath, 'utf-8');
168
+ const lines = content.split('\n');
169
+ if (lines.length <= 1)
170
+ return; // Only header or empty
171
+ const header = lines[0];
172
+ const events = lines.slice(1).filter((line) => line.trim());
173
+ if (events.length <= maxEvents)
174
+ return;
175
+ const pruned = events.slice(-maxEvents);
176
+ fs.writeFileSync(filePath, header + '\n' + pruned.join('\n') + '\n');
177
+ }
178
+ catch {
179
+ // Best effort
180
+ }
181
+ }
182
+ export function getChannel(dirs, channelId) {
183
+ const header = readChannelHeader(dirs, channelId);
184
+ if (header) {
185
+ return metaHeaderToRecord(header);
186
+ }
187
+ return null;
188
+ }
189
+ export function listChannels(dirs) {
190
+ const dir = getChannelsDir(dirs);
191
+ if (!fs.existsSync(dir))
192
+ return [];
193
+ const items = [];
194
+ for (const file of fs.readdirSync(dir)) {
195
+ if (!file.endsWith('.jsonl'))
196
+ continue;
197
+ const filePath = path.join(dir, file);
198
+ try {
199
+ const content = fs.readFileSync(filePath, 'utf-8');
200
+ const firstLine = content.split('\n')[0];
201
+ if (!firstLine)
202
+ continue;
203
+ const parsed = JSON.parse(firstLine);
204
+ if (isMetaHeader(parsed)) {
205
+ items.push(metaHeaderToRecord(parsed));
206
+ }
207
+ }
208
+ catch {
209
+ // Ignore malformed channel files
210
+ }
211
+ }
212
+ return items.sort((a, b) => a.id.localeCompare(b.id));
213
+ }
214
+ export function writeChannel(dirs, record) {
215
+ ensureDir(getChannelsDir(dirs));
216
+ const filePath = channelPath(dirs, record.id);
217
+ const metaHeader = createMetaHeader(record);
218
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
219
+ // Write metadata header as first line, preserving any existing events
220
+ let existingEvents = [];
221
+ if (fs.existsSync(filePath)) {
222
+ try {
223
+ existingEvents = readChannelEventLines(dirs, record.id);
224
+ }
225
+ catch {
226
+ // Ignore read errors, start fresh
227
+ }
228
+ }
229
+ const lines = [JSON.stringify(metaHeader), ...existingEvents];
230
+ fs.writeFileSync(tmp, lines.join('\n') + '\n');
231
+ fs.renameSync(tmp, filePath);
232
+ return record;
233
+ }
234
+ /**
235
+ * Patch the sessionId on a channel's metadata header if it's currently empty.
236
+ * Used by the harness server when it discovers the session ID after the channel
237
+ * was already created (race condition with session-id file).
238
+ * Returns true if the channel was patched, false if no change was needed.
239
+ */
240
+ export function patchChannelSessionId(dirs, channelId, sessionId) {
241
+ if (!sessionId)
242
+ return false;
243
+ const header = readChannelHeader(dirs, channelId);
244
+ if (!header || header.sessionId)
245
+ return false; // already set or missing
246
+ const filePath = channelPath(dirs, channelId);
247
+ try {
248
+ const content = fs.readFileSync(filePath, 'utf-8');
249
+ const lines = content.split('\n');
250
+ if (lines.length === 0)
251
+ return false;
252
+ const meta = JSON.parse(lines[0]);
253
+ meta.sessionId = sessionId;
254
+ lines[0] = JSON.stringify(meta);
255
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
256
+ fs.writeFileSync(tmp, lines.join('\n'));
257
+ fs.renameSync(tmp, filePath);
258
+ return true;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
264
+ export function ensureNamedChannel(dirs, channelId, createdBy, description) {
265
+ const normalized = normalizeChannelId(channelId);
266
+ const existing = getChannel(dirs, normalized);
267
+ if (existing)
268
+ return existing;
269
+ return writeChannel(dirs, {
270
+ id: normalized,
271
+ type: 'named',
272
+ createdAt: new Date().toISOString(),
273
+ createdBy,
274
+ description,
275
+ });
276
+ }
277
+ export function ensureDefaultNamedChannels(dirs, createdBy) {
278
+ return DEFAULT_NAMED_CHANNELS.map((channel) => ensureNamedChannel(dirs, channel.id, createdBy, channel.description));
279
+ }
280
+ function toKebabCase(value) {
281
+ return value
282
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
283
+ .replace(/[^a-zA-Z0-9]+/g, '-')
284
+ .replace(/-+/g, '-')
285
+ .replace(/^-|-$/g, '')
286
+ .toLowerCase();
287
+ }
288
+ function allocateSessionChannelId(dirs, baseId) {
289
+ const normalizedBase = normalizeChannelId(baseId);
290
+ if (!getChannel(dirs, normalizedBase))
291
+ return normalizedBase;
292
+ for (let i = 2; i <= 99; i++) {
293
+ const candidate = `${normalizedBase}-${i}`;
294
+ if (!getChannel(dirs, candidate))
295
+ return candidate;
296
+ }
297
+ const suffix = Math.random().toString(36).slice(2, 6);
298
+ return `${normalizedBase}-${suffix}`;
299
+ }
300
+ export function generateSessionChannelId() {
301
+ const generated = generateMemorableName();
302
+ return toKebabCase(generated);
303
+ }
304
+ export function findChannelBySessionId(dirs, sessionId) {
305
+ if (!sessionId)
306
+ return null;
307
+ for (const channel of listChannels(dirs)) {
308
+ if (channel.type === 'session' && channel.sessionId === sessionId)
309
+ return channel;
310
+ }
311
+ return null;
312
+ }
313
+ export function createSessionChannel(dirs, sessionId, createdBy) {
314
+ return writeChannel(dirs, {
315
+ id: allocateSessionChannelId(dirs, generateSessionChannelId()),
316
+ type: 'session',
317
+ createdAt: new Date().toISOString(),
318
+ createdBy,
319
+ sessionId,
320
+ });
321
+ }
322
+ export function ensureSessionChannel(dirs, sessionId, createdBy) {
323
+ if (sessionId) {
324
+ const existing = findChannelBySessionId(dirs, sessionId);
325
+ if (existing)
326
+ return existing;
327
+ }
328
+ return createSessionChannel(dirs, sessionId, createdBy);
329
+ }
330
+ export function ensureExistingOrCreateChannel(dirs, channelId, options) {
331
+ const normalized = normalizeChannelId(channelId);
332
+ if (!isValidChannelId(normalized))
333
+ return null;
334
+ const existing = getChannel(dirs, normalized);
335
+ if (existing)
336
+ return existing;
337
+ if (DEFAULT_NAMED_CHANNELS.some((channel) => channel.id === normalized)) {
338
+ const preset = DEFAULT_NAMED_CHANNELS.find((channel) => channel.id === normalized);
339
+ return ensureNamedChannel(dirs, normalized, options?.createdBy, preset.description);
340
+ }
341
+ if (!options?.create)
342
+ return null;
343
+ if (isSessionChannelId(normalized)) {
344
+ return writeChannel(dirs, {
345
+ id: normalized,
346
+ type: 'session',
347
+ createdAt: new Date().toISOString(),
348
+ createdBy: options.createdBy,
349
+ description: options.description,
350
+ });
351
+ }
352
+ return ensureNamedChannel(dirs, normalized, options.createdBy, options.description);
353
+ }