@zgeoff/atc 2.27.0 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -19,9 +19,19 @@ import { KEY, planTextEdit } from './keys';
19
19
  import { pickRefusalStep } from './pick-refusal-step';
20
20
  import { resolvePathInput } from './resolve-path-input';
21
21
  import { resolveWorkspaceRoot } from './resolve-workspace-root';
22
- import { ansi, cols, drawPicker } from './ui';
23
-
24
- type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
22
+ import { splitToWidth } from './split-to-width';
23
+ import { ansi, cols, drawPicker, getPickerWidth } from './ui';
24
+
25
+ type PickerStep =
26
+ | 'agent'
27
+ | 'dir'
28
+ | 'source'
29
+ | 'ref'
30
+ | 'target'
31
+ | 'confirm'
32
+ | 'name'
33
+ | 'prompt'
34
+ | 'spawned';
25
35
 
26
36
  // What the flow borrows from the client that owns the screen, the daemon
27
37
  // connection and the fleet mirror.
@@ -226,6 +236,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
226
236
 
227
237
  private pending: PendingRequest | null = null;
228
238
 
239
+ // A session the daemon started with warnings, and those warnings, shown
240
+ // before the flow attaches it.
241
+ private spawned: TMirror | null = null;
242
+
243
+ private spawnWarnings: readonly string[] = [];
244
+
229
245
  private requestSeq = 0;
230
246
 
231
247
  // The flow's generation. Opening the flow and every way out of it moves
@@ -281,6 +297,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
281
297
  applyKey(buf: Buffer) {
282
298
  this.refusal = null;
283
299
 
300
+ if (this.step === 'spawned') {
301
+ this.applySpawnedKey(buf);
302
+
303
+ return;
304
+ }
305
+
284
306
  const edit = planTextEdit(buf, this.input, {
285
307
  isLeaderKey: this.deps.isLeaderKey,
286
308
  moves:
@@ -387,6 +409,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
387
409
  this.renderTargetStep(verb);
388
410
  } else if (this.step === 'confirm') {
389
411
  this.renderConfirmStep();
412
+ } else if (this.step === 'spawned') {
413
+ drawPicker({
414
+ title: `${verb}: started`,
415
+ items: this.spawnWarnings.flatMap((w) => splitToWidth(w, getPickerWidth() - 4)),
416
+ selected: -1,
417
+ input: '',
418
+ hint: '⏎ attach · esc back',
419
+ });
390
420
  } else if (this.step === 'name') {
391
421
  const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
392
422
 
@@ -1714,9 +1744,49 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1714
1744
  this.stopFlow();
1715
1745
  this.deps.upsertMirror(session);
1716
1746
 
1747
+ const warnings = spawned.answer['warnings'];
1748
+
1749
+ // A workspace that left changes behind says so before the session's
1750
+ // screen takes over.
1751
+ if (Array.isArray(warnings) && warnings.length > 0) {
1752
+ this.spawned = session;
1753
+ this.spawnWarnings = warnings.filter((w): w is string => typeof w === 'string');
1754
+ this.step = 'spawned';
1755
+
1756
+ process.stdout.write(ansi.clear);
1757
+ this.render();
1758
+
1759
+ return;
1760
+ }
1761
+
1717
1762
  await this.deps.attach(session.id);
1718
1763
  }
1719
1764
 
1765
+ // The started step takes Enter to attach the session and Esc or the
1766
+ // leader to return to the screen the flow came from; the session runs
1767
+ // either way.
1768
+ private applySpawnedKey(buf: Buffer) {
1769
+ const edit = planTextEdit(buf, '', { isLeaderKey: this.deps.isLeaderKey, moves: false });
1770
+
1771
+ if (edit.kind !== 'submit' && edit.kind !== 'cancel' && edit.kind !== 'leader') {
1772
+ return;
1773
+ }
1774
+
1775
+ const session = this.spawned;
1776
+
1777
+ this.spawned = null;
1778
+ this.spawnWarnings = [];
1779
+ this.step = 'agent';
1780
+
1781
+ if (edit.kind === 'submit' && session !== null) {
1782
+ void this.deps.attach(session.id);
1783
+
1784
+ return;
1785
+ }
1786
+
1787
+ this.deps.toBase();
1788
+ }
1789
+
1720
1790
  // A refused git workspace spawn returns to the step that can fix it. Any
1721
1791
  // other refusal keeps the picker on the step that sent it, with the
1722
1792
  // entered text back in the input, so the user can fix the cause and retry
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Splits text into rows of at most `width` characters, breaking at spaces
3
+ * and counting a grapheme cluster as one character. A word wider than a
4
+ * row breaks at the row's edge, never inside a character.
5
+ */
6
+ export function splitToWidth(text: string, width: number): string[] {
7
+ if (width <= 0) {
8
+ return [text];
9
+ }
10
+
11
+ const segmenter = new Intl.Segmenter();
12
+
13
+ const rows: string[] = [];
14
+ let row: string[] = [];
15
+
16
+ for (const word of text.split(' ')) {
17
+ let rest = Array.from(segmenter.segment(word), (part) => part.segment);
18
+
19
+ while (rest.length > width) {
20
+ if (row.length > 0) {
21
+ rows.push(row.join(''));
22
+
23
+ row = [];
24
+ }
25
+
26
+ rows.push(rest.slice(0, width).join(''));
27
+
28
+ rest = rest.slice(width);
29
+ }
30
+
31
+ if (row.length === 0) {
32
+ row = rest;
33
+ } else if (row.length + 1 + rest.length <= width) {
34
+ row = [...row, ' ', ...rest];
35
+ } else {
36
+ rows.push(row.join(''));
37
+
38
+ row = rest;
39
+ }
40
+ }
41
+
42
+ if (row.length > 0) {
43
+ rows.push(row.join(''));
44
+ }
45
+
46
+ return rows;
47
+ }
package/src/client/ui.ts CHANGED
@@ -65,6 +65,14 @@ export function cols(): number {
65
65
  return process.stdout.columns || 80;
66
66
  }
67
67
 
68
+ /**
69
+ * The width of the picker box, borders included. An item row holds 4
70
+ * columns fewer.
71
+ */
72
+ export function getPickerWidth(): number {
73
+ return Math.min(cols() - 4, 90);
74
+ }
75
+
68
76
  export function rows(): number {
69
77
  return process.stdout.rows || 24;
70
78
  }
@@ -393,7 +401,7 @@ export interface PickerView {
393
401
  }
394
402
 
395
403
  export function drawPicker(view: PickerView) {
396
- const width = Math.min(cols() - 4, 90);
404
+ const width = getPickerWidth();
397
405
  const rowsList: Row[] = [boxTop(width, view.title)];
398
406
  const shown = view.items.slice(0, 10);
399
407
 
@@ -326,7 +326,7 @@ async function resolveSource(
326
326
  await requireNoOriginRewriteCredentials(source.path);
327
327
 
328
328
  const resolved = await resolvePathSource(source.path, {
329
- allowDirty: source.allowDirty ?? 'refuse',
329
+ ...(source.allowDirty === undefined ? {} : { allowDirty: source.allowDirty }),
330
330
  transports,
331
331
  });
332
332
 
@@ -58,7 +58,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
58
  'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
59
59
  ),
60
60
  workspace: SPAWN_SCHEMA.shape.workspace.describe(
61
- "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, refusing uncommitted changes unless allowDirty is 'warn'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
61
+ "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
62
62
  ),
63
63
  trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
64
64
  'Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source, an imp target, and a brokered Claude gateway with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.',
@@ -54,8 +54,9 @@ const GIT_SHA = z.string().regex(COMMIT_ID, 'a git workspace sha is a full commi
54
54
  * Where a spawn's working directory comes from, materialized as a clean
55
55
  * checkout into the spawn's `cwd` on its execution target. A `path` source
56
56
  * is a directory on the daemon's host, resolved to its origin URL and
57
- * pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
58
- * changes to HEAD and leaves the changes behind with a warning. A `git`
57
+ * pushed HEAD. A tree with uncommitted or untracked changes resolves to
58
+ * HEAD and leaves the changes behind with a warning, unless `allowDirty`
59
+ * is `'refuse'`, which refuses it. A `git`
59
60
  * source is a repository URL with a branch or tag `ref`, a full commit
60
61
  * `sha`, or both, and an optional `credentialRef` naming the daemon
61
62
  * environment variable that holds its token. With both, the sha is the
@@ -39,8 +39,9 @@ interface PathSourceRefusal {
39
39
  * can clone, so a workspace never carries files that exist only here. The
40
40
  * commit is HEAD, and it must already be on origin: a remote-tracking ref
41
41
  * under origin contains it, or origin advertises it as a ref tip. A tree
42
- * with uncommitted or untracked changes is refused, or with `allowDirty:
43
- * 'warn'` resolves to HEAD and leaves those changes behind with a warning.
42
+ * with uncommitted or untracked changes resolves to HEAD and leaves those
43
+ * changes behind, with a warning that counts them; `allowDirty: 'refuse'`
44
+ * refuses it instead. The changes are never copied.
44
45
  * Submodules are refused. The URL is origin's, with any credential stripped.
45
46
  */
46
47
  export async function resolvePathSource(
@@ -81,7 +82,9 @@ export async function resolvePathSource(
81
82
  return { ok: false, code: 'has_submodules', message: `${root} uses submodules` };
82
83
  }
83
84
 
84
- const status = await runGit(['status', '--porcelain', '--untracked-files=normal'], { cwd: root });
85
+ // Every untracked file is listed on its own, so an untracked directory
86
+ // counts each file inside it.
87
+ const status = await runGit(['status', '--porcelain', '--untracked-files=all'], { cwd: root });
85
88
 
86
89
  if (status.exitCode !== 0) {
87
90
  return {
@@ -91,10 +94,11 @@ export async function resolvePathSource(
91
94
  };
92
95
  }
93
96
 
94
- const dirty = status.stdout.trim() !== '';
97
+ const changed = countChangedPaths(status.stdout);
98
+ const dirty = changed > 0;
95
99
  const warnings: string[] = [];
96
100
 
97
- if (dirty && options.allowDirty !== 'warn') {
101
+ if (dirty && options.allowDirty === 'refuse') {
98
102
  return {
99
103
  ok: false,
100
104
  code: 'workspace_dirty',
@@ -103,7 +107,10 @@ export async function resolvePathSource(
103
107
  }
104
108
 
105
109
  if (dirty) {
106
- warnings.push(`uncommitted and untracked changes in ${root} stay behind; using ${sha}`);
110
+ // The commit leads, so a display that cuts the note short keeps it.
111
+ warnings.push(
112
+ `cloned commit ${sha.slice(0, 12)}; left ${changed} uncommitted or untracked ${changed === 1 ? 'path' : 'paths'} behind in ${root}`,
113
+ );
107
114
  }
108
115
 
109
116
  const origin = await runGit(['ls-remote', '--get-url', 'origin'], { cwd: root });
@@ -150,6 +157,14 @@ function hasSubmodules(listing: string): boolean {
150
157
  .some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'));
151
158
  }
152
159
 
160
+ /**
161
+ * How many paths a porcelain status listing holds. Only the count leaves
162
+ * this module: a path's name can itself be sensitive.
163
+ */
164
+ function countChangedPaths(porcelain: string): number {
165
+ return porcelain.split('\n').filter((line) => line.trim() !== '').length;
166
+ }
167
+
153
168
  /**
154
169
  * Whether origin already has the commit. A local remote-tracking ref proves
155
170
  * it without network access; failing that, origin's advertised ref tips are