@xfey/tutti 0.1.33 → 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 (34) hide show
  1. package/README.md +3 -3
  2. package/dist/server-shell/cli/args.d.ts +6 -0
  3. package/dist/server-shell/cli/args.js +30 -0
  4. package/dist/server-shell/cli/cli.js +118 -6
  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/machine-project-inspector.d.ts +1 -0
  9. package/dist/server-shell/cli/machine-project-inspector.js +1 -0
  10. package/dist/server-shell/cli/managed-host.d.ts +1 -0
  11. package/dist/server-shell/cli/managed-host.js +6 -4
  12. package/dist/server-shell/cli/runtime-commands.d.ts +1 -0
  13. package/dist/server-shell/cli/runtime-commands.js +5 -1
  14. package/dist/server-shell/local-console/folder-picker.d.ts +0 -1
  15. package/dist/server-shell/local-console/folder-picker.js +2 -33
  16. package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
  17. package/dist/server-shell/local-console/invocation-context.js +56 -0
  18. package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
  19. package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
  20. package/dist/server-shell/local-console/managed-console.d.ts +38 -0
  21. package/dist/server-shell/local-console/managed-console.js +254 -36
  22. package/dist/server-shell/local-console/project-service.d.ts +11 -7
  23. package/dist/server-shell/local-console/project-service.js +102 -57
  24. package/dist/server-shell/local-console/runtime-endpoint.d.ts +15 -1
  25. package/dist/server-shell/local-console/runtime-endpoint.js +21 -5
  26. package/dist/server-shell/local-console/server.d.ts +12 -0
  27. package/dist/server-shell/local-console/server.js +152 -45
  28. package/dist/server-shell/local-console/session.d.ts +10 -15
  29. package/dist/server-shell/local-console/session.js +69 -17
  30. package/package.json +1 -1
  31. package/web/assets/index-B84rQ4YJ.js +29 -0
  32. package/web/assets/{index-BcZpeSaX.css → index-C7RDpM1L.css} +1 -1
  33. package/web/index.html +2 -2
  34. package/web/assets/index-DKf4jz_E.js +0 -29
@@ -1,12 +1,15 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, resolveTuttiHome, } from "../../providers/openai/index.js";
3
+ import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, } from "../../providers/openai/index.js";
4
4
  import { formatCliErrorReason, LaunchError } from "../cli/errors.js";
5
5
  import { prepareLaunchProject } from "../cli/launch.js";
6
6
  import { rotateHostLocalInvite } from "../cli/local-control-client.js";
7
+ import { readMachineProjectBinding, readMachineRuntimeEndpoint } from "../cli/machine-local.js";
7
8
  import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
8
9
  import { resolveManagedProjectContext } from "../cli/project-resolver.js";
9
10
  import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
11
+ import { readCliVersion } from "../cli/version.js";
12
+ import { createLocalConsoleOperationEnvironment, } from "./invocation-context.js";
10
13
  export class LocalConsoleProjectError extends Error {
11
14
  code;
12
15
  statusCode;
@@ -44,7 +47,8 @@ function validateProvider(input) {
44
47
  }
45
48
  return provider;
46
49
  }
47
- function projectFromRuntimeRow(row, providerStatus) {
50
+ function projectFromRuntimeRow(row, providerStatus, activityAt) {
51
+ const currentVersion = readCliVersion();
48
52
  return {
49
53
  project_id: row.project_id,
50
54
  display_name: row.display_name,
@@ -56,29 +60,69 @@ function projectFromRuntimeRow(row, providerStatus) {
56
60
  ...(row.relay_connection_status === undefined
57
61
  ? {}
58
62
  : { relay_connection_status: row.relay_connection_status }),
63
+ ...(activityAt === undefined ? {} : { activity_at: activityAt }),
64
+ ...(row.host_version === undefined ? {} : { host_version: row.host_version }),
65
+ ...(row.host_version === undefined || row.host_version === currentVersion
66
+ ? {}
67
+ : { update_required: true }),
59
68
  };
60
69
  }
70
+ function readProjectActivityAt(tuttiHome, row) {
71
+ if (row.last_connected_at !== undefined) {
72
+ return row.last_connected_at;
73
+ }
74
+ try {
75
+ const endpoint = readMachineRuntimeEndpoint(tuttiHome, row.project_id);
76
+ if (endpoint !== null) {
77
+ return endpoint.updated_at;
78
+ }
79
+ }
80
+ catch {
81
+ // Invalid endpoint state is already represented by the project status.
82
+ }
83
+ try {
84
+ return readMachineProjectBinding(tuttiHome, row.project_id)?.updated_at;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
61
90
  export class LocalConsoleProjectService {
62
- #cwd;
63
- #env;
91
+ #tuttiHome;
92
+ #serviceEnvironment;
64
93
  #launches = new Map();
65
- constructor(options = {}) {
66
- this.#cwd = resolve(options.cwd ?? process.cwd());
67
- this.#env = options.env ?? process.env;
94
+ #mutationTail = Promise.resolve();
95
+ constructor(options) {
96
+ this.#tuttiHome = resolve(options.tuttiHome);
97
+ this.#serviceEnvironment = options.serviceEnvironment ?? process.env;
98
+ }
99
+ #operationOptions(context) {
100
+ return {
101
+ cwd: context.currentDirectory,
102
+ env: createLocalConsoleOperationEnvironment({
103
+ serviceEnvironment: this.#serviceEnvironment,
104
+ invocationEnvironment: context.environment,
105
+ tuttiHome: this.#tuttiHome,
106
+ }),
107
+ };
68
108
  }
69
- async listProjects() {
70
- const tuttiHome = resolveTuttiHome(this.#env.TUTTI_HOME, this.#cwd);
109
+ #runMutation(operation) {
110
+ const result = this.#mutationTail.then(operation, operation);
111
+ this.#mutationTail = result.then(() => undefined, () => undefined);
112
+ return result;
113
+ }
114
+ async listProjects(context) {
115
+ const operation = this.#operationOptions(context);
71
116
  const rows = await listRuntimeProjects({
72
117
  all: true,
73
- cwd: this.#cwd,
74
- env: this.#env,
118
+ ...operation,
75
119
  });
76
120
  return rows.map((row) => {
77
121
  const provider = readOpenAiProviderConfigProjection({
78
- tuttiHome,
122
+ tuttiHome: this.#tuttiHome,
79
123
  projectId: row.project_id,
80
124
  });
81
- return projectFromRuntimeRow(row, provider.status);
125
+ return projectFromRuntimeRow(row, provider.status, readProjectActivityAt(this.#tuttiHome, row));
82
126
  });
83
127
  }
84
128
  async discoverModels(input) {
@@ -92,29 +136,29 @@ export class LocalConsoleProjectService {
92
136
  apiKey: provider.api_key,
93
137
  });
94
138
  }
95
- async launchProject(input) {
139
+ async launchProject(input, context) {
96
140
  const workspacePath = validateWorkspacePath(input.workspacePath);
97
141
  const active = this.#launches.get(workspacePath);
98
142
  if (active !== undefined) {
99
143
  return await active;
100
144
  }
101
- const launch = this.#launchProject({
145
+ const launch = this.#runMutation(async () => await this.#launchProject({
102
146
  workspacePath,
103
147
  ...(input.provider === undefined ? {} : { provider: validateProvider(input.provider) }),
104
- }).finally(() => {
148
+ }, context)).finally(() => {
105
149
  this.#launches.delete(workspacePath);
106
150
  });
107
151
  this.#launches.set(workspacePath, launch);
108
152
  return await launch;
109
153
  }
110
- async #launchProject(input) {
154
+ async #launchProject(input, context) {
155
+ const operation = this.#operationOptions(context);
111
156
  let preparation;
112
157
  try {
113
158
  preparation = await prepareLaunchProject({
114
159
  workspacePath: input.workspacePath,
115
160
  yes: true,
116
- cwd: this.#cwd,
117
- env: this.#env,
161
+ ...operation,
118
162
  });
119
163
  }
120
164
  catch (error) {
@@ -138,7 +182,8 @@ export class LocalConsoleProjectService {
138
182
  try {
139
183
  spawnDetachedHost({
140
184
  workspaceRoot: preparation.workspace_root,
141
- env: this.#env,
185
+ env: operation.env,
186
+ inheritProcessEnv: false,
142
187
  });
143
188
  const ready = await waitForManagedHostReady({
144
189
  tuttiHome: preparation.tutti_home,
@@ -148,7 +193,7 @@ export class LocalConsoleProjectService {
148
193
  if (ready.join_url === undefined) {
149
194
  throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
150
195
  }
151
- const projects = await this.listProjects();
196
+ const projects = await this.listProjects(context);
152
197
  const project = projects.find((item) => item.project_id === preparation.project_id);
153
198
  if (project === undefined) {
154
199
  throw new LocalConsoleProjectError("project_not_found", "The launched project could not be read from the machine project list.");
@@ -165,44 +210,44 @@ export class LocalConsoleProjectService {
165
210
  throw this.#normalizeError(error);
166
211
  }
167
212
  }
168
- async refreshInvite(projectId) {
169
- const project = resolveManagedProjectContext({
170
- target: projectId,
171
- cwd: this.#cwd,
172
- env: this.#env,
173
- });
174
- if (project.runtime_endpoint === null) {
175
- throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
176
- }
177
- try {
178
- const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
179
- const joinUrl = status.relay?.join_url;
180
- if (joinUrl === undefined) {
181
- throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
213
+ async refreshInvite(projectId, context) {
214
+ return await this.#runMutation(async () => {
215
+ const project = resolveManagedProjectContext({
216
+ target: projectId,
217
+ ...this.#operationOptions(context),
218
+ });
219
+ if (project.runtime_endpoint === null) {
220
+ throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
182
221
  }
183
- return {
184
- join_url: joinUrl,
185
- ...(status.relay?.join_token_expires_at === undefined
186
- ? {}
187
- : { expires_at: status.relay.join_token_expires_at }),
188
- };
189
- }
190
- catch (error) {
191
- throw this.#normalizeError(error);
192
- }
222
+ try {
223
+ const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
224
+ const joinUrl = status.relay?.join_url;
225
+ if (joinUrl === undefined) {
226
+ throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
227
+ }
228
+ return {
229
+ join_url: joinUrl,
230
+ ...(status.relay?.join_token_expires_at === undefined
231
+ ? {}
232
+ : { expires_at: status.relay.join_token_expires_at }),
233
+ };
234
+ }
235
+ catch (error) {
236
+ throw this.#normalizeError(error);
237
+ }
238
+ });
193
239
  }
194
- async stopProject(projectId) {
195
- try {
196
- return {
197
- message: await runStopCommand(projectId, {
198
- cwd: this.#cwd,
199
- env: this.#env,
200
- }),
201
- };
202
- }
203
- catch (error) {
204
- throw this.#normalizeError(error);
205
- }
240
+ async stopProject(projectId, context) {
241
+ return await this.#runMutation(async () => {
242
+ try {
243
+ return {
244
+ message: await runStopCommand(projectId, this.#operationOptions(context)),
245
+ };
246
+ }
247
+ catch (error) {
248
+ throw this.#normalizeError(error);
249
+ }
250
+ });
206
251
  }
207
252
  #normalizeError(error) {
208
253
  if (error instanceof LocalConsoleProjectError) {
@@ -1,17 +1,31 @@
1
1
  export type LocalConsoleRuntimeEndpoint = {
2
+ schema_version: 2;
3
+ instance_id: string;
4
+ pid: number;
5
+ base_url: string;
6
+ control_token: string;
7
+ version: string;
8
+ entrypoint: string;
9
+ started_at: string;
10
+ };
11
+ export type LegacyLocalConsoleRuntimeEndpoint = {
2
12
  schema_version: 1;
3
13
  pid: number;
4
14
  base_url: string;
5
15
  control_token: string;
6
16
  started_at: string;
7
17
  };
18
+ export type ReadableLocalConsoleRuntimeEndpoint = LocalConsoleRuntimeEndpoint | LegacyLocalConsoleRuntimeEndpoint;
8
19
  export declare function createLocalConsoleSecret(): string;
9
- export declare function readLocalConsoleRuntimeEndpoint(tuttiHome: string): LocalConsoleRuntimeEndpoint | null;
20
+ export declare function readLocalConsoleRuntimeEndpoint(tuttiHome: string): ReadableLocalConsoleRuntimeEndpoint | null;
10
21
  export declare function writeLocalConsoleRuntimeEndpoint(options: {
11
22
  tuttiHome: string;
23
+ instanceId: string;
12
24
  pid: number;
13
25
  baseUrl: string;
14
26
  controlToken: string;
27
+ version: string;
28
+ entrypoint: string;
15
29
  now?: () => Date;
16
30
  }): LocalConsoleRuntimeEndpoint;
17
31
  export declare function deleteLocalConsoleRuntimeEndpoint(options: {
@@ -7,10 +7,8 @@ function endpointPath(tuttiHome) {
7
7
  function isRecord(value) {
8
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
9
  }
10
- function isEndpoint(value) {
11
- return (isRecord(value) &&
12
- value.schema_version === 1 &&
13
- typeof value.pid === "number" &&
10
+ function hasSharedEndpointFields(value) {
11
+ return (typeof value.pid === "number" &&
14
12
  Number.isInteger(value.pid) &&
15
13
  typeof value.base_url === "string" &&
16
14
  /^http:\/\/127\.0\.0\.1:\d+$/u.test(value.base_url) &&
@@ -18,6 +16,21 @@ function isEndpoint(value) {
18
16
  value.control_token.length >= 32 &&
19
17
  typeof value.started_at === "string");
20
18
  }
19
+ function isEndpoint(value) {
20
+ if (!isRecord(value) || !hasSharedEndpointFields(value)) {
21
+ return false;
22
+ }
23
+ if (value.schema_version === 1) {
24
+ return true;
25
+ }
26
+ return (value.schema_version === 2 &&
27
+ typeof value.instance_id === "string" &&
28
+ value.instance_id.length >= 16 &&
29
+ typeof value.version === "string" &&
30
+ value.version.trim() !== "" &&
31
+ typeof value.entrypoint === "string" &&
32
+ value.entrypoint.trim() !== "");
33
+ }
21
34
  export function createLocalConsoleSecret() {
22
35
  return randomBytes(32).toString("base64url");
23
36
  }
@@ -36,10 +49,13 @@ export function readLocalConsoleRuntimeEndpoint(tuttiHome) {
36
49
  }
37
50
  export function writeLocalConsoleRuntimeEndpoint(options) {
38
51
  const record = {
39
- schema_version: 1,
52
+ schema_version: 2,
53
+ instance_id: options.instanceId,
40
54
  pid: options.pid,
41
55
  base_url: options.baseUrl,
42
56
  control_token: options.controlToken,
57
+ version: options.version,
58
+ entrypoint: options.entrypoint,
43
59
  started_at: (options.now ?? (() => new Date()))().toISOString(),
44
60
  };
45
61
  if (!isEndpoint(record)) {
@@ -1,17 +1,29 @@
1
1
  import { type FastifyInstance } from "fastify";
2
2
  export declare const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
3
+ type LocalConsoleRuntimeMetadata = {
4
+ instanceId: string;
5
+ version: string;
6
+ entrypoint: string;
7
+ startedAt: string;
8
+ pid: number;
9
+ };
3
10
  type LocalConsoleServerOptions = {
4
11
  controlToken: string;
5
12
  cwd: string;
13
+ tuttiHome?: string;
6
14
  env?: NodeJS.ProcessEnv;
7
15
  now?: () => Date;
8
16
  webStaticRoot?: string | false;
17
+ runtime?: LocalConsoleRuntimeMetadata;
18
+ onActivity?: () => void;
19
+ onShutdown?: () => void;
9
20
  };
10
21
  export declare function createLocalConsoleServer(options: LocalConsoleServerOptions): FastifyInstance;
11
22
  export declare function runLocalConsoleProcess(options?: {
12
23
  cwd?: string;
13
24
  env?: NodeJS.ProcessEnv;
14
25
  now?: () => Date;
26
+ idleTimeoutMs?: number;
15
27
  }): Promise<void>;
16
28
  export {};
17
29
  //# sourceMappingURL=server.d.ts.map
@@ -1,9 +1,12 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
  import fastify from "fastify";
4
5
  import { resolveTuttiHome } from "../../providers/openai/index.js";
6
+ import { readCliVersion } from "../cli/version.js";
5
7
  import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
6
- import { createLocalProjectFolder, detectLocalFolderPicker, pickLocalFolder, } from "./folder-picker.js";
8
+ import { detectLocalFolderPicker, pickLocalFolder } from "./folder-picker.js";
9
+ import { LOCAL_CONSOLE_INVOCATION_ENV_KEYS, createLocalConsoleServiceEnvironment, } from "./invocation-context.js";
7
10
  import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
8
11
  import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
9
12
  import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
@@ -59,12 +62,13 @@ function requireLoopbackHost(request) {
59
62
  }
60
63
  export function createLocalConsoleServer(options) {
61
64
  const app = fastify({ logger: false, bodyLimit: 64 * 1024 });
65
+ const tuttiHome = options.tuttiHome ?? resolveTuttiHome(options.env?.TUTTI_HOME, options.cwd);
62
66
  const sessions = new LocalConsoleSessionRegistry({
63
67
  ...(options.now === undefined ? {} : { now: options.now }),
64
68
  });
65
69
  const projects = new LocalConsoleProjectService({
66
- cwd: options.cwd,
67
- ...(options.env === undefined ? {} : { env: options.env }),
70
+ tuttiHome,
71
+ ...(options.env === undefined ? {} : { serviceEnvironment: options.env }),
68
72
  });
69
73
  app.addHook("onRequest", (request, reply, done) => {
70
74
  try {
@@ -101,10 +105,11 @@ export function createLocalConsoleServer(options) {
101
105
  if (!tokenEquals(bearerToken(request.headers.authorization), options.controlToken)) {
102
106
  throw new LocalConsoleHttpError(401, "unauthorized", "Local Console control token is invalid.");
103
107
  }
108
+ options.onActivity?.();
104
109
  }
105
110
  function requireSession(request, csrf = false) {
106
111
  const cookie = parseCookieHeader(headerValue(request.headers.cookie));
107
- const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
112
+ const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE], headerValue(request.headers["x-local-console-context"]));
108
113
  if (session === null) {
109
114
  throw new LocalConsoleHttpError(401, "session_required", "Open Tutti from the CLI again.");
110
115
  }
@@ -115,22 +120,65 @@ export function createLocalConsoleServer(options) {
115
120
  throw new LocalConsoleHttpError(403, "csrf_rejected", "Local Console request was rejected.");
116
121
  }
117
122
  }
123
+ options.onActivity?.();
118
124
  return session;
119
125
  }
120
- app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({ status: "ready" }));
126
+ app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({
127
+ status: "ready",
128
+ protocol_version: 2,
129
+ ...(options.runtime === undefined
130
+ ? {}
131
+ : {
132
+ instance_id: options.runtime.instanceId,
133
+ version: options.runtime.version,
134
+ pid: options.runtime.pid,
135
+ }),
136
+ }));
137
+ app.get(`${LOCAL_CONSOLE_API_BASE}/control/status`, (request) => {
138
+ requireControl(request);
139
+ return {
140
+ status: "running",
141
+ protocol_version: 2,
142
+ ...(options.runtime === undefined
143
+ ? {}
144
+ : {
145
+ instance_id: options.runtime.instanceId,
146
+ version: options.runtime.version,
147
+ entrypoint: options.runtime.entrypoint,
148
+ started_at: options.runtime.startedAt,
149
+ pid: options.runtime.pid,
150
+ }),
151
+ };
152
+ });
153
+ app.post(`${LOCAL_CONSOLE_API_BASE}/control/shutdown`, (request) => {
154
+ requireControl(request);
155
+ setImmediate(() => options.onShutdown?.());
156
+ return { status: "stopping" };
157
+ });
121
158
  app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
122
159
  schema: {
123
160
  body: {
124
161
  type: "object",
125
162
  required: ["current_directory"],
126
163
  additionalProperties: false,
127
- properties: { current_directory: { type: "string", minLength: 1, maxLength: 4096 } },
164
+ properties: {
165
+ current_directory: { type: "string", minLength: 1, maxLength: 4096 },
166
+ environment: {
167
+ type: "object",
168
+ additionalProperties: false,
169
+ properties: Object.fromEntries(LOCAL_CONSOLE_INVOCATION_ENV_KEYS.map((key) => [
170
+ key,
171
+ { type: "string", minLength: 1, maxLength: 4096 },
172
+ ])),
173
+ },
174
+ },
128
175
  },
129
176
  },
130
177
  }, (request) => {
131
178
  requireControl(request);
132
179
  const access = sessions.issueAccessToken({
133
180
  currentDirectory: resolve(request.body.current_directory),
181
+ environment: request.body.environment ?? {},
134
182
  });
135
183
  return { access_token: access.token, expires_at: access.expires_at };
136
184
  });
@@ -145,51 +193,41 @@ export function createLocalConsoleServer(options) {
145
193
  },
146
194
  }, (request, reply) => {
147
195
  requireSameOrigin(request);
148
- const session = sessions.exchangeAccessToken(request.body.access_token);
196
+ const cookie = parseCookieHeader(headerValue(request.headers.cookie));
197
+ const session = sessions.exchangeAccessToken(request.body.access_token, cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
149
198
  if (session === null) {
150
199
  throw new LocalConsoleHttpError(401, "access_token_invalid", "This Local Console access link has expired. Run `tutti` again.");
151
200
  }
152
- void reply.header("set-cookie", localConsoleSessionCookie(session.sessionToken, session.expires_at));
153
- return { csrf_token: session.csrfToken, expires_at: session.expires_at };
201
+ if (session.sessionToken !== undefined) {
202
+ void reply.header("set-cookie", localConsoleSessionCookie(session.sessionToken, session.expires_at));
203
+ }
204
+ options.onActivity?.();
205
+ return {
206
+ csrf_token: session.csrfToken,
207
+ context_token: session.contextToken,
208
+ expires_at: session.expires_at,
209
+ };
154
210
  });
155
211
  app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
156
212
  const session = requireSession(request);
157
- const picker = detectLocalFolderPicker(process.platform, options.env ?? process.env);
158
213
  return {
159
214
  csrf_token: session.csrfToken,
160
- current_directory: session.currentDirectory,
215
+ current_directory: session.context.currentDirectory,
161
216
  platform: process.platform,
162
- folder_picker: picker.kind,
217
+ folder_picker: detectLocalFolderPicker(process.platform, session.context.environment).kind,
163
218
  };
164
219
  });
165
220
  app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
221
+ const session = requireSession(request);
222
+ return { projects: await projects.listProjects(session.context) };
223
+ });
224
+ app.get(`${LOCAL_CONSOLE_API_BASE}/heartbeat`, (request) => {
166
225
  requireSession(request);
167
- return { projects: await projects.listProjects() };
226
+ return { status: "ok" };
168
227
  });
169
228
  app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
170
- requireSession(request, true);
171
- return await pickLocalFolder({ env: options.env ?? process.env });
172
- });
173
- app.post(`${LOCAL_CONSOLE_API_BASE}/folders/create`, {
174
- schema: {
175
- body: {
176
- type: "object",
177
- required: ["parent_path", "name"],
178
- additionalProperties: false,
179
- properties: {
180
- parent_path: { type: "string", minLength: 1 },
181
- name: { type: "string", minLength: 1, maxLength: 160 },
182
- },
183
- },
184
- },
185
- }, (request) => {
186
- requireSession(request, true);
187
- try {
188
- return { path: createLocalProjectFolder(request.body.parent_path, request.body.name) };
189
- }
190
- catch (error) {
191
- throw new LocalConsoleHttpError(422, "project_folder_create_failed", error instanceof Error ? error.message : "Project folder could not be created.");
192
- }
229
+ const session = requireSession(request, true);
230
+ return await pickLocalFolder({ env: session.context.environment });
193
231
  });
194
232
  app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
195
233
  schema: {
@@ -232,19 +270,19 @@ export function createLocalConsoleServer(options) {
232
270
  },
233
271
  },
234
272
  }, async (request) => {
235
- requireSession(request, true);
273
+ const session = requireSession(request, true);
236
274
  return await projects.launchProject({
237
275
  workspacePath: request.body.workspace_path,
238
276
  ...(request.body.provider === undefined ? {} : { provider: request.body.provider }),
239
- });
277
+ }, session.context);
240
278
  });
241
279
  app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/invite`, async (request) => {
242
- requireSession(request, true);
243
- return await projects.refreshInvite(request.params.projectId);
280
+ const session = requireSession(request, true);
281
+ return await projects.refreshInvite(request.params.projectId, session.context);
244
282
  });
245
283
  app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/stop`, async (request) => {
246
- requireSession(request, true);
247
- return await projects.stopProject(request.params.projectId);
284
+ const session = requireSession(request, true);
285
+ return await projects.stopProject(request.params.projectId, session.context);
248
286
  });
249
287
  const webStaticRoot = options.webStaticRoot === false
250
288
  ? undefined
@@ -261,24 +299,64 @@ function listeningPort(app) {
261
299
  }
262
300
  return address.port;
263
301
  }
302
+ function entrypointIdentity(path) {
303
+ try {
304
+ const stats = statSync(path);
305
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
264
311
  export async function runLocalConsoleProcess(options = {}) {
265
312
  const cwd = resolve(options.cwd ?? process.cwd());
266
313
  const env = options.env ?? process.env;
267
314
  const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
315
+ const serviceEnvironment = createLocalConsoleServiceEnvironment({ env, tuttiHome });
268
316
  const controlToken = createLocalConsoleSecret();
317
+ const instanceId = createLocalConsoleSecret();
318
+ const now = options.now ?? (() => new Date());
319
+ const startedAt = now().toISOString();
320
+ const version = readCliVersion();
321
+ const entrypointArgument = process.argv[1];
322
+ if (entrypointArgument === undefined || entrypointArgument.trim() === "") {
323
+ throw new Error("Cannot locate the Tutti CLI entrypoint.");
324
+ }
325
+ const entrypoint = resolve(entrypointArgument);
326
+ const initialEntrypointIdentity = entrypointIdentity(entrypoint);
327
+ if (initialEntrypointIdentity === null) {
328
+ throw new Error("The Tutti CLI entrypoint is unavailable.");
329
+ }
330
+ let lastActivityAt = now().getTime();
331
+ let requestClose = () => undefined;
269
332
  const app = createLocalConsoleServer({
270
333
  controlToken,
271
334
  cwd,
272
- env,
335
+ tuttiHome,
336
+ env: serviceEnvironment,
337
+ runtime: {
338
+ instanceId,
339
+ version,
340
+ entrypoint,
341
+ startedAt,
342
+ pid: process.pid,
343
+ },
344
+ onActivity: () => {
345
+ lastActivityAt = now().getTime();
346
+ },
347
+ onShutdown: () => requestClose(),
273
348
  ...(options.now === undefined ? {} : { now: options.now }),
274
349
  });
275
350
  await app.listen({ host: "127.0.0.1", port: 0 });
276
351
  writeLocalConsoleRuntimeEndpoint({
277
352
  tuttiHome,
353
+ instanceId,
278
354
  pid: process.pid,
279
355
  baseUrl: `http://127.0.0.1:${listeningPort(app)}`,
280
356
  controlToken,
281
- ...(options.now === undefined ? {} : { now: options.now }),
357
+ version,
358
+ entrypoint,
359
+ now,
282
360
  });
283
361
  let closing = false;
284
362
  const close = async () => {
@@ -290,12 +368,41 @@ export async function runLocalConsoleProcess(options = {}) {
290
368
  tuttiHome,
291
369
  expectedControlToken: controlToken,
292
370
  });
293
- await app.close();
371
+ const forceClose = setTimeout(() => app.server.closeAllConnections(), 5_000);
372
+ forceClose.unref();
373
+ try {
374
+ await app.close();
375
+ }
376
+ finally {
377
+ clearTimeout(forceClose);
378
+ }
294
379
  };
380
+ requestClose = () => void close();
381
+ const idleTimeoutMs = options.idleTimeoutMs ?? 60 * 60 * 1_000;
382
+ const idleCheck = setInterval(() => {
383
+ let installedVersion = null;
384
+ try {
385
+ installedVersion = readCliVersion();
386
+ }
387
+ catch {
388
+ // Package removal is handled as a runtime identity change.
389
+ }
390
+ if (!existsSync(entrypoint) ||
391
+ entrypointIdentity(entrypoint) !== initialEntrypointIdentity ||
392
+ installedVersion !== version) {
393
+ void close();
394
+ return;
395
+ }
396
+ if (now().getTime() - lastActivityAt >= idleTimeoutMs) {
397
+ void close();
398
+ }
399
+ }, Math.min(60_000, Math.max(1_000, Math.floor(idleTimeoutMs / 4))));
400
+ idleCheck.unref();
295
401
  process.once("SIGINT", () => void close());
296
402
  process.once("SIGTERM", () => void close());
297
403
  await new Promise((resolveClosed) => {
298
404
  app.server.once("close", resolveClosed);
299
405
  });
406
+ clearInterval(idleCheck);
300
407
  }
301
408
  //# sourceMappingURL=server.js.map