@shardflux/cli 0.20.0 → 0.22.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 +214 -1
- package/README.md +215 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-login.d.ts +4 -3
- package/dist/acp-login.js +7 -5
- package/dist/acp-models.js +20 -15
- package/dist/acp.js +66 -13
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +86 -32
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.js +24 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +94 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-forward.d.ts +2 -0
- package/dist/cli-proxy-forward.js +9 -2
- package/dist/cli-proxy-hub.d.ts +69 -25
- package/dist/cli-proxy-hub.js +231 -54
- package/dist/cli-proxy.d.ts +70 -1
- package/dist/cli-proxy.js +298 -32
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +5 -0
- package/dist/codex-cloud.js +88 -17
- package/dist/command-center-actions.js +5 -1
- package/dist/commands.js +171 -22
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +77 -13
- package/dist/conversations.d.ts +73 -1
- package/dist/conversations.js +257 -23
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +27 -1
- package/dist/errors.js +45 -5
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +108 -54
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +63 -0
- package/dist/move-worktrees.js +201 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +116 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +59 -27
- package/dist/prepared-repository.d.ts +202 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1306 -0
- package/dist/project-preparation.d.ts +48 -0
- package/dist/project-preparation.js +304 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +19 -4
- package/dist/project-sync.js +31 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.js +1 -4
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/terminal-apps.d.ts +157 -0
- package/dist/terminal-apps.js +561 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.js +11 -3
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which organization and project an account command acts in: --org/--project (id or slug), else the saved default
|
|
3
|
+
* (`shard orgs use`, `shard projects use`, `shard setup`), else the only one there is; with several the usage error
|
|
4
|
+
* lists them. Also the checks that say plainly when the saved default or the API key belongs to an organization the
|
|
5
|
+
* person is no longer in, with the command that fixes it (0.20.2+), and the project a T3 Connect sign-in is scoped by.
|
|
6
|
+
*/
|
|
7
|
+
import { Shardflux, ShardfluxApiError } from '@shardflux/sdk';
|
|
8
|
+
import { UUID } from "./args.js";
|
|
9
|
+
import { AuthConfigError, NotFoundError, UsageError } from "./errors.js";
|
|
10
|
+
/** commands.ts's `str`, here so this module does not import commands.ts (which imports it). */
|
|
11
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
12
|
+
export async function allOrgs(account) {
|
|
13
|
+
const rows = [];
|
|
14
|
+
for await (const o of account.organizations.listAll())
|
|
15
|
+
rows.push(o);
|
|
16
|
+
return rows;
|
|
17
|
+
}
|
|
18
|
+
export async function allProjects(account, organizationId) {
|
|
19
|
+
const rows = [];
|
|
20
|
+
for await (const p of account.projects.listAll(organizationId))
|
|
21
|
+
rows.push(p);
|
|
22
|
+
return rows;
|
|
23
|
+
}
|
|
24
|
+
export const choices = (rows) => rows.map((r) => ` ${r.id} ${r.slug} ${r.name}`).join('\n');
|
|
25
|
+
export function byRef(rows, ref) {
|
|
26
|
+
const exact = rows.find((r) => r.id === ref || r.slug === ref);
|
|
27
|
+
if (exact)
|
|
28
|
+
return exact;
|
|
29
|
+
const named = rows.filter((r) => r.name.toLowerCase() === ref.toLowerCase());
|
|
30
|
+
return named.length === 1 ? named[0] : undefined;
|
|
31
|
+
}
|
|
32
|
+
export const gone = (err) => err instanceof ShardfluxApiError && (err.status === 404 || err.status === 403);
|
|
33
|
+
export async function findOrg(account, ref) {
|
|
34
|
+
if (UUID.test(ref)) {
|
|
35
|
+
try {
|
|
36
|
+
return await account.organizations.get(ref);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (!gone(err))
|
|
40
|
+
throw err;
|
|
41
|
+
throw new NotFoundError(`Organization ${ref} is not one of yours.`, 'list yours: shard orgs list');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const hit = byRef(await allOrgs(account), ref);
|
|
45
|
+
if (!hit)
|
|
46
|
+
throw new NotFoundError(`No organization "${ref}" among yours.`, 'list them: shard orgs list');
|
|
47
|
+
return hit;
|
|
48
|
+
}
|
|
49
|
+
/** `<slug>` of the only organization, else the placeholder: the fix commands below are exact when they can be. */
|
|
50
|
+
const orgArg = (orgs) => (orgs.length === 1 ? orgs[0].slug : '<id|slug>');
|
|
51
|
+
/** The fix for a saved default organization the person is no longer in. */
|
|
52
|
+
export function staleDefaultOrg(saved, orgs) {
|
|
53
|
+
const list = orgs.length ? `\nYour organizations:\n${choices(orgs)}\n` : '\n';
|
|
54
|
+
return new UsageError(`Your saved default organization ${saved} is no longer one of yours (it is not in shard orgs list).${list}`, orgs.length
|
|
55
|
+
? `save another: shard orgs use ${orgArg(orgs)} (with a new API key for it: shard setup --org ${orgArg(orgs)} --new-key)`
|
|
56
|
+
: 'create one with a project and an API key: shard setup');
|
|
57
|
+
}
|
|
58
|
+
/** `ref` (a positional), else --org, else the saved default, else the only organization. */
|
|
59
|
+
export async function resolveOrg(ctx, account, values, ref) {
|
|
60
|
+
const given = ref ?? str(values.org);
|
|
61
|
+
if (given !== undefined)
|
|
62
|
+
return findOrg(account, given);
|
|
63
|
+
const saved = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
64
|
+
if (saved) {
|
|
65
|
+
try {
|
|
66
|
+
return await account.organizations.get(saved);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (gone(err))
|
|
70
|
+
throw staleDefaultOrg(saved, await allOrgs(account));
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const orgs = await allOrgs(account);
|
|
75
|
+
if (orgs.length === 1)
|
|
76
|
+
return orgs[0];
|
|
77
|
+
if (orgs.length === 0)
|
|
78
|
+
throw new UsageError('You have no organization yet.', 'create one with a project and an API key: shard setup (or shard orgs create <name>)');
|
|
79
|
+
throw new UsageError(`You belong to ${orgs.length} organizations; pick one with --org <id|slug>:\n${choices(orgs)}\n`, 'or save a default: shard orgs use <id|slug>');
|
|
80
|
+
}
|
|
81
|
+
/** `ref` (a positional), else --project, else the saved default (unless --org names another), else the only project. */
|
|
82
|
+
export async function resolveProject(ctx, account, values, ref) {
|
|
83
|
+
const given = ref ?? str(values.project);
|
|
84
|
+
const orgFlag = str(values.org);
|
|
85
|
+
if (given !== undefined && UUID.test(given)) {
|
|
86
|
+
const p = await account.projects.get(given);
|
|
87
|
+
if (orgFlag !== undefined) {
|
|
88
|
+
const org = await findOrg(account, orgFlag);
|
|
89
|
+
if (p.organization_id !== org.id)
|
|
90
|
+
throw new UsageError(`project ${given} is not in organization ${org.slug}`);
|
|
91
|
+
}
|
|
92
|
+
return p;
|
|
93
|
+
}
|
|
94
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
95
|
+
if (given === undefined && profile.project_id && orgFlag === undefined) {
|
|
96
|
+
try {
|
|
97
|
+
return await account.projects.get(profile.project_id);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (!gone(err))
|
|
101
|
+
throw err;
|
|
102
|
+
const orgs = await allOrgs(account);
|
|
103
|
+
// The whole saved default is from an organization this person left: say that, not only "the project".
|
|
104
|
+
if (profile.organization_id && !orgs.some((o) => o.id === profile.organization_id))
|
|
105
|
+
throw staleDefaultOrg(profile.organization_id, orgs);
|
|
106
|
+
throw new NotFoundError(`The default project ${profile.project_id} is no longer available to you.`, 'pick another: shard projects use <project> (list: shard projects list)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const org = await resolveOrg(ctx, account, values);
|
|
110
|
+
const projects = await allProjects(account, org.id);
|
|
111
|
+
if (given !== undefined) {
|
|
112
|
+
const hit = byRef(projects, given);
|
|
113
|
+
if (!hit)
|
|
114
|
+
throw new NotFoundError(`No project "${given}" in ${org.name} (${org.slug}).`, 'list them: shard projects list');
|
|
115
|
+
return hit;
|
|
116
|
+
}
|
|
117
|
+
const saved = profile.project_id ? projects.find((p) => p.id === profile.project_id) : undefined;
|
|
118
|
+
if (saved)
|
|
119
|
+
return saved;
|
|
120
|
+
if (projects.length === 1)
|
|
121
|
+
return projects[0];
|
|
122
|
+
if (projects.length === 0)
|
|
123
|
+
throw new UsageError(`${org.name} (${org.slug}) has no project yet.`, 'create one: shard projects create <name> (or shard setup)');
|
|
124
|
+
throw new UsageError(`${org.name} has ${projects.length} projects; pick one with --project <id|slug>:\n${choices(projects)}\n`, 'or save a default: shard projects use <id|slug>');
|
|
125
|
+
}
|
|
126
|
+
export async function keyScope(ctx) {
|
|
127
|
+
let token;
|
|
128
|
+
let source;
|
|
129
|
+
try {
|
|
130
|
+
({ token, source } = ctx.apiKey());
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err instanceof AuthConfigError)
|
|
134
|
+
return null;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const me = await new Shardflux({ ...ctx.accountOptions(), apiKey: token }).me();
|
|
139
|
+
if (me.type !== 'api_key' || !me.api_key)
|
|
140
|
+
return null;
|
|
141
|
+
return { keyId: token.split('_')[1] ?? '', source, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// A rejected or unreadable key is reported where it is used; here it only means "no key to compare".
|
|
145
|
+
if (err instanceof ShardfluxApiError || (err instanceof Error && /apiKey must be/.test(err.message)))
|
|
146
|
+
return null;
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const keyName = (k) => (k.source === 'env' ? `The API key in SHARDFLUX_API_KEY (${k.keyId})` : `The saved API key ${k.keyId}`);
|
|
151
|
+
/**
|
|
152
|
+
* Plain warnings about defaults that point into an organization the signed-in person is no longer in: the saved
|
|
153
|
+
* default organization, and the API key's organization. Each names the command that fixes it.
|
|
154
|
+
*/
|
|
155
|
+
export function staleScopeWarnings(ctx, orgs, key) {
|
|
156
|
+
const out = [];
|
|
157
|
+
const mine = (id) => orgs.some((o) => o.id === id);
|
|
158
|
+
const arg = orgArg(orgs);
|
|
159
|
+
const saved = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
160
|
+
if (saved && !mine(saved)) {
|
|
161
|
+
out.push({ code: 'default_organization_gone', message: `Your saved default organization ${saved} is no longer one of yours (it is not in shard orgs list).`, fix: `shard orgs use ${arg}` });
|
|
162
|
+
}
|
|
163
|
+
const target = orgs.find((o) => o.id === saved);
|
|
164
|
+
const warning = key ? keyOrganizationWarning(key, orgs, target) : null;
|
|
165
|
+
if (warning)
|
|
166
|
+
out.push(warning);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/** Compare membership before defaults: a departed organization is different from another one still accessible. */
|
|
170
|
+
export function keyOrganizationWarning(key, orgs, target, projectId) {
|
|
171
|
+
const departed = !orgs.some((o) => o.id === key.organizationId);
|
|
172
|
+
if (!departed && (!target || key.organizationId === target.id))
|
|
173
|
+
return null;
|
|
174
|
+
const arg = target?.slug ?? orgArg(orgs);
|
|
175
|
+
const command = target || orgs.length ? `shard setup --org ${arg}${projectId ? ` --project ${projectId}` : ''} --new-key` : 'shard setup --new-key';
|
|
176
|
+
return {
|
|
177
|
+
code: departed ? 'api_key_organization_gone' : 'api_key_in_other_organization',
|
|
178
|
+
message: departed
|
|
179
|
+
? `${keyName(key)} belongs to organization ${key.organizationId}, which you are no longer in; commands that use the key still act there.`
|
|
180
|
+
: keyElsewhere(key, target),
|
|
181
|
+
fix: `${command}${key.source === 'env' ? ', then unset SHARDFLUX_API_KEY' : ''}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The stale-default warnings for the signed-in person, or none: without a session (or with one the API no longer
|
|
186
|
+
* accepts) there is no list of their organizations to compare with, and the command itself goes on either way.
|
|
187
|
+
*/
|
|
188
|
+
export async function sessionScopeWarnings(ctx, key) {
|
|
189
|
+
if (ctx.sessionSource() === null)
|
|
190
|
+
return [];
|
|
191
|
+
let orgs;
|
|
192
|
+
try {
|
|
193
|
+
orgs = await allOrgs(ctx.account());
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (err instanceof ShardfluxApiError || err instanceof AuthConfigError)
|
|
197
|
+
return [];
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
return staleScopeWarnings(ctx, orgs, key);
|
|
201
|
+
}
|
|
202
|
+
/** Warnings on stderr, one `warning:` line and its `fix:` each (text mode). */
|
|
203
|
+
export function writeWarnings(ctx, warnings) {
|
|
204
|
+
if (ctx.json)
|
|
205
|
+
return;
|
|
206
|
+
for (const w of warnings)
|
|
207
|
+
ctx.io.stderr.write(`warning: ${w.message}\nfix: ${w.fix}\n`);
|
|
208
|
+
}
|
|
209
|
+
/** The note for a default just saved in `org` while the API key acts in another organization (orgs use, projects use). */
|
|
210
|
+
export function keyElsewhere(key, org) {
|
|
211
|
+
return `${keyName(key)} belongs to organization ${key.organizationId}, not ${org.name} (${org.slug}): workspace, agent and usage commands use the key, so they still act there.`;
|
|
212
|
+
}
|
|
213
|
+
// ---- T3 Connect --------------------------------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* The project a T3 Connect sign-in is scoped by (0.20.2+). A sign-in is per person and organization, and a session in
|
|
216
|
+
* more than one organization must name one (the API answers 422 without `project_id`). --project, else a project of
|
|
217
|
+
* --org (the saved default or the key's project when it is in that organization, else its first), else the API key's
|
|
218
|
+
* project (T3 links the key's workspaces with the sign-in of the key's organization, and the onboarding row checks it
|
|
219
|
+
* with the key), else the saved default project. Undefined when none is known: one organization needs none.
|
|
220
|
+
*/
|
|
221
|
+
export async function t3SignInProject(ctx, values) {
|
|
222
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
223
|
+
const project = str(values.project);
|
|
224
|
+
if (project !== undefined && UUID.test(project) && str(values.org) === undefined)
|
|
225
|
+
return project;
|
|
226
|
+
if (project !== undefined)
|
|
227
|
+
return (await resolveProject(ctx, ctx.account(), values)).id;
|
|
228
|
+
if (str(values.org) !== undefined) {
|
|
229
|
+
const account = ctx.account();
|
|
230
|
+
const org = await resolveOrg(ctx, account, values);
|
|
231
|
+
const projects = await allProjects(account, org.id);
|
|
232
|
+
const pick = projects.find((p) => p.id === profile.project_id) ?? projects.find((p) => p.id === profile.api_key?.project_id) ?? projects[0];
|
|
233
|
+
if (!pick)
|
|
234
|
+
throw new UsageError(`${org.name} (${org.slug}) has no project yet.`, `create one: shard projects create <name> --org ${org.slug}`);
|
|
235
|
+
return pick.id;
|
|
236
|
+
}
|
|
237
|
+
// The saved key's project is in the credentials file; a key in SHARDFLUX_API_KEY (which wins) is asked where it acts.
|
|
238
|
+
const key = ctx.io.env.SHARDFLUX_API_KEY?.trim() ? (await keyScope(ctx))?.projectId : profile.api_key?.project_id;
|
|
239
|
+
return key ?? profile.project_id;
|
|
240
|
+
}
|
|
241
|
+
/** The API's 422 for a session in several organizations without a project (production before train 22): what to run. */
|
|
242
|
+
export function t3NeedsProject(err) {
|
|
243
|
+
if (!(err instanceof ShardfluxApiError) || err.code !== 'validation_failed' || err.details?.field !== 'project_id')
|
|
244
|
+
return null;
|
|
245
|
+
return new UsageError('You belong to more than one organization, and T3 Connect signs you in for one of them.', 'name it: --project <id|slug> (or --org <id|slug>), or save a default: shard projects use <id|slug>');
|
|
246
|
+
}
|
package/dist/acp-login.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Ctx } from './commands.js';
|
|
2
2
|
/**
|
|
3
|
-
* The ACP terminal sign-in method. An editor
|
|
4
|
-
*
|
|
3
|
+
* The ACP terminal sign-in method. An editor runs it in its own terminal when the user chooses it, then reconnects:
|
|
4
|
+
* T3 Code appends these args to the configured `shard acp` (`shard acp --login`); ACP registry clients run the agent
|
|
5
|
+
* with them in place of `acp` (`shard --login`). Both start the same flow.
|
|
5
6
|
*/
|
|
6
7
|
export declare const ACP_LOGIN_METHOD: {
|
|
7
8
|
readonly id: "shardflux-login";
|
|
@@ -14,7 +15,7 @@ export declare const ACP_LOGIN_METHOD: {
|
|
|
14
15
|
export declare function acpSignedIn(ctx: Ctx): boolean;
|
|
15
16
|
/**
|
|
16
17
|
* `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
|
|
17
|
-
* account (signed up or in with this
|
|
18
|
+
* account (signed up or in with this computer's Codex login, else an email), its API key, the Codex login copy and the
|
|
18
19
|
* cloud Claude sign-in.
|
|
19
20
|
*/
|
|
20
21
|
export declare function runAcpLogin(ctx: Ctx): Promise<number>;
|
package/dist/acp-login.js
CHANGED
|
@@ -5,9 +5,11 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { describeError } from "./errors.js";
|
|
6
6
|
import { cloudClaudeLogin, loginClaudeInWorkspace } from "./claude-cloud-login.js";
|
|
7
7
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
8
|
+
import { thisComputer } from "./this-computer.js";
|
|
8
9
|
/**
|
|
9
|
-
* The ACP terminal sign-in method. An editor
|
|
10
|
-
*
|
|
10
|
+
* The ACP terminal sign-in method. An editor runs it in its own terminal when the user chooses it, then reconnects:
|
|
11
|
+
* T3 Code appends these args to the configured `shard acp` (`shard acp --login`); ACP registry clients run the agent
|
|
12
|
+
* with them in place of `acp` (`shard --login`). Both start the same flow.
|
|
11
13
|
*/
|
|
12
14
|
export const ACP_LOGIN_METHOD = {
|
|
13
15
|
id: 'shardflux-login',
|
|
@@ -44,7 +46,7 @@ async function accountReady(ctx) {
|
|
|
44
46
|
const say = (ctx, text) => { ctx.io.stderr.write(text); };
|
|
45
47
|
/**
|
|
46
48
|
* `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
|
|
47
|
-
* account (signed up or in with this
|
|
49
|
+
* account (signed up or in with this computer's Codex login, else an email), its API key, the Codex login copy and the
|
|
48
50
|
* cloud Claude sign-in.
|
|
49
51
|
*/
|
|
50
52
|
export async function runAcpLogin(ctx) {
|
|
@@ -63,7 +65,7 @@ export async function runAcpLogin(ctx) {
|
|
|
63
65
|
await setup();
|
|
64
66
|
}
|
|
65
67
|
else if (codex) {
|
|
66
|
-
say(ctx,
|
|
68
|
+
say(ctx, `Signing in with the Codex login on ${thisComputer()}…\n`);
|
|
67
69
|
await HANDLERS.signup(quiet, { setup: false }, []);
|
|
68
70
|
await setup();
|
|
69
71
|
}
|
|
@@ -95,7 +97,7 @@ export async function runAcpLogin(ctx) {
|
|
|
95
97
|
say(ctx, 'Already signed in.\n');
|
|
96
98
|
if (codex) {
|
|
97
99
|
await syncAgentCredentials(ctx.cloud(), { home, redact: (value) => ctx.addSecret(value) });
|
|
98
|
-
say(ctx,
|
|
100
|
+
say(ctx, `Codex: connected with the login on ${thisComputer()}.\n`);
|
|
99
101
|
}
|
|
100
102
|
if (await cloudClaudeLogin(ctx))
|
|
101
103
|
say(ctx, 'Claude Code: connected.\n');
|
package/dist/acp-models.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { projectIdentity } from "./project-identity.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { seedAgentRepository } from "./agent-repository.js";
|
|
6
|
-
import { createConversationRecord, conversationWorkspace } from "./conversations.js";
|
|
5
|
+
import { localChanges, seedAgentRepository } from "./agent-repository.js";
|
|
6
|
+
import { checkoutConversationBranch, createConversationRecord, conversationWorkspace } from "./conversations.js";
|
|
7
7
|
import { AgentStateStore } from "./agent-state.js";
|
|
8
8
|
import { objectValue } from "./acp-rpc.js";
|
|
9
9
|
import { nativeCurrentModel, nativeModelOptions } from "./acp-catalog.js";
|
|
10
|
-
import {
|
|
11
|
-
import { sendTwinSeed, twinCheckoutArgv } from "./worktree-twin.js";
|
|
10
|
+
import { sendTwinSeed } from "./worktree-twin.js";
|
|
12
11
|
/** No prompts, login, logout or credential writes. Every child process group is
|
|
13
12
|
* terminated, including initialization failures and interrupted clients. */
|
|
14
13
|
export const ACP_MODELS_SCRIPT = String.raw `
|
|
@@ -131,29 +130,35 @@ export async function discoverAcpCatalog(ctx, ws, root) {
|
|
|
131
130
|
throw new Error('The guest adapters returned no available models. Check your cloud agent sign-ins.');
|
|
132
131
|
return catalog;
|
|
133
132
|
}
|
|
133
|
+
/** The project files the adapters read for their settings and MCP servers. */
|
|
134
|
+
const ACP_INITIAL_FILES = ['.claude/settings.json', '.claude/settings.local.json', '.codex/config.toml', '.mcp.json'];
|
|
134
135
|
export const ACP_LIFECYCLE = { async prepare(ctx, folder, name, callbacks) {
|
|
135
136
|
let allocation;
|
|
136
137
|
const c = await createConversationRecord(ctx, folder, 'claude', 'acp', name, view => { allocation = view; }, `acp-session-${name}`);
|
|
137
138
|
callbacks.allocated(c, allocation);
|
|
139
|
+
// This computer's changes are read while the VM starts (0.21.1+). Its agent config follows the first prompt (acp.ts).
|
|
140
|
+
const changes = localChanges(folder, ctx.signal);
|
|
141
|
+
changes.catch(() => undefined);
|
|
138
142
|
const ws = await conversationWorkspace(ctx, c);
|
|
139
143
|
callbacks.ready(ws);
|
|
140
|
-
if (!(await savedMachine(ctx))?.template)
|
|
141
|
-
await syncAgentConfig(ctx, ws);
|
|
142
144
|
await ws.cell().files.mkdir(c.worktree_path, { parents: true });
|
|
145
|
+
const background = [];
|
|
143
146
|
let initialReady, initialFailed;
|
|
144
147
|
const initialFiles = new Promise((resolve, reject) => { initialReady = resolve; initialFailed = reject; });
|
|
148
|
+
// The adapters read the project's own agent settings when it has any: discovery then waits for those files. A
|
|
149
|
+
// project without them is discovered while it seeds (0.21.1+): the catalog depends on the account, not the files.
|
|
150
|
+
const projectSettings = ACP_INITIAL_FILES.some(path => existsSync(join(folder, path)));
|
|
145
151
|
const seeded = (async () => {
|
|
146
|
-
await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true,
|
|
147
|
-
initialFiles:
|
|
152
|
+
await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true, changes, background: started => background.push(started),
|
|
153
|
+
initialFiles: ACP_INITIAL_FILES, onInitialFiles: initialReady });
|
|
148
154
|
await sendTwinSeed(ctx.store.dir, ws.cell(), c, folder, ctx.signal);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
|
|
152
|
-
await recordSeedSnapshot(ws, c, ctx.signal);
|
|
155
|
+
await checkoutConversationBranch(ctx, ws.cell(), c);
|
|
156
|
+
await Promise.all(background);
|
|
153
157
|
callbacks.prepared();
|
|
154
158
|
})();
|
|
155
159
|
void seeded.catch(initialFailed);
|
|
156
|
-
const models = (async () => {
|
|
160
|
+
const models = (async () => { if (projectSettings)
|
|
161
|
+
await initialFiles; return discoverAcpCatalog(ctx, ws, c.worktree_path); })();
|
|
157
162
|
// Observe both failures immediately. Never start a second VM, and never delete
|
|
158
163
|
// the user's conversation VM as catalog cleanup.
|
|
159
164
|
const results = await Promise.allSettled([seeded, models]);
|
package/dist/acp.js
CHANGED
|
@@ -8,12 +8,16 @@ import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
|
|
|
8
8
|
import { AcpJournal } from "./acp-journal.js";
|
|
9
9
|
import { getConversation, conversationWorkspace, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
|
|
10
10
|
import { projectIdentity } from "./project-identity.js";
|
|
11
|
+
import { projectSyncBoundary } from "./project-sync.js";
|
|
12
|
+
import { localManifest } from "./project-sync-manifest.js";
|
|
11
13
|
import { describeError, ownHint } from "./errors.js";
|
|
12
14
|
import { serveAcpDeviceApprovals } from "./device-bridge.js";
|
|
13
15
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
14
16
|
import { cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
15
17
|
import { ACP_LOGIN_METHOD, acpSignedIn } from "./acp-login.js";
|
|
16
18
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
19
|
+
import { syncAgentConfigAfterStart } from "./agent-config-sync.js";
|
|
20
|
+
import { savedMachine } from "./filesystem-state.js";
|
|
17
21
|
const PREFIX = 'shardflux:';
|
|
18
22
|
export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
|
|
19
23
|
export function parseAcpModel(value) {
|
|
@@ -196,11 +200,16 @@ export class ShardfluxAcp {
|
|
|
196
200
|
}
|
|
197
201
|
if (!record.model)
|
|
198
202
|
throw new RpcError(-32602, 'Choose an available model before sending a message.');
|
|
199
|
-
await this.#connect(session);
|
|
203
|
+
const started = await this.#connect(session);
|
|
200
204
|
if (method === 'session/load' || method === 'session/resume')
|
|
201
205
|
return this.#configuration(record.model, { ...record.setup, sessionId: record.id }, record.catalog);
|
|
202
206
|
const mapped = acpReferences(params, record, session.conversation.worktree_path, true);
|
|
207
|
+
const first = method === 'session/prompt' && !record.prompted;
|
|
203
208
|
if (method === 'session/prompt') {
|
|
209
|
+
// A connection this prompt started has just reconciled the project files, and settled them unless that pass
|
|
210
|
+
// moved some (0.21.1+).
|
|
211
|
+
if (!started || !session.files.settled())
|
|
212
|
+
await session.files.reconcile();
|
|
204
213
|
record.prompted = true;
|
|
205
214
|
record.updatedAt = new Date().toISOString();
|
|
206
215
|
this.#save(record);
|
|
@@ -209,13 +218,23 @@ export class ShardfluxAcp {
|
|
|
209
218
|
return session.transport.rpc.notify(method, mapped);
|
|
210
219
|
let result;
|
|
211
220
|
try {
|
|
212
|
-
|
|
221
|
+
const answer = session.transport.rpc.request(method, mapped, method === 'session/prompt' ? 0 : 120_000);
|
|
222
|
+
// Without a saved machine this computer's agent config is copied in once the first prompt is on its way, never
|
|
223
|
+
// before it (0.21.1+), as for a terminal conversation.
|
|
224
|
+
if (first && session.ws && !(await savedMachine(this.#ctx).catch(() => undefined))?.template)
|
|
225
|
+
void syncAgentConfigAfterStart(this.#ctx, session.ws);
|
|
226
|
+
result = objectValue(await answer);
|
|
213
227
|
}
|
|
214
228
|
catch (e) {
|
|
215
229
|
if (method === 'session/prompt')
|
|
216
230
|
await this.#rejectModel(session, parseAcpModel(record.model).id, e);
|
|
217
231
|
throw e;
|
|
218
232
|
}
|
|
233
|
+
finally {
|
|
234
|
+
// T3 snapshots the local checkout as soon as the prompt response arrives.
|
|
235
|
+
if (method === 'session/prompt' && !this.#ctx.signal.aborted && !session.stop.signal.aborted)
|
|
236
|
+
await session.files.reconcile();
|
|
237
|
+
}
|
|
219
238
|
return this.#configuration(record.model, acpReferences(result, record, session.conversation.worktree_path), record.catalog);
|
|
220
239
|
}
|
|
221
240
|
#prepare(session) {
|
|
@@ -226,6 +245,12 @@ export class ShardfluxAcp {
|
|
|
226
245
|
// The native selected adapter must not wait for the other harness's model
|
|
227
246
|
// probe. Both are guest processes; only boot/repository preparation gates it.
|
|
228
247
|
session.prepared = new Promise(resolve => { prepared = resolve; });
|
|
248
|
+
// This computer's file hashes are read while the VM starts and seeds: the first prompt's reconcile then hashes only
|
|
249
|
+
// what changed since (0.21.1+). Best effort; the reconcile reads whatever is missing.
|
|
250
|
+
if (!session.hashes) {
|
|
251
|
+
session.hashes = new Map();
|
|
252
|
+
void localManifest(record.folder, ctx.signal, session.hashes).catch(() => undefined);
|
|
253
|
+
}
|
|
229
254
|
session.preparing = (async () => {
|
|
230
255
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
231
256
|
const catalog = await this.#lifecycle.prepare(ctx, record.folder, `acp-${record.id.slice(3).replaceAll('-', '')}`, {
|
|
@@ -329,25 +354,34 @@ export class ShardfluxAcp {
|
|
|
329
354
|
}
|
|
330
355
|
return session;
|
|
331
356
|
}
|
|
357
|
+
/** True when this call started the connection (and so reconciled the project files just now). */
|
|
332
358
|
async #connect(session) {
|
|
333
359
|
await session.prepared;
|
|
334
360
|
if (session.preparationError)
|
|
335
361
|
throw session.preparationError;
|
|
336
|
-
if (session.connecting)
|
|
337
|
-
|
|
362
|
+
if (session.connecting) {
|
|
363
|
+
await session.connecting;
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
338
366
|
if (session.transport && !session.transport.rpc.closed)
|
|
339
|
-
return;
|
|
367
|
+
return false;
|
|
340
368
|
session.connecting = this.#start(session).finally(() => { session.connecting = undefined; });
|
|
341
|
-
|
|
369
|
+
await session.connecting;
|
|
370
|
+
return true;
|
|
342
371
|
}
|
|
343
372
|
async #start(session) {
|
|
344
373
|
const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
|
|
345
374
|
record.app = app;
|
|
346
375
|
await session.transport?.close();
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
376
|
+
// The sign-in is checked beside reading the conversation and its VM; it passes before anything starts in the VM.
|
|
377
|
+
const signIn = (async () => {
|
|
378
|
+
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
379
|
+
if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
|
|
380
|
+
throw new RpcError(-32000, 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
|
|
381
|
+
})();
|
|
382
|
+
signIn.catch(() => undefined);
|
|
350
383
|
if (!record.conversationId) {
|
|
384
|
+
await signIn;
|
|
351
385
|
this.#prepare(session);
|
|
352
386
|
await session.prepared;
|
|
353
387
|
if (session.preparationError)
|
|
@@ -361,10 +395,26 @@ export class ShardfluxAcp {
|
|
|
361
395
|
session.ws = await conversationWorkspace(ctx, session.conversation);
|
|
362
396
|
rememberConversation(ctx, session.conversation, record.folder);
|
|
363
397
|
}
|
|
398
|
+
await signIn;
|
|
364
399
|
let c = session.conversation;
|
|
365
400
|
const cell = session.ws.cell();
|
|
366
|
-
|
|
401
|
+
const menubar = ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
|
|
367
402
|
await restoreConversationBinding(ctx, c, session.ws);
|
|
403
|
+
// The project files are reconciled, device approvals served and the supervisor launched side by side; the native
|
|
404
|
+
// session starts once all three are done (0.21.1+).
|
|
405
|
+
const files = (async () => {
|
|
406
|
+
const boundary = await projectSyncBoundary({ ...ctx, signal: AbortSignal.any([ctx.signal, session.stop.signal]) }, session.ws, projectIdentity(record.folder), {
|
|
407
|
+
root: c.worktree_path, agent: record.id.replace(/^sf-/, 'acp-'), seed: false,
|
|
408
|
+
stateKey: `acp:${ctx.apiUrl}:${record.id}:${record.folder}`, ...(session.hashes ? { cache: session.hashes } : {}),
|
|
409
|
+
// Before its first prompt the VM holds what this computer seeded it with, so edits made here since are not conflicts.
|
|
410
|
+
vmBaseline: !record.prompted,
|
|
411
|
+
});
|
|
412
|
+
await boundary.reconcile();
|
|
413
|
+
return boundary;
|
|
414
|
+
})();
|
|
415
|
+
const launch = cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], { cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20 });
|
|
416
|
+
files.catch(() => undefined);
|
|
417
|
+
launch.catch(() => undefined);
|
|
368
418
|
session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
|
|
369
419
|
const result = objectValue(await this.#client.request('session/request_permission', { sessionId: record.id,
|
|
370
420
|
toolCall: { toolCallId: approval.request_id, title: approval.summary, kind: 'execute', status: 'pending' },
|
|
@@ -372,15 +422,18 @@ export class ShardfluxAcp {
|
|
|
372
422
|
const outcome = objectValue(result.outcome);
|
|
373
423
|
return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
|
|
374
424
|
});
|
|
375
|
-
|
|
425
|
+
session.files = await files;
|
|
426
|
+
const launched = await launch;
|
|
427
|
+
await menubar;
|
|
376
428
|
if (launched.exitCode !== 0)
|
|
377
429
|
throw new Error(`ACP supervisor could not attach: ${launched.stderr.trim()}`);
|
|
378
|
-
|
|
430
|
+
const [claimed, statusText] = await Promise.all([getConversation(ctx, c.id), cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 })]);
|
|
431
|
+
c = claimed;
|
|
379
432
|
if (c.harness !== app)
|
|
380
433
|
throw new Error('The guest did not claim this conversation harness.');
|
|
381
434
|
session.conversation = c;
|
|
382
435
|
rememberConversation(ctx, c, record.folder);
|
|
383
|
-
const status = JSON.parse(
|
|
436
|
+
const status = JSON.parse(statusText);
|
|
384
437
|
if (status.conversation_id !== c.id || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start || status.ended)
|
|
385
438
|
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
386
439
|
const owner = `${session.ws.id}:${status.pid}:${status.start}`;
|
package/dist/agent-commands.d.ts
CHANGED
|
@@ -7,13 +7,15 @@ import type { ModelChoice } from './agent-models.js';
|
|
|
7
7
|
export declare function disambiguatedSlug(slug: string, identity: string): string;
|
|
8
8
|
export declare function projectTemplate(ctx: Ctx, remote: string | null): Promise<string>;
|
|
9
9
|
/** `place` is the repository or folder name session titles end with ("todo-app"), `task` what a new session is titled
|
|
10
|
-
* with before the guest names it from its first prompt (a first prompt line or the conversation's title)
|
|
10
|
+
* with before the guest names it from its first prompt (a first prompt line or the conversation's title), and
|
|
11
|
+
* `launch.titlePrefix` the marker titles start with when this computer chose one ("" for none; 0.20.1+). */
|
|
11
12
|
export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, place?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean, conversation?: {
|
|
12
13
|
id: string;
|
|
13
14
|
session: string | null;
|
|
14
15
|
}, task?: string, launch?: {
|
|
15
16
|
choice?: ModelChoice;
|
|
16
17
|
help?: () => Promise<string>;
|
|
18
|
+
titlePrefix?: string;
|
|
17
19
|
}): Promise<PtySession>;
|
|
18
20
|
/** The first non-empty line of a prompt or title, bounded; the guest makes it the task part of the session title. */
|
|
19
21
|
export declare function sessionTask(text: string | undefined): string | undefined;
|
|
@@ -24,6 +26,7 @@ export declare function terminal(ctx: Ctx, ws: Workspace, app: 'claude' | 'codex
|
|
|
24
26
|
title?: string;
|
|
25
27
|
choice?: ModelChoice;
|
|
26
28
|
afterStart?: () => Promise<void> | undefined;
|
|
29
|
+
setupLabelled?: Promise<void>;
|
|
27
30
|
}): Promise<number>;
|
|
28
31
|
export declare const launchProject: Handler;
|
|
29
32
|
/** The project VM launch; with `--in t3` a first message becomes a thread on the T3 server in it (SSH mode, 0.17.0+). */
|