@zgeoff/atc 2.33.0 → 2.35.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.
@@ -1,5 +1,6 @@
1
1
  interface DestinationParts {
2
- // The root the checkout lands under, absolute on its target.
2
+ // The root the checkout lands under: absolute on its target, or relative
3
+ // to the target user's home, where an empty root is the home itself.
3
4
  readonly root: string;
4
5
 
5
6
  // The repository URL the checkout comes from, as the daemon resolved it.
@@ -7,15 +8,18 @@ interface DestinationParts {
7
8
 
8
9
  // The branch or tag the commit was chosen by, or null for a bare commit.
9
10
  readonly ref: string | null;
10
- readonly sha: string;
11
+
12
+ // The commit checked out, or null when only the ref picks it.
13
+ readonly sha: string | null;
11
14
  }
12
15
 
13
16
  /**
14
17
  * The default directory a git workspace lands in: `<repo>-<ref>-<short
15
- * sha>` under the root, with the ref left out for a bare commit. The repo
16
- * is the URL's last path segment without `.git`, and each part keeps only
17
- * `[A-Za-z0-9._-]`, with `/` turned into `-`, so the name is one path
18
- * segment on any target.
18
+ * sha>` under the root, with the ref left out for a bare commit and the
19
+ * short sha for a ref alone. The repo is the URL's last path segment
20
+ * without `.git`, and each part keeps only `[A-Za-z0-9._-]`, with `/`
21
+ * turned into `-`, so the name is one path segment on any target; a name
22
+ * with nothing left is `workspace`.
19
23
  */
20
24
  export function buildWorkspaceDestination(parts: DestinationParts): string {
21
25
  const repo =
@@ -25,12 +29,19 @@ export function buildWorkspaceDestination(parts: DestinationParts): string {
25
29
  .at(-1)
26
30
  ?.replace(/\.git$/u, '') ?? '';
27
31
 
28
- const name = [repo, ...(parts.ref === null ? [] : [parts.ref]), parts.sha.slice(0, 7)]
32
+ const name = [
33
+ repo,
34
+ ...(parts.ref === null ? [] : [parts.ref]),
35
+ ...(parts.sha === null ? [] : [parts.sha.slice(0, 7)]),
36
+ ]
29
37
  .map((part) => toSafeSegment(part))
30
38
  .filter((part) => part !== '')
31
39
  .join('-');
32
40
 
33
- return `${parts.root.replace(/\/+$/u, '')}/${name}`;
41
+ const segment = name === '' ? 'workspace' : name;
42
+ const root = parts.root.replace(/\/+$/u, '');
43
+
44
+ return parts.root === '' ? segment : `${root}/${segment}`;
34
45
  }
35
46
 
36
47
  function toSafeSegment(part: string): string {
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { z } from 'zod';
4
+ import { buildOptionalBoolean } from './build-optional-boolean';
4
5
  import { buildOptionalString } from './build-optional-string';
5
6
  import { buildOptionalStringArray } from './build-optional-string-array';
6
7
  import { collectAuthProfiles } from './collect-auth-profiles';
@@ -65,6 +66,11 @@ export interface Config {
65
66
 
66
67
  // The workspace config problems, one line each.
67
68
  workspaceErrors: readonly string[];
69
+
70
+ // Whether a fleet restore after a daemon restart sends a session that
71
+ // was mid-turn one message to carry on; a spawn can override it per
72
+ // session.
73
+ resumeInterruptedTurns: boolean;
68
74
  }
69
75
 
70
76
  /**
@@ -109,6 +115,7 @@ const DEFAULTS: Config = {
109
115
  principals: null,
110
116
  principalErrors: [],
111
117
  workspaceErrors: [],
118
+ resumeInterruptedTurns: false,
112
119
  };
113
120
 
114
121
  const configDir = join(resolveHomeDir(), '.config', 'atc');
@@ -148,6 +155,7 @@ const CONFIG_SCHEMA = z.object({
148
155
  targets: z.unknown().optional(),
149
156
  defaultTarget: z.unknown().optional(),
150
157
  principals: z.unknown().optional(),
158
+ resumeInterruptedTurns: buildOptionalBoolean(),
151
159
  });
152
160
 
153
161
  /**
@@ -336,6 +344,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
336
344
  principals: principals.principals,
337
345
  principalErrors: principals.errors,
338
346
  workspaceErrors: workspaces.errors,
347
+ resumeInterruptedTurns: parsed.data.resumeInterruptedTurns ?? DEFAULTS.resumeInterruptedTurns,
339
348
  };
340
349
  }
341
350
 
package/src/statusline.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
3
+ import { resolveAgentHome } from './agents/resolve-agent-home';
4
4
  import { sendBridgeRequest } from './protocol/send-bridge-request';
5
5
  import { statusFile } from './shared/config';
6
6
  import { isRecord, sendReport } from './shared/report';
7
7
 
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
10
- * user's own statusline (from ~/.claude/settings.json), appends the atc fleet
10
+ * user's own statusline (from settings.json in the Claude config folder,
11
+ * `$CLAUDE_CONFIG_DIR` or ~/.claude), appends the atc fleet
11
12
  * segment, and heartbeats the session id back to the atc socket, with the
12
13
  * agent id the command gave it. Always exits 0 so it never breaks the
13
14
  * session it renders for.
@@ -37,7 +38,7 @@ export async function runStatusline(agent: string): Promise<void> {
37
38
  let chained = '';
38
39
 
39
40
  try {
40
- const settingsPath = join(homedir(), '.claude', 'settings.json');
41
+ const settingsPath = join(resolveAgentHome('CLAUDE_CONFIG_DIR', '.claude'), 'settings.json');
41
42
  const rawSettings = readFileSync(settingsPath, 'utf8');
42
43
  const settings: unknown = JSON.parse(rawSettings);
43
44
  const statusLine = isRecord(settings) ? settings['statusLine'] : undefined;
@@ -67,6 +67,11 @@ export interface FleetEntry {
67
67
  // parent's when the two share one host. A row without one runs on a host
68
68
  // of its own.
69
69
  readonly hostKey?: SessionID;
70
+
71
+ // Whether a fleet restore after a daemon restart sends the session one
72
+ // message to carry on a turn the restart cut off; a row without one
73
+ // follows the daemon's config.
74
+ readonly resumeInterruptedTurns?: boolean;
70
75
  }
71
76
 
72
77
  export interface FleetStore {
@@ -34,6 +34,10 @@ interface FleetTable {
34
34
  // its parent's when the two share one host. Null for a row from before
35
35
  // hosts were recorded, which runs on its own.
36
36
  host_key: string | null;
37
+
38
+ // Whether a restore sends the session one message to carry on an
39
+ // interrupted turn: 1 or 0 as the spawn chose, null to follow the config.
40
+ resume_interrupted_turns: number | null;
37
41
  }
38
42
 
39
43
  interface EventsTable {
@@ -549,6 +553,14 @@ const MIGRATIONS: Record<string, Migration> = {
549
553
  .execute();
550
554
  },
551
555
  },
556
+ '026_add_fleet_resume_interrupted_turns': {
557
+ async up(db: Kysely<StateStoreSchema>) {
558
+ await db.schema
559
+ .alterTable('fleet')
560
+ .addColumn('resume_interrupted_turns', 'integer')
561
+ .execute();
562
+ },
563
+ },
552
564
  };
553
565
 
554
566
  const PROVIDER: MigrationProvider = {
@@ -38,6 +38,7 @@ import type {
38
38
 
39
39
  // Spelled as the partial index's predicate so SQLite can match them.
40
40
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
41
+ const TURN_KINDS = ['prompt-submitted', 'turn-done', 'needs-input', 'ended'];
41
42
 
42
43
  /**
43
44
  * Some sessions' slice of the event trail: rows under their atc ids, plus
@@ -210,6 +211,7 @@ export class StateStore {
210
211
  'workspace.withheld_env',
211
212
  'fleet.desired',
212
213
  'fleet.host_key',
214
+ 'fleet.resume_interrupted_turns',
213
215
  ])
214
216
  .execute();
215
217
 
@@ -239,6 +241,9 @@ export class StateStore {
239
241
  ...buildWithheldEnvField(row.withheld_env),
240
242
  ...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
241
243
  ...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
244
+ ...(row.resume_interrupted_turns === null
245
+ ? {}
246
+ : { resumeInterruptedTurns: row.resume_interrupted_turns !== 0 }),
242
247
  });
243
248
  }
244
249
 
@@ -346,6 +351,10 @@ export class StateStore {
346
351
  target_identity: entry.targetIdentity ?? null,
347
352
  desired: entry.desired ?? null,
348
353
  host_key: entry.hostKey ?? null,
354
+ resume_interrupted_turns:
355
+ entry.resumeInterruptedTurns === undefined
356
+ ? null
357
+ : Number(entry.resumeInterruptedTurns),
349
358
  })
350
359
  .execute();
351
360
 
@@ -539,6 +548,31 @@ export class StateStore {
539
548
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
540
549
  }
541
550
 
551
+ // The kind of the session's latest event that moves a turn: a prompt
552
+ // submitted, a turn done, a request for input, or the session's end.
553
+ // Null when the trail holds none. Matches the session by either id, as
554
+ // the activity read does.
555
+ async findLatestTurnKind(
556
+ atcID: SessionID,
557
+ agentSessionID: AgentSessionID | undefined,
558
+ ): Promise<string | null> {
559
+ const row = await this.db
560
+ .selectFrom('events')
561
+ .select('kind')
562
+ .where('kind', 'in', TURN_KINDS)
563
+ .where((eb) =>
564
+ eb.or([
565
+ eb('atc_id', '=', atcID),
566
+ ...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
567
+ ]),
568
+ )
569
+ .orderBy('id', 'desc')
570
+ .limit(1)
571
+ .executeTakeFirst();
572
+
573
+ return row?.kind ?? null;
574
+ }
575
+
542
576
  async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
543
577
  await this.db
544
578
  .insertInto('spawn_history')
@@ -939,6 +973,7 @@ export class StateStore {
939
973
  .updateTable('workspace_materialization')
940
974
  .set({
941
975
  phase: fields.phase,
976
+ ...(fields.dir === undefined ? {} : { dir: fields.dir }),
942
977
  ...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
943
978
  ...(fields.sha === undefined ? {} : { sha: fields.sha }),
944
979
  ...(fields.ref === undefined ? {} : { ref: fields.ref }),
@@ -50,6 +50,10 @@ export interface WorkspaceMaterialization {
50
50
  // The fields a materialization rewrites as it moves through its phases.
51
51
  export interface MaterializationUpdate {
52
52
  readonly phase: MaterializationPhase;
53
+
54
+ // The directory the workspace landed in, once a directory the daemon
55
+ // picked is claimed.
56
+ readonly dir?: string;
53
57
  readonly repoURL?: string;
54
58
  readonly sha?: string;
55
59
  readonly ref?: string | null;