@nac3/forge-cli 1.1.1 → 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
@@ -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;
@@ -834,8 +865,10 @@ function startDesktopCommandConsumer(port, coord = {}) {
834
865
  * a phone interrupt lands MID-TURN instead of waiting for it to end.
835
866
  * Each filters to its own subset; the other still advances its cursor
836
867
  * past the skipped events. */
868
+ const selfInstanceId = getInstanceIdentity().id;
837
869
  const chat = startCommandConsumer({
838
870
  enabled: relayEnabled, intervalMs: 3000,
871
+ instanceId: selfInstanceId, /* item 2: run only commands targeted at me (or untargeted) */
839
872
  /* only MY project's chat commands (multi-instance integrity); the other
840
873
  * instances' consumers advance their cursor past the skipped ones. */
841
874
  accept: (c) => !c.control && ownsChatCommand(c.projectSlug),
@@ -845,12 +878,30 @@ function startDesktopCommandConsumer(port, coord = {}) {
845
878
  });
846
879
  const control = startCommandConsumer({
847
880
  enabled: relayEnabled, intervalMs: 1200,
881
+ instanceId: selfInstanceId,
848
882
  accept: (c) => !!c.control,
849
883
  pollCommands, runTurn, onControl, reply,
850
884
  onError: () => { },
851
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();
852
903
  return {
853
- stop() { clearInterval(peerTimer); chat.stop(); control.stop(); },
904
+ stop() { clearInterval(peerTimer); clearInterval(presenceTimer); chat.stop(); control.stop(); },
854
905
  tickOnce: async () => { await refreshPeers(); await chat.tickOnce(); await control.tickOnce(); },
855
906
  };
856
907
  }
@@ -1018,31 +1069,56 @@ async function handleForgeTaskDelete(req, res, ctx) {
1018
1069
  const removed = await sup.remove(id);
1019
1070
  sendJson(res, removed ? 200 : 404, { ok: removed, id });
1020
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
+ }
1021
1100
  function relayForward(event, data) {
1022
1101
  const pub = getRelayPublisher();
1023
1102
  if (!pub)
1024
1103
  return;
1025
1104
  try {
1026
1105
  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') {
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') {
1031
1109
  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' });
1110
+ pub.activity('remote running...', { kind: 'heartbeat' }); /* first line now; then every 60s */
1111
+ startRelayHeartbeat();
1037
1112
  }
1038
1113
  else if (event === 'turn_complete') {
1114
+ stopRelayHeartbeat();
1039
1115
  pub.status('round_done');
1040
- pub.activity('turno completo', { kind: 'task' });
1041
1116
  /* refresh the phone's board after each turn (workflow/pendings may
1042
- * have changed). */
1117
+ * have changed) -- the pizarron is the value the user keeps. */
1043
1118
  publishBoardSnapshot();
1044
1119
  }
1045
1120
  else if (event === 'error') {
1121
+ stopRelayHeartbeat();
1046
1122
  pub.status('error', { message: d.message ?? d.error ?? '' });
1047
1123
  }
1048
1124
  }