tinker-agent 2.2.0 → 2.4.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.
@@ -13,6 +13,7 @@ import {
13
13
  } from "node:fs/promises";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { Database } from "bun:sqlite";
16
+ import { parseMessageId } from "../ids/runtime-id";
16
17
  import type {
17
18
  ContextRevisionId,
18
19
  ContextSurfaceId,
@@ -34,6 +35,11 @@ import {
34
35
  } from "../model/model-client";
35
36
  import type { ToolDefinition, ToolRawResult } from "../tools/types";
36
37
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
38
+ import {
39
+ canonicalHomeRoot,
40
+ resolveWorkspaceStorageRoot,
41
+ workspaceStorageRoot,
42
+ } from "./workspace-storage";
37
43
  import {
38
44
  ContextProtocolError,
39
45
  ContextProtocolValidator,
@@ -418,6 +424,7 @@ export type CreateNewSessionStoreInput = {
418
424
  projectInstruction?: ProjectInstructionManifest;
419
425
  idFactory: RuntimeIdFactory;
420
426
  clock?: () => string;
427
+ homeRoot?: string;
421
428
  };
422
429
 
423
430
  export type OpenSessionStoreInput = {
@@ -425,6 +432,7 @@ export type OpenSessionStoreInput = {
425
432
  sessionId: SessionId;
426
433
  clock?: () => string;
427
434
  allowIncomplete?: boolean;
435
+ homeRoot?: string;
428
436
  };
429
437
 
430
438
  export class SessionStore implements SessionLedgerCommitter {
@@ -447,6 +455,7 @@ export class SessionStore implements SessionLedgerCommitter {
447
455
  sessionDirectory: string;
448
456
  databasePath: string;
449
457
  clock: () => string;
458
+ homeRoot?: string;
450
459
  },
451
460
  ) {
452
461
  this.sessionId = input.sessionId;
@@ -454,14 +463,17 @@ export class SessionStore implements SessionLedgerCommitter {
454
463
  this.sessionDirectory = input.sessionDirectory;
455
464
  this.databasePath = input.databasePath;
456
465
  this.clock = input.clock;
466
+ this.homeRoot = input.homeRoot;
457
467
  }
458
468
 
469
+ private readonly homeRoot?: string;
470
+
459
471
  private readonly clock: () => string;
460
472
 
461
473
  static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
462
474
  const clock = input.clock ?? (() => new Date().toISOString());
463
475
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
464
- const sessionsRoot = await ensureSessionsRoot(workspaceRoot);
476
+ const sessionsRoot = await ensureSessionsRoot(workspaceRoot, input.homeRoot);
465
477
  const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
466
478
  try {
467
479
  await mkdir(sessionDirectory, { mode: 0o700 });
@@ -540,6 +552,7 @@ export class SessionStore implements SessionLedgerCommitter {
540
552
  sessionDirectory,
541
553
  databasePath,
542
554
  clock,
555
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
543
556
  });
544
557
  await store.correctDatabaseModes();
545
558
  store.validateCreatingState();
@@ -558,7 +571,10 @@ export class SessionStore implements SessionLedgerCommitter {
558
571
  static async openExisting(input: OpenSessionStoreInput): Promise<SessionStore> {
559
572
  const clock = input.clock ?? (() => new Date().toISOString());
560
573
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
561
- const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
574
+ const sessionsRoot = path.join(
575
+ workspaceStorageRoot(workspaceRoot, await canonicalHomeRoot(input.homeRoot)),
576
+ "sessions",
577
+ );
562
578
  await validateSessionsRoot(sessionsRoot, input.sessionId);
563
579
  const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
564
580
  await validateSecureDirectory(sessionDirectory, input.sessionId);
@@ -593,6 +609,7 @@ export class SessionStore implements SessionLedgerCommitter {
593
609
  sessionDirectory,
594
610
  databasePath,
595
611
  clock,
612
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
596
613
  });
597
614
  store.recallIndexRebuilt = recallIndexContractUpgraded;
598
615
  const meta = store.readMeta();
@@ -2366,7 +2383,10 @@ export class SessionStore implements SessionLedgerCommitter {
2366
2383
  if (distinct.size === 0) {
2367
2384
  return;
2368
2385
  }
2369
- const store = await ImageAssetStore.open({ workspaceRoot: this.workspaceRoot });
2386
+ const store = await ImageAssetStore.open({
2387
+ workspaceRoot: this.workspaceRoot,
2388
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2389
+ });
2370
2390
  for (const asset of distinct.values()) {
2371
2391
  await store.verify(asset);
2372
2392
  }
@@ -2670,6 +2690,7 @@ export class SessionStore implements SessionLedgerCommitter {
2670
2690
  sessionDirectory: stagingDirectory,
2671
2691
  databasePath: stagingDatabasePath,
2672
2692
  clock: this.clock,
2693
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2673
2694
  });
2674
2695
  clonedStore.validateAll({ allowOpenTail: false });
2675
2696
  await clonedStore.verifyImageAssetFiles();
@@ -3802,11 +3823,17 @@ function normalizeInputModalities(
3802
3823
  );
3803
3824
  }
3804
3825
 
3805
- export function sessionDatabasePath(
3826
+ export async function resolveSessionDatabasePath(
3806
3827
  workspaceRoot: string,
3807
3828
  sessionId: SessionId,
3808
- ): string {
3809
- return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "session.sqlite");
3829
+ homeRoot?: string,
3830
+ ): Promise<string> {
3831
+ return path.join(
3832
+ await resolveWorkspaceStorageRoot(workspaceRoot, homeRoot),
3833
+ "sessions",
3834
+ sessionId,
3835
+ "session.sqlite",
3836
+ );
3810
3837
  }
3811
3838
 
3812
3839
  function insertFrame(database: Database, frame: ProtocolFrame): void {
@@ -5070,6 +5097,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
5070
5097
  "web_search",
5071
5098
  "web_fetch",
5072
5099
  "recall",
5100
+ "context_maintenance",
5073
5101
  "memory_search",
5074
5102
  "memory_get",
5075
5103
  "wait",
@@ -5088,9 +5116,282 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
5088
5116
  if (kind === "view_image") {
5089
5117
  return decodeStoredViewImageRawResult(raw);
5090
5118
  }
5119
+ if (kind === "context_maintenance") {
5120
+ return decodeStoredContextMaintenanceRawResult(raw);
5121
+ }
5091
5122
  return immutableCanonicalClone(raw) as ToolRawResult;
5092
5123
  }
5093
5124
 
5125
+ function decodeStoredContextMaintenanceRawResult(
5126
+ raw: Record<string, unknown>,
5127
+ ): Extract<ToolRawResult, { kind: "context_maintenance" }> {
5128
+ const operation = enumFromSql(
5129
+ raw.operation,
5130
+ ["status", "candidates", "swap"] as const,
5131
+ "context maintenance operation",
5132
+ );
5133
+ if (raw.ok === false) {
5134
+ if (operation !== "swap") {
5135
+ assertObjectKeys(
5136
+ raw,
5137
+ ["kind", "ok", "operation", "error"],
5138
+ ["kind", "ok", "operation", "error"],
5139
+ `failed context ${operation} result`,
5140
+ );
5141
+ return immutableRecord({
5142
+ kind: "context_maintenance" as const,
5143
+ ok: false as const,
5144
+ operation,
5145
+ error: nonEmptyStringFromJson(raw.error, `context ${operation} error`),
5146
+ });
5147
+ }
5148
+ assertObjectKeys(
5149
+ raw,
5150
+ ["kind", "ok", "operation", "scheduled", "rejected", "error"],
5151
+ ["kind", "ok", "operation", "scheduled", "rejected"],
5152
+ "failed context swap result",
5153
+ );
5154
+ if (!Array.isArray(raw.scheduled) || raw.scheduled.length !== 0) {
5155
+ throw new Error("Failed context swap result must schedule no candidates.");
5156
+ }
5157
+ const rejected = decodeContextSwapRejected(raw.rejected);
5158
+ const error =
5159
+ raw.error === undefined
5160
+ ? undefined
5161
+ : nonEmptyStringFromJson(raw.error, "context swap error");
5162
+ if (rejected.length === 0 && error === undefined) {
5163
+ throw new Error("Failed context swap result must explain its failure.");
5164
+ }
5165
+ return immutableRecord({
5166
+ kind: "context_maintenance" as const,
5167
+ ok: false as const,
5168
+ operation,
5169
+ scheduled: Object.freeze([]),
5170
+ rejected,
5171
+ ...(error === undefined ? {} : { error }),
5172
+ });
5173
+ }
5174
+ if (raw.ok !== true) {
5175
+ throw new Error("Context maintenance raw result ok must be a boolean.");
5176
+ }
5177
+ if (operation === "status") {
5178
+ assertObjectKeys(
5179
+ raw,
5180
+ [
5181
+ "kind",
5182
+ "ok",
5183
+ "operation",
5184
+ "usedInputTokens",
5185
+ "inputBudgetTokens",
5186
+ "pressure",
5187
+ "triggerTokens",
5188
+ "source",
5189
+ ],
5190
+ [
5191
+ "kind",
5192
+ "ok",
5193
+ "operation",
5194
+ "usedInputTokens",
5195
+ "inputBudgetTokens",
5196
+ "pressure",
5197
+ "triggerTokens",
5198
+ "source",
5199
+ ],
5200
+ "context status result",
5201
+ );
5202
+ return immutableRecord({
5203
+ kind: "context_maintenance" as const,
5204
+ ok: true as const,
5205
+ operation,
5206
+ usedInputTokens: nonNegativeJsonInteger(
5207
+ raw.usedInputTokens,
5208
+ "context status usedInputTokens",
5209
+ ),
5210
+ inputBudgetTokens: positiveJsonInteger(
5211
+ raw.inputBudgetTokens,
5212
+ "context status inputBudgetTokens",
5213
+ ),
5214
+ pressure: enumFromSql(
5215
+ raw.pressure,
5216
+ ["normal", "high", "critical"] as const,
5217
+ "context status pressure",
5218
+ ),
5219
+ triggerTokens: positiveJsonInteger(
5220
+ raw.triggerTokens,
5221
+ "context status triggerTokens",
5222
+ ),
5223
+ source: enumFromSql(
5224
+ raw.source,
5225
+ [
5226
+ "estimated_full",
5227
+ "provider_measured",
5228
+ "measured_plus_estimated_delta",
5229
+ ] as const,
5230
+ "context status source",
5231
+ ),
5232
+ });
5233
+ }
5234
+ if (operation === "candidates") {
5235
+ assertObjectKeys(
5236
+ raw,
5237
+ ["kind", "ok", "operation", "total", "candidates"],
5238
+ ["kind", "ok", "operation", "total", "candidates"],
5239
+ "context swap candidates result",
5240
+ );
5241
+ if (!Array.isArray(raw.candidates) || raw.candidates.length > 50) {
5242
+ throw new Error("Context swap candidates result has an invalid page.");
5243
+ }
5244
+ const candidates = raw.candidates.map((value, index) => {
5245
+ const candidate = recordFromSql(value, `context candidate ${index}`);
5246
+ assertObjectKeys(
5247
+ candidate,
5248
+ ["candidateId", "label", "ordinal", "savingsBytes"],
5249
+ ["candidateId", "label", "ordinal", "savingsBytes"],
5250
+ `context candidate ${index}`,
5251
+ );
5252
+ const label = stringFromSql(candidate.label, `context candidate ${index} label`);
5253
+ if (
5254
+ label === "" ||
5255
+ label !== label.replace(/[\p{Cc}\p{Cf}\s]+/gu, " ").trim() ||
5256
+ Buffer.byteLength(label, "utf8") > 80
5257
+ ) {
5258
+ throw new Error(`Context candidate ${index} label is invalid or too large.`);
5259
+ }
5260
+ return immutableRecord({
5261
+ candidateId: parseMessageId(
5262
+ stringFromSql(candidate.candidateId, `context candidate ${index} ID`),
5263
+ ),
5264
+ label,
5265
+ ordinal: positiveJsonInteger(
5266
+ candidate.ordinal,
5267
+ `context candidate ${index} ordinal`,
5268
+ ),
5269
+ savingsBytes: positiveJsonInteger(
5270
+ candidate.savingsBytes,
5271
+ `context candidate ${index} savingsBytes`,
5272
+ ),
5273
+ });
5274
+ });
5275
+ if (
5276
+ new Set(candidates.map((candidate) => candidate.candidateId)).size !==
5277
+ candidates.length ||
5278
+ candidates.some(
5279
+ (candidate, index) =>
5280
+ index > 0 &&
5281
+ candidate.ordinal <= (candidates[index - 1]?.ordinal ?? candidate.ordinal),
5282
+ )
5283
+ ) {
5284
+ throw new Error(
5285
+ "Context swap candidates must have unique IDs and ascending ordinals.",
5286
+ );
5287
+ }
5288
+ const total = nonNegativeJsonInteger(raw.total, "context candidates total");
5289
+ if (total < candidates.length) {
5290
+ throw new Error("Context candidates total is smaller than its page.");
5291
+ }
5292
+ return immutableRecord({
5293
+ kind: "context_maintenance" as const,
5294
+ ok: true as const,
5295
+ operation,
5296
+ total,
5297
+ candidates: Object.freeze(candidates),
5298
+ });
5299
+ }
5300
+
5301
+ assertObjectKeys(
5302
+ raw,
5303
+ ["kind", "ok", "operation", "scheduled", "rejected", "note"],
5304
+ ["kind", "ok", "operation", "scheduled", "rejected", "note"],
5305
+ "context swap result",
5306
+ );
5307
+ if (!Array.isArray(raw.scheduled) || raw.scheduled.length < 1) {
5308
+ throw new Error("Successful context swap result must schedule candidates.");
5309
+ }
5310
+ const scheduled = raw.scheduled.map((value, index) => {
5311
+ const candidate = recordFromSql(value, `scheduled context candidate ${index}`);
5312
+ assertObjectKeys(
5313
+ candidate,
5314
+ ["candidateId", "savingsBytes"],
5315
+ ["candidateId", "savingsBytes"],
5316
+ `scheduled context candidate ${index}`,
5317
+ );
5318
+ return immutableRecord({
5319
+ candidateId: parseMessageId(
5320
+ stringFromSql(candidate.candidateId, `scheduled candidate ${index} ID`),
5321
+ ),
5322
+ savingsBytes: positiveJsonInteger(
5323
+ candidate.savingsBytes,
5324
+ `scheduled candidate ${index} savingsBytes`,
5325
+ ),
5326
+ });
5327
+ });
5328
+ if (
5329
+ scheduled.length > 16 ||
5330
+ new Set(scheduled.map((candidate) => candidate.candidateId)).size !==
5331
+ scheduled.length
5332
+ ) {
5333
+ throw new Error("Successful context swap result has invalid scheduled IDs.");
5334
+ }
5335
+ const rejected = decodeContextSwapRejected(raw.rejected);
5336
+ if (
5337
+ scheduled.length + rejected.length > 16 ||
5338
+ scheduled.some((scheduledCandidate) =>
5339
+ rejected.some(
5340
+ (rejectedCandidate) =>
5341
+ rejectedCandidate.candidateId === scheduledCandidate.candidateId,
5342
+ ),
5343
+ )
5344
+ ) {
5345
+ throw new Error("Context swap result candidate partitions are invalid.");
5346
+ }
5347
+ const note = stringFromSql(raw.note, "context swap note");
5348
+ return immutableRecord({
5349
+ kind: "context_maintenance" as const,
5350
+ ok: true as const,
5351
+ operation,
5352
+ scheduled: Object.freeze(scheduled),
5353
+ rejected,
5354
+ note,
5355
+ });
5356
+ }
5357
+
5358
+ function decodeContextSwapRejected(value: unknown): readonly {
5359
+ readonly candidateId: MessageId;
5360
+ readonly reason: string;
5361
+ }[] {
5362
+ if (!Array.isArray(value) || value.length > 16) {
5363
+ throw new Error("Context swap rejected candidates must be an array of at most 16.");
5364
+ }
5365
+ const rejected = value.map((entry, index) => {
5366
+ const candidate = recordFromSql(entry, `rejected context candidate ${index}`);
5367
+ assertObjectKeys(
5368
+ candidate,
5369
+ ["candidateId", "reason"],
5370
+ ["candidateId", "reason"],
5371
+ `rejected context candidate ${index}`,
5372
+ );
5373
+ const reason = stringFromSql(
5374
+ candidate.reason,
5375
+ `rejected context candidate ${index} reason`,
5376
+ );
5377
+ if (!/^[a-z][a-z0-9_]{0,79}$/.test(reason)) {
5378
+ throw new Error(`Rejected context candidate ${index} reason is invalid.`);
5379
+ }
5380
+ return immutableRecord({
5381
+ candidateId: parseMessageId(
5382
+ stringFromSql(candidate.candidateId, `rejected candidate ${index} ID`),
5383
+ ),
5384
+ reason,
5385
+ });
5386
+ });
5387
+ if (
5388
+ new Set(rejected.map((candidate) => candidate.candidateId)).size !== rejected.length
5389
+ ) {
5390
+ throw new Error("Context swap rejected candidate IDs must be unique.");
5391
+ }
5392
+ return Object.freeze(rejected);
5393
+ }
5394
+
5094
5395
  function decodeStoredViewImageRawResult(
5095
5396
  raw: Record<string, unknown>,
5096
5397
  ): Extract<ToolRawResult, { kind: "view_image" }> {
@@ -6081,8 +6382,14 @@ async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
6081
6382
  return realpath(workspaceRoot);
6082
6383
  }
6083
6384
 
6084
- async function ensureSessionsRoot(workspaceRoot: string): Promise<string> {
6085
- const tinkerRoot = path.join(workspaceRoot, ".tinker");
6385
+ async function ensureSessionsRoot(
6386
+ workspaceRoot: string,
6387
+ homeRoot?: string,
6388
+ ): Promise<string> {
6389
+ const tinkerRoot = workspaceStorageRoot(
6390
+ workspaceRoot,
6391
+ await canonicalHomeRoot(homeRoot),
6392
+ );
6086
6393
  const sessionsRoot = path.join(tinkerRoot, "sessions");
6087
6394
  await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
6088
6395
  await validateSessionsRoot(sessionsRoot);
@@ -6398,6 +6705,31 @@ function numberFromJson(value: unknown, name: string): number {
6398
6705
  return value as number;
6399
6706
  }
6400
6707
 
6708
+ function safeJsonInteger(value: unknown, name: string): number {
6709
+ if (!Number.isSafeInteger(value)) {
6710
+ throw new Error(`${name} must be a safe integer.`);
6711
+ }
6712
+ return value as number;
6713
+ }
6714
+
6715
+ function nonNegativeJsonInteger(value: unknown, name: string): number {
6716
+ const number = safeJsonInteger(value, name);
6717
+ if (number < 0) throw new Error(`${name} must be non-negative.`);
6718
+ return number;
6719
+ }
6720
+
6721
+ function positiveJsonInteger(value: unknown, name: string): number {
6722
+ const number = safeJsonInteger(value, name);
6723
+ if (number < 1) throw new Error(`${name} must be positive.`);
6724
+ return number;
6725
+ }
6726
+
6727
+ function nonEmptyStringFromJson(value: unknown, name: string): string {
6728
+ const text = stringFromSql(value, name);
6729
+ if (text.trim() === "") throw new Error(`${name} must not be empty.`);
6730
+ return text;
6731
+ }
6732
+
6401
6733
  function enumFromSql<const T extends readonly string[]>(
6402
6734
  value: unknown,
6403
6735
  values: T,
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const TINKER_HOME_DIR = ".tinker";
7
+ const PROJECTS_DIR = "projects";
8
+ const SLUG_MAX_LENGTH = 24;
9
+ const HASH_HEX_LENGTH = 8;
10
+ const FALLBACK_SLUG = "project";
11
+
12
+ export const TINKER_HOME_ENV = "TINKER_HOME";
13
+
14
+ /**
15
+ * Base directory for global Tinker state. TINKER_HOME overrides the OS home
16
+ * directory; tests and the PTY harness use it to isolate state.
17
+ */
18
+ export function defaultHomeRoot(env: NodeJS.ProcessEnv = process.env): string {
19
+ const override = env[TINKER_HOME_ENV]?.trim();
20
+ return override === undefined || override === "" ? os.homedir() : override;
21
+ }
22
+
23
+ /**
24
+ * Stable directory name for one workspace inside the global Tinker home, e.g.
25
+ * "tinker-a1b2c3d4". The slug is decorative; the hash suffix over the
26
+ * canonical workspace path provides uniqueness.
27
+ */
28
+ export function workspaceStorageDirectoryName(canonicalWorkspaceRoot: string): string {
29
+ const slug = slugify(path.basename(canonicalWorkspaceRoot));
30
+ const hash = createHash("sha256")
31
+ .update(canonicalWorkspaceRoot)
32
+ .digest("hex")
33
+ .slice(0, HASH_HEX_LENGTH);
34
+ return `${slug}-${hash}`;
35
+ }
36
+
37
+ /**
38
+ * Storage root for one canonical workspace root under a canonical home root:
39
+ * <home>/.tinker/projects/<slug-hash>. Both inputs must already be canonical
40
+ * (see resolveWorkspaceStorageRoot for the resolving variant).
41
+ */
42
+ export function workspaceStorageRoot(
43
+ canonicalWorkspaceRoot: string,
44
+ canonicalHomeRoot: string,
45
+ ): string {
46
+ return path.join(
47
+ canonicalHomeRoot,
48
+ TINKER_HOME_DIR,
49
+ PROJECTS_DIR,
50
+ workspaceStorageDirectoryName(canonicalWorkspaceRoot),
51
+ );
52
+ }
53
+
54
+ /** Canonical (symlink-resolved) home root used for global Tinker state. */
55
+ export async function canonicalHomeRoot(
56
+ homeRoot: string = defaultHomeRoot(),
57
+ ): Promise<string> {
58
+ return realpath(homeRoot);
59
+ }
60
+
61
+ /**
62
+ * Resolves the per-workspace storage root under the global Tinker home. Both
63
+ * the workspace and the home root are canonicalized so a project reached
64
+ * through different symlinked paths maps to a single storage directory.
65
+ */
66
+ export async function resolveWorkspaceStorageRoot(
67
+ workspaceRoot: string,
68
+ homeRoot: string = defaultHomeRoot(),
69
+ ): Promise<string> {
70
+ return workspaceStorageRoot(
71
+ await realpath(workspaceRoot),
72
+ await canonicalHomeRoot(homeRoot),
73
+ );
74
+ }
75
+
76
+ function slugify(name: string): string {
77
+ const slug = name
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "-")
80
+ .replace(/^-+/, "")
81
+ .slice(0, SLUG_MAX_LENGTH)
82
+ .replace(/-+$/, "");
83
+ return slug === "" ? FALLBACK_SLUG : slug;
84
+ }
@@ -20,6 +20,7 @@ import {
20
20
  TERMINAL_SCREEN_ROWS,
21
21
  type TerminalScreen,
22
22
  } from "./terminal-screen";
23
+ import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
23
24
 
24
25
  export type ShellTaskStatus =
25
26
  | "running"
@@ -112,6 +113,7 @@ export type ShellTaskManagerOptions = {
112
113
  cwdState: CwdState;
113
114
  runtimeSession: RuntimeSessionContext;
114
115
  stopGraceMs?: number;
116
+ homeRoot?: string;
115
117
  };
116
118
 
117
119
  const defaultStopGraceMs = 2_000;
@@ -121,6 +123,7 @@ export class ShellTaskManager {
121
123
  private readonly stopGraceMs: number;
122
124
  private acceptingTasks = true;
123
125
  private shutdownPromise?: Promise<ShutdownResult>;
126
+ private bashDirectoryPromise?: Promise<string>;
124
127
 
125
128
  constructor(private readonly options: ShellTaskManagerOptions) {
126
129
  this.stopGraceMs = options.stopGraceMs ?? defaultStopGraceMs;
@@ -129,6 +132,14 @@ export class ShellTaskManager {
129
132
  }
130
133
  }
131
134
 
135
+ private bashDirectory(): Promise<string> {
136
+ this.bashDirectoryPromise ??= resolveWorkspaceStorageRoot(
137
+ this.options.workspaceRoot,
138
+ this.options.homeRoot,
139
+ ).then((storageRoot) => path.join(storageRoot, "bash"));
140
+ return this.bashDirectoryPromise;
141
+ }
142
+
132
143
  async start(input: {
133
144
  command: string;
134
145
  description: string;
@@ -140,18 +151,9 @@ export class ShellTaskManager {
140
151
  }
141
152
 
142
153
  const id = createUuidV7();
143
- const outputFilePath = path.join(
144
- this.options.workspaceRoot,
145
- ".tinker",
146
- "bash",
147
- `${id}.log`,
148
- );
149
- const cwdFilePath = path.join(
150
- this.options.workspaceRoot,
151
- ".tinker",
152
- "bash",
153
- `${id}.cwd`,
154
- );
154
+ const bashDirectory = await this.bashDirectory();
155
+ const outputFilePath = path.join(bashDirectory, `${id}.log`);
156
+ const cwdFilePath = path.join(bashDirectory, `${id}.cwd`);
155
157
  await ensureEmptyFile(cwdFilePath);
156
158
 
157
159
  const output = await TaskOutput.create(outputFilePath);