@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.
@@ -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
@@ -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
- if (event === 'tool_round_start') {
1016
- pub.activity(String(d.summary || d.verb_hint || d.verb_id || 'trabajando'), d.detail);
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
- /* PND-132 -- a per-turn heartbeat in the activity feed so it does not
1021
- * look frozen for hours on turns that run no tools (pure text replies).
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
  }