@cordfuse/crosstalk 6.0.0-alpha.6 → 6.0.0-alpha.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "6.0.0-alpha.6",
3
+ "version": "6.0.0-alpha.8",
4
4
  "description": "Crosstalk runtime — async messaging between agents over git, across machines.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/actor.ts CHANGED
@@ -1,8 +1,31 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { hostname as osHostname } from 'os';
3
+ import { hostname as osHostname, platform } from 'os';
4
+ import { spawnSync } from 'child_process';
4
5
  import { parseFrontmatter } from './frontmatter.js';
5
6
 
7
+ // Collect the names this machine might be known by. On macOS, the kernel
8
+ // hostname (`os.hostname()`) drifts with DHCP/VPN/Tailscale when the static
9
+ // HostName is unset — `scutil --get LocalHostName` is the stable Bonjour
10
+ // name (e.g. `Steves-MacBook-Air`), and host files commonly use the `.local`
11
+ // form. Trying all variants makes auto-detect deterministic across network
12
+ // state without forcing every Mac operator to pin `--host`.
13
+ function candidateHostNames(): string[] {
14
+ const names = new Set<string>();
15
+ names.add(osHostname());
16
+ if (platform() === 'darwin') {
17
+ const r = spawnSync('scutil', ['--get', 'LocalHostName'], { encoding: 'utf-8' });
18
+ if (r.status === 0) {
19
+ const local = r.stdout.trim();
20
+ if (local) {
21
+ names.add(local);
22
+ names.add(`${local}.local`);
23
+ }
24
+ }
25
+ }
26
+ return [...names];
27
+ }
28
+
6
29
  export interface HostActorTier {
7
30
  cli: string;
8
31
  count?: number;
@@ -36,13 +59,15 @@ export function findHostFile(transportRoot: string, override?: string): HostFile
36
59
  if (!target) throw new Error(`Host file '${override}' not found in ${dir}`);
37
60
  return parseHostFile(join(dir, target));
38
61
  }
39
- const hostName = osHostname();
62
+ const names = candidateHostNames();
40
63
  for (const f of files) {
41
64
  const parsed = parseHostFile(join(dir, f));
42
- if (parsed.hostname === hostName || parsed.alias === hostName) return parsed;
65
+ for (const n of names) {
66
+ if (parsed.hostname === n || parsed.alias === n) return parsed;
67
+ }
43
68
  }
44
69
  throw new Error(
45
- `No host file matches hostname '${hostName}' in ${dir}. ` +
70
+ `No host file matches any of [${names.join(', ')}] in ${dir}. ` +
46
71
  `Pass --host <alias> to override.`,
47
72
  );
48
73
  }
package/src/dispatch.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  gitCommitAndPush,
30
30
  cursorBaseline,
31
31
  newFilesSince,
32
+ hostFileCommit,
32
33
  type ChannelMessage,
33
34
  } from './transport.js';
34
35
  import {
@@ -361,15 +362,23 @@ async function dispatchTick(): Promise<TickResult> {
361
362
  const pending: PendingDispatch[] = [];
362
363
 
363
364
  for (const channelUuid of channels) {
364
- const cursor = readCursor(transportRoot, actorName, channelUuid);
365
- if (cursor === head) continue;
366
-
367
- // First encounter: seed to HEAD so only future messages are dispatched.
368
- // Without this, a null cursor falls through to `post = messages` and
369
- // replays the full channel history on every fresh-state boot.
370
- if (cursor === null) {
371
- writeCursor(transportRoot, actorName, channelUuid, head);
372
- continue;
365
+ const persistedCursor = readCursor(transportRoot, actorName, channelUuid);
366
+ if (persistedCursor === head) continue;
367
+
368
+ // First encounter: seed to the commit that introduced this actor's host
369
+ // file. Messages sent after the host joined are delivered (store-and-
370
+ // forward); pre-join history is ignored. Seeding to HEAD would silently
371
+ // drop messages sent while the dispatcher was offline — the wrong trade.
372
+ // Fall through after seeding so this tick processes the post-join backlog
373
+ // (otherwise `--once` users hit a seed-then-dispatch two-tick gotcha).
374
+ let cursor: string;
375
+ if (persistedCursor === null) {
376
+ const joinCommit = hostFileCommit(transportRoot, host.alias);
377
+ cursor = joinCommit ?? head;
378
+ writeCursor(transportRoot, actorName, channelUuid, cursor);
379
+ if (cursor === head) continue;
380
+ } else {
381
+ cursor = persistedCursor;
373
382
  }
374
383
 
375
384
  const messages = listChannelMessages(transportRoot, channelUuid);
package/src/transport.ts CHANGED
@@ -68,6 +68,19 @@ export function cursorBaseline(transportRoot: string): string | null {
68
68
  return null;
69
69
  }
70
70
 
71
+ // Find the commit that introduced this actor's host file. Used to seed
72
+ // the cursor on first boot: messages sent after the host file landed are
73
+ // deliverable (store-and-forward); pre-join history is ignored. Falls back
74
+ // to HEAD if git log fails (conservative: no history replay in that case).
75
+ export function hostFileCommit(transportRoot: string, hostname: string): string | null {
76
+ const hostPath = `hosts/${hostname}.md`;
77
+ const r = captureGit(transportRoot, ['log', '--format=%H', '--diff-filter=A', '--', hostPath]);
78
+ if (r.status !== 0 || !r.stdout.trim()) return null;
79
+ // `git log` lists newest first; the last line is the introducing commit.
80
+ const lines = r.stdout.trim().split('\n').filter(Boolean);
81
+ return lines[lines.length - 1] ?? null;
82
+ }
83
+
71
84
  // Repo-relative paths of message files added between `sinceCommit` and
72
85
  // HEAD. Returns null when the commit is unknown to this clone (state dir
73
86
  // copied across transports, history rewritten) — caller falls back to a