@cordfuse/crosstalk 5.0.0-alpha.7 → 6.0.0-alpha.1
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/bin/crosstalk.js +34 -78
- package/package.json +4 -4
- package/src/activation.ts +104 -0
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +252 -661
- package/src/dlq.ts +68 -136
- package/src/init.ts +17 -41
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +48 -67
- package/src/state.ts +143 -0
- package/src/status.ts +18 -57
- package/src/transport.ts +68 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- package/src/cursor.ts +0 -48
- package/template/.amazonq/rules/crosstalk.md +0 -2
- package/template/.continue/rules/crosstalk.md +0 -7
- package/template/.cursor/rules/crosstalk.mdc +0 -7
- package/template/.github/copilot-instructions.md +0 -2
- package/template/.windsurfrules +0 -2
- package/template/AGENTS.md +0 -2
- package/template/ANTIGRAVITY.md +0 -2
- package/template/CLAUDE.md +0 -2
- package/template/GEMINI.md +0 -2
- package/template/OPENCODE.md +0 -2
- package/template/QWEN.md +0 -2
- package/template/README.md +0 -22
- package/template/local/CROSSTALK.md +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -589
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -260
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/concierge.md +0 -130
- package/template/upstream/actors/devops-engineer.md +0 -83
- package/template/upstream/actors/documentation-engineer.md +0 -107
- package/template/upstream/actors/infrastructure-engineer.md +0 -83
- package/template/upstream/actors/junior-developer.md +0 -83
- package/template/upstream/actors/precise-generalist.md +0 -48
- package/template/upstream/actors/product-manager.md +0 -83
- package/template/upstream/actors/qa-engineer.md +0 -83
- package/template/upstream/actors/security-engineer.md +0 -92
- package/template/upstream/actors/senior-generalist-engineer.md +0 -111
- package/template/upstream/actors/senior-software-engineer.md +0 -94
- package/template/upstream/actors/skeptic.md +0 -89
- package/template/upstream/actors/technical-writer.md +0 -89
- package/template/upstream/actors/ux-designer.md +0 -83
package/src/transport.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
|
|
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
|
|
5
|
-
import {
|
|
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
|
|
32
|
-
// Returns true if recovery was performed
|
|
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
|
-
|
|
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
|
|
58
|
-
if (
|
|
59
|
-
return { ok: false, error: (
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
140
|
-
} catch
|
|
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
|
-
|
|
153
|
-
if (typeof data
|
|
154
|
-
if (typeof data
|
|
155
|
-
if (typeof data
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1
|
+
// Advisory turn coordination via @cordfuse/turnq.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
|
35
|
-
const apiKey = process.env
|
|
28
|
+
const url = process.env['TURNQ_URL'];
|
|
29
|
+
const apiKey = process.env['TURNQ_API_KEY'];
|
|
36
30
|
coordinatorPromise = createCoordinator(
|
|
37
|
-
url ? { url, apiKey, fallback:
|
|
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
|
|
38
|
+
const prefix = process.env['TURNQ_CHANNEL'] ?? 'crosstalk';
|
|
45
39
|
return `${prefix}/${name}`;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
28
|
+
// crosstalk upgrade --pointers — also overwrite the entry pointer files
|
|
28
29
|
|
|
29
|
-
import { existsSync, readFileSync,
|
|
30
|
-
import { resolve, join, dirname
|
|
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
|
-
|
|
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
|
|
211
|
+
console.log('Your local/, hosts/, and data/ were not touched.');
|
package/src/wake.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
import { resolve, join } from 'path';
|
|
1
|
+
// crosstalk wake — poke the local dispatcher to tick immediately.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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,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.
|
package/template/.windsurfrules
DELETED
package/template/AGENTS.md
DELETED
package/template/ANTIGRAVITY.md
DELETED
package/template/CLAUDE.md
DELETED
package/template/GEMINI.md
DELETED
package/template/OPENCODE.md
DELETED
package/template/QWEN.md
DELETED