@shardflux/cli 0.14.5 → 0.15.0
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 +61 -0
- package/README.md +72 -10
- package/dist/account-args.js +28 -7
- package/dist/account-commands.js +140 -11
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +55 -4
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.js +26 -1
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { CodexConnectorView, CodexHealth } from './codex-cloud.js';
|
|
4
|
+
/**
|
|
5
|
+
* The first-run demo's task: it shows the agent's own computer at work. The user copies it and pastes it into the
|
|
6
|
+
* launcher themselves; Daylight's own CLAUDE.md and AGENTS.md say how to run it and test it in the desktop browser.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEMO_PROMPT = "Start Daylight and test it in the browser, then tell me what works.";
|
|
9
|
+
export type AgentApp = 'claude' | 'codex';
|
|
10
|
+
/**
|
|
11
|
+
* Where Claude Code and Codex install their commands in a home folder. Apps opened from the Finder (the menu bar app,
|
|
12
|
+
* and this CLI run by it) get a minimal PATH; the menu bar app adds Homebrew's and /usr/local/bin, and these cover the
|
|
13
|
+
* rest (Claude Code's installer uses ~/.local/bin).
|
|
14
|
+
*/
|
|
15
|
+
export declare function installDirs(home: string): string[];
|
|
16
|
+
/** Claude Code or Codex is on this Mac: its home folder (~/.claude, ~/.codex) or its command on PATH or in an install folder. */
|
|
17
|
+
export declare function foundApps(env: NodeJS.ProcessEnv): Promise<AgentApp[]>;
|
|
18
|
+
/** --apps claude,codex (repeatable or comma separated); unknown names are refused. */
|
|
19
|
+
export declare function parseApps(value: unknown): AgentApp[] | undefined;
|
|
20
|
+
type CredentialRow = {
|
|
21
|
+
app: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
scope?: string;
|
|
24
|
+
access?: {
|
|
25
|
+
verdict: 'usable' | 'refused' | 'unknown';
|
|
26
|
+
checked_at: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
type Action = {
|
|
30
|
+
title: string;
|
|
31
|
+
command?: string[];
|
|
32
|
+
retry?: boolean;
|
|
33
|
+
};
|
|
34
|
+
export interface Connection {
|
|
35
|
+
id: 'account' | AgentApp | 't3';
|
|
36
|
+
/**
|
|
37
|
+
* verified: checked live just now. action: the person does `action`. pending: it is getting there on its own.
|
|
38
|
+
* unknown: ask again. info: nothing to do here yet (T3 Code: after a project is picked, or its provider's own Sign in).
|
|
39
|
+
*/
|
|
40
|
+
state: 'verified' | 'action' | 'pending' | 'unknown' | 'info';
|
|
41
|
+
/** For scripts: the command that connects it later (T3 Code over SSH, once there is a project). */
|
|
42
|
+
next?: string[];
|
|
43
|
+
fact: string;
|
|
44
|
+
action?: Action;
|
|
45
|
+
picked?: boolean;
|
|
46
|
+
found?: boolean;
|
|
47
|
+
/** T3 Code only: optional (never needed to continue), its app (for the icon) and how it connects. */
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
app_path?: string;
|
|
50
|
+
mode?: 'provider' | 'ssh';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* T3 Code, when it is installed: an ACP-capable build (0.0.46+, nightly) has Shardflux as a provider, which every
|
|
54
|
+
* `shard` run writes into T3's settings, so it is connected with nothing to sign in; stable 0.0.45 connects a project
|
|
55
|
+
* over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
|
|
56
|
+
*/
|
|
57
|
+
export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform): Promise<Connection | null>;
|
|
58
|
+
/** Claude: the account login is ready and Claude itself accepts it right now. */
|
|
59
|
+
export declare function claudeConnection(row: CredentialRow | undefined): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
60
|
+
/** Codex: this Mac's Codex login is copied and the account's Codex remote device (one executor) is connected. */
|
|
61
|
+
export declare function codexConnection(local: boolean, synced: boolean, connector: CodexConnectorView | null, health: CodexHealth): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
62
|
+
/**
|
|
63
|
+
* The onboarding report, for an agent setting itself up from chat and for the menu bar app. It verifies every
|
|
64
|
+
* connection live: the Shardflux account, Claude (Claude itself accepts the login) and Codex (the login is copied and
|
|
65
|
+
* the account's Codex remote device is connected), for the apps picked (--apps, else saved, else found on this Mac).
|
|
66
|
+
* With --connect it also does what needs no sign-in: copies the Codex login and enables the Codex remote device.
|
|
67
|
+
*/
|
|
68
|
+
export declare function onboardingReport(ctx: Ctx, values: Values): Promise<{
|
|
69
|
+
version: number;
|
|
70
|
+
api_url: string;
|
|
71
|
+
apps: AgentApp[];
|
|
72
|
+
found_apps: AgentApp[];
|
|
73
|
+
connections: Connection[];
|
|
74
|
+
ready: boolean;
|
|
75
|
+
account_error: string | null;
|
|
76
|
+
demo: {
|
|
77
|
+
prompt: string;
|
|
78
|
+
project: string;
|
|
79
|
+
prepare: string[];
|
|
80
|
+
};
|
|
81
|
+
activity: {
|
|
82
|
+
state: "running" | "failed" | "suspended" | "deleted" | "creating" | "starting" | "suspending" | "resuming" | "forking" | "stopping" | "deleting";
|
|
83
|
+
workspace: {
|
|
84
|
+
id: string;
|
|
85
|
+
key: string;
|
|
86
|
+
};
|
|
87
|
+
conversation: {
|
|
88
|
+
id: string;
|
|
89
|
+
state: "archived" | "working" | "needs_you" | "unresolved" | "done";
|
|
90
|
+
summary: string;
|
|
91
|
+
} | null;
|
|
92
|
+
commands: never[];
|
|
93
|
+
processes: never[];
|
|
94
|
+
ports: never[];
|
|
95
|
+
installs: never[];
|
|
96
|
+
screen: null;
|
|
97
|
+
browser: null;
|
|
98
|
+
changes: {
|
|
99
|
+
files: number;
|
|
100
|
+
commits: number;
|
|
101
|
+
};
|
|
102
|
+
} | {
|
|
103
|
+
changes: {
|
|
104
|
+
files: number;
|
|
105
|
+
commits: number;
|
|
106
|
+
};
|
|
107
|
+
observed_at: string;
|
|
108
|
+
screen: {
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
} | null;
|
|
112
|
+
browser: BrowserWindow | null;
|
|
113
|
+
state: string;
|
|
114
|
+
workspace: {
|
|
115
|
+
id: string;
|
|
116
|
+
key: string;
|
|
117
|
+
};
|
|
118
|
+
conversation: {
|
|
119
|
+
id: string;
|
|
120
|
+
state: "archived" | "working" | "needs_you" | "unresolved" | "done";
|
|
121
|
+
summary: string;
|
|
122
|
+
} | null;
|
|
123
|
+
commands: string[];
|
|
124
|
+
processes: {
|
|
125
|
+
command: string;
|
|
126
|
+
started_at: string | null;
|
|
127
|
+
}[];
|
|
128
|
+
ports: number[];
|
|
129
|
+
installs: {
|
|
130
|
+
packages: string[];
|
|
131
|
+
sudo: boolean;
|
|
132
|
+
}[];
|
|
133
|
+
} | null;
|
|
134
|
+
steps: ({
|
|
135
|
+
id: string;
|
|
136
|
+
title: string;
|
|
137
|
+
status: string;
|
|
138
|
+
command: string[];
|
|
139
|
+
action?: undefined;
|
|
140
|
+
} | {
|
|
141
|
+
id: string;
|
|
142
|
+
title: string;
|
|
143
|
+
status: string;
|
|
144
|
+
command?: undefined;
|
|
145
|
+
action?: undefined;
|
|
146
|
+
} | {
|
|
147
|
+
id: string;
|
|
148
|
+
title: string;
|
|
149
|
+
status: string;
|
|
150
|
+
action: string;
|
|
151
|
+
command?: undefined;
|
|
152
|
+
})[];
|
|
153
|
+
}>;
|
|
154
|
+
/** The report as text: one line per connection, then what to do next. */
|
|
155
|
+
export declare function onboardingText(report: Awaited<ReturnType<typeof onboardingReport>>): string;
|
|
156
|
+
interface ProcessRow {
|
|
157
|
+
pid: number;
|
|
158
|
+
comm: string;
|
|
159
|
+
cmdline?: string[];
|
|
160
|
+
started_at?: string | null;
|
|
161
|
+
}
|
|
162
|
+
/** Node names its main thread "MainThread" and npm retitles itself "npm run dev": the program is the first argument. */
|
|
163
|
+
export declare function activeCommands(processes: ProcessRow[]): string[];
|
|
164
|
+
/** Commands and listening ports of a running VM. Never wakes a parked computer. */
|
|
165
|
+
export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
|
|
166
|
+
state: "running" | "failed" | "suspended" | "deleted" | "creating" | "starting" | "suspending" | "resuming" | "forking" | "stopping" | "deleting";
|
|
167
|
+
workspace: {
|
|
168
|
+
id: string;
|
|
169
|
+
key: string;
|
|
170
|
+
};
|
|
171
|
+
conversation: {
|
|
172
|
+
id: string;
|
|
173
|
+
state: "archived" | "working" | "needs_you" | "unresolved" | "done";
|
|
174
|
+
summary: string;
|
|
175
|
+
} | null;
|
|
176
|
+
commands: never[];
|
|
177
|
+
processes: never[];
|
|
178
|
+
ports: never[];
|
|
179
|
+
installs: never[];
|
|
180
|
+
screen: null;
|
|
181
|
+
browser: null;
|
|
182
|
+
changes: {
|
|
183
|
+
files: number;
|
|
184
|
+
commits: number;
|
|
185
|
+
};
|
|
186
|
+
} | {
|
|
187
|
+
changes: {
|
|
188
|
+
files: number;
|
|
189
|
+
commits: number;
|
|
190
|
+
};
|
|
191
|
+
observed_at: string;
|
|
192
|
+
screen: {
|
|
193
|
+
width: number;
|
|
194
|
+
height: number;
|
|
195
|
+
} | null;
|
|
196
|
+
browser: BrowserWindow | null;
|
|
197
|
+
state: string;
|
|
198
|
+
workspace: {
|
|
199
|
+
id: string;
|
|
200
|
+
key: string;
|
|
201
|
+
};
|
|
202
|
+
conversation: {
|
|
203
|
+
id: string;
|
|
204
|
+
state: "archived" | "working" | "needs_you" | "unresolved" | "done";
|
|
205
|
+
summary: string;
|
|
206
|
+
} | null;
|
|
207
|
+
commands: string[];
|
|
208
|
+
processes: {
|
|
209
|
+
command: string;
|
|
210
|
+
started_at: string | null;
|
|
211
|
+
}[];
|
|
212
|
+
ports: number[];
|
|
213
|
+
installs: {
|
|
214
|
+
packages: string[];
|
|
215
|
+
sudo: boolean;
|
|
216
|
+
}[];
|
|
217
|
+
}>;
|
|
218
|
+
/** The ranked commands with when each started: a dev server that restarts after a save starts again. */
|
|
219
|
+
export declare function activeProcesses(processes: ProcessRow[]): Array<{
|
|
220
|
+
command: string;
|
|
221
|
+
started_at: string | null;
|
|
222
|
+
}>;
|
|
223
|
+
/**
|
|
224
|
+
* What was installed with sudo since the workspace was created, from apt's own history: the packages each
|
|
225
|
+
* `apt-get install` asked for (not their automatic dependencies). Short installs finish between two process samples;
|
|
226
|
+
* the history keeps them. Entries without Requested-By ran as root, like the image build.
|
|
227
|
+
*/
|
|
228
|
+
export declare function aptInstalls(history: string, since?: string): Array<{
|
|
229
|
+
packages: string[];
|
|
230
|
+
sudo: boolean;
|
|
231
|
+
}>;
|
|
232
|
+
export declare function gitChanges(output: string): {
|
|
233
|
+
files: number;
|
|
234
|
+
commits: number;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* The desktop's size and its largest visible browser window, so a live view can show the page the agent is testing
|
|
238
|
+
* rather than the whole screen with its panel and clock. Null fields when the desktop or the browser is not open.
|
|
239
|
+
*/
|
|
240
|
+
type BrowserWindow = {
|
|
241
|
+
x: number;
|
|
242
|
+
y: number;
|
|
243
|
+
width: number;
|
|
244
|
+
height: number;
|
|
245
|
+
title: string;
|
|
246
|
+
};
|
|
247
|
+
export declare function desktopWindows(output: string): {
|
|
248
|
+
screen: {
|
|
249
|
+
width: number;
|
|
250
|
+
height: number;
|
|
251
|
+
} | null;
|
|
252
|
+
browser: BrowserWindow | null;
|
|
253
|
+
};
|
|
254
|
+
/** "/usr/local/bin/node server.mjs" reads as "node server.mjs"; long arguments are cut. */
|
|
255
|
+
export declare function commandLine(argv: string[]): string;
|
|
256
|
+
/** Listening TCP ports from `ss -ltnp` or /proc/net/tcp{,6}, without the harness's or the desktop viewer's own ports. */
|
|
257
|
+
export declare function listeningPorts(output: string): number[];
|
|
258
|
+
export {};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { delimiter, join, resolve, basename } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { resolveWorkspace } from "./commands.js";
|
|
5
|
+
import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
6
|
+
import { getConversation } from "./conversations.js";
|
|
7
|
+
import { codexHealth, readCodexConnector, restartCodexConnector } from "./codex-cloud.js";
|
|
8
|
+
import { applicationDirs, t3Apps } from "./agent-targets.js";
|
|
9
|
+
import { configureT3Provider, t3BundleSupportsAcp } from "./t3-provider.js";
|
|
10
|
+
/**
|
|
11
|
+
* The first-run demo's task: it shows the agent's own computer at work. The user copies it and pastes it into the
|
|
12
|
+
* launcher themselves; Daylight's own CLAUDE.md and AGENTS.md say how to run it and test it in the desktop browser.
|
|
13
|
+
*/
|
|
14
|
+
export const DEMO_PROMPT = 'Start Daylight and test it in the browser, then tell me what works.';
|
|
15
|
+
const APPS = ['claude', 'codex'];
|
|
16
|
+
async function jsonFile(path) {
|
|
17
|
+
try {
|
|
18
|
+
const data = await readFile(path, 'utf8');
|
|
19
|
+
return JSON.parse(data);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
26
|
+
/**
|
|
27
|
+
* Where Claude Code and Codex install their commands in a home folder. Apps opened from the Finder (the menu bar app,
|
|
28
|
+
* and this CLI run by it) get a minimal PATH; the menu bar app adds Homebrew's and /usr/local/bin, and these cover the
|
|
29
|
+
* rest (Claude Code's installer uses ~/.local/bin).
|
|
30
|
+
*/
|
|
31
|
+
export function installDirs(home) {
|
|
32
|
+
return [join(home, '.local', 'bin'), join(home, '.claude', 'local'), join(home, '.npm-global', 'bin'), join(home, '.bun', 'bin'),
|
|
33
|
+
join(home, '.volta', 'bin'), join(home, 'Library', 'pnpm')];
|
|
34
|
+
}
|
|
35
|
+
/** Claude Code or Codex is on this Mac: its home folder (~/.claude, ~/.codex) or its command on PATH or in an install folder. */
|
|
36
|
+
export async function foundApps(env) {
|
|
37
|
+
const home = env.HOME ?? homedir();
|
|
38
|
+
const onPath = async (name) => {
|
|
39
|
+
for (const dir of [...(env.PATH ?? '').split(delimiter).filter(Boolean), ...installDirs(home)])
|
|
40
|
+
if (await exists(join(dir, name)))
|
|
41
|
+
return true;
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
const found = [];
|
|
45
|
+
for (const app of APPS)
|
|
46
|
+
if (await exists(join(home, `.${app}`)) || await onPath(app))
|
|
47
|
+
found.push(app);
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
/** --apps claude,codex (repeatable or comma separated); unknown names are refused. */
|
|
51
|
+
export function parseApps(value) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const words = (Array.isArray(value) ? value : [value]).flatMap(v => String(v).split(',')).map(v => v.trim()).filter(Boolean);
|
|
55
|
+
for (const word of words)
|
|
56
|
+
if (!APPS.includes(word))
|
|
57
|
+
throw new Error('--apps takes claude, codex or both');
|
|
58
|
+
return APPS.filter(app => words.includes(app));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* T3 Code, when it is installed: an ACP-capable build (0.0.46+, nightly) has Shardflux as a provider, which every
|
|
62
|
+
* `shard` run writes into T3's settings, so it is connected with nothing to sign in; stable 0.0.45 connects a project
|
|
63
|
+
* over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
|
|
64
|
+
*/
|
|
65
|
+
export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.io.env), platform = process.platform) {
|
|
66
|
+
const app = t3Apps(appDirs, platform)[0];
|
|
67
|
+
if (!app)
|
|
68
|
+
return null;
|
|
69
|
+
const base = { id: 't3', optional: true, found: true, app_path: app.path };
|
|
70
|
+
if (t3BundleSupportsAcp(app.path)) {
|
|
71
|
+
try {
|
|
72
|
+
await configureT3Provider(ctx.io.env, ctx.store.dir, false, ctx.signal);
|
|
73
|
+
// The provider works once there is an API key; without an account its own Sign in (shard acp --login) makes one.
|
|
74
|
+
if (!signedIn)
|
|
75
|
+
return { ...base, mode: 'provider', state: 'info', fact: 'In T3 Code, the Shardflux provider’s Sign in finishes it.' };
|
|
76
|
+
return { ...base, mode: 'provider', state: 'verified', fact: 'Shardflux added as a provider. Pick a Shardflux model in a new thread.' };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return { ...base, mode: 'provider', state: 'action', fact: error instanceof Error ? error.message : 'T3 Code’s settings could not be updated.', action: { title: 'Check again' } };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Stable T3: an SSH environment per project; the newest one this API set up is the one to add.
|
|
83
|
+
const manifest = await jsonFile(join(ctx.store.dir, 'targets.json'));
|
|
84
|
+
const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
|
|
85
|
+
const host = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').map(e => e.host).at(-1);
|
|
86
|
+
if (host)
|
|
87
|
+
return { ...base, mode: 'ssh', state: 'verified', fact: `In T3 Code: Add environment → SSH → ${host}` };
|
|
88
|
+
// An SSH environment is per project (shardflux-<project>-main): it is set up once a project is picked.
|
|
89
|
+
return { ...base, mode: 'ssh', state: 'info', fact: 'Available after you pick a project.', next: shard(['agents', 'new', '--in', 't3', '--path', '<project>']) };
|
|
90
|
+
}
|
|
91
|
+
const shard = (words) => ['shard', ...words];
|
|
92
|
+
/** Claude: the account login is ready and Claude itself accepts it right now. */
|
|
93
|
+
export function claudeConnection(row) {
|
|
94
|
+
const signIn = { title: 'Sign in to Claude', command: shard(['agents', 'credentials', 'login', 'claude']) };
|
|
95
|
+
if (!row || row.status !== 'ready')
|
|
96
|
+
return { state: 'action', fact: 'Sign in once; every conversation uses it.', action: signIn };
|
|
97
|
+
// Only a refusal blocks. Usable, unknown (Claude did not answer) and an API without the live check all accept the
|
|
98
|
+
// stored ready login: a check that learned nothing is not a reason to stop someone.
|
|
99
|
+
if (row.access?.verdict === 'refused')
|
|
100
|
+
return { state: 'action', fact: 'Claude no longer accepts this sign-in.', action: { ...signIn, title: 'Sign in to Claude again' } };
|
|
101
|
+
return { state: 'verified', fact: 'Signed in. Works in every conversation, on web and phone.' };
|
|
102
|
+
}
|
|
103
|
+
/** Codex: this Mac's Codex login is copied and the account's Codex remote device (one executor) is connected. */
|
|
104
|
+
export function codexConnection(local, synced, connector, health) {
|
|
105
|
+
const enable = { title: 'Connect Codex', command: shard(['agents', 'codex', 'enable']) };
|
|
106
|
+
if (!synced) {
|
|
107
|
+
return local ? { state: 'action', fact: 'Available on web and phone once connected.', action: enable }
|
|
108
|
+
: { state: 'action', fact: 'Sign in to Codex on this Mac first.', action: { title: 'Sign in to Codex', command: ['codex', 'login'] } };
|
|
109
|
+
}
|
|
110
|
+
const retry = { title: 'Retry', retry: true, command: shard(['setup', '--inspect', '--apps', 'codex', '--connect', '--retry-codex']) };
|
|
111
|
+
switch (health) {
|
|
112
|
+
case 'ready': return { state: 'verified', fact: 'Available on web and phone.' };
|
|
113
|
+
case 'missing':
|
|
114
|
+
case 'disabled':
|
|
115
|
+
return { state: 'action', fact: 'Available on web and phone once connected.', action: enable };
|
|
116
|
+
case 'starting': return { state: 'pending', fact: 'Connecting your Codex remote device…' };
|
|
117
|
+
case 'failed': return { state: 'action', fact: 'Codex’s remote device didn’t start.', action: retry };
|
|
118
|
+
case 'stuck': return { state: 'action', fact: 'Codex’s remote device is taking too long to start.', action: retry };
|
|
119
|
+
case 'attention': return { state: 'action', fact: connector?.last_error ? `Codex needs attention: ${connector.last_error}` : 'Codex needs attention.', action: retry };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The onboarding report, for an agent setting itself up from chat and for the menu bar app. It verifies every
|
|
124
|
+
* connection live: the Shardflux account, Claude (Claude itself accepts the login) and Codex (the login is copied and
|
|
125
|
+
* the account's Codex remote device is connected), for the apps picked (--apps, else saved, else found on this Mac).
|
|
126
|
+
* With --connect it also does what needs no sign-in: copies the Codex login and enables the Codex remote device.
|
|
127
|
+
*/
|
|
128
|
+
export async function onboardingReport(ctx, values) {
|
|
129
|
+
const saved = await jsonFile(resolve(ctx.store.dir, 'onboarding.json'));
|
|
130
|
+
const state = saved?.api_url === ctx.apiUrl ? saved : undefined;
|
|
131
|
+
const found = await foundApps(ctx.io.env);
|
|
132
|
+
// An empty saved pick means nothing was chosen yet: what is found on this Mac is pre-ticked.
|
|
133
|
+
const savedApps = Array.isArray(state?.apps) && state.apps.length ? parseApps(state.apps.filter(v => typeof v === 'string')) : undefined;
|
|
134
|
+
const apps = parseApps(values.apps) ?? savedApps ?? found;
|
|
135
|
+
const connect = values.connect === true;
|
|
136
|
+
let connected = false;
|
|
137
|
+
let accountError = null;
|
|
138
|
+
let credentials = [];
|
|
139
|
+
let connector = null;
|
|
140
|
+
let health = 'missing';
|
|
141
|
+
const home = ctx.io.env.HOME ?? ctx.io.env.USERPROFILE;
|
|
142
|
+
const localCodex = home ? (await readAgentCredentials({ home, redact: value => ctx.addSecret(value) })).some(v => v.app === 'codex') : false;
|
|
143
|
+
try {
|
|
144
|
+
ctx.apiKey();
|
|
145
|
+
const principal = await ctx.cloud().me();
|
|
146
|
+
connected = principal.type === 'api_key';
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
accountError = error instanceof Error ? error.message : 'Sign in to Shardflux, then check again.';
|
|
150
|
+
}
|
|
151
|
+
// An API from before the live Claude check refuses check_access (422): read the stored status instead.
|
|
152
|
+
const listCredentials = async () => {
|
|
153
|
+
if (!apps.includes('claude'))
|
|
154
|
+
return await ctx.cloud().agentCredentials.list();
|
|
155
|
+
try {
|
|
156
|
+
return await ctx.cloud().agentCredentials.list({ checkAccess: true });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.status !== 422)
|
|
160
|
+
throw error;
|
|
161
|
+
return await ctx.cloud().agentCredentials.list();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// A check that fails says so on its own row; it never fails the report (or `shard setup`, which includes it).
|
|
165
|
+
const failure = (error) => ({ state: 'unknown', fact: `Couldn’t check: ${error instanceof Error ? error.message : String(error)}`, action: { title: 'Check again' } });
|
|
166
|
+
let credentialError, codexError;
|
|
167
|
+
if (connected) {
|
|
168
|
+
try {
|
|
169
|
+
credentials = await listCredentials();
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
credentialError = error;
|
|
173
|
+
}
|
|
174
|
+
if (apps.includes('codex') && credentialError === undefined) {
|
|
175
|
+
try {
|
|
176
|
+
if (connect && localCodex && !credentials.some(row => row.app === 'codex') && home) {
|
|
177
|
+
await syncAgentCredentials(ctx.cloud(), { home, redact: value => ctx.addSecret(value) });
|
|
178
|
+
credentials = await listCredentials();
|
|
179
|
+
}
|
|
180
|
+
let read = await readCodexConnector(ctx);
|
|
181
|
+
health = codexHealth(read.connector, read.workspaceState);
|
|
182
|
+
const synced = credentials.some(row => row.app === 'codex');
|
|
183
|
+
// One executor per account, enabled as `shard setup` does when this Mac is signed in to Codex. --retry-codex starts
|
|
184
|
+
// a failed or stuck one again, once.
|
|
185
|
+
if (connect && synced && (health === 'missing' || health === 'disabled')) {
|
|
186
|
+
read = { connector: await ctx.cloud().codex.enable({ signal: ctx.signal }), workspaceState: null };
|
|
187
|
+
health = codexHealth(read.connector, null);
|
|
188
|
+
}
|
|
189
|
+
else if (connect && synced && values['retry-codex'] === true && (health === 'failed' || health === 'stuck' || health === 'attention')) {
|
|
190
|
+
read = { connector: await restartCodexConnector(ctx), workspaceState: null };
|
|
191
|
+
health = codexHealth(read.connector, null);
|
|
192
|
+
}
|
|
193
|
+
connector = read.connector;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
codexError = error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const account = connected ? { id: 'account', state: 'verified', fact: 'Signed in.' }
|
|
201
|
+
: { id: 'account', state: 'action', fact: 'Sign in to Shardflux to continue.', action: { title: 'Sign in', command: shard(['signup']) } };
|
|
202
|
+
const signIn = { state: 'action', fact: 'Sign in to Shardflux first.' };
|
|
203
|
+
const claude = { id: 'claude', picked: apps.includes('claude'), found: found.includes('claude'),
|
|
204
|
+
...(!connected ? signIn : credentialError !== undefined ? failure(credentialError) : claudeConnection(credentials.find(row => row.app === 'claude'))) };
|
|
205
|
+
const codex = { id: 'codex', picked: apps.includes('codex'), found: found.includes('codex'),
|
|
206
|
+
...(!connected ? signIn : (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
|
|
207
|
+
: codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
|
|
208
|
+
const t3 = await t3Connection(ctx, connected);
|
|
209
|
+
const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
|
|
210
|
+
// Ready: signed in and at least one agent app connected; the other can be connected later (Settings, or this again).
|
|
211
|
+
const ready = account.state === 'verified' && [claude, codex].some(c => c.state === 'verified');
|
|
212
|
+
const done = (value) => value === true || (typeof value === 'string' && value.length > 0);
|
|
213
|
+
return {
|
|
214
|
+
version: 3, api_url: ctx.apiUrl, apps, found_apps: found, connections, ready, account_error: accountError,
|
|
215
|
+
demo: { prompt: DEMO_PROMPT, project: 'Daylight (sample)', prepare: shard(['setup', '--prepare-sample', '--json']) },
|
|
216
|
+
activity: values.activity === true && typeof values.workspace === 'string' ? await liveActivity(ctx, values.workspace) : null,
|
|
217
|
+
steps: [
|
|
218
|
+
{ id: 'account', title: 'Sign in to Shardflux', status: connected ? 'complete' : 'available', command: shard(['signup']) },
|
|
219
|
+
{ id: 'connect', title: 'Connect Claude, Codex or both', status: ready ? 'complete' : 'available' },
|
|
220
|
+
{ id: 'demo', title: 'Try it once: your agent’s own computer', status: done(state?.demo_finished) ? 'complete' : 'available',
|
|
221
|
+
action: 'Copy the task, press ⌥⇧Space, paste it, pick Daylight (sample) and start it. The agent installs what it needs, starts it and tests it in its own browser.' },
|
|
222
|
+
{ id: 'own', title: 'Your own project', status: done(state?.own_key) ? 'complete' : 'available',
|
|
223
|
+
action: 'Press ⌥⇧Space, pick your project and describe what you want.' },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** The report as text: one line per connection, then what to do next. */
|
|
228
|
+
export function onboardingText(report) {
|
|
229
|
+
const names = { account: 'Shardflux', claude: 'Claude', codex: 'Codex', t3: 'T3 Code' };
|
|
230
|
+
const mark = { verified: '✓', action: '!', pending: '…', unknown: '?', info: '·' };
|
|
231
|
+
const lines = report.connections.filter(c => c.id === 'account' || c.picked || c.id === 't3').map(c => `${mark[c.state]} ${names[c.id]}: ${c.fact}${c.action?.command ? ` (${c.action.command.join(' ')})` : ''}`);
|
|
232
|
+
return `${lines.join('\n')}\n${report.ready ? `Ready. Try it once: ${report.demo.prompt}` : 'Not ready yet: finish the steps above, then run this again.'}\n`;
|
|
233
|
+
}
|
|
234
|
+
/** What a person recognises, most telling first: installs, then how the app is run, then what it runs. */
|
|
235
|
+
const RANKS = [/^(sudo|apt|apt-get|dpkg)$/, /^(npm|npx|pnpm|yarn|bun)$/, /^(node|python[0-9.]*|uv|vite|next|deno|docker|cargo|go|make)$/, /^(pip[0-9.]*|inotifywait)$/];
|
|
236
|
+
const AGENT_INTERNALS = /\/opt\/(codex|claude)\/|sf-agent|\.claude\/|shardflux/;
|
|
237
|
+
/** Node names its main thread "MainThread" and npm retitles itself "npm run dev": the program is the first argument. */
|
|
238
|
+
export function activeCommands(processes) {
|
|
239
|
+
const rows = processes.map(p => {
|
|
240
|
+
const argv = p.cmdline?.length ? p.cmdline : [p.comm];
|
|
241
|
+
const program = basename(argv[0].split(' ')[0]);
|
|
242
|
+
return { argv, rank: RANKS.findIndex(rank => rank.test(program)), started: Date.parse(p.started_at ?? '') || p.pid };
|
|
243
|
+
}).filter(row => row.rank >= 0 && !AGENT_INTERNALS.test(row.argv.join(' ')));
|
|
244
|
+
rows.sort((a, b) => a.rank - b.rank || b.started - a.started);
|
|
245
|
+
return [...new Set(rows.map(row => commandLine(row.argv)))].slice(0, 3);
|
|
246
|
+
}
|
|
247
|
+
/** Commands and listening ports of a running VM. Never wakes a parked computer. */
|
|
248
|
+
export async function liveActivity(ctx, ref) {
|
|
249
|
+
// A Codex Cloud conversation is named by its id (conv_…); its VM is the conversation's workspace.
|
|
250
|
+
let conversation = ref.startsWith('conv_') ? await getConversation(ctx, ref) : undefined;
|
|
251
|
+
const workspace = await resolveWorkspace(ctx, conversation?.workspace_id ?? ref);
|
|
252
|
+
if (!conversation && workspace.data.labels?.shard_conversation) {
|
|
253
|
+
conversation = await getConversation(ctx, workspace.data.labels.shard_conversation).catch(() => undefined);
|
|
254
|
+
}
|
|
255
|
+
const turn = conversation ? { id: conversation.id, state: conversation.state, summary: conversation.summary } : null;
|
|
256
|
+
const identity = { id: workspace.id, key: workspace.key };
|
|
257
|
+
if (workspace.data.deleted_at || workspace.data.observed_state !== 'running') {
|
|
258
|
+
return { state: workspace.data.observed_state, workspace: identity, conversation: turn, commands: [], processes: [], ports: [], installs: [], screen: null, browser: null, changes: { files: 0, commits: 0 } };
|
|
259
|
+
}
|
|
260
|
+
const cell = workspace.cell({ wake: null });
|
|
261
|
+
try {
|
|
262
|
+
const since = workspace.data.created_at ?? '';
|
|
263
|
+
const [processes, sockets, apt, windows, git] = await Promise.all([
|
|
264
|
+
cell.processes.list(),
|
|
265
|
+
cell.exec.run(['sh', '-c', LISTENERS], { timeoutMs: 3000, maxOutputBytes: 64 * 1024 }).catch(() => null),
|
|
266
|
+
cell.exec.run(['tail', '-c', '32768', '/var/log/apt/history.log'], { timeoutMs: 3000, maxOutputBytes: 40 * 1024 }).catch(() => null),
|
|
267
|
+
cell.exec.run(['sh', '-c', DESKTOP_WINDOWS], { timeoutMs: 3000, maxOutputBytes: 16 * 1024 }).catch(() => null),
|
|
268
|
+
cell.exec.run(['sh', '-c', GIT_CHANGES, 'git-changes', since], { timeoutMs: 5000, maxOutputBytes: 16 * 1024 }).catch(() => null),
|
|
269
|
+
]);
|
|
270
|
+
return { state: 'running', workspace: identity, conversation: turn, commands: activeCommands(processes.data), processes: activeProcesses(processes.data),
|
|
271
|
+
ports: listeningPorts(sockets?.stdout ?? ''), installs: aptInstalls(apt?.stdout ?? '', workspace.data.created_at),
|
|
272
|
+
...desktopWindows(windows?.stdout ?? ''), changes: gitChanges(git?.stdout ?? ''), observed_at: new Date().toISOString() };
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
cell.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** The ranked commands with when each started: a dev server that restarts after a save starts again. */
|
|
279
|
+
export function activeProcesses(processes) {
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const command of activeCommands(processes)) {
|
|
282
|
+
const starts = processes.filter(p => commandLine(p.cmdline?.length ? p.cmdline : [p.comm]) === command).map(p => p.started_at ?? null).filter((v) => !!v).sort();
|
|
283
|
+
out.push({ command, started_at: starts.at(-1) ?? null });
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What was installed with sudo since the workspace was created, from apt's own history: the packages each
|
|
289
|
+
* `apt-get install` asked for (not their automatic dependencies). Short installs finish between two process samples;
|
|
290
|
+
* the history keeps them. Entries without Requested-By ran as root, like the image build.
|
|
291
|
+
*/
|
|
292
|
+
export function aptInstalls(history, since) {
|
|
293
|
+
const from = since ? Date.parse(since) : NaN;
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const entry of history.split(/\n\s*\n/)) {
|
|
296
|
+
const field = (name) => entry.match(new RegExp(`^${name}: (.*)$`, 'm'))?.[1]?.trim();
|
|
297
|
+
const command = field('Commandline'), started = field('Start-Date'), by = field('Requested-By');
|
|
298
|
+
if (!command || !by || !/\b(apt-get|apt)\s+install\b/.test(command))
|
|
299
|
+
continue;
|
|
300
|
+
const when = started ? Date.parse(started.replace(/\s+/, 'T') + 'Z') : NaN;
|
|
301
|
+
if (Number.isFinite(from) && (!Number.isFinite(when) || when < from - 60_000))
|
|
302
|
+
continue;
|
|
303
|
+
const words = command.split(/\s+/);
|
|
304
|
+
const packages = words.slice(words.indexOf('install') + 1).filter(w => !w.startsWith('-') && !w.includes('/') && !w.includes('='));
|
|
305
|
+
if (packages.length)
|
|
306
|
+
out.push({ packages: packages.slice(0, 6), sudo: true });
|
|
307
|
+
}
|
|
308
|
+
return out.slice(-4);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* What the agent changed in the repositories in its home folder: uncommitted files, and commits made since the
|
|
312
|
+
* workspace was created ($1). The seeded project's own history is older and not counted.
|
|
313
|
+
*/
|
|
314
|
+
const GIT_CHANGES = 'for g in $(find /home/user -maxdepth 4 -name .git -type d 2>/dev/null); do d=${g%/.git}; git -C "$d" status --porcelain 2>/dev/null | sed "s/^/F /"; git -C "$d" log --since="$1" --format="C %H" 2>/dev/null; done; true';
|
|
315
|
+
export function gitChanges(output) {
|
|
316
|
+
const lines = output.split('\n');
|
|
317
|
+
return { files: new Set(lines.filter(l => l.startsWith('F ')).map(l => l.slice(2))).size, commits: new Set(lines.filter(l => l.startsWith('C '))).size };
|
|
318
|
+
}
|
|
319
|
+
/** The desktop's size and its visible browser windows with their titles (display :1, as the computer tool uses it). */
|
|
320
|
+
const DESKTOP_WINDOWS = 'export DISPLAY=:1; xdotool getdisplaygeometry 2>/dev/null || exit 0; for w in $(xdotool search --onlyvisible --class "chrom|firefox" 2>/dev/null); do xdotool getwindowgeometry --shell "$w"; printf "NAME=%s\\n" "$(xdotool getwindowname "$w" | tr -d "\\n")"; done; true';
|
|
321
|
+
export function desktopWindows(output) {
|
|
322
|
+
const lines = output.trim().split('\n');
|
|
323
|
+
const size = lines[0]?.match(/^(\d+) (\d+)$/);
|
|
324
|
+
if (!size)
|
|
325
|
+
return { screen: null, browser: null };
|
|
326
|
+
const screen = { width: Number(size[1]), height: Number(size[2]) };
|
|
327
|
+
let browser = null;
|
|
328
|
+
for (const block of lines.slice(1).join('\n').split(/^WINDOW=/m).slice(1)) {
|
|
329
|
+
const value = (name) => Number(block.match(new RegExp(`^${name}=(-?\\d+)$`, 'm'))?.[1] ?? NaN);
|
|
330
|
+
const window = { x: value('X'), y: value('Y'), width: value('WIDTH'), height: value('HEIGHT'), title: (block.match(/^NAME=(.*)$/m)?.[1] ?? '').slice(0, 200) };
|
|
331
|
+
if (![window.x, window.y, window.width, window.height].every(Number.isFinite) || window.width < 200 || window.height < 150)
|
|
332
|
+
continue;
|
|
333
|
+
if (!browser || window.width * window.height > browser.width * browser.height)
|
|
334
|
+
browser = window;
|
|
335
|
+
}
|
|
336
|
+
return { screen, browser };
|
|
337
|
+
}
|
|
338
|
+
/** "/usr/local/bin/node server.mjs" reads as "node server.mjs"; long arguments are cut. */
|
|
339
|
+
export function commandLine(argv) {
|
|
340
|
+
const [first = '', ...rest] = argv;
|
|
341
|
+
return [basename(first), ...rest].join(' ').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
342
|
+
}
|
|
343
|
+
/** Listening sockets with their processes (`ss`), else /proc/net/tcp{,6} on an image without it. */
|
|
344
|
+
const LISTENERS = 'command -v ss >/dev/null && exec ss -ltnp; exec cat /proc/net/tcp /proc/net/tcp6';
|
|
345
|
+
/**
|
|
346
|
+
* The computer's own listeners, not the agent's work: Codex Cloud's exec server and executor (staging: :43920 and
|
|
347
|
+
* :43922 in every Codex conversation), the guest agent and the desktop.
|
|
348
|
+
*/
|
|
349
|
+
const HARNESS_LISTENERS = new Set(['codex', 'sf-agent', 'sf-guest-agent', 'Xvnc']);
|
|
350
|
+
/** Listening TCP ports from `ss -ltnp` or /proc/net/tcp{,6}, without the harness's or the desktop viewer's own ports. */
|
|
351
|
+
export function listeningPorts(output) {
|
|
352
|
+
const ports = new Set();
|
|
353
|
+
for (const line of output.split('\n')) {
|
|
354
|
+
const fields = line.trim().split(/\s+/);
|
|
355
|
+
let port;
|
|
356
|
+
if (fields[0] === 'LISTEN') {
|
|
357
|
+
const owner = line.match(/users:\(\("([^"]+)"/)?.[1];
|
|
358
|
+
if (owner && HARNESS_LISTENERS.has(owner))
|
|
359
|
+
continue;
|
|
360
|
+
port = parseInt(fields[3]?.split(':').pop() ?? '', 10);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
if (fields[3] !== '0A')
|
|
364
|
+
continue;
|
|
365
|
+
port = parseInt(fields[1]?.split(':').pop() ?? '', 16);
|
|
366
|
+
}
|
|
367
|
+
if (port >= 1024 && port < 49152 && !(port >= 5900 && port < 6000) && port !== 6080)
|
|
368
|
+
ports.add(port);
|
|
369
|
+
}
|
|
370
|
+
return [...ports].sort((a, b) => a - b).slice(0, 4);
|
|
371
|
+
}
|