@xfey/tenso 0.1.134 → 0.1.135

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.
@@ -25,6 +25,8 @@ export type CliCommand = {
25
25
  yes: boolean;
26
26
  } | {
27
27
  kind: "internal-console-run";
28
+ } | {
29
+ kind: "internal-desktop-runtime";
28
30
  } | {
29
31
  kind: "internal-desktop-open";
30
32
  url?: string;
@@ -149,11 +149,13 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
149
149
  : "internal-package-update-complete",
150
150
  };
151
151
  }
152
- if (internalCommand === "console-run") {
152
+ if (internalCommand === "console-run" || internalCommand === "desktop-runtime") {
153
153
  if (args.length > 0) {
154
154
  throw new LaunchError("git_bootstrap_failed", "Internal console-run does not accept options", "Internal commands are managed by Tenso.");
155
155
  }
156
- return { kind: "internal-console-run" };
156
+ return {
157
+ kind: internalCommand === "console-run" ? "internal-console-run" : "internal-desktop-runtime",
158
+ };
157
159
  }
158
160
  if (internalCommand === "desktop-open") {
159
161
  if (args.length > 1) {
@@ -14,6 +14,8 @@ import { resolveRelayUrl } from "./launch.js";
14
14
  import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
15
15
  import { runPackageUpdateCompletionProtocol, runPackageUpdateWorkerProtocol, } from "../local-console/package-update-process.js";
16
16
  import { recoverPackageUpdateForConsoleStartup } from "../local-console/package-update-recovery.js";
17
+ import { runDesktopRuntimeBridge } from "../desktop-runtime/bridge.js";
18
+ import { openInstalledMacApplication } from "../desktop-runtime/installation.js";
17
19
  const HELP_TEXT = `Usage:
18
20
  tenso
19
21
  tenso launch [target] [--yes] [--foreground]
@@ -235,6 +237,10 @@ async function ensureUserLocalConsole(options) {
235
237
  return (await ensureLocalConsole({ cwd: options.cwd, env, source: options.source })).url;
236
238
  }
237
239
  async function runLocalConsoleCommand() {
240
+ if (openInstalledMacApplication()) {
241
+ process.stdout.write("Opened Tenso for Mac.\n");
242
+ return;
243
+ }
238
244
  const url = await ensureUserLocalConsole({ cwd: process.cwd(), source: "terminal" });
239
245
  if (openLocalConsoleBrowser(url)) {
240
246
  process.stdout.write(`Tenso is ready at ${new URL(url).origin}/console\n`);
@@ -407,6 +413,9 @@ try {
407
413
  case "internal-console-run":
408
414
  await runLocalConsoleProcess();
409
415
  break;
416
+ case "internal-desktop-runtime":
417
+ await runDesktopRuntimeBridge();
418
+ break;
410
419
  case "internal-desktop-open":
411
420
  await runDesktopOpenCommand(command.url);
412
421
  break;
@@ -1,4 +1,5 @@
1
1
  import { homedir } from "node:os";
2
+ import { installedMacApplication } from "../desktop-runtime/installation.js";
2
3
  import { dirname, isAbsolute, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { resolveTuttiHome } from "../../providers/openai/index.js";
@@ -139,6 +140,9 @@ function createRealContext(options) {
139
140
  const cwd = resolve(options.cwd ?? process.cwd());
140
141
  const env = options.env ?? process.env;
141
142
  const platform = options.platform ?? process.platform;
143
+ const nativeApplication = platform === "darwin" ? installedMacApplication(env) : null;
144
+ if (nativeApplication !== null)
145
+ return { status: "installed", entry_path: nativeApplication, changed: false };
142
146
  const runner = options.runner ?? runDesktopCommand;
143
147
  const packageIdentity = readCliPackageIdentity();
144
148
  const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
@@ -0,0 +1,2 @@
1
+ export declare function runDesktopRuntimeBridge(): Promise<void>;
2
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1,101 @@
1
+ import { createInterface } from "node:readline";
2
+ import { dirname, resolve } from "node:path";
3
+ import { resolveTuttiHome } from "../../providers/openai/index.js";
4
+ import { ensureLocalConsole, readLocalConsoleServiceStatus, } from "../local-console/managed-console.js";
5
+ import { readLocalConsoleRuntimeEndpoint, } from "../local-console/runtime-endpoint.js";
6
+ import { executeDesktopOpenIntent, parseTuttiDesktopUrl } from "../desktop-integration/index.js";
7
+ import { resolveRelayUrl } from "../cli/launch.js";
8
+ import { parseDesktopRequest } from "./protocol.js";
9
+ import { retireOwnedNpmLauncher } from "./installation.js";
10
+ async function control(endpoint, path, body) {
11
+ const response = await fetch(`${endpoint.base_url}/local-console/v1/control/${path}`, {
12
+ method: "POST",
13
+ headers: {
14
+ authorization: `Bearer ${endpoint.control_token}`,
15
+ "content-type": "application/json",
16
+ },
17
+ body: JSON.stringify(body ?? {}),
18
+ signal: AbortSignal.timeout(300_000),
19
+ });
20
+ if (!response.ok)
21
+ throw new Error(response.status === 404 ? "previous_installation_running" : "service_unavailable");
22
+ return (await response.json());
23
+ }
24
+ export async function runDesktopRuntimeBridge() {
25
+ const cwd = dirname(resolve(process.argv[1]));
26
+ const tuttiHome = resolveTuttiHome(process.env.TUTTI_HOME, cwd);
27
+ let booted = false;
28
+ let consoleInstance;
29
+ let inputBytes = 0;
30
+ process.stdin.on("data", (chunk) => {
31
+ inputBytes += chunk.length;
32
+ if (inputBytes > 64 * 1024)
33
+ process.exit(1);
34
+ });
35
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
36
+ for await (const line of input) {
37
+ inputBytes = 0;
38
+ let id = 0;
39
+ try {
40
+ const request = parseDesktopRequest(line);
41
+ id = request.id;
42
+ const intent = request.action === "protocol" ? parseTuttiDesktopUrl(request.url) : undefined;
43
+ let openUrl;
44
+ let result = { status: "ready" };
45
+ const current = readLocalConsoleRuntimeEndpoint(tuttiHome);
46
+ if (request.action === "quit" && current === null)
47
+ result = { status: "stopped" };
48
+ else if (request.action === "status" && current === null)
49
+ result = { status: "blocked", reason: "recovery_required" };
50
+ else if (request.action === "quit" || request.action === "status") {
51
+ result = await control(current, "desktop-session", {
52
+ action: request.action === "quit" ? "exit" : "status",
53
+ });
54
+ }
55
+ else {
56
+ if (!booted && current !== null) {
57
+ const status = await readLocalConsoleServiceStatus({ cwd });
58
+ if (status.status === "running" && status.update_required) {
59
+ const stopped = await control(current, "idle-shutdown");
60
+ if (stopped.status === "blocked") {
61
+ process.stdout.write(JSON.stringify({ id, ...stopped }) + "\n");
62
+ continue;
63
+ }
64
+ }
65
+ }
66
+ const access = await ensureLocalConsole({ cwd, source: "desktop" });
67
+ if (!booted || consoleInstance !== access.endpoint.instance_id) {
68
+ result = await control(access.endpoint, "desktop-session", { action: "restore" });
69
+ booted = result.status === "ready";
70
+ consoleInstance = booted ? access.endpoint.instance_id : undefined;
71
+ if (booted)
72
+ retireOwnedNpmLauncher(tuttiHome);
73
+ }
74
+ if (result.status === "ready") {
75
+ if (request.action === "protocol") {
76
+ await executeDesktopOpenIntent({
77
+ intent: intent,
78
+ accessUrl: access.url,
79
+ relayUrl: resolveRelayUrl(process.env),
80
+ openBrowser: (url) => {
81
+ openUrl = url;
82
+ return true;
83
+ },
84
+ });
85
+ }
86
+ else
87
+ openUrl = access.url;
88
+ }
89
+ }
90
+ process.stdout.write(JSON.stringify({ id, ...result, ...(openUrl ? { open_url: openUrl } : {}) }) + "\n");
91
+ }
92
+ catch (error) {
93
+ const reason = error instanceof Error &&
94
+ ["invalid_request", "previous_installation_running"].includes(error.message)
95
+ ? error.message
96
+ : "service_unavailable";
97
+ process.stdout.write(JSON.stringify({ id, status: "error", reason }) + "\n");
98
+ }
99
+ }
100
+ }
101
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1,4 @@
1
+ export declare function installedMacApplication(env?: NodeJS.ProcessEnv): string | null;
2
+ export declare function openInstalledMacApplication(): boolean;
3
+ export declare function retireOwnedNpmLauncher(tuttiHome: string): void;
4
+ //# sourceMappingURL=installation.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { readCliPackageIdentity } from "../cli/version.js";
6
+ import { createMacosDesktopAdapter } from "../desktop-integration/macos.js";
7
+ import { readDesktopIntegrationRecord, writeDesktopIntegrationRecord, } from "../desktop-integration/record.js";
8
+ export function installedMacApplication(env = process.env) {
9
+ if (process.platform !== "darwin" ||
10
+ (env.TUTTI_HOME !== undefined && resolve(env.TUTTI_HOME) !== join(homedir(), ".tutti")))
11
+ return null;
12
+ for (const app of ["/Applications/Tenso.app", join(homedir(), "Applications/Tenso.app")]) {
13
+ try {
14
+ const distribution = JSON.parse(readFileSync(join(app, "Contents/Resources/desktop-distribution.json"), "utf8"));
15
+ if (distribution.schema_version === 1 &&
16
+ typeof distribution.version === "string" &&
17
+ existsSync(join(app, "Contents/MacOS/Tenso")))
18
+ return app;
19
+ }
20
+ catch {
21
+ /* An npm-generated launcher or an unrelated app is not a distribution. */
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ export function openInstalledMacApplication() {
27
+ const app = installedMacApplication();
28
+ if (app === null)
29
+ return false;
30
+ return spawnSync("/usr/bin/open", ["-a", app], { stdio: "ignore", shell: false }).status === 0;
31
+ }
32
+ export function retireOwnedNpmLauncher(tuttiHome) {
33
+ const app = installedMacApplication();
34
+ if (app === null || resolve(tuttiHome) !== join(homedir(), ".tutti"))
35
+ return;
36
+ const current = readCliPackageIdentity();
37
+ if (current.root !== join(app, "Contents/Resources/host"))
38
+ return;
39
+ try {
40
+ const adapter = createMacosDesktopAdapter({
41
+ homeDir: homedir(),
42
+ assetRoot: join(current.root, "desktop-assets"),
43
+ });
44
+ const record = readDesktopIntegrationRecord(tuttiHome);
45
+ if (record?.state !== "installed" ||
46
+ record.entry_path !== adapter.entryPath ||
47
+ adapter.entryPath === app ||
48
+ adapter.inspect() !== "owned")
49
+ return;
50
+ adapter.remove();
51
+ writeDesktopIntegrationRecord(tuttiHome, {
52
+ ...record,
53
+ state: "removed",
54
+ updated_at: new Date().toISOString(),
55
+ });
56
+ }
57
+ catch {
58
+ /* Unknown or damaged ownership records are never removed. */
59
+ }
60
+ }
61
+ //# sourceMappingURL=installation.js.map
@@ -0,0 +1,10 @@
1
+ export type DesktopRequest = {
2
+ id: number;
3
+ action: "boot" | "open" | "status" | "quit";
4
+ } | {
5
+ id: number;
6
+ action: "protocol";
7
+ url: string;
8
+ };
9
+ export declare function parseDesktopRequest(line: string): DesktopRequest;
10
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1,22 @@
1
+ export function parseDesktopRequest(line) {
2
+ if (Buffer.byteLength(line) > 8192)
3
+ throw new Error("invalid_request");
4
+ const value = JSON.parse(line);
5
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6
+ throw new Error("invalid_request");
7
+ const v = value;
8
+ if (!Number.isSafeInteger(v.id) || v.id < 1)
9
+ throw new Error("invalid_request");
10
+ if (v.action === "protocol" &&
11
+ typeof v.url === "string" &&
12
+ v.url.length < 4096 &&
13
+ Object.keys(v).sort().join() === "action,id,url") {
14
+ return v;
15
+ }
16
+ if (["boot", "open", "status", "quit"].includes(v.action) &&
17
+ Object.keys(v).sort().join() === "action,id") {
18
+ return v;
19
+ }
20
+ throw new Error("invalid_request");
21
+ }
22
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1,32 @@
1
+ import type { ProjectId } from "@tutti/shared/ids";
2
+ import type { LocalConsoleOperationCoordinator } from "../local-console/operation-coordinator.js";
3
+ import { type PackageUpdateHostReadiness } from "../local-console/package-update-service.js";
4
+ import { restorePackageUpdateHosts, stopPackageUpdateHost } from "../local-console/package-update-hosts.js";
5
+ import { type PackageUpdateLockAcquisition } from "../local-console/package-update-lock.js";
6
+ export type DesktopResult = {
7
+ status: "ready" | "blocked" | "stopped";
8
+ reason?: "host_busy" | "host_identity_unverified" | "console_busy" | "update_in_progress" | "recovery_required";
9
+ running_projects?: number;
10
+ restore_failed?: number;
11
+ };
12
+ export declare function readDesktopRestoreSet(tuttiHome: string): ProjectId[];
13
+ export declare function writeDesktopRestoreSet(tuttiHome: string, ids: readonly ProjectId[]): void;
14
+ type DesktopSessionOptions = {
15
+ tuttiHome: string;
16
+ env: NodeJS.ProcessEnv;
17
+ operations: LocalConsoleOperationCoordinator;
18
+ inspect?: () => Promise<PackageUpdateHostReadiness>;
19
+ stop?: typeof stopPackageUpdateHost;
20
+ restore?: typeof restorePackageUpdateHosts;
21
+ acquireLock?: () => PackageUpdateLockAcquisition;
22
+ };
23
+ export declare class DesktopSessionService {
24
+ #private;
25
+ constructor(options: DesktopSessionOptions);
26
+ status(): Promise<DesktopResult>;
27
+ restore(): Promise<DesktopResult>;
28
+ prepareExit(): Promise<DesktopResult>;
29
+ dispose(): void;
30
+ }
31
+ export {};
32
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { inspectMachineProjects } from "../cli/machine-project-inspector.js";
5
+ import { readHostLocalPackageUpdateReadiness } from "../cli/local-control-client.js";
6
+ import { inspectPackageUpdateHosts, } from "../local-console/package-update-service.js";
7
+ import { restorePackageUpdateHosts, stopPackageUpdateHost, } from "../local-console/package-update-hosts.js";
8
+ import { tryAcquirePackageUpdateLock, recoverDesktopPackageUpdateLock, } from "../local-console/package-update-lock.js";
9
+ export function readDesktopRestoreSet(tuttiHome) {
10
+ const path = join(tuttiHome, "desktop", "session.json");
11
+ if (!existsSync(path))
12
+ return [];
13
+ const value = JSON.parse(readFileSync(path, "utf8"));
14
+ if (typeof value !== "object" || value === null || Array.isArray(value))
15
+ throw new Error("desktop_record_invalid");
16
+ const record = value;
17
+ if (record.schema_version !== 1 ||
18
+ Object.keys(record).sort().join() !== "restore_project_ids,schema_version" ||
19
+ !Array.isArray(record.restore_project_ids) ||
20
+ record.restore_project_ids.length > 10000 ||
21
+ !record.restore_project_ids.every((id) => typeof id === "string" && /^prj_[0-9A-HJKMNP-TV-Z]{26}$/u.test(id))) {
22
+ throw new Error("desktop_record_invalid");
23
+ }
24
+ return [...new Set(record.restore_project_ids)];
25
+ }
26
+ export function writeDesktopRestoreSet(tuttiHome, ids) {
27
+ const path = join(tuttiHome, "desktop", "session.json");
28
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
29
+ chmodSync(dirname(path), 0o700);
30
+ const temporary = `${path}.${randomUUID()}.tmp`;
31
+ try {
32
+ writeFileSync(temporary, JSON.stringify({ schema_version: 1, restore_project_ids: [...new Set(ids)] }) + "\n", { mode: 0o600, flag: "wx" });
33
+ renameSync(temporary, path);
34
+ }
35
+ finally {
36
+ if (existsSync(temporary))
37
+ unlinkSync(temporary);
38
+ }
39
+ }
40
+ export class DesktopSessionService {
41
+ #options;
42
+ #release;
43
+ #stopped = false;
44
+ constructor(options) {
45
+ this.#options = options;
46
+ }
47
+ async #inspect() {
48
+ if (this.#options.inspect)
49
+ return this.#options.inspect();
50
+ return inspectPackageUpdateHosts({
51
+ projects: await inspectMachineProjects({ tuttiHome: this.#options.tuttiHome }),
52
+ readReadiness: async (project) => project.endpoint.kind === "valid"
53
+ ? readHostLocalPackageUpdateReadiness({ endpoint: project.endpoint.record })
54
+ : "unsupported",
55
+ });
56
+ }
57
+ async status() {
58
+ if (this.#stopped)
59
+ return { status: "stopped" };
60
+ if (this.#options.operations.state() !== "idle")
61
+ return { status: "blocked", reason: "console_busy" };
62
+ const result = await this.#inspect();
63
+ return result.status === "ready"
64
+ ? { status: "ready", running_projects: result.restore_project_ids.length }
65
+ : { status: "blocked", reason: result.reason_code };
66
+ }
67
+ #acquire() {
68
+ const operation = this.#options.operations.tryBeginPackageUpdate();
69
+ if (!operation)
70
+ return { status: "blocked", reason: "console_busy" };
71
+ let lock;
72
+ try {
73
+ recoverDesktopPackageUpdateLock(this.#options.tuttiHome);
74
+ lock =
75
+ this.#options.acquireLock?.() ??
76
+ tryAcquirePackageUpdateLock({ tuttiHome: this.#options.tuttiHome, purpose: "desktop" });
77
+ }
78
+ catch {
79
+ operation.release();
80
+ return { status: "blocked", reason: "recovery_required" };
81
+ }
82
+ if (lock.status !== "acquired") {
83
+ operation.release();
84
+ return {
85
+ status: "blocked",
86
+ reason: lock.status === "active" ? "update_in_progress" : "recovery_required",
87
+ };
88
+ }
89
+ this.#release = () => {
90
+ lock.lease.release();
91
+ operation.release();
92
+ };
93
+ return null;
94
+ }
95
+ async restore() {
96
+ const blocked = this.#acquire();
97
+ if (blocked)
98
+ return blocked;
99
+ try {
100
+ const ids = readDesktopRestoreSet(this.#options.tuttiHome);
101
+ const result = await this.#restore(ids);
102
+ writeDesktopRestoreSet(this.#options.tuttiHome, result.failed_project_ids);
103
+ return { status: "ready", restore_failed: result.failed_project_ids.length };
104
+ }
105
+ finally {
106
+ this.dispose();
107
+ }
108
+ }
109
+ #restore(projectIds) {
110
+ return (this.#options.restore ?? restorePackageUpdateHosts)({
111
+ tuttiHome: this.#options.tuttiHome,
112
+ projectIds,
113
+ env: this.#options.env,
114
+ });
115
+ }
116
+ async prepareExit() {
117
+ if (this.#stopped)
118
+ return { status: "stopped" };
119
+ const blocked = this.#acquire();
120
+ if (blocked)
121
+ return blocked;
122
+ const stopped = [];
123
+ let previous;
124
+ let recoveryWritten = false;
125
+ try {
126
+ const readiness = await this.#inspect();
127
+ if (readiness.status === "blocked")
128
+ return { status: "blocked", reason: readiness.reason_code };
129
+ try {
130
+ previous = readDesktopRestoreSet(this.#options.tuttiHome);
131
+ }
132
+ catch {
133
+ return { status: "blocked", reason: "recovery_required" };
134
+ }
135
+ // Persist before stopping the first Host, so a crash can restore partial progress.
136
+ writeDesktopRestoreSet(this.#options.tuttiHome, [
137
+ ...previous,
138
+ ...readiness.restore_project_ids,
139
+ ]);
140
+ recoveryWritten = true;
141
+ for (const projectId of readiness.restore_project_ids) {
142
+ const result = await (this.#options.stop ?? stopPackageUpdateHost)({
143
+ tuttiHome: this.#options.tuttiHome,
144
+ projectId,
145
+ });
146
+ if (result !== "stopped") {
147
+ // A rejected response may follow an accepted shutdown whose response was lost.
148
+ // Reconcile the attempted Host as well; restore skips verified running Hosts.
149
+ const restored = await this.#restore([...stopped, projectId]);
150
+ writeDesktopRestoreSet(this.#options.tuttiHome, [
151
+ ...previous,
152
+ ...restored.failed_project_ids,
153
+ ]);
154
+ return {
155
+ status: "blocked",
156
+ reason: result,
157
+ restore_failed: restored.failed_project_ids.length,
158
+ };
159
+ }
160
+ stopped.push(projectId);
161
+ }
162
+ this.#stopped = true;
163
+ // Hold both leases until Console closes, preventing a new Launch during quit/update.
164
+ return { status: "stopped" };
165
+ }
166
+ catch {
167
+ if (!recoveryWritten)
168
+ return { status: "blocked", reason: "recovery_required" };
169
+ const restored = await this.#restore(readDesktopRestoreSet(this.#options.tuttiHome));
170
+ writeDesktopRestoreSet(this.#options.tuttiHome, restored.failed_project_ids);
171
+ return {
172
+ status: "blocked",
173
+ reason: "recovery_required",
174
+ restore_failed: restored.failed_project_ids.length,
175
+ };
176
+ }
177
+ finally {
178
+ if (!this.#stopped)
179
+ this.dispose();
180
+ }
181
+ }
182
+ dispose() {
183
+ this.#release?.();
184
+ this.#release = undefined;
185
+ }
186
+ }
187
+ //# sourceMappingURL=session.js.map
@@ -29,6 +29,7 @@ export declare function tryAcquirePackageUpdateLock(options: {
29
29
  pid?: number;
30
30
  staleMs?: number;
31
31
  processAlive?: (pid: number) => boolean;
32
+ purpose?: "desktop";
32
33
  }): PackageUpdateLockAcquisition;
33
34
  export declare function removeRecoverablePackageUpdateLock(options: {
34
35
  tuttiHome: string;
@@ -36,6 +37,8 @@ export declare function removeRecoverablePackageUpdateLock(options: {
36
37
  staleMs?: number;
37
38
  processAlive?: (pid: number) => boolean;
38
39
  }): boolean;
40
+ /** Only native lifecycle locks with a proven dead owner; never a pending npm update. */
41
+ export declare function recoverDesktopPackageUpdateLock(tuttiHome: string): void;
39
42
  export declare function transferPackageUpdateLockFromCurrentProcess(options: {
40
43
  tuttiHome: string;
41
44
  nextPid: number;
@@ -92,7 +92,7 @@ export function tryAcquirePackageUpdateLock(options) {
92
92
  const path = packageUpdateLockPath(options.tuttiHome);
93
93
  const now = options.now ?? Date.now;
94
94
  ensurePrivateRuntimeDirectory(path);
95
- const ownerId = randomBytes(16).toString("base64url");
95
+ const ownerId = `${options.purpose === "desktop" ? "desktop-" : ""}${randomBytes(16).toString("base64url")}`;
96
96
  const timestamp = new Date(now()).toISOString();
97
97
  const record = {
98
98
  schema_version: 1,
@@ -176,6 +176,21 @@ export function removeRecoverablePackageUpdateLock(options) {
176
176
  return false;
177
177
  }
178
178
  }
179
+ /** Only native lifecycle locks with a proven dead owner; never a pending npm update. */
180
+ export function recoverDesktopPackageUpdateLock(tuttiHome) {
181
+ const path = packageUpdateLockPath(tuttiHome);
182
+ const record = parseRecord(path);
183
+ if (!record?.owner_id.startsWith("desktop-") || defaultProcessAlive(record.pid))
184
+ return;
185
+ if (parseRecord(path)?.owner_id !== record.owner_id)
186
+ return;
187
+ try {
188
+ unlinkSync(path);
189
+ }
190
+ catch {
191
+ /* Another lifecycle process may have recovered it. */
192
+ }
193
+ }
179
194
  export function transferPackageUpdateLockFromCurrentProcess(options) {
180
195
  if (!isPositiveInteger(options.nextPid)) {
181
196
  return false;
@@ -1,5 +1,6 @@
1
1
  import { createProjectFolder, defaultProjectsDirectory, } from "./project-folders.js";
2
2
  import { timingSafeEqual } from "node:crypto";
3
+ import { DesktopSessionService } from "../desktop-runtime/session.js";
3
4
  import { registerConsoleBrowserBridge } from "./browser-bridge.js";
4
5
  import { LocalConsoleManagedProjectService } from "./managed-project-service.js";
5
6
  import { registerLocalConsoleManagedProjectRoutes } from "./managed-project-routes.js";
@@ -81,6 +82,17 @@ export function createLocalConsoleServer(options) {
81
82
  ...(options.now === undefined ? {} : { now: options.now }),
82
83
  });
83
84
  const operations = options.operationCoordinator ?? new LocalConsoleOperationCoordinator();
85
+ const desktopSession = new DesktopSessionService({
86
+ tuttiHome,
87
+ operations,
88
+ env: options.env ?? process.env,
89
+ });
90
+ let nativeShutdownLease;
91
+ app.addHook("onClose", (_instance, done) => {
92
+ desktopSession.dispose();
93
+ nativeShutdownLease?.release();
94
+ done();
95
+ });
84
96
  const projects = new LocalConsoleProjectService({
85
97
  tuttiHome,
86
98
  operations,
@@ -232,6 +244,37 @@ export function createLocalConsoleServer(options) {
232
244
  setImmediate(() => options.onShutdown?.());
233
245
  return { status: "stopping" };
234
246
  });
247
+ // Native-only control: never included in the browser bridge allowlist.
248
+ app.post(`${LOCAL_CONSOLE_API_BASE}/control/desktop-session`, {
249
+ schema: {
250
+ body: {
251
+ type: "object",
252
+ additionalProperties: false,
253
+ required: ["action"],
254
+ properties: { action: { enum: ["status", "restore", "exit"] } },
255
+ },
256
+ querystring: { type: "object", additionalProperties: false, properties: {} },
257
+ },
258
+ }, async (request) => {
259
+ requireControl(request);
260
+ if (request.body.action === "status")
261
+ return desktopSession.status();
262
+ if (request.body.action === "restore")
263
+ return desktopSession.restore();
264
+ const result = await desktopSession.prepareExit();
265
+ if (result.status === "stopped")
266
+ setImmediate(() => options.onShutdown?.());
267
+ return result;
268
+ });
269
+ app.post(`${LOCAL_CONSOLE_API_BASE}/control/idle-shutdown`, (request) => {
270
+ requireControl(request);
271
+ const lease = operations.tryBeginPackageUpdate();
272
+ if (lease === null)
273
+ return { status: "blocked", reason: "console_busy" };
274
+ nativeShutdownLease = lease;
275
+ setImmediate(() => options.onShutdown?.());
276
+ return { status: "stopping" };
277
+ });
235
278
  app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
236
279
  schema: {
237
280
  body: {
@@ -1157,6 +1157,7 @@ export declare const FeishuSetupProjectionSchema: import("@sinclair/typebox").TU
1157
1157
  visibility: import("@sinclair/typebox").TLiteral<"not_recoverable">;
1158
1158
  }>, import("@sinclair/typebox").TObject<{
1159
1159
  state: import("@sinclair/typebox").TLiteral<"connected">;
1160
+ group_url: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
1160
1161
  }>]>;
1161
1162
  export declare const IntegrationConversationBindingRequestSchema: import("@sinclair/typebox").TObject<{
1162
1163
  protocol_version: import("@sinclair/typebox").TLiteral<1>;
@@ -316,7 +316,13 @@ export const FeishuSetupProjectionSchema = Type.Union([
316
316
  expires_at: Type.Optional(IsoDateTimeStringSchema),
317
317
  visibility: Type.Literal("not_recoverable"),
318
318
  }, { additionalProperties: false }),
319
- Type.Object({ state: Type.Literal("connected") }, { additionalProperties: false }),
319
+ Type.Object({
320
+ state: Type.Literal("connected"),
321
+ group_url: Type.Optional(Type.String({
322
+ pattern: "^https://applink\\.feishu\\.cn/client/chat/open\\?chatId=oc_[A-Za-z0-9_-]{1,128}$",
323
+ maxLength: 256,
324
+ })),
325
+ }, { additionalProperties: false }),
320
326
  ]);
321
327
  export const IntegrationConversationBindingRequestSchema = Type.Object({
322
328
  protocol_version: Type.Literal(1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tenso",
3
- "version": "0.1.134",
3
+ "version": "0.1.135",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",