@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -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-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. 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,6 +1,18 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { StringDecoder } from 'node:string_decoder';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { ShardfluxApiError, ShardfluxProtocolError } from '@shardflux/sdk';
3
5
  import { AcpRpc } from "./acp-rpc.js";
6
+ import { reattachDelayMs } from "./agent-terminal.js";
7
+ const RECONNECT_BUDGET_MS = 120_000;
8
+ function retryableRead(error) {
9
+ if (error instanceof ShardfluxApiError)
10
+ return error.retryable || ['stale_epoch', 'token_expired', 'workspace_not_running', 'workspace_busy'].includes(error.code);
11
+ return error instanceof TypeError || error instanceof ShardfluxProtocolError && error.status === 0;
12
+ }
13
+ // An input this small (every RPC but a long prompt) travels in its command's start request: one request instead of a
14
+ // start, a stdin frame, an output stream and a cancel (0.23.0+).
15
+ const ONE_SHOT_INPUT = 192 << 10;
4
16
  // InputACP's private, same-uid socket receipt protocol. Stdin avoids Linux's 128 KiB per-argument limit.
5
17
  export const ACP_INPUT_SCRIPT = `import json, os, socket, sys
6
18
  payload = sys.stdin.buffer.readline((2 << 20) + 1024)
@@ -70,6 +82,19 @@ export class AcpJournal {
70
82
  async #deliver(inputId, message) {
71
83
  const dir = `/home/user/.local/share/shardflux/acp/${this.#conversation}`;
72
84
  const payload = Buffer.from(`{"mode":"input","input_id":${JSON.stringify(inputId)},"message":${message}}\n`);
85
+ if (payload.length <= ONE_SHOT_INPUT) {
86
+ const r = await this.#cell.exec.run(['python3', '-c', ACP_INPUT_SCRIPT, dir], { cwd: this.#cwd, stdin: payload, timeoutMs: 15_000, maxOutputBytes: 65536, signal: this.#signal });
87
+ let receipt;
88
+ try {
89
+ receipt = JSON.parse(r.stdout);
90
+ }
91
+ catch {
92
+ receipt = undefined;
93
+ }
94
+ if (r.exitCode !== 0 || receipt?.accepted !== true)
95
+ throw new Error('ACP delivery is unverified; load this session before continuing.');
96
+ return;
97
+ }
73
98
  const session = await this.#cell.exec.start({ argv: ['python3', '-c', ACP_INPUT_SCRIPT, dir], cwd: this.#cwd, stdin_open: true, timeout_ms: 15_000 }, this.#signal);
74
99
  let offset = 0, stdout = '';
75
100
  try {
@@ -167,17 +192,22 @@ export class AcpJournal {
167
192
  const signal = AbortSignal.any([this.#signal, reader.signal]);
168
193
  try {
169
194
  let failures = 0;
195
+ let outageStarted;
170
196
  while (this.#wanted() && !signal.aborted) {
171
197
  const decoder = new StringDecoder('utf8');
172
198
  let tail = '', offset = 0;
199
+ let interruption;
173
200
  try {
174
201
  const session = await this.#cell.exec.start({ argv: ['sf-agent', 'acp', 'attach', '--conversation', this.#conversation, '--from-seq', String(this.#cursor.from_seq), '--follow'], cwd: this.#cwd, timeout_ms: 0 }, signal);
175
202
  this.#follower = session.session_id;
176
203
  for await (const event of await this.#cell.exec.output(session.session_id, { follow: true, signal })) {
177
- if (event.type === 'error')
178
- throw new Error(event.error?.error.message ?? 'ACP journal failed');
204
+ if (event.type === 'error') {
205
+ if (event.error)
206
+ throw new ShardfluxApiError(502, event.error, 'cell');
207
+ throw new Error('ACP journal failed without an error envelope');
208
+ }
179
209
  if (event.type === 'exit')
180
- throw new Error('ACP journal detached');
210
+ throw new ShardfluxProtocolError('ACP journal detached', 0, 'cell');
181
211
  if (event.type !== 'output' || event.stream !== 'stdout' || !event.data)
182
212
  continue;
183
213
  const bytes = Buffer.from(event.data, 'base64'), at = event.offset ?? offset;
@@ -189,8 +219,12 @@ export class AcpJournal {
189
219
  for (let newline; (newline = tail.indexOf('\n')) >= 0;) {
190
220
  const line = tail.slice(0, newline);
191
221
  tail = tail.slice(newline + 1);
222
+ const before = this.#cursor.from_seq;
192
223
  await this.#entry(line);
193
- failures = 0;
224
+ if (this.#cursor.from_seq > before) {
225
+ failures = 0;
226
+ outageStarted = undefined;
227
+ }
194
228
  }
195
229
  if (Buffer.byteLength(tail) > 4 << 20)
196
230
  throw new Error('ACP journal frame exceeds 4 MiB');
@@ -198,17 +232,35 @@ export class AcpJournal {
198
232
  return;
199
233
  }
200
234
  if (!signal.aborted)
201
- throw new Error('ACP journal connection ended');
235
+ throw new ShardfluxProtocolError('ACP journal connection ended', 0, 'cell');
202
236
  }
203
237
  catch (error) {
204
238
  if (signal.aborted)
205
239
  break;
206
- if (++failures > 3)
240
+ if (!retryableRead(error))
207
241
  throw error;
242
+ outageStarted ??= performance.now();
243
+ if (performance.now() - outageStarted >= RECONNECT_BUDGET_MS)
244
+ throw new Error('ACP journal reconnect timed out; load this session to continue.', { cause: error });
245
+ failures++;
246
+ interruption = error;
208
247
  }
209
248
  finally {
210
249
  await this.#detach();
211
250
  }
251
+ // Retrying only the read follower cannot submit a second turn. Keep
252
+ // the original sequence/prompt cursor and stop waits on editor exit.
253
+ // A stopped reader (pause, idle) ends the wait as it ends an attach:
254
+ // the loop exits without closing the editor's connection.
255
+ if (this.#wanted() && !signal.aborted && interruption !== undefined) {
256
+ try {
257
+ await delay(Math.min(reattachDelayMs(interruption, failures), RECONNECT_BUDGET_MS - (performance.now() - outageStarted)), undefined, { signal });
258
+ }
259
+ catch (error) {
260
+ if (!signal.aborted)
261
+ throw error;
262
+ }
263
+ }
212
264
  }
213
265
  }
214
266
  catch (error) {
@@ -1,7 +1,9 @@
1
1
  import type { Ctx } from './commands.js';
2
+ import type { ImportResult, LocalCliProxy } from './cli-proxy.js';
2
3
  /**
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.
4
+ * The ACP terminal sign-in method. An editor runs it in its own terminal when the user chooses it, then reconnects:
5
+ * T3 Code appends these args to the configured `shard acp` (`shard acp --login`); ACP registry clients run the agent
6
+ * with them in place of `acp` (`shard --login`). Both start the same flow.
5
7
  */
6
8
  export declare const ACP_LOGIN_METHOD: {
7
9
  readonly id: "shardflux-login";
@@ -12,9 +14,15 @@ export declare const ACP_LOGIN_METHOD: {
12
14
  };
13
15
  /** Whether an API key is configured (no request; a revoked key surfaces as auth_required on its first call). */
14
16
  export declare function acpSignedIn(ctx: Ctx): boolean;
17
+ /** How the sign-in finds and moves this computer's CLIProxyAPI (tests replace both). */
18
+ export interface AcpLoginDeps {
19
+ detect?: (env: Record<string, string | undefined>) => Promise<LocalCliProxy | null>;
20
+ move?: (ctx: Ctx) => Promise<ImportResult>;
21
+ }
15
22
  /**
16
23
  * `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
- * cloud Claude sign-in.
24
+ * account (signed up or in with this computer's Codex login, else an email), its API key, the Codex login copy, the
25
+ * user's T3 Code proxy setup (a CLIProxyAPI on this computer, moved into the account when they say so) and the cloud
26
+ * Claude sign-in (not needed when Claude Code goes through that proxy with its own token).
19
27
  */
20
- export declare function runAcpLogin(ctx: Ctx): Promise<number>;
28
+ export declare function runAcpLogin(ctx: Ctx, deps?: AcpLoginDeps): Promise<number>;
package/dist/acp-login.js CHANGED
@@ -5,9 +5,13 @@ 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";
9
+ import { detectLocalCliProxy, importCliProxy } from "./cli-proxy.js";
10
+ import { accountVmProxy, readT3ProxySetup } from "./t3-proxy.js";
8
11
  /**
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.
12
+ * The ACP terminal sign-in method. An editor runs it in its own terminal when the user chooses it, then reconnects:
13
+ * T3 Code appends these args to the configured `shard acp` (`shard acp --login`); ACP registry clients run the agent
14
+ * with them in place of `acp` (`shard --login`). Both start the same flow.
11
15
  */
12
16
  export const ACP_LOGIN_METHOD = {
13
17
  id: 'shardflux-login',
@@ -44,10 +48,11 @@ async function accountReady(ctx) {
44
48
  const say = (ctx, text) => { ctx.io.stderr.write(text); };
45
49
  /**
46
50
  * `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
48
- * cloud Claude sign-in.
51
+ * account (signed up or in with this computer's Codex login, else an email), its API key, the Codex login copy, the
52
+ * user's T3 Code proxy setup (a CLIProxyAPI on this computer, moved into the account when they say so) and the cloud
53
+ * Claude sign-in (not needed when Claude Code goes through that proxy with its own token).
49
54
  */
50
- export async function runAcpLogin(ctx) {
55
+ export async function runAcpLogin(ctx, deps = {}) {
51
56
  const { HANDLERS } = await import("./handlers.js");
52
57
  const home = ctx.io.env.HOME ?? homedir();
53
58
  const codex = existsSync(join(home, '.codex', 'auth.json'));
@@ -63,7 +68,7 @@ export async function runAcpLogin(ctx) {
63
68
  await setup();
64
69
  }
65
70
  else if (codex) {
66
- say(ctx, 'Signing in with the Codex login on this Mac…\n');
71
+ say(ctx, `Signing in with the Codex login on ${thisComputer()}…\n`);
67
72
  await HANDLERS.signup(quiet, { setup: false }, []);
68
73
  await setup();
69
74
  }
@@ -95,9 +100,33 @@ export async function runAcpLogin(ctx) {
95
100
  say(ctx, 'Already signed in.\n');
96
101
  if (codex) {
97
102
  await syncAgentCredentials(ctx.cloud(), { home, redact: (value) => ctx.addSecret(value) });
98
- say(ctx, 'Codex: connected with the login on this Mac.\n');
103
+ say(ctx, `Codex: connected with the login on ${thisComputer()}.\n`);
99
104
  }
100
- if (await cloudClaudeLogin(ctx))
105
+ // T3 Code's own providers go through CLIProxyAPI on this computer: cloud threads follow once it is in the account.
106
+ let plan = accountVmProxy(ctx);
107
+ if (plan.importable.length) {
108
+ const local = await (deps.detect ?? ((env) => detectLocalCliProxy({ env })))(ctx.io.env).catch(() => null);
109
+ if (local?.logins.length) {
110
+ const answer = (await ctx.input.answer('\nYour T3 Code setup uses CLIProxyAPI on this computer. Move it into your Shardflux account so cloud conversations use it too? Your logins and routing come along, and this computer keeps http://127.0.0.1:8317. [Y/n] '))?.trim().toLowerCase() ?? 'n';
111
+ if (answer === '' || answer.startsWith('y')) {
112
+ try {
113
+ // The answer above is the consent to stop the local copy too.
114
+ const moved = await (deps.move ?? ((c) => importCliProxy(c, { values: { yes: true } })))(ctx);
115
+ plan = accountVmProxy(ctx);
116
+ say(ctx, `CLIProxyAPI: in your account, ${moved.logins.active.length} login${moved.logins.active.length === 1 ? '' : 's'} serving. Cloud conversations use it.\n`);
117
+ }
118
+ catch (error) {
119
+ say(ctx, `CLIProxyAPI: ${describeError(error).message} Run: shard cli-proxy import\n`);
120
+ }
121
+ }
122
+ else
123
+ say(ctx, 'CLIProxyAPI: stays on this computer; cloud conversations use your Shardflux sign-ins. Move it any time: shard cli-proxy import\n');
124
+ }
125
+ }
126
+ const claude = readT3ProxySetup({ env: ctx.io.env }).claude;
127
+ if (plan.claudeCredential && claude)
128
+ say(ctx, `Claude Code: connected through ${claude.local ? 'your CLIProxyAPI' : new URL(claude.url).host}.\n`);
129
+ else if (await cloudClaudeLogin(ctx))
101
130
  say(ctx, 'Claude Code: connected.\n');
102
131
  else {
103
132
  const answer = (await ctx.input.answer('\nConnect Claude Code now? It opens a Claude sign-in page. [Y/n] '))?.trim().toLowerCase() ?? 'n';
@@ -24,11 +24,16 @@ export declare class AcpModelCache {
24
24
  read(scope: AcpScope): AcpCatalog;
25
25
  write(scope: AcpScope, catalog: AcpCatalog): void;
26
26
  }
27
- export declare function discoverAcpCatalog(ctx: Ctx, ws: Workspace, root: string): Promise<AcpCatalog>;
27
+ export declare function discoverAcpCatalog(ctx: Ctx, ws: Workspace, root: string, env?: Record<string, string>): Promise<AcpCatalog>;
28
28
  export interface AcpPreparation {
29
29
  allocated(conversation: Conversation, workspace: WorkspaceView): void;
30
30
  ready(workspace: Workspace): void;
31
31
  prepared(): void;
32
+ /** The user's T3 Code proxy setup (t3-proxy.ts): the adapters' variables, and the VM files they read, placed first. */
33
+ proxy?: {
34
+ env: Record<string, string>;
35
+ ready(workspace: Workspace): Promise<void>;
36
+ };
32
37
  }
33
38
  export interface AcpLifecycle {
34
39
  prepare(ctx: Ctx, folder: string, name: string, callbacks: AcpPreparation): Promise<AcpCatalog>;
@@ -1,14 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, posix } 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 } from "./agent-repository.js";
6
+ import { createConversationRecord, conversationWorkspace, seedConversation } from "./conversations.js";
7
+ import { seedPhase } from "./seed-phases.js";
7
8
  import { AgentStateStore } from "./agent-state.js";
8
9
  import { objectValue } from "./acp-rpc.js";
9
10
  import { nativeCurrentModel, nativeModelOptions } from "./acp-catalog.js";
10
- import { recordSeedSnapshot } from "./files-mirror.js";
11
- import { sendTwinSeed, twinCheckoutArgv } 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 `
@@ -116,9 +115,9 @@ export class AcpModelCache {
116
115
  });
117
116
  }
118
117
  }
119
- export async function discoverAcpCatalog(ctx, ws, root) {
118
+ export async function discoverAcpCatalog(ctx, ws, root, env = {}) {
120
119
  const result = await ws.cell().exec.run(['node', '-e', ACP_MODELS_SCRIPT], { cwd: root, timeoutMs: 55_000,
121
- signal: ctx.signal, maxOutputBytes: 1 << 20, env: { SF_ACP_MODEL_DISCOVERY: '1' } });
120
+ signal: ctx.signal, maxOutputBytes: 1 << 20, env: { ...env, SF_ACP_MODEL_DISCOVERY: '1' } });
122
121
  if (result.exitCode !== 0)
123
122
  throw new Error(`Guest model discovery failed: ${result.stderr.trim()}`);
124
123
  const discovered = JSON.parse(result.stdout), catalog = {};
@@ -131,29 +130,40 @@ 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);
143
+ seedPhase('vm_running');
139
144
  callbacks.ready(ws);
140
- if (!(await savedMachine(ctx))?.template)
141
- await syncAgentConfig(ctx, ws);
142
- await ws.cell().files.mkdir(c.worktree_path, { parents: true });
143
145
  let initialReady, initialFailed;
144
146
  const initialFiles = new Promise((resolve, reject) => { initialReady = resolve; initialFailed = reject; });
147
+ // The adapters read the project's own agent settings when it has any: discovery then waits for those files. A
148
+ // project without them is discovered while it seeds (0.21.1+), beside the worktree rather than in it (a prepared
149
+ // worktree is moved into place whole): the catalog depends on the account, not the files.
150
+ const projectSettings = ACP_INITIAL_FILES.some(path => existsSync(join(folder, path)));
151
+ // The same seed as a terminal start (0.22.0+): the prepared revision, only what the project cache lacks, or the copy
152
+ // seed. Media land before the reply and those still on their way stay out of the first file reconcile (acp.ts).
145
153
  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 });
148
- 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);
154
+ await seedConversation(ctx, c, ws, folder, undefined, changes, undefined, { initialFiles: ACP_INITIAL_FILES, onInitialFiles: initialReady, landNow: true });
153
155
  callbacks.prepared();
154
156
  })();
155
157
  void seeded.catch(initialFailed);
156
- const models = (async () => { await initialFiles; return discoverAcpCatalog(ctx, ws, c.worktree_path); })();
158
+ const proxied = callbacks.proxy?.ready(ws) ?? Promise.resolve();
159
+ const models = (async () => {
160
+ await proxied;
161
+ if (projectSettings) {
162
+ await initialFiles;
163
+ return discoverAcpCatalog(ctx, ws, c.worktree_path, callbacks.proxy?.env);
164
+ }
165
+ return discoverAcpCatalog(ctx, ws, posix.dirname(c.worktree_path), callbacks.proxy?.env);
166
+ })();
157
167
  // Observe both failures immediately. Never start a second VM, and never delete
158
168
  // the user's conversation VM as catalog cleanup.
159
169
  const results = await Promise.allSettled([seeded, models]);