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
@@ -0,0 +1,87 @@
1
+ import {
2
+ createRuntimeSession,
3
+ type CreateRuntimeSessionInput,
4
+ } from "../agent/runtime-session";
5
+ import type { AssistantTextDeltaSink } from "../agent/assistant-text-delta";
6
+ import type { EventSink } from "../events/event-sink";
7
+ import {
8
+ buildSystemPrompt,
9
+ loadProjectInstructions,
10
+ projectInstructionManifest,
11
+ } from "../instructions/project-instructions";
12
+ import { createReasoningEffortController } from "../model/reasoning-effort";
13
+ import { loadSkillCatalog } from "../skills/skill-loader";
14
+ import type { RunnerConfig } from "./config";
15
+ import type { PublicToolingConfig } from "./public-config-contract";
16
+ import {
17
+ createRunnerModelClient,
18
+ createWebFetchRefiner,
19
+ RUNTIME_INSTRUCTIONS,
20
+ } from "./runner-dependencies";
21
+
22
+ type InteractiveRuntimeInput = {
23
+ sessionLease?: CreateRuntimeSessionInput["sessionLease"];
24
+ config: RunnerConfig;
25
+ workspaceRoot: string;
26
+ homeRoot?: string;
27
+ selection: CreateRuntimeSessionInput["selection"];
28
+ toolingConfig: PublicToolingConfig;
29
+ env: NodeJS.ProcessEnv;
30
+ sink: EventSink & AssistantTextDeltaSink;
31
+ owner: "local-tui" | "service";
32
+ };
33
+
34
+ // Both interactive owners expose the same runtime interaction and undo capabilities.
35
+ const CAPABILITIES = {
36
+ "local-tui": { enableTurnUndo: true, enableProviderRetryPrompt: true },
37
+ service: { enableTurnUndo: true, enableProviderRetryPrompt: true },
38
+ } as const;
39
+
40
+ /** Composition only: callers retain config selection and runtime ownership. */
41
+ export async function buildInteractiveRuntimeInput(
42
+ input: InteractiveRuntimeInput,
43
+ ): Promise<CreateRuntimeSessionInput> {
44
+ const { config, workspaceRoot, env, sink } = input;
45
+ const reasoning = createReasoningEffortController(config.reasoning);
46
+ const modelClient = createRunnerModelClient(config, undefined, env, reasoning);
47
+ const projectInstructions = await loadProjectInstructions(workspaceRoot);
48
+ const skillCatalog = await loadSkillCatalog({ workspaceRoot });
49
+ return {
50
+ sessionLease: input.sessionLease,
51
+ workspaceRoot,
52
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
53
+ ...(input.selection.mode === "new"
54
+ ? { selection: input.selection }
55
+ : { selection: input.selection }),
56
+ modelName: config.modelName,
57
+ profileName: config.profileName,
58
+ maxIterations: config.maxIterations,
59
+ includeReasoningContent: config.includeReasoningContent,
60
+ contextProfile: config.contextProfile,
61
+ contextBudget: config.contextBudget,
62
+ modelClient,
63
+ systemPrompt: buildSystemPrompt({
64
+ workspaceRoot,
65
+ runtimeInstructions: RUNTIME_INSTRUCTIONS(workspaceRoot),
66
+ projectInstructions,
67
+ }),
68
+ projectInstruction: projectInstructionManifest(projectInstructions),
69
+ skillCatalog,
70
+ presentationSinks: [sink],
71
+ assistantTextDeltaSink: sink,
72
+ toolingConfig: input.toolingConfig,
73
+ webFetchRefiner: createWebFetchRefiner(config, env, reasoning),
74
+ ...CAPABILITIES[input.owner],
75
+ enableAskUser: true,
76
+ // "tui" currently denotes an interactive guard, including hosted sessions.
77
+ bashGuard: {
78
+ mode: config.bashGuardMode,
79
+ source: config.bashGuardSource,
80
+ surface: "tui",
81
+ },
82
+ };
83
+ }
84
+
85
+ export async function createInteractiveRuntimeSession(input: InteractiveRuntimeInput) {
86
+ return createRuntimeSession(await buildInteractiveRuntimeInput(input));
87
+ }
@@ -0,0 +1,131 @@
1
+ import { startSupervisor } from "./service-supervisor";
2
+ import { loadPackageMetadata } from "./package-metadata";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+ import { constants } from "node:fs";
5
+ import { chmod, mkdir, open } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseSessionId } from "../ids/runtime-id";
9
+ import { inspectSessionLock, SessionLease } from "../session/session-lock";
10
+ import { SessionError } from "../session/session-errors";
11
+ import { SERVICE_LEASE_ID } from "../remote/service-store";
12
+ import {
13
+ discoverLocalService,
14
+ localServicePaths,
15
+ type LocalServiceInstance,
16
+ type LocalServiceTarget,
17
+ } from "../remote/local-service-discovery";
18
+
19
+ const STARTUP_LEASE_ID = parseSessionId("00000000-0000-7000-8000-000000000002");
20
+
21
+ /** Shared by the explicit background command and the default client entry. */
22
+ export async function ensureLocalService(
23
+ target: LocalServiceTarget,
24
+ env: NodeJS.ProcessEnv,
25
+ timeoutMs = 30000,
26
+ ): Promise<LocalServiceInstance> {
27
+ const version = (await loadPackageMetadata()).version;
28
+ const checkVersion = (instance: LocalServiceInstance) => {
29
+ if (instance.appVersion && instance.appVersion !== version)
30
+ throw new Error(
31
+ "A different Tinker version is running. Use tinker serve --restart before connecting; active work is not interrupted automatically.",
32
+ );
33
+ return instance;
34
+ };
35
+ const paths = localServicePaths(target.config.stateDirectory);
36
+ if (process.platform === "win32")
37
+ throw new Error("Background service startup currently requires macOS/Linux.");
38
+ const deadline = Date.now() + timeoutMs;
39
+ const existing = await discoverLocalService(target);
40
+ if (existing) return checkVersion(existing);
41
+ await mkdir(paths.startup, { recursive: true, mode: 0o700 });
42
+ await chmod(target.config.stateDirectory, 0o700);
43
+ await chmod(paths.startup, 0o700);
44
+ let lease: SessionLease | undefined;
45
+ while (Date.now() < deadline && !lease) {
46
+ const running = await discoverLocalService(target);
47
+ if (running) return checkVersion(running);
48
+ try {
49
+ lease = await SessionLease.acquire({
50
+ sessionDirectory: paths.startup,
51
+ sessionId: STARTUP_LEASE_ID,
52
+ });
53
+ } catch (error) {
54
+ // A competing process may still be writing its freshly-created lease.
55
+ if (
56
+ !(error instanceof SessionError) ||
57
+ !["SESSION_LOCKED", "SESSION_LOCK_CORRUPT"].includes(error.code)
58
+ )
59
+ throw error;
60
+ await Bun.sleep(50);
61
+ }
62
+ }
63
+ if (!lease)
64
+ throw new Error(
65
+ `Timed out waiting for service startup ownership. Check ${paths.startup}.`,
66
+ );
67
+ let supervised = false;
68
+ let child: ChildProcess | undefined;
69
+ let childFailure: Error | undefined;
70
+ try {
71
+ while (Date.now() < deadline) {
72
+ const running = await discoverLocalService(target);
73
+ if (running) return checkVersion(running);
74
+ if (childFailure) throw childFailure;
75
+ if (child && (child.exitCode !== null || child.signalCode !== null))
76
+ throw new Error(`Service exited before becoming ready. Check ${paths.log}.`);
77
+ if (!child && !supervised) {
78
+ const ownership = await inspectSessionLock({
79
+ sessionDirectory: target.config.stateDirectory,
80
+ sessionId: SERVICE_LEASE_ID,
81
+ });
82
+ // A foreground service or an orphaned starter's child may be initializing.
83
+ // Never kill it, steal its lease, or start a second runtime owner.
84
+ if (ownership === "none" || ownership === "stale") {
85
+ supervised = await startSupervisor(target);
86
+ if (supervised) continue;
87
+ const log = await open(
88
+ paths.log,
89
+ constants.O_WRONLY |
90
+ constants.O_CREAT |
91
+ constants.O_APPEND |
92
+ constants.O_NOFOLLOW,
93
+ 0o600,
94
+ );
95
+ try {
96
+ await log.chmod(0o600);
97
+ child = spawn(
98
+ process.execPath,
99
+ [
100
+ fileURLToPath(new URL("./index.ts", import.meta.url)),
101
+ "serve",
102
+ "--config",
103
+ target.configPath,
104
+ ],
105
+ {
106
+ cwd: path.dirname(target.configPath),
107
+ env: { ...env, TINKER_HOME: target.homeRoot },
108
+ detached: true,
109
+ stdio: ["ignore", log.fd, log.fd],
110
+ },
111
+ );
112
+ child.once("error", () => {
113
+ childFailure = new Error(
114
+ `Could not launch the service. Check ${paths.log}.`,
115
+ );
116
+ });
117
+ child.unref();
118
+ } finally {
119
+ await log.close();
120
+ }
121
+ }
122
+ }
123
+ await Bun.sleep(50);
124
+ }
125
+ throw new Error(
126
+ `Service is not ready. A live owner is left running; check ${paths.log} and ${paths.instance}.`,
127
+ );
128
+ } finally {
129
+ await lease.release();
130
+ }
131
+ }
@@ -0,0 +1,234 @@
1
+ import type { RuntimeSession } from "../agent/runtime-session";
2
+ import type { EventSink } from "../events/event-sink";
3
+ import type { AgentEvent } from "../events/types";
4
+ import type {
5
+ AssistantTextDeltaSink,
6
+ AssistantTextDeltaUpdate,
7
+ } from "../agent/assistant-text-delta";
8
+ import { createUuidV7 } from "../ids/uuid-v7";
9
+ import type { SessionId } from "../ids/runtime-id";
10
+ import { ResumeProjectionReader } from "../session/resume-projection";
11
+ import { SessionCatalog } from "../session/session-catalog";
12
+ import type { TuiSessionView } from "../tui/tui-session-controller";
13
+ import { TuiProjectionStore } from "../tui/tui-projection-store";
14
+ import {
15
+ LocalWorkspaceClient,
16
+ createLocalSessionBinding,
17
+ type OwnedSessionBinding,
18
+ } from "../client/local-workspace-client";
19
+ import type { SessionClient, ClientConnection } from "../client/session-client";
20
+ import {
21
+ deriveRunnerConfig,
22
+ type ResolvedPublicConfig,
23
+ type RunnerConfig,
24
+ } from "./config";
25
+ import { createInteractiveRuntimeSession } from "./interactive-runtime";
26
+ import { resolveSessionProfileName } from "./model-profiles";
27
+ import type { ClientModelProfile } from "../client/model-catalog";
28
+
29
+ export async function createLocalTuiClient(options: {
30
+ publicConfig: ResolvedPublicConfig;
31
+ initialRunnerConfig: RunnerConfig;
32
+ env: NodeJS.ProcessEnv;
33
+ workspaceRoot: string;
34
+ }): Promise<ClientConnection<TuiSessionView>> {
35
+ const { workspaceRoot } = options;
36
+ const config = options.initialRunnerConfig;
37
+ const profiles =
38
+ options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
39
+ const createSessionForConfig = async (
40
+ sessionConfig: RunnerConfig,
41
+ mode: "new" | "resume",
42
+ sessionId: SessionId,
43
+ sink: EventSink & AssistantTextDeltaSink,
44
+ ): Promise<RuntimeSession> =>
45
+ createInteractiveRuntimeSession({
46
+ config: sessionConfig,
47
+ workspaceRoot,
48
+ selection: { mode, sessionId },
49
+ toolingConfig: options.publicConfig.tooling,
50
+ env: options.env,
51
+ sink,
52
+ owner: "local-tui",
53
+ });
54
+
55
+ const projectionStore = new TuiProjectionStore({
56
+ sessionId: config.sessionId,
57
+ modelName: config.modelName,
58
+ workspaceRoot,
59
+ });
60
+ const initialSession = await createSessionForConfig(
61
+ config,
62
+ "new",
63
+ config.sessionId,
64
+ projectionStore,
65
+ );
66
+ try {
67
+ const catalog = new SessionCatalog({ workspaceRoot });
68
+
69
+ const openStoredSession = async (
70
+ sessionId: SessionId,
71
+ ): Promise<OwnedSessionBinding<TuiProjectionStore>> => {
72
+ const deferred = new DeferredProjectionSink();
73
+ const summary = await catalog.get(sessionId);
74
+ const resumeConfig = deriveRunnerConfig(options.publicConfig, {
75
+ sessionId,
76
+ ...(profiles === undefined
77
+ ? {}
78
+ : { profileName: resolveSessionProfileName(profiles, summary) }),
79
+ });
80
+ const runtimeSession = await createSessionForConfig(
81
+ resumeConfig,
82
+ "resume",
83
+ sessionId,
84
+ deferred,
85
+ );
86
+ const targetProjection = new TuiProjectionStore({
87
+ sessionId,
88
+ modelName: resumeConfig.modelName,
89
+ workspaceRoot,
90
+ });
91
+ try {
92
+ targetProjection.hydrate(
93
+ await ResumeProjectionReader.read({
94
+ workspaceRoot,
95
+ sessionId,
96
+ modelName: resumeConfig.modelName,
97
+ }),
98
+ );
99
+ await deferred.attach(targetProjection);
100
+ } catch (error) {
101
+ await runtimeSession
102
+ .dispose({ type: "runner_failed", error: errorMessage(error) })
103
+ .catch(() => undefined);
104
+ throw error;
105
+ }
106
+ return createLocalSessionBinding({
107
+ runtimeSession,
108
+ modelName: resumeConfig.modelName,
109
+ workspaceRoot,
110
+ profileName: resumeConfig.profileName,
111
+ projectionStore: targetProjection,
112
+ });
113
+ };
114
+
115
+ const createNewSessionBinding = async (
116
+ sessionConfig: RunnerConfig,
117
+ ): Promise<OwnedSessionBinding<TuiProjectionStore>> => {
118
+ const freshProjectionStore = new TuiProjectionStore({
119
+ sessionId: sessionConfig.sessionId,
120
+ modelName: sessionConfig.modelName,
121
+ workspaceRoot,
122
+ });
123
+ const runtimeSession = await createSessionForConfig(
124
+ sessionConfig,
125
+ "new",
126
+ sessionConfig.sessionId,
127
+ freshProjectionStore,
128
+ );
129
+ return createLocalSessionBinding({
130
+ runtimeSession,
131
+ modelName: sessionConfig.modelName,
132
+ workspaceRoot,
133
+ profileName: sessionConfig.profileName,
134
+ projectionStore: freshProjectionStore,
135
+ });
136
+ };
137
+
138
+ const createSessionWithProfile = async (
139
+ profile: ClientModelProfile,
140
+ ): Promise<OwnedSessionBinding<TuiProjectionStore>> => {
141
+ if (profiles === undefined) {
142
+ throw new Error("Model profiles are not configured.");
143
+ }
144
+ return createNewSessionBinding(
145
+ deriveRunnerConfig(options.publicConfig, {
146
+ sessionId: createUuidV7() as SessionId,
147
+ profileName: profile.name,
148
+ }),
149
+ );
150
+ };
151
+
152
+ const createFreshSession = async (
153
+ current: SessionClient<TuiProjectionStore>,
154
+ ): Promise<OwnedSessionBinding<TuiProjectionStore>> => {
155
+ const sessionId = createUuidV7() as SessionId;
156
+ if (profiles === undefined) {
157
+ return createNewSessionBinding(
158
+ deriveRunnerConfig(options.publicConfig, { sessionId }),
159
+ );
160
+ }
161
+ if (current.profileName === undefined) {
162
+ throw new Error("Current session does not have a model profile.");
163
+ }
164
+ return createNewSessionBinding(
165
+ deriveRunnerConfig(options.publicConfig, {
166
+ sessionId,
167
+ profileName: current.profileName,
168
+ }),
169
+ );
170
+ };
171
+
172
+ const client = new LocalWorkspaceClient(
173
+ createLocalSessionBinding({
174
+ runtimeSession: initialSession,
175
+ modelName: config.modelName,
176
+ workspaceRoot,
177
+ profileName: config.profileName,
178
+ projectionStore,
179
+ }),
180
+ catalog,
181
+ openStoredSession,
182
+ createSessionWithProfile,
183
+ createFreshSession,
184
+ );
185
+ return {
186
+ client,
187
+ // Transitional local ownership retains today's exit cleanup policy.
188
+ close: (reason) =>
189
+ client.dispose(
190
+ reason.type === "client_exit"
191
+ ? { type: "tui_exit" }
192
+ : { type: "runner_failed", error: reason.error },
193
+ ),
194
+ };
195
+ } catch (error) {
196
+ await initialSession
197
+ .dispose({ type: "initialization_failed", error: errorMessage(error) })
198
+ .catch(() => undefined);
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ class DeferredProjectionSink implements EventSink, AssistantTextDeltaSink {
204
+ readonly name = "deferred-tui-projection";
205
+ private target?: TuiProjectionStore;
206
+ private readonly buffered: AgentEvent[] = [];
207
+
208
+ async append(event: AgentEvent): Promise<void> {
209
+ if (this.target === undefined) {
210
+ this.buffered.push(event);
211
+ return;
212
+ }
213
+ await this.target.append(event);
214
+ }
215
+
216
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
217
+ this.target?.updateAssistantTextDelta(update);
218
+ }
219
+
220
+ async attach(target: TuiProjectionStore): Promise<void> {
221
+ if (this.target !== undefined) {
222
+ throw new Error("Deferred projection sink is already attached.");
223
+ }
224
+ this.target = target;
225
+ for (const event of this.buffered) {
226
+ await target.append(event);
227
+ }
228
+ this.buffered.length = 0;
229
+ }
230
+ }
231
+
232
+ function errorMessage(error: unknown): string {
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
package/src/cli/main.ts CHANGED
@@ -90,6 +90,7 @@ export type MainDependencies = {
90
90
  input: { readonly stdin: PromptReadable; readonly cwd: string },
91
91
  ) => Promise<ResolvedPrompt>;
92
92
  readonly loadTuiRunner: () => Promise<TuiRunner>;
93
+ readonly loadDefaultTuiRunner: () => Promise<typeof import("./default-tui-runner")>;
93
94
  readonly loadOneShotRunner: () => Promise<OneShotRunner>;
94
95
  readonly loadUpdateRunner: () => Promise<UpdateRunner>;
95
96
  readonly loadServeRunner: () => Promise<typeof import("./serve-runner")>;
@@ -103,6 +104,7 @@ const DEFAULT_DEPENDENCIES: MainDependencies = {
103
104
  createSessionId: () => createUuidV7() as SessionId,
104
105
  resolvePromptSource,
105
106
  loadTuiRunner: () => import("./tui-runner"),
107
+ loadDefaultTuiRunner: () => import("./default-tui-runner"),
106
108
  loadOneShotRunner: () => import("./run-runner"),
107
109
  loadUpdateRunner: () => import("./update-runner"),
108
110
  loadServeRunner: () => import("./serve-runner"),
@@ -167,15 +169,33 @@ export async function main(
167
169
 
168
170
  if (parsed.command.type === "serve" || parsed.command.type === "connect") {
169
171
  const options = {
170
- configPath: path.resolve(cwd, parsed.command.configPath),
172
+ ...(parsed.command.configPath === undefined
173
+ ? {}
174
+ : { configPath: path.resolve(cwd, parsed.command.configPath) }),
171
175
  env,
172
176
  stdout: input.stdout,
173
177
  };
174
178
  try {
175
179
  const exitCode =
176
180
  parsed.command.type === "serve"
177
- ? await (await dependencies.loadServeRunner()).runServe(options)
178
- : await (await dependencies.loadConnectRunner()).runConnect(options);
181
+ ? await (await dependencies.loadServeRunner()).runServe({
182
+ ...parsed.command,
183
+ ...options,
184
+ })
185
+ : await (await dependencies.loadConnectRunner()).runConnect({
186
+ ...parsed.command,
187
+ ...options,
188
+ configPath: path.resolve(cwd, parsed.command.configPath),
189
+ cwd,
190
+ ...(parsed.command.serviceConfigPath === undefined
191
+ ? {}
192
+ : {
193
+ serviceConfigPath: path.resolve(
194
+ cwd,
195
+ parsed.command.serviceConfigPath,
196
+ ),
197
+ }),
198
+ });
179
199
  return finish(exitCode);
180
200
  } catch (error) {
181
201
  await writeCliOutput(
@@ -186,6 +206,29 @@ export async function main(
186
206
  }
187
207
  }
188
208
 
209
+ if (parsed.command.type === "tui" && !parsed.command.local) {
210
+ try {
211
+ const runner = await dependencies.loadDefaultTuiRunner();
212
+ return finish(
213
+ await runner.runDefaultTui({
214
+ cwd,
215
+ env,
216
+ stdout: input.stdout,
217
+ profileName: parsed.command.profileName,
218
+ }),
219
+ );
220
+ } catch (error) {
221
+ await writeCliOutput(
222
+ input.stderr,
223
+ renderCliFailure(
224
+ "Local service connection failed (use --local for independent execution)",
225
+ error,
226
+ ),
227
+ );
228
+ return finish(1);
229
+ }
230
+ }
231
+
189
232
  let configBoundary: ConfigBoundary;
190
233
  let publicConfig: ResolvedPublicConfig;
191
234
  let runnerConfig: RunnerConfig;
@@ -44,21 +44,84 @@ export const PUBLIC_CLI_CONTRACT = Object.freeze({
44
44
  description: "display help for command",
45
45
  }),
46
46
  tui: Object.freeze({
47
- description: "Start the interactive terminal interface.",
47
+ description:
48
+ "Start the full TUI through the shared local service; exiting detaches only.",
49
+ localOption: Object.freeze({
50
+ flags: "--local",
51
+ description: "Run the TUI independently without a service (explicit fallback).",
52
+ } satisfies PublicCliOption),
48
53
  profileOption: PROFILE_OPTION,
49
54
  }),
50
55
  serve: Object.freeze({
56
+ residentOptions: Object.freeze([
57
+ {
58
+ flags: "--install",
59
+ description:
60
+ "Install and start a macOS per-user LaunchAgent with crash restart.",
61
+ },
62
+ {
63
+ flags: "--uninstall",
64
+ description:
65
+ "Drain, stop and remove the LaunchAgent; retain history and configuration.",
66
+ },
67
+ {
68
+ flags: "--stop",
69
+ description:
70
+ "Drain and stop the service; disable supervisor restart until the next start.",
71
+ },
72
+ {
73
+ flags: "--restart",
74
+ description: "Drain and restart with the current executable and configuration.",
75
+ },
76
+ {
77
+ flags: "--force",
78
+ description: "Allow interruption after the configured shutdown grace period.",
79
+ },
80
+ ] satisfies readonly PublicCliOption[]),
51
81
  command: "serve",
52
82
  description: "Run the local daemon for paired remote clients.",
53
83
  configOption: Object.freeze({
54
84
  flags: "--config <path>",
55
- description: "Read the service JSON configuration.",
85
+ description:
86
+ "Read service JSON (default: <home>/.tinker/service/service.json; home follows TINKER_HOME).",
56
87
  valueName: "path",
57
88
  } satisfies PublicCliOption),
89
+ backgroundOption: Object.freeze({
90
+ flags: "--background",
91
+ description:
92
+ "Discover or start one detached service for this state directory, and print its address as JSON.",
93
+ } satisfies PublicCliOption),
94
+ statusOption: Object.freeze({
95
+ flags: "--status",
96
+ description:
97
+ "Probe the local service without starting it; print JSON and exit 1 when offline.",
98
+ } satisfies PublicCliOption),
58
99
  }),
59
100
  connect: Object.freeze({
60
101
  command: "connect",
61
102
  description: "Attach a terminal client to a service; exiting detaches only.",
103
+ tuiOption: Object.freeze({
104
+ flags: "--tui",
105
+ description: "Use the full TUI for service sessions and task execution.",
106
+ } satisfies PublicCliOption),
107
+ workspaceOption: Object.freeze({
108
+ flags: "--workspace <id>",
109
+ description:
110
+ "Select a service workspace for --tui; otherwise resolve the current directory on the service host.",
111
+ valueName: "id",
112
+ } satisfies PublicCliOption),
113
+ serviceConfigOption: Object.freeze({
114
+ flags: "--service-config <path>",
115
+ description:
116
+ "With --tui, discover/start this local service and register the current directory if needed.",
117
+ valueName: "path",
118
+ } satisfies PublicCliOption),
119
+ sessionOption: Object.freeze({
120
+ flags: "--session <id>",
121
+ description:
122
+ "Connect an existing session with --tui; otherwise create a new one.",
123
+ valueName: "id",
124
+ } satisfies PublicCliOption),
62
125
  configOption: Object.freeze({
63
126
  flags: "--config <path>",
64
127
  description: "Read the paired client JSON configuration.",
@@ -605,7 +605,7 @@ function optionalRawString(value: string | undefined): string | undefined {
605
605
  return normalized === undefined || normalized === "" ? undefined : normalized;
606
606
  }
607
607
 
608
- function resolveUserPath(cwd: string, value: string): string {
608
+ export function resolveUserPath(cwd: string, value: string): string {
609
609
  let expanded = value;
610
610
  if (value === "~") {
611
611
  expanded = os.homedir();