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.
- package/CHANGELOG.md +66 -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/runner-dependencies.ts +35 -35
- 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/tools/bash.ts +9 -1
- package/src/tools/bounded-output-preview.ts +2 -2
- 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
|
@@ -12,47 +12,47 @@ import type { RunnerConfig } from "./config";
|
|
|
12
12
|
|
|
13
13
|
export const RUNTIME_INSTRUCTIONS = (
|
|
14
14
|
workspaceRoot: string,
|
|
15
|
-
): string => `You are a coding agent.
|
|
15
|
+
): string => `You are Tinker, a coding agent.
|
|
16
|
+
|
|
17
|
+
## Workspace
|
|
16
18
|
|
|
17
|
-
Current workspace:
|
|
18
19
|
${workspaceRoot}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Use Read to open specific files returned by Grep.
|
|
28
|
-
Use Edit to replace exact strings in existing files. Set old_string="" to create a file or write to an empty file.
|
|
29
|
-
Use Read before the first Write of an existing file in the current runtime.
|
|
30
|
-
Write creates missing parent directories when creating a file.
|
|
31
|
-
Write may fail if the runtime has no known version or the file changed after it was last observed. If that happens, call Read again and retry with the updated content.
|
|
32
|
-
Use Read before an exact-string Edit when this runtime has not already established the current version through Read, Write, or Edit. A successful paginated Read is sufficient. Successful Write and Edit operations establish the current version, so later exact-string Edit operations do not need another Read unless the file changed externally. Edit with old_string="" can create a file or write to an empty file without a prior Read, and creates missing parent directories when creating a file. Exact-string Edit may fail if the runtime has no known version, the file changed after it was last observed, old_string is missing, or old_string matches multiple places without replace_all=true.
|
|
33
|
-
Use WebSearch, when it is available, to look up current information on the web such as recent releases, documentation, and news. Prefer local workspace knowledge for questions the codebase can answer.
|
|
34
|
-
Use WebFetch to read the content of a specific URL, such as documentation pages found via WebSearch.
|
|
21
|
+
Relative file-tool paths resolve from this workspace.
|
|
22
|
+
Absolute file paths may refer to locations outside it.
|
|
23
|
+
|
|
24
|
+
## Runtime contracts
|
|
25
|
+
|
|
26
|
+
Read, Write, and Edit participate in runtime file-version tracking.
|
|
27
|
+
Their tool definitions specify operation-specific preconditions and exceptions.
|
|
35
28
|
Prefer Read for reading files instead of using cat on large files.
|
|
36
29
|
Prefer Write or Edit for changing files instead of shell redirection.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
TaskOutput reports a task's current status, latest output, or current terminal screen. For non-PTY logs, offset (1-based) and limit select consecutive lines instead of the default head/tail preview; PTY tasks ignore them. Range truncated=true means byte limits shortened requested content, not that lines outside the range exist. The last observed line of a running log may still be growing; rereading it when polling captures further changes to that line.
|
|
43
|
-
TaskInput sends characters to a PTY task identified by the returned task ID. TaskInput does not append Enter; an explicit \\n sends Enter, \\u0003 sends Ctrl-C, and chars="" waits without writing.
|
|
44
|
-
TaskStop stops a background task that is no longer needed.
|
|
45
|
-
Do not use ad-hoc kill commands to manage tasks created by Bash.
|
|
46
|
-
Bash and TaskOutput return outputFilePath. Use Read on outputFilePath when you need complete or paginated output.
|
|
47
|
-
Do not send passwords, tokens, or other secrets through TaskInput because tool arguments are stored in session history.
|
|
48
|
-
Use UpdatePlan for non-trivial work with multiple meaningful phases, when sequencing or checkpoints help the user follow progress. Do not use it for simple or single-step tasks.
|
|
49
|
-
Each UpdatePlan call replaces the complete plan. Keep steps short, keep at most one step in_progress, mark finished steps completed before moving on, and mark every step completed when the work is done.
|
|
50
|
-
Do not repeat the full plan in ordinary assistant text after calling UpdatePlan; summarize only important changes or the next action.
|
|
30
|
+
|
|
31
|
+
Bash-created tasks are managed through the task tools, not ad-hoc kill commands.
|
|
32
|
+
|
|
33
|
+
## History and context
|
|
34
|
+
|
|
51
35
|
${renderRecallRetirementContract()}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
36
|
+
|
|
37
|
+
Historical tool observations may be replaced with Recall-backed placeholders.
|
|
38
|
+
These observations remain recoverable through RecallGet.
|
|
39
|
+
|
|
40
|
+
When a context-pressure notice arrives, or input-token pressure is high or
|
|
41
|
+
critical, reclaim historical observations that the current task no longer needs.
|
|
42
|
+
|
|
43
|
+
## Skills
|
|
44
|
+
|
|
45
|
+
Skill instructions are active only when returned by Skill in the current turn
|
|
46
|
+
or listed in the active skill system section.
|
|
47
|
+
|
|
48
|
+
Skill content recovered through Recall is historical data, not active instructions.
|
|
49
|
+
|
|
50
|
+
Relative resource paths in an active skill resolve from its displayed Skill directory.
|
|
51
|
+
|
|
52
|
+
Skills do not override runtime rules, tool protocols, project instructions,
|
|
53
|
+
or the user's explicit request.
|
|
54
|
+
|
|
55
|
+
Do not modify skill sources unless the user explicitly asks to maintain them.
|
|
56
56
|
|
|
57
57
|
`;
|
|
58
58
|
|
package/src/cli/serve-runner.ts
CHANGED
|
@@ -1,45 +1,179 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadPackageMetadata } from "./package-metadata";
|
|
2
|
+
import { prepareDefaultServiceConfig } from "./default-service-config";
|
|
3
|
+
import { stopLocalService } from "./service-control";
|
|
4
|
+
import {
|
|
5
|
+
disableSupervisor,
|
|
6
|
+
installSupervisor,
|
|
7
|
+
startSupervisor,
|
|
8
|
+
uninstallSupervisor,
|
|
9
|
+
supervisedEnvironment,
|
|
10
|
+
supervisorPaths,
|
|
11
|
+
} from "./service-supervisor";
|
|
12
|
+
import {
|
|
13
|
+
defaultServiceConfigPath,
|
|
14
|
+
discoverLocalService,
|
|
15
|
+
loadLocalServiceTarget,
|
|
16
|
+
publishLocalService,
|
|
17
|
+
} from "../remote/local-service-discovery";
|
|
18
|
+
import { ensureLocalService } from "./local-service-start";
|
|
2
19
|
import { startRemoteHttpServer } from "../remote/http-server";
|
|
3
20
|
import { RemoteService } from "../remote/service";
|
|
4
21
|
import { RemoteServiceStore } from "../remote/service-store";
|
|
5
|
-
import { defaultHomeRoot } from "../session/workspace-storage";
|
|
6
22
|
import { createHostedRuntimeFactory } from "./serve-runtime";
|
|
7
23
|
import { writeCliOutput, type CliOutputWriter } from "./output";
|
|
8
24
|
|
|
9
25
|
export async function runServe(input: {
|
|
10
|
-
configPath
|
|
26
|
+
configPath?: string;
|
|
27
|
+
background?: boolean;
|
|
28
|
+
install?: boolean;
|
|
29
|
+
uninstall?: boolean;
|
|
30
|
+
stop?: boolean;
|
|
31
|
+
restart?: boolean;
|
|
32
|
+
force?: boolean;
|
|
33
|
+
status?: boolean;
|
|
11
34
|
env: NodeJS.ProcessEnv;
|
|
12
35
|
stdout: CliOutputWriter;
|
|
13
36
|
}): Promise<number> {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
homeRoot,
|
|
37
|
+
const env = await supervisedEnvironment(input.env);
|
|
38
|
+
// MCP and tool subprocesses inherit the service process environment.
|
|
39
|
+
if (input.env.TINKER_SERVICE_ENV_FILE) Object.assign(process.env, env);
|
|
40
|
+
if (input.install && !input.configPath) await prepareDefaultServiceConfig(env);
|
|
41
|
+
const target = await loadLocalServiceTarget(
|
|
42
|
+
input.configPath ?? defaultServiceConfigPath(env),
|
|
43
|
+
env,
|
|
22
44
|
);
|
|
45
|
+
if (input.install && (await Bun.file(supervisorPaths(target).marker).exists())) {
|
|
46
|
+
const instance = await ensureLocalService(target, env);
|
|
47
|
+
await writeCliOutput(
|
|
48
|
+
input.stdout,
|
|
49
|
+
`${JSON.stringify({ status: "online", ...instance })}\n`,
|
|
50
|
+
);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (input.install || input.uninstall || input.stop || input.restart) {
|
|
54
|
+
await disableSupervisor(target);
|
|
55
|
+
try {
|
|
56
|
+
await stopLocalService(target, input.force);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await startSupervisor(target).catch(() => undefined);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (input.uninstall) await uninstallSupervisor(target);
|
|
62
|
+
if (input.install) await installSupervisor(target, env);
|
|
63
|
+
const instance =
|
|
64
|
+
input.install || input.restart
|
|
65
|
+
? await ensureLocalService(target, env)
|
|
66
|
+
: undefined;
|
|
67
|
+
await writeCliOutput(
|
|
68
|
+
input.stdout,
|
|
69
|
+
`${JSON.stringify(instance ? { status: "online", ...instance } : { status: "stopped", stateDirectory: target.config.stateDirectory })}\n`,
|
|
70
|
+
);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (input.background || input.status) {
|
|
74
|
+
const instance = input.status
|
|
75
|
+
? await discoverLocalService(target, true)
|
|
76
|
+
: await ensureLocalService(target, env);
|
|
77
|
+
await writeCliOutput(
|
|
78
|
+
input.stdout,
|
|
79
|
+
`${JSON.stringify(
|
|
80
|
+
instance
|
|
81
|
+
? { status: "online", ...instance }
|
|
82
|
+
: {
|
|
83
|
+
status: "offline",
|
|
84
|
+
configPath: target.configPath,
|
|
85
|
+
stateDirectory: target.config.stateDirectory,
|
|
86
|
+
},
|
|
87
|
+
)}\n`,
|
|
88
|
+
);
|
|
89
|
+
return instance ? 0 : 1;
|
|
90
|
+
}
|
|
91
|
+
const { config, homeRoot } = target;
|
|
92
|
+
const store = await RemoteServiceStore.open(config.stateDirectory);
|
|
93
|
+
let service: RemoteService;
|
|
94
|
+
try {
|
|
95
|
+
service = new RemoteService(
|
|
96
|
+
store,
|
|
97
|
+
config.workspaces,
|
|
98
|
+
createHostedRuntimeFactory(() => service.workspaces, env, homeRoot),
|
|
99
|
+
homeRoot,
|
|
100
|
+
config.resident,
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await store.close();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
23
106
|
let transport: ReturnType<typeof startRemoteHttpServer> | undefined;
|
|
107
|
+
let unpublish: (() => Promise<void>) | undefined;
|
|
108
|
+
let stopRequested = false;
|
|
109
|
+
let signalStop: (() => void) | undefined;
|
|
110
|
+
let hardStop: ReturnType<typeof setTimeout> | undefined;
|
|
111
|
+
const setDeadline = () => {
|
|
112
|
+
hardStop ??= setTimeout(
|
|
113
|
+
() => {
|
|
114
|
+
console.error(
|
|
115
|
+
"Service shutdown timed out; recovery will mark unfinished work interrupted on restart.",
|
|
116
|
+
);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
},
|
|
119
|
+
(config.resident?.shutdownGraceMs ?? 30000) + 10000,
|
|
120
|
+
);
|
|
121
|
+
hardStop.unref();
|
|
122
|
+
};
|
|
123
|
+
const stop = () => {
|
|
124
|
+
setDeadline();
|
|
125
|
+
stopRequested = true;
|
|
126
|
+
signalStop?.();
|
|
127
|
+
};
|
|
128
|
+
const onSignal = () => {
|
|
129
|
+
setDeadline();
|
|
130
|
+
void service
|
|
131
|
+
.drain(true)
|
|
132
|
+
.catch(() => undefined)
|
|
133
|
+
.finally(stop);
|
|
134
|
+
};
|
|
135
|
+
process.once("SIGINT", onSignal);
|
|
136
|
+
process.once("SIGTERM", onSignal);
|
|
24
137
|
try {
|
|
25
138
|
await service.initialize();
|
|
26
139
|
transport = startRemoteHttpServer(service, config);
|
|
140
|
+
if (stopRequested) return 0;
|
|
141
|
+
if (process.platform !== "win32")
|
|
142
|
+
unpublish = await publishLocalService(
|
|
143
|
+
target,
|
|
144
|
+
service.epoch,
|
|
145
|
+
transport.port,
|
|
146
|
+
(directory) => service.registerLocalWorkspace(directory),
|
|
147
|
+
{
|
|
148
|
+
appVersion: (await loadPackageMetadata()).version,
|
|
149
|
+
status: () => service.residentStatus(),
|
|
150
|
+
shutdown: async (force) => {
|
|
151
|
+
await service.drain(force);
|
|
152
|
+
return stop;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
);
|
|
27
156
|
await writeCliOutput(
|
|
28
157
|
input.stdout,
|
|
29
|
-
`Tinker service listening on https://${config.hostname}:${transport.port}; ${
|
|
158
|
+
`Tinker service listening on https://${config.hostname === "::1" ? "[::1]" : config.hostname}:${transport.port}; ${service.workspaces.length} workspace(s).\nClient disconnects detach only. Stop the process to shut down hosted sessions.\n`,
|
|
30
159
|
);
|
|
31
160
|
await new Promise<void>((resolve) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
process.off("SIGTERM", stop);
|
|
35
|
-
resolve();
|
|
36
|
-
};
|
|
37
|
-
process.once("SIGINT", stop);
|
|
38
|
-
process.once("SIGTERM", stop);
|
|
161
|
+
signalStop = resolve;
|
|
162
|
+
if (stopRequested) resolve();
|
|
39
163
|
});
|
|
40
164
|
return 0;
|
|
41
165
|
} finally {
|
|
42
|
-
|
|
43
|
-
|
|
166
|
+
process.off("SIGINT", onSignal);
|
|
167
|
+
process.off("SIGTERM", onSignal);
|
|
168
|
+
try {
|
|
169
|
+
await unpublish?.();
|
|
170
|
+
} finally {
|
|
171
|
+
try {
|
|
172
|
+
await transport?.stopTransport();
|
|
173
|
+
} finally {
|
|
174
|
+
await service.close();
|
|
175
|
+
if (hardStop) clearTimeout(hardStop);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
44
178
|
}
|
|
45
179
|
}
|
package/src/cli/serve-runtime.ts
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
|
-
import { createRuntimeSession } from "../agent/runtime-session";
|
|
2
1
|
import { parseSessionId } from "../ids/runtime-id";
|
|
3
|
-
import {
|
|
4
|
-
buildSystemPrompt,
|
|
5
|
-
loadProjectInstructions,
|
|
6
|
-
projectInstructionManifest,
|
|
7
|
-
} from "../instructions/project-instructions";
|
|
8
|
-
import { createReasoningEffortController } from "../model/reasoning-effort";
|
|
9
2
|
import { resolveSessionDatabasePath } from "../session/session-store";
|
|
10
3
|
import { SessionCatalog } from "../session/session-catalog";
|
|
11
|
-
import { loadSkillCatalog } from "../skills/skill-loader";
|
|
12
4
|
import type { RemoteWorkspaceConfig } from "../remote/config";
|
|
13
5
|
import type { HostedRuntimeFactory } from "../agent/runtime-hosted-session";
|
|
14
6
|
import { deriveRunnerConfig, resolvePublicConfig } from "./config";
|
|
15
7
|
import { resolveSessionProfileName } from "./model-profiles";
|
|
16
|
-
import {
|
|
17
|
-
createRunnerModelClient,
|
|
18
|
-
createWebFetchRefiner,
|
|
19
|
-
RUNTIME_INSTRUCTIONS,
|
|
20
|
-
} from "./runner-dependencies";
|
|
8
|
+
import { createInteractiveRuntimeSession } from "./interactive-runtime";
|
|
21
9
|
|
|
22
10
|
/** Service composition reuses the existing configuration/provider/runtime contracts. */
|
|
23
11
|
export function createHostedRuntimeFactory(
|
|
24
|
-
workspaces:
|
|
12
|
+
workspaces:
|
|
13
|
+
| readonly RemoteWorkspaceConfig[]
|
|
14
|
+
| (() => readonly RemoteWorkspaceConfig[]),
|
|
25
15
|
env: NodeJS.ProcessEnv,
|
|
26
16
|
homeRoot?: string,
|
|
27
17
|
): HostedRuntimeFactory {
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
const entries = () => (typeof workspaces === "function" ? workspaces() : workspaces);
|
|
19
|
+
const factory: HostedRuntimeFactory = async ({ record, sink, lease }) => {
|
|
20
|
+
const workspace = entries().find((entry) => entry.id === record.workspaceId);
|
|
30
21
|
if (!workspace || workspace.path !== record.workspacePath)
|
|
31
22
|
throw new Error("Managed workspace configuration changed.");
|
|
32
23
|
const sessionId = parseSessionId(record.id);
|
|
@@ -34,7 +25,7 @@ export function createHostedRuntimeFactory(
|
|
|
34
25
|
env: { ...env, TINKER_WORKSPACE: workspace.path },
|
|
35
26
|
cwd: workspace.path,
|
|
36
27
|
});
|
|
37
|
-
let profileName = workspace.profile;
|
|
28
|
+
let profileName = record.profileName ?? workspace.profile;
|
|
38
29
|
if (record.initialized && publicConfig.mode === "profile") {
|
|
39
30
|
const summary = await new SessionCatalog({
|
|
40
31
|
workspaceRoot: workspace.path,
|
|
@@ -46,38 +37,16 @@ export function createHostedRuntimeFactory(
|
|
|
46
37
|
sessionId,
|
|
47
38
|
...(profileName ? { profileName } : {}),
|
|
48
39
|
});
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
const runtime = await createInteractiveRuntimeSession({
|
|
41
|
+
config,
|
|
42
|
+
sessionLease: lease,
|
|
52
43
|
workspaceRoot: workspace.path,
|
|
53
44
|
...(homeRoot === undefined ? {} : { homeRoot }),
|
|
54
|
-
|
|
55
|
-
? { selection: { mode: "resume" as const, sessionId } }
|
|
56
|
-
: { selection: { mode: "new" as const, sessionId } }),
|
|
57
|
-
modelName: config.modelName,
|
|
58
|
-
profileName: config.profileName,
|
|
59
|
-
maxIterations: config.maxIterations,
|
|
60
|
-
includeReasoningContent: config.includeReasoningContent,
|
|
61
|
-
contextProfile: config.contextProfile,
|
|
62
|
-
contextBudget: config.contextBudget,
|
|
63
|
-
modelClient: createRunnerModelClient(config, undefined, env, reasoning),
|
|
64
|
-
systemPrompt: buildSystemPrompt({
|
|
65
|
-
workspaceRoot: workspace.path,
|
|
66
|
-
runtimeInstructions: RUNTIME_INSTRUCTIONS(workspace.path),
|
|
67
|
-
projectInstructions,
|
|
68
|
-
}),
|
|
69
|
-
projectInstruction: projectInstructionManifest(projectInstructions),
|
|
70
|
-
skillCatalog: await loadSkillCatalog({ workspaceRoot: workspace.path }),
|
|
71
|
-
presentationSinks: [sink],
|
|
72
|
-
assistantTextDeltaSink: sink,
|
|
45
|
+
selection: { mode: record.initialized ? "resume" : "new", sessionId },
|
|
73
46
|
toolingConfig: publicConfig.tooling,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
mode: config.bashGuardMode,
|
|
78
|
-
source: config.bashGuardSource,
|
|
79
|
-
surface: "tui",
|
|
80
|
-
},
|
|
47
|
+
env,
|
|
48
|
+
sink,
|
|
49
|
+
owner: "service",
|
|
81
50
|
});
|
|
82
51
|
try {
|
|
83
52
|
return {
|
|
@@ -88,6 +57,8 @@ export function createHostedRuntimeFactory(
|
|
|
88
57
|
homeRoot,
|
|
89
58
|
),
|
|
90
59
|
modelName: config.modelName,
|
|
60
|
+
profileName: config.profileName,
|
|
61
|
+
modelCatalog: await factory.profiles!(workspace.id),
|
|
91
62
|
};
|
|
92
63
|
} catch (error) {
|
|
93
64
|
await runtime.dispose({
|
|
@@ -97,4 +68,34 @@ export function createHostedRuntimeFactory(
|
|
|
97
68
|
throw error;
|
|
98
69
|
}
|
|
99
70
|
};
|
|
71
|
+
const readConfig = async (workspaceId: string) => {
|
|
72
|
+
const workspace = entries().find((entry) => entry.id === workspaceId);
|
|
73
|
+
if (!workspace) throw new Error("Workspace is not configured.");
|
|
74
|
+
return resolvePublicConfig({
|
|
75
|
+
env: { ...env, TINKER_WORKSPACE: workspace.path },
|
|
76
|
+
cwd: workspace.path,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
factory.profiles = async (workspaceId) => {
|
|
80
|
+
const config = await readConfig(workspaceId);
|
|
81
|
+
if (config.mode !== "profile") return undefined;
|
|
82
|
+
return {
|
|
83
|
+
defaultProfile: config.profiles.defaultProfile,
|
|
84
|
+
profiles: [...config.profiles.profiles.values()].map(
|
|
85
|
+
({ name, model, contextWindowTokens, maxSupportedOutputTokens }) => ({
|
|
86
|
+
name,
|
|
87
|
+
model,
|
|
88
|
+
contextWindowTokens,
|
|
89
|
+
maxSupportedOutputTokens,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
factory.persistDefaultProfile = async (workspaceId, profileName) => {
|
|
95
|
+
const config = await readConfig(workspaceId);
|
|
96
|
+
if (config.mode !== "profile")
|
|
97
|
+
throw new Error("Model profiles are not configured.");
|
|
98
|
+
await config.persistDefaultProfile(profileName);
|
|
99
|
+
};
|
|
100
|
+
return factory;
|
|
100
101
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { inspectSessionLock } from "../session/session-lock";
|
|
2
|
+
import { SERVICE_LEASE_ID } from "../remote/service-store";
|
|
3
|
+
import {
|
|
4
|
+
shutdownLocalService,
|
|
5
|
+
type LocalServiceTarget,
|
|
6
|
+
} from "../remote/local-service-discovery";
|
|
7
|
+
|
|
8
|
+
/** Uses the private live endpoint; never sends signals to a PID from stale metadata. */
|
|
9
|
+
export async function stopLocalService(
|
|
10
|
+
target: LocalServiceTarget,
|
|
11
|
+
force = false,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
await shutdownLocalService(target, force);
|
|
14
|
+
const deadline =
|
|
15
|
+
Date.now() + (target.config.resident?.shutdownGraceMs ?? 30000) + 15000;
|
|
16
|
+
while (Date.now() < deadline) {
|
|
17
|
+
const ownership = await inspectSessionLock({
|
|
18
|
+
sessionDirectory: target.config.stateDirectory,
|
|
19
|
+
sessionId: SERVICE_LEASE_ID,
|
|
20
|
+
});
|
|
21
|
+
if (ownership === "none" || ownership === "stale") return;
|
|
22
|
+
await Bun.sleep(50);
|
|
23
|
+
}
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Service has not released ownership. Inspect ${target.config.stateDirectory}; no process was killed automatically.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile, chmod } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { LocalServiceTarget } from "../remote/local-service-discovery";
|
|
7
|
+
|
|
8
|
+
export function supervisorPaths(target: LocalServiceTarget) {
|
|
9
|
+
const label = `dev.tinker.service.${createHash("sha256").update(target.config.stateDirectory).digest("hex").slice(0, 16)}`;
|
|
10
|
+
return {
|
|
11
|
+
label,
|
|
12
|
+
job: `gui/${process.getuid?.()}/${label}`,
|
|
13
|
+
plist: path.join(homedir(), "Library/LaunchAgents", `${label}.plist`),
|
|
14
|
+
enabled: path.join(target.config.stateDirectory, "supervisor.enabled"),
|
|
15
|
+
environment: path.join(target.config.stateDirectory, "supervisor-env.json"),
|
|
16
|
+
marker: path.join(target.config.stateDirectory, "supervisor.json"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const xml = (text: string) =>
|
|
20
|
+
text.replace(
|
|
21
|
+
/[&<>"']/g,
|
|
22
|
+
(c) =>
|
|
23
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
|
24
|
+
);
|
|
25
|
+
export function launchAgentPlist(target: LocalServiceTarget): string {
|
|
26
|
+
const files = supervisorPaths(target);
|
|
27
|
+
const args = [
|
|
28
|
+
process.execPath,
|
|
29
|
+
fileURLToPath(new URL("./index.ts", import.meta.url)),
|
|
30
|
+
"serve",
|
|
31
|
+
"--config",
|
|
32
|
+
target.configPath,
|
|
33
|
+
];
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
|
|
35
|
+
<key>Label</key><string>${xml(files.label)}</string>
|
|
36
|
+
<key>ProgramArguments</key><array>${args.map((value) => `<string>${xml(value)}</string>`).join("")}</array>
|
|
37
|
+
<key>WorkingDirectory</key><string>${xml(path.dirname(target.configPath))}</string>
|
|
38
|
+
<key>EnvironmentVariables</key><dict><key>TINKER_HOME</key><string>${xml(target.homeRoot)}</string><key>TINKER_SERVICE_ENV_FILE</key><string>${xml(files.environment)}</string></dict>
|
|
39
|
+
<key>KeepAlive</key><dict><key>PathState</key><dict><key>${xml(files.enabled)}</key><true/></dict></dict>
|
|
40
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
41
|
+
<key>ExitTimeOut</key><integer>${Math.ceil((target.config.resident?.shutdownGraceMs ?? 30000) / 1000) + 10}</integer>
|
|
42
|
+
<key>StandardOutPath</key><string>${xml(path.join(target.config.stateDirectory, "service.log"))}</string>
|
|
43
|
+
<key>StandardErrorPath</key><string>${xml(path.join(target.config.stateDirectory, "service.log"))}</string>
|
|
44
|
+
<key>Umask</key><integer>63</integer>
|
|
45
|
+
</dict></plist>\n`;
|
|
46
|
+
}
|
|
47
|
+
export async function launchctl(args: string[]): Promise<void> {
|
|
48
|
+
const child = Bun.spawn(["/bin/launchctl", ...args], {
|
|
49
|
+
stdout: "ignore",
|
|
50
|
+
stderr: "pipe",
|
|
51
|
+
});
|
|
52
|
+
const [error, code] = await Promise.all([
|
|
53
|
+
new Response(child.stderr).text(),
|
|
54
|
+
child.exited,
|
|
55
|
+
]);
|
|
56
|
+
if (code !== 0)
|
|
57
|
+
throw new Error(`launchctl ${args[0]} failed (${code}): ${error.trim()}`);
|
|
58
|
+
}
|
|
59
|
+
export async function installSupervisor(
|
|
60
|
+
target: LocalServiceTarget,
|
|
61
|
+
env: NodeJS.ProcessEnv,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (process.platform !== "darwin")
|
|
64
|
+
throw new Error(
|
|
65
|
+
"System supervision currently supports macOS LaunchAgents; use serve --background elsewhere.",
|
|
66
|
+
);
|
|
67
|
+
const files = supervisorPaths(target);
|
|
68
|
+
if (await Bun.file(files.marker).exists())
|
|
69
|
+
throw new Error(
|
|
70
|
+
"Supervision is already installed. Stop and uninstall before replacing its configuration.",
|
|
71
|
+
);
|
|
72
|
+
await mkdir(path.dirname(files.plist), { recursive: true, mode: 0o700 });
|
|
73
|
+
await mkdir(target.config.stateDirectory, { recursive: true, mode: 0o700 });
|
|
74
|
+
const kept = Object.fromEntries(
|
|
75
|
+
Object.entries(env).filter(
|
|
76
|
+
([key, value]) =>
|
|
77
|
+
value !== undefined &&
|
|
78
|
+
key !== "TINKER_SERVICE_ENV_FILE" &&
|
|
79
|
+
(key.startsWith("TINKER_") ||
|
|
80
|
+
[
|
|
81
|
+
"PATH",
|
|
82
|
+
"HOME",
|
|
83
|
+
"LANG",
|
|
84
|
+
"LC_ALL",
|
|
85
|
+
"TZ",
|
|
86
|
+
"EXA_API_KEY",
|
|
87
|
+
"HTTP_PROXY",
|
|
88
|
+
"HTTPS_PROXY",
|
|
89
|
+
"ALL_PROXY",
|
|
90
|
+
"NO_PROXY",
|
|
91
|
+
"SSL_CERT_FILE",
|
|
92
|
+
"SSL_CERT_DIR",
|
|
93
|
+
].includes(key)),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
await writeFile(
|
|
97
|
+
files.environment,
|
|
98
|
+
JSON.stringify({ ...kept, TINKER_HOME: target.homeRoot }) + "\n",
|
|
99
|
+
{ mode: 0o600, flag: "wx" },
|
|
100
|
+
);
|
|
101
|
+
let wrotePlist = false;
|
|
102
|
+
let bootstrapped = false;
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(files.plist, launchAgentPlist(target), { mode: 0o600, flag: "wx" });
|
|
105
|
+
wrotePlist = true;
|
|
106
|
+
await writeFile(files.enabled, "enabled\n", { mode: 0o600 });
|
|
107
|
+
await launchctl(["bootstrap", `gui/${process.getuid?.()}`, files.plist]);
|
|
108
|
+
bootstrapped = true;
|
|
109
|
+
await writeFile(
|
|
110
|
+
files.marker,
|
|
111
|
+
JSON.stringify({ version: 1, label: files.label }) + "\n",
|
|
112
|
+
{ mode: 0o600 },
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await rm(files.enabled, { force: true });
|
|
116
|
+
if (bootstrapped) await launchctl(["bootout", files.job]).catch(() => undefined);
|
|
117
|
+
await Promise.all(
|
|
118
|
+
[...(wrotePlist ? [files.plist] : []), files.environment].map((file) =>
|
|
119
|
+
rm(file, { force: true }),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function disableSupervisor(target: LocalServiceTarget): Promise<void> {
|
|
126
|
+
await rm(supervisorPaths(target).enabled, { force: true });
|
|
127
|
+
}
|
|
128
|
+
export async function startSupervisor(target: LocalServiceTarget): Promise<boolean> {
|
|
129
|
+
const files = supervisorPaths(target);
|
|
130
|
+
if (!(await Bun.file(files.marker).exists())) return false;
|
|
131
|
+
if (process.platform !== "darwin")
|
|
132
|
+
throw new Error("This service is managed by macOS launchd.");
|
|
133
|
+
await writeFile(files.enabled, "enabled\n", { mode: 0o600 });
|
|
134
|
+
// A job may be registered after a login, or may need bootstrap after an explicit bootout.
|
|
135
|
+
try {
|
|
136
|
+
await launchctl(["print", files.job]);
|
|
137
|
+
} catch {
|
|
138
|
+
await launchctl(["bootstrap", `gui/${process.getuid?.()}`, files.plist]);
|
|
139
|
+
}
|
|
140
|
+
await launchctl(["kickstart", files.job]);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
export async function uninstallSupervisor(target: LocalServiceTarget): Promise<void> {
|
|
144
|
+
const files = supervisorPaths(target);
|
|
145
|
+
if (!(await Bun.file(files.marker).exists())) return;
|
|
146
|
+
await disableSupervisor(target);
|
|
147
|
+
await launchctl(["bootout", files.job]);
|
|
148
|
+
await Promise.all(
|
|
149
|
+
[files.plist, files.marker, files.environment].map((file) =>
|
|
150
|
+
rm(file, { force: true }),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
export async function supervisedEnvironment(
|
|
155
|
+
env: NodeJS.ProcessEnv,
|
|
156
|
+
): Promise<NodeJS.ProcessEnv> {
|
|
157
|
+
if (!env.TINKER_SERVICE_ENV_FILE) return env;
|
|
158
|
+
const values = JSON.parse(
|
|
159
|
+
await readFile(env.TINKER_SERVICE_ENV_FILE, "utf8"),
|
|
160
|
+
) as Record<string, unknown>;
|
|
161
|
+
if (
|
|
162
|
+
!values ||
|
|
163
|
+
Array.isArray(values) ||
|
|
164
|
+
typeof values !== "object" ||
|
|
165
|
+
Object.values(values).some((value) => typeof value !== "string")
|
|
166
|
+
)
|
|
167
|
+
throw new Error("Invalid supervised service environment.");
|
|
168
|
+
await chmod(env.TINKER_SERVICE_ENV_FILE, 0o600);
|
|
169
|
+
return { ...env, ...values } as NodeJS.ProcessEnv;
|
|
170
|
+
}
|