tinker-agent 2.13.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.
- package/CHANGELOG.md +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- 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
|
-
|
|
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(
|
|
178
|
-
|
|
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:
|
|
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:
|
|
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();
|