libretto 0.5.5 → 0.6.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.
Files changed (110) hide show
  1. package/README.md +23 -10
  2. package/README.template.md +23 -10
  3. package/dist/cli/cli.js +10 -0
  4. package/dist/cli/commands/ai.js +77 -2
  5. package/dist/cli/commands/browser.js +98 -8
  6. package/dist/cli/commands/execution.js +152 -56
  7. package/dist/cli/commands/setup.js +390 -0
  8. package/dist/cli/commands/snapshot.js +2 -2
  9. package/dist/cli/commands/status.js +62 -0
  10. package/dist/cli/core/{snapshot-api-config.js → ai-model.js} +81 -7
  11. package/dist/cli/core/api-snapshot-analyzer.js +7 -5
  12. package/dist/cli/core/browser.js +202 -36
  13. package/dist/cli/core/{ai-config.js → config.js} +14 -79
  14. package/dist/cli/core/context.js +1 -25
  15. package/dist/cli/core/deploy-artifact.js +121 -61
  16. package/dist/cli/core/providers/browserbase.js +53 -0
  17. package/dist/cli/core/providers/index.js +48 -0
  18. package/dist/cli/core/providers/kernel.js +46 -0
  19. package/dist/cli/core/providers/libretto-cloud.js +58 -0
  20. package/dist/cli/core/readonly-exec.js +231 -0
  21. package/dist/{shared/llm/client.js → cli/core/resolve-model.js} +4 -68
  22. package/dist/cli/core/session.js +53 -0
  23. package/dist/cli/core/skill-version.js +73 -0
  24. package/dist/cli/core/telemetry.js +1 -54
  25. package/dist/cli/index.js +1 -7
  26. package/dist/cli/router.js +4 -4
  27. package/dist/cli/workers/run-integration-runtime.js +19 -13
  28. package/dist/cli/workers/run-integration-worker-protocol.js +5 -2
  29. package/dist/index.d.ts +2 -4
  30. package/dist/index.js +2 -2
  31. package/dist/runtime/extract/extract.d.ts +2 -2
  32. package/dist/runtime/extract/extract.js +4 -2
  33. package/dist/runtime/extract/index.d.ts +1 -1
  34. package/dist/runtime/recovery/agent.d.ts +2 -3
  35. package/dist/runtime/recovery/agent.js +5 -3
  36. package/dist/runtime/recovery/errors.d.ts +2 -3
  37. package/dist/runtime/recovery/errors.js +4 -2
  38. package/dist/runtime/recovery/index.d.ts +1 -2
  39. package/dist/runtime/recovery/recovery.d.ts +2 -3
  40. package/dist/runtime/recovery/recovery.js +3 -3
  41. package/dist/shared/debug/pause.js +4 -21
  42. package/dist/shared/run/api.d.ts +2 -0
  43. package/dist/shared/run/browser.d.ts +9 -1
  44. package/dist/shared/run/browser.js +43 -3
  45. package/dist/shared/state/index.d.ts +1 -1
  46. package/dist/shared/state/index.js +2 -0
  47. package/dist/shared/state/session-state.d.ts +20 -1
  48. package/dist/shared/state/session-state.js +12 -2
  49. package/dist/shared/workflow/workflow.d.ts +2 -1
  50. package/dist/shared/workflow/workflow.js +16 -9
  51. package/package.json +17 -16
  52. package/scripts/postinstall.mjs +13 -11
  53. package/scripts/skills-libretto.mjs +14 -4
  54. package/skills/AGENTS.md +11 -0
  55. package/skills/libretto/SKILL.md +30 -9
  56. package/skills/libretto/references/auth-profiles.md +1 -1
  57. package/skills/libretto/references/code-generation-rules.md +3 -3
  58. package/skills/libretto/references/configuration-file-reference.md +11 -6
  59. package/skills/libretto-readonly/SKILL.md +95 -0
  60. package/src/cli/cli.ts +10 -0
  61. package/src/cli/commands/ai.ts +111 -1
  62. package/src/cli/commands/browser.ts +111 -9
  63. package/src/cli/commands/execution.ts +181 -74
  64. package/src/cli/commands/setup.ts +516 -0
  65. package/src/cli/commands/snapshot.ts +2 -2
  66. package/src/cli/commands/status.ts +79 -0
  67. package/src/cli/core/{snapshot-api-config.ts → ai-model.ts} +154 -14
  68. package/src/cli/core/api-snapshot-analyzer.ts +7 -5
  69. package/src/cli/core/browser.ts +242 -35
  70. package/src/cli/core/{ai-config.ts → config.ts} +14 -108
  71. package/src/cli/core/context.ts +1 -45
  72. package/src/cli/core/deploy-artifact.ts +141 -71
  73. package/src/cli/core/providers/browserbase.ts +57 -0
  74. package/src/cli/core/providers/index.ts +62 -0
  75. package/src/cli/core/providers/kernel.ts +49 -0
  76. package/src/cli/core/providers/libretto-cloud.ts +61 -0
  77. package/src/cli/core/providers/types.ts +9 -0
  78. package/src/cli/core/readonly-exec.ts +284 -0
  79. package/src/{shared/llm/client.ts → cli/core/resolve-model.ts} +3 -85
  80. package/src/cli/core/session.ts +75 -2
  81. package/src/cli/core/skill-version.ts +93 -0
  82. package/src/cli/core/telemetry.ts +0 -52
  83. package/src/cli/index.ts +0 -6
  84. package/src/cli/router.ts +4 -4
  85. package/src/cli/workers/run-integration-runtime.ts +18 -16
  86. package/src/cli/workers/run-integration-worker-protocol.ts +4 -1
  87. package/src/index.ts +1 -7
  88. package/src/runtime/extract/extract.ts +6 -5
  89. package/src/runtime/recovery/agent.ts +5 -4
  90. package/src/runtime/recovery/errors.ts +4 -3
  91. package/src/runtime/recovery/recovery.ts +4 -4
  92. package/src/shared/debug/pause.ts +4 -23
  93. package/src/shared/run/browser.ts +50 -1
  94. package/src/shared/state/index.ts +2 -0
  95. package/src/shared/state/session-state.ts +10 -0
  96. package/src/shared/workflow/workflow.ts +24 -13
  97. package/dist/cli/commands/init.js +0 -286
  98. package/dist/cli/commands/logs.js +0 -117
  99. package/dist/shared/llm/ai-sdk-adapter.d.ts +0 -22
  100. package/dist/shared/llm/ai-sdk-adapter.js +0 -49
  101. package/dist/shared/llm/client.d.ts +0 -13
  102. package/dist/shared/llm/index.d.ts +0 -5
  103. package/dist/shared/llm/index.js +0 -6
  104. package/dist/shared/llm/types.d.ts +0 -67
  105. package/src/cli/commands/init.ts +0 -331
  106. package/src/cli/commands/logs.ts +0 -128
  107. package/src/shared/llm/ai-sdk-adapter.ts +0 -81
  108. package/src/shared/llm/index.ts +0 -3
  109. package/src/shared/llm/types.ts +0 -63
  110. /package/dist/{shared/llm → cli/core/providers}/types.js +0 -0
@@ -0,0 +1,61 @@
1
+ import type { ProviderApi } from "./types.js";
2
+
3
+ export function createLibrettoCloudProvider(): ProviderApi {
4
+ const apiKey = process.env.LIBRETTO_API_KEY;
5
+ if (!apiKey)
6
+ throw new Error(
7
+ "LIBRETTO_API_KEY is required for the Libretto Cloud provider.",
8
+ );
9
+ const apiUrl = process.env.LIBRETTO_API_URL;
10
+ if (!apiUrl)
11
+ throw new Error(
12
+ "LIBRETTO_API_URL is required for the Libretto Cloud provider.",
13
+ );
14
+ const endpoint = apiUrl.replace(/\/$/, "");
15
+
16
+ return {
17
+ async createSession() {
18
+ const timeoutSeconds = Number(
19
+ process.env.LIBRETTO_TIMEOUT_SECONDS ?? 7200,
20
+ );
21
+ const resp = await fetch(`${endpoint}/v1/sessions/create`, {
22
+ method: "POST",
23
+ headers: {
24
+ "x-api-key": apiKey,
25
+ "Content-Type": "application/json",
26
+ },
27
+ body: JSON.stringify({ timeout_seconds: timeoutSeconds }),
28
+ });
29
+ if (!resp.ok) {
30
+ const body = await resp.text();
31
+ throw new Error(
32
+ `Libretto Cloud API error (${resp.status}): ${body}`,
33
+ );
34
+ }
35
+ const json = (await resp.json()) as {
36
+ session_id: string;
37
+ cdp_url: string;
38
+ };
39
+ return {
40
+ sessionId: json.session_id,
41
+ cdpEndpoint: json.cdp_url,
42
+ };
43
+ },
44
+ async closeSession(sessionId) {
45
+ const resp = await fetch(`${endpoint}/v1/sessions/close`, {
46
+ method: "POST",
47
+ headers: {
48
+ "x-api-key": apiKey,
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({ session_id: sessionId }),
52
+ });
53
+ if (!resp.ok) {
54
+ const body = await resp.text();
55
+ throw new Error(
56
+ `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
57
+ );
58
+ }
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,9 @@
1
+ export type ProviderSession = {
2
+ sessionId: string; // remote session id for cleanup
3
+ cdpEndpoint: string; // CDP WebSocket URL
4
+ };
5
+
6
+ export type ProviderApi = {
7
+ createSession(): Promise<ProviderSession>;
8
+ closeSession(sessionId: string): Promise<void>;
9
+ };
@@ -0,0 +1,284 @@
1
+ import type { Locator, Page } from "playwright";
2
+
3
+ const PAGE_READ_METHODS = new Set([
4
+ "url",
5
+ "title",
6
+ "content",
7
+ "pageErrors",
8
+ "viewportSize",
9
+ "waitForLoadState",
10
+ "waitForRequest",
11
+ "waitForResponse",
12
+ "waitForURL",
13
+ ]);
14
+ const PAGE_LOCATOR_FACTORY_METHODS = new Set([
15
+ "locator",
16
+ "getByRole",
17
+ "getByText",
18
+ "getByLabel",
19
+ "getByPlaceholder",
20
+ "getByAltText",
21
+ "getByTitle",
22
+ "getByTestId",
23
+ ]);
24
+
25
+ const PAGE_ALLOWED_PROPERTIES = new Set<string>([]);
26
+
27
+ const LOCATOR_READ_METHODS = new Set([
28
+ "textContent",
29
+ "innerText",
30
+ "allTextContents",
31
+ "allInnerTexts",
32
+ "ariaSnapshot",
33
+ "boundingBox",
34
+ "count",
35
+ "getAttribute",
36
+ "inputValue",
37
+ "isChecked",
38
+ "isDisabled",
39
+ "isEditable",
40
+ "isEnabled",
41
+ "isVisible",
42
+ "isHidden",
43
+ "waitFor",
44
+ ]);
45
+
46
+ const LOCATOR_FACTORY_METHODS = new Set([
47
+ "locator",
48
+ "getByRole",
49
+ "getByText",
50
+ "getByLabel",
51
+ "getByPlaceholder",
52
+ "getByAltText",
53
+ "getByTitle",
54
+ "getByTestId",
55
+ "filter",
56
+ "and",
57
+ "or",
58
+ "first",
59
+ "last",
60
+ "nth",
61
+ ]);
62
+
63
+ const LOCATOR_COLLECTION_FACTORY_METHODS = new Set(["all"]);
64
+
65
+ const LOCATOR_SCROLL_METHODS = new Set(["scrollIntoViewIfNeeded"]);
66
+
67
+ const LOCATOR_ALLOWED_PROPERTIES = new Set<string>([]);
68
+
69
+ type ReadonlyExecOptions = {
70
+ onActivity?: () => void;
71
+ };
72
+
73
+ const readonlyPageCache = new WeakMap<Page, Page>();
74
+ const readonlyLocatorCache = new WeakMap<Locator, Locator>();
75
+
76
+ function markActivity(onActivity?: () => void): void {
77
+ onActivity?.();
78
+ }
79
+
80
+ export class ReadonlyExecDeniedError extends Error {
81
+ constructor(message: string) {
82
+ super(`ReadonlyExecDenied: ${message}`);
83
+ this.name = "ReadonlyExecDenied";
84
+ }
85
+ }
86
+
87
+ function denyOperation(targetName: "page" | "locator", method: string): never {
88
+ throw new ReadonlyExecDeniedError(
89
+ `${targetName}.${method} is blocked in readonly-exec`,
90
+ );
91
+ }
92
+
93
+ export function wrapLocatorForReadonlyExec(
94
+ locator: Locator,
95
+ options: ReadonlyExecOptions = {},
96
+ ): Locator {
97
+ const cached = readonlyLocatorCache.get(locator);
98
+ if (cached) return cached;
99
+
100
+ const proxy = new Proxy(locator, {
101
+ get(target, prop, receiver) {
102
+ if (typeof prop !== "string") {
103
+ return Reflect.get(target, prop, receiver);
104
+ }
105
+
106
+ const value = Reflect.get(target, prop, target);
107
+ if (typeof value !== "function") {
108
+ if (LOCATOR_ALLOWED_PROPERTIES.has(prop)) {
109
+ return value;
110
+ }
111
+ return denyOperation("locator", prop);
112
+ }
113
+
114
+ if (LOCATOR_READ_METHODS.has(prop)) {
115
+ return (...args: unknown[]) => {
116
+ const result = value.apply(target, args);
117
+ markActivity(options.onActivity);
118
+ return result;
119
+ };
120
+ }
121
+
122
+ if (LOCATOR_FACTORY_METHODS.has(prop)) {
123
+ return (...args: unknown[]) => {
124
+ const nextLocator = value.apply(target, args) as Locator;
125
+ markActivity(options.onActivity);
126
+ return wrapLocatorForReadonlyExec(nextLocator, options);
127
+ };
128
+ }
129
+
130
+ if (LOCATOR_COLLECTION_FACTORY_METHODS.has(prop)) {
131
+ return async (...args: unknown[]) => {
132
+ const locators = (await value.apply(target, args)) as Locator[];
133
+ markActivity(options.onActivity);
134
+ return locators.map((locator) =>
135
+ wrapLocatorForReadonlyExec(locator, options),
136
+ );
137
+ };
138
+ }
139
+
140
+ if (LOCATOR_SCROLL_METHODS.has(prop)) {
141
+ return async (...args: unknown[]) => {
142
+ await value.apply(target, args);
143
+ markActivity(options.onActivity);
144
+ };
145
+ }
146
+
147
+ return (..._args: unknown[]) => denyOperation("locator", prop);
148
+ },
149
+ });
150
+
151
+ readonlyLocatorCache.set(locator, proxy as Locator);
152
+ return proxy as Locator;
153
+ }
154
+
155
+ export function wrapPageForReadonlyExec(
156
+ page: Page,
157
+ options: ReadonlyExecOptions = {},
158
+ ): Page {
159
+ const cached = readonlyPageCache.get(page);
160
+ if (cached) return cached;
161
+
162
+ const proxy = new Proxy(page, {
163
+ get(target, prop, receiver) {
164
+ if (typeof prop !== "string") {
165
+ return Reflect.get(target, prop, receiver);
166
+ }
167
+
168
+ const value = Reflect.get(target, prop, target);
169
+ if (typeof value !== "function") {
170
+ if (PAGE_ALLOWED_PROPERTIES.has(prop)) {
171
+ return value;
172
+ }
173
+ return denyOperation("page", prop);
174
+ }
175
+
176
+ if (PAGE_READ_METHODS.has(prop)) {
177
+ return (...args: unknown[]) => {
178
+ const result = value.apply(target, args);
179
+ markActivity(options.onActivity);
180
+ return result;
181
+ };
182
+ }
183
+
184
+ if (PAGE_LOCATOR_FACTORY_METHODS.has(prop)) {
185
+ return (...args: unknown[]) => {
186
+ const locator = value.apply(target, args) as Locator;
187
+ markActivity(options.onActivity);
188
+ return wrapLocatorForReadonlyExec(locator, options);
189
+ };
190
+ }
191
+
192
+ return (..._args: unknown[]) => denyOperation("page", prop);
193
+ },
194
+ });
195
+
196
+ readonlyPageCache.set(page, proxy as Page);
197
+ return proxy as Page;
198
+ }
199
+
200
+ function resolveRequestMethod(
201
+ input: RequestInfo | URL,
202
+ init?: RequestInit,
203
+ ): string {
204
+ const requestMethod =
205
+ typeof Request !== "undefined" && input instanceof Request
206
+ ? input.method
207
+ : undefined;
208
+ return (init?.method ?? requestMethod ?? "GET").toUpperCase();
209
+ }
210
+
211
+ function assertReadonlyRequestBodyAllowed(
212
+ input: RequestInfo | URL,
213
+ init?: RequestInit,
214
+ ): void {
215
+ if (init?.body !== undefined) {
216
+ throw new ReadonlyExecDeniedError(
217
+ "request bodies are blocked in readonly-exec",
218
+ );
219
+ }
220
+
221
+ if (
222
+ typeof Request !== "undefined" &&
223
+ input instanceof Request &&
224
+ input.body !== null
225
+ ) {
226
+ throw new ReadonlyExecDeniedError(
227
+ "request bodies are blocked in readonly-exec",
228
+ );
229
+ }
230
+ }
231
+
232
+ export function createReadonlyExecHelpers(
233
+ page: Page,
234
+ options: ReadonlyExecOptions = {},
235
+ ) {
236
+ const readonlyPage = wrapPageForReadonlyExec(page, options);
237
+ const execState: Record<string, unknown> = {};
238
+
239
+ return {
240
+ page: readonlyPage,
241
+ state: execState,
242
+ // Playwright has no native viewport scroll method — only locator.scrollIntoViewIfNeeded().
243
+ // Arbitrary scrolling requires page.evaluate(), which is blocked by the readonly proxy
244
+ // since it can run arbitrary code. This helper calls evaluate on the raw (unwrapped) page,
245
+ // scoped to just window.scrollBy.
246
+ scrollBy: async (deltaX: number, deltaY: number) => {
247
+ await page.evaluate(
248
+ ([x, y]) => {
249
+ window.scrollBy(x, y);
250
+ },
251
+ [deltaX, deltaY] as const,
252
+ );
253
+ markActivity(options.onActivity);
254
+ },
255
+ get: async (input: RequestInfo | URL, init?: RequestInit) => {
256
+ const method = resolveRequestMethod(input, init);
257
+ if (method !== "GET" && method !== "HEAD") {
258
+ throw new ReadonlyExecDeniedError(
259
+ `${method} requests are blocked in readonly-exec`,
260
+ );
261
+ }
262
+ assertReadonlyRequestBodyAllowed(input, init);
263
+ markActivity(options.onActivity);
264
+ return await fetch(input, {
265
+ ...init,
266
+ method,
267
+ });
268
+ },
269
+ // Shadows the global Node.js fetch to prevent unrestricted HTTP access.
270
+ // Without this, agent code would fall through to the global fetch (POST, PUT, DELETE, etc.).
271
+ fetch: () => {
272
+ throw new ReadonlyExecDeniedError(
273
+ "fetch is blocked in readonly-exec; use get() instead",
274
+ );
275
+ },
276
+ console,
277
+ setTimeout,
278
+ setInterval,
279
+ clearTimeout,
280
+ clearInterval,
281
+ URL,
282
+ Buffer,
283
+ };
284
+ }
@@ -1,6 +1,4 @@
1
- import { generateObject, type LanguageModel, type ModelMessage } from "ai";
2
- import type { ZodType, output as ZodOutput } from "zod";
3
- import type { LLMClient, Message, MessageContentPart } from "./types.js";
1
+ import type { LanguageModel } from "ai";
4
2
 
5
3
  export type Provider = "google" | "vertex" | "anthropic" | "openai";
6
4
 
@@ -138,87 +136,7 @@ async function getProviderModel(
138
136
  }
139
137
  }
140
138
 
141
- function convertUserContentParts(parts: MessageContentPart[]) {
142
- return parts.map((part) => {
143
- if (part.type === "text") {
144
- return { type: "text" as const, text: part.text };
145
- }
146
- return {
147
- type: "image" as const,
148
- image: part.image,
149
- ...(part.mediaType ? { mediaType: part.mediaType } : {}),
150
- };
151
- });
152
- }
153
-
154
- function convertAssistantContentParts(parts: MessageContentPart[]) {
155
- return parts
156
- .filter(
157
- (part): part is MessageContentPart & { type: "text" } =>
158
- part.type === "text",
159
- )
160
- .map((part) => ({ type: "text" as const, text: part.text }));
161
- }
162
-
163
- function convertMessages(messages: Message[]): ModelMessage[] {
164
- return messages.map((msg): ModelMessage => {
165
- if (msg.role === "user") {
166
- if (typeof msg.content === "string") {
167
- return { role: "user", content: msg.content };
168
- }
169
- return {
170
- role: "user",
171
- content: convertUserContentParts(msg.content),
172
- };
173
- }
174
- if (typeof msg.content === "string") {
175
- return { role: "assistant", content: msg.content };
176
- }
177
- return {
178
- role: "assistant",
179
- content: convertAssistantContentParts(msg.content),
180
- };
181
- });
182
- }
183
-
184
- export function createLLMClient(model: string): LLMClient {
139
+ export async function resolveModel(model: string): Promise<LanguageModel> {
185
140
  const { provider, modelId } = parseModel(model);
186
- let modelPromise: Promise<LanguageModel> | null = null;
187
-
188
- const getModel = () => {
189
- modelPromise ??= getProviderModel(provider, modelId);
190
- return modelPromise;
191
- };
192
-
193
- return {
194
- async generateObject<T extends ZodType>(opts: {
195
- prompt: string;
196
- schema: T;
197
- temperature?: number;
198
- }): Promise<ZodOutput<T>> {
199
- const aiModel = await getModel();
200
- const result = await generateObject({
201
- model: aiModel,
202
- prompt: opts.prompt,
203
- schema: opts.schema,
204
- temperature: opts.temperature ?? 0,
205
- });
206
- return result.object as ZodOutput<T>;
207
- },
208
-
209
- async generateObjectFromMessages<T extends ZodType>(opts: {
210
- messages: Message[];
211
- schema: T;
212
- temperature?: number;
213
- }): Promise<ZodOutput<T>> {
214
- const aiModel = await getModel();
215
- const result = await generateObject({
216
- model: aiModel,
217
- messages: convertMessages(opts.messages),
218
- schema: opts.schema,
219
- temperature: opts.temperature ?? 0,
220
- });
221
- return result.object as ZodOutput<T>;
222
- },
223
- };
141
+ return getProviderModel(provider, modelId);
224
142
  }
@@ -14,9 +14,11 @@ import {
14
14
  LIBRETTO_SESSIONS_DIR,
15
15
  } from "./context.js";
16
16
  import {
17
+ SessionAccessModeSchema,
17
18
  SESSION_STATE_VERSION,
18
19
  parseSessionStateContent,
19
20
  serializeSessionState,
21
+ type SessionAccessMode,
20
22
  type SessionStatus,
21
23
  type SessionState,
22
24
  } from "../../shared/state/index.js";
@@ -35,7 +37,13 @@ export function generateSessionName(): string {
35
37
  return `ses-${id}`;
36
38
  }
37
39
  export { SESSION_STATE_VERSION };
38
- export type { SessionStatus, SessionState };
40
+ export type { SessionAccessMode, SessionStatus, SessionState };
41
+
42
+ export function resolveSessionAccessMode(
43
+ state: Pick<SessionState, "mode"> | null | undefined,
44
+ ): SessionAccessMode {
45
+ return SessionAccessModeSchema.parse(state?.mode);
46
+ }
39
47
 
40
48
  export function logFileForSession(session: string): string {
41
49
  validateSessionName(session);
@@ -111,6 +119,26 @@ function listActiveSessions(): string[] {
111
119
  return listSessionsWithStateFile();
112
120
  }
113
121
 
122
+ /**
123
+ * List sessions whose state file exists and whose pid is still running.
124
+ * Returns session states (not just names) so callers can access port, status, etc.
125
+ */
126
+ export function listRunningSessions(): SessionState[] {
127
+ const sessions = listSessionsWithStateFile();
128
+ const running: SessionState[] = [];
129
+ for (const name of sessions) {
130
+ const state = readSessionState(name);
131
+ if (!state) continue;
132
+ if (state.provider) {
133
+ running.push(state);
134
+ continue;
135
+ }
136
+ if (state.pid == null || !isPidRunning(state.pid)) continue;
137
+ running.push(state);
138
+ }
139
+ return running;
140
+ }
141
+
114
142
  function throwSessionNotFoundError(session: string): never {
115
143
  const active = listActiveSessions();
116
144
  const lines = [`No session "${session}" found.`];
@@ -165,11 +193,47 @@ export function writeSessionState(
165
193
  logger?.info("session-state-write", {
166
194
  session: state.session,
167
195
  stateFile,
196
+ mode: state.mode,
168
197
  port: state.port,
169
198
  pid: state.pid,
170
199
  });
171
200
  }
172
201
 
202
+ export function setSessionMode(
203
+ session: string,
204
+ mode: SessionAccessMode,
205
+ logger?: LoggerApi,
206
+ ): SessionState {
207
+ const state = readSessionStateOrThrow(session);
208
+ const normalizedMode = SessionAccessModeSchema.parse(mode);
209
+ if (state.mode === normalizedMode) {
210
+ return state;
211
+ }
212
+
213
+ const nextState = {
214
+ ...state,
215
+ mode: normalizedMode,
216
+ };
217
+ writeSessionState(nextState, logger);
218
+ return nextState;
219
+ }
220
+
221
+ export function assertSessionAllowsCommand(
222
+ state: SessionState,
223
+ commandName: string,
224
+ allowedModes: readonly SessionAccessMode[],
225
+ ): void {
226
+ const mode = resolveSessionAccessMode(state);
227
+ if (allowedModes.includes(mode)) {
228
+ return;
229
+ }
230
+
231
+ const supportedModes = [...allowedModes].join(", ");
232
+ throw new Error(
233
+ `Command "${commandName}" is blocked for session "${state.session}" because it is in ${mode} mode. Allowed modes for this command: ${supportedModes}. Run \`libretto session-mode write-access --session ${state.session}\` to unlock the session.`,
234
+ );
235
+ }
236
+
173
237
  export function clearSessionState(session: string, logger?: LoggerApi): void {
174
238
  const stateFile = getStateFilePath(session);
175
239
  if (!existsSync(stateFile)) {
@@ -180,7 +244,7 @@ export function clearSessionState(session: string, logger?: LoggerApi): void {
180
244
  logger?.info("session-state-cleared", { session, stateFile });
181
245
  }
182
246
 
183
- function isPidRunning(pid: number): boolean {
247
+ export function isPidRunning(pid: number): boolean {
184
248
  try {
185
249
  process.kill(pid, 0);
186
250
  return true;
@@ -212,6 +276,15 @@ export function assertSessionAvailableForStart(
212
276
  ): void {
213
277
  const existingState = readSessionState(session, logger);
214
278
  if (!existingState) return;
279
+
280
+ // Cloud provider sessions have no local PID — treat them as active
281
+ // if they have a provider field with a cdpEndpoint.
282
+ if (existingState.provider && existingState.cdpEndpoint) {
283
+ throw new Error(
284
+ `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: libretto close --session ${session}`,
285
+ );
286
+ }
287
+
215
288
  if (existingState.pid == null || !isPidRunning(existingState.pid)) {
216
289
  setSessionStatus(session, "exited", logger);
217
290
  return;
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { REPO_ROOT } from "./context.js";
5
+
6
+ type PackageManifest = {
7
+ version?: string;
8
+ };
9
+
10
+ const INSTALLED_SKILL_PATHS = [
11
+ [".agents", "skills", "libretto", "SKILL.md"],
12
+ [".claude", "skills", "libretto", "SKILL.md"],
13
+ ] as const;
14
+
15
+ let cachedCliVersion: string | null = null;
16
+
17
+ function readCurrentCliVersion(): string {
18
+ if (cachedCliVersion) {
19
+ return cachedCliVersion;
20
+ }
21
+
22
+ const packageJsonPath = fileURLToPath(
23
+ new URL("../../../package.json", import.meta.url),
24
+ );
25
+ const manifest = JSON.parse(
26
+ readFileSync(packageJsonPath, "utf8"),
27
+ ) as PackageManifest;
28
+
29
+ if (!manifest.version) {
30
+ throw new Error(
31
+ `Unable to determine current libretto version from ${packageJsonPath}.`,
32
+ );
33
+ }
34
+
35
+ cachedCliVersion = manifest.version;
36
+ return cachedCliVersion;
37
+ }
38
+
39
+ function readInstalledSkillVersion(skillPath: string): string | null {
40
+ if (!existsSync(skillPath)) {
41
+ return null;
42
+ }
43
+
44
+ const contents = readFileSync(skillPath, "utf8");
45
+ const frontmatterMatch = contents.match(/^---\r?\n([\s\S]*?)\r?\n---/);
46
+ if (!frontmatterMatch) {
47
+ return null;
48
+ }
49
+
50
+ const metadataBlock = frontmatterMatch[1].match(
51
+ /^metadata:\s*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m,
52
+ )?.[1];
53
+ if (!metadataBlock) {
54
+ return null;
55
+ }
56
+
57
+ const versionMatch = metadataBlock.match(
58
+ /^[ \t]+version:\s*["']?([^"'\r\n]+)["']?\s*$/m,
59
+ );
60
+ return versionMatch?.[1]?.trim() ?? null;
61
+ }
62
+
63
+ function findInstalledSkillVersionMismatch(): {
64
+ installedVersion: string;
65
+ cliVersion: string;
66
+ } | null {
67
+ const cliVersion = readCurrentCliVersion();
68
+
69
+ for (const relativePathParts of INSTALLED_SKILL_PATHS) {
70
+ const skillPath = join(REPO_ROOT, ...relativePathParts);
71
+ const installedVersion = readInstalledSkillVersion(skillPath);
72
+ if (installedVersion && installedVersion !== cliVersion) {
73
+ return { installedVersion, cliVersion };
74
+ }
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ export function warnIfInstalledSkillOutOfDate(): void {
81
+ try {
82
+ const mismatch = findInstalledSkillVersionMismatch();
83
+ if (!mismatch) {
84
+ return;
85
+ }
86
+
87
+ console.error(
88
+ `Warning: Your agent skill (${mismatch.installedVersion}) is out of date with your Libretto CLI (${mismatch.cliVersion}). Please run \`npx libretto setup\` to update your skills to the correct version.`,
89
+ );
90
+ } catch {
91
+ // Never block command execution on a best-effort skill version check.
92
+ }
93
+ }