@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.
@@ -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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"}
@@ -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":"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"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/chat/server.ts"],"names":[],"mappings":"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"}
@@ -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
- const r = await relayPoll({ deviceId }, since, ['command']);
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
- if (event === 'tool_round_start') {
1028
- pub.activity(String(d.summary || d.verb_hint || d.verb_id || 'trabajando'), d.detail);
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
- /* PND-132 -- a per-turn heartbeat in the activity feed so it does not
1033
- * look frozen for hours on turns that run no tools (pure text replies).
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
  }