@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.
- package/CHANGELOG.md +24 -0
- package/dist/commands/browser-picker.js +1 -18
- package/dist/commands/cloud.js +1 -25
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +129 -8
- package/dist/commands/exec.js +49 -6
- package/dist/commands/factory.js +1 -4
- package/dist/commands/inspect.js +1 -11
- package/dist/commands/mcp.js +2 -6
- package/dist/commands/message.js +1 -4
- package/dist/commands/profiles.js +1 -18
- package/dist/commands/repo.js +33 -14
- package/dist/commands/resource-view.d.ts +1 -0
- package/dist/commands/resource-view.js +5 -17
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +1 -28
- package/dist/commands/sessions-picker.js +1 -18
- package/dist/commands/sessions.js +6 -8
- package/dist/commands/teams-picker.js +1 -32
- package/dist/commands/teams.js +1 -27
- package/dist/commands/tmux.js +1 -3
- package/dist/commands/view.js +1 -9
- package/dist/commands/worktree.js +1 -4
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +20 -33
- package/dist/lib/auto-dispatch-linear.d.ts +18 -0
- package/dist/lib/auto-dispatch-linear.js +107 -0
- package/dist/lib/auto-dispatch-provider.d.ts +10 -0
- package/dist/lib/auto-dispatch-provider.js +25 -0
- package/dist/lib/auto-dispatch.d.ts +87 -0
- package/dist/lib/auto-dispatch.js +142 -0
- package/dist/lib/browser/cdp.js +11 -2
- package/dist/lib/browser/drivers/ssh.d.ts +28 -10
- package/dist/lib/browser/drivers/ssh.js +57 -18
- package/dist/lib/browser/refs.js +1 -5
- package/dist/lib/cli-resources.d.ts +0 -2
- package/dist/lib/cli-resources.js +30 -13
- package/dist/lib/cloud/rush.d.ts +0 -24
- package/dist/lib/cloud/rush.js +0 -31
- package/dist/lib/crabbox/cli.js +4 -1
- package/dist/lib/crabbox/lease.js +29 -1
- package/dist/lib/daemon.js +41 -0
- package/dist/lib/exec.js +31 -14
- package/dist/lib/format.d.ts +38 -0
- package/dist/lib/format.js +108 -0
- package/dist/lib/git.d.ts +21 -0
- package/dist/lib/git.js +92 -0
- package/dist/lib/hooks/cache.d.ts +9 -2
- package/dist/lib/hooks/cache.js +220 -8
- package/dist/lib/hooks.js +17 -8
- package/dist/lib/platform/exec.d.ts +4 -1
- package/dist/lib/platform/exec.js +8 -2
- package/dist/lib/resources.d.ts +0 -8
- package/dist/lib/resources.js +0 -10
- package/dist/lib/runner.js +10 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +11 -1
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +1 -4
- package/dist/lib/session/db.js +20 -25
- package/dist/lib/session/discover.d.ts +2 -2
- package/dist/lib/session/discover.js +61 -48
- package/dist/lib/session/parse.js +35 -34
- package/dist/lib/session/render.d.ts +7 -3
- package/dist/lib/session/render.js +15 -9
- package/dist/lib/session/state.d.ts +55 -0
- package/dist/lib/session/state.js +87 -10
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/shims.d.ts +9 -3
- package/dist/lib/shims.js +44 -8
- package/dist/lib/ssh-tunnel.d.ts +33 -2
- package/dist/lib/ssh-tunnel.js +94 -7
- package/dist/lib/staleness/types.d.ts +0 -1
- package/dist/lib/types.d.ts +14 -1
- package/dist/lib/versions.d.ts +0 -26
- package/dist/lib/versions.js +2 -145
- package/dist/lib/warn-unpushed.d.ts +40 -0
- package/dist/lib/warn-unpushed.js +128 -0
- package/package.json +3 -1
- package/dist/lib/resources/index.d.ts +0 -53
- 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
|
+
}
|
package/dist/lib/browser/cdp.js
CHANGED
|
@@ -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
|
|
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 ===
|
|
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.
|
|
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).
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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).
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
});
|
package/dist/lib/browser/refs.js
CHANGED
|
@@ -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[];
|