@xfey/tutti 0.1.32 → 0.1.34

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 (45) hide show
  1. package/README.md +5 -4
  2. package/dist/server-shell/cli/args.d.ts +10 -0
  3. package/dist/server-shell/cli/args.js +40 -1
  4. package/dist/server-shell/cli/cli.js +136 -5
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
  7. package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
  8. package/dist/server-shell/cli/host-server-runtime.js +2 -10
  9. package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
  10. package/dist/server-shell/cli/machine-project-inspector.js +1 -0
  11. package/dist/server-shell/cli/managed-host.d.ts +1 -0
  12. package/dist/server-shell/cli/managed-host.js +6 -4
  13. package/dist/server-shell/cli/runtime-commands.d.ts +1 -0
  14. package/dist/server-shell/cli/runtime-commands.js +5 -1
  15. package/dist/server-shell/http/routes/project-api/project-timeline-projection.js +4 -4
  16. package/dist/server-shell/http/static-web.d.ts +1 -0
  17. package/dist/server-shell/http/static-web.js +14 -1
  18. package/dist/server-shell/local-console/browser-open.d.ts +5 -0
  19. package/dist/server-shell/local-console/browser-open.js +40 -0
  20. package/dist/server-shell/local-console/folder-picker.d.ts +20 -0
  21. package/dist/server-shell/local-console/folder-picker.js +78 -0
  22. package/dist/server-shell/local-console/index.d.ts +4 -0
  23. package/dist/server-shell/local-console/index.js +4 -0
  24. package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
  25. package/dist/server-shell/local-console/invocation-context.js +56 -0
  26. package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
  27. package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
  28. package/dist/server-shell/local-console/managed-console.d.ts +47 -0
  29. package/dist/server-shell/local-console/managed-console.js +312 -0
  30. package/dist/server-shell/local-console/project-service.d.ts +61 -0
  31. package/dist/server-shell/local-console/project-service.js +262 -0
  32. package/dist/server-shell/local-console/runtime-endpoint.d.ts +35 -0
  33. package/dist/server-shell/local-console/runtime-endpoint.js +90 -0
  34. package/dist/server-shell/local-console/server.d.ts +29 -0
  35. package/dist/server-shell/local-console/server.js +408 -0
  36. package/dist/server-shell/local-console/session.d.ts +28 -0
  37. package/dist/server-shell/local-console/session.js +168 -0
  38. package/package.json +1 -1
  39. package/web/assets/index-B84rQ4YJ.js +29 -0
  40. package/web/assets/index-C7RDpM1L.css +1 -0
  41. package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
  42. package/web/index.html +6 -3
  43. package/web/assets/index-17FuaN3j.js +0 -29
  44. package/web/assets/index-C3nAJcU3.css +0 -1
  45. package/web/assets/tutti_avatar-DAVuzlig.png +0 -0
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants, accessSync, existsSync, statSync } from "node:fs";
3
+ import { delimiter, join, resolve } from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ function executableInPath(command, env) {
7
+ for (const directory of (env.PATH ?? "").split(delimiter)) {
8
+ if (directory.trim() === "") {
9
+ continue;
10
+ }
11
+ const candidate = join(directory, command);
12
+ try {
13
+ accessSync(candidate, constants.X_OK);
14
+ return candidate;
15
+ }
16
+ catch {
17
+ // Keep searching PATH.
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ export function detectLocalFolderPicker(platform = process.platform, env = process.env) {
23
+ if (platform === "darwin") {
24
+ return { kind: "macos", executable: "/usr/bin/osascript" };
25
+ }
26
+ if (platform !== "linux" || (env.DISPLAY === undefined && env.WAYLAND_DISPLAY === undefined)) {
27
+ return { kind: "unavailable" };
28
+ }
29
+ const zenity = executableInPath("zenity", env);
30
+ if (zenity !== null) {
31
+ return { kind: "zenity", executable: zenity };
32
+ }
33
+ const kdialog = executableInPath("kdialog", env);
34
+ if (kdialog !== null) {
35
+ return { kind: "kdialog", executable: kdialog };
36
+ }
37
+ return { kind: "unavailable" };
38
+ }
39
+ function normalizeSelectedPath(stdout) {
40
+ const value = stdout.trim();
41
+ if (value === "") {
42
+ return null;
43
+ }
44
+ const selected = resolve(value);
45
+ if (!existsSync(selected) || !statSync(selected).isDirectory()) {
46
+ return null;
47
+ }
48
+ return selected;
49
+ }
50
+ export async function pickLocalFolder(options = {}) {
51
+ const prompt = options.prompt ?? "Choose a Tutti project folder";
52
+ const picker = detectLocalFolderPicker(options.platform, options.env);
53
+ if (picker.kind === "unavailable" || picker.executable === undefined) {
54
+ return {
55
+ kind: "unavailable",
56
+ message: "No desktop folder picker is available. Enter an absolute folder path instead.",
57
+ };
58
+ }
59
+ const args = picker.kind === "macos"
60
+ ? ["-e", `POSIX path of (choose folder with prompt ${JSON.stringify(prompt)})`]
61
+ : picker.kind === "zenity"
62
+ ? ["--file-selection", "--directory", `--title=${prompt}`]
63
+ : ["--getexistingdirectory", resolve(process.cwd()), "--title", prompt];
64
+ try {
65
+ const result = await execFileAsync(picker.executable, args, {
66
+ encoding: "utf8",
67
+ env: options.env ?? process.env,
68
+ maxBuffer: 16 * 1024,
69
+ timeout: 120_000,
70
+ });
71
+ const selected = normalizeSelectedPath(result.stdout);
72
+ return selected === null ? { kind: "cancelled" } : { kind: "selected", path: selected };
73
+ }
74
+ catch {
75
+ return { kind: "cancelled" };
76
+ }
77
+ }
78
+ //# sourceMappingURL=folder-picker.js.map
@@ -0,0 +1,4 @@
1
+ export * from "./browser-open.js";
2
+ export * from "./managed-console.js";
3
+ export * from "./server.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export * from "./browser-open.js";
2
+ export * from "./managed-console.js";
3
+ export * from "./server.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ export declare const LOCAL_CONSOLE_INVOCATION_ENV_KEYS: readonly ["DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "TUTTI_HOST_ENDPOINT_HOST", "TUTTI_HOST_ENDPOINT_PORT", "TUTTI_RELAY_URL", "WAYLAND_DISPLAY", "XDG_CURRENT_DESKTOP"];
2
+ export type LocalConsoleInvocationEnvironment = Partial<Record<(typeof LOCAL_CONSOLE_INVOCATION_ENV_KEYS)[number], string>>;
3
+ export type LocalConsoleInvocationContext = {
4
+ currentDirectory: string;
5
+ environment: LocalConsoleInvocationEnvironment;
6
+ };
7
+ export declare function collectLocalConsoleInvocationEnvironment(env: NodeJS.ProcessEnv): LocalConsoleInvocationEnvironment;
8
+ export declare function createLocalConsoleInvocationContext(options: {
9
+ cwd: string;
10
+ env: NodeJS.ProcessEnv;
11
+ }): LocalConsoleInvocationContext;
12
+ export declare function createLocalConsoleServiceEnvironment(options: {
13
+ env: NodeJS.ProcessEnv;
14
+ tuttiHome: string;
15
+ }): NodeJS.ProcessEnv;
16
+ export declare function createLocalConsoleOperationEnvironment(options: {
17
+ serviceEnvironment: NodeJS.ProcessEnv;
18
+ invocationEnvironment: LocalConsoleInvocationEnvironment;
19
+ tuttiHome: string;
20
+ }): NodeJS.ProcessEnv;
21
+ //# sourceMappingURL=invocation-context.d.ts.map
@@ -0,0 +1,56 @@
1
+ import { resolve } from "node:path";
2
+ export const LOCAL_CONSOLE_INVOCATION_ENV_KEYS = [
3
+ "DBUS_SESSION_BUS_ADDRESS",
4
+ "DISPLAY",
5
+ "LANG",
6
+ "LC_ALL",
7
+ "LC_CTYPE",
8
+ "PATH",
9
+ "TUTTI_HOST_ENDPOINT_HOST",
10
+ "TUTTI_HOST_ENDPOINT_PORT",
11
+ "TUTTI_RELAY_URL",
12
+ "WAYLAND_DISPLAY",
13
+ "XDG_CURRENT_DESKTOP",
14
+ ];
15
+ export function collectLocalConsoleInvocationEnvironment(env) {
16
+ const environment = {};
17
+ for (const key of LOCAL_CONSOLE_INVOCATION_ENV_KEYS) {
18
+ const value = env[key];
19
+ if (value !== undefined && value !== "") {
20
+ environment[key] = value;
21
+ }
22
+ }
23
+ return environment;
24
+ }
25
+ export function createLocalConsoleInvocationContext(options) {
26
+ return {
27
+ currentDirectory: resolve(options.cwd),
28
+ environment: collectLocalConsoleInvocationEnvironment(options.env),
29
+ };
30
+ }
31
+ export function createLocalConsoleServiceEnvironment(options) {
32
+ const environment = { TUTTI_HOME: options.tuttiHome };
33
+ for (const key of [
34
+ "HOME",
35
+ "LANG",
36
+ "LC_ALL",
37
+ "LC_CTYPE",
38
+ "LOGNAME",
39
+ "TMPDIR",
40
+ "USER",
41
+ ]) {
42
+ const value = options.env[key];
43
+ if (value !== undefined && value !== "") {
44
+ environment[key] = value;
45
+ }
46
+ }
47
+ return environment;
48
+ }
49
+ export function createLocalConsoleOperationEnvironment(options) {
50
+ return {
51
+ ...options.serviceEnvironment,
52
+ ...options.invocationEnvironment,
53
+ TUTTI_HOME: options.tuttiHome,
54
+ };
55
+ }
56
+ //# sourceMappingURL=invocation-context.js.map
@@ -0,0 +1,13 @@
1
+ type LifecycleLockTiming = {
2
+ staleMs: number;
3
+ waitMs: number;
4
+ pollMs: number;
5
+ };
6
+ export declare function withLocalConsoleLifecycleLock<T>(options: {
7
+ tuttiHome: string;
8
+ run: () => Promise<T>;
9
+ now?: () => number;
10
+ timing?: Partial<LifecycleLockTiming>;
11
+ }): Promise<T>;
12
+ export {};
13
+ //# sourceMappingURL=lifecycle-lock.d.ts.map
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ const LOCK_STALE_MS = 30_000;
5
+ const LOCK_WAIT_MS = 35_000;
6
+ const LOCK_POLL_MS = 100;
7
+ function delay(milliseconds) {
8
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
9
+ }
10
+ function lockPath(tuttiHome) {
11
+ return join(tuttiHome, "runtime", "local-console.lock");
12
+ }
13
+ function ensurePrivateRuntimeDirectory(tuttiHome) {
14
+ const directory = dirname(lockPath(tuttiHome));
15
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
16
+ chmodSync(directory, 0o700);
17
+ }
18
+ function lockOwnerAlive(path) {
19
+ try {
20
+ const value = JSON.parse(readFileSync(path, "utf8"));
21
+ if (!Number.isInteger(value.pid)) {
22
+ return false;
23
+ }
24
+ process.kill(value.pid, 0);
25
+ return true;
26
+ }
27
+ catch (error) {
28
+ return (typeof error === "object" &&
29
+ error !== null &&
30
+ "code" in error &&
31
+ error.code === "EPERM");
32
+ }
33
+ }
34
+ function removeStaleLock(path, now, staleMs) {
35
+ try {
36
+ const age = now() - statSync(path).mtimeMs;
37
+ if (age < staleMs || lockOwnerAlive(path)) {
38
+ return false;
39
+ }
40
+ unlinkSync(path);
41
+ return true;
42
+ }
43
+ catch (error) {
44
+ if (typeof error === "object" &&
45
+ error !== null &&
46
+ "code" in error &&
47
+ error.code === "ENOENT") {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ }
53
+ async function acquireLock(options) {
54
+ const now = options.now ?? Date.now;
55
+ const timing = options.timing ?? {
56
+ staleMs: LOCK_STALE_MS,
57
+ waitMs: LOCK_WAIT_MS,
58
+ pollMs: LOCK_POLL_MS,
59
+ };
60
+ const path = lockPath(options.tuttiHome);
61
+ const deadline = now() + timing.waitMs;
62
+ const ownerId = randomBytes(16).toString("base64url");
63
+ ensurePrivateRuntimeDirectory(options.tuttiHome);
64
+ while (now() < deadline) {
65
+ try {
66
+ const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
67
+ try {
68
+ writeFileSync(descriptor, `${JSON.stringify({
69
+ pid: process.pid,
70
+ owner_id: ownerId,
71
+ acquired_at: new Date(now()).toISOString(),
72
+ })}\n`, "utf8");
73
+ }
74
+ finally {
75
+ closeSync(descriptor);
76
+ }
77
+ chmodSync(path, 0o600);
78
+ return () => {
79
+ try {
80
+ let record;
81
+ try {
82
+ record = JSON.parse(readFileSync(path, "utf8"));
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (record.owner_id !== ownerId) {
88
+ return;
89
+ }
90
+ unlinkSync(path);
91
+ }
92
+ catch (error) {
93
+ if (typeof error !== "object" ||
94
+ error === null ||
95
+ !("code" in error) ||
96
+ error.code !== "ENOENT") {
97
+ throw error;
98
+ }
99
+ }
100
+ };
101
+ }
102
+ catch (error) {
103
+ if (typeof error !== "object" ||
104
+ error === null ||
105
+ !("code" in error) ||
106
+ error.code !== "EEXIST") {
107
+ throw error;
108
+ }
109
+ removeStaleLock(path, now, timing.staleMs);
110
+ await delay(timing.pollMs);
111
+ }
112
+ }
113
+ throw new Error("Timed out waiting for the Tutti service lifecycle lock.");
114
+ }
115
+ export async function withLocalConsoleLifecycleLock(options) {
116
+ const timing = options.timing === undefined
117
+ ? undefined
118
+ : {
119
+ staleMs: options.timing.staleMs ?? LOCK_STALE_MS,
120
+ waitMs: options.timing.waitMs ?? LOCK_WAIT_MS,
121
+ pollMs: options.timing.pollMs ?? LOCK_POLL_MS,
122
+ };
123
+ const release = await acquireLock({
124
+ tuttiHome: options.tuttiHome,
125
+ ...(options.now === undefined ? {} : { now: options.now }),
126
+ ...(timing === undefined ? {} : { timing }),
127
+ });
128
+ try {
129
+ return await options.run();
130
+ }
131
+ finally {
132
+ release();
133
+ }
134
+ }
135
+ //# sourceMappingURL=lifecycle-lock.js.map
@@ -0,0 +1,47 @@
1
+ import { type LocalConsoleRuntimeEndpoint } from "./runtime-endpoint.js";
2
+ type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
3
+ export type LocalConsoleServiceStatus = {
4
+ status: "stopped";
5
+ log_path: string;
6
+ } | {
7
+ status: "stale";
8
+ pid: number;
9
+ base_url: string;
10
+ started_at: string;
11
+ log_path: string;
12
+ } | {
13
+ status: "running";
14
+ pid: number;
15
+ base_url: string;
16
+ started_at: string;
17
+ version: string;
18
+ current_version: string;
19
+ update_required: boolean;
20
+ log_path: string;
21
+ };
22
+ export declare function getLocalConsoleLogPath(tuttiHome: string): string;
23
+ export declare function ensureLocalConsole(options?: {
24
+ cwd?: string;
25
+ env?: NodeJS.ProcessEnv;
26
+ fetchImpl?: FetchLike;
27
+ }): Promise<{
28
+ url: string;
29
+ endpoint: LocalConsoleRuntimeEndpoint;
30
+ }>;
31
+ export declare function readLocalConsoleServiceStatus(options?: {
32
+ cwd?: string;
33
+ env?: NodeJS.ProcessEnv;
34
+ fetchImpl?: FetchLike;
35
+ }): Promise<LocalConsoleServiceStatus>;
36
+ export declare function stopLocalConsoleService(options?: {
37
+ cwd?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ fetchImpl?: FetchLike;
40
+ }): Promise<"stopped" | "already_stopped">;
41
+ export declare function restartLocalConsoleService(options?: {
42
+ cwd?: string;
43
+ env?: NodeJS.ProcessEnv;
44
+ fetchImpl?: FetchLike;
45
+ }): Promise<LocalConsoleRuntimeEndpoint>;
46
+ export {};
47
+ //# sourceMappingURL=managed-console.d.ts.map
@@ -0,0 +1,312 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { resolveTuttiHome } from "../../providers/openai/index.js";
5
+ import { readCliVersion } from "../cli/version.js";
6
+ import { createLocalConsoleInvocationContext, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
7
+ import { withLocalConsoleLifecycleLock } from "./lifecycle-lock.js";
8
+ import { deleteLocalConsoleRuntimeEndpoint, readLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
9
+ import { LOCAL_CONSOLE_API_BASE } from "./server.js";
10
+ const READY_TIMEOUT_MS = 15_000;
11
+ const READY_POLL_MS = 150;
12
+ const STOP_TIMEOUT_MS = 5_000;
13
+ const CONSOLE_LOG_MAX_BYTES = 2 * 1024 * 1024;
14
+ function delay(milliseconds) {
15
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
16
+ }
17
+ function cliEntrypoint() {
18
+ const entrypoint = process.argv[1];
19
+ if (entrypoint === undefined || entrypoint.trim() === "") {
20
+ throw new Error("Cannot locate the Tutti CLI entrypoint.");
21
+ }
22
+ return resolve(entrypoint);
23
+ }
24
+ export function getLocalConsoleLogPath(tuttiHome) {
25
+ return join(tuttiHome, "logs", "console.log");
26
+ }
27
+ function prepareConsoleLog(tuttiHome) {
28
+ const path = getLocalConsoleLogPath(tuttiHome);
29
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
30
+ chmodSync(dirname(path), 0o700);
31
+ if (existsSync(path) && statSync(path).size >= CONSOLE_LOG_MAX_BYTES) {
32
+ const previousPath = `${path}.1`;
33
+ if (existsSync(previousPath)) {
34
+ unlinkSync(previousPath);
35
+ }
36
+ renameSync(path, previousPath);
37
+ }
38
+ const descriptor = openSync(path, "a", 0o600);
39
+ chmodSync(path, 0o600);
40
+ return descriptor;
41
+ }
42
+ async function endpointHealth(endpoint, fetchImpl) {
43
+ try {
44
+ const response = await fetchImpl(`${endpoint.base_url}${LOCAL_CONSOLE_API_BASE}/health`, {
45
+ signal: AbortSignal.timeout(1_000),
46
+ });
47
+ if (!response.ok) {
48
+ return { kind: "stale" };
49
+ }
50
+ const value = (await response.json());
51
+ if (value.status !== "ready") {
52
+ return { kind: "stale" };
53
+ }
54
+ if (endpoint.schema_version === 1) {
55
+ return { kind: "ready", identityVerified: false };
56
+ }
57
+ return {
58
+ kind: "ready",
59
+ identityVerified: value.instance_id === endpoint.instance_id && value.pid === endpoint.pid,
60
+ };
61
+ }
62
+ catch {
63
+ return { kind: "stale" };
64
+ }
65
+ }
66
+ function endpointMatchesCurrentRuntime(endpoint) {
67
+ return (endpoint.schema_version === 2 &&
68
+ endpoint.version === readCliVersion() &&
69
+ resolve(endpoint.entrypoint) === cliEntrypoint());
70
+ }
71
+ function spawnDetachedConsole(options) {
72
+ const logDescriptor = prepareConsoleLog(options.tuttiHome);
73
+ try {
74
+ const child = spawn(process.execPath, [...process.execArgv, cliEntrypoint(), "internal", "console-run"], {
75
+ cwd: options.cwd,
76
+ detached: true,
77
+ env: createLocalConsoleServiceEnvironment({
78
+ env: options.env,
79
+ tuttiHome: options.tuttiHome,
80
+ }),
81
+ stdio: ["ignore", logDescriptor, logDescriptor],
82
+ });
83
+ child.on("error", () => undefined);
84
+ child.unref();
85
+ return child;
86
+ }
87
+ finally {
88
+ closeSync(logDescriptor);
89
+ }
90
+ }
91
+ async function waitForConsoleEndpoint(options) {
92
+ const deadline = Date.now() + READY_TIMEOUT_MS;
93
+ while (Date.now() < deadline) {
94
+ const endpoint = readLocalConsoleRuntimeEndpoint(options.tuttiHome);
95
+ if (endpoint?.schema_version === 2 && endpointMatchesCurrentRuntime(endpoint)) {
96
+ const health = await endpointHealth(endpoint, options.fetchImpl);
97
+ if (health.kind === "ready" && health.identityVerified) {
98
+ return endpoint;
99
+ }
100
+ }
101
+ await delay(READY_POLL_MS);
102
+ }
103
+ throw new Error(`Tutti service did not become ready. Inspect ${getLocalConsoleLogPath(options.tuttiHome)}.`);
104
+ }
105
+ async function waitForEndpointToStop(options) {
106
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
107
+ while (Date.now() < deadline) {
108
+ const current = readLocalConsoleRuntimeEndpoint(options.tuttiHome);
109
+ if (current === null || current.control_token !== options.endpoint.control_token) {
110
+ return true;
111
+ }
112
+ if ((await endpointHealth(options.endpoint, options.fetchImpl)).kind === "stale") {
113
+ deleteLocalConsoleRuntimeEndpoint({
114
+ tuttiHome: options.tuttiHome,
115
+ expectedControlToken: options.endpoint.control_token,
116
+ });
117
+ return true;
118
+ }
119
+ await delay(100);
120
+ }
121
+ return false;
122
+ }
123
+ async function authenticateLegacyEndpoint(options) {
124
+ try {
125
+ const response = await options.fetchImpl(`${options.endpoint.base_url}${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
126
+ method: "POST",
127
+ headers: {
128
+ authorization: `Bearer ${options.endpoint.control_token}`,
129
+ "content-type": "application/json",
130
+ },
131
+ body: JSON.stringify({ current_directory: options.cwd }),
132
+ signal: AbortSignal.timeout(2_000),
133
+ });
134
+ return response.ok;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ async function stopEndpoint(options) {
141
+ const health = await endpointHealth(options.endpoint, options.fetchImpl);
142
+ if (health.kind === "stale") {
143
+ deleteLocalConsoleRuntimeEndpoint({
144
+ tuttiHome: options.tuttiHome,
145
+ expectedControlToken: options.endpoint.control_token,
146
+ });
147
+ return;
148
+ }
149
+ if (options.endpoint.schema_version === 2 && !health.identityVerified) {
150
+ throw new Error("The running Tutti service identity does not match its endpoint record.");
151
+ }
152
+ let shutdownAccepted = false;
153
+ try {
154
+ const response = await options.fetchImpl(`${options.endpoint.base_url}${LOCAL_CONSOLE_API_BASE}/control/shutdown`, {
155
+ method: "POST",
156
+ headers: { authorization: `Bearer ${options.endpoint.control_token}` },
157
+ signal: AbortSignal.timeout(2_000),
158
+ });
159
+ shutdownAccepted = response.ok;
160
+ }
161
+ catch {
162
+ // A legacy service is handled by the authenticated SIGTERM fallback below.
163
+ }
164
+ if (!shutdownAccepted) {
165
+ const authenticated = await authenticateLegacyEndpoint(options);
166
+ if (!authenticated) {
167
+ throw new Error("The running Tutti service identity could not be verified.");
168
+ }
169
+ process.kill(options.endpoint.pid, "SIGTERM");
170
+ }
171
+ if (!(await waitForEndpointToStop(options))) {
172
+ throw new Error("The Tutti service did not stop before the shutdown timeout.");
173
+ }
174
+ }
175
+ async function ensureConsoleEndpointUnlocked(options) {
176
+ const existing = readLocalConsoleRuntimeEndpoint(options.tuttiHome);
177
+ if (existing !== null) {
178
+ const health = await endpointHealth(existing, options.fetchImpl);
179
+ if (health.kind === "ready" &&
180
+ (existing.schema_version === 1 || health.identityVerified) &&
181
+ endpointMatchesCurrentRuntime(existing)) {
182
+ return existing;
183
+ }
184
+ if (health.kind === "ready") {
185
+ await stopEndpoint({ ...options, endpoint: existing });
186
+ }
187
+ else {
188
+ deleteLocalConsoleRuntimeEndpoint({
189
+ tuttiHome: options.tuttiHome,
190
+ expectedControlToken: existing.control_token,
191
+ });
192
+ }
193
+ }
194
+ const child = spawnDetachedConsole(options);
195
+ try {
196
+ return await waitForConsoleEndpoint(options);
197
+ }
198
+ catch (error) {
199
+ if (child.pid !== undefined) {
200
+ try {
201
+ process.kill(child.pid, "SIGTERM");
202
+ }
203
+ catch {
204
+ // The process already exited.
205
+ }
206
+ }
207
+ throw error;
208
+ }
209
+ }
210
+ async function issueBrowserAccess(options) {
211
+ const context = createLocalConsoleInvocationContext({ cwd: options.cwd, env: options.env });
212
+ const response = await options.fetchImpl(`${options.endpoint.base_url}${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
213
+ method: "POST",
214
+ headers: {
215
+ authorization: `Bearer ${options.endpoint.control_token}`,
216
+ accept: "application/json",
217
+ "content-type": "application/json",
218
+ },
219
+ body: JSON.stringify({
220
+ current_directory: context.currentDirectory,
221
+ environment: context.environment,
222
+ }),
223
+ signal: AbortSignal.timeout(3_000),
224
+ });
225
+ if (!response.ok) {
226
+ throw new Error("Tutti could not create a browser access link.");
227
+ }
228
+ const value = (await response.json());
229
+ if (typeof value.access_token !== "string" || value.access_token.trim() === "") {
230
+ throw new Error("Tutti returned an invalid browser access link.");
231
+ }
232
+ return `${options.endpoint.base_url}/console#access_token=${encodeURIComponent(value.access_token)}`;
233
+ }
234
+ export async function ensureLocalConsole(options = {}) {
235
+ const cwd = resolve(options.cwd ?? process.cwd());
236
+ const env = options.env ?? process.env;
237
+ const fetchImpl = options.fetchImpl ?? fetch;
238
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
239
+ const endpoint = await withLocalConsoleLifecycleLock({
240
+ tuttiHome,
241
+ run: async () => await ensureConsoleEndpointUnlocked({ cwd, env, tuttiHome, fetchImpl }),
242
+ });
243
+ return {
244
+ endpoint,
245
+ url: await issueBrowserAccess({ endpoint, cwd, env, fetchImpl }),
246
+ };
247
+ }
248
+ export async function readLocalConsoleServiceStatus(options = {}) {
249
+ const cwd = resolve(options.cwd ?? process.cwd());
250
+ const env = options.env ?? process.env;
251
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
252
+ const logPath = getLocalConsoleLogPath(tuttiHome);
253
+ const endpoint = readLocalConsoleRuntimeEndpoint(tuttiHome);
254
+ if (endpoint === null) {
255
+ return { status: "stopped", log_path: logPath };
256
+ }
257
+ const health = await endpointHealth(endpoint, options.fetchImpl ?? fetch);
258
+ if (health.kind === "stale" || (endpoint.schema_version === 2 && !health.identityVerified)) {
259
+ return {
260
+ status: "stale",
261
+ pid: endpoint.pid,
262
+ base_url: endpoint.base_url,
263
+ started_at: endpoint.started_at,
264
+ log_path: logPath,
265
+ };
266
+ }
267
+ const currentVersion = readCliVersion();
268
+ return {
269
+ status: "running",
270
+ pid: endpoint.pid,
271
+ base_url: endpoint.base_url,
272
+ started_at: endpoint.started_at,
273
+ version: endpoint.schema_version === 2 ? endpoint.version : "unknown",
274
+ current_version: currentVersion,
275
+ update_required: !endpointMatchesCurrentRuntime(endpoint),
276
+ log_path: logPath,
277
+ };
278
+ }
279
+ export async function stopLocalConsoleService(options = {}) {
280
+ const cwd = resolve(options.cwd ?? process.cwd());
281
+ const env = options.env ?? process.env;
282
+ const fetchImpl = options.fetchImpl ?? fetch;
283
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
284
+ return await withLocalConsoleLifecycleLock({
285
+ tuttiHome,
286
+ run: async () => {
287
+ const endpoint = readLocalConsoleRuntimeEndpoint(tuttiHome);
288
+ if (endpoint === null) {
289
+ return "already_stopped";
290
+ }
291
+ await stopEndpoint({ endpoint, tuttiHome, cwd, fetchImpl });
292
+ return "stopped";
293
+ },
294
+ });
295
+ }
296
+ export async function restartLocalConsoleService(options = {}) {
297
+ const cwd = resolve(options.cwd ?? process.cwd());
298
+ const env = options.env ?? process.env;
299
+ const fetchImpl = options.fetchImpl ?? fetch;
300
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
301
+ return await withLocalConsoleLifecycleLock({
302
+ tuttiHome,
303
+ run: async () => {
304
+ const endpoint = readLocalConsoleRuntimeEndpoint(tuttiHome);
305
+ if (endpoint !== null) {
306
+ await stopEndpoint({ endpoint, tuttiHome, cwd, fetchImpl });
307
+ }
308
+ return await ensureConsoleEndpointUnlocked({ cwd, env, tuttiHome, fetchImpl });
309
+ },
310
+ });
311
+ }
312
+ //# sourceMappingURL=managed-console.js.map