@cordfuse/crosstalk 5.0.0-alpha.7 → 6.0.0-alpha.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 (53) hide show
  1. package/bin/crosstalk.js +34 -78
  2. package/package.json +4 -4
  3. package/src/activation.ts +104 -0
  4. package/src/attach.ts +1 -1
  5. package/src/channel.ts +8 -21
  6. package/src/chat.ts +52 -115
  7. package/src/dispatch.ts +265 -660
  8. package/src/dlq.ts +68 -136
  9. package/src/init.ts +17 -41
  10. package/src/open.ts +55 -31
  11. package/src/replies.ts +59 -0
  12. package/src/send.ts +48 -67
  13. package/src/state.ts +173 -0
  14. package/src/status.ts +18 -57
  15. package/src/stop.ts +37 -0
  16. package/src/transport.ts +68 -198
  17. package/src/turnq.ts +64 -32
  18. package/src/upgrade.ts +9 -11
  19. package/src/wake.ts +5 -6
  20. package/src/cursor.ts +0 -48
  21. package/template/.amazonq/rules/crosstalk.md +0 -2
  22. package/template/.continue/rules/crosstalk.md +0 -7
  23. package/template/.cursor/rules/crosstalk.mdc +0 -7
  24. package/template/.github/copilot-instructions.md +0 -2
  25. package/template/.windsurfrules +0 -2
  26. package/template/AGENTS.md +0 -2
  27. package/template/ANTIGRAVITY.md +0 -2
  28. package/template/CLAUDE.md +0 -2
  29. package/template/GEMINI.md +0 -2
  30. package/template/OPENCODE.md +0 -2
  31. package/template/QWEN.md +0 -2
  32. package/template/README.md +0 -22
  33. package/template/local/CROSSTALK.md +0 -4
  34. package/template/upstream/CROSSTALK-VERSION +0 -1
  35. package/template/upstream/CROSSTALK.md +0 -589
  36. package/template/upstream/JITTER.md +0 -24
  37. package/template/upstream/OPERATOR.md +0 -60
  38. package/template/upstream/PROTOCOL.md +0 -260
  39. package/template/upstream/actors/cloud-architect.md +0 -83
  40. package/template/upstream/actors/concierge.md +0 -130
  41. package/template/upstream/actors/devops-engineer.md +0 -83
  42. package/template/upstream/actors/documentation-engineer.md +0 -107
  43. package/template/upstream/actors/infrastructure-engineer.md +0 -83
  44. package/template/upstream/actors/junior-developer.md +0 -83
  45. package/template/upstream/actors/precise-generalist.md +0 -48
  46. package/template/upstream/actors/product-manager.md +0 -83
  47. package/template/upstream/actors/qa-engineer.md +0 -83
  48. package/template/upstream/actors/security-engineer.md +0 -92
  49. package/template/upstream/actors/senior-generalist-engineer.md +0 -111
  50. package/template/upstream/actors/senior-software-engineer.md +0 -94
  51. package/template/upstream/actors/skeptic.md +0 -89
  52. package/template/upstream/actors/technical-writer.md +0 -89
  53. package/template/upstream/actors/ux-designer.md +0 -83
package/src/replies.ts ADDED
@@ -0,0 +1,59 @@
1
+ // crosstalk replies — have my messages been answered yet?
2
+ //
3
+ // Checks each given relPath for a message whose `re:` points back at it.
4
+ // This replaces v5's read-receipt machinery: instead of recipients writing
5
+ // "I saw it" markers into the channel, the asker just looks for actual
6
+ // replies. Exit 0 when everything is answered, 2 while anything is pending
7
+ // — so agents can poll cheaply in a loop.
8
+
9
+ import { resolve } from 'path';
10
+ import { gitPull, discoverChannels, listChannelMessages } from './transport.js';
11
+ import { reList } from './activation.js';
12
+
13
+ const transportRoot = resolve(process.cwd());
14
+ const argv = process.argv.slice(2);
15
+
16
+ function flag(name: string): string | undefined {
17
+ const i = argv.indexOf(name);
18
+ if (i === -1 || i === argv.length - 1) return undefined;
19
+ return argv[i + 1];
20
+ }
21
+
22
+ const reArg = flag('--re');
23
+ const channelArg = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
24
+
25
+ if (!reArg) {
26
+ console.error('Usage: crosstalk replies --re <relPath[,relPath...]> [--channel <uuid>]');
27
+ process.exit(1);
28
+ }
29
+
30
+ const targets = reArg.split(',').map((s) => s.trim()).filter(Boolean);
31
+
32
+ gitPull(transportRoot); // best-effort freshness; a failed pull just means slightly stale answers
33
+
34
+ const channels = channelArg ? [channelArg] : discoverChannels(transportRoot);
35
+
36
+ // target relPath -> reply found
37
+ const found = new Map<string, { from: string; relPath: string }>();
38
+ for (const channel of channels) {
39
+ for (const msg of listChannelMessages(transportRoot, channel)) {
40
+ for (const entry of reList(msg.data['re'])) {
41
+ if (targets.includes(entry) && !found.has(entry)) {
42
+ found.set(entry, { from: String(msg.data['from']), relPath: msg.relPath });
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ let pending = 0;
49
+ for (const target of targets) {
50
+ const reply = found.get(target);
51
+ if (reply) {
52
+ console.log(`REPLIED ${target} <- ${reply.from} (${reply.relPath})`);
53
+ } else {
54
+ console.log(`PENDING ${target}`);
55
+ pending++;
56
+ }
57
+ }
58
+
59
+ process.exit(pending > 0 ? 2 : 0);
package/src/send.ts CHANGED
@@ -1,9 +1,19 @@
1
+ // crosstalk send — write a message into a channel and push it.
2
+ //
3
+ // `re:` is the whole activation story (CROSSTALK.md "Activation"):
4
+ // inside a dispatched turn, CROSSTALK_DISPATCH_RE carries the relPath(s)
5
+ // of the message(s) being answered (comma-separated for a batch), and
6
+ // send links them all automatically. `--new` suppresses that — the
7
+ // message becomes a fresh task and wakes its addressee unconditionally.
8
+ // Operators (no env) always send new tasks.
9
+
1
10
  import { resolve, join } from 'path';
2
11
  import { mkdirSync, writeFileSync } from 'fs';
3
12
  import { now, messageFilename } from './filenames.js';
4
13
  import { serializeFrontmatter } from './frontmatter.js';
5
14
  import { gitCommitAndPush } from './transport.js';
6
15
  import { withLock } from './turnq.js';
16
+ import { sendWakeSignal } from './state.js';
7
17
 
8
18
  const transportRoot = resolve(process.cwd());
9
19
  const argv = process.argv.slice(2);
@@ -15,93 +25,64 @@ function flag(name: string): string | undefined {
15
25
  }
16
26
 
17
27
  async function main(): Promise<void> {
18
- const channelUuid = flag('--channel');
28
+ const channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
19
29
  const to = flag('--to');
20
- // Sender identity precedence:
21
- // 1. --from on the command line (explicit operator/actor choice)
22
- // 2. CROSSTALK_DISPATCH_ACTOR env var (set by dispatch.ts when it spawns
23
- // an actor's CLI — so the actor's outbound messages route as itself,
24
- // not as the operator). Fixes the alpha.5 finding where concierge's
25
- // fan-out messages went out as `from=steve` because send.ts fell
26
- // through to USER instead.
27
- // 3. $USER (interactive operator default)
28
- // 4. literal 'steve' as last resort
29
30
  const from = flag('--from')
30
31
  ?? process.env['CROSSTALK_DISPATCH_ACTOR']
31
32
  ?? process.env['USER']
32
- ?? 'steve';
33
+ ?? 'operator';
33
34
  const tier = flag('--tier');
34
- // Lifecycle kind. `work` (default) — recipient is being asked to act, will
35
- // wake on receipt. `result` — informational reply, wakes the recipient only
36
- // if it previously asked the sender for work (reply causality). See
37
- // PROTOCOL.md "Message kinds". Proactive sends default to `work`; the
38
- // runtime's auto-reply path defaults to `result`.
39
- const kind = flag('--kind') ?? 'work';
35
+ const isNew = argv.includes('--new');
40
36
  const body = argv[argv.length - 1];
41
37
 
42
38
  if (!channelUuid || !to || !body || body.startsWith('--')) {
43
39
  console.error(
44
- 'Usage: crosstalk send --channel <uuid> --to <actor> [--from <actor>] [--tier <name>] [--kind work|result] "<message body>"',
40
+ 'Usage: crosstalk send --to <actor[,actor...]> [--channel <uuid>] [--from <actor>] [--tier <name>] [--new] "<message body>"',
45
41
  );
46
42
  process.exit(1);
47
43
  }
48
44
 
49
- if (kind !== 'work' && kind !== 'result') {
50
- console.error(`Invalid --kind '${kind}'. Must be 'work' or 'result'.`);
51
- process.exit(1);
52
- }
45
+ const reTargets = (isNew ? '' : process.env['CROSSTALK_DISPATCH_RE'] ?? '')
46
+ .split(',')
47
+ .map((s) => s.trim())
48
+ .filter(Boolean);
53
49
 
54
- await withLock('dispatch', async () => {
55
- const ts = now();
56
- const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
57
- mkdirSync(dir, { recursive: true });
50
+ const ts = now();
51
+ const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
52
+ mkdirSync(dir, { recursive: true });
58
53
 
59
- const frontmatter: Record<string, unknown> = {
60
- from,
61
- to,
62
- type: 'text',
63
- kind,
64
- timestamp: ts.iso,
65
- };
66
- if (tier) frontmatter.tier = tier;
67
- const content = serializeFrontmatter(frontmatter, body);
54
+ const frontmatter: Record<string, unknown> = {
55
+ from,
56
+ to: to.includes(',') ? to.split(',').map((s) => s.trim()).filter(Boolean) : to,
57
+ type: 'text',
58
+ timestamp: ts.iso,
59
+ };
60
+ if (reTargets.length === 1) frontmatter['re'] = reTargets[0];
61
+ else if (reTargets.length > 1) frontmatter['re'] = reTargets;
62
+ if (tier) frontmatter['tier'] = tier;
68
63
 
69
- const filename = messageFilename(ts);
70
- writeFileSync(join(dir, filename), content);
64
+ const filename = messageFilename(ts);
65
+ writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
71
66
 
72
- const pushResult = gitCommitAndPush(
73
- transportRoot,
74
- `send: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`,
75
- );
67
+ // Lock only the commit+push — the narrow critical section. If the lock
68
+ // can't be had, git arbitrates anyway (see turnq.ts).
69
+ const pushResult = await withLock(transportRoot, 'git', async () =>
70
+ gitCommitAndPush(transportRoot, `send: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`),
71
+ );
76
72
 
77
- // Poke dispatch to tick immediately (even if push failed — local cursor still advances).
78
- try {
79
- const wakeDir = join(transportRoot, '.turnq');
80
- mkdirSync(wakeDir, { recursive: true });
81
- writeFileSync(join(wakeDir, 'wake.signal'), `${Date.now()}\n`);
82
- } catch {
83
- /* wake is best-effort */
84
- }
73
+ sendWakeSignal(transportRoot);
85
74
 
86
- if (!pushResult.ok && pushResult.error) {
87
- // Note: deliberately NOT writing to errors/. That directory is dispatcher-
88
- // owned state, and operator-side writes from `crosstalk send` were
89
- // dirtying the working tree, causing subsequent `git pull --rebase` to
90
- // fail with "unstaged changes". Surface the error to stderr only.
91
- const kind = pushResult.committed ? 'push' : 'commit';
92
- console.error(`Wrote locally: ${join(ts.pathDate, filename)}`);
93
- console.error(`but git ${kind} FAILED:`);
94
- console.error(` ${pushResult.error.slice(0, 300)}`);
95
- console.error('');
96
- console.error('Your message is in your local clone but not on origin.');
97
- console.error('Recover with:');
98
- console.error(' git pull --rebase');
99
- console.error(' git push');
100
- process.exit(3);
101
- }
75
+ if (!pushResult.ok && pushResult.error) {
76
+ console.error(`Wrote locally: ${join(ts.pathDate, filename)}`);
77
+ console.error(`but git ${pushResult.committed ? 'push' : 'commit'} FAILED:`);
78
+ console.error(` ${pushResult.error.slice(0, 300)}`);
79
+ console.error('\nYour message is in the local clone but not on origin. Recover with:');
80
+ console.error(' git fetch origin && git rebase origin/main && git push');
81
+ process.exit(3);
82
+ }
102
83
 
103
- console.log(`Sent: ${join(ts.pathDate, filename)}`);
104
- });
84
+ console.log(`Sent: ${join(ts.pathDate, filename)}${reTargets.length ? ` (re: ${reTargets.join(', ')})` : ''}`);
85
+ process.exit(0);
105
86
  }
106
87
 
107
88
  main();
package/src/state.ts ADDED
@@ -0,0 +1,173 @@
1
+ // Machine-local dispatcher state. NONE of this lives in the transport repo —
2
+ // the repo carries conversation; each machine carries its own progress
3
+ // through it. That separation is what makes the dispatcher's git operations
4
+ // conflict-free: its commits only ever contain data/ (messages).
5
+ //
6
+ // Layout under the state dir:
7
+ // cursors/<actor>/<channel-uuid> — last-scanned git commit hash
8
+ // dlq/<id>.md — failed-dispatch entries
9
+ // errors.log — infra failures, JSONL, append-only
10
+ // heartbeat — last tick timestamp + pid + version
11
+ // dispatcher.pid — PID of the running dispatcher process
12
+ // wake.signal — touched to wake the dispatch loop
13
+ //
14
+ // Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
15
+ // else ~/.local/state/crosstalk/<transport-id> where <transport-id> is a
16
+ // hash of the git origin URL (or the repo path when there is no origin).
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ unlinkSync,
24
+ appendFileSync,
25
+ } from 'fs';
26
+ import { join, dirname } from 'path';
27
+ import { homedir } from 'os';
28
+ import { createHash } from 'crypto';
29
+ import { spawnSync } from 'child_process';
30
+
31
+ const resolved = new Map<string, string>();
32
+
33
+ export function stateDir(transportRoot: string): string {
34
+ const cached = resolved.get(transportRoot);
35
+ if (cached) return cached;
36
+ let dir = process.env['CROSSTALK_STATE_DIR'];
37
+ if (!dir) {
38
+ const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
39
+ cwd: transportRoot,
40
+ encoding: 'utf-8',
41
+ });
42
+ const identity = origin.status === 0 && origin.stdout.trim()
43
+ ? origin.stdout.trim()
44
+ : transportRoot;
45
+ const id = createHash('sha256').update(identity).digest('hex').slice(0, 12);
46
+ dir = join(homedir(), '.local', 'state', 'crosstalk', id);
47
+ }
48
+ mkdirSync(dir, { recursive: true });
49
+ resolved.set(transportRoot, dir);
50
+ return dir;
51
+ }
52
+
53
+ // ── cursors ──
54
+
55
+ // A cursor is the git commit hash the channel was last scanned at. NOT a
56
+ // message relPath: filenames order by sender timestamp, but messages reach
57
+ // origin in PUSH order — a message that loses a push race can land on
58
+ // origin with a timestamp earlier than one already processed, and a
59
+ // relPath cursor would skip it forever. Commit-based cursors can't: a late
60
+ // arrival is always in a new commit. (Found by the Monte Carlo harness.)
61
+ const VALID_CURSOR = /^[0-9a-f]{40}$/;
62
+
63
+ export function cursorPath(transportRoot: string, actor: string, channelUuid: string): string {
64
+ return join(stateDir(transportRoot), 'cursors', actor, channelUuid);
65
+ }
66
+
67
+ export function readCursor(transportRoot: string, actor: string, channelUuid: string): string | null {
68
+ const p = cursorPath(transportRoot, actor, channelUuid);
69
+ if (!existsSync(p)) return null;
70
+ let raw: string;
71
+ try {
72
+ raw = readFileSync(p, 'utf-8').trim();
73
+ } catch (err) {
74
+ logError(transportRoot, 'fs', `cursor read failed for ${actor}@${channelUuid}: ${(err as Error).message}`);
75
+ return null;
76
+ }
77
+ if (raw.length === 0) return null;
78
+ if (!VALID_CURSOR.test(raw)) {
79
+ logError(
80
+ transportRoot,
81
+ 'parse',
82
+ `invalid cursor for ${actor}@${channelUuid}: '${raw.slice(0, 80)}' — re-scanning channel from start`,
83
+ );
84
+ return null;
85
+ }
86
+ return raw;
87
+ }
88
+
89
+ export function writeCursor(
90
+ transportRoot: string,
91
+ actor: string,
92
+ channelUuid: string,
93
+ commit: string,
94
+ ): void {
95
+ const p = cursorPath(transportRoot, actor, channelUuid);
96
+ mkdirSync(dirname(p), { recursive: true });
97
+ writeFileSync(p, commit + '\n');
98
+ }
99
+
100
+ // ── pidfile ──
101
+
102
+ export function pidfilePath(transportRoot: string): string {
103
+ return join(stateDir(transportRoot), 'dispatcher.pid');
104
+ }
105
+
106
+ export function writePidfile(transportRoot: string): void {
107
+ try {
108
+ writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
109
+ } catch { /* best-effort */ }
110
+ }
111
+
112
+ export function removePidfile(transportRoot: string): void {
113
+ try {
114
+ unlinkSync(pidfilePath(transportRoot));
115
+ } catch { /* already gone */ }
116
+ }
117
+
118
+ export function readPidfile(transportRoot: string): number | null {
119
+ try {
120
+ const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
121
+ const n = parseInt(raw, 10);
122
+ return Number.isFinite(n) && n > 0 ? n : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // ── heartbeat + wake ──
129
+
130
+ export function writeHeartbeat(transportRoot: string, version: string): void {
131
+ try {
132
+ const data = { ts: new Date().toISOString(), pid: process.pid, version };
133
+ writeFileSync(join(stateDir(transportRoot), 'heartbeat'), JSON.stringify(data) + '\n');
134
+ } catch { /* best-effort */ }
135
+ }
136
+
137
+ export function readHeartbeat(transportRoot: string): { ts: string; pid: number; version: string } | null {
138
+ try {
139
+ return JSON.parse(readFileSync(join(stateDir(transportRoot), 'heartbeat'), 'utf-8'));
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ export function wakeSignalPath(transportRoot: string): string {
146
+ return join(stateDir(transportRoot), 'wake.signal');
147
+ }
148
+
149
+ export function sendWakeSignal(transportRoot: string): void {
150
+ try {
151
+ writeFileSync(wakeSignalPath(transportRoot), `${Date.now()}\n`);
152
+ } catch { /* best-effort */ }
153
+ }
154
+
155
+ // ── error log — infra failures, JSONL append ──
156
+
157
+ export type ErrorKind = 'git_pull' | 'git_push' | 'git_commit' | 'fs' | 'parse' | 'turnq' | 'other';
158
+
159
+ export function logError(transportRoot: string, kind: ErrorKind, message: string): void {
160
+ try {
161
+ const line = JSON.stringify({ ts: new Date().toISOString(), kind, message: message.slice(0, 500) });
162
+ appendFileSync(join(stateDir(transportRoot), 'errors.log'), line + '\n');
163
+ } catch { /* best-effort */ }
164
+ }
165
+
166
+ export function countErrors(transportRoot: string): number {
167
+ try {
168
+ const raw = readFileSync(join(stateDir(transportRoot), 'errors.log'), 'utf-8');
169
+ return raw.split('\n').filter((l) => l.trim().length > 0).length;
170
+ } catch {
171
+ return 0;
172
+ }
173
+ }
package/src/status.ts CHANGED
@@ -1,14 +1,16 @@
1
+ // crosstalk status — transport + dispatcher health at a glance.
2
+
1
3
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
4
  import { resolve, join } from 'path';
3
5
  import { findHostFile } from './actor.js';
4
- import { discoverChannels, countErrorEntries } from './transport.js';
5
- import { readCursor } from './cursor.js';
6
- import { parseFrontmatter } from './frontmatter.js';
7
- import type { DlqEntry } from './dlq.js';
6
+ import { discoverChannels } from './transport.js';
7
+ import { stateDir, readCursor, readHeartbeat, countErrors } from './state.js';
8
+ import { countDlqEntries } from './dlq.js';
8
9
 
9
10
  const transportRoot = resolve(process.cwd());
10
11
 
11
12
  console.log(`Crosstalk transport: ${transportRoot}`);
13
+ console.log(`Machine state dir: ${stateDir(transportRoot)}`);
12
14
  console.log('');
13
15
 
14
16
  let actorNames: string[] = [];
@@ -17,8 +19,7 @@ try {
17
19
  console.log(`Host file: hosts/${host.alias}.md`);
18
20
  actorNames = Object.keys(host.actors);
19
21
  for (const a of actorNames) {
20
- const tiers = Object.entries(host.actors[a]);
21
- const tierDescs = tiers.map(([t, v]) => {
22
+ const tierDescs = Object.entries(host.actors[a]!).map(([t, v]) => {
22
23
  const count = typeof v === 'object' && v.count ? `x${v.count}` : '';
23
24
  return `${t}${count}`;
24
25
  });
@@ -30,33 +31,13 @@ try {
30
31
 
31
32
  console.log('');
32
33
 
33
- const lockPath = join(transportRoot, '.turnq', 'dispatch.lock');
34
- if (existsSync(lockPath)) {
35
- try {
36
- const meta = JSON.parse(readFileSync(lockPath, 'utf-8')) as { pid: number; acquired: string };
37
- const ageS = Math.floor((Date.now() - new Date(meta.acquired).getTime()) / 1000);
38
- console.log(`turnq lock: HELD by pid ${meta.pid} for ${ageS}s (since ${meta.acquired})`);
39
- } catch {
40
- console.log('turnq lock: HELD (unparseable metadata)');
41
- }
42
- } else {
43
- console.log('turnq lock: free');
44
- }
45
-
46
- const heartbeatPath = join(transportRoot, '.turnq', 'heartbeat');
47
- if (existsSync(heartbeatPath)) {
48
- try {
49
- const hb = JSON.parse(readFileSync(heartbeatPath, 'utf-8')) as { ts: string; pid: number; version?: string };
50
- const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
51
- const fresh = ageS < 120;
52
- const stale = ageS > 300;
53
- const label = fresh ? 'LIVE' : stale ? 'STALE' : 'idle';
54
- console.log(`dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago${hb.version ? `, ${hb.version}` : ''})`);
55
- } catch {
56
- console.log('dispatch heartbeat: (unparseable)');
57
- }
34
+ const hb = readHeartbeat(transportRoot);
35
+ if (hb) {
36
+ const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
37
+ const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
38
+ console.log(`dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago, ${hb.version})`);
58
39
  } else {
59
- console.log('dispatch heartbeat: never (no dispatch has run on this clone)');
40
+ console.log('dispatch heartbeat: never (no dispatch has run on this machine)');
60
41
  }
61
42
 
62
43
  console.log('');
@@ -69,7 +50,7 @@ for (const ch of channels) {
69
50
  const chPath = join(channelDir, 'CHANNEL.md');
70
51
  if (existsSync(chPath)) {
71
52
  const m = readFileSync(chPath, 'utf-8').match(/^name:\s*(.+)$/m);
72
- if (m) name = m[1].trim();
53
+ if (m) name = m[1]!.trim();
73
54
  }
74
55
  let msgCount = 0;
75
56
  const walk = (d: string): void => {
@@ -81,34 +62,14 @@ for (const ch of channels) {
81
62
  };
82
63
  try { walk(channelDir); } catch { /* ignore */ }
83
64
  console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)`);
84
-
85
65
  for (const actor of actorNames) {
86
66
  const cursor = readCursor(transportRoot, actor, ch);
87
- if (cursor) console.log(` cursor[${actor}] ${cursor}`);
67
+ if (cursor) console.log(` cursor[${actor}] -> ${cursor}`);
88
68
  }
89
69
  }
90
70
 
91
71
  console.log('');
92
72
 
93
- const dlqDir = join(transportRoot, 'dlq');
94
- let dlqTotal = 0;
95
- let dlqQuarantined = 0;
96
- let dlqDispatch = 0;
97
- let dlqConfig = 0;
98
- if (existsSync(dlqDir)) {
99
- for (const f of readdirSync(dlqDir).filter((x) => x.endsWith('.md'))) {
100
- try {
101
- const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dlqDir, f), 'utf-8'));
102
- dlqTotal++;
103
- if (data.quarantined) dlqQuarantined++;
104
- if (data.kind === 'dispatch') dlqDispatch++;
105
- if (data.kind === 'config') dlqConfig++;
106
- } catch { /* skip unparseable */ }
107
- }
108
- }
109
- console.log(
110
- `DLQ entries: ${dlqTotal} (${dlqDispatch} dispatch, ${dlqConfig} config, ${dlqQuarantined} quarantined)`,
111
- );
112
-
113
- const errorCount = countErrorEntries(transportRoot);
114
- console.log(`Infrastructure errors logged: ${errorCount}`);
73
+ const dlq = countDlqEntries(transportRoot);
74
+ console.log(`DLQ entries: ${dlq.total} (${dlq.quarantined} quarantined)`);
75
+ console.log(`Infrastructure errors logged: ${countErrors(transportRoot)}`);
package/src/stop.ts ADDED
@@ -0,0 +1,37 @@
1
+ // crosstalk stop — send SIGTERM to the running dispatcher and wait for it to exit.
2
+
3
+ import { resolve } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { readPidfile, removePidfile } from './state.js';
6
+
7
+ const transportRoot = resolve(process.cwd());
8
+
9
+ function processRunning(pid: number): boolean {
10
+ try { process.kill(pid, 0); return true; } catch { return false; }
11
+ }
12
+
13
+ const pid = readPidfile(transportRoot);
14
+ if (pid === null) {
15
+ console.error('crosstalk stop: no dispatcher.pid found — is dispatch running?');
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!processRunning(pid)) {
20
+ console.error(`crosstalk stop: pid ${pid} is not running — removing stale pidfile`);
21
+ removePidfile(transportRoot);
22
+ process.exit(1);
23
+ }
24
+
25
+ process.kill(pid, 'SIGTERM');
26
+
27
+ const deadline = Date.now() + 5_000;
28
+ while (Date.now() < deadline) {
29
+ if (!processRunning(pid)) {
30
+ console.log(`crosstalk stop: dispatcher (pid ${pid}) stopped`);
31
+ process.exit(0);
32
+ }
33
+ spawnSync('sleep', ['0.1']);
34
+ }
35
+
36
+ console.error(`crosstalk stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
37
+ process.exit(1);