@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +214 -1
  2. package/README.md +215 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-return.d.ts +46 -2
  16. package/dist/agent-config-return.js +110 -4
  17. package/dist/agent-credentials.js +7 -3
  18. package/dist/agent-repository.d.ts +16 -3
  19. package/dist/agent-repository.js +149 -74
  20. package/dist/agent-setup.d.ts +14 -3
  21. package/dist/agent-setup.js +78 -36
  22. package/dist/agent-target-commands.js +24 -13
  23. package/dist/args.d.ts +1 -1
  24. package/dist/args.js +94 -54
  25. package/dist/bin.js +5 -1
  26. package/dist/browser.d.ts +13 -2
  27. package/dist/browser.js +30 -14
  28. package/dist/claude-cloud-login.d.ts +24 -2
  29. package/dist/claude-cloud-login.js +151 -11
  30. package/dist/cli-proxy-forward.d.ts +2 -0
  31. package/dist/cli-proxy-forward.js +9 -2
  32. package/dist/cli-proxy-hub.d.ts +69 -25
  33. package/dist/cli-proxy-hub.js +231 -54
  34. package/dist/cli-proxy.d.ts +70 -1
  35. package/dist/cli-proxy.js +298 -32
  36. package/dist/client-settings.d.ts +39 -0
  37. package/dist/client-settings.js +91 -0
  38. package/dist/codex-cloud.d.ts +5 -0
  39. package/dist/codex-cloud.js +88 -17
  40. package/dist/command-center-actions.js +5 -1
  41. package/dist/commands.js +171 -22
  42. package/dist/conversation-commands.d.ts +5 -0
  43. package/dist/conversation-commands.js +8 -5
  44. package/dist/conversation-terminal.d.ts +10 -2
  45. package/dist/conversation-terminal.js +77 -13
  46. package/dist/conversations.d.ts +73 -1
  47. package/dist/conversations.js +257 -23
  48. package/dist/device-bridge.d.ts +1 -1
  49. package/dist/device-bridge.js +6 -4
  50. package/dist/device-helper.js +43 -22
  51. package/dist/errors.d.ts +27 -1
  52. package/dist/errors.js +45 -5
  53. package/dist/filesystem-commands.d.ts +16 -9
  54. package/dist/filesystem-commands.js +108 -54
  55. package/dist/format.d.ts +2 -1
  56. package/dist/format.js +10 -1
  57. package/dist/handlers.js +3 -1
  58. package/dist/initial-prompt.d.ts +8 -0
  59. package/dist/initial-prompt.js +43 -1
  60. package/dist/large-media.d.ts +14 -0
  61. package/dist/large-media.js +100 -0
  62. package/dist/linux-terminal.d.ts +5 -3
  63. package/dist/linux-terminal.js +6 -20
  64. package/dist/local-sessions.d.ts +26 -2
  65. package/dist/local-sessions.js +113 -24
  66. package/dist/machine-setup.d.ts +11 -1
  67. package/dist/machine-setup.js +69 -22
  68. package/dist/machine-status.d.ts +67 -0
  69. package/dist/machine-status.js +140 -0
  70. package/dist/main.js +11 -13
  71. package/dist/move-worktrees-restore.py +144 -0
  72. package/dist/move-worktrees.d.ts +63 -0
  73. package/dist/move-worktrees.js +201 -0
  74. package/dist/move.d.ts +10 -1
  75. package/dist/move.js +116 -16
  76. package/dist/onboarding.d.ts +11 -7
  77. package/dist/onboarding.js +59 -27
  78. package/dist/prepared-repository.d.ts +202 -0
  79. package/dist/prepared-repository.js +860 -0
  80. package/dist/prepared-repository.py +1306 -0
  81. package/dist/project-preparation.d.ts +48 -0
  82. package/dist/project-preparation.js +304 -0
  83. package/dist/project-sync-manifest.d.ts +4 -0
  84. package/dist/project-sync-manifest.js +5 -1
  85. package/dist/project-sync-transfer.js +1 -1
  86. package/dist/project-sync.d.ts +19 -4
  87. package/dist/project-sync.js +31 -8
  88. package/dist/project-template-setup.d.ts +33 -1
  89. package/dist/project-template-setup.js +59 -2
  90. package/dist/secret-ask.d.ts +72 -0
  91. package/dist/secret-ask.js +351 -0
  92. package/dist/seed-phases.d.ts +8 -1
  93. package/dist/seed-phases.js +17 -2
  94. package/dist/setup-cache.d.ts +7 -0
  95. package/dist/setup-cache.js +32 -0
  96. package/dist/t3-launch.js +1 -4
  97. package/dist/t3-mode.d.ts +8 -2
  98. package/dist/t3-mode.js +19 -4
  99. package/dist/t3-phone.d.ts +4 -2
  100. package/dist/t3-phone.js +12 -4
  101. package/dist/terminal-apps.d.ts +157 -0
  102. package/dist/terminal-apps.js +561 -0
  103. package/dist/terminal-onboarding.js +6 -1
  104. package/dist/this-computer.d.ts +11 -0
  105. package/dist/this-computer.js +36 -0
  106. package/dist/thread-on-computer.js +4 -1
  107. package/dist/update.js +11 -3
  108. package/dist/worktree-twin.d.ts +10 -2
  109. package/dist/worktree-twin.js +27 -9
  110. 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
+ }
@@ -1,7 +1,8 @@
1
1
  import type { Ctx } from './commands.js';
2
2
  /**
3
- * The ACP terminal sign-in method. An editor (T3 Code) runs `shard acp --login` in its own terminal when the user
4
- * chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
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 Mac's Codex login, else an email), its API key, the Codex login copy and the
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 (T3 Code) runs `shard acp --login` in its own terminal when the user
10
- * chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
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 Mac's Codex login, else an email), its API key, the Codex login copy and the
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, 'Signing in with the Codex login on this Mac…\n');
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, 'Codex: connected with the login on this Mac.\n');
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');
@@ -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 { savedMachine } from "./filesystem-state.js";
4
- import { syncAgentConfig } from "./agent-config-sync.js";
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 { recordSeedSnapshot } from "./files-mirror.js";
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: ['.claude/settings.json', '.claude/settings.local.json', '.codex/config.toml', '.mcp.json'], onInitialFiles: initialReady });
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
- const result = await ws.cell().exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
150
- if (result.exitCode !== 0)
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 () => { await initialFiles; return discoverAcpCatalog(ctx, ws, c.worktree_path); })();
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
- result = objectValue(await session.transport.rpc.request(method, mapped, method === 'session/prompt' ? 0 : 120_000));
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
- return session.connecting;
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
- return session.connecting;
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
- await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
348
- if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
349
- throw new RpcError(-32000, 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
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
- await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
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
- const launched = await cell.exec.run(['sf-agent', 'acp', app, '--conversation', c.id, '--detach'], { cwd: c.worktree_path, timeoutMs: 30_000, signal: ctx.signal, maxOutputBytes: 1 << 20 });
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
- c = await getConversation(ctx, c.id);
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(await cell.files.readText(`/home/user/.local/share/shardflux/acp/${c.id}/status.json`, { length: 8192 }));
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}`;
@@ -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+). */