@phnx-labs/agents-cli 1.22.80 → 1.22.81

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.81
4
+
5
+ - **Same-task page reopen refreshes the SAME tab instead of duplicating it (PHNX-2399).** When an agent reopens a URL that is already live in one of its browser task's OWN tabs, `agents browser navigate` / `tab add` now issue a real `Page.reload` on that same tab — keeping its tab id and CDP target, marking it current without stealing window focus — and report `refreshed: true` with the note `Tab already open—refreshed`, rather than opening a second copy. Borrowed Arc/user tabs are excluded, a stale registered tab or a failed reload never reports a phantom refresh, and the lookup→reload/create→persist section is serialized per task (and first-use creation per caller) so concurrent requests converge on one tab and the same id. The implicit first-open at the create boundary is reported as a genuine open, not disguised as a refresh. `navigate` / `tab add` gain `--json` (`{ ok, task, tabId, url, created, refreshed, message }`), and `agents browser tabs` again marks the current tab. Source: `cli/src/lib/browser/service.ts`, `cli/src/lib/browser/ipc.ts`, `cli/src/lib/browser/types.ts`, `cli/src/commands/browser.ts`.
6
+
3
7
  ## 1.22.80
4
8
 
5
9
  - **`agents prune cleanup` collapses duplicate account homes to one per account — and keeps the RIGHT one (PHNX-3940).** After the automatic-update pass moves every home to the latest release in place, an account can be left with two homes: the original identity-captured login and a freshly re-logged-in home that only carries an email ("usage unavailable"). Consolidation now groups those together and keeps the home that best represents the account — captured identity first, then a signed-in credential, then the newest running release — instead of the blind highest dir-name semver, which was the just-created duplicate and whose selection trashed the working login. When the retired duplicate held the global default, the default is repointed onto the keeper first, so the duplicate actually collapses instead of being skipped. Retirement stays a reversible soft-delete (`agents restore`). Source: `cli/src/commands/view.ts` (`planDuplicatePrune`).
@@ -1507,6 +1507,7 @@ function registerTaskCommands(browser) {
1507
1507
  .option('--fps <n>', 'Recording frames per second (with --record; 1–30, default 5)', (v) => parseInt(v, 10))
1508
1508
  .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
1509
1509
  .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
1510
+ .option('--json', 'Output machine-readable JSON (task handle + tabId + reused/message)')
1510
1511
  .action(async (opts) => {
1511
1512
  // Fast-fail copy of the consent gate, so a refused start never resolves or
1512
1513
  // auto-creates a profile. The AUTHORITATIVE gate is in the daemon
@@ -1736,12 +1737,33 @@ function registerTaskCommands(browser) {
1736
1737
  createdAt: Date.now(),
1737
1738
  });
1738
1739
  }
1740
+ // --json: machine-readable, still task-handle-first for callers that parse it.
1741
+ if (opts.json) {
1742
+ console.log(JSON.stringify({
1743
+ ok: true,
1744
+ task: response.task,
1745
+ tabId: response.tabId,
1746
+ device: boundDevice,
1747
+ profile: profileName,
1748
+ // `reused` is task-level; `created`/`refreshed` describe the page op.
1749
+ reused: response.reused === true,
1750
+ created: response.created,
1751
+ refreshed: response.refreshed,
1752
+ message: response.message,
1753
+ }, null, 2));
1754
+ return;
1755
+ }
1739
1756
  // stdout: just the resolved name, one line, no decoration. Lets callers do:
1740
1757
  // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
1741
1758
  console.log(response.task);
1742
1759
  // stderr: human-friendly commentary so a TTY user still sees what happened.
1743
- // Shell substitution captures stdout only, so $(...) stays clean.
1744
- console.error(`Task "${response.task}" started on ${boundDevice} (profile: ${profileName}).`);
1760
+ // Shell substitution captures stdout only, so $(...) stays clean. A same-name
1761
+ // retry reused the TASK — key on `reused`, not the page-level `refreshed`
1762
+ // (a no-URL retry reuses the task without reloading anything).
1763
+ const startVerb = response.reused ? 'reused' : 'started';
1764
+ console.error(`Task "${response.task}" ${startVerb} on ${boundDevice} (profile: ${profileName}).`);
1765
+ if (response.message)
1766
+ console.error(response.message);
1745
1767
  if (opts.url && response.tabId) {
1746
1768
  console.error(`Tab ${response.tabId}`);
1747
1769
  }
@@ -1938,6 +1960,7 @@ function registerTaskCommands(browser) {
1938
1960
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1939
1961
  .option('-u, --url <url>', 'URL to navigate to (or pass it positionally)')
1940
1962
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
1963
+ .option('--json', 'Output machine-readable JSON')
1941
1964
  .action(async (urlPos, opts) => {
1942
1965
  const url = urlPos || opts.url;
1943
1966
  if (!url) {
@@ -1952,13 +1975,34 @@ function registerTaskCommands(browser) {
1952
1975
  profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
1953
1976
  });
1954
1977
  if (!response.ok) {
1955
- console.error(response.error);
1978
+ if (opts.json)
1979
+ console.log(JSON.stringify({ ok: false, error: response.error }));
1980
+ else
1981
+ console.error(response.error);
1956
1982
  process.exit(1);
1957
1983
  }
1984
+ if (opts.json) {
1985
+ console.log(JSON.stringify({
1986
+ ok: true,
1987
+ task: response.task,
1988
+ tabId: response.tabId,
1989
+ url,
1990
+ created: response.created ?? false,
1991
+ refreshed: response.refreshed ?? false,
1992
+ message: response.message,
1993
+ }, null, 2));
1994
+ return;
1995
+ }
1958
1996
  if (response.task) {
1959
1997
  console.error(`task ${response.task}`);
1960
1998
  }
1961
- console.log(`Navigated ${response.tabId} to ${url}`);
1999
+ // Honest human output: a same-task reopen reloaded the SAME tab rather than
2000
+ // navigating a fresh one (PHNX-2399). Any picked-device / reopen note rides
2001
+ // stderr so stdout stays the one-line result.
2002
+ if (response.message)
2003
+ console.error(response.message);
2004
+ const verb = response.refreshed ? 'Refreshed' : 'Navigated';
2005
+ console.log(`${verb} ${response.tabId} to ${url}`);
1962
2006
  });
1963
2007
  // Tab subcommand group
1964
2008
  const tab = browser.command('tab').description('Manage tabs');
@@ -1969,6 +2013,7 @@ function registerTaskCommands(browser) {
1969
2013
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1970
2014
  .requiredOption('--url <url>', 'URL to open in the new tab')
1971
2015
  .option('-p, --profile <name>', 'Browser profile')
2016
+ .option('--json', 'Output machine-readable JSON')
1972
2017
  .action(async (opts) => {
1973
2018
  const task = resolveTaskName(opts);
1974
2019
  const response = await sendIPCRequest({
@@ -1978,10 +2023,34 @@ function registerTaskCommands(browser) {
1978
2023
  profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
1979
2024
  });
1980
2025
  if (!response.ok) {
1981
- console.error(response.error);
2026
+ if (opts.json)
2027
+ console.log(JSON.stringify({ ok: false, error: response.error }));
2028
+ else
2029
+ console.error(response.error);
1982
2030
  process.exit(1);
1983
2031
  }
1984
- console.log(`Opened tab ${response.tabId}: ${opts.url}`);
2032
+ if (opts.json) {
2033
+ console.log(JSON.stringify({
2034
+ ok: true,
2035
+ task: response.task,
2036
+ tabId: response.tabId,
2037
+ url: opts.url,
2038
+ created: response.created ?? false,
2039
+ refreshed: response.refreshed ?? false,
2040
+ message: response.message,
2041
+ }, null, 2));
2042
+ return;
2043
+ }
2044
+ // A reopen of a URL already in an owned tab refreshes that tab in place
2045
+ // rather than opening a duplicate (PHNX-2399).
2046
+ if (response.refreshed) {
2047
+ if (response.message)
2048
+ console.error(response.message);
2049
+ console.log(`Refreshed tab ${response.tabId}: ${opts.url}`);
2050
+ }
2051
+ else {
2052
+ console.log(`Opened tab ${response.tabId}: ${opts.url}`);
2053
+ }
1985
2054
  });
1986
2055
  tab
1987
2056
  .command('focus <tabId>')
@@ -105,7 +105,14 @@ export declare class BrowserIPCServer {
105
105
  private connections;
106
106
  /** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
107
107
  private stopping;
108
- constructor(service: BrowserService);
108
+ /**
109
+ * Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
110
+ * server binds (and unlinks a stale copy of) THIS path instead of the shared
111
+ * production `browser.sock`, so a test never unlinks the user's live daemon
112
+ * socket. Default (undefined) is unchanged. Not for production use.
113
+ */
114
+ private readonly socketPathOverride?;
115
+ constructor(service: BrowserService, socketPathOverride?: string);
109
116
  start(): Promise<void>;
110
117
  /**
111
118
  * Stop accepting, release the binding, and only then resolve.
@@ -443,12 +443,23 @@ export class BrowserIPCServer {
443
443
  connections = new Set();
444
444
  /** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
445
445
  stopping = null;
446
- constructor(service) {
446
+ /**
447
+ * Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
448
+ * server binds (and unlinks a stale copy of) THIS path instead of the shared
449
+ * production `browser.sock`, so a test never unlinks the user's live daemon
450
+ * socket. Default (undefined) is unchanged. Not for production use.
451
+ */
452
+ socketPathOverride;
453
+ constructor(service, socketPathOverride) {
447
454
  this.service = service;
455
+ this.socketPathOverride = socketPathOverride;
448
456
  }
449
457
  async start() {
450
- const socketPath = getSocketPath();
451
- const endpoint = getIpcEndpoint();
458
+ const socketPath = this.socketPathOverride ?? getSocketPath();
459
+ // On POSIX the endpoint IS the socket path; an override addresses the same
460
+ // isolated file. (Windows keeps its derived pipe name; the override is
461
+ // POSIX-only test machinery.)
462
+ const endpoint = this.socketPathOverride ?? getIpcEndpoint();
452
463
  const socketDir = path.dirname(socketPath);
453
464
  fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
454
465
  if (!IS_WINDOWS) {
@@ -615,6 +626,13 @@ export class BrowserIPCServer {
615
626
  async bindTask(request) {
616
627
  const createIfMissing = PAGE_CREATE_VERBS.has(request.action);
617
628
  const isClose = CLOSE_VERBS.has(request.action);
629
+ // NEVER trust these off the wire — they are server-derived bind state written
630
+ // BELOW from the resolver's own result. A client that pre-set `firstOpen` in
631
+ // its request JSON could otherwise steer navigate/tab-add to short-circuit
632
+ // onto an arbitrary tab id / forged created flag (or forge a picked-device
633
+ // hint). Clear before binding so only this handler's values survive (PHNX-2399).
634
+ delete request.firstOpen;
635
+ delete request.picked;
618
636
  // stop with --profile only (no task) is handled by the stop case itself.
619
637
  if (request.action === 'stop' && request.profile && !request.task)
620
638
  return undefined;
@@ -643,6 +661,13 @@ export class BrowserIPCServer {
643
661
  if (resolved.created && resolved.picked) {
644
662
  request.picked = resolved.picked;
645
663
  }
664
+ // The implicit create already opened request.url on this tab. Carry the
665
+ // ACTUAL page result so the navigate/tab-add handler reports that first
666
+ // open honestly (created/refreshed as it happened) instead of executing
667
+ // the URL a second time (PHNX-2399).
668
+ if (resolved.created && resolved.firstOpen) {
669
+ request.firstOpen = resolved.firstOpen;
670
+ }
646
671
  return undefined;
647
672
  }
648
673
  catch (err) {
@@ -738,13 +763,26 @@ export class BrowserIPCServer {
738
763
  fleetRemote: request.fleetRemote,
739
764
  title: request.title,
740
765
  });
766
+ // `reused` is task-level (a same-name retry matched an existing task);
767
+ // `created`/`refreshed` describe the actual PAGE operation, carried from
768
+ // `firstOpen`. A no-URL retry is `reused: true` with created/refreshed
769
+ // both false — no reload happened, so it must NOT read as refreshed. A
770
+ // pure task reuse with no page refresh gets the "Reused existing task"
771
+ // note; a same-URL reopen gets its own "Tab already open—refreshed" note
772
+ // from `firstOpen.message` (PHNX-2399).
773
+ const startReuseNote = result.reused && !result.firstOpen?.refreshed && !result.firstOpen?.created
774
+ ? 'Reused existing task'
775
+ : undefined;
741
776
  return {
742
777
  ok: true,
743
778
  task: result.name,
744
779
  tabId: result.tabId,
745
780
  windowTargetId: result.windowId,
746
781
  skill: result.skill,
747
- message: result.picked,
782
+ reused: result.reused,
783
+ created: result.firstOpen?.created,
784
+ refreshed: result.firstOpen?.refreshed,
785
+ message: [result.picked, result.firstOpen?.message, startReuseNote].filter(Boolean).join(' · ') || undefined,
748
786
  };
749
787
  }
750
788
  // The out-of-process seam onto the abandoned-task reaper: the daemon
@@ -824,16 +862,60 @@ export class BrowserIPCServer {
824
862
  error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
825
863
  };
826
864
  }
827
- const result = await this.service.navigate(request.task, request.url, request.profile);
865
+ // The implicit create already opened this URL on this tab. Report the
866
+ // ACTUAL first-open result (adopt → created:false, fresh → created:true)
867
+ // instead of re-navigating it or assuming a create (PHNX-2399).
868
+ const firstOpen = request.firstOpen;
828
869
  const picked = request.picked;
829
- return { ok: true, tabId: result.tabId, task: request.task, message: picked };
870
+ if (firstOpen) {
871
+ return {
872
+ ok: true,
873
+ tabId: firstOpen.tabId,
874
+ task: request.task,
875
+ created: firstOpen.created,
876
+ refreshed: firstOpen.refreshed,
877
+ message: [picked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
878
+ };
879
+ }
880
+ const result = await this.service.navigate(request.task, request.url, request.profile);
881
+ return {
882
+ ok: true,
883
+ tabId: result.tabId,
884
+ task: request.task,
885
+ created: result.created,
886
+ refreshed: result.refreshed,
887
+ // A picked-device hint and a reopen note are distinct signals; combine
888
+ // them so a first navigate on a remote-picked device still surfaces the
889
+ // pick, and a plain reopen surfaces the refresh note.
890
+ message: [picked, result.message].filter(Boolean).join(' · ') || undefined,
891
+ };
830
892
  }
831
893
  case 'tab-add': {
832
894
  if (!request.task || !request.url) {
833
895
  return { ok: false, error: 'Task and URL required' };
834
896
  }
897
+ const tabAddPicked = request.picked;
898
+ const firstOpen = request.firstOpen;
899
+ if (firstOpen) {
900
+ return {
901
+ ok: true,
902
+ tabId: firstOpen.tabId,
903
+ task: request.task,
904
+ created: firstOpen.created,
905
+ refreshed: firstOpen.refreshed,
906
+ message: [tabAddPicked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
907
+ };
908
+ }
835
909
  const result = await this.service.tabAdd(request.task, request.url, request.profile);
836
- return { ok: true, tabId: result.tabId };
910
+ return {
911
+ ok: true,
912
+ tabId: result.tabId,
913
+ task: request.task,
914
+ created: result.created,
915
+ refreshed: result.refreshed,
916
+ // Keep the picked-device hint AND any reopen note (same shape as navigate).
917
+ message: [tabAddPicked, result.message].filter(Boolean).join(' · ') || undefined,
918
+ };
837
919
  }
838
920
  case 'tab-focus': {
839
921
  if (!request.task || !request.tabId) {
@@ -854,7 +936,10 @@ export class BrowserIPCServer {
854
936
  return { ok: false, error: 'Task required' };
855
937
  }
856
938
  const tabs = await this.service.tabList(request.task);
857
- return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, task: request.task })) };
939
+ // Carry `current` through — it is the one field a caller needs to show
940
+ // which tab URL-less verbs act on, and dropping it here left `agents
941
+ // browser tabs` unable to mark the selection (PHNX-2399).
942
+ return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: request.task })) };
858
943
  }
859
944
  case 'evaluate': {
860
945
  if (!request.task || !request.expr) {
@@ -4,7 +4,7 @@ import { type ResolvedDomainSkill } from './domain-skills.js';
4
4
  import { type Task, type TabInfo, type ProfileStatus, type BrowserType, type HistoricalTask, type ReapResult, type ProfileName, type ConnectionKey } from './types.js';
5
5
  import { type ReapOptions } from './hygiene.js';
6
6
  import { type RefOpts, type RefNode } from './refs.js';
7
- import type { TargetFilter } from './types.js';
7
+ import type { TargetFilter, PageOpenResult } from './types.js';
8
8
  export type UploadMode = 'auto' | 'input' | 'drop' | 'chooser';
9
9
  export declare function resolveScreenshotOutputPath(outputPath: string | undefined, automaticPath: string): string;
10
10
  /**
@@ -135,6 +135,49 @@ export declare function resolveTaskIdentity(forwarded: {
135
135
  launchId?: string;
136
136
  sessionId?: string;
137
137
  };
138
+ export interface StartOptions {
139
+ taskName?: string;
140
+ url?: string;
141
+ endpointName?: string;
142
+ skipDomainSkill?: boolean;
143
+ /** Always open a new tab, skipping the abandoned-task reclaim. */
144
+ fresh?: boolean;
145
+ /** Caller identity, forwarded from the CLI (see IPCRequest.actor/launchId). */
146
+ actor?: string;
147
+ launchId?: string;
148
+ /** Calling agent session, forwarded from the CLI (see IPCRequest.sessionId). */
149
+ sessionId?: string;
150
+ /** Whether the CALLER was dispatched here by a fleet `--device` hop. */
151
+ fleetRemote?: boolean;
152
+ /** Explicit human label (`--title`). */
153
+ title?: string;
154
+ /** Injected reachability probe — production uses ssh; tests pass a fake. */
155
+ probe?: DeviceProbe;
156
+ }
157
+ export interface StartResult {
158
+ task: string;
159
+ name: string;
160
+ tabId?: string;
161
+ windowId?: string;
162
+ /** BARE profile name — what the caller asked for, never the runtime key. */
163
+ profile: ProfileName;
164
+ /** Runtime key the task actually landed on (`<profile>@<device>`). */
165
+ key: ConnectionKey;
166
+ /** Device the daemon connected to. */
167
+ device: string;
168
+ /** Set when the daemon picked a remote declaring device. */
169
+ picked?: string;
170
+ skill?: ResolvedDomainSkill;
171
+ /** A same-name start that matched an existing task and reused it (PHNX-2399). */
172
+ reused?: boolean;
173
+ /**
174
+ * The actual page operation the URL open performed (PHNX-2399), or undefined
175
+ * when start opened no URL. Carries created/refreshed/message truthfully:
176
+ * a fresh tab is `created`, a same-URL owned-tab reopen is `refreshed`, an
177
+ * adopted abandoned tab or a no-op reuse is neither.
178
+ */
179
+ firstOpen?: PageOpenResult;
180
+ }
138
181
  export declare class BrowserService {
139
182
  private static readonly SOURCE_PREFIX;
140
183
  /**
@@ -155,39 +198,56 @@ export declare class BrowserService {
155
198
  private lastTouchPersist;
156
199
  /** Coalescing window for the `touchTask` write. See {@link touchTask}. */
157
200
  private static readonly TOUCH_PERSIST_INTERVAL_MS;
158
- start(profileName: string, opts?: {
159
- taskName?: string;
160
- url?: string;
161
- endpointName?: string;
162
- skipDomainSkill?: boolean;
163
- /** Always open a new tab, skipping the abandoned-task reclaim. */
164
- fresh?: boolean;
165
- /** Caller identity, forwarded from the CLI (see IPCRequest.actor/launchId). */
166
- actor?: string;
167
- launchId?: string;
168
- /** Calling agent session, forwarded from the CLI (see IPCRequest.sessionId). */
169
- sessionId?: string;
170
- /** Whether the CALLER was dispatched here by a fleet `--device` hop. */
171
- fleetRemote?: boolean;
172
- /** Explicit human label (`--title`). */
173
- title?: string;
174
- /** Injected reachability probe — production uses ssh; tests pass a fake. */
175
- probe?: DeviceProbe;
176
- }): Promise<{
177
- task: string;
178
- name: string;
179
- tabId?: string;
180
- windowId?: string;
181
- /** BARE profile name — what the caller asked for, never the runtime key. */
182
- profile: ProfileName;
183
- /** Runtime key the task actually landed on (`<profile>@<device>`). */
184
- key: ConnectionKey;
185
- /** Device the daemon connected to. */
186
- device: string;
187
- /** Set when the daemon picked a remote declaring device. */
188
- picked?: string;
189
- skill?: ResolvedDomainSkill;
190
- }>;
201
+ /**
202
+ * Serialization chains for the reopen/create critical section (PHNX-2399).
203
+ * Nothing else serializes IPC requests — `BrowserIPCServer` registers a
204
+ * per-connection `socket.on('data', async …)` that Node never awaits — so two
205
+ * concurrent `navigate`/`tab-add` requests, or two concurrent first-use
206
+ * creates for one caller, race the "is this URL already open?" lookup and each
207
+ * open a duplicate tab. Each key funnels its section through a promise chain so
208
+ * the second request sees the first's tab and refreshes it in place.
209
+ *
210
+ * Keys are DISJOINT by concern so a nested call can never wait on a lock its
211
+ * own outer frame holds: task-scoped work keys on `task:<key>:<name>`, first-
212
+ * use creation keys on `create:<callerId>`. `start()`'s Arc/Electron branch
213
+ * calls `navigate()` (a `task:` key) while the create path holds a `create:`
214
+ * key — different namespaces, so no re-entrant deadlock.
215
+ */
216
+ private critical;
217
+ /**
218
+ * Run `fn` mutually exclusive against every other call sharing `key`. The
219
+ * chain never rejects (each link swallows its own settlement) so one failed
220
+ * section cannot wedge the queue; `fn`'s own result/throw is returned to THIS
221
+ * caller unchanged. The map entry is dropped once its tail settles with no
222
+ * newer waiter, so idle tasks/callers don't accumulate.
223
+ */
224
+ private runExclusive;
225
+ /**
226
+ * The task's OWN tabs (excluding borrowed ones — a borrowed tab predates the
227
+ * task, see {@link Task.borrowedTabs}) that are still live in the browser AND
228
+ * whose current document canonically equals `url`. Deliberately scoped to THIS
229
+ * task: it is neither {@link adoptTabShowing} (reclaims OTHER abandoned tasks'
230
+ * tabs) nor {@link pickReusableTargetWithoutCreate} (borrows a USER tab on Arc)
231
+ * — the same-task reopen contract only ever touches a tab the task itself owns.
232
+ * A registered id with no live target (a tab closed out from under us) is
233
+ * skipped, so a stale mapping never masquerades as a reopenable tab.
234
+ */
235
+ private findOwnedTabShowing;
236
+ /**
237
+ * Same-task reopen (PHNX-2399): the requested URL is already live in a tab this
238
+ * task owns, so RELOAD that same tab and keep its ids rather than opening a
239
+ * duplicate. Returns the retained short id, or undefined when no owned tab
240
+ * shows the URL (the caller then falls through to its normal navigate/create
241
+ * semantics — that no-match split is intentional and preserved).
242
+ *
243
+ * A real `Page.reload` is issued; a failed reload THROWS rather than reporting
244
+ * a phantom refresh (a stale target was already excluded by
245
+ * {@link findOwnedTabShowing}). The tab is marked current WITHOUT
246
+ * `Target.activateTarget` — reopen must not steal window focus.
247
+ */
248
+ private reopenOwnedTab;
249
+ start(profileName: string, opts?: StartOptions): Promise<StartResult>;
250
+ private startBody;
191
251
  /**
192
252
  * Reclaim a live page already showing `url` from an ABANDONED task, instead
193
253
  * of opening a second copy of the same page (RUSH-2622). Returns its CDP
@@ -271,11 +331,18 @@ export declare class BrowserService {
271
331
  tabId: string;
272
332
  url: string;
273
333
  created: boolean;
334
+ refreshed: boolean;
335
+ message?: string;
274
336
  }>;
337
+ private navigateLocked;
275
338
  tabAdd(taskId: string, url: string, profileRef?: ProfileName | ConnectionKey): Promise<{
276
339
  tabId: string;
277
340
  url: string;
341
+ created: boolean;
342
+ refreshed: boolean;
343
+ message?: string;
278
344
  }>;
345
+ private tabAddLocked;
279
346
  tabFocus(taskId: string, tabHint: string): Promise<{
280
347
  tabId: string;
281
348
  }>;
@@ -471,6 +538,18 @@ export declare class BrowserService {
471
538
  private createPageTarget;
472
539
  private getOrCreateWindow;
473
540
  private hasTaskNamed;
541
+ /** The (connection, task) a handle names anywhere, or undefined. */
542
+ private findTaskByHandle;
543
+ /**
544
+ * Same-name `start --task <name>` retry (PHNX-2399). When a task by that name
545
+ * already exists this REUSES it — reopening the URL in the same tab (same id,
546
+ * a real reload, the `Tab already open—refreshed` note) — but only after
547
+ * proving it is genuinely the same task: same bare profile, same endpoint, and
548
+ * a matching caller identity. A mismatch on any of those is a real conflict and
549
+ * throws, so a different caller/profile/endpoint can never silently acquire
550
+ * another task. Serialized by the `namedstart:` lock in {@link start}.
551
+ */
552
+ private retryNamedStart;
474
553
  /** Map key for a task on a connection (tasks are keyed by `name`). */
475
554
  private taskMapKey;
476
555
  /** Find a task by map key, id, or name on one connection. */
@@ -503,7 +582,16 @@ export declare class BrowserService {
503
582
  created: boolean;
504
583
  picked?: string;
505
584
  device?: string;
585
+ /**
586
+ * When this call CREATED the task by starting a browser on `opts.url`, the
587
+ * page operation `start` already performed on that URL. The owning IPC
588
+ * handler reports it truthfully (adopt → created:false, Arc reuse →
589
+ * created:false, fresh tab → created:true) instead of re-running the
590
+ * navigate/tab-add (a duplicate execution) and assuming a create (PHNX-2399).
591
+ */
592
+ firstOpen?: PageOpenResult;
506
593
  } | null>;
594
+ private resolveOrCreateByIdentity;
507
595
  private listTasksForCaller;
508
596
  private currentUrlHint;
509
597
  /**
@@ -318,7 +318,110 @@ export class BrowserService {
318
318
  lastTouchPersist = new Map();
319
319
  /** Coalescing window for the `touchTask` write. See {@link touchTask}. */
320
320
  static TOUCH_PERSIST_INTERVAL_MS = 60_000;
321
+ /**
322
+ * Serialization chains for the reopen/create critical section (PHNX-2399).
323
+ * Nothing else serializes IPC requests — `BrowserIPCServer` registers a
324
+ * per-connection `socket.on('data', async …)` that Node never awaits — so two
325
+ * concurrent `navigate`/`tab-add` requests, or two concurrent first-use
326
+ * creates for one caller, race the "is this URL already open?" lookup and each
327
+ * open a duplicate tab. Each key funnels its section through a promise chain so
328
+ * the second request sees the first's tab and refreshes it in place.
329
+ *
330
+ * Keys are DISJOINT by concern so a nested call can never wait on a lock its
331
+ * own outer frame holds: task-scoped work keys on `task:<key>:<name>`, first-
332
+ * use creation keys on `create:<callerId>`. `start()`'s Arc/Electron branch
333
+ * calls `navigate()` (a `task:` key) while the create path holds a `create:`
334
+ * key — different namespaces, so no re-entrant deadlock.
335
+ */
336
+ critical = new Map();
337
+ /**
338
+ * Run `fn` mutually exclusive against every other call sharing `key`. The
339
+ * chain never rejects (each link swallows its own settlement) so one failed
340
+ * section cannot wedge the queue; `fn`'s own result/throw is returned to THIS
341
+ * caller unchanged. The map entry is dropped once its tail settles with no
342
+ * newer waiter, so idle tasks/callers don't accumulate.
343
+ */
344
+ async runExclusive(key, fn) {
345
+ const prev = this.critical.get(key) ?? Promise.resolve();
346
+ const run = prev.then(fn, fn);
347
+ const tail = run.then(() => undefined, () => undefined);
348
+ this.critical.set(key, tail);
349
+ try {
350
+ return await run;
351
+ }
352
+ finally {
353
+ // Only the last link clears the entry; a newer waiter has replaced it.
354
+ if (this.critical.get(key) === tail)
355
+ this.critical.delete(key);
356
+ }
357
+ }
358
+ /**
359
+ * The task's OWN tabs (excluding borrowed ones — a borrowed tab predates the
360
+ * task, see {@link Task.borrowedTabs}) that are still live in the browser AND
361
+ * whose current document canonically equals `url`. Deliberately scoped to THIS
362
+ * task: it is neither {@link adoptTabShowing} (reclaims OTHER abandoned tasks'
363
+ * tabs) nor {@link pickReusableTargetWithoutCreate} (borrows a USER tab on Arc)
364
+ * — the same-task reopen contract only ever touches a tab the task itself owns.
365
+ * A registered id with no live target (a tab closed out from under us) is
366
+ * skipped, so a stale mapping never masquerades as a reopenable tab.
367
+ */
368
+ findOwnedTabShowing(task, url, liveTargets) {
369
+ const borrowed = new Set(task.borrowedTabs ?? []);
370
+ const wanted = canonicalTabUrl(url);
371
+ const liveById = new Map(liveTargets.filter((t) => t.type === 'page').map((t) => [t.targetId, t]));
372
+ for (const [shortId, cdpId] of Object.entries(task.tabs)) {
373
+ if (borrowed.has(shortId))
374
+ continue;
375
+ const live = liveById.get(cdpId);
376
+ if (!live)
377
+ continue; // registered but gone from the browser — not reopenable
378
+ if (canonicalTabUrl(live.url) === wanted)
379
+ return { shortId, targetId: cdpId };
380
+ }
381
+ return undefined;
382
+ }
383
+ /**
384
+ * Same-task reopen (PHNX-2399): the requested URL is already live in a tab this
385
+ * task owns, so RELOAD that same tab and keep its ids rather than opening a
386
+ * duplicate. Returns the retained short id, or undefined when no owned tab
387
+ * shows the URL (the caller then falls through to its normal navigate/create
388
+ * semantics — that no-match split is intentional and preserved).
389
+ *
390
+ * A real `Page.reload` is issued; a failed reload THROWS rather than reporting
391
+ * a phantom refresh (a stale target was already excluded by
392
+ * {@link findOwnedTabShowing}). The tab is marked current WITHOUT
393
+ * `Target.activateTarget` — reopen must not steal window focus.
394
+ */
395
+ async reopenOwnedTab(conn, task, url) {
396
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
397
+ const match = this.findOwnedTabShowing(task, url, targetInfos);
398
+ if (!match)
399
+ return undefined;
400
+ const sessionId = await this.getSessionId(conn, match.targetId);
401
+ // Real reload of the SAME document. If it throws, the ids are left exactly as
402
+ // they were and the error surfaces — never a success that didn't happen.
403
+ await conn.cdp.send('Page.reload', {}, sessionId);
404
+ // Mark current without foreground activation (no Target.activateTarget).
405
+ task.currentTabId = match.shortId;
406
+ await this.saveTaskState(task.profile, conn.tasks);
407
+ return { tabId: match.shortId };
408
+ }
321
409
  async start(profileName, opts = {}) {
410
+ // A NAMED start is serialized on the task NAME so two concurrent
411
+ // `start --task <name>` requests can't both pass the existence check and
412
+ // both create — the second observes the first's task and takes the retry (or
413
+ // conflict) path. The key is the bare name, NOT `profile:name`: the existence
414
+ // check (`findTaskByHandle`) is global by name, so two starts of the same
415
+ // name under DIFFERENT profiles must share one lock or they'd both pass.
416
+ // Disjoint `namedstart:` namespace, so the nested navigate()/create locks
417
+ // below never deadlock against it (PHNX-2399). An unnamed start needs no such
418
+ // guard: it always mints a fresh id.
419
+ if (opts.taskName) {
420
+ return this.runExclusive(`namedstart:${opts.taskName}`, () => this.startBody(profileName, opts));
421
+ }
422
+ return this.startBody(profileName, opts);
423
+ }
424
+ async startBody(profileName, opts) {
322
425
  // Consent gate, before anything is resolved or launched. This is the
323
426
  // authoritative one: gating only the `browser start` COMMAND left the ~18
324
427
  // page verbs that create a browser implicitly (navigate, click, screenshot,
@@ -337,8 +440,13 @@ export class BrowserService {
337
440
  const taskId = generateTaskId();
338
441
  let taskName;
339
442
  if (opts.taskName) {
340
- if (this.hasTaskNamed(opts.taskName)) {
341
- throw new Error(`Task "${opts.taskName}" already exists. Pick a different --task name or stop the existing one first.`);
443
+ const existing = this.findTaskByHandle(opts.taskName);
444
+ if (existing) {
445
+ // Same-name start RETRY (PHNX-2399): reuse the existing task rather than
446
+ // erroring — but only when it is genuinely the same task. A different
447
+ // profile, endpoint, or caller identity is a real conflict and must NOT
448
+ // silently acquire someone else's task.
449
+ return this.retryNamedStart(existing, profileName, routed, opts);
342
450
  }
343
451
  taskName = opts.taskName;
344
452
  }
@@ -482,8 +590,12 @@ export class BrowserService {
482
590
  });
483
591
  }).catch(() => { });
484
592
  // If URL provided, reclaim a tab an abandoned task is holding on it, else
485
- // create one directly (no about:blank).
593
+ // create one directly (no about:blank). `firstOpen` records what actually
594
+ // happened so the owning IPC handler reports it truthfully rather than
595
+ // assuming a create (PHNX-2399): ADOPTING an abandoned tab is created:false,
596
+ // opening a fresh target is created:true.
486
597
  let tabId;
598
+ let firstOpen;
487
599
  if (opts.url && !conn.electron && conn.browserType !== 'arc') {
488
600
  const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
489
601
  const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
@@ -493,6 +605,7 @@ export class BrowserService {
493
605
  this.invalidateTargetCache(conn);
494
606
  await this.saveTaskState(effectiveKey, conn.tasks);
495
607
  tabId = shortId;
608
+ firstOpen = { tabId: shortId, created: !adopted, refreshed: false };
496
609
  }
497
610
  else if (opts.url && (conn.electron || conn.browserType === 'arc')) {
498
611
  // Electron and Arc share the reuse-in-place path: neither may open a fresh
@@ -500,9 +613,11 @@ export class BrowserService {
500
613
  // so the implicit first navigate attaches to an existing tab — honoring the
501
614
  // profile's --target-filter — instead of throwing. This is what makes the
502
615
  // documented `navigate --profile arc --url …` first-use workflow attach rather
503
- // than refuse on a task-less profile (PHNX-2399 review).
616
+ // than refuse on a task-less profile (PHNX-2399 review). Carry navigate's
617
+ // real result — Arc borrow is created:false, not a fabricated create.
504
618
  const result = await this.navigate(taskName, opts.url, effectiveKey);
505
619
  tabId = result.tabId;
620
+ firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
506
621
  }
507
622
  // Domain-skill discovery: when a URL is supplied, look up site-specific
508
623
  // operating instructions and pass them back so the calling agent can pick
@@ -522,6 +637,7 @@ export class BrowserService {
522
637
  device: routed.device,
523
638
  picked: routed.picked,
524
639
  skill,
640
+ firstOpen,
525
641
  };
526
642
  }
527
643
  /**
@@ -812,7 +928,21 @@ export class BrowserService {
812
928
  }
813
929
  async navigate(taskId, url, profileRef) {
814
930
  const { conn, task } = await this.findTask(taskId, profileRef);
931
+ // Serialize the reopen/create section so two concurrent navigates for one
932
+ // task don't both miss the "already open?" check and open duplicates.
933
+ return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.navigateLocked(conn, task, url));
934
+ }
935
+ async navigateLocked(conn, task, url) {
815
936
  this.maybeUpdateLabelFromUrl(conn, task, url);
937
+ // Same-task reopen (PHNX-2399): the URL is already live in a tab this task
938
+ // owns → reload THAT tab in place and keep its id, before the create/reuse
939
+ // paths below. Chrome and Arc alike; Electron drives one window and never
940
+ // has a second owned tab to match, so this is a no-op there.
941
+ const reopened = await this.reopenOwnedTab(conn, task, url);
942
+ if (reopened) {
943
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
944
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
945
+ }
816
946
  // If we have a current tab, navigate in it (reuse)
817
947
  const currentShortId = task.currentTabId;
818
948
  if (currentShortId && task.tabs[currentShortId]) {
@@ -821,7 +951,7 @@ export class BrowserService {
821
951
  await conn.cdp.send('Page.navigate', { url }, sessionId);
822
952
  await this.saveTaskState(task.profile, conn.tasks);
823
953
  emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
824
- return { tabId: currentShortId, url, created: false };
954
+ return { tabId: currentShortId, url, created: false, refreshed: false };
825
955
  }
826
956
  // No current tab - create one
827
957
  if (conn.electron) {
@@ -836,7 +966,7 @@ export class BrowserService {
836
966
  task.currentTabId = shortId;
837
967
  await this.saveTaskState(task.profile, conn.tasks);
838
968
  emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
839
- return { tabId: shortId, url, created: true };
969
+ return { tabId: shortId, url, created: true, refreshed: false };
840
970
  }
841
971
  // Arc exposes page targets and honors Page.navigate on them; what it cannot
842
972
  // survive is Target.createTarget (#2778). createPageTarget below therefore
@@ -885,7 +1015,7 @@ export class BrowserService {
885
1015
  tabId: shortId,
886
1016
  created: false,
887
1017
  });
888
- return { tabId: shortId, url, created: false };
1018
+ return { tabId: shortId, url, created: false, refreshed: false };
889
1019
  }
890
1020
  // Nothing safe to reuse -- fall through to the actionable refusal rather
891
1021
  // than taking over a page the user is reading.
@@ -898,20 +1028,31 @@ export class BrowserService {
898
1028
  this.invalidateTargetCache(conn);
899
1029
  await this.saveTaskState(task.profile, conn.tasks);
900
1030
  emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
901
- return { tabId: shortId, url, created: true };
1031
+ return { tabId: shortId, url, created: true, refreshed: false };
902
1032
  }
903
1033
  async tabAdd(taskId, url, profileRef) {
904
1034
  const { conn, task } = await this.findTask(taskId, profileRef);
1035
+ return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.tabAddLocked(conn, task, url));
1036
+ }
1037
+ async tabAddLocked(conn, task, url) {
905
1038
  if (conn.electron) {
906
1039
  throw new Error('Electron apps do not support opening additional tabs');
907
1040
  }
1041
+ // Same-task reopen (PHNX-2399): reopening a URL already live in one of this
1042
+ // task's own tabs refreshes THAT tab rather than opening a duplicate. The
1043
+ // no-match case below stays `tab add`'s intentional create-a-new-tab
1044
+ // semantics — distinct from `navigate`, which reuses the current tab.
1045
+ const reopened = await this.reopenOwnedTab(conn, task, url);
1046
+ if (reopened) {
1047
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
1048
+ }
908
1049
  const result = await this.createPageTarget(conn, { url });
909
1050
  const shortId = generateShortId();
910
1051
  task.tabs[shortId] = result.targetId;
911
1052
  task.currentTabId = shortId; // new tab becomes current
912
1053
  this.invalidateTargetCache(conn);
913
1054
  await this.saveTaskState(task.profile, conn.tasks);
914
- return { tabId: shortId, url };
1055
+ return { tabId: shortId, url, created: true, refreshed: false };
915
1056
  }
916
1057
  async tabFocus(taskId, tabHint) {
917
1058
  const { conn, task } = await this.findTask(taskId);
@@ -2377,6 +2518,75 @@ export class BrowserService {
2377
2518
  }
2378
2519
  return false;
2379
2520
  }
2521
+ /** The (connection, task) a handle names anywhere, or undefined. */
2522
+ findTaskByHandle(handle) {
2523
+ for (const conn of this.connections.values()) {
2524
+ const task = this.lookupTaskOnConn(conn, handle);
2525
+ if (task)
2526
+ return { conn, task };
2527
+ }
2528
+ return undefined;
2529
+ }
2530
+ /**
2531
+ * Same-name `start --task <name>` retry (PHNX-2399). When a task by that name
2532
+ * already exists this REUSES it — reopening the URL in the same tab (same id,
2533
+ * a real reload, the `Tab already open—refreshed` note) — but only after
2534
+ * proving it is genuinely the same task: same bare profile, same endpoint, and
2535
+ * a matching caller identity. A mismatch on any of those is a real conflict and
2536
+ * throws, so a different caller/profile/endpoint can never silently acquire
2537
+ * another task. Serialized by the `namedstart:` lock in {@link start}.
2538
+ */
2539
+ async retryNamedStart(existing, profileName, routed, opts) {
2540
+ const { conn, task } = existing;
2541
+ const existingKey = conn.key ?? task.profile;
2542
+ // Profile + endpoint must match what this retry asked for.
2543
+ if (!keyBelongsToProfile(existingKey, profileName)) {
2544
+ throw new Error(actionable(`Task "${opts.taskName}" already exists on profile "${parseConnectionKey(existingKey).profile}", not "${profileName}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
2545
+ }
2546
+ const wantEndpoint = parseConnectionKey(routed.key).endpoint;
2547
+ const haveEndpoint = parseConnectionKey(existingKey).endpoint;
2548
+ if (opts.endpointName && wantEndpoint !== haveEndpoint) {
2549
+ throw new Error(actionable(`Task "${opts.taskName}" already exists on endpoint "${haveEndpoint}", not "${wantEndpoint}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
2550
+ }
2551
+ // Caller identity must match. Both identity-less (a human shell) is fine;
2552
+ // otherwise the SAME session/launch must own it — never another caller's.
2553
+ const caller = { sessionId: opts.sessionId, launchId: opts.launchId };
2554
+ const bothAnon = !task.sessionId && !task.launchId && !caller.sessionId && !caller.launchId;
2555
+ if (!bothAnon && !taskMatchesCaller(task, caller)) {
2556
+ throw new Error(actionable(`Task "${opts.taskName}" already exists and belongs to a different caller.`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
2557
+ }
2558
+ // Genuine same-task retry. When a URL is given, run the real navigate — which
2559
+ // reopens the same owned tab (refreshed) OR reuses the current tab for a
2560
+ // different URL (created:false, refreshed:false) — and carry its ACTUAL
2561
+ // result. With NO URL there is NO page operation: no reload, no counter
2562
+ // change; it is a pure task reuse (created/refreshed both false).
2563
+ let firstOpen;
2564
+ if (opts.url) {
2565
+ const nav = await this.navigate(task.name, opts.url, existingKey);
2566
+ firstOpen = { tabId: nav.tabId, created: nav.created, refreshed: nav.refreshed, message: nav.message };
2567
+ }
2568
+ else {
2569
+ firstOpen = { tabId: task.currentTabId, created: false, refreshed: false };
2570
+ }
2571
+ let skill;
2572
+ if (opts.url && !opts.skipDomainSkill) {
2573
+ const resolved = resolveDomainSkill(opts.url);
2574
+ if (resolved)
2575
+ skill = resolved;
2576
+ }
2577
+ return {
2578
+ task: task.id,
2579
+ name: task.name,
2580
+ tabId: firstOpen.tabId,
2581
+ profile: profileName,
2582
+ key: existingKey,
2583
+ device: routed.device,
2584
+ picked: routed.picked,
2585
+ skill,
2586
+ reused: true,
2587
+ firstOpen,
2588
+ };
2589
+ }
2380
2590
  /** Map key for a task on a connection (tasks are keyed by `name`). */
2381
2591
  taskMapKey(conn, task) {
2382
2592
  for (const [key, t] of conn.tasks) {
@@ -2428,6 +2638,17 @@ export class BrowserService {
2428
2638
  const found = await this.findTask(opts.task, opts.profile);
2429
2639
  return { ...found, created: false };
2430
2640
  }
2641
+ // Serialize identity resolution + implicit creation per caller so two
2642
+ // concurrent first-use requests (e.g. two `navigate <url>` with no --task)
2643
+ // don't both see zero matches and both start a task. The `create:` namespace
2644
+ // is disjoint from the `task:` locks `navigate`/`tabAdd` take, so the nested
2645
+ // `start → navigate` (Arc/Electron) can never wait on a lock this frame holds
2646
+ // (PHNX-2399). Distinct profiles create distinct tasks, so profile is in the
2647
+ // key; identity-less human shells share one `anon` lane.
2648
+ const createKey = `create:${opts.sessionId ?? opts.launchId ?? 'anon'}:${opts.profile ?? 'default'}`;
2649
+ return this.runExclusive(createKey, () => this.resolveOrCreateByIdentity(opts));
2650
+ }
2651
+ async resolveOrCreateByIdentity(opts) {
2431
2652
  // After a daemon restart the connections map is empty while tasks.json still
2432
2653
  // holds live tasks. Rehydrate before identity matching so done/screenshot
2433
2654
  // without --task still find the caller's task.
@@ -2482,6 +2703,10 @@ export class BrowserService {
2482
2703
  created: true,
2483
2704
  picked: started.picked,
2484
2705
  device: started.device,
2706
+ // `start` already opened opts.url on this tab; the handler must not open
2707
+ // it a second time (a duplicate tab for tab-add; a redundant reload for
2708
+ // navigate). Carry the ACTUAL page result so created/refreshed are truthful.
2709
+ firstOpen: started.firstOpen,
2485
2710
  };
2486
2711
  }
2487
2712
  listTasksForCaller(caller) {
@@ -3003,10 +3228,23 @@ export class BrowserService {
3003
3228
  conn.targetCache = undefined;
3004
3229
  }
3005
3230
  async saveTaskState(key, tasks) {
3006
- const runtimeDir = getProfileRuntimeDir(key);
3007
- await fs.promises.mkdir(runtimeDir, { recursive: true });
3008
- const state = Object.fromEntries(tasks);
3009
- await fs.promises.writeFile(path.join(runtimeDir, 'tasks.json'), JSON.stringify(state, null, 2));
3231
+ // tasks.json holds EVERY task on this connection, so two tasks persisting
3232
+ // concurrently (the per-task locks are disjoint, and `touchTask`/label writes
3233
+ // fire outside any task lock) would interleave writes of the same file and
3234
+ // could truncate or corrupt it. Serialize the whole file write per runtime
3235
+ // key — a `persist:` namespace disjoint from the `task:`/`create:` locks, so a
3236
+ // save issued while holding a task lock never deadlocks (PHNX-2399).
3237
+ await this.runExclusive(`persist:${key}`, async () => {
3238
+ const runtimeDir = getProfileRuntimeDir(key);
3239
+ await fs.promises.mkdir(runtimeDir, { recursive: true });
3240
+ const state = Object.fromEntries(tasks);
3241
+ // Write to a temp sibling then rename, so a reader never sees a half-written
3242
+ // file even if the process dies mid-write (rename is atomic on one fs).
3243
+ const target = path.join(runtimeDir, 'tasks.json');
3244
+ const tmp = `${target}.${process.pid}.tmp`;
3245
+ await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2));
3246
+ await fs.promises.rename(tmp, target);
3247
+ });
3010
3248
  }
3011
3249
  loadTaskState(key) {
3012
3250
  const runtimeDir = getProfileRuntimeDir(key);
@@ -241,6 +241,8 @@ export interface TabInfo {
241
241
  url: string;
242
242
  title: string;
243
243
  task: string;
244
+ /** Whether this is the task's current tab (the one URL-less verbs act on). */
245
+ current?: boolean;
244
246
  }
245
247
  export interface ProfileStatus {
246
248
  /**
@@ -395,6 +397,31 @@ export interface IPCRequest {
395
397
  * not a truthful signal about the CURRENT caller; only the request is.
396
398
  */
397
399
  fleetRemote?: boolean;
400
+ /**
401
+ * SERVER-INTERNAL, never trusted off the wire (PHNX-2399). When a page verb's
402
+ * `bindTask` implicitly CREATED the task and `start` already opened the URL,
403
+ * the daemon stamps the resulting {@link PageOpenResult} here so the handler
404
+ * reports that first open truthfully (adopt → created:false, Arc/Electron
405
+ * reuse → created:false, fresh tab → created:true) WITHOUT re-executing the
406
+ * URL. `bindTask` clears any client-supplied value before binding.
407
+ */
408
+ firstOpen?: PageOpenResult;
409
+ }
410
+ /**
411
+ * The outcome of opening/showing a URL on a task tab (PHNX-2399): the ONE shared
412
+ * shape `navigate` / `tab add` / `start`'s first-open all report, so no surface
413
+ * has to re-derive created/refreshed from ad-hoc booleans.
414
+ * - `created` — a NEW page target was opened.
415
+ * - `refreshed` — an existing OWNED tab showing this URL was reloaded in place.
416
+ * - neither — an existing tab was reused as-is (adopted abandoned tab, or
417
+ * navigated the current tab to a different URL).
418
+ * `created` and `refreshed` are mutually exclusive.
419
+ */
420
+ export interface PageOpenResult {
421
+ tabId?: string;
422
+ created: boolean;
423
+ refreshed: boolean;
424
+ message?: string;
398
425
  }
399
426
  /** Subset of IPCResponse describing a recording start result. */
400
427
  export interface RecordStartFields {
@@ -412,6 +439,26 @@ export interface IPCResponse {
412
439
  error?: string;
413
440
  task?: string;
414
441
  tabId?: string;
442
+ /**
443
+ * navigate / tab-add: whether this action OPENED a new page target (true) or
444
+ * acted on an existing owned tab (false). A same-task reopen — the requested
445
+ * URL was already live in one of the task's own tabs — reports `created: false`
446
+ * with `refreshed: true`, so the caller can say "opened" vs "refreshed" honestly.
447
+ */
448
+ created?: boolean;
449
+ /**
450
+ * navigate / tab-add: the requested URL was already open in an owned tab, so
451
+ * that SAME tab was reloaded in place (same tabId) rather than a duplicate
452
+ * opened. Mutually exclusive with `created: true`.
453
+ */
454
+ refreshed?: boolean;
455
+ /**
456
+ * start: a same-name retry matched an existing task and REUSED it (task-level,
457
+ * distinct from the page-level `created`/`refreshed`). A no-URL retry is
458
+ * `reused: true` with `refreshed: undefined` — it did no page operation, so it
459
+ * must NOT read as a refresh (PHNX-2399).
460
+ */
461
+ reused?: boolean;
415
462
  windowTargetId?: string;
416
463
  tabs?: TabInfo[];
417
464
  profiles?: ProfileStatus[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.80",
3
+ "version": "1.22.81",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",