@nac3/forge-cli 1.1.0 → 1.2.0
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/dist/chat/instance_lock.d.ts +35 -0
- package/dist/chat/instance_lock.d.ts.map +1 -0
- package/dist/chat/instance_lock.js +118 -0
- package/dist/chat/instance_lock.js.map +1 -0
- package/dist/chat/server.d.ts +4 -0
- package/dist/chat/server.d.ts.map +1 -1
- package/dist/chat/server.js +100 -12
- package/dist/chat/server.js.map +1 -1
- package/dist/commands/chat.d.ts +3 -0
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +10 -0
- package/dist/commands/chat.js.map +1 -1
- package/dist/relay/command_consumer.d.ts +10 -0
- package/dist/relay/command_consumer.d.ts.map +1 -1
- package/dist/relay/command_consumer.js +9 -2
- package/dist/relay/command_consumer.js.map +1 -1
- package/dist/relay/instance_identity.d.ts +13 -0
- package/dist/relay/instance_identity.d.ts.map +1 -0
- package/dist/relay/instance_identity.js +37 -0
- package/dist/relay/instance_identity.js.map +1 -0
- package/dist/relay/relay_publisher.d.ts +16 -0
- package/dist/relay/relay_publisher.d.ts.map +1 -1
- package/dist/relay/relay_publisher.js +20 -0
- package/dist/relay/relay_publisher.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface LockHolder {
|
|
2
|
+
pid: number;
|
|
3
|
+
port: number;
|
|
4
|
+
root: string;
|
|
5
|
+
since: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LockResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
/** Present when ok=false: the live instance already holding this root. */
|
|
10
|
+
holder?: LockHolder;
|
|
11
|
+
/** Present when ok=true: remove the lock (only if we still own it). */
|
|
12
|
+
release?: () => void;
|
|
13
|
+
/** Present when ok=true: rewrite the stored port once the real bound port
|
|
14
|
+
* is known (the requested port may have changed via fallback). */
|
|
15
|
+
update?: (port: number) => void;
|
|
16
|
+
}
|
|
17
|
+
/** Thrown by startChatServer when a live instance already owns the root. */
|
|
18
|
+
export declare class DuplicateInstanceError extends Error {
|
|
19
|
+
readonly holder: LockHolder;
|
|
20
|
+
constructor(holder: LockHolder);
|
|
21
|
+
}
|
|
22
|
+
/** True when a process with `pid` is currently running. `process.kill(pid, 0)`
|
|
23
|
+
* sends no signal -- it only probes existence. EPERM means it exists but is
|
|
24
|
+
* not ours (still alive); ESRCH means gone. */
|
|
25
|
+
export declare function pidAlive(pid: number): boolean;
|
|
26
|
+
/** Claim the single-instance lock for a project root. Returns {ok:false,
|
|
27
|
+
* holder} if a LIVE instance already owns it, else writes our lock and
|
|
28
|
+
* returns {ok:true, release, update}. */
|
|
29
|
+
export declare function acquireProjectLock(root: string, self: {
|
|
30
|
+
pid: number;
|
|
31
|
+
port: number;
|
|
32
|
+
}): LockResult;
|
|
33
|
+
/** A human, actionable message for the CLI when a duplicate is refused. */
|
|
34
|
+
export declare function duplicateMessage(holder: LockHolder): string;
|
|
35
|
+
//# sourceMappingURL=instance_lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instance_lock.d.ts","sourceRoot":"","sources":["../../src/chat/instance_lock.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,UAAU;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;CAAE;AAEvF,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,0EAA0E;IAC1E,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;uEACmE;IACnE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;AAED,4EAA4E;AAC5E,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;gBAChB,MAAM,EAAE,UAAU;CAK/B;AAaD;;gDAEgD;AAChD,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAI7C;AAUD;;0CAE0C;AAC1C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,UAAU,CA+BhG;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAO3D"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance-per-project lock (2026-09-02).
|
|
3
|
+
*
|
|
4
|
+
* Guard so two `yf chat` instances do NOT open the SAME project root: a
|
|
5
|
+
* duplicate wastes a whole relay-poll stream (real Cloudflare cost) and
|
|
6
|
+
* causes the multi-instance relay race. The lock is a small JSON file under
|
|
7
|
+
* the OS temp dir, keyed by a hash of the NORMALISED root, holding the
|
|
8
|
+
* owner's { pid, port, since }.
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* - A live holder (its pid still alive AND not us) -> refuse.
|
|
12
|
+
* - A stale lock (dead pid, or corrupt/absent file) -> reclaim.
|
|
13
|
+
* - release() removes the file only if we still own it.
|
|
14
|
+
* - Headless sub-sessions (agentMode) and the test harness bypass the lock
|
|
15
|
+
* at the caller; this module only implements acquire/release.
|
|
16
|
+
*
|
|
17
|
+
* ASCII-only.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
/** Thrown by startChatServer when a live instance already owns the root. */
|
|
24
|
+
export class DuplicateInstanceError extends Error {
|
|
25
|
+
holder;
|
|
26
|
+
constructor(holder) {
|
|
27
|
+
super('Ya hay una instancia de Forge abierta en este proyecto.');
|
|
28
|
+
this.name = 'DuplicateInstanceError';
|
|
29
|
+
this.holder = holder;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function normRoot(root) {
|
|
33
|
+
const r = path.resolve(root);
|
|
34
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
35
|
+
}
|
|
36
|
+
function lockPath(root) {
|
|
37
|
+
const dir = path.join(os.tmpdir(), 'yujin-forge', 'locks');
|
|
38
|
+
const h = createHash('sha1').update(normRoot(root)).digest('hex').slice(0, 16);
|
|
39
|
+
return path.join(dir, h + '.json');
|
|
40
|
+
}
|
|
41
|
+
/** True when a process with `pid` is currently running. `process.kill(pid, 0)`
|
|
42
|
+
* sends no signal -- it only probes existence. EPERM means it exists but is
|
|
43
|
+
* not ours (still alive); ESRCH means gone. */
|
|
44
|
+
export function pidAlive(pid) {
|
|
45
|
+
if (!pid || pid <= 0)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
return e.code === 'EPERM';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function readHolder(p) {
|
|
56
|
+
try {
|
|
57
|
+
const h = JSON.parse(readFileSync(p, 'utf-8'));
|
|
58
|
+
if (h && typeof h.pid === 'number')
|
|
59
|
+
return h;
|
|
60
|
+
}
|
|
61
|
+
catch { /* absent or corrupt */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** Claim the single-instance lock for a project root. Returns {ok:false,
|
|
65
|
+
* holder} if a LIVE instance already owns it, else writes our lock and
|
|
66
|
+
* returns {ok:true, release, update}. */
|
|
67
|
+
export function acquireProjectLock(root, self) {
|
|
68
|
+
const p = lockPath(root);
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
catch { /* exists */ }
|
|
73
|
+
const write = (port) => {
|
|
74
|
+
const holder = { pid: self.pid, port, root: path.resolve(root), since: Date.now() };
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(p, JSON.stringify(holder), 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
catch { /* best effort */ }
|
|
79
|
+
};
|
|
80
|
+
/* Exclusive create wins the common no-lock case + the simultaneous race. */
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(p, JSON.stringify({ pid: self.pid, port: self.port, root: path.resolve(root), since: Date.now() }), { flag: 'wx' });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* File exists -> inspect the incumbent. */
|
|
86
|
+
const held = readHolder(p);
|
|
87
|
+
if (held && held.pid !== self.pid && pidAlive(held.pid)) {
|
|
88
|
+
return { ok: false, holder: held };
|
|
89
|
+
}
|
|
90
|
+
/* Stale (dead pid) or ours -> reclaim by overwriting. */
|
|
91
|
+
write(self.port);
|
|
92
|
+
}
|
|
93
|
+
const release = () => {
|
|
94
|
+
const cur = readHolder(p);
|
|
95
|
+
if (cur && cur.pid === self.pid) {
|
|
96
|
+
try {
|
|
97
|
+
rmSync(p, { force: true });
|
|
98
|
+
}
|
|
99
|
+
catch { /* gone */ }
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const update = (port) => {
|
|
103
|
+
const cur = readHolder(p);
|
|
104
|
+
if (!cur || cur.pid === self.pid)
|
|
105
|
+
write(port);
|
|
106
|
+
};
|
|
107
|
+
return { ok: true, release, update };
|
|
108
|
+
}
|
|
109
|
+
/** A human, actionable message for the CLI when a duplicate is refused. */
|
|
110
|
+
export function duplicateMessage(holder) {
|
|
111
|
+
return 'Ya hay una instancia de Forge abierta en este proyecto:\n'
|
|
112
|
+
+ ' ' + holder.root + '\n'
|
|
113
|
+
+ ' (PID ' + holder.pid + ', panel en http://127.0.0.1:' + holder.port + ')\n\n'
|
|
114
|
+
+ 'Abri esa instancia, o abri Forge en OTRA carpeta. Si estas seguro de que\n'
|
|
115
|
+
+ 'la anterior murio, volve a intentar (el lock viejo se descarta solo) o\n'
|
|
116
|
+
+ 'forza con --force.';
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=instance_lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"instance_lock.js","sourceRoot":"","sources":["../../src/chat/instance_lock.ts"],"names":[],"mappings":"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"}
|
package/dist/chat/server.d.ts
CHANGED
|
@@ -53,6 +53,10 @@ export interface StartOptions {
|
|
|
53
53
|
* phone command). The child's verb scope is gated separately by the
|
|
54
54
|
* YF_AGENT_MODE env flag in nac3_invoke. */
|
|
55
55
|
agentMode?: boolean;
|
|
56
|
+
/** Escape hatch for the single-instance-per-root guard (2026-09-02). When
|
|
57
|
+
* true, allow opening even if another live instance already owns this
|
|
58
|
+
* project root. Wired to `yf chat --force`. */
|
|
59
|
+
allowDuplicateRoot?: boolean;
|
|
56
60
|
}
|
|
57
61
|
export interface StartedServer {
|
|
58
62
|
server: Server;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/chat/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAKjG,OAAO,EAAoD,KAAK,aAAa,EAAoB,MAAM,aAAa,CAAC;AAErH,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAInD,OAAO,EAEL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AAczB,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAsBjD,OAAO,EAAyD,KAAK,eAAe,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/chat/server.ts"],"names":[],"mappings":"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"}
|
package/dist/chat/server.js
CHANGED
|
@@ -41,10 +41,12 @@ import { executeScaffold, rollbackScaffold } from './spec_scaffold.js';
|
|
|
41
41
|
import { VERSION } from '../version.js';
|
|
42
42
|
import { readProjectIdentityCached, formatProjectIdentityLines } from '../core/project_identity.js';
|
|
43
43
|
import { buildDoctrineDigestLines } from './doctrine_digest.js';
|
|
44
|
+
import { acquireProjectLock, DuplicateInstanceError } from './instance_lock.js';
|
|
44
45
|
import { profilePromptLines, initProfile, activeProfile } from '../core/forge_profile.js';
|
|
45
46
|
import { isAllowedSlot, listAllowedSlots } from '../core/provider_keys.js';
|
|
46
47
|
import { toolStatusSection, startToolStatusRefresh, environmentLines } from './tool_status.js';
|
|
47
48
|
import { RelayPublisher } from '../relay/relay_publisher.js';
|
|
49
|
+
import { getInstanceIdentity } from '../relay/instance_identity.js';
|
|
48
50
|
import { isMobilePaired, relayGate } from '../core/mobile_pairing_state.js';
|
|
49
51
|
import { relayPoll } from '../relay/relay_client.js';
|
|
50
52
|
import { startCommandConsumer } from '../relay/command_consumer.js';
|
|
@@ -74,6 +76,19 @@ async function findFreePort(start, host, maxTries = 50) {
|
|
|
74
76
|
throw new Error('No free port found in ' + start + '..' + (start + maxTries));
|
|
75
77
|
}
|
|
76
78
|
export async function startChatServer(opts) {
|
|
79
|
+
/* Single-instance-per-project guard (2026-09-02). Refuse a 2nd `yf chat` on
|
|
80
|
+
* the SAME root: it wastes a whole relay-poll stream (real Cloudflare cost)
|
|
81
|
+
* and triggers the multi-instance relay race. Bypassed for headless
|
|
82
|
+
* sub-sessions (agentMode), the test harness (injected claude), and an
|
|
83
|
+
* explicit --force (allowDuplicateRoot). The port is a placeholder here and
|
|
84
|
+
* gets rewritten to the real bound port after listen. */
|
|
85
|
+
let projectLock = null;
|
|
86
|
+
if (!opts.agentMode && !opts.claude && !opts.allowDuplicateRoot) {
|
|
87
|
+
const lock = acquireProjectLock(opts.projectRoot, { pid: process.pid, port: opts.port });
|
|
88
|
+
if (!lock.ok && lock.holder)
|
|
89
|
+
throw new DuplicateInstanceError(lock.holder);
|
|
90
|
+
projectLock = lock;
|
|
91
|
+
}
|
|
77
92
|
/* Port auto-increment: if the requested port is taken (another yf chat is
|
|
78
93
|
* already there), step up to the next free one. Determined BEFORE anything
|
|
79
94
|
* reads opts.port (panel MCP bridge, relay consumer, url), so every consumer
|
|
@@ -459,6 +474,22 @@ export async function startChatServer(opts) {
|
|
|
459
474
|
};
|
|
460
475
|
tryListen(opts.port);
|
|
461
476
|
});
|
|
477
|
+
/* Port-race fix (2026-09-01). The eager `claude` client above was built
|
|
478
|
+
* BEFORE the EADDRINUSE retry could finalize opts.port. The CLI client
|
|
479
|
+
* FREEZES panelBridge.port at construction, so when two instances race for
|
|
480
|
+
* the same port and this one fell back, its panelBridge still points at the
|
|
481
|
+
* ORIGINAL (now someone else's) port -> the `claude -p` subprocess POSTs
|
|
482
|
+
* panel-invoke to the WRONG server with the wrong token -> 403. Now that
|
|
483
|
+
* opts.port is the REAL bound port, rebuild the CLI client so its bridge
|
|
484
|
+
* points home. No-op in the common (no-fallback) case; skipped when a client
|
|
485
|
+
* was injected (tests) or the provider is not the CLI bridge. */
|
|
486
|
+
if (!opts.claude && activeChatProvider === 'claude-code-cli') {
|
|
487
|
+
claude = await buildClaudeClient(activeChatProvider);
|
|
488
|
+
}
|
|
489
|
+
/* Now that the real bound port is known (post fallback), stamp it into the
|
|
490
|
+
* single-instance lock so a later duplicate points the user at the right
|
|
491
|
+
* panel URL. */
|
|
492
|
+
projectLock?.update?.(opts.port);
|
|
462
493
|
/* P2 -- relay command consumer (inbound phone chat) + the board ctx.
|
|
463
494
|
* Start the consumer ALWAYS: it self-gates on relayEnabled() every tick,
|
|
464
495
|
* so if the user pairs a phone MID-SESSION it wakes up automatically (no
|
|
@@ -573,6 +604,17 @@ export async function startChatServer(opts) {
|
|
|
573
604
|
if (bridgeHandle) {
|
|
574
605
|
Promise.resolve(bridgeHandle.stop()).catch(() => undefined);
|
|
575
606
|
}
|
|
607
|
+
/* Release the single-instance-per-project lock (2026-09-02) so the next
|
|
608
|
+
* `yf chat` on this root is allowed in. */
|
|
609
|
+
try {
|
|
610
|
+
projectLock?.release?.();
|
|
611
|
+
}
|
|
612
|
+
catch { /* noop */ }
|
|
613
|
+
/* Stop the "remote running..." relay heartbeat. */
|
|
614
|
+
try {
|
|
615
|
+
stopRelayHeartbeat();
|
|
616
|
+
}
|
|
617
|
+
catch { /* noop */ }
|
|
576
618
|
/* alpha.59z.136 slice 14 -- flush in-flight ingest session
|
|
577
619
|
* writes BEFORE the server closes so test teardowns that
|
|
578
620
|
* fs.rm the home directory immediately after do not race
|
|
@@ -606,6 +648,7 @@ function getRelayPublisher() {
|
|
|
606
648
|
_relayPub = new RelayPublisher({
|
|
607
649
|
deps: { deviceId: 'desktop-' + process.pid }, /* default auth = vault + license */
|
|
608
650
|
enabled: relayEnabled,
|
|
651
|
+
instanceId: getInstanceIdentity().id, /* item 2: tag every event so the phone mirrors only the chosen instance */
|
|
609
652
|
});
|
|
610
653
|
}
|
|
611
654
|
return _relayPub;
|
|
@@ -822,8 +865,10 @@ function startDesktopCommandConsumer(port, coord = {}) {
|
|
|
822
865
|
* a phone interrupt lands MID-TURN instead of waiting for it to end.
|
|
823
866
|
* Each filters to its own subset; the other still advances its cursor
|
|
824
867
|
* past the skipped events. */
|
|
868
|
+
const selfInstanceId = getInstanceIdentity().id;
|
|
825
869
|
const chat = startCommandConsumer({
|
|
826
870
|
enabled: relayEnabled, intervalMs: 3000,
|
|
871
|
+
instanceId: selfInstanceId, /* item 2: run only commands targeted at me (or untargeted) */
|
|
827
872
|
/* only MY project's chat commands (multi-instance integrity); the other
|
|
828
873
|
* instances' consumers advance their cursor past the skipped ones. */
|
|
829
874
|
accept: (c) => !c.control && ownsChatCommand(c.projectSlug),
|
|
@@ -833,12 +878,30 @@ function startDesktopCommandConsumer(port, coord = {}) {
|
|
|
833
878
|
});
|
|
834
879
|
const control = startCommandConsumer({
|
|
835
880
|
enabled: relayEnabled, intervalMs: 1200,
|
|
881
|
+
instanceId: selfInstanceId,
|
|
836
882
|
accept: (c) => !!c.control,
|
|
837
883
|
pollCommands, runTurn, onControl, reply,
|
|
838
884
|
onError: () => { },
|
|
839
885
|
});
|
|
886
|
+
/* Item 2 (cross-machine): advertise this instance's presence on a slow
|
|
887
|
+
* heartbeat (gated by relayEnabled -> only when a phone is paired) so the
|
|
888
|
+
* phone can list + pick instances across machines and TTL-expire dead ones. */
|
|
889
|
+
const presenceTimer = setInterval(() => {
|
|
890
|
+
try {
|
|
891
|
+
const pub = getRelayPublisher();
|
|
892
|
+
if (!pub)
|
|
893
|
+
return;
|
|
894
|
+
const idn = getInstanceIdentity();
|
|
895
|
+
const slug = _relayBoardCtx?.slug ?? null;
|
|
896
|
+
pub.presence({ hostname: idn.hostname, pid: idn.pid, port, ...(slug ? { project_slug: slug, project_name: slug } : {}) });
|
|
897
|
+
void pub.flush();
|
|
898
|
+
}
|
|
899
|
+
catch { /* best-effort */ }
|
|
900
|
+
}, 15000);
|
|
901
|
+
if (typeof presenceTimer.unref === 'function')
|
|
902
|
+
presenceTimer.unref();
|
|
840
903
|
return {
|
|
841
|
-
stop() { clearInterval(peerTimer); chat.stop(); control.stop(); },
|
|
904
|
+
stop() { clearInterval(peerTimer); clearInterval(presenceTimer); chat.stop(); control.stop(); },
|
|
842
905
|
tickOnce: async () => { await refreshPeers(); await chat.tickOnce(); await control.tickOnce(); },
|
|
843
906
|
};
|
|
844
907
|
}
|
|
@@ -1006,31 +1069,56 @@ async function handleForgeTaskDelete(req, res, ctx) {
|
|
|
1006
1069
|
const removed = await sup.remove(id);
|
|
1007
1070
|
sendJson(res, removed ? 200 : 404, { ok: removed, id });
|
|
1008
1071
|
}
|
|
1072
|
+
/* Low-frequency "remote running..." heartbeat to the phone while the desktop
|
|
1073
|
+
* is thinking (Pablo 2026-09-02). Replaces the per-tool activity stream, which
|
|
1074
|
+
* was the bulk of the relay's Cloudflare appends and only calmed anxiety. The
|
|
1075
|
+
* pizarron (board) still refreshes -- THAT is the signal that adds value. */
|
|
1076
|
+
let _relayHeartbeat = null;
|
|
1077
|
+
function stopRelayHeartbeat() {
|
|
1078
|
+
if (_relayHeartbeat) {
|
|
1079
|
+
clearInterval(_relayHeartbeat);
|
|
1080
|
+
_relayHeartbeat = null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function startRelayHeartbeat() {
|
|
1084
|
+
stopRelayHeartbeat();
|
|
1085
|
+
_relayHeartbeat = setInterval(() => {
|
|
1086
|
+
try {
|
|
1087
|
+
const pub = getRelayPublisher();
|
|
1088
|
+
if (!pub) {
|
|
1089
|
+
stopRelayHeartbeat();
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
pub.activity('remote running...', { kind: 'heartbeat' });
|
|
1093
|
+
void pub.flush();
|
|
1094
|
+
}
|
|
1095
|
+
catch { /* best-effort */ }
|
|
1096
|
+
}, 60_000);
|
|
1097
|
+
if (typeof _relayHeartbeat.unref === 'function')
|
|
1098
|
+
_relayHeartbeat.unref();
|
|
1099
|
+
}
|
|
1009
1100
|
function relayForward(event, data) {
|
|
1010
1101
|
const pub = getRelayPublisher();
|
|
1011
1102
|
if (!pub)
|
|
1012
1103
|
return;
|
|
1013
1104
|
try {
|
|
1014
1105
|
const d = (data && typeof data === 'object') ? data : {};
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
else if (event === 'turn_start') {
|
|
1106
|
+
/* tool_round_start: intentionally NO relay activity anymore -- the granular
|
|
1107
|
+
* per-tool feed was the append-heavy part; the phone now gets a heartbeat. */
|
|
1108
|
+
if (event === 'turn_start') {
|
|
1019
1109
|
pub.status('working', { tier: d.tier, provider: d.provider, model: d.model });
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
* Before, the feed only got an entry on tool_round_start. */
|
|
1023
|
-
const ut = typeof d.user_text === 'string' ? d.user_text.slice(0, 80) : '';
|
|
1024
|
-
pub.activity(ut ? 'procesando: ' + ut : 'nuevo turno', { kind: 'task' });
|
|
1110
|
+
pub.activity('remote running...', { kind: 'heartbeat' }); /* first line now; then every 60s */
|
|
1111
|
+
startRelayHeartbeat();
|
|
1025
1112
|
}
|
|
1026
1113
|
else if (event === 'turn_complete') {
|
|
1114
|
+
stopRelayHeartbeat();
|
|
1027
1115
|
pub.status('round_done');
|
|
1028
|
-
pub.activity('turno completo', { kind: 'task' });
|
|
1029
1116
|
/* refresh the phone's board after each turn (workflow/pendings may
|
|
1030
|
-
* have changed). */
|
|
1117
|
+
* have changed) -- the pizarron is the value the user keeps. */
|
|
1031
1118
|
publishBoardSnapshot();
|
|
1032
1119
|
}
|
|
1033
1120
|
else if (event === 'error') {
|
|
1121
|
+
stopRelayHeartbeat();
|
|
1034
1122
|
pub.status('error', { message: d.message ?? d.error ?? '' });
|
|
1035
1123
|
}
|
|
1036
1124
|
}
|