@nac3/forge-cli 1.1.1 → 1.2.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/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 +93 -13
- 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_client.d.ts +1 -1
- package/dist/relay/relay_client.d.ts.map +1 -1
- package/dist/relay/relay_client.js +11 -4
- package/dist/relay/relay_client.js.map +1 -1
- 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
|
|
@@ -471,6 +486,10 @@ export async function startChatServer(opts) {
|
|
|
471
486
|
if (!opts.claude && activeChatProvider === 'claude-code-cli') {
|
|
472
487
|
claude = await buildClaudeClient(activeChatProvider);
|
|
473
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);
|
|
474
493
|
/* P2 -- relay command consumer (inbound phone chat) + the board ctx.
|
|
475
494
|
* Start the consumer ALWAYS: it self-gates on relayEnabled() every tick,
|
|
476
495
|
* so if the user pairs a phone MID-SESSION it wakes up automatically (no
|
|
@@ -585,6 +604,17 @@ export async function startChatServer(opts) {
|
|
|
585
604
|
if (bridgeHandle) {
|
|
586
605
|
Promise.resolve(bridgeHandle.stop()).catch(() => undefined);
|
|
587
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 */ }
|
|
588
618
|
/* alpha.59z.136 slice 14 -- flush in-flight ingest session
|
|
589
619
|
* writes BEFORE the server closes so test teardowns that
|
|
590
620
|
* fs.rm the home directory immediately after do not race
|
|
@@ -618,6 +648,7 @@ function getRelayPublisher() {
|
|
|
618
648
|
_relayPub = new RelayPublisher({
|
|
619
649
|
deps: { deviceId: 'desktop-' + process.pid }, /* default auth = vault + license */
|
|
620
650
|
enabled: relayEnabled,
|
|
651
|
+
instanceId: getInstanceIdentity().id, /* item 2: tag every event so the phone mirrors only the chosen instance */
|
|
621
652
|
});
|
|
622
653
|
}
|
|
623
654
|
return _relayPub;
|
|
@@ -689,7 +720,11 @@ function startDesktopCommandConsumer(port, coord = {}) {
|
|
|
689
720
|
hasBridge: peerSnapshot.hasBridge,
|
|
690
721
|
});
|
|
691
722
|
const pollCommands = async (since) => {
|
|
692
|
-
|
|
723
|
+
/* Long-poll (2026-09-02): hold up to 20s server-side so the desktop makes
|
|
724
|
+
* ~1 request / 20s instead of ~1 / 3s -- ~8x fewer Workers requests, same
|
|
725
|
+
* D1 reads. Events still return the instant they arrive (worker checks
|
|
726
|
+
* every 2.5s). */
|
|
727
|
+
const r = await relayPoll({ deviceId }, since, ['command'], 20);
|
|
693
728
|
return {
|
|
694
729
|
ok: r.ok !== false,
|
|
695
730
|
events: (r.events ?? []).map((e) => ({
|
|
@@ -834,8 +869,10 @@ function startDesktopCommandConsumer(port, coord = {}) {
|
|
|
834
869
|
* a phone interrupt lands MID-TURN instead of waiting for it to end.
|
|
835
870
|
* Each filters to its own subset; the other still advances its cursor
|
|
836
871
|
* past the skipped events. */
|
|
872
|
+
const selfInstanceId = getInstanceIdentity().id;
|
|
837
873
|
const chat = startCommandConsumer({
|
|
838
874
|
enabled: relayEnabled, intervalMs: 3000,
|
|
875
|
+
instanceId: selfInstanceId, /* item 2: run only commands targeted at me (or untargeted) */
|
|
839
876
|
/* only MY project's chat commands (multi-instance integrity); the other
|
|
840
877
|
* instances' consumers advance their cursor past the skipped ones. */
|
|
841
878
|
accept: (c) => !c.control && ownsChatCommand(c.projectSlug),
|
|
@@ -845,12 +882,30 @@ function startDesktopCommandConsumer(port, coord = {}) {
|
|
|
845
882
|
});
|
|
846
883
|
const control = startCommandConsumer({
|
|
847
884
|
enabled: relayEnabled, intervalMs: 1200,
|
|
885
|
+
instanceId: selfInstanceId,
|
|
848
886
|
accept: (c) => !!c.control,
|
|
849
887
|
pollCommands, runTurn, onControl, reply,
|
|
850
888
|
onError: () => { },
|
|
851
889
|
});
|
|
890
|
+
/* Item 2 (cross-machine): advertise this instance's presence on a slow
|
|
891
|
+
* heartbeat (gated by relayEnabled -> only when a phone is paired) so the
|
|
892
|
+
* phone can list + pick instances across machines and TTL-expire dead ones. */
|
|
893
|
+
const presenceTimer = setInterval(() => {
|
|
894
|
+
try {
|
|
895
|
+
const pub = getRelayPublisher();
|
|
896
|
+
if (!pub)
|
|
897
|
+
return;
|
|
898
|
+
const idn = getInstanceIdentity();
|
|
899
|
+
const slug = _relayBoardCtx?.slug ?? null;
|
|
900
|
+
pub.presence({ hostname: idn.hostname, pid: idn.pid, port, ...(slug ? { project_slug: slug, project_name: slug } : {}) });
|
|
901
|
+
void pub.flush();
|
|
902
|
+
}
|
|
903
|
+
catch { /* best-effort */ }
|
|
904
|
+
}, 15000);
|
|
905
|
+
if (typeof presenceTimer.unref === 'function')
|
|
906
|
+
presenceTimer.unref();
|
|
852
907
|
return {
|
|
853
|
-
stop() { clearInterval(peerTimer); chat.stop(); control.stop(); },
|
|
908
|
+
stop() { clearInterval(peerTimer); clearInterval(presenceTimer); chat.stop(); control.stop(); },
|
|
854
909
|
tickOnce: async () => { await refreshPeers(); await chat.tickOnce(); await control.tickOnce(); },
|
|
855
910
|
};
|
|
856
911
|
}
|
|
@@ -1018,31 +1073,56 @@ async function handleForgeTaskDelete(req, res, ctx) {
|
|
|
1018
1073
|
const removed = await sup.remove(id);
|
|
1019
1074
|
sendJson(res, removed ? 200 : 404, { ok: removed, id });
|
|
1020
1075
|
}
|
|
1076
|
+
/* Low-frequency "remote running..." heartbeat to the phone while the desktop
|
|
1077
|
+
* is thinking (Pablo 2026-09-02). Replaces the per-tool activity stream, which
|
|
1078
|
+
* was the bulk of the relay's Cloudflare appends and only calmed anxiety. The
|
|
1079
|
+
* pizarron (board) still refreshes -- THAT is the signal that adds value. */
|
|
1080
|
+
let _relayHeartbeat = null;
|
|
1081
|
+
function stopRelayHeartbeat() {
|
|
1082
|
+
if (_relayHeartbeat) {
|
|
1083
|
+
clearInterval(_relayHeartbeat);
|
|
1084
|
+
_relayHeartbeat = null;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
function startRelayHeartbeat() {
|
|
1088
|
+
stopRelayHeartbeat();
|
|
1089
|
+
_relayHeartbeat = setInterval(() => {
|
|
1090
|
+
try {
|
|
1091
|
+
const pub = getRelayPublisher();
|
|
1092
|
+
if (!pub) {
|
|
1093
|
+
stopRelayHeartbeat();
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
pub.activity('remote running...', { kind: 'heartbeat' });
|
|
1097
|
+
void pub.flush();
|
|
1098
|
+
}
|
|
1099
|
+
catch { /* best-effort */ }
|
|
1100
|
+
}, 60_000);
|
|
1101
|
+
if (typeof _relayHeartbeat.unref === 'function')
|
|
1102
|
+
_relayHeartbeat.unref();
|
|
1103
|
+
}
|
|
1021
1104
|
function relayForward(event, data) {
|
|
1022
1105
|
const pub = getRelayPublisher();
|
|
1023
1106
|
if (!pub)
|
|
1024
1107
|
return;
|
|
1025
1108
|
try {
|
|
1026
1109
|
const d = (data && typeof data === 'object') ? data : {};
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
else if (event === 'turn_start') {
|
|
1110
|
+
/* tool_round_start: intentionally NO relay activity anymore -- the granular
|
|
1111
|
+
* per-tool feed was the append-heavy part; the phone now gets a heartbeat. */
|
|
1112
|
+
if (event === 'turn_start') {
|
|
1031
1113
|
pub.status('working', { tier: d.tier, provider: d.provider, model: d.model });
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
* Before, the feed only got an entry on tool_round_start. */
|
|
1035
|
-
const ut = typeof d.user_text === 'string' ? d.user_text.slice(0, 80) : '';
|
|
1036
|
-
pub.activity(ut ? 'procesando: ' + ut : 'nuevo turno', { kind: 'task' });
|
|
1114
|
+
pub.activity('remote running...', { kind: 'heartbeat' }); /* first line now; then every 60s */
|
|
1115
|
+
startRelayHeartbeat();
|
|
1037
1116
|
}
|
|
1038
1117
|
else if (event === 'turn_complete') {
|
|
1118
|
+
stopRelayHeartbeat();
|
|
1039
1119
|
pub.status('round_done');
|
|
1040
|
-
pub.activity('turno completo', { kind: 'task' });
|
|
1041
1120
|
/* refresh the phone's board after each turn (workflow/pendings may
|
|
1042
|
-
* have changed). */
|
|
1121
|
+
* have changed) -- the pizarron is the value the user keeps. */
|
|
1043
1122
|
publishBoardSnapshot();
|
|
1044
1123
|
}
|
|
1045
1124
|
else if (event === 'error') {
|
|
1125
|
+
stopRelayHeartbeat();
|
|
1046
1126
|
pub.status('error', { message: d.message ?? d.error ?? '' });
|
|
1047
1127
|
}
|
|
1048
1128
|
}
|