@phnx-labs/agents-cli 1.22.79 → 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.
@@ -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.79",
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",