@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/transport.ts CHANGED
@@ -1,8 +1,13 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
1
+ // Git transport layer. The dispatcher's commits contain ONLY data/
2
+ // machine-local state lives in the state dir (state.ts), so there is
3
+ // nothing to exclude, untrack, or heal. Push rejection means another
4
+ // machine won the race: pull --rebase and retry at the call site.
5
+
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
2
7
  import { join } from 'path';
3
8
  import { spawnSync } from 'child_process';
4
- import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
5
- import { now } from './filenames.js';
9
+ import { parseFrontmatter } from './frontmatter.js';
10
+ import { logError } from './state.js';
6
11
 
7
12
  export interface ChannelMessage {
8
13
  relPath: string;
@@ -28,9 +33,8 @@ function captureGit(cwd: string, args: string[]): { status: number; stdout: stri
28
33
  return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
29
34
  }
30
35
 
31
- // Detect and auto-recover from an interrupted rebase/merge in the working tree.
32
- // Returns true if recovery was performed (caller should treat as a non-fatal
33
- // infrastructure event). The error log surfaces the recovery in errors/.
36
+ // Detect and abort an interrupted rebase/merge left by a killed process.
37
+ // Returns true if recovery was performed.
34
38
  export function recoverInterruptedGit(transportRoot: string): boolean {
35
39
  const halfStates: { dir: string; abortArgs: string[] }[] = [
36
40
  { dir: '.git/rebase-merge', abortArgs: ['rebase', '--abort'] },
@@ -41,7 +45,7 @@ export function recoverInterruptedGit(transportRoot: string): boolean {
41
45
  for (const { dir, abortArgs } of halfStates) {
42
46
  if (existsSync(join(transportRoot, dir))) {
43
47
  const r = captureGit(transportRoot, abortArgs);
44
- writeErrorLog(
48
+ logError(
45
49
  transportRoot,
46
50
  'git_pull',
47
51
  `recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
@@ -52,17 +56,47 @@ export function recoverInterruptedGit(transportRoot: string): boolean {
52
56
  return false;
53
57
  }
54
58
 
59
+ // The commit cursors anchor to. Prefer the origin tip: origin history is
60
+ // append-only, so a cursor pointing there can never be orphaned by a local
61
+ // `pull --rebase` rewriting unpushed commits. HEAD is the fallback for
62
+ // transports without a remote.
63
+ export function cursorBaseline(transportRoot: string): string | null {
64
+ for (const ref of ['origin/HEAD', 'origin/main', 'HEAD']) {
65
+ const r = captureGit(transportRoot, ['rev-parse', ref]);
66
+ if (r.status === 0) return r.stdout.trim();
67
+ }
68
+ return null;
69
+ }
70
+
71
+ // Repo-relative paths of message files added between `sinceCommit` and
72
+ // HEAD. Returns null when the commit is unknown to this clone (state dir
73
+ // copied across transports, history rewritten) — caller falls back to a
74
+ // full channel scan.
75
+ export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
76
+ const r = captureGit(transportRoot, [
77
+ 'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
78
+ ]);
79
+ if (r.status !== 0) return null;
80
+ return r.stdout.split('\n').filter(Boolean);
81
+ }
82
+
55
83
  export function gitPull(transportRoot: string): GitResult {
56
84
  recoverInterruptedGit(transportRoot);
57
- const r = captureGit(transportRoot, ['pull', '--rebase', '--quiet']);
58
- if (r.status !== 0) {
59
- return { ok: false, error: (r.stderr || r.stdout).trim().slice(0, 500) };
85
+ const fetch = captureGit(transportRoot, ['fetch', 'origin', '--quiet']);
86
+ if (fetch.status !== 0) {
87
+ return { ok: false, error: (fetch.stderr || fetch.stdout).trim().slice(0, 500) };
88
+ }
89
+ const rebase = captureGit(transportRoot, ['rebase', 'origin/main']);
90
+ if (rebase.status !== 0) {
91
+ return { ok: false, error: (rebase.stderr || rebase.stdout).trim().slice(0, 500) };
60
92
  }
61
93
  return { ok: true };
62
94
  }
63
95
 
96
+ // Stage data/ only, commit, push. On push rejection, one pull --rebase +
97
+ // re-push — collision-free filenames make the rebase trivially clean.
64
98
  export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
65
- const status = captureGit(transportRoot, ['status', '--porcelain']);
99
+ const status = captureGit(transportRoot, ['status', '--porcelain', '--', 'data/']);
66
100
  if (status.status !== 0) {
67
101
  return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
68
102
  }
@@ -70,44 +104,30 @@ export function gitCommitAndPush(transportRoot: string, message: string): GitPus
70
104
  return { ok: true, committed: false, pushed: false };
71
105
  }
72
106
 
73
- // Stage everything EXCEPT .turnq/ (machine-local runtime state; commits of
74
- // this directory cause modify/delete conflicts the moment another clone
75
- // untracks it via gitignore). Pathspec exclusion is independent of the
76
- // transport's .gitignore — defensive against gitignore drift.
77
- //
78
- // Edge: `git add -A . :(exclude).turnq` exits non-zero with "The following
79
- // paths are ignored by one of your .gitignore files: .turnq" because the
80
- // exclude pathspec itself matches a gitignored path. The add still stages
81
- // every other change correctly — only the exit code is misleading. So we
82
- // treat that specific failure-pattern as benign and let the subsequent
83
- // commit step decide whether anything actually got staged.
84
- const add = captureGit(transportRoot, ['add', '-A', '.', ':(exclude).turnq']);
85
- const addBenignIgnoredPath = add.status !== 0 &&
86
- /paths are ignored/.test(add.stderr);
87
- if (add.status !== 0 && !addBenignIgnoredPath) {
107
+ const add = captureGit(transportRoot, ['add', '--', 'data/']);
108
+ if (add.status !== 0) {
88
109
  return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
89
110
  }
90
111
 
91
- // If .turnq/ was previously committed (pre-alpha.4 transport), the index
92
- // may still hold tracked .turnq/* entries. Untrack them here so subsequent
93
- // pulls don't fight with operator clones that have untracked .turnq/. This
94
- // is a one-time-per-transport heal; on a clean transport it's a no-op.
95
- const indexedTurnq = captureGit(transportRoot, ['ls-files', '.turnq']);
96
- if (indexedTurnq.status === 0 && indexedTurnq.stdout.trim().length > 0) {
97
- captureGit(transportRoot, ['rm', '-r', '--cached', '--quiet', '.turnq']);
98
- }
99
-
100
- const commit = captureGit(transportRoot, ['commit', '-m', message]);
112
+ const commit = captureGit(transportRoot, ['commit', '-m', message, '--', 'data/']);
101
113
  if (commit.status !== 0) {
102
- // Empty commit ("nothing to commit") is fine — the exclusion may have
103
- // dropped the only change. Treat exit-1 with no error text as no-op.
104
114
  const noop = commit.stdout.includes('nothing to commit') ||
105
115
  commit.stderr.includes('nothing to commit');
106
116
  if (noop) return { ok: true, committed: false, pushed: false };
107
117
  return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
108
118
  }
109
119
 
110
- const push = captureGit(transportRoot, ['push', '--quiet']);
120
+ // Push rejection is NORMAL under concurrent writers — git is the
121
+ // arbiter and collision-free filenames make every rebase clean. Retry
122
+ // with jitter; many writers racing one origin converge within a few
123
+ // rounds (verified by the Monte Carlo harness).
124
+ let push = captureGit(transportRoot, ['push', '--quiet']);
125
+ for (let attempt = 0; push.status !== 0 && attempt < 5; attempt++) {
126
+ spawnSync('sleep', [(0.05 + Math.random() * 0.3 * (attempt + 1)).toFixed(2)]);
127
+ const pull = gitPull(transportRoot);
128
+ if (!pull.ok) continue;
129
+ push = captureGit(transportRoot, ['push', '--quiet']);
130
+ }
111
131
  if (push.status !== 0) {
112
132
  return {
113
133
  ok: false,
@@ -126,34 +146,23 @@ export function discoverChannels(transportRoot: string): string[] {
126
146
  try {
127
147
  entries = readdirSync(channelsDir);
128
148
  } catch (err) {
129
- writeErrorLog(
130
- transportRoot,
131
- 'fs',
132
- `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`,
133
- );
149
+ logError(transportRoot, 'fs', `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`);
134
150
  return [];
135
151
  }
136
152
  return entries.filter((name) => {
137
- const dir = join(channelsDir, name);
138
153
  try {
139
- return statSync(dir).isDirectory();
140
- } catch (err) {
141
- writeErrorLog(
142
- transportRoot,
143
- 'fs',
144
- `discoverChannels stat failed on ${dir}: ${(err as Error).message}`,
145
- );
154
+ return statSync(join(channelsDir, name)).isDirectory();
155
+ } catch {
146
156
  return false;
147
157
  }
148
158
  });
149
159
  }
150
160
 
151
161
  function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
152
- // Required: from (string), to (string or string[]), type (string), timestamp (string)
153
- if (typeof data.from !== 'string') return false;
154
- if (typeof data.to !== 'string' && !Array.isArray(data.to)) return false;
155
- if (typeof data.type !== 'string') return false;
156
- if (typeof data.timestamp !== 'string') return false;
162
+ if (typeof data['from'] !== 'string') return false;
163
+ if (typeof data['to'] !== 'string' && !Array.isArray(data['to'])) return false;
164
+ if (typeof data['type'] !== 'string') return false;
165
+ if (typeof data['timestamp'] !== 'string') return false;
157
166
  return true;
158
167
  }
159
168
 
@@ -175,19 +184,11 @@ export function listChannelMessages(transportRoot: string, channelUuid: string):
175
184
  try {
176
185
  parsed = parseFrontmatter(raw);
177
186
  } catch (err) {
178
- writeErrorLog(
179
- transportRoot,
180
- 'parse',
181
- `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`,
182
- );
187
+ logError(transportRoot, 'parse', `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`);
183
188
  continue;
184
189
  }
185
190
  if (!isValidMessageFrontmatter(parsed.data)) {
186
- writeErrorLog(
187
- transportRoot,
188
- 'parse',
189
- `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`,
190
- );
191
+ logError(transportRoot, 'parse', `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`);
191
192
  continue;
192
193
  }
193
194
  results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
@@ -197,134 +198,3 @@ export function listChannelMessages(transportRoot: string, channelUuid: string):
197
198
  walk(channelDir, '');
198
199
  return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
199
200
  }
200
-
201
- // ── errors/ log — infrastructure failures (git, fs, etc.) ──
202
- // Deduped by (kind + signature). If a matching entry exists, increment count
203
- // + update lastAt. Otherwise create a new entry.
204
-
205
- export type ErrorKind = 'git_pull' | 'git_push' | 'git_commit' | 'fs' | 'parse' | 'other';
206
-
207
- interface ErrorEntry {
208
- id: string;
209
- kind: ErrorKind;
210
- signature: string;
211
- count: number;
212
- firstAt: string;
213
- lastAt: string;
214
- error: string;
215
- }
216
-
217
- function errorSignature(kind: ErrorKind, error: string): string {
218
- const firstLine = error.split('\n')[0] ?? '';
219
- return `${kind}::${firstLine.trim().slice(0, 120)}`;
220
- }
221
-
222
- export function writeErrorLog(transportRoot: string, kind: ErrorKind, error: string): string {
223
- const dir = join(transportRoot, 'errors');
224
- mkdirSync(dir, { recursive: true });
225
- const sig = errorSignature(kind, error);
226
-
227
- const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
228
- for (const f of files) {
229
- const path = join(dir, f);
230
- try {
231
- const { data } = parseFrontmatter<ErrorEntry>(readFileSync(path, 'utf-8'));
232
- if (data.signature === sig) {
233
- const updated: ErrorEntry = {
234
- ...data,
235
- count: (data.count ?? 1) + 1,
236
- lastAt: new Date().toISOString(),
237
- error: error.slice(0, 500),
238
- };
239
- writeFileSync(path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
240
- return data.id;
241
- }
242
- } catch { /* skip unparseable */ }
243
- }
244
-
245
- const ts = now();
246
- const id = `${ts.fileTime}-${ts.hex}`;
247
- const entry: ErrorEntry = {
248
- id, kind, signature: sig,
249
- count: 1,
250
- firstAt: ts.iso, lastAt: ts.iso,
251
- error: error.slice(0, 500),
252
- };
253
- writeFileSync(
254
- join(dir, `${id}.md`),
255
- serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
256
- );
257
- return id;
258
- }
259
-
260
- export function countErrorEntries(transportRoot: string): number {
261
- const dir = join(transportRoot, 'errors');
262
- if (!existsSync(dir)) return 0;
263
- return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
264
- }
265
-
266
- // Sweep channels for read receipts older than `thresholdMs` that lack a
267
- // corresponding `type: text` reply from the same actor referencing the same
268
- // original message. Each stale receipt is logged once to errors/ kind=parse
269
- // (we reuse 'parse' rather than introduce another kind for one weak signal).
270
- // Returns the number of stale receipts surfaced.
271
- export function sweepStaleReadReceipts(
272
- transportRoot: string,
273
- thresholdMs: number,
274
- ): number {
275
- const channelsDir = join(transportRoot, 'data', 'channels');
276
- if (!existsSync(channelsDir)) return 0;
277
- let channels: string[];
278
- try {
279
- channels = readdirSync(channelsDir).filter((name) => {
280
- try { return statSync(join(channelsDir, name)).isDirectory(); }
281
- catch { return false; }
282
- });
283
- } catch { return 0; }
284
-
285
- const now = Date.now();
286
- let surfaced = 0;
287
-
288
- for (const channelUuid of channels) {
289
- const messages = listChannelMessages(transportRoot, channelUuid);
290
-
291
- // Build: for each (fromActor, refRelPath), did a text reply follow the read receipt?
292
- const readReceipts: { actor: string; ref: string; ts: number; relPath: string }[] = [];
293
- const replyKeys = new Set<string>();
294
-
295
- for (const msg of messages) {
296
- const from = typeof msg.data['from'] === 'string' ? msg.data['from'] : null;
297
- const type = typeof msg.data['type'] === 'string' ? msg.data['type'] : null;
298
- const timestamp = typeof msg.data['timestamp'] === 'string' ? msg.data['timestamp'] : null;
299
- const ref = typeof msg.data['ref'] === 'string' ? msg.data['ref'] : null;
300
- if (!from || !type || !timestamp) continue;
301
-
302
- if (type === 'read' && ref) {
303
- readReceipts.push({
304
- actor: from,
305
- ref,
306
- ts: new Date(timestamp).getTime(),
307
- relPath: msg.relPath,
308
- });
309
- } else if (type === 'text') {
310
- // A text reply doesn't carry a ref field. We approximate "did this
311
- // actor reply after the read receipt" by keying on actor only; if
312
- // any later text exists from the same actor in this channel, treat
313
- // earlier read receipts as resolved. Imperfect but cheap.
314
- replyKeys.add(from);
315
- }
316
- }
317
-
318
- for (const rr of readReceipts) {
319
- if (replyKeys.has(rr.actor)) continue;
320
- if (now - rr.ts <= thresholdMs) continue;
321
- writeErrorLog(
322
- transportRoot,
323
- 'parse',
324
- `stale read receipt ${channelUuid}/${rr.relPath}: actor=${rr.actor} claimed ref=${rr.ref} ${Math.floor((now - rr.ts) / 60000)}min ago, no reply yet`,
325
- );
326
- surfaced++;
327
- }
328
- }
329
- return surfaced;
330
- }
package/src/turnq.ts CHANGED
@@ -1,26 +1,20 @@
1
- // Thin wrapper around @cordfuse/turnq's coordinator.
1
+ // Advisory turn coordination via @cordfuse/turnq.
2
2
  //
3
- // Two modes, picked automatically by the underlying coordinator:
4
- //
5
- // Local (default, no env vars set): file lock via flock(2). Safe across
6
- // multiple processes on the same host; the OS releases the lock if the
7
- // process dies, so stale locks are impossible.
8
- //
9
- // Distributed (env var TURNQ_URL set): HTTP coordinator running on a
10
- // reachable turnq server. Serializes turns across multiple hosts. If
11
- // the server is unreachable, falls back to local silently (default
12
- // `fallback: true`).
3
+ // The lock is an OPTIMIZATION, never a correctness requirement git
4
+ // arbitrates (collision-free filenames + push rejection/rebase retry).
5
+ // So: wait a bounded time for the turn, then proceed without it. A turnq
6
+ // bug or outage can never wedge a dispatcher; it just costs an occasional
7
+ // rebase. Failures are loud (errors.log), never silently fallen back.
13
8
  //
14
9
  // Env vars:
15
- // TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local mode)
10
+ // TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local flock)
16
11
  // TURNQ_API_KEY — required when TURNQ_URL is set
17
- // TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk").
18
- // Concatenated with the lock name to form the actual
19
- // channel — e.g. "crosstalk/dispatch". Lets multiple
20
- // transports share the same turnq server without
21
- // colliding.
12
+ // TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk")
22
13
 
23
14
  import { createCoordinator } from '@cordfuse/turnq/coordinator';
15
+ import { logError } from './state.js';
16
+
17
+ const TURN_WAIT_MS = 15_000;
24
18
 
25
19
  interface Coordinator {
26
20
  withTurn<T>(channel: string, fn: () => Promise<T>): Promise<T>;
@@ -31,29 +25,67 @@ let coordinatorPromise: Promise<Coordinator> | null = null;
31
25
 
32
26
  function getCoordinator(): Promise<Coordinator> {
33
27
  if (!coordinatorPromise) {
34
- const url = process.env.TURNQ_URL;
35
- const apiKey = process.env.TURNQ_API_KEY;
28
+ const url = process.env['TURNQ_URL'];
29
+ const apiKey = process.env['TURNQ_API_KEY'];
36
30
  coordinatorPromise = createCoordinator(
37
- url ? { url, apiKey, fallback: true } : {},
31
+ url ? { url, apiKey, fallback: false } : {},
38
32
  );
39
33
  }
40
34
  return coordinatorPromise;
41
35
  }
42
36
 
43
37
  function channelFor(name: string): string {
44
- const prefix = process.env.TURNQ_CHANNEL ?? 'crosstalk';
38
+ const prefix = process.env['TURNQ_CHANNEL'] ?? 'crosstalk';
45
39
  return `${prefix}/${name}`;
46
40
  }
47
41
 
48
- /**
49
- * Run `fn` while holding the turn for the named lock. Queues if another
50
- * caller holds it; runs in FIFO order. The lock auto-releases when `fn`
51
- * resolves or rejects.
52
- *
53
- * In local mode this is a flock(2) on a temp file. In distributed mode
54
- * it's an HTTP turn on a cordfuse/turnq server.
55
- */
56
- export async function withLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
57
- const coordinator = await getCoordinator();
58
- return coordinator.withTurn(channelFor(name), fn);
42
+ // Run `fn` while holding the named turn if it can be acquired within
43
+ // TURN_WAIT_MS; otherwise (timeout, coordinator error, server down) log
44
+ // and run `fn` anyway. `fn` runs exactly once the `ran` guard is safe
45
+ // because the event loop is single-threaded.
46
+ export async function withLock<T>(transportRoot: string, name: string, fn: () => Promise<T>): Promise<T> {
47
+ let ran = false;
48
+ const runOnce = (): Promise<T> => {
49
+ ran = true;
50
+ return fn();
51
+ };
52
+
53
+ let coordinator: Coordinator;
54
+ try {
55
+ coordinator = await getCoordinator();
56
+ } catch (err) {
57
+ coordinatorPromise = null;
58
+ logError(transportRoot, 'turnq', `coordinator init failed — proceeding without lock: ${(err as Error).message}`);
59
+ return runOnce();
60
+ }
61
+
62
+ return new Promise<T>((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ if (ran) return;
65
+ logError(transportRoot, 'turnq', `turn '${name}' not granted within ${TURN_WAIT_MS}ms — proceeding without lock`);
66
+ runOnce().then(resolve, reject);
67
+ }, TURN_WAIT_MS);
68
+ timer.unref?.();
69
+
70
+ coordinator
71
+ .withTurn(channelFor(name), async () => {
72
+ clearTimeout(timer);
73
+ if (ran) return; // timeout path already ran fn; release the turn immediately
74
+ await runOnce().then(resolve, reject);
75
+ })
76
+ .catch((err) => {
77
+ clearTimeout(timer);
78
+ if (ran) return;
79
+ logError(transportRoot, 'turnq', `turn '${name}' failed — proceeding without lock: ${(err as Error).message}`);
80
+ runOnce().then(resolve, reject);
81
+ });
82
+ });
83
+ }
84
+
85
+ export async function closeCoordinator(): Promise<void> {
86
+ if (!coordinatorPromise) return;
87
+ try {
88
+ (await coordinatorPromise).close();
89
+ } catch { /* best-effort */ }
90
+ coordinatorPromise = null;
59
91
  }
package/src/upgrade.ts CHANGED
@@ -8,26 +8,27 @@
8
8
  // files into the operator's transport so they catch up.
9
9
  //
10
10
  // What this command DOES touch:
11
- // - upstream/CROSSTALK.md, PROTOCOL.md, OPERATOR.md, JITTER.md
11
+ // - upstream/CROSSTALK.md, PROTOCOL.md, OPERATOR.md
12
12
  // - upstream/CROSSTALK-VERSION
13
13
  // - upstream/actors/ (default actor profile starter set)
14
14
  //
15
15
  // What this command NEVER touches:
16
16
  // - local/ — operator-owned actor profiles and identity
17
- // - hosts/ — operator-owned host file
17
+ // - hosts/ — operator-owned host files
18
18
  // - data/ — channels and memories
19
- // - cursors/, dlq/, errors/ — dispatcher-owned runtime state
20
19
  // - root pointer files (CLAUDE.md etc.) — only updated if a --pointers flag
21
20
  // is passed, since they rarely change and overwriting them surprises
22
21
  // operators who've customized them
22
+ // (Dispatcher state — cursors, dlq, error logs — lives outside the repo
23
+ // in the state dir and is never in scope.)
23
24
  //
24
25
  // Usage:
25
26
  // crosstalk upgrade — sync upstream/ from runtime template
26
27
  // crosstalk upgrade --dry-run — show what would change, no writes
27
- // crosstalk upgrade --pointers — also overwrite the 11 entry pointer files
28
+ // crosstalk upgrade --pointers — also overwrite the entry pointer files
28
29
 
29
- import { existsSync, readFileSync, writeFileSync, cpSync, statSync, readdirSync } from 'fs';
30
- import { resolve, join, dirname, relative } from 'path';
30
+ import { existsSync, readFileSync, cpSync, statSync, readdirSync, mkdirSync } from 'fs';
31
+ import { resolve, join, dirname } from 'path';
31
32
  import { fileURLToPath } from 'url';
32
33
 
33
34
  const transportRoot = resolve(process.cwd());
@@ -195,10 +196,7 @@ if (updatePointers) {
195
196
  const fromPath = join(templateDir, pf);
196
197
  const toPath = join(transportRoot, pf);
197
198
  if (!existsSync(fromPath)) continue;
198
- const targetDir = dirname(toPath);
199
- if (!existsSync(targetDir)) {
200
- cpSync(targetDir, targetDir, { recursive: true });
201
- }
199
+ mkdirSync(dirname(toPath), { recursive: true });
202
200
  cpSync(fromPath, toPath, { force: true });
203
201
  }
204
202
  console.log(` ✓ pointer files synced`);
@@ -210,4 +208,4 @@ console.log(`Commit the upstream/ changes when ready:`);
210
208
  console.log(` git add upstream/${updatePointers ? ' ' + POINTER_FILES.slice(0, 3).join(' ') + ' ...' : ''}`);
211
209
  console.log(` git commit -m "spec: upgrade to ${toVersion}"`);
212
210
  console.log('');
213
- console.log('Your local/, hosts/, data/, cursors/, dlq/, errors/ were not touched.');
211
+ console.log('Your local/, hosts/, and data/ were not touched.');
package/src/wake.ts CHANGED
@@ -1,8 +1,7 @@
1
- import { writeFileSync, mkdirSync } from 'fs';
2
- import { resolve, join } from 'path';
1
+ // crosstalk wake poke the local dispatcher to tick immediately.
3
2
 
4
- const transportRoot = resolve(process.cwd());
5
- const wakeDir = join(transportRoot, '.turnq');
6
- mkdirSync(wakeDir, { recursive: true });
7
- writeFileSync(join(wakeDir, 'wake.signal'), `${Date.now()}\n`);
3
+ import { resolve } from 'path';
4
+ import { sendWakeSignal } from './state.js';
5
+
6
+ sendWakeSignal(resolve(process.cwd()));
8
7
  console.log('wake signal sent');
package/src/cursor.ts DELETED
@@ -1,48 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
- import { join, dirname } from 'path';
3
- import { writeErrorLog } from './transport.js';
4
-
5
- // Cursor format mirrors message relPath from filenames.ts:
6
- // YYYY/MM/DD/HHMMSSmmmZ-<8hex>.md
7
- const VALID_CURSOR = /^\d{4}\/\d{2}\/\d{2}\/\d{6}\d{3}Z-[0-9a-f]{8}\.md$/;
8
-
9
- export function cursorPath(transportRoot: string, actor: string, channelUuid: string): string {
10
- return join(transportRoot, 'cursors', actor, `${channelUuid}.md`);
11
- }
12
-
13
- export function readCursor(transportRoot: string, actor: string, channelUuid: string): string | null {
14
- const p = cursorPath(transportRoot, actor, channelUuid);
15
- if (!existsSync(p)) return null;
16
- let raw: string;
17
- try {
18
- raw = readFileSync(p, 'utf-8').trim();
19
- } catch (err) {
20
- writeErrorLog(
21
- transportRoot,
22
- 'fs',
23
- `cursor read failed for ${actor}@${channelUuid}: ${(err as Error).message}`,
24
- );
25
- return null;
26
- }
27
- if (raw.length === 0) return null;
28
- if (!VALID_CURSOR.test(raw)) {
29
- writeErrorLog(
30
- transportRoot,
31
- 'parse',
32
- `cursor file for ${actor}@${channelUuid} contains invalid value '${raw.slice(0, 80)}' — treating as null (will re-scan from start)`,
33
- );
34
- return null;
35
- }
36
- return raw;
37
- }
38
-
39
- export function writeCursor(
40
- transportRoot: string,
41
- actor: string,
42
- channelUuid: string,
43
- relPath: string,
44
- ): void {
45
- const p = cursorPath(transportRoot, actor, channelUuid);
46
- mkdirSync(dirname(p), { recursive: true });
47
- writeFileSync(p, relPath + '\n');
48
- }
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,7 +0,0 @@
1
- ---
2
- name: Crosstalk Transport
3
- alwaysApply: true
4
- ---
5
-
6
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
7
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,7 +0,0 @@
1
- ---
2
- description: Crosstalk transport agent instructions
3
- applyTo: "**"
4
- ---
5
-
6
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
7
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.
package/template/QWEN.md DELETED
@@ -1,2 +0,0 @@
1
- Read [local/CROSSTALK.md](local/CROSSTALK.md) for your identity configuration (if you are a dispatched actor).
2
- Read [upstream/CROSSTALK.md](upstream/CROSSTALK.md) for the protocol specification.