@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.
- package/CHANGELOG.md +8 -0
- package/dist/commands/browser.js +75 -6
- package/dist/commands/view.d.ts +65 -3
- package/dist/commands/view.js +121 -56
- package/dist/lib/browser/ipc.d.ts +8 -1
- package/dist/lib/browser/ipc.js +93 -8
- package/dist/lib/browser/service.d.ts +122 -34
- package/dist/lib/browser/service.js +251 -13
- package/dist/lib/browser/types.d.ts +47 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
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.
|
|
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",
|