@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 +4 -0
- package/dist/commands/browser.js +75 -6
- 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
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`).
|
package/dist/commands/browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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",
|