@phnx-labs/agents-cli 1.20.50 → 1.20.51

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/cloud.js +1 -25
  4. package/dist/commands/computer.d.ts +1 -0
  5. package/dist/commands/computer.js +129 -8
  6. package/dist/commands/exec.js +49 -6
  7. package/dist/commands/factory.js +1 -4
  8. package/dist/commands/inspect.js +1 -11
  9. package/dist/commands/mcp.js +2 -6
  10. package/dist/commands/message.js +1 -4
  11. package/dist/commands/profiles.js +1 -18
  12. package/dist/commands/repo.js +33 -14
  13. package/dist/commands/resource-view.d.ts +1 -0
  14. package/dist/commands/resource-view.js +5 -17
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +1 -28
  17. package/dist/commands/sessions-picker.js +1 -18
  18. package/dist/commands/sessions.js +6 -8
  19. package/dist/commands/teams-picker.js +1 -32
  20. package/dist/commands/teams.js +1 -27
  21. package/dist/commands/tmux.js +1 -3
  22. package/dist/commands/view.js +1 -9
  23. package/dist/commands/worktree.js +1 -4
  24. package/dist/lib/agents.d.ts +0 -4
  25. package/dist/lib/agents.js +20 -33
  26. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  27. package/dist/lib/auto-dispatch-linear.js +107 -0
  28. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  29. package/dist/lib/auto-dispatch-provider.js +25 -0
  30. package/dist/lib/auto-dispatch.d.ts +87 -0
  31. package/dist/lib/auto-dispatch.js +142 -0
  32. package/dist/lib/browser/cdp.js +11 -2
  33. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  34. package/dist/lib/browser/drivers/ssh.js +57 -18
  35. package/dist/lib/browser/refs.js +1 -5
  36. package/dist/lib/cli-resources.d.ts +0 -2
  37. package/dist/lib/cli-resources.js +30 -13
  38. package/dist/lib/cloud/rush.d.ts +0 -24
  39. package/dist/lib/cloud/rush.js +0 -31
  40. package/dist/lib/crabbox/cli.js +4 -1
  41. package/dist/lib/crabbox/lease.js +29 -1
  42. package/dist/lib/daemon.js +41 -0
  43. package/dist/lib/exec.js +31 -14
  44. package/dist/lib/format.d.ts +38 -0
  45. package/dist/lib/format.js +108 -0
  46. package/dist/lib/git.d.ts +21 -0
  47. package/dist/lib/git.js +92 -0
  48. package/dist/lib/hooks/cache.d.ts +9 -2
  49. package/dist/lib/hooks/cache.js +220 -8
  50. package/dist/lib/hooks.js +17 -8
  51. package/dist/lib/platform/exec.d.ts +4 -1
  52. package/dist/lib/platform/exec.js +8 -2
  53. package/dist/lib/resources.d.ts +0 -8
  54. package/dist/lib/resources.js +0 -10
  55. package/dist/lib/runner.js +10 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/session/active.d.ts +11 -1
  59. package/dist/lib/session/active.js +3 -0
  60. package/dist/lib/session/db.d.ts +1 -4
  61. package/dist/lib/session/db.js +20 -25
  62. package/dist/lib/session/discover.d.ts +2 -2
  63. package/dist/lib/session/discover.js +61 -48
  64. package/dist/lib/session/parse.js +35 -34
  65. package/dist/lib/session/render.d.ts +7 -3
  66. package/dist/lib/session/render.js +15 -9
  67. package/dist/lib/session/state.d.ts +55 -0
  68. package/dist/lib/session/state.js +87 -10
  69. package/dist/lib/session/types.d.ts +9 -0
  70. package/dist/lib/shims.d.ts +9 -3
  71. package/dist/lib/shims.js +44 -8
  72. package/dist/lib/ssh-tunnel.d.ts +33 -2
  73. package/dist/lib/ssh-tunnel.js +94 -7
  74. package/dist/lib/staleness/types.d.ts +0 -1
  75. package/dist/lib/types.d.ts +14 -1
  76. package/dist/lib/versions.d.ts +0 -26
  77. package/dist/lib/versions.js +2 -145
  78. package/dist/lib/warn-unpushed.d.ts +40 -0
  79. package/dist/lib/warn-unpushed.js +128 -0
  80. package/package.json +3 -1
  81. package/dist/lib/resources/index.d.ts +0 -53
  82. package/dist/lib/resources/index.js +0 -76
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Concrete Linear gateway for auto-dispatch.
3
+ *
4
+ * Talks to the Linear GraphQL API. The API key is read from the LINEAR_API_KEY
5
+ * env var, falling back to the macOS keychain generic-password `linear-api-key`
6
+ * (how the rest of the stack stores it). If no key is resolvable, the gateway is
7
+ * absent and the daemon skips auto-dispatch entirely.
8
+ *
9
+ * "Started" (Doing) and "unstarted" (Todo) are Linear state *types*, so we never
10
+ * hardcode a team's custom state names. `startIssue` resolves the team's first
11
+ * started-type state (cached per team) and moves the issue there — the existing
12
+ * factory webhook turns that transition into a real run.
13
+ */
14
+ import { execFileSync } from 'child_process';
15
+ const LINEAR_API = 'https://api.linear.app/graphql';
16
+ /** Resolve the Linear API key from env or (on macOS) the keychain. Null if absent. */
17
+ export function resolveLinearApiKey() {
18
+ const fromEnv = process.env.LINEAR_API_KEY?.trim();
19
+ if (fromEnv)
20
+ return fromEnv;
21
+ if (process.platform === 'darwin') {
22
+ try {
23
+ const out = execFileSync('security', ['find-generic-password', '-s', 'linear-api-key', '-w'], {
24
+ encoding: 'utf-8',
25
+ stdio: ['ignore', 'pipe', 'ignore'],
26
+ });
27
+ const key = out.trim();
28
+ if (key)
29
+ return key;
30
+ }
31
+ catch {
32
+ // not in keychain — fall through
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ async function gql(apiKey, query, variables) {
38
+ const res = await fetch(LINEAR_API, {
39
+ method: 'POST',
40
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ query, variables }),
42
+ });
43
+ if (!res.ok)
44
+ throw new Error(`Linear API HTTP ${res.status}`);
45
+ const json = (await res.json());
46
+ if (json.errors?.length)
47
+ throw new Error(`Linear API: ${json.errors.map((e) => e.message).join('; ')}`);
48
+ if (!json.data)
49
+ throw new Error('Linear API: empty response');
50
+ return json.data;
51
+ }
52
+ /** Build a real Linear gateway, or null when no API key is configured. */
53
+ export function createLinearGateway() {
54
+ const apiKey = resolveLinearApiKey();
55
+ if (!apiKey)
56
+ return null;
57
+ // Cache the resolved started-state id per team so we don't re-query each dispatch.
58
+ const startedStateByTeam = new Map();
59
+ async function resolveStartedState(teamId) {
60
+ const cached = startedStateByTeam.get(teamId);
61
+ if (cached)
62
+ return cached;
63
+ const data = await gql(apiKey, `query($t:String!){ workflowStates(filter:{ team:{ id:{ eq:$t } }, type:{ eq:"started" } }, first:10){ nodes{ id type position } } }`, { t: teamId });
64
+ const nodes = data.workflowStates.nodes.slice().sort((a, b) => a.position - b.position);
65
+ const state = nodes[0];
66
+ if (!state)
67
+ throw new Error(`no started-type workflow state for team ${teamId}`);
68
+ startedStateByTeam.set(teamId, state.id);
69
+ return state.id;
70
+ }
71
+ // team id per issue, captured during fetchDelegatedTodo, so startIssue can resolve state.
72
+ const teamByIssue = new Map();
73
+ return {
74
+ async countInFlight(linearProjectId) {
75
+ const data = await gql(apiKey, `query($p:ID!){ issues(filter:{ project:{ id:{ eq:$p } }, state:{ type:{ eq:"started" } }, delegate:{ null:false } }, first:250){ nodes{ id } } }`, { p: linearProjectId });
76
+ return data.issues.nodes.length;
77
+ },
78
+ async fetchDelegatedTodo(linearProjectId) {
79
+ const data = await gql(apiKey, `query($p:ID!){ issues(filter:{ project:{ id:{ eq:$p } }, state:{ type:{ eq:"unstarted" } }, delegate:{ null:false } }, first:50){ nodes{ id identifier title priority delegate{ name } team{ id } } } }`, { p: linearProjectId });
80
+ const out = [];
81
+ for (const n of data.issues.nodes) {
82
+ if (!n.delegate?.name)
83
+ continue;
84
+ if (n.team?.id)
85
+ teamByIssue.set(n.id, n.team.id);
86
+ out.push({ id: n.id, identifier: n.identifier, title: n.title ?? '', priority: n.priority ?? 0, delegateName: n.delegate.name });
87
+ }
88
+ return out;
89
+ },
90
+ async markStarted(issueId, delegateName) {
91
+ const teamId = teamByIssue.get(issueId);
92
+ if (!teamId)
93
+ throw new Error(`unknown team for issue ${issueId} (fetch it first)`);
94
+ const stateId = await resolveStartedState(teamId);
95
+ const data = await gql(apiKey, `mutation($id:String!,$s:String!){ issueUpdate(id:$id, input:{ stateId:$s }){ success } }`, { id: issueId, s: stateId });
96
+ if (!data.issueUpdate.success)
97
+ throw new Error(`issueUpdate failed for ${issueId}`);
98
+ // Leave a trail so the auto-dispatch is visible in the ticket history.
99
+ try {
100
+ await gql(apiKey, `mutation($i:String!,$b:String!){ commentCreate(input:{ issueId:$i, body:$b }){ success } }`, { i: issueId, b: `Auto-dispatched to **${delegateName}** by the factory (moved Todo → Doing).` });
101
+ }
102
+ catch {
103
+ // comment is best-effort — the state change is what matters
104
+ }
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Concrete Dispatcher for auto-dispatch — runs a ticket through agents-cli's own
3
+ * cloud-provider abstraction (`resolveProvider().dispatch()`), the same layer as
4
+ * `agents cloud run`. Rush (Prix) is one provider among rush/codex/factory; a
5
+ * project pins its provider via `provider` in projects.json, else the delegate
6
+ * agent's native cloud is used. This is what removes the hidden Prix coupling —
7
+ * auto-dispatch no longer depends on the prix/api webhook.
8
+ */
9
+ import type { Dispatcher } from './auto-dispatch.js';
10
+ export declare function createProviderDispatcher(): Dispatcher;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Concrete Dispatcher for auto-dispatch — runs a ticket through agents-cli's own
3
+ * cloud-provider abstraction (`resolveProvider().dispatch()`), the same layer as
4
+ * `agents cloud run`. Rush (Prix) is one provider among rush/codex/factory; a
5
+ * project pins its provider via `provider` in projects.json, else the delegate
6
+ * agent's native cloud is used. This is what removes the hidden Prix coupling —
7
+ * auto-dispatch no longer depends on the prix/api webhook.
8
+ */
9
+ import { resolveProvider } from './cloud/registry.js';
10
+ export function createProviderDispatcher() {
11
+ return {
12
+ async dispatch({ agent, prompt, repo, provider }) {
13
+ const p = resolveProvider(provider, agent);
14
+ const caps = p.capabilities();
15
+ if (!caps.available) {
16
+ throw new Error(`cloud provider '${p.id}' is not available (auth/binary missing)`);
17
+ }
18
+ if (!caps.dispatch) {
19
+ throw new Error(`cloud provider '${p.id}' does not support dispatch`);
20
+ }
21
+ const task = await p.dispatch({ agent, prompt, repo });
22
+ return { id: task.id };
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Auto-dispatch — pull side of the factory dispatch loop.
3
+ *
4
+ * Polls Linear for issues that are delegated to an agent and still in Todo for a
5
+ * managed project, and — up to a per-project concurrency cap — DISPATCHES each
6
+ * through agents-cli's own cloud-provider abstraction (`resolveProvider().dispatch()`),
7
+ * then marks the ticket Doing so it isn't picked up twice.
8
+ *
9
+ * The dispatch goes through the same provider layer as `agents cloud run`, so Rush
10
+ * (Prix) is just one provider among rush/codex/factory — NOT a hidden requirement.
11
+ * A project may pin its provider via `provider` in ~/.agents/factory/projects.json.
12
+ *
13
+ * OPT-IN: a project auto-dispatches ONLY when `autoDispatch: true` AND `maxAgents > 0`.
14
+ * Nothing global is on by default.
15
+ */
16
+ /** The subset of a managed project this module needs (mirror of the shared JSON). */
17
+ export interface AutoDispatchProject {
18
+ id: string;
19
+ name: string;
20
+ linearProjectId?: string;
21
+ repoSlug?: string;
22
+ autoDispatch?: boolean;
23
+ maxAgents?: number;
24
+ provider?: string;
25
+ }
26
+ /** A Linear issue that is a candidate for dispatch. */
27
+ export interface DelegatedIssue {
28
+ id: string;
29
+ identifier: string;
30
+ title: string;
31
+ delegateName: string;
32
+ priority: number;
33
+ }
34
+ /** One planned dispatch: which issue, to which agent, in which project. */
35
+ export interface PlannedDispatch {
36
+ projectId: string;
37
+ repoSlug?: string;
38
+ provider?: string;
39
+ issueId: string;
40
+ identifier: string;
41
+ title: string;
42
+ delegateName: string;
43
+ }
44
+ /** Path to the shared factory project registry (written by the factory UI). */
45
+ export declare function factoryProjectsPath(): string;
46
+ /** Read the project registry rows this module cares about. */
47
+ export declare function readAutoDispatchProjects(): AutoDispatchProject[];
48
+ /** A project is eligible only when it has explicitly opted in with a positive cap. */
49
+ export declare function isEligible(p: AutoDispatchProject): boolean;
50
+ /**
51
+ * PURE planner. Given eligible projects, in-flight counts, and delegated-Todo
52
+ * issues per project, decide which issues to dispatch — never exceeding
53
+ * `maxAgents` in-flight. Highest Linear priority first.
54
+ */
55
+ export declare function planAutoDispatch(projects: AutoDispatchProject[], inFlightByProject: Record<string, number>, delegatedTodoByProject: Record<string, DelegatedIssue[]>): PlannedDispatch[];
56
+ /** Map Linear priority to a sortable rank (urgent first, none last). */
57
+ export declare function priorityRank(priority: number): number;
58
+ /** Build the prompt an auto-dispatched agent receives for a ticket. */
59
+ export declare function dispatchPrompt(identifier: string, title: string): string;
60
+ /** Linear I/O surface — discovery + bookkeeping (NOT the dispatch trigger). */
61
+ export interface LinearGateway {
62
+ /** Count issues in a started ("Doing") state whose delegate is set, per project. */
63
+ countInFlight(linearProjectId: string): Promise<number>;
64
+ /** Fetch delegated issues in an unstarted ("Todo") state for a project. */
65
+ fetchDelegatedTodo(linearProjectId: string): Promise<DelegatedIssue[]>;
66
+ /** After a successful dispatch: move the issue Todo -> Doing so it isn't re-picked. */
67
+ markStarted(issueId: string, delegateName: string): Promise<void>;
68
+ }
69
+ /** Dispatch surface — runs a ticket through agents-cli's cloud/local provider layer. */
70
+ export interface Dispatcher {
71
+ dispatch(opts: {
72
+ agent: string;
73
+ prompt: string;
74
+ repo?: string;
75
+ provider?: string;
76
+ }): Promise<{
77
+ id: string;
78
+ }>;
79
+ }
80
+ export interface AutoDispatchDeps {
81
+ projects: AutoDispatchProject[];
82
+ linear: LinearGateway;
83
+ dispatcher: Dispatcher;
84
+ log?: (level: 'INFO' | 'WARN' | 'ERROR', msg: string) => void;
85
+ }
86
+ /** One tick: read state per eligible project, plan, dispatch, then mark started. */
87
+ export declare function autoDispatchTick(deps: AutoDispatchDeps): Promise<PlannedDispatch[]>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Auto-dispatch — pull side of the factory dispatch loop.
3
+ *
4
+ * Polls Linear for issues that are delegated to an agent and still in Todo for a
5
+ * managed project, and — up to a per-project concurrency cap — DISPATCHES each
6
+ * through agents-cli's own cloud-provider abstraction (`resolveProvider().dispatch()`),
7
+ * then marks the ticket Doing so it isn't picked up twice.
8
+ *
9
+ * The dispatch goes through the same provider layer as `agents cloud run`, so Rush
10
+ * (Prix) is just one provider among rush/codex/factory — NOT a hidden requirement.
11
+ * A project may pin its provider via `provider` in ~/.agents/factory/projects.json.
12
+ *
13
+ * OPT-IN: a project auto-dispatches ONLY when `autoDispatch: true` AND `maxAgents > 0`.
14
+ * Nothing global is on by default.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { homedir } from 'os';
19
+ /** Path to the shared factory project registry (written by the factory UI). */
20
+ export function factoryProjectsPath() {
21
+ return path.join(homedir(), '.agents', 'factory', 'projects.json');
22
+ }
23
+ /** Read the project registry rows this module cares about. */
24
+ export function readAutoDispatchProjects() {
25
+ let raw;
26
+ try {
27
+ raw = JSON.parse(fs.readFileSync(factoryProjectsPath(), 'utf-8'));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ if (!Array.isArray(raw))
33
+ return [];
34
+ const out = [];
35
+ for (const r of raw) {
36
+ if (!r || typeof r !== 'object')
37
+ continue;
38
+ const o = r;
39
+ if (typeof o.id !== 'string' || typeof o.name !== 'string')
40
+ continue;
41
+ out.push({
42
+ id: o.id,
43
+ name: o.name,
44
+ linearProjectId: typeof o.linearProjectId === 'string' ? o.linearProjectId : undefined,
45
+ repoSlug: typeof o.repoSlug === 'string' ? o.repoSlug : undefined,
46
+ autoDispatch: o.autoDispatch === true,
47
+ maxAgents: typeof o.maxAgents === 'number' ? o.maxAgents : undefined,
48
+ provider: typeof o.provider === 'string' ? o.provider : undefined,
49
+ });
50
+ }
51
+ return out;
52
+ }
53
+ /** A project is eligible only when it has explicitly opted in with a positive cap. */
54
+ export function isEligible(p) {
55
+ return p.autoDispatch === true && typeof p.maxAgents === 'number' && p.maxAgents > 0 && !!p.linearProjectId;
56
+ }
57
+ /**
58
+ * PURE planner. Given eligible projects, in-flight counts, and delegated-Todo
59
+ * issues per project, decide which issues to dispatch — never exceeding
60
+ * `maxAgents` in-flight. Highest Linear priority first.
61
+ */
62
+ export function planAutoDispatch(projects, inFlightByProject, delegatedTodoByProject) {
63
+ const plan = [];
64
+ for (const p of projects) {
65
+ if (!isEligible(p))
66
+ continue;
67
+ const cap = p.maxAgents;
68
+ const slots = cap - (inFlightByProject[p.id] ?? 0);
69
+ if (slots <= 0)
70
+ continue;
71
+ const candidates = (delegatedTodoByProject[p.id] ?? [])
72
+ .slice()
73
+ .sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
74
+ for (const issue of candidates.slice(0, slots)) {
75
+ plan.push({
76
+ projectId: p.id,
77
+ repoSlug: p.repoSlug,
78
+ provider: p.provider,
79
+ issueId: issue.id,
80
+ identifier: issue.identifier,
81
+ title: issue.title,
82
+ delegateName: issue.delegateName,
83
+ });
84
+ }
85
+ }
86
+ return plan;
87
+ }
88
+ /** Map Linear priority to a sortable rank (urgent first, none last). */
89
+ export function priorityRank(priority) {
90
+ return priority === 0 ? 5 : priority;
91
+ }
92
+ /** Build the prompt an auto-dispatched agent receives for a ticket. */
93
+ export function dispatchPrompt(identifier, title) {
94
+ return `Work on Linear ticket ${identifier}: ${title}. Read the ticket for full context, implement it, and open a PR when done.`;
95
+ }
96
+ /** One tick: read state per eligible project, plan, dispatch, then mark started. */
97
+ export async function autoDispatchTick(deps) {
98
+ const log = deps.log ?? (() => { });
99
+ const eligible = deps.projects.filter(isEligible);
100
+ if (eligible.length === 0)
101
+ return [];
102
+ const inFlight = {};
103
+ const todo = {};
104
+ for (const p of eligible) {
105
+ const pid = p.linearProjectId;
106
+ try {
107
+ inFlight[p.id] = await deps.linear.countInFlight(pid);
108
+ todo[p.id] = await deps.linear.fetchDelegatedTodo(pid);
109
+ }
110
+ catch (err) {
111
+ log('WARN', `auto-dispatch: failed to read Linear state for '${p.name}': ${err.message}`);
112
+ inFlight[p.id] = Number.MAX_SAFE_INTEGER; // fail closed for this project
113
+ todo[p.id] = [];
114
+ }
115
+ }
116
+ const plan = planAutoDispatch(eligible, inFlight, todo);
117
+ const dispatched = [];
118
+ for (const d of plan) {
119
+ try {
120
+ const task = await deps.dispatcher.dispatch({
121
+ agent: d.delegateName.trim().toLowerCase(),
122
+ prompt: dispatchPrompt(d.identifier, d.title),
123
+ repo: d.repoSlug,
124
+ provider: d.provider,
125
+ });
126
+ // Bookkeeping only — dispatch already happened; moving to Doing keeps the
127
+ // ticket out of the next Todo poll. A failure here is non-fatal.
128
+ try {
129
+ await deps.linear.markStarted(d.issueId, d.delegateName);
130
+ }
131
+ catch (err) {
132
+ log('WARN', `auto-dispatch: dispatched ${d.identifier} but failed to mark Doing: ${err.message}`);
133
+ }
134
+ dispatched.push(d);
135
+ log('INFO', `auto-dispatch: dispatched ${d.identifier} to ${d.delegateName} (task ${task.id})`);
136
+ }
137
+ catch (err) {
138
+ log('WARN', `auto-dispatch: failed to dispatch ${d.identifier}: ${err.message}`);
139
+ }
140
+ }
141
+ return dispatched;
142
+ }
@@ -1,3 +1,12 @@
1
+ // The `ws` client, NOT the platform (undici) WebSocket: undici enforces a
2
+ // non-configurable max decompressed message size, and a CDP response carrying
3
+ // a base64 screenshot of a content-rich page blows past it — the socket dies
4
+ // with code 1006 ("Max decompressed message size exceeded") while the command
5
+ // is pending. `ws` offers no permessage-deflate by default and takes an
6
+ // explicit payload cap. (Reproduced live against a remote Edge over
7
+ // `browser --host`: fresh blank newtab passed, reused content-rich newtab
8
+ // failed on every Page.captureScreenshot.)
9
+ import WSWebSocket from 'ws';
1
10
  const pipeTransports = new Map();
2
11
  export function registerPipeTransport(transport) {
3
12
  const id = `pipe://${process.pid}/${pipeTransports.size + 1}`;
@@ -26,7 +35,7 @@ export class CDPClient {
26
35
  return;
27
36
  }
28
37
  return new Promise((resolve, reject) => {
29
- this.ws = new WebSocket(endpoint);
38
+ this.ws = new WSWebSocket(endpoint, { maxPayload: 256 * 1024 * 1024 });
30
39
  this.transport = 'websocket';
31
40
  this.ws.onopen = () => resolve();
32
41
  this.ws.onerror = () => reject(new Error('WebSocket error'));
@@ -102,7 +111,7 @@ export class CDPClient {
102
111
  this.transport = null;
103
112
  }
104
113
  get connected() {
105
- return ((this.ws !== null && this.ws.readyState === WebSocket.OPEN) ||
114
+ return ((this.ws !== null && this.ws.readyState === WSWebSocket.OPEN) ||
106
115
  (this.pipe !== null && !this.pipe.write.destroyed));
107
116
  }
108
117
  get isOpen() {
@@ -25,26 +25,44 @@ export declare function connectSSH(endpoint: string, profile: BrowserProfile): P
25
25
  */
26
26
  export declare function encodePowerShell(script: string): string;
27
27
  /**
28
- * The PowerShell that launches the browser on a Windows remote. Two hard
28
+ * The PowerShell that launches the browser on a Windows remote. Three hard
29
29
  * requirements shaped this:
30
30
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
31
31
  * the session's job tree on disconnect, which reaps both `start /B` and
32
- * `Start-Process` children (verified against a real box). WMI
33
- * `Win32_Process.Create` spawns under the WMI provider service instead,
34
- * so the process survives after we drop the ssh connection and reconnect
35
- * over the CDP tunnel.
36
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
32
+ * `Start-Process` children (verified against a real box). A scheduled
33
+ * task runs under the Task Scheduler service, so the process survives
34
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
35
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
36
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
37
+ * spawned Edge in session 0 (the services session): the CDP socket bound
38
+ * and accepted, yet the DevTools server never initialized — every
39
+ * /json/version probe hung forever and DevToolsActivePort was never
40
+ * written (verified against a real box, Edg/150). A scheduled task
41
+ * registered and started by the logged-on user runs in that user's
42
+ * interactive session, where DevTools comes up normally.
43
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
37
44
  * port comes up even when the user already has Edge open.
38
45
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
39
- * registry at runtime rather than relying on a bare `msedge` name.
46
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
47
+ * unregistered immediately after start — the browser keeps running; nothing
48
+ * lingers in the scheduler.
40
49
  */
41
50
  export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
42
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
51
+ /**
52
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
53
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
54
+ * killing only the port-owning main process orphans its children. The orphans
55
+ * keep the profile's SingletonLock, so every later launch against the same
56
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
57
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
58
+ * box (found live on win-mini by the #561 e2e suite).
59
+ */
43
60
  export declare function buildWindowsKillScript(port: number): string;
44
61
  /**
45
62
  * Build the remote command that launches the browser detached with a CDP port.
46
63
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
47
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
64
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
65
+ * so it survives the ssh session AND serves CDP (session 0 never does).
48
66
  */
49
67
  export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
50
68
  /**
@@ -57,7 +75,7 @@ export declare function ensureRemoteBrowser(user: string, host: string, browserT
57
75
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
58
76
  /**
59
77
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
60
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
78
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
61
79
  * the ssh tunnel is torn down — killing only the tunnel left it running on
62
80
  * win-mini after every `browser stop`. Best-effort: never rejects.
63
81
  */
@@ -5,8 +5,11 @@ import { getPortOccupant } from '../chrome.js';
5
5
  import { parseEndpointUrl } from '../profiles.js';
6
6
  import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
7
7
  // shellQuote lives in the shared ssh-exec helper (single choke point); re-export
8
- // so existing importers of `shellQuote` from this module keep working.
9
- import { shellQuote } from '../../ssh-exec.js';
8
+ // so existing importers of `shellQuote` from this module keep working. SSH_OPTS
9
+ // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
+ // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
+ // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
+ import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
10
13
  export { shellQuote };
11
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
12
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -168,18 +171,27 @@ export function encodePowerShell(script) {
168
171
  return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
169
172
  }
170
173
  /**
171
- * The PowerShell that launches the browser on a Windows remote. Two hard
174
+ * The PowerShell that launches the browser on a Windows remote. Three hard
172
175
  * requirements shaped this:
173
176
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
174
177
  * the session's job tree on disconnect, which reaps both `start /B` and
175
- * `Start-Process` children (verified against a real box). WMI
176
- * `Win32_Process.Create` spawns under the WMI provider service instead,
177
- * so the process survives after we drop the ssh connection and reconnect
178
- * over the CDP tunnel.
179
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
178
+ * `Start-Process` children (verified against a real box). A scheduled
179
+ * task runs under the Task Scheduler service, so the process survives
180
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
181
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
182
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
183
+ * spawned Edge in session 0 (the services session): the CDP socket bound
184
+ * and accepted, yet the DevTools server never initialized — every
185
+ * /json/version probe hung forever and DevToolsActivePort was never
186
+ * written (verified against a real box, Edg/150). A scheduled task
187
+ * registered and started by the logged-on user runs in that user's
188
+ * interactive session, where DevTools comes up normally.
189
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
180
190
  * port comes up even when the user already has Edge open.
181
191
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
182
- * registry at runtime rather than relying on a bare `msedge` name.
192
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
193
+ * unregistered immediately after start — the browser keeps running; nothing
194
+ * lingers in the scheduler.
183
195
  */
184
196
  export function buildWindowsLaunchScript(browserType, port, customBinary) {
185
197
  let exeStmts;
@@ -211,21 +223,42 @@ export function buildWindowsLaunchScript(browserType, port, customBinary) {
211
223
  // source — a test asserts CDP is never opened to `*`.
212
224
  return [
213
225
  ...exeStmts,
214
- `$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
226
+ // First-run / default-browser modals block automation (same rationale as
227
+ // the local launcher in chrome.ts), and the crash-restore bubble is worse
228
+ // here: `browser stop` hard-kills the remote process, so the NEXT launch
229
+ // against the same --user-data-dir marks the profile crashed and session
230
+ // restore swaps the initial page target — closing its CDP websocket while
231
+ // a command is pending (reproduced live: Page.captureScreenshot rejected
232
+ // with "CDP connection closed" on every reused-profile launch).
233
+ `$args = '--remote-debugging-port=${port}` +
215
234
  ` --remote-allow-origins=http://127.0.0.1:${port}` +
235
+ ` --no-first-run --no-default-browser-check` +
236
+ ` --hide-crash-restore-bubble --disable-session-crashed-bubble` +
216
237
  ` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
217
- `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cl } | Out-Null`,
238
+ `$action = New-ScheduledTaskAction -Execute $exe -Argument $args`,
239
+ `Register-ScheduledTask -TaskName 'agents-browser-${port}' -Action $action -Force | Out-Null`,
240
+ `Start-ScheduledTask -TaskName 'agents-browser-${port}'`,
241
+ `Unregister-ScheduledTask -TaskName 'agents-browser-${port}' -Confirm:$false`,
218
242
  ].join('; ');
219
243
  }
220
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
244
+ /**
245
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
246
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
247
+ * killing only the port-owning main process orphans its children. The orphans
248
+ * keep the profile's SingletonLock, so every later launch against the same
249
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
250
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
251
+ * box (found live on win-mini by the #561 e2e suite).
252
+ */
221
253
  export function buildWindowsKillScript(port) {
222
254
  return (`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue ` +
223
- `| ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`);
255
+ `| ForEach-Object { taskkill /PID $_.OwningProcess /T /F 2>$null } | Out-Null`);
224
256
  }
225
257
  /**
226
258
  * Build the remote command that launches the browser detached with a CDP port.
227
259
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
228
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
260
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
261
+ * so it survives the ssh session AND serves CDP (session 0 never does).
229
262
  */
230
263
  export function buildLaunchCmd(remoteOs, browserType, port, customBinary) {
231
264
  if (remoteOs === 'windows') {
@@ -266,10 +299,12 @@ export async function ensureRemoteBrowser(user, host, browserType, port, remoteO
266
299
  // ignored and this resolved on close regardless of exit code, so failures
267
300
  // were swallowed and only re-emerged 8s later as a generic
268
301
  // "SSH tunnel failed to establish".
302
+ // Options BEFORE the target (matching `sshExec`): OpenSSH's BSD getopt on
303
+ // macOS stops at the first non-option, so any `-o` after `user@host` would
304
+ // be swallowed into the remote command instead of applied.
269
305
  const child = spawn('ssh', [
306
+ ...SSH_OPTS,
270
307
  `${user}@${host}`,
271
- '-o',
272
- 'BatchMode=yes',
273
308
  remoteCmd,
274
309
  ], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
275
310
  let stderr = '';
@@ -319,7 +354,7 @@ export async function restartRemoteBrowser(user, host, browserType, port, remote
319
354
  }
320
355
  /**
321
356
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
322
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
357
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
323
358
  * the ssh tunnel is torn down — killing only the tunnel left it running on
324
359
  * win-mini after every `browser stop`. Best-effort: never rejects.
325
360
  */
@@ -328,7 +363,11 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
328
363
  }
329
364
  function runSSHCommand(user, host, cmd) {
330
365
  return new Promise((resolve) => {
331
- const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
366
+ // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
+ // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
+ // connected-but-slow command, not the connect itself). Options precede the
369
+ // target — see ensureRemoteBrowser.
370
+ const child = spawn('ssh', [...SSH_OPTS, `${user}@${host}`, cmd], {
332
371
  stdio: 'ignore',
333
372
  windowsHide: true,
334
373
  });
@@ -1,3 +1,4 @@
1
+ import { truncate } from '../format.js';
1
2
  /** Snapshot the stable descriptor for every ref in a freshly-built node map. */
2
3
  export function describeRefs(nodeMap) {
3
4
  return Array.from(nodeMap.values()).map((n) => ({
@@ -175,11 +176,6 @@ export async function getRefs(cdp, sessionId, opts = {}) {
175
176
  }
176
177
  return { refs: lines.join('\n'), nodeMap, opts: { interactive, limit } };
177
178
  }
178
- function truncate(s, max) {
179
- if (s.length <= max)
180
- return s;
181
- return s.slice(0, max - 1) + '…';
182
- }
183
179
  export async function resolveRefToCoords(cdp, sessionId, nodeMap, ref) {
184
180
  const node = nodeMap.get(ref);
185
181
  if (!node)
@@ -133,5 +133,3 @@ export declare function listCliStatus(cwd?: string): {
133
133
  statuses: CliStatus[];
134
134
  errors: CliManifestError[];
135
135
  };
136
- /** Names of CLIs that are declared but not currently installed on the host. */
137
- export declare function getMissingClis(cwd?: string): CliManifest[];