tinker-agent 2.12.0 → 3.0.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 (61) hide show
  1. package/CHANGELOG.md +66 -1
  2. package/README.md +25 -4
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +505 -81
  5. package/src/agent/runtime-session-contracts.ts +9 -0
  6. package/src/agent/runtime-session.ts +29 -7
  7. package/src/cli/command-line.ts +159 -11
  8. package/src/cli/connect-runner.tsx +63 -0
  9. package/src/cli/connect-workspace.ts +33 -0
  10. package/src/cli/default-service-config.ts +163 -0
  11. package/src/cli/default-tui-runner.ts +23 -0
  12. package/src/cli/interactive-runtime.ts +87 -0
  13. package/src/cli/local-service-start.ts +131 -0
  14. package/src/cli/local-tui-client.ts +234 -0
  15. package/src/cli/main.ts +46 -3
  16. package/src/cli/public-cli-contract.ts +65 -2
  17. package/src/cli/public-config-contract.ts +1 -1
  18. package/src/cli/runner-dependencies.ts +35 -35
  19. package/src/cli/serve-runner.ts +155 -21
  20. package/src/cli/serve-runtime.ts +47 -46
  21. package/src/cli/service-control.ts +27 -0
  22. package/src/cli/service-supervisor.ts +170 -0
  23. package/src/cli/service-upgrade.ts +106 -0
  24. package/src/cli/tui-runner.tsx +9 -261
  25. package/src/cli/update-runner.ts +4 -0
  26. package/src/client/local-workspace-client.ts +238 -0
  27. package/src/client/model-catalog.ts +15 -0
  28. package/src/client/remote-images.ts +80 -0
  29. package/src/client/remote-prompt-history.ts +22 -0
  30. package/src/client/remote-tasks.ts +231 -0
  31. package/src/client/remote-tui-view.ts +69 -0
  32. package/src/client/remote-workspace-client.ts +528 -0
  33. package/src/client/session-client.ts +109 -0
  34. package/src/image/image-asset-store.ts +17 -0
  35. package/src/remote/client.ts +27 -5
  36. package/src/remote/config.ts +19 -4
  37. package/src/remote/failures.ts +68 -0
  38. package/src/remote/http-server.ts +120 -3
  39. package/src/remote/local-service-discovery.ts +361 -0
  40. package/src/remote/prompt-history.ts +36 -0
  41. package/src/remote/protocol.ts +93 -2
  42. package/src/remote/resident-policy.ts +96 -0
  43. package/src/remote/service-store.ts +28 -6
  44. package/src/remote/service.ts +479 -60
  45. package/src/remote/session-operations.ts +66 -0
  46. package/src/remote/session-reads.ts +126 -0
  47. package/src/remote/sync-hub.ts +2 -0
  48. package/src/remote/tui-protocol.ts +32 -0
  49. package/src/remote/workspace-resolution.ts +82 -0
  50. package/src/session/resume-projection.ts +19 -2
  51. package/src/session/session-lock.ts +32 -0
  52. package/src/session/session-store-contracts.ts +2 -0
  53. package/src/session/session-store.ts +7 -4
  54. package/src/tools/bash.ts +9 -1
  55. package/src/tools/bounded-output-preview.ts +2 -2
  56. package/src/tui/app.tsx +67 -26
  57. package/src/tui/components/model-picker.tsx +5 -5
  58. package/src/tui/components/prompt-input.tsx +16 -1
  59. package/src/tui/components/resume-session-picker.tsx +6 -2
  60. package/src/tui/tui-projection-store.ts +25 -0
  61. package/src/tui/tui-session-controller.ts +12 -282
@@ -1,3 +1,4 @@
1
+ import type { SessionLease } from "../session/session-lock";
1
2
  import type {
2
3
  ProviderRetryDecision,
3
4
  ProviderRetrySnapshot,
@@ -100,6 +101,12 @@ export type RuntimeSession = {
100
101
  signal: AbortSignal,
101
102
  prospectiveMessageImageCount: number,
102
103
  ): Promise<ImportedImageAsset>;
104
+ importImageBytes(
105
+ bytes: Buffer,
106
+ originalName: string,
107
+ signal: AbortSignal,
108
+ prospectiveMessageImageCount: number,
109
+ ): Promise<ImportedImageAsset>;
103
110
  verifyImageAssets(
104
111
  assets: readonly ImageAssetRef[],
105
112
  signal: AbortSignal,
@@ -127,6 +134,7 @@ export type RuntimeSession = {
127
134
  askUser(): AskUserSnapshot;
128
135
  subscribeAskUser(listener: () => void): () => void;
129
136
  resolveAskUser(response: AskUserResolution): Promise<void>;
137
+ retainSessionLease(): SessionLease;
130
138
  dispose(reason: SessionDisposeReason): Promise<void>;
131
139
  };
132
140
 
@@ -208,6 +216,7 @@ export type SkillsUpdateSummary = {
208
216
  };
209
217
 
210
218
  export type CommonRuntimeSessionInput = {
219
+ sessionLease?: SessionLease;
211
220
  workspaceRoot: string;
212
221
  homeRoot?: string;
213
222
  modelName: string;
@@ -170,6 +170,7 @@ const defaultDependencies: RuntimeSessionFactoryDependencies = {
170
170
  ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
171
171
  })
172
172
  : SessionStore.openExisting({
173
+ lease: input.sessionLease,
173
174
  workspaceRoot: input.workspaceRoot,
174
175
  sessionId: input.selection.sessionId,
175
176
  ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
@@ -774,6 +775,29 @@ class DefaultRuntimeSession implements RuntimeSession {
774
775
  signal: AbortSignal,
775
776
  prospectiveMessageImageCount: number,
776
777
  ): Promise<ImportedImageAsset> {
778
+ const assertImageAllowed = this.imageAdmissionCheck(prospectiveMessageImageCount);
779
+ if (this.supportsImageInput()) assertImageAllowed();
780
+ return this.assetStore.importWorkspaceFile(sourcePath, {
781
+ signal,
782
+ accept: assertImageAllowed,
783
+ });
784
+ }
785
+
786
+ async importImageBytes(
787
+ bytes: Buffer,
788
+ originalName: string,
789
+ signal: AbortSignal,
790
+ prospectiveMessageImageCount: number,
791
+ ): Promise<ImportedImageAsset> {
792
+ const assertImageAllowed = this.imageAdmissionCheck(prospectiveMessageImageCount);
793
+ if (this.supportsImageInput()) assertImageAllowed();
794
+ return this.assetStore.importBytes(bytes, originalName, {
795
+ signal,
796
+ accept: assertImageAllowed,
797
+ });
798
+ }
799
+
800
+ private imageAdmissionCheck(prospectiveMessageImageCount: number): () => void {
777
801
  if (this.state !== "ready") {
778
802
  throw new Error(`Cannot import an image while RuntimeSession is ${this.state}.`);
779
803
  }
@@ -806,13 +830,7 @@ class DefaultRuntimeSession implements RuntimeSession {
806
830
  }
807
831
  };
808
832
 
809
- if (this.supportsImageInput()) {
810
- assertImageAllowed();
811
- }
812
- return this.assetStore.importWorkspaceFile(sourcePath, {
813
- signal,
814
- accept: assertImageAllowed,
815
- });
833
+ return assertImageAllowed;
816
834
  }
817
835
 
818
836
  async verifyImageAssets(
@@ -1056,6 +1074,10 @@ class DefaultRuntimeSession implements RuntimeSession {
1056
1074
  return completion;
1057
1075
  }
1058
1076
 
1077
+ retainSessionLease() {
1078
+ return this.store.retainLease();
1079
+ }
1080
+
1059
1081
  dispose(reason: SessionDisposeReason): Promise<void> {
1060
1082
  this.disposePromise ??= this.performDispose(reason);
1061
1083
  return this.disposePromise;
@@ -4,10 +4,27 @@ import type { PromptSource } from "./prompt-source";
4
4
  import { CliUsageError, type CliCommandScope } from "./output";
5
5
 
6
6
  export type CliCommand =
7
- | { readonly type: "tui"; readonly profileName?: string }
7
+ | { readonly type: "tui"; readonly profileName?: string; readonly local?: boolean }
8
8
  | { readonly type: "update" }
9
- | { readonly type: "serve"; readonly configPath: string }
10
- | { readonly type: "connect"; readonly configPath: string }
9
+ | {
10
+ readonly type: "serve";
11
+ readonly configPath?: string;
12
+ readonly background?: boolean;
13
+ readonly install?: boolean;
14
+ readonly uninstall?: boolean;
15
+ readonly stop?: boolean;
16
+ readonly restart?: boolean;
17
+ readonly force?: boolean;
18
+ readonly status?: boolean;
19
+ }
20
+ | {
21
+ readonly type: "connect";
22
+ readonly configPath: string;
23
+ readonly tui?: boolean;
24
+ readonly workspaceId?: string;
25
+ readonly sessionId?: string;
26
+ readonly serviceConfigPath?: string;
27
+ }
11
28
  | {
12
29
  readonly type: "run";
13
30
  readonly profileName?: string;
@@ -45,6 +62,7 @@ export async function parseCommandLine(
45
62
  .helpOption(contract.helpFlags)
46
63
  .version(packageVersion, contract.versionFlags)
47
64
  .option(contract.tui.profileOption.flags, contract.tui.profileOption.description)
65
+ .option(contract.tui.localOption.flags, contract.tui.localOption.description)
48
66
  .helpCommand(contract.helpCommand.command, contract.helpCommand.description)
49
67
  .showHelpAfterError('Run "tinker --help" for usage.')
50
68
  .showSuggestionAfterError(false)
@@ -61,9 +79,10 @@ export async function parseCommandLine(
61
79
  });
62
80
 
63
81
  program.action(() => {
64
- const { profile } = program.opts<{ profile?: string }>();
82
+ const { profile, local } = program.opts<{ profile?: string; local?: boolean }>();
65
83
  selectedCommand = Object.freeze({
66
84
  type: "tui",
85
+ ...(local ? { local: true } : {}),
67
86
  ...(profile === undefined
68
87
  ? {}
69
88
  : { profileName: validateProfile(profile, "root") }),
@@ -134,17 +153,140 @@ export async function parseCommandLine(
134
153
 
135
154
  for (const type of ["serve", "connect"] as const) {
136
155
  const command = contract[type];
137
- program
156
+ const subcommand = program
138
157
  .command(command.command)
139
158
  .description(command.description)
140
- .requiredOption(command.configOption.flags, command.configOption.description)
141
159
  .allowExcessArguments(false)
142
- .exitOverride()
143
- .action((options: { config: string }) => {
144
- if (!options.config.trim())
160
+ .exitOverride();
161
+ if (type === "serve") {
162
+ subcommand.option(command.configOption.flags, command.configOption.description);
163
+ subcommand.option(
164
+ contract.serve.backgroundOption.flags,
165
+ contract.serve.backgroundOption.description,
166
+ );
167
+ subcommand.option(
168
+ contract.serve.statusOption.flags,
169
+ contract.serve.statusOption.description,
170
+ );
171
+ for (const option of contract.serve.residentOptions)
172
+ subcommand.option(option.flags, option.description);
173
+ } else
174
+ subcommand.requiredOption(
175
+ command.configOption.flags,
176
+ command.configOption.description,
177
+ );
178
+ if (type === "connect") {
179
+ for (const option of [
180
+ contract.connect.tuiOption,
181
+ contract.connect.workspaceOption,
182
+ contract.connect.sessionOption,
183
+ contract.connect.serviceConfigOption,
184
+ ])
185
+ subcommand.option(option.flags, option.description);
186
+ }
187
+ subcommand.action(
188
+ (options: {
189
+ config?: string;
190
+ background?: boolean;
191
+ install?: boolean;
192
+ uninstall?: boolean;
193
+ stop?: boolean;
194
+ restart?: boolean;
195
+ force?: boolean;
196
+ status?: boolean;
197
+ tui?: boolean;
198
+ workspace?: string;
199
+ session?: string;
200
+ serviceConfig?: string;
201
+ }) => {
202
+ if (options.config !== undefined && !options.config.trim())
145
203
  throw new CliUsageError("--config requires a non-empty path.", type);
146
- selectedCommand = Object.freeze({ type, configPath: options.config });
147
- });
204
+ if (type === "serve") {
205
+ if (
206
+ [
207
+ options.background,
208
+ options.status,
209
+ options.install,
210
+ options.uninstall,
211
+ options.stop,
212
+ options.restart,
213
+ ].filter(Boolean).length > 1
214
+ )
215
+ throw new CliUsageError(
216
+ "Service management modes are mutually exclusive.",
217
+ type,
218
+ );
219
+ if (
220
+ options.force &&
221
+ ![options.install, options.uninstall, options.stop, options.restart].some(
222
+ Boolean,
223
+ )
224
+ )
225
+ throw new CliUsageError(
226
+ "--force requires --install, --uninstall, --stop or --restart.",
227
+ type,
228
+ );
229
+ selectedCommand = Object.freeze({
230
+ ...(options.install ? { install: true } : {}),
231
+ ...(options.uninstall ? { uninstall: true } : {}),
232
+ ...(options.stop ? { stop: true } : {}),
233
+ ...(options.restart ? { restart: true } : {}),
234
+ ...(options.force ? { force: true } : {}),
235
+ type,
236
+ ...(options.config === undefined ? {} : { configPath: options.config }),
237
+ ...(options.background ? { background: true } : {}),
238
+ ...(options.status ? { status: true } : {}),
239
+ });
240
+ return;
241
+ }
242
+ if (options.config === undefined)
243
+ throw new CliUsageError("--config is required.", type);
244
+ if (
245
+ options.tui ||
246
+ options.workspace !== undefined ||
247
+ options.session !== undefined ||
248
+ options.serviceConfig !== undefined
249
+ ) {
250
+ if (
251
+ !options.tui ||
252
+ (options.workspace !== undefined && !options.workspace.trim())
253
+ )
254
+ throw new CliUsageError(
255
+ "--workspace, --session and --service-config require --tui; workspace ID must be non-empty.",
256
+ type,
257
+ );
258
+ if (options.serviceConfig !== undefined && !options.serviceConfig.trim())
259
+ throw new CliUsageError(
260
+ "--service-config requires a non-empty path.",
261
+ type,
262
+ );
263
+ if (
264
+ options.workspace !== undefined &&
265
+ !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(options.workspace)
266
+ )
267
+ throw new CliUsageError("Invalid workspace ID.", type);
268
+ if (
269
+ options.session !== undefined &&
270
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
271
+ options.session,
272
+ )
273
+ )
274
+ throw new CliUsageError("Invalid session ID.", type);
275
+ selectedCommand = Object.freeze({
276
+ type,
277
+ configPath: options.config,
278
+ tui: true,
279
+ ...(options.workspace === undefined
280
+ ? {}
281
+ : { workspaceId: options.workspace }),
282
+ ...(options.session ? { sessionId: options.session } : {}),
283
+ ...(options.serviceConfig === undefined
284
+ ? {}
285
+ : { serviceConfigPath: options.serviceConfig }),
286
+ });
287
+ } else selectedCommand = Object.freeze({ type, configPath: options.config });
288
+ },
289
+ );
148
290
  }
149
291
 
150
292
  program
@@ -176,6 +318,11 @@ export async function parseCommandLine(
176
318
  if (selectedCommand === undefined) {
177
319
  throw new Error("Commander completed without selecting a command.");
178
320
  }
321
+ if (selectedCommand.type !== "tui" && program.opts<{ local?: boolean }>().local)
322
+ throw new CliUsageError(
323
+ "--local only applies to the default TUI.",
324
+ selectedCommand.type,
325
+ );
179
326
  const topLevelProfile = program.opts<{ profile?: string }>().profile;
180
327
  if (selectedCommand.type === "run" && topLevelProfile !== undefined) {
181
328
  throw new CliUsageError(
@@ -232,6 +379,7 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
232
379
  index += profile.consumedNext ? 1 : 0;
233
380
  continue;
234
381
  }
382
+ if (token === "--local") continue;
235
383
  if (token.startsWith("-")) {
236
384
  rootBlocked = true;
237
385
  continue;
@@ -1,15 +1,27 @@
1
+ import { App } from "../tui/app";
2
+ import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
3
+ import { clipboardWriterForEnvironment } from "../tui/clipboard";
4
+ import { createRemoteTuiClient } from "../client/remote-workspace-client";
1
5
  import { render } from "ink";
2
6
  import { RemoteClient, loadRemoteClientConfig } from "../remote/client";
3
7
  import { RemoteApp } from "../tui/remote-app";
4
8
  import type { CliOutputWriter } from "./output";
9
+ import { resolveConnectedWorkspace } from "./connect-workspace";
5
10
 
6
11
  export async function runConnect(input: {
7
12
  configPath: string;
8
13
  env: NodeJS.ProcessEnv;
9
14
  stdout: CliOutputWriter;
15
+ cwd?: string;
16
+ tui?: boolean;
17
+ workspaceId?: string;
18
+ sessionId?: string;
19
+ profileName?: string;
20
+ serviceConfigPath?: string;
10
21
  }): Promise<number> {
11
22
  if (!process.stdin.isTTY)
12
23
  throw new Error("tinker connect requires an interactive terminal.");
24
+ if (input.tui) return runFullTui(input);
13
25
  const client = new RemoteClient(await loadRemoteClientConfig(input.configPath));
14
26
  let instance: ReturnType<typeof render> | undefined;
15
27
  try {
@@ -24,3 +36,54 @@ export async function runConnect(input: {
24
36
  process.stdin.pause();
25
37
  }
26
38
  }
39
+
40
+ async function runFullTui(input: {
41
+ configPath: string;
42
+ env: NodeJS.ProcessEnv;
43
+ cwd?: string;
44
+ workspaceId?: string;
45
+ sessionId?: string;
46
+ profileName?: string;
47
+ serviceConfigPath?: string;
48
+ }): Promise<number> {
49
+ const config = await loadRemoteClientConfig(input.configPath);
50
+ const workspaceId = await resolveConnectedWorkspace({
51
+ ...input,
52
+ config,
53
+ cwd: input.cwd ?? process.cwd(),
54
+ });
55
+ const connection = await createRemoteTuiClient(
56
+ config,
57
+ workspaceId,
58
+ input.sessionId,
59
+ input.profileName,
60
+ );
61
+ let instance: ReturnType<typeof render> | undefined;
62
+ try {
63
+ const [history, projectSlashCommands] = await Promise.all([
64
+ connection.client.loadHistory(),
65
+ connection.client.projectCommands(),
66
+ prepareShikiHighlighter(),
67
+ ]);
68
+ instance = render(
69
+ <App
70
+ sessionController={connection.client}
71
+ history={history}
72
+ projectSlashCommands={projectSlashCommands}
73
+ readGitBranch={connection.client.readGitBranch}
74
+ fileLister={connection.client.listFiles}
75
+ readViewFile={connection.client.readFile}
76
+ writeClipboard={clipboardWriterForEnvironment(input.env)}
77
+ listStoredMemories={connection.client.listStoredMemories}
78
+ />,
79
+ { incrementalRendering: true },
80
+ );
81
+ await instance.waitUntilExit();
82
+ return 0;
83
+ } finally {
84
+ instance?.unmount();
85
+ await connection.close({ type: "client_exit" });
86
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
87
+ process.stdin.pause();
88
+ }
89
+ }
@@ -0,0 +1,33 @@
1
+ import { RemoteClient, type RemoteClientConfig } from "../remote/client";
2
+ import {
3
+ loadLocalServiceTarget,
4
+ registerLocalWorkspace,
5
+ } from "../remote/local-service-discovery";
6
+ import { ensureLocalService } from "./local-service-start";
7
+
8
+ export async function resolveConnectedWorkspace(input: {
9
+ config: RemoteClientConfig;
10
+ cwd: string;
11
+ env: NodeJS.ProcessEnv;
12
+ workspaceId?: string;
13
+ serviceConfigPath?: string;
14
+ }): Promise<string> {
15
+ const probe = new RemoteClient(input.config, false);
16
+ try {
17
+ if (input.serviceConfigPath) {
18
+ const target = await loadLocalServiceTarget(input.serviceConfigPath, input.env);
19
+ const local = await ensureLocalService(target, input.env);
20
+ const remote = await probe.request<{ instanceId: string }>("/v1/service");
21
+ if (remote.instanceId !== local.instanceId)
22
+ throw new Error(
23
+ "Client pairing points to a different service. Use the client configuration for this local service before registering a workspace.",
24
+ );
25
+ if (!input.workspaceId)
26
+ return (await registerLocalWorkspace(target, input.cwd)).id;
27
+ }
28
+ if (input.workspaceId) return input.workspaceId;
29
+ return (await probe.resolveWorkspace(input.cwd)).id;
30
+ } finally {
31
+ await probe.close();
32
+ }
33
+ }
@@ -0,0 +1,163 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ defaultServiceConfigPath,
6
+ loadLocalServiceTarget,
7
+ } from "../remote/local-service-discovery";
8
+ import { loadRemoteClientConfig } from "../remote/client";
9
+ import { authenticateDevice } from "../remote/config";
10
+ import { ensureLocalService } from "./local-service-start";
11
+
12
+ /** Publish an entire credential/config bundle atomically; competing first launches reuse the winner. */
13
+ export async function prepareDefaultServiceConfig(
14
+ env: NodeJS.ProcessEnv,
15
+ ): Promise<string> {
16
+ if (process.platform === "win32")
17
+ throw new Error(
18
+ "Default service startup requires macOS/Linux. Use tinker --local on Windows.",
19
+ );
20
+ const configPath = defaultServiceConfigPath(env);
21
+ const directory = path.dirname(configPath);
22
+ if (await exists(directory)) {
23
+ if (!(await exists(configPath)))
24
+ throw new Error(
25
+ `Service directory has no service.json: ${directory}. Complete its configuration or use tinker --local.`,
26
+ );
27
+ return configPath;
28
+ }
29
+ await mkdir(path.dirname(directory), { recursive: true, mode: 0o700 });
30
+ const temporary = `${directory}.setup-${randomUUID()}`;
31
+ await mkdir(temporary, { mode: 0o700 });
32
+ try {
33
+ const child = Bun.spawn(
34
+ [
35
+ "openssl",
36
+ "req",
37
+ "-x509",
38
+ "-newkey",
39
+ "rsa:2048",
40
+ "-nodes",
41
+ "-sha256",
42
+ "-days",
43
+ "365",
44
+ "-subj",
45
+ "/CN=Tinker local service",
46
+ "-addext",
47
+ "subjectAltName=DNS:localhost,IP:127.0.0.1",
48
+ "-addext",
49
+ "basicConstraints=critical,CA:TRUE",
50
+ "-keyout",
51
+ path.join(temporary, "server.key"),
52
+ "-out",
53
+ path.join(temporary, "server.crt"),
54
+ ],
55
+ { stdout: "ignore", stderr: "pipe" },
56
+ );
57
+ const [error, code] = await Promise.all([
58
+ new Response(child.stderr).text(),
59
+ child.exited,
60
+ ]);
61
+ if (code !== 0)
62
+ throw new Error(`Cannot prepare local TLS certificate: ${error.trim()}`);
63
+ await chmod(path.join(temporary, "server.key"), 0o600);
64
+ await chmod(path.join(temporary, "server.crt"), 0o600);
65
+ const token = randomBytes(32).toString("base64url");
66
+ await writeFile(
67
+ path.join(temporary, "service.json"),
68
+ JSON.stringify(
69
+ {
70
+ version: 1,
71
+ localBootstrap: 1,
72
+ hostname: "127.0.0.1",
73
+ port: 0,
74
+ tls: { certFile: "./server.crt", keyFile: "./server.key" },
75
+ devices: [
76
+ {
77
+ id: "local-terminal",
78
+ name: "Local terminal",
79
+ tokenSha256: createHash("sha256").update(token).digest("hex"),
80
+ },
81
+ ],
82
+ workspaces: [],
83
+ },
84
+ null,
85
+ 2,
86
+ ) + "\n",
87
+ { mode: 0o600 },
88
+ );
89
+ await writeFile(
90
+ path.join(temporary, "client.json"),
91
+ JSON.stringify(
92
+ {
93
+ url: "https://127.0.0.1:1",
94
+ token,
95
+ caFile: "./server.crt",
96
+ },
97
+ null,
98
+ 2,
99
+ ) + "\n",
100
+ { mode: 0o600 },
101
+ );
102
+ try {
103
+ await rename(temporary, directory);
104
+ } catch (error) {
105
+ if (
106
+ !["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")
107
+ )
108
+ throw error;
109
+ if (!(await exists(configPath)))
110
+ throw new Error(`Incomplete service configuration at ${directory}.`, {
111
+ cause: error,
112
+ });
113
+ }
114
+ return configPath;
115
+ } finally {
116
+ await rm(temporary, { recursive: true, force: true });
117
+ }
118
+ }
119
+
120
+ export async function prepareDefaultConnection(env: NodeJS.ProcessEnv) {
121
+ const serviceConfigPath = await prepareDefaultServiceConfig(env);
122
+ const configPath = path.join(path.dirname(serviceConfigPath), "client.json");
123
+ // Validate credentials before starting a service; never replace an existing pairing.
124
+ const client = await loadRemoteClientConfig(configPath);
125
+ const target = await loadLocalServiceTarget(serviceConfigPath, env);
126
+ if (!authenticateDevice(`Bearer ${client.token}`, target.config.devices))
127
+ throw new Error(
128
+ `Local client credentials do not match ${serviceConfigPath}. Repair client.json before connecting.`,
129
+ );
130
+ const instance = await ensureLocalService(target, env);
131
+ const raw = JSON.parse(await readFile(serviceConfigPath, "utf8")) as {
132
+ localBootstrap?: unknown;
133
+ };
134
+ if (raw.localBootstrap === 1) {
135
+ // Auto-created local pairing follows the actual loopback port after every restart.
136
+ const pairing = JSON.parse(await readFile(configPath, "utf8")) as Record<
137
+ string,
138
+ unknown
139
+ >;
140
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
141
+ try {
142
+ await writeFile(
143
+ temporary,
144
+ JSON.stringify({ ...pairing, url: instance.url }, null, 2) + "\n",
145
+ { mode: 0o600 },
146
+ );
147
+ await rename(temporary, configPath);
148
+ } finally {
149
+ await rm(temporary, { force: true });
150
+ }
151
+ }
152
+ return { serviceConfigPath, configPath };
153
+ }
154
+
155
+ async function exists(filename: string): Promise<boolean> {
156
+ try {
157
+ await stat(filename);
158
+ return true;
159
+ } catch (error) {
160
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
161
+ throw error;
162
+ }
163
+ }
@@ -0,0 +1,23 @@
1
+ import { resolveUserPath } from "./public-config-contract";
2
+ import type { CliOutputWriter } from "./output";
3
+ import { prepareDefaultConnection } from "./default-service-config";
4
+ import { runConnect } from "./connect-runner";
5
+
6
+ export async function runDefaultTui(input: {
7
+ cwd: string;
8
+ env: NodeJS.ProcessEnv;
9
+ stdout: CliOutputWriter;
10
+ profileName?: string;
11
+ }): Promise<number> {
12
+ if (!process.stdin.isTTY)
13
+ throw new Error(
14
+ "tinker requires an interactive terminal. Use tinker run for one-shot execution.",
15
+ );
16
+ const configs = await prepareDefaultConnection(input.env);
17
+ return runConnect({
18
+ ...input,
19
+ ...configs,
20
+ cwd: resolveUserPath(input.cwd, input.env.TINKER_WORKSPACE?.trim() || "."),
21
+ tui: true,
22
+ });
23
+ }