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.
- package/CHANGELOG.md +70 -0
- package/README.md +23 -37
- package/action-types.ts +1 -0
- package/channel.ts +30 -0
- package/dist/action-types.js +1 -0
- package/dist/channel.js +353 -0
- package/dist/config-overlay.js +146 -0
- package/dist/config.js +165 -0
- package/dist/extension/activity.js +203 -0
- package/dist/extension/deliver-message.js +36 -0
- package/dist/extension/status.js +96 -0
- package/dist/feed-scroll-core.js +124 -0
- package/dist/feed-scroll.js +136 -0
- package/dist/feed.js +325 -0
- package/dist/handlers/auto-register.js +77 -0
- package/dist/handlers/coordination/index.js +7 -0
- package/dist/handlers/coordination/join.js +98 -0
- package/dist/handlers/coordination/list.js +79 -0
- package/dist/handlers/coordination/messaging.js +91 -0
- package/dist/handlers/coordination/rename.js +17 -0
- package/dist/handlers/coordination/reservations.js +63 -0
- package/dist/handlers/coordination/status.js +58 -0
- package/dist/handlers/coordination/whois.js +76 -0
- package/dist/handlers/coordination.js +2 -0
- package/dist/handlers/result.js +12 -0
- package/dist/handlers.js +3 -0
- package/dist/harness/cli.js +674 -0
- package/dist/harness/server.js +411 -0
- package/dist/index.js +502 -0
- package/dist/lib/format.js +24 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/names.js +212 -0
- package/dist/lib/paths.js +41 -0
- package/dist/lib/status.js +89 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib.js +2 -0
- package/dist/overlay/feed-window.js +160 -0
- package/dist/overlay/input.js +274 -0
- package/dist/overlay/notifications.js +51 -0
- package/dist/overlay/render-detail.js +360 -0
- package/dist/overlay/render-feed.js +109 -0
- package/dist/overlay/render-status.js +189 -0
- package/dist/overlay/snapshot.js +94 -0
- package/dist/overlay-actions.js +327 -0
- package/dist/overlay-render.js +3 -0
- package/dist/overlay.js +509 -0
- package/dist/router.js +125 -0
- package/dist/store/agents.js +180 -0
- package/dist/store/registration.js +378 -0
- package/dist/store/registry.js +2 -0
- package/dist/store/shared.js +186 -0
- package/dist/store.js +1 -0
- package/dist/swarm/agent-loader.js +38 -0
- package/dist/swarm/handlers/_utils.js +5 -0
- package/dist/swarm/handlers/index.js +3 -0
- package/dist/swarm/handlers/spawn.js +180 -0
- package/dist/swarm/handlers/status.js +73 -0
- package/dist/swarm/handlers/task-archive.js +60 -0
- package/dist/swarm/handlers/task-block.js +46 -0
- package/dist/swarm/handlers/task-create.js +36 -0
- package/dist/swarm/handlers/task-lifecycle.js +129 -0
- package/dist/swarm/handlers/task-ops.js +45 -0
- package/dist/swarm/handlers/task-progress.js +28 -0
- package/dist/swarm/handlers/task-query.js +87 -0
- package/dist/swarm/handlers.js +2 -0
- package/dist/swarm/labels.js +11 -0
- package/dist/swarm/live-progress.js +115 -0
- package/dist/swarm/progress.js +80 -0
- package/dist/swarm/result.js +6 -0
- package/dist/swarm/spawn.js +530 -0
- package/dist/swarm/task-actions.js +126 -0
- package/dist/swarm/task-store/cleanup.js +70 -0
- package/dist/swarm/task-store/commands.js +192 -0
- package/dist/swarm/task-store/events.js +170 -0
- package/dist/swarm/task-store/index.js +5 -0
- package/dist/swarm/task-store/persistence.js +42 -0
- package/dist/swarm/task-store/queries.js +146 -0
- package/dist/swarm/task-store/types.js +1 -0
- package/dist/swarm/task-store.js +2 -0
- package/dist/swarm/types.js +1 -0
- package/extension/deliver-message.ts +1 -3
- package/handlers/auto-register.ts +1 -1
- package/handlers/coordination/join.ts +1 -1
- package/handlers/coordination/status.ts +1 -2
- package/handlers/result.ts +4 -4
- package/harness/cli.ts +713 -0
- package/harness/server.ts +495 -0
- package/index.ts +141 -167
- package/install.mjs +1 -1
- package/overlay/render-detail.ts +2 -10
- package/overlay/render-status.ts +1 -6
- package/overlay/snapshot.ts +1 -1
- package/package.json +7 -3
- package/router.ts +1 -1
- package/skills/pi-messenger-swarm/SKILL.md +93 -107
- package/store/registration.ts +14 -10
- package/swarm/handlers/spawn.ts +4 -3
- package/swarm/handlers/status.ts +3 -3
- package/swarm/handlers/task-create.ts +1 -1
- 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
|
-
```
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
```
|
|
68
|
-
|
|
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
|
-
```
|
|
175
|
-
|
|
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
|
-
```
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
```
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
```
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
```
|
|
298
|
-
|
|
299
|
-
|
|
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
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 {};
|
package/dist/channel.js
ADDED
|
@@ -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
|
+
}
|