@shardflux/cli 0.15.1 → 0.16.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 (47) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +50 -3
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +8 -2
  5. package/dist/agent-commands.js +23 -11
  6. package/dist/agent-target-commands.d.ts +7 -2
  7. package/dist/agent-target-commands.js +12 -7
  8. package/dist/agent-targets.d.ts +3 -0
  9. package/dist/agent-targets.js +4 -1
  10. package/dist/agent-terminal.d.ts +8 -0
  11. package/dist/agent-terminal.js +35 -2
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +29 -3
  14. package/dist/codex-cloud.d.ts +2 -0
  15. package/dist/codex-cloud.js +2 -0
  16. package/dist/command-center-actions.js +9 -0
  17. package/dist/commands.d.ts +23 -1
  18. package/dist/commands.js +54 -14
  19. package/dist/conversation-commands.js +6 -0
  20. package/dist/conversation-terminal.js +10 -1
  21. package/dist/conversations.d.ts +13 -1
  22. package/dist/conversations.js +41 -5
  23. package/dist/device-helper.js +9 -2
  24. package/dist/errors.d.ts +5 -0
  25. package/dist/errors.js +11 -1
  26. package/dist/files-mirror.d.ts +164 -0
  27. package/dist/files-mirror.js +761 -0
  28. package/dist/handlers.js +3 -1
  29. package/dist/main.js +39 -14
  30. package/dist/menubar.d.ts +57 -0
  31. package/dist/menubar.js +264 -45
  32. package/dist/registry.d.ts +8 -0
  33. package/dist/registry.js +18 -0
  34. package/dist/thread-on-computer.d.ts +35 -0
  35. package/dist/thread-on-computer.js +200 -0
  36. package/dist/update-check.d.ts +9 -1
  37. package/dist/update-check.js +55 -17
  38. package/dist/update.d.ts +2 -0
  39. package/dist/update.js +26 -8
  40. package/dist/wait-status.d.ts +22 -0
  41. package/dist/wait-status.js +59 -0
  42. package/dist/worktree-twin-engine.d.ts +95 -0
  43. package/dist/worktree-twin-engine.js +402 -0
  44. package/dist/worktree-twin-land.py +331 -0
  45. package/dist/worktree-twin.d.ts +296 -0
  46. package/dist/worktree-twin.js +718 -0
  47. package/package.json +3 -3
@@ -0,0 +1,331 @@
1
+ """Shardflux landing: runs in a conversation's VM, shipped and started by the shard CLI (worktree-twin.ts).
2
+
3
+ Input: one JSON object on stdin. Output: one JSON object on stdout (exit 0; "state" says what happened).
4
+
5
+ op "land" brings the conversation's committed work (its branch tip T) onto the user's branch tip B, as captured in the
6
+ user's twin W (a snapshot commit whose first parent is B and whose tree is the user's working state):
7
+ B' (tip) fast-forward to T when B is an ancestor of T and B..T holds no snapshot commit; otherwise the
8
+ conversation's first-parent commits since its last landed point replayed onto B (author and message
9
+ kept); if a replay step conflicts, one squash merge; if that conflicts too: state "conflict".
10
+ R (result) merge-tree --merge-base B W B': the user's working state plus the conversation's changes. Overlapping
11
+ hunks keep the user's text (-X ours); any other conflicting path keeps the user's version.
12
+ mode "pr" pushes B' to origin as the conversation branch and opens a pull request instead (gh).
13
+ op "confirm" records T as landed (refs/shard/land/upto here) and, after the files are on the user's disk, copies the
14
+ twin, tip, result and upto to the project hub (refs/shard/twin/<device>/<worktree>, refs/shard/land/<name>/*). The
15
+ hub is on shared storage, so "land" never touches it: it only reads the hub's upto when nothing closer knows it.
16
+ Small bundles travel inline (base64 in the JSON) instead of as files.
17
+
18
+ Only Git plumbing: the conversation's worktree, index and HEAD are never changed.
19
+ """
20
+ import base64
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ import time
27
+
28
+ SNAPSHOT = 'Shardflux-Snapshot:'
29
+ INLINE_BYTES = 512 << 10
30
+ STARTED = time.monotonic()
31
+ PHASES = {}
32
+
33
+
34
+ def phase(name):
35
+ PHASES[name] = int((time.monotonic() - STARTED) * 1000)
36
+
37
+
38
+ class Fail(Exception):
39
+ def __init__(self, reason, message):
40
+ super().__init__(message)
41
+ self.reason = reason
42
+
43
+
44
+ def git(cwd, *args, env=None, data=None, ok=(0,)):
45
+ full = dict(os.environ, GIT_TERMINAL_PROMPT='0', LC_ALL='C')
46
+ if env:
47
+ full.update(env)
48
+ p = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', *args], cwd=cwd, env=full, input=data,
49
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
50
+ if p.returncode not in ok:
51
+ tail = p.stderr.decode('utf-8', 'replace').strip()[-400:]
52
+ raise Fail('git', 'git %s failed (%d): %s' % (args[0], p.returncode, tail))
53
+ return p
54
+
55
+
56
+ def text(cwd, *args, **kw):
57
+ return git(cwd, *args, **kw).stdout.decode('utf-8', 'surrogateescape').strip()
58
+
59
+
60
+ def commit(cwd, rev):
61
+ p = git(cwd, 'rev-parse', '-q', '--verify', rev + '^{commit}', ok=(0, 1))
62
+ return p.stdout.decode().strip() if p.returncode == 0 else ''
63
+
64
+
65
+ def ancestor(cwd, a, b):
66
+ return git(cwd, 'merge-base', '--is-ancestor', a, b, ok=(0, 1)).returncode == 0
67
+
68
+
69
+ def tree_of(cwd, rev):
70
+ return text(cwd, 'rev-parse', rev + '^{tree}')
71
+
72
+
73
+ def merge_tree(cwd, base, ours, theirs, strategy=None):
74
+ args = ['merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', '--merge-base=' + base]
75
+ if strategy:
76
+ args += ['-X', strategy]
77
+ p = git(cwd, *args, ours, theirs, ok=(0, 1))
78
+ parts = p.stdout.decode('utf-8', 'surrogateescape').split('\0')
79
+ return parts[0].strip(), (sorted({x for x in parts[1:] if x}) if p.returncode == 1 else [])
80
+
81
+
82
+ def message_of(cwd, rev):
83
+ raw = git(cwd, 'cat-file', 'commit', rev).stdout
84
+ return raw.split(b'\n\n', 1)[1] if b'\n\n' in raw else b''
85
+
86
+
87
+ def author_of(cwd, rev):
88
+ name, email, date = text(cwd, 'log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', rev).split('\0')
89
+ return {'GIT_AUTHOR_NAME': name, 'GIT_AUTHOR_EMAIL': email, 'GIT_AUTHOR_DATE': date}
90
+
91
+
92
+ def committer(cwd):
93
+ name = git(cwd, 'config', 'user.name', ok=(0, 1)).stdout.decode().strip()
94
+ email = git(cwd, 'config', 'user.email', ok=(0, 1)).stdout.decode().strip()
95
+ return {'GIT_COMMITTER_NAME': name or 'Shardflux', 'GIT_COMMITTER_EMAIL': email or 'landing@shardflux.dev'}
96
+
97
+
98
+ def commit_tree(cwd, tree, parents, message, env):
99
+ args = ['commit-tree', tree]
100
+ for p in parents:
101
+ args += ['-p', p]
102
+ return text(cwd, *args, '-F', '-', env=env, data=message)
103
+
104
+
105
+ def snapshot(message):
106
+ return any(line.startswith(SNAPSHOT) for line in message.decode('utf-8', 'replace').splitlines())
107
+
108
+
109
+ def replay(cwd, onto, commits, who):
110
+ cur = onto
111
+ for c in commits:
112
+ message = message_of(cwd, c)
113
+ if snapshot(message):
114
+ continue
115
+ parent = commit(cwd, c + '^1')
116
+ if not parent:
117
+ return None
118
+ tree, conflicts = merge_tree(cwd, parent, cur, c)
119
+ if conflicts:
120
+ return None
121
+ if tree == tree_of(cwd, cur):
122
+ continue # already there (as rebase drops a commit that became empty)
123
+ cur = commit_tree(cwd, tree, [cur], message, dict(who, **author_of(cwd, c)))
124
+ return cur
125
+
126
+
127
+ def user_side(cwd, upto, base, tip, who):
128
+ """The user's state the conversation already contains, as a merge base for squashing it onto base.
129
+
130
+ Normally that is upto (its starting snapshot or last landed point). When the conversation merged the user's branch
131
+ (resolving a conflict), it also contains that branch's commit mb: the base is then upto and mb merged (a commit
132
+ made here, never pushed), so neither the user's commits nor the uncommitted edits it started from count as the
133
+ conversation's change. None when the two cannot be merged cleanly."""
134
+ p = git(cwd, 'merge-base', base, tip, ok=(0, 1))
135
+ mb = p.stdout.decode().strip()
136
+ if not mb or ancestor(cwd, mb, upto):
137
+ return upto
138
+ if ancestor(cwd, upto, mb):
139
+ return mb
140
+ p = git(cwd, 'merge-base', upto, mb, ok=(0, 1))
141
+ common = p.stdout.decode().strip()
142
+ if not common:
143
+ return None
144
+ tree, conflicts = merge_tree(cwd, common, mb, upto)
145
+ if conflicts:
146
+ return None
147
+ return commit_tree(cwd, tree, [mb, upto], b'Shardflux: user state\n', who)
148
+
149
+
150
+ def override(cwd, tree, source, paths):
151
+ """tree with each of paths (files or directories) taken from source, the user's working state, or removed when
152
+ source lacks it."""
153
+ fd, index = tempfile.mkstemp(prefix='shard-land-index-')
154
+ os.close(fd)
155
+ os.unlink(index)
156
+ env = {'GIT_INDEX_FILE': index}
157
+ try:
158
+ git(cwd, 'read-tree', tree, env=env)
159
+ git(cwd, 'rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', *paths, env=env)
160
+ listing = git(cwd, 'ls-tree', '-r', '-z', '--full-tree', source, '--', *paths).stdout
161
+ lines = b''.join(item.replace(b' blob ', b' ', 1).replace(b' commit ', b' ', 1) + b'\0' for item in listing.split(b'\0') if item)
162
+ if lines:
163
+ git(cwd, 'update-index', '-z', '--index-info', env=env, data=lines)
164
+ return text(cwd, 'write-tree', env=env)
165
+ finally:
166
+ if os.path.exists(index):
167
+ os.unlink(index)
168
+
169
+
170
+ def changed(cwd, a, b):
171
+ out = git(cwd, 'diff-tree', '-r', '-z', '--name-only', '--no-renames', a, b).stdout.decode('utf-8', 'surrogateescape')
172
+ return sorted({x for x in out.split('\0') if x})
173
+
174
+
175
+ def hub_ref(cwd, hub, ref):
176
+ if not hub or not os.path.isdir(hub):
177
+ return ''
178
+ # One ref read on the shared storage (ls-remote would start upload-pack and list every ref: most of a second).
179
+ p = git(cwd, '--git-dir=' + hub, 'rev-parse', '-q', '--verify', ref, ok=(0, 1, 128))
180
+ return p.stdout.decode().strip() if p.returncode == 0 else ''
181
+
182
+
183
+ def land(a):
184
+ cwd, hub, name = a['repo'], a.get('hub') or '', a['name']
185
+ if not os.path.isdir(hub):
186
+ hub = ''
187
+ branch = 'refs/heads/' + a['branch']
188
+ head = git(cwd, 'symbolic-ref', '-q', 'HEAD', ok=(0, 1)).stdout.decode().strip()
189
+ if head != branch:
190
+ raise Fail('not_on_branch', 'the conversation worktree is on %s, not %s' % (head or 'a detached HEAD', branch))
191
+ tip = commit(cwd, 'HEAD')
192
+ phase('start')
193
+ if a.get('bundle_b64'):
194
+ fd, path = tempfile.mkstemp(prefix='shard-twin-', suffix='.bundle')
195
+ with os.fdopen(fd, 'wb') as f:
196
+ f.write(base64.b64decode(a['bundle_b64']))
197
+ a['bundle'] = path
198
+ if a.get('bundle'):
199
+ # A VM without an earlier landing of this conversation lacks the bundle's prerequisites: the CLI then sends
200
+ # everything since the conversation's base.
201
+ try:
202
+ p = git(cwd, 'fetch', '-q', '--no-tags', a['bundle'], '+%s:refs/shard/twin-in' % a['twin_ref'], ok=(0, 1, 128))
203
+ finally:
204
+ if a.get('bundle_b64'):
205
+ os.unlink(a['bundle'])
206
+ phase('twin_in')
207
+ if p.returncode != 0:
208
+ reason = 'prerequisites' if b'prerequisite' in p.stderr else 'git'
209
+ raise Fail(reason, 'twin bundle: %s' % p.stderr.decode('utf-8', 'replace').strip()[-400:])
210
+ twin = commit(cwd, a['twin'])
211
+ if not twin:
212
+ raise Fail('twin_missing', 'the twin %s did not arrive' % a['twin'])
213
+ base = commit(cwd, twin + '^1')
214
+ if not base:
215
+ raise Fail('twin_unborn', 'the twin has no branch commit')
216
+ git(cwd, 'update-ref', 'refs/shard/twin-in', twin)
217
+ result = {'base': base, 'twin': twin, 'tip': base, 'result': twin, 'upto': tip, 'conversation_tip': tip}
218
+ upto = ''
219
+ candidates = (lambda: a.get('upto') or '', lambda: commit(cwd, 'refs/shard/land/upto'),
220
+ lambda: hub_ref(cwd, hub, 'refs/shard/land/%s/upto' % name), lambda: a.get('base') or '')
221
+ for get in candidates:
222
+ candidate = get()
223
+ if candidate and commit(cwd, candidate) and ancestor(cwd, candidate, tip):
224
+ upto = commit(cwd, candidate)
225
+ break
226
+ if not upto:
227
+ upto = text(cwd, 'merge-base', tip, base) if git(cwd, 'merge-base', tip, base, ok=(0, 1)).returncode == 0 else ''
228
+ if not upto:
229
+ raise Fail('unrelated', 'the conversation shares no history with the twin')
230
+ if upto == tip or ancestor(cwd, tip, base):
231
+ result['state'] = 'noop'
232
+ return result
233
+ phase('upto')
234
+ who = committer(cwd)
235
+ log = git(cwd, 'log', '--format=%H%x1f%B%x1e', '%s..%s' % (base, tip)).stdout if ancestor(cwd, base, tip) else b''
236
+ commits = [x for x in text(cwd, 'rev-list', '--reverse', '--first-parent', tip, '^' + upto, '^' + base).split('\n') if x]
237
+ if ancestor(cwd, base, tip) and not any(snapshot(e.split(b'\x1f', 1)[1]) for e in log.split(b'\x1e') if b'\x1f' in e):
238
+ new, method = tip, 'fast-forward'
239
+ else:
240
+ new, method = replay(cwd, base, commits, who), 'replay'
241
+ if new is None:
242
+ mb = user_side(cwd, upto, base, tip, who)
243
+ tree, conflicts = merge_tree(cwd, mb, base, tip) if mb else ('', ['(user branch)'])
244
+ if conflicts:
245
+ result.update(state='conflict', stage='commits', conflicts=conflicts)
246
+ return result
247
+ subjects = []
248
+ for c in commits:
249
+ m = message_of(cwd, c)
250
+ if not snapshot(m) and not commit(cwd, c + '^2'):
251
+ subjects.append('- ' + m.decode('utf-8', 'replace').split('\n', 1)[0])
252
+ body = '%s\n\n%s\n\nShardflux-Conversation: %s\n' % (a.get('title') or a['branch'], '\n'.join(subjects), a.get('conversation_id', ''))
253
+ new, method = commit_tree(cwd, tree, [base], body.encode(), dict(who, **author_of(cwd, tip))), 'squash'
254
+ phase('commits')
255
+ result.update(tip=new, method=method)
256
+ if new == base or tree_of(cwd, new) == tree_of(cwd, base):
257
+ result['state'] = 'noop'
258
+ return result
259
+ if a.get('mode') == 'pr':
260
+ git(cwd, 'push', '-q', '--force', 'origin', '%s:refs/heads/%s' % (new, a['branch']))
261
+ url = subprocess.run(['gh', 'pr', 'view', a['branch'], '--json', 'url,state', '--jq', 'select(.state == "OPEN") | .url'],
262
+ cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.decode().strip()
263
+ if not url:
264
+ p = subprocess.run(['gh', 'pr', 'create', '--base', a['into'], '--head', a['branch'], '--title', a.get('title') or a['branch'],
265
+ '--body', a.get('summary') or ''], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
266
+ if p.returncode != 0:
267
+ raise Fail('pr', 'gh pr create failed: %s' % p.stderr.decode('utf-8', 'replace').strip()[-400:])
268
+ url = p.stdout.decode().strip().split('\n')[-1]
269
+ result.update(state='pr', url=url)
270
+ return result
271
+ tree, conflicts = merge_tree(cwd, base, twin, new)
272
+ shadowed = list(conflicts)
273
+ if conflicts:
274
+ tree, rest = merge_tree(cwd, base, twin, new, 'ours')
275
+ if rest:
276
+ tree = override(cwd, tree, twin, rest)
277
+ trailers = 'Shardflux-Conversation: %s\nShardflux-Into: %s\nShardflux-Twin: %s\nShardflux-Upto: %s\n' % (a.get('conversation_id', ''), a['into'], twin, tip)
278
+ res = commit_tree(cwd, tree, [new], ('Shardflux landing result\n\n' + trailers).encode(), dict(who, GIT_AUTHOR_NAME='Shardflux', GIT_AUTHOR_EMAIL='landing@shardflux.dev'))
279
+ git(cwd, 'update-ref', 'refs/shard/land/tip', new)
280
+ git(cwd, 'update-ref', 'refs/shard/land/result', res)
281
+ phase('result')
282
+ out = a['out']
283
+ git(cwd, 'bundle', 'create', '-q', out, 'refs/shard/land/tip', 'refs/shard/land/result', '--not', base)
284
+ phase('bundle')
285
+ size = os.path.getsize(out)
286
+ result.update(state='ready', result=res, files=changed(cwd, twin, res), shadowed=shadowed, bundle=out, bundle_bytes=size)
287
+ if size <= INLINE_BYTES:
288
+ with open(out, 'rb') as f:
289
+ result['bundle_b64'] = base64.b64encode(f.read()).decode()
290
+ os.unlink(out)
291
+ result['bundle'] = None
292
+ return result
293
+
294
+
295
+ def confirm(a):
296
+ cwd, hub = a['repo'], a.get('hub') or ''
297
+ upto = commit(cwd, a['upto'])
298
+ if not upto:
299
+ raise Fail('unknown_commit', 'landed commit %s is not in this repository' % a['upto'])
300
+ git(cwd, 'update-ref', 'refs/shard/land/upto', upto)
301
+ if a.get('into'):
302
+ # The guest's turn-end report keeps the conversation landed while HEAD is this commit.
303
+ path = text(cwd, 'rev-parse', '--git-path', 'shard-landed')
304
+ path = path if os.path.isabs(path) else os.path.join(cwd, path)
305
+ with open(path + '.tmp', 'w') as f:
306
+ f.write('%s %s\n' % (upto, a['into']))
307
+ os.replace(path + '.tmp', path)
308
+ phase('recorded')
309
+ if hub and os.path.isdir(hub):
310
+ refs = ['+%s:refs/shard/land/%s/upto' % (upto, a['name'])]
311
+ for sha, ref in ((a.get('twin'), a.get('hub_twin_ref')), (a.get('tip'), 'refs/shard/land/%s/tip' % a['name']),
312
+ (a.get('result'), 'refs/shard/land/%s/result' % a['name'])):
313
+ if sha and ref and commit(cwd, sha):
314
+ refs.append('+%s:%s' % (sha, ref))
315
+ git(cwd, 'push', '-q', '--atomic', '--no-verify', hub, *refs)
316
+ phase('hub')
317
+ return {'state': 'confirmed', 'upto': upto, 'hub': bool(hub and os.path.isdir(hub))}
318
+
319
+
320
+ def main():
321
+ a = json.loads(sys.stdin.read())
322
+ try:
323
+ out = confirm(a) if a.get('op') == 'confirm' else land(a)
324
+ except Fail as e:
325
+ out = {'state': 'error', 'reason': e.reason, 'message': str(e)}
326
+ out['phases'] = PHASES
327
+ sys.stdout.write(json.dumps(out) + '\n')
328
+
329
+
330
+ if __name__ == '__main__':
331
+ main()
@@ -0,0 +1,296 @@
1
+ import type { CellClient } from '@shardflux/sdk';
2
+ import { AgentStateStore } from './agent-state.js';
3
+ /** Files above this stay at HEAD's version in a twin (the bound the seed copy already uses). */
4
+ export declare const TWIN_MAX_FILE_BYTES: number;
5
+ /** A file the landing rewrites must be untouched this long first. */
6
+ export declare const QUIET_MS = 2000;
7
+ export declare const HUB_PATH = "/shardflux/project/git/hub.git";
8
+ export interface GitResult {
9
+ code: number;
10
+ stdout: Buffer;
11
+ stderr: string;
12
+ }
13
+ export declare class TwinGitError extends Error {
14
+ readonly code: number;
15
+ readonly stderr: string;
16
+ constructor(args: string[], result: GitResult);
17
+ }
18
+ export interface GitOptions {
19
+ env?: Record<string, string>;
20
+ input?: string | Uint8Array;
21
+ ok?: number[];
22
+ signal?: AbortSignal;
23
+ }
24
+ /** Git with no hooks, no fsmonitor and no optional locks; binary-safe output. */
25
+ export declare function git(cwd: string, args: string[], options?: GitOptions): Promise<GitResult>;
26
+ export declare function gitText(cwd: string, args: string[], options?: GitOptions): Promise<string>;
27
+ export interface WorktreeInfo {
28
+ root: string;
29
+ gitDir: string;
30
+ commonDir: string;
31
+ head: string | null;
32
+ branch: string | null;
33
+ }
34
+ export declare function inspectWorktree(root: string): Promise<WorktreeInfo>;
35
+ /** The user's own Git operation in progress: the twin waits for it. */
36
+ export declare function pauseReason(info: Pick<WorktreeInfo, 'gitDir' | 'commonDir'>): string | null;
37
+ export interface WorktreeEntry {
38
+ root: string;
39
+ head: string | null;
40
+ branch: string | null;
41
+ }
42
+ export declare function listWorktrees(anyRoot: string): Promise<WorktreeEntry[]>;
43
+ export declare const worktreeId: (root: string) => string;
44
+ export declare const twinRef: (id: string) => string;
45
+ export declare const hubTwinRef: (device: string, id: string) => string;
46
+ export interface Snapshot {
47
+ commit: string;
48
+ tree: string;
49
+ base: string;
50
+ branch: string | null;
51
+ root: string;
52
+ id: string;
53
+ held: string[];
54
+ headTree: string;
55
+ }
56
+ /**
57
+ * One snapshot commit of the worktree's working state (what `git add -A` sees; `.shard/` and files above the size gate
58
+ * left at HEAD's version), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
59
+ */
60
+ export declare function snapshotWorktree(info: WorktreeInfo, options: {
61
+ device: string;
62
+ stateDir: string;
63
+ maxFileBytes?: number;
64
+ now?: Date;
65
+ signal?: AbortSignal;
66
+ }): Promise<Snapshot>;
67
+ /** A twin for a branch checked out nowhere: its own tree, first parent its tip. */
68
+ export declare function refTwin(repo: string, branch: string, device: string, now?: Date): Promise<Snapshot>;
69
+ export interface LandingPlan {
70
+ /** The worktree where the branch is checked out; null lands the branch ref only. */
71
+ worktree: WorktreeInfo | null;
72
+ repo: string;
73
+ branch: string;
74
+ base: string;
75
+ twin: string;
76
+ tip: string;
77
+ result: string;
78
+ }
79
+ export interface Applied {
80
+ state: 'applied';
81
+ files: string[];
82
+ raced: string[];
83
+ backup: string | null;
84
+ at: number;
85
+ }
86
+ export type ApplyOutcome = Applied | {
87
+ state: 'paused';
88
+ reason: string;
89
+ } | {
90
+ state: 'moved';
91
+ reason: string;
92
+ paths?: string[];
93
+ } | {
94
+ state: 'busy';
95
+ waitMs: number;
96
+ };
97
+ export interface ApplyOptions {
98
+ backupRoot: string;
99
+ quietMs?: number;
100
+ now?: () => number;
101
+ label: {
102
+ conversation_id: string;
103
+ name: string;
104
+ title: string;
105
+ };
106
+ /** Called just before each file's final check (tests reproduce a save in that gap). */
107
+ beforeWrite?: (path: string) => void;
108
+ }
109
+ /** Prune twin backups older than 14 days, then the oldest while the total exceeds 1 GiB. */
110
+ export declare function pruneBackups(root: string, now?: number, maxDays?: number, maxBytes?: number): string[];
111
+ export interface BackupManifest {
112
+ version: 1;
113
+ created_at: string;
114
+ worktree: string;
115
+ branch: string;
116
+ conversation_id: string;
117
+ name: string;
118
+ title: string;
119
+ from: string;
120
+ to: string;
121
+ twin: string;
122
+ result: string;
123
+ files: {
124
+ path: string;
125
+ action: 'modify' | 'delete' | 'create';
126
+ before: string | null;
127
+ after: string | null;
128
+ saved: boolean;
129
+ }[];
130
+ }
131
+ /** Writes each backed-up file back (the current content is backed up first); files the landing created are removed. */
132
+ export declare function restoreBackup(backup: string, options: {
133
+ backupRoot: string;
134
+ now?: () => number;
135
+ }): Promise<{
136
+ restored: string[];
137
+ backup: string;
138
+ }>;
139
+ /**
140
+ * Moves the branch from plan.base to plan.tip and the worktree from plan.twin's tree to plan.result's tree, only where
141
+ * the disk still holds what the twin captured and has been quiet. Never drops a user's edit: a changed file means
142
+ * 'moved' (redo the round), a running Git operation 'paused', a recent write 'busy'.
143
+ */
144
+ export declare function applyLanding(plan: LandingPlan, options: ApplyOptions): Promise<ApplyOutcome>;
145
+ /** Index entries that equal the old tip move to the new tip; entries the user staged differently are merged three ways. */
146
+ export declare function refreshIndex(root: string, base: string, tip: string, attempts?: number): Promise<void>;
147
+ export type TwinCell = {
148
+ exec: Pick<CellClient['exec'], 'run'>;
149
+ files: Pick<CellClient['files'], 'read' | 'write' | 'remove' | 'stat'>;
150
+ };
151
+ export interface RoundConversation {
152
+ id: string;
153
+ name: string;
154
+ title: string;
155
+ summary: string;
156
+ branch: string;
157
+ base_commit: string;
158
+ worktree_path: string;
159
+ }
160
+ export interface RoundInput {
161
+ conversation: RoundConversation;
162
+ into: string;
163
+ mode: 'auto' | 'pr';
164
+ device: string;
165
+ repo: string;
166
+ worktree: WorktreeInfo | null;
167
+ stateDir: string;
168
+ backupRoot: string;
169
+ /** The conversation's last landed tip known here (the ledger), and local commits the VM is known to have. */
170
+ upto?: string;
171
+ known?: string[];
172
+ hub?: string;
173
+ vmTmp?: string;
174
+ quietMs?: number;
175
+ signal?: AbortSignal;
176
+ now?: () => number;
177
+ /**
178
+ * The conversation's VM is gone (it ended while this computer was away): the round runs in another VM of the project
179
+ * that reads the project hub, on a temporary repository holding the conversation's branch from the hub. The hub is
180
+ * only read; repoRemote lets `pr` push to the repository's origin.
181
+ */
182
+ fromHub?: {
183
+ repoRemote?: string;
184
+ };
185
+ }
186
+ export interface LandResult {
187
+ state: 'ready' | 'noop' | 'conflict' | 'pr' | 'error';
188
+ base?: string;
189
+ twin?: string;
190
+ tip?: string;
191
+ result?: string;
192
+ upto?: string;
193
+ conversation_tip?: string;
194
+ method?: string;
195
+ phases?: Record<string, number>;
196
+ files?: string[];
197
+ shadowed?: string[];
198
+ conflicts?: string[];
199
+ stage?: string;
200
+ url?: string;
201
+ reason?: string;
202
+ message?: string;
203
+ bundle?: string | null;
204
+ bundle_b64?: string;
205
+ bundle_bytes?: number;
206
+ hub?: boolean;
207
+ }
208
+ export type RoundOutcome = {
209
+ state: 'landed';
210
+ land: LandResult;
211
+ applied: Applied;
212
+ timings: Record<string, number>;
213
+ confirmed: Promise<void>;
214
+ } | {
215
+ state: 'noop' | 'pr' | 'conflict' | 'error';
216
+ land: LandResult;
217
+ timings: Record<string, number>;
218
+ } | {
219
+ state: 'paused' | 'moved' | 'busy';
220
+ reason: string;
221
+ waitMs?: number;
222
+ land?: LandResult;
223
+ timings: Record<string, number>;
224
+ };
225
+ /** The landing script, shipped next to this module (src in the monorepo, dist when published). */
226
+ export declare function landingScript(): string;
227
+ export declare function runLandingScript(cell: TwinCell, input: Record<string, unknown>, signal?: AbortSignal): Promise<LandResult>;
228
+ /** A temporary repository in the cell with the conversation's branch (and its earlier landing refs) from the hub. */
229
+ export declare const HUB_CHECKOUT = "set -eu\nd=$(mktemp -d \"${TMPDIR:-/tmp}/shard-land-XXXXXX\")\ngit init -q \"$d\"\ncd \"$d\"\ngit fetch -q --no-tags \"$1\" \"+refs/heads/$2:refs/heads/$2\"\ngit fetch -q --no-tags \"$1\" \"+refs/shard/land/$3/upto:refs/shard/land/upto\" 2>/dev/null || true\ngit symbolic-ref HEAD \"refs/heads/$2\"\nif [ -n \"$4\" ]; then git remote add origin \"$4\"; fi\nprintf '%s\\n' \"$d\"\n";
230
+ export declare function landingRound(cell: TwinCell, input: RoundInput): Promise<RoundOutcome>;
231
+ export type LandingMode = 'auto' | 'pr' | 'off';
232
+ export interface TwinConversationState {
233
+ base_branch?: string;
234
+ seed?: string;
235
+ seed_snapshot?: boolean;
236
+ folder?: string;
237
+ upto?: string;
238
+ tip?: string;
239
+ landed_revision?: string;
240
+ landed_at?: string;
241
+ method?: string;
242
+ conflict_tip?: string;
243
+ conflict_revision?: string;
244
+ pr_url?: string;
245
+ failures?: number;
246
+ retry_at?: number;
247
+ last_error?: string;
248
+ latency_ms?: number;
249
+ }
250
+ interface TwinDoc {
251
+ landing?: Record<string, LandingMode>;
252
+ conversations?: Record<string, TwinConversationState>;
253
+ }
254
+ export declare class TwinLedger {
255
+ readonly store: AgentStateStore;
256
+ constructor(dir: string);
257
+ read(): TwinDoc;
258
+ mode(project: string): LandingMode;
259
+ setMode(project: string, mode: LandingMode): void;
260
+ conversation(id: string): TwinConversationState;
261
+ patch(id: string, change: Partial<TwinConversationState>): void;
262
+ forget(id: string): void;
263
+ update(change: (doc: TwinDoc) => void): void;
264
+ }
265
+ export declare const backupRoot: (stateDir: string) => string;
266
+ export declare function relativeTo(root: string, path: string): string;
267
+ /** This computer's device id (the device helper's), created on first use. */
268
+ export declare function deviceId(stateDir: string): string;
269
+ export interface TwinSeed {
270
+ commit: string;
271
+ snapshot: boolean;
272
+ }
273
+ /**
274
+ * The commit a new conversation starts from: HEAD when the worktree is clean (or landing is off, or the user's Git
275
+ * operation is running), else the twin snapshot S (first parent HEAD). The conversation's own work is then S..T, and
276
+ * the user's edits stay theirs.
277
+ */
278
+ export declare function twinSeed(stateDir: string, folder: string, head: string, project?: string): Promise<TwinSeed>;
279
+ export declare function rememberTwinSeed(stateDir: string, conversation: string, folder: string, seed: TwinSeed, branch?: string): void;
280
+ /** Carries a snapshot seed into the conversation's VM repository (one small bundle: the snapshot over HEAD). */
281
+ export declare function sendTwinSeed(stateDir: string, cell: TwinCell, conversation: {
282
+ id: string;
283
+ base_commit: string;
284
+ worktree_path: string;
285
+ }, folder: string, signal?: AbortSignal): Promise<void>;
286
+ /**
287
+ * The conversation branch at its base. A snapshot base already matches the copied files: the branch starts at HEAD
288
+ * and moves to the snapshot without touching them (`checkout` would refuse the "local changes").
289
+ */
290
+ export declare function twinCheckoutArgv(stateDir: string, conversation: {
291
+ id: string;
292
+ branch: string;
293
+ base_commit: string;
294
+ worktree_path: string;
295
+ }): string[];
296
+ export {};