@shardflux/cli 0.11.1 → 0.13.1

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 (88) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +83 -15
  3. package/dist/account-args.js +2 -0
  4. package/dist/account-commands.js +39 -0
  5. package/dist/agent-commands.d.ts +8 -0
  6. package/dist/agent-commands.js +223 -0
  7. package/dist/agent-config-files.d.ts +23 -0
  8. package/dist/agent-config-files.js +203 -0
  9. package/dist/agent-config-mcp.d.ts +36 -0
  10. package/dist/agent-config-mcp.js +225 -0
  11. package/dist/agent-config-sync.d.ts +58 -0
  12. package/dist/agent-config-sync.js +366 -0
  13. package/dist/agent-credentials.d.ts +14 -0
  14. package/dist/agent-credentials.js +93 -0
  15. package/dist/agent-git-commands.d.ts +10 -0
  16. package/dist/agent-git-commands.js +294 -0
  17. package/dist/agent-git.d.ts +47 -0
  18. package/dist/agent-git.js +285 -0
  19. package/dist/agent-reachability.d.ts +4 -0
  20. package/dist/agent-reachability.js +13 -0
  21. package/dist/agent-repository.d.ts +13 -0
  22. package/dist/agent-repository.js +182 -0
  23. package/dist/agent-state.d.ts +16 -0
  24. package/dist/agent-state.js +63 -0
  25. package/dist/agent-target-commands.d.ts +10 -0
  26. package/dist/agent-target-commands.js +171 -0
  27. package/dist/agent-targets.d.ts +33 -0
  28. package/dist/agent-targets.js +267 -0
  29. package/dist/agent-terminal.d.ts +35 -0
  30. package/dist/agent-terminal.js +209 -0
  31. package/dist/args.d.ts +8 -4
  32. package/dist/args.js +120 -10
  33. package/dist/assisted-setup.d.ts +24 -0
  34. package/dist/assisted-setup.js +77 -0
  35. package/dist/browser-commands.d.ts +3 -0
  36. package/dist/browser-commands.js +83 -0
  37. package/dist/browser-profile-copy.d.ts +2 -0
  38. package/dist/browser-profile-copy.js +2 -0
  39. package/dist/browser-readers.d.ts +13 -0
  40. package/dist/browser-readers.js +249 -0
  41. package/dist/browser-sites.d.ts +1 -0
  42. package/dist/browser-sites.js +13 -0
  43. package/dist/claude-cloud-login.d.ts +9 -0
  44. package/dist/claude-cloud-login.js +143 -0
  45. package/dist/command-center-actions.d.ts +6 -0
  46. package/dist/command-center-actions.js +132 -0
  47. package/dist/command-center-keys.d.ts +6 -0
  48. package/dist/command-center-keys.js +38 -0
  49. package/dist/command-center-model.d.ts +29 -0
  50. package/dist/command-center-model.js +90 -0
  51. package/dist/command-center-renderer.d.ts +25 -0
  52. package/dist/command-center-renderer.js +100 -0
  53. package/dist/command-center.d.ts +18 -0
  54. package/dist/command-center.js +320 -0
  55. package/dist/commands.d.ts +8 -2
  56. package/dist/commands.js +123 -8
  57. package/dist/credential-prompts.d.ts +22 -0
  58. package/dist/credential-prompts.js +193 -0
  59. package/dist/filesystem-commands.d.ts +2 -0
  60. package/dist/filesystem-commands.js +63 -0
  61. package/dist/filesystem-state.d.ts +3 -0
  62. package/dist/filesystem-state.js +12 -0
  63. package/dist/git-remote-bin.d.ts +2 -0
  64. package/dist/git-remote-bin.js +37 -0
  65. package/dist/git-remote-helper.d.ts +8 -0
  66. package/dist/git-remote-helper.js +31 -0
  67. package/dist/git-remote.d.ts +14 -0
  68. package/dist/git-remote.js +136 -0
  69. package/dist/handlers.js +9 -1
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +1 -0
  72. package/dist/input.d.ts +5 -0
  73. package/dist/main.js +21 -5
  74. package/dist/menubar.d.ts +32 -0
  75. package/dist/menubar.js +180 -0
  76. package/dist/project-identity.d.ts +14 -0
  77. package/dist/project-identity.js +61 -0
  78. package/dist/project-sync-manifest.d.ts +27 -0
  79. package/dist/project-sync-manifest.js +225 -0
  80. package/dist/project-sync-transfer.d.ts +14 -0
  81. package/dist/project-sync-transfer.js +240 -0
  82. package/dist/project-sync.d.ts +38 -0
  83. package/dist/project-sync.js +243 -0
  84. package/dist/project-template-setup.d.ts +17 -0
  85. package/dist/project-template-setup.js +89 -0
  86. package/dist/vault.d.ts +16 -0
  87. package/dist/vault.js +153 -0
  88. package/package.json +9 -2
@@ -0,0 +1,294 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { Shardflux, ShardfluxApiError } from '@shardflux/sdk';
4
+ import { out, resolveWorkspace, str } from "./commands.js";
5
+ import { AgentStateStore } from "./agent-state.js";
6
+ import { resolveApiUrl } from "./args.js";
7
+ import { AgentGit, agentName } from "./agent-git.js";
8
+ import { shardRemote } from "./git-remote.js";
9
+ import { projectIdentity } from "./project-identity.js";
10
+ import { UsageError } from "./errors.js";
11
+ import { workspaceRepositoryPath } from "./agent-repository.js";
12
+ const execute = promisify(execFile);
13
+ const DAY = 86_400_000;
14
+ const missing = (error) => error instanceof ShardfluxApiError && error.status === 404;
15
+ export { needsGitSeed, seedAgentRepository } from "./agent-repository.js";
16
+ export async function rememberGitAgent(ctx, ws, folder) {
17
+ if (!ws.labels.agent || ws.labels.agent === 'main')
18
+ return;
19
+ const project = projectIdentity(folder);
20
+ if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
21
+ return;
22
+ let git;
23
+ try {
24
+ git = await AgentGit.open(folder, new AgentStateStore(ctx.store.dir), { ...process.env, ...ctx.io.env }, ctx.signal);
25
+ }
26
+ catch (error) {
27
+ if (error.code === 128)
28
+ return;
29
+ throw error;
30
+ }
31
+ await git.locked(() => git.track(agentName(ws.labels.agent || ws.key.split('/').at(-1)), {
32
+ workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl,
33
+ remote_path: workspaceRepositoryPath(ws), next_check: Date.now() + 60_000,
34
+ }));
35
+ }
36
+ async function selection(ctx, ref, folder) {
37
+ const store = new AgentStateStore(ctx.store.dir);
38
+ const repos = (store.read().git_repositories ?? {});
39
+ const matches = Object.values(repos).filter(repo => Object.entries(repo.agents).some(([name, entry]) => entry.api_url === ctx.apiUrl && (entry.workspace_key === ref || entry.workspace_id === ref || !ref.includes('/') && name === ref)));
40
+ const env = { ...process.env, ...ctx.io.env, SHARDFLUX_API_URL: ctx.apiUrl, SHARDFLUX_CONFIG_DIR: ctx.store.dir };
41
+ let git;
42
+ try {
43
+ git = await AgentGit.open(folder ?? './', store, env, ctx.signal);
44
+ }
45
+ catch (error) {
46
+ if (folder || !ref.includes('/') || matches.length !== 1)
47
+ throw error;
48
+ git = await AgentGit.open(matches[0].root, store, env, ctx.signal);
49
+ }
50
+ if (!folder && ref.includes('/') && matches.length === 1 && !Object.values(git.entries()).some(entry => entry.workspace_key === ref || entry.workspace_id === ref))
51
+ git = await AgentGit.open(matches[0].root, store, env, ctx.signal);
52
+ const found = Object.entries(git.entries()).find(([name, entry]) => entry.api_url === ctx.apiUrl && (entry.workspace_key === ref || entry.workspace_id === ref || name === ref));
53
+ const name = agentName(found?.[0] ?? ref.split('/').at(-1));
54
+ const key = found?.[1].workspace_key ?? (ref.includes('/') ? ref : `${projectIdentity(git.root).slug}/${name}`);
55
+ if (name === 'main')
56
+ throw new UsageError('finish an extra agent; the project main workspace is preserved');
57
+ return { git, name, key };
58
+ }
59
+ async function workspace(ctx, git, name, key) {
60
+ const ws = await resolveWorkspace(ctx, key);
61
+ if (ws.data.deleted_at)
62
+ throw new UsageError(`workspace ${key} is gone; use shard agents restore ${name} if it was archived`);
63
+ const project = projectIdentity(git.root);
64
+ if (ws.labels.repo_remote && ws.labels.repo_remote !== project.remote)
65
+ throw new UsageError('agent repository does not match this local repository');
66
+ const saved = git.entries()[name];
67
+ if (saved && saved.workspace_id !== ws.id)
68
+ throw new UsageError('workspace identity changed; saved work belongs to the previous agent');
69
+ await git.track(name, { workspace_id: ws.id, workspace_key: ws.key, api_url: ctx.apiUrl, remote_path: workspaceRepositoryPath(ws), next_check: Date.now() + 60_000 });
70
+ return ws;
71
+ }
72
+ async function fetch(ctx, git, name, ws, path) {
73
+ const entry = git.entries()[name];
74
+ const remotePath = path ?? entry.remote_path;
75
+ shardRemote(entry.workspace_key, remotePath);
76
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
77
+ try {
78
+ const status = await cell.exec.run(['git', '-C', remotePath, 'status', '--porcelain', '--untracked-files=all'], { timeoutMs: 15_000, signal: ctx.signal });
79
+ if (status.exitCode !== 0)
80
+ throw new UsageError(`cannot read the agent repository at ${remotePath}: ${status.stderr.trim()}`);
81
+ if (status.stdout.trim())
82
+ throw new UsageError('agent repository has uncommitted files; commit them in the agent before fetching its work');
83
+ }
84
+ finally {
85
+ cell.close();
86
+ }
87
+ await git.fetch(name, shardRemote(entry.workspace_key, remotePath));
88
+ git.save(name, { ...entry, remote_path: remotePath });
89
+ }
90
+ async function deleteWorkspace(ctx, entry) {
91
+ let ws;
92
+ try {
93
+ ws = await ctx.cloud().request('GET', `/v1/workspaces/${encodeURIComponent(entry.workspace_id)}`, { signal: ctx.signal });
94
+ }
95
+ catch (error) {
96
+ if (missing(error))
97
+ return;
98
+ throw error;
99
+ }
100
+ if (ws.id !== entry.workspace_id || ws.workspace_key !== entry.workspace_key)
101
+ throw new UsageError('workspace identity changed; cleanup preserved the saved work');
102
+ if (!ws.deleted_at) {
103
+ if (entry.disposition !== 'discard' && ws.agents?.some(agent => agent.state === 'working'))
104
+ throw new UsageError('agent is working; park it before finishing cleanup');
105
+ const result = await ctx.cloud().request('DELETE', `/v1/workspaces/${encodeURIComponent(entry.workspace_id)}`, { signal: ctx.signal });
106
+ if (!result.workspace || result.workspace.id !== entry.workspace_id || !result.workspace.deleted_at)
107
+ throw new Error('workspace deletion returned no tombstone receipt; saved work was preserved');
108
+ }
109
+ }
110
+ function finishHandler(action) {
111
+ return async (ctx, values, [ref]) => {
112
+ if (!ref)
113
+ throw new UsageError(`agents ${action} requires an agent`);
114
+ const { git, name, key } = await selection(ctx, ref, str(values.path));
115
+ return git.locked(async () => {
116
+ let entry = git.entries()[name];
117
+ if (!entry) {
118
+ await workspace(ctx, git, name, key);
119
+ entry = git.entries()[name];
120
+ }
121
+ if (entry.archived_at !== undefined && action !== 'discard')
122
+ throw new UsageError(`agent ${name} is archived; restore it first`);
123
+ if (!entry.disposition && action !== 'discard') {
124
+ const ws = await workspace(ctx, git, name, key);
125
+ if (ws.data.agents?.some(agent => agent.state === 'working'))
126
+ throw new UsageError('agent is working; park it before merging or keeping its work');
127
+ await fetch(ctx, git, name, ws, str(values['remote-path']));
128
+ }
129
+ let pr;
130
+ const push = action === 'keep' && values.push === true ? async (branch) => {
131
+ await git.run(['push', '--set-upstream', 'origin', `refs/heads/${branch}:refs/heads/${branch}`]);
132
+ // A missing gh is fine; an installed but failing gh leaves the agent intact for a retry.
133
+ try {
134
+ await execute('gh', ['--version'], { timeout: 5000 });
135
+ }
136
+ catch (error) {
137
+ if (error.code === 'ENOENT')
138
+ return;
139
+ throw error;
140
+ }
141
+ try {
142
+ pr = (await execute('gh', ['pr', 'view', branch, '--json', 'url', '--jq', '.url'], { cwd: git.root, timeout: 30_000, env: { ...process.env, GH_PROMPT_DISABLED: '1' } })).stdout.trim();
143
+ }
144
+ catch {
145
+ pr = (await execute('gh', ['pr', 'create', '--head', branch, '--fill'], { cwd: git.root, timeout: 30_000, env: { ...process.env, GH_PROMPT_DISABLED: '1' } })).stdout.trim();
146
+ }
147
+ } : undefined;
148
+ const result = await git.finish(name, action, entry => deleteWorkspace(ctx, entry), push);
149
+ out(ctx, `${action === 'merge' ? 'Merged' : action === 'keep' ? 'Kept' : 'Discarded'} ${key}${result.branch ? ` as ${result.branch}` : ''}${pr ? `: ${pr}` : ''}\n`, { agent: key, action, ...result, ...(pr ? { pr } : {}) });
150
+ return 0;
151
+ });
152
+ };
153
+ }
154
+ export const AGENT_GIT_HANDLERS = {
155
+ 'agents merge': finishHandler('merge'), 'agents keep': finishHandler('keep'), 'agents discard': finishHandler('discard'),
156
+ async 'agents worktree'(ctx, values, [ref]) {
157
+ if (!ref)
158
+ throw new UsageError('agents worktree requires an agent');
159
+ const { git, name, key } = await selection(ctx, ref, str(values.path));
160
+ return git.locked(async () => {
161
+ const ws = await workspace(ctx, git, name, key);
162
+ await fetch(ctx, git, name, ws, str(values['remote-path']));
163
+ const path = await git.openWorktree(name);
164
+ out(ctx, `${path}\n`, { agent: key, path, ref: git.ref(name) });
165
+ return 0;
166
+ });
167
+ },
168
+ async 'agents restore'(ctx, values, [ref]) {
169
+ if (!ref)
170
+ throw new UsageError('agents restore requires an agent');
171
+ const { git, name, key } = await selection(ctx, ref, str(values.path));
172
+ return git.locked(async () => {
173
+ const branch = await git.restore(name);
174
+ out(ctx, `Restored ${key} as ${branch}\n`, { agent: key, branch });
175
+ return 0;
176
+ });
177
+ },
178
+ };
179
+ /** No Git or network work when no records are due. Active passes are bounded and never infer deletion from an error. */
180
+ export async function housekeeping(ctx, now = Date.now, budgetMs = 1500) {
181
+ const store = new AgentStateStore(ctx.store.dir);
182
+ const repos = (store.read().git_repositories ?? {});
183
+ const controller = new AbortController();
184
+ const timer = setTimeout(() => controller.abort(), budgetMs);
185
+ const signal = AbortSignal.any([ctx.signal, controller.signal]);
186
+ const bounded = { ...ctx, signal };
187
+ try {
188
+ for (const [common, repo] of Object.entries(repos)) {
189
+ if (!Object.values(repo.agents).some(e => e.api_url === ctx.apiUrl && e.next_check <= now()))
190
+ continue;
191
+ if (signal.aborted)
192
+ break;
193
+ const opened = await AgentGit.open(repo.root, store, { ...process.env, ...ctx.io.env }, signal);
194
+ if (opened.common !== common)
195
+ throw new UsageError('saved repository identity changed; housekeeping preserved its records');
196
+ const git = new AgentGit(repo.root, common, store, opened.run, now);
197
+ await git.locked(async () => {
198
+ for (const [name, entry] of Object.entries(git.entries())) {
199
+ if (signal.aborted)
200
+ break;
201
+ if (entry.api_url !== ctx.apiUrl || entry.next_check > now() || entry.disposition)
202
+ continue;
203
+ if (entry.delete_pending) {
204
+ const pending = await bounded.cloud().request('GET', `/v1/workspaces/${encodeURIComponent(entry.workspace_id)}`, { signal }).catch(error => { if (missing(error))
205
+ return undefined; throw error; });
206
+ if (pending && !pending.deleted_at && (pending.observed_state !== 'suspended' || pending.desired_state !== 'suspended' || pending.active_operation || pending.agents?.some(agent => agent.state === 'working'))) {
207
+ git.save(name, { ...entry, next_check: now() + 60_000 });
208
+ continue;
209
+ }
210
+ await deleteWorkspace(bounded, entry);
211
+ const archived = { ...entry };
212
+ delete archived.delete_pending;
213
+ git.save(name, { ...archived, next_check: archived.archived_at + 30 * DAY });
214
+ continue;
215
+ }
216
+ if (entry.archived_at !== undefined) {
217
+ await git.removeWorktree(name);
218
+ await git.run(['update-ref', '-d', git.ref(name)]);
219
+ if (now() >= entry.archived_at + 30 * DAY) {
220
+ await git.run(['update-ref', '-d', git.ref(name, true)]);
221
+ git.save(name);
222
+ }
223
+ else
224
+ git.save(name, { ...entry, next_check: entry.archived_at + 30 * DAY });
225
+ continue;
226
+ }
227
+ let ws;
228
+ try {
229
+ ws = await bounded.cloud().request('GET', `/v1/workspaces/${encodeURIComponent(entry.workspace_id)}`, { signal });
230
+ }
231
+ catch (error) {
232
+ if (!missing(error))
233
+ throw error;
234
+ }
235
+ if (ws && (ws.id !== entry.workspace_id || ws.workspace_key !== entry.workspace_key))
236
+ throw new UsageError('workspace identity changed; housekeeping preserved saved work');
237
+ if (!ws || ws.deleted_at) {
238
+ await git.archive(name);
239
+ continue;
240
+ }
241
+ const deleteAt = ws.retention?.delete_at ? Date.parse(ws.retention.delete_at) : NaN;
242
+ if (Number.isFinite(deleteAt) && deleteAt <= now() && ws.observed_state === 'suspended' && ws.desired_state === 'suspended' && !ws.active_operation && !ws.agents?.some(agent => agent.state === 'working')) {
243
+ await git.assertCleanWorktree(name);
244
+ // Retention owns deletion. Archive first so a transient delete failure cannot lose saved commits.
245
+ if (!await git.archive(name)) {
246
+ try {
247
+ await deleteWorkspace(bounded, entry);
248
+ }
249
+ catch (error) {
250
+ git.save(name, { ...entry, next_check: now() + 60_000 });
251
+ throw error;
252
+ }
253
+ continue;
254
+ }
255
+ git.save(name, { ...git.entries()[name], delete_pending: true, next_check: now() + 60_000 });
256
+ await deleteWorkspace(bounded, entry);
257
+ const archived = { ...git.entries()[name] };
258
+ delete archived.delete_pending;
259
+ git.save(name, { ...archived, next_check: archived.archived_at + 30 * DAY });
260
+ }
261
+ else
262
+ git.save(name, { ...entry, next_check: now() + 60_000 });
263
+ }
264
+ await git.run(['worktree', 'prune']);
265
+ });
266
+ }
267
+ }
268
+ catch (error) {
269
+ if (!controller.signal.aborted && !ctx.signal.aborted)
270
+ ctx.io.stderr.write(`shard housekeeping: ${error instanceof Error ? error.message : String(error)}\n`);
271
+ }
272
+ finally {
273
+ clearTimeout(timer);
274
+ }
275
+ }
276
+ /** Help/version starts also clean saved work, without requiring credentials when nothing is due. */
277
+ export async function startupHousekeeping(ctx) {
278
+ try {
279
+ const state = new AgentStateStore(ctx.store.dir).read();
280
+ if (!state.git_repositories || !Object.keys(state.git_repositories).length)
281
+ return;
282
+ const apiUrl = resolveApiUrl(undefined, ctx.io.env.SHARDFLUX_API_URL ?? ctx.io.env.SHARDFLUX_BASE_URL);
283
+ let cloud;
284
+ await housekeeping({ ...ctx, apiUrl, cloud: () => {
285
+ const key = ctx.io.env.SHARDFLUX_API_KEY?.trim() || ctx.store.profile(apiUrl).api_key?.token;
286
+ if (!key)
287
+ throw new UsageError('saved agents need a project key for housekeeping; run shard setup');
288
+ return cloud ??= new Shardflux({ apiKey: key, baseUrl: apiUrl, versionCheck: false });
289
+ } });
290
+ }
291
+ catch (error) {
292
+ ctx.io.stderr.write(`shard housekeeping: ${error instanceof Error ? error.message : String(error)}\n`);
293
+ }
294
+ }
@@ -0,0 +1,47 @@
1
+ import { AgentStateStore } from './agent-state.js';
2
+ export interface GitAgent {
3
+ workspace_id: string;
4
+ workspace_key: string;
5
+ remote_path: string;
6
+ api_url: string;
7
+ next_check: number;
8
+ archived_at?: number;
9
+ delete_pending?: boolean;
10
+ disposition?: 'merge' | 'keep' | 'discard';
11
+ branch?: string;
12
+ }
13
+ export interface GitRepository {
14
+ root: string;
15
+ agents: Record<string, GitAgent>;
16
+ }
17
+ export type GitRepositories = Record<string, GitRepository>;
18
+ export type GitRun = (args: string[]) => Promise<string>;
19
+ export declare function gitRunner(root: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): GitRun;
20
+ export declare function agentName(name: string): string;
21
+ export declare class AgentGit {
22
+ readonly root: string;
23
+ readonly common: string;
24
+ readonly store: AgentStateStore;
25
+ readonly run: GitRun;
26
+ readonly now: () => number;
27
+ constructor(root: string, common: string, store: AgentStateStore, run?: GitRun, now?: () => number);
28
+ static open(folder: string, store: AgentStateStore, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<AgentGit>;
29
+ repositories(): GitRepositories;
30
+ entries(): Record<string, GitAgent>;
31
+ save(name: string, entry?: GitAgent): void;
32
+ locked<T>(fn: () => Promise<T>): Promise<T>;
33
+ ref(name: string, archive?: boolean): string;
34
+ worktree(name: string): string;
35
+ oid(ref: string): Promise<string | undefined>;
36
+ track(name: string, entry: GitAgent): Promise<void>;
37
+ fetch(name: string, remote: string): Promise<void>;
38
+ assertCleanWorktree(name: string): Promise<void>;
39
+ assertOwnedWorktree(name: string): Promise<void>;
40
+ openWorktree(name: string): Promise<string>;
41
+ removeWorktree(name: string, force?: boolean): Promise<void>;
42
+ finish(name: string, action: 'merge' | 'keep' | 'discard', removeWorkspace: (entry: GitAgent) => Promise<void>, push?: (branch: string) => Promise<void>): Promise<{
43
+ branch?: string;
44
+ }>;
45
+ archive(name: string, archivedAt?: number): Promise<boolean>;
46
+ restore(name: string): Promise<string>;
47
+ }
@@ -0,0 +1,285 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { UsageError } from "./errors.js";
6
+ import { AgentStateStore } from "./agent-state.js";
7
+ import { gitPath } from "./git-remote-helper.js";
8
+ const execute = promisify(execFile);
9
+ const DAY = 86_400_000;
10
+ export function gitRunner(root, env = process.env, signal) {
11
+ return async (args) => {
12
+ const result = await execute('git', ['--no-pager', '-C', root, ...args], {
13
+ env: { ...env, PATH: gitPath(env), GIT_TERMINAL_PROMPT: '0', GIT_MERGE_AUTOEDIT: 'no' },
14
+ timeout: 120_000, maxBuffer: 4 * 1024 * 1024, signal,
15
+ });
16
+ return result.stdout.trim();
17
+ };
18
+ }
19
+ export function agentName(name) {
20
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) || name.includes('..') || name.endsWith('.') || name.endsWith('.lock') || name === 'archive')
21
+ throw new UsageError('agent name is not valid for a Git ref');
22
+ return name;
23
+ }
24
+ export class AgentGit {
25
+ root;
26
+ common;
27
+ store;
28
+ run;
29
+ now;
30
+ constructor(root, common, store, run = gitRunner(root), now = Date.now) {
31
+ this.root = root;
32
+ this.common = common;
33
+ this.store = store;
34
+ this.run = run;
35
+ this.now = now;
36
+ }
37
+ static async open(folder, store, env, signal) {
38
+ const run = gitRunner(folder, env, signal);
39
+ const root = realpathSync(await run(['rev-parse', '--show-toplevel']));
40
+ const common = realpathSync(resolve(folder, await run(['rev-parse', '--git-common-dir'])));
41
+ return new AgentGit(root, common, store, gitRunner(root, env, signal));
42
+ }
43
+ repositories() { return (this.store.read().git_repositories ?? {}); }
44
+ entries() { return this.repositories()[this.common]?.agents ?? {}; }
45
+ save(name, entry) {
46
+ this.store.update(state => {
47
+ const repos = (state.git_repositories ?? {});
48
+ const repo = repos[this.common] ?? { root: this.root, agents: {} };
49
+ if (entry)
50
+ repo.agents[name] = entry;
51
+ else
52
+ delete repo.agents[name];
53
+ if (Object.keys(repo.agents).length)
54
+ repos[this.common] = repo;
55
+ else
56
+ delete repos[this.common];
57
+ state.git_repositories = repos;
58
+ });
59
+ }
60
+ async locked(fn) {
61
+ const file = join(this.common, 'shard-agents.lock');
62
+ try {
63
+ writeFileSync(file, String(process.pid), { flag: 'wx', mode: 0o600 });
64
+ }
65
+ catch (error) {
66
+ if (error.code !== 'EEXIST')
67
+ throw error;
68
+ const pid = Number(readFileSync(file, 'utf8'));
69
+ try {
70
+ process.kill(pid, 0);
71
+ }
72
+ catch (probe) {
73
+ if (probe.code === 'ESRCH') {
74
+ rmSync(file);
75
+ return this.locked(fn);
76
+ }
77
+ }
78
+ throw new UsageError('another shard Git action is running in this repository');
79
+ }
80
+ try {
81
+ return await fn();
82
+ }
83
+ finally {
84
+ rmSync(file, { force: true });
85
+ }
86
+ }
87
+ ref(name, archive = false) { return `refs/shard/${archive ? 'archive/' : ''}${agentName(name)}`; }
88
+ worktree(name) { return join(this.repositories()[this.common]?.root ?? this.root, '.shard', 'worktrees', agentName(name)); }
89
+ async oid(ref) {
90
+ try {
91
+ return await this.run(['rev-parse', '--verify', `${ref}^{commit}`]);
92
+ }
93
+ catch (error) {
94
+ if (error.code === 128)
95
+ return undefined;
96
+ throw error;
97
+ }
98
+ }
99
+ async track(name, entry) {
100
+ agentName(name);
101
+ const prior = this.entries()[name];
102
+ if (prior?.archived_at || prior?.disposition || prior && prior.workspace_id !== entry.workspace_id)
103
+ throw new UsageError(`agent ${name} already has saved work; restore or discard it before reusing the name`);
104
+ const head = await this.oid('HEAD');
105
+ if (head && !await this.oid(this.ref(name)))
106
+ await this.run(['update-ref', this.ref(name), head]);
107
+ this.save(name, { ...prior, ...entry });
108
+ }
109
+ async fetch(name, remote) {
110
+ await this.run(['fetch', '--no-tags', '--no-write-fetch-head', remote, `+HEAD:${this.ref(name)}`]);
111
+ // Detached editor commits are also work. Keep them if they extend the fetched tip; refuse divergence.
112
+ const path = this.worktree(name);
113
+ if (existsSync(path)) {
114
+ const local = await this.run(['-C', path, 'rev-parse', 'HEAD']);
115
+ const fetched = await this.oid(this.ref(name));
116
+ if (local !== fetched) {
117
+ try {
118
+ await this.run(['merge-base', '--is-ancestor', this.ref(name), local]);
119
+ }
120
+ catch {
121
+ try {
122
+ await this.run(['merge-base', '--is-ancestor', local, this.ref(name)]);
123
+ return;
124
+ }
125
+ catch {
126
+ throw new UsageError(`local editor and cloud work diverged for ${name}; merge or push the editor commits before finishing`);
127
+ }
128
+ }
129
+ await this.run(['update-ref', this.ref(name), local]);
130
+ }
131
+ }
132
+ }
133
+ async assertCleanWorktree(name) {
134
+ const path = this.worktree(name);
135
+ if (existsSync(path))
136
+ await this.assertOwnedWorktree(name);
137
+ if (existsSync(path) && await this.run(['-C', path, 'status', '--porcelain', '--untracked-files=all']))
138
+ throw new UsageError(`local worktree ${path} has uncommitted files; commit or save them before cleanup`);
139
+ }
140
+ async assertOwnedWorktree(name) {
141
+ const path = this.worktree(name);
142
+ const common = realpathSync(resolve(path, await this.run(['-C', path, 'rev-parse', '--git-common-dir'])));
143
+ const registered = (await this.run(['worktree', 'list', '--porcelain'])).split('\n').includes(`worktree ${path}`);
144
+ if (common !== this.common || !registered)
145
+ throw new UsageError(`${path} is not this repository's managed worktree`);
146
+ }
147
+ async openWorktree(name) {
148
+ const path = this.worktree(name);
149
+ const excluded = join(this.common, 'info', 'exclude');
150
+ mkdirSync(dirname(excluded), { recursive: true });
151
+ const text = existsSync(excluded) ? readFileSync(excluded, 'utf8') : '';
152
+ if (!text.split('\n').some(line => line === '.shard/' || line === '/.shard/'))
153
+ writeFileSync(excluded, `${text}${text && !text.endsWith('\n') ? '\n' : ''}/.shard/\n`);
154
+ if (!existsSync(path)) {
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ await this.run(['worktree', 'add', '--detach', path, this.ref(name)]);
157
+ }
158
+ else {
159
+ const listed = (await this.run(['worktree', 'list', '--porcelain'])).split('\n').includes(`worktree ${path}`);
160
+ if (!listed)
161
+ throw new UsageError(`${path} exists but is not this repository's worktree`);
162
+ const head = await this.run(['-C', path, 'rev-parse', 'HEAD']);
163
+ const tip = await this.oid(this.ref(name));
164
+ if (head !== tip) {
165
+ await this.assertCleanWorktree(name);
166
+ await this.run(['merge-base', '--is-ancestor', head, this.ref(name)]);
167
+ await this.run(['-C', path, 'reset', '--hard', this.ref(name)]);
168
+ }
169
+ }
170
+ return path;
171
+ }
172
+ async removeWorktree(name, force = false) {
173
+ const path = this.worktree(name);
174
+ if (existsSync(path)) {
175
+ await this.assertOwnedWorktree(name);
176
+ await this.run(['worktree', 'remove', ...(force ? ['--force'] : []), path]);
177
+ }
178
+ await this.run(['worktree', 'prune']);
179
+ }
180
+ async finish(name, action, removeWorkspace, push) {
181
+ const entry = this.entries()[name];
182
+ if (!entry)
183
+ throw new UsageError(`no saved agent ${name}`);
184
+ if (entry.disposition && entry.disposition !== action)
185
+ throw new UsageError(`agent ${name} has a pending ${entry.disposition}; repeat that command to finish cleanup`);
186
+ if (action !== 'discard')
187
+ await this.assertCleanWorktree(name);
188
+ else if (existsSync(this.worktree(name)))
189
+ await this.assertOwnedWorktree(name);
190
+ let branch = entry.branch;
191
+ if (!entry.disposition) {
192
+ if (action === 'merge') {
193
+ const current = await this.run(['symbolic-ref', '--quiet', 'HEAD']).catch(() => '');
194
+ if (!current)
195
+ throw new UsageError('merge requires a current local branch');
196
+ if (await this.run(['status', '--porcelain', '--untracked-files=all']))
197
+ throw new UsageError('commit or stash local changes before merging an agent');
198
+ try {
199
+ await this.run(['merge', '--no-edit', this.ref(name)]);
200
+ }
201
+ catch (error) {
202
+ throw new UsageError(`agent merge stopped: ${error instanceof Error ? error.message : String(error)}\nResolve with normal Git commands or git merge --abort. The agent, hidden ref and worktree are preserved.`);
203
+ }
204
+ }
205
+ else if (action === 'keep') {
206
+ branch = `shard/${agentName(name)}`;
207
+ const existing = await this.oid(`refs/heads/${branch}`);
208
+ const head = await this.oid(this.ref(name));
209
+ if (!head)
210
+ throw new UsageError(`agent ${name} has no fetched commit`);
211
+ if (existing && existing !== head)
212
+ throw new UsageError(`branch ${branch} already exists with different work`);
213
+ if (!existing)
214
+ await this.run(['branch', branch, this.ref(name)]);
215
+ }
216
+ this.save(name, { ...entry, disposition: action, ...(branch ? { branch } : {}) });
217
+ }
218
+ if (push && branch)
219
+ await push(branch);
220
+ await removeWorkspace(this.entries()[name]);
221
+ await this.removeWorktree(name, action === 'discard');
222
+ await this.run(['update-ref', '-d', this.ref(name)]);
223
+ await this.run(['update-ref', '-d', this.ref(name, true)]);
224
+ this.save(name);
225
+ return branch ? { branch } : {};
226
+ }
227
+ async archive(name, archivedAt = this.now()) {
228
+ const entry = this.entries()[name];
229
+ if (!entry)
230
+ return false;
231
+ await this.assertCleanWorktree(name);
232
+ let head = await this.oid(this.ref(name));
233
+ if (existsSync(this.worktree(name))) {
234
+ const local = await this.run(['-C', this.worktree(name), 'rev-parse', 'HEAD']);
235
+ if (!head)
236
+ head = local;
237
+ else if (local !== head) {
238
+ try {
239
+ await this.run(['merge-base', '--is-ancestor', head, local]);
240
+ head = local;
241
+ }
242
+ catch {
243
+ try {
244
+ await this.run(['merge-base', '--is-ancestor', local, head]);
245
+ }
246
+ catch {
247
+ throw new UsageError(`local editor and saved cloud work diverged for ${name}; preserve both before archiving`);
248
+ }
249
+ }
250
+ }
251
+ }
252
+ if (!head) {
253
+ await this.removeWorktree(name);
254
+ await this.run(['update-ref', '-d', this.ref(name)]);
255
+ this.save(name);
256
+ return false;
257
+ }
258
+ await this.run(['update-ref', this.ref(name, true), head]);
259
+ // Persist the timestamp before removing the active ref; a crash never restarts the 30-day clock.
260
+ this.save(name, { ...entry, archived_at: archivedAt, next_check: this.now() });
261
+ await this.removeWorktree(name);
262
+ await this.run(['update-ref', '-d', this.ref(name)]);
263
+ this.save(name, { ...entry, archived_at: archivedAt, next_check: archivedAt + 30 * DAY });
264
+ return true;
265
+ }
266
+ async restore(name) {
267
+ const entry = this.entries()[name];
268
+ if (entry?.delete_pending)
269
+ throw new UsageError(`agent ${name} still has workspace deletion pending; run shard again to finish housekeeping`);
270
+ if (entry?.archived_at === undefined || this.now() >= entry.archived_at + 30 * DAY)
271
+ throw new UsageError(`agent ${name} has no archive within the 30-day restore window`);
272
+ const head = await this.oid(this.ref(name, true));
273
+ if (!head)
274
+ throw new UsageError(`archive ${name} has no saved commit`);
275
+ const branch = `shard/${agentName(name)}`;
276
+ const existing = await this.oid(`refs/heads/${branch}`);
277
+ if (existing && existing !== head)
278
+ throw new UsageError(`branch ${branch} already exists with different work`);
279
+ if (!existing)
280
+ await this.run(['branch', branch, head]);
281
+ await this.run(['update-ref', '-d', this.ref(name, true)]);
282
+ this.save(name);
283
+ return branch;
284
+ }
285
+ }
@@ -0,0 +1,4 @@
1
+ import type { Values } from './args.js';
2
+ export declare function agentReachability(values: Values): {
3
+ agentReachableSeconds?: number;
4
+ };
@@ -0,0 +1,13 @@
1
+ import { parseDuration } from "./args.js";
2
+ import { UsageError } from "./errors.js";
3
+ export function agentReachability(values) {
4
+ const text = values.reachable;
5
+ if (text === undefined)
6
+ return {};
7
+ if (typeof text !== 'string')
8
+ throw new UsageError('--reachable requires a duration');
9
+ const ms = /^0+(?:\.0+)?(?:ms|s|m|h)?$/.test(text.trim()) ? 0 : parseDuration(text, 'reachable');
10
+ if (ms % 1000 !== 0 || ms > 86_400_000)
11
+ throw new UsageError('--reachable must be whole seconds from 0 to 24h (e.g. 30m; 0 off)');
12
+ return { agentReachableSeconds: ms / 1000 };
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ export declare function projectWorkspacePath(project: {
4
+ slug: string;
5
+ }): string;
6
+ /** New workspaces label this path explicitly; custom remote paths remain available to Git callers. */
7
+ export declare function workspaceRepositoryPath(ws: Pick<Workspace, 'labels' | 'key'>): string;
8
+ export declare function needsGitSeed(ctx: Ctx, ws: Workspace): boolean;
9
+ /** One history seed for C5 and C11. Bundles use the bounded files path, without a Git helper or second connection. */
10
+ export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: string, options?: {
11
+ root?: string;
12
+ populate?: boolean;
13
+ }): Promise<void>;