@k2b/cloud 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -106,8 +106,8 @@
106
106
  "@tabler/icons": "3.48.0",
107
107
  "@tailwindcss/typography": "0.5.20",
108
108
  "@k2b/nessi": "0.18.0",
109
- "@k2b/ssr": "0.16.0",
110
- "@k2b/ui": "0.26.0",
109
+ "@k2b/ssr": "0.17.0",
110
+ "@k2b/ui": "0.27.0",
111
111
  "@k2b/stdlib": "0.29.0",
112
112
  "@k2b/sync": "7.0.0",
113
113
  "@nats-io/jetstream": "3.4.0",
package/scripts/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  - `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
4
4
  - `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
5
5
  - `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
6
+ - `cli-pack.ts` — packs a standalone `cld` module with its verified manifest and skill references: `bun run packages/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>`. Reads the package name and version from the current directory; publish the output with `npm publish <dir>` or archive it with `npm pack <dir>`.
6
7
  - `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
7
8
  - `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
8
9
  - `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Pack a `cld` module as a package plugin, for authors without a Cloud
4
+ * application:
5
+ *
6
+ * bun run node_modules/@k2b/cloud/scripts/cli-pack.ts src/cli.ts --references src/cli-references --out dist/cld-plugin
7
+ *
8
+ * Writes the same verified bundle the application build writes to
9
+ * `dist/cli/<name>/` (`cli.js`, `references/`, `manifest.json`), plus a
10
+ * `package.json` whose `cld.manifest` points at the manifest. Name and version
11
+ * come from `package.json` in the current directory.
12
+ */
13
+ import { lstat, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
14
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { buildCliPlugin, writeCliPlugin } from "../src/_internal/cli-plugins";
17
+ import { validateCloudCliModule } from "../src/cli/plugin";
18
+
19
+ const USAGE = "Usage: bun run node_modules/@k2b/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>";
20
+
21
+ const HELP = `${USAGE}
22
+
23
+ Packs a cld module as a package plugin: the bundled module cli.js, the skill
24
+ references (index.md plus further Markdown files), manifest.json with the
25
+ SHA-512 of every file, and a package.json with "cld": { "manifest": "manifest.json" }.
26
+ Name and version come from package.json in the current directory.
27
+
28
+ --out must be missing, empty, or an earlier pack output, which is replaced.
29
+ Ship the result with \`npm publish <out>\` or \`npm pack <out>\`.`;
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
32
+
33
+ const readJson = (path: string): Promise<unknown> =>
34
+ readFile(path, "utf8")
35
+ .then(JSON.parse)
36
+ .catch(() => null);
37
+
38
+ const parseArgs = (args: string[]) => {
39
+ const [module, ...rest] = args;
40
+ const flags = new Map<string, string>();
41
+ for (let index = 0; index < rest.length; index += 2) {
42
+ const [flag, value] = [rest[index]!, rest[index + 1]];
43
+ if (!["--references", "--out"].includes(flag) || !value || value.startsWith("-") || flags.has(flag)) throw new Error(USAGE);
44
+ flags.set(flag, value);
45
+ }
46
+ const references = flags.get("--references");
47
+ const out = flags.get("--out");
48
+ if (!module || module.startsWith("-") || !references || !out) throw new Error(USAGE);
49
+ return { module: resolve(module), references: resolve(references), out: resolve(out) };
50
+ };
51
+
52
+ const containsPath = (directory: string, path: string): boolean => {
53
+ const inside = relative(directory, path);
54
+ return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
55
+ };
56
+
57
+ /** Empty `out` for a fresh pack; anything but an earlier pack output stays untouched. */
58
+ const prepareOut = async (out: string, inputs: string[]): Promise<void> => {
59
+ const refusal = `Refusing --out ${out}: it must be missing, empty, or an earlier pack output.`;
60
+ const output = await realpath(out).catch(() => out);
61
+ for (const input of inputs) {
62
+ const path = await realpath(input).catch(() => input);
63
+ if (containsPath(out, input) || containsPath(output, path)) throw new Error(refusal);
64
+ }
65
+ const info = await lstat(out).catch(() => null);
66
+ if (!info) return;
67
+ if (!info.isDirectory()) throw new Error(refusal);
68
+ const entries = await readdir(out);
69
+ if (entries.length === 0) return;
70
+ const previous = await readJson(join(out, "package.json"));
71
+ if (
72
+ entries.some((entry) => !["package.json", "manifest.json", "cli.js", "references"].includes(entry)) ||
73
+ !isRecord(previous) ||
74
+ !isRecord(previous.cld) ||
75
+ previous.cld.manifest !== "manifest.json"
76
+ )
77
+ throw new Error(refusal);
78
+ await rm(out, { recursive: true });
79
+ };
80
+
81
+ const pack = async (args: string[]): Promise<void> => {
82
+ if (args.includes("--help") || args.includes("-h")) {
83
+ console.log(HELP);
84
+ return;
85
+ }
86
+ const options = parseArgs(args);
87
+ const cwd = process.cwd();
88
+ const manifest = await readJson(join(cwd, "package.json"));
89
+ if (
90
+ !isRecord(manifest) ||
91
+ typeof manifest.name !== "string" ||
92
+ !manifest.name ||
93
+ typeof manifest.version !== "string" ||
94
+ !manifest.version
95
+ ) {
96
+ throw new Error(`${join(cwd, "package.json")} needs a string name and version.`);
97
+ }
98
+ const { name: packageName, version } = manifest;
99
+ await prepareOut(options.out, [cwd, options.module, options.references]);
100
+ const loaded: unknown = await import(pathToFileURL(options.module).href);
101
+ const module = validateCloudCliModule(isRecord(loaded) ? loaded.default : undefined);
102
+ const plugin = await buildCliPlugin({
103
+ appDir: cwd,
104
+ appId: packageName,
105
+ name: module.name,
106
+ declaration: { module: options.module, references: options.references },
107
+ version,
108
+ });
109
+ await writeCliPlugin(options.out, plugin);
110
+ await writeFile(
111
+ join(options.out, "package.json"),
112
+ `${JSON.stringify({ name: packageName, version, type: "module", cld: { manifest: "manifest.json" } }, null, 2)}\n`,
113
+ );
114
+ const references = plugin.manifest.files.filter((file) => file.path.startsWith("references/")).length;
115
+ console.log(
116
+ `Packed ${packageName}@${version} (module "${module.name}", ${references} reference files) to ${options.out}. Ship it with \`npm publish ${options.out}\` or \`npm pack ${options.out}\`.`,
117
+ );
118
+ };
119
+
120
+ pack(Bun.argv.slice(2)).catch((error: unknown) => {
121
+ console.error(error instanceof Error ? error.message.replace(/\s*\n\s*/g, " ") : String(error));
122
+ process.exitCode = 1;
123
+ });
@@ -0,0 +1,11 @@
1
+ export const SHELL_FOCUS_COOKIE = "cloud_shell_focus";
2
+
3
+ export const readShellFocusCookie = (cookieHeader: string | null | undefined): boolean =>
4
+ cookieHeader
5
+ ?.split(";")
6
+ .map((part) => part.trim())
7
+ .find((part) => part.startsWith(`${SHELL_FOCUS_COOKIE}=`))
8
+ ?.slice(SHELL_FOCUS_COOKIE.length + 1) === "1";
9
+
10
+ export const shellFocusCookie = (hidden: boolean, secure: boolean): string =>
11
+ `${SHELL_FOCUS_COOKIE}=${hidden ? "1" : ""}; Path=/; Max-Age=${hidden ? 31536000 : 0}; SameSite=Lax${secure ? "; Secure" : ""}`;
@@ -0,0 +1,46 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import PermissionEditor from "./PermissionEditor";
4
+
5
+ const host = document.getElementById("root");
6
+ if (!host) throw new Error("Missing harness root");
7
+ render(
8
+ () => (
9
+ <LocaleProvider locale="en">
10
+ <PermissionEditor
11
+ initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
12
+ id: `access-${index}`,
13
+ principal: { type: "user", userId: `user-${index}` },
14
+ displayName,
15
+ permission: "read",
16
+ createdAt: "2026-10-15T07:00:00.000Z",
17
+ }))}
18
+ // Equal-width labels isolate pending-state layout from a normal change in label width.
19
+ allowedLevels={[
20
+ { level: "read", label: "Level 1" },
21
+ { level: "write", label: "Level 2" },
22
+ ]}
23
+ allowAuthenticated={false}
24
+ grantAccess={async () => {
25
+ throw new Error("Not used");
26
+ }}
27
+ updateAccess={() =>
28
+ new Promise<void>((resolve) => {
29
+ // The test waits for this mark, so it does not depend on how the editor shows a pending change.
30
+ document.documentElement.dataset.saving = "true";
31
+ document.addEventListener(
32
+ "settle-access",
33
+ () => {
34
+ delete document.documentElement.dataset.saving;
35
+ resolve();
36
+ },
37
+ { once: true },
38
+ );
39
+ })
40
+ }
41
+ revokeAccess={async () => {}}
42
+ />
43
+ </LocaleProvider>
44
+ ),
45
+ host,
46
+ );
@@ -236,8 +236,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
236
236
  };
237
237
 
238
238
  type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
239
+ // The row whose change is saving: it shows the chosen level with a spinner, the other rows wait.
240
+ const [updating, setUpdating] = createSignal<LevelChange | null>(null);
241
+ const [revoking, setRevoking] = createSignal<string | null>(null);
242
+ const updatingRow = (accessId: string) => {
243
+ const change = updating();
244
+ return change?.accessId === accessId ? change : undefined;
245
+ };
239
246
  const updateMut = mutation.create<LevelChange, LevelChange>({
240
247
  mutation: async (data) => {
248
+ setUpdating(data);
241
249
  await props.updateAccess(data.accessId, data.permission, data.scope);
242
250
  return data;
243
251
  },
@@ -247,6 +255,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
247
255
  }
248
256
  },
249
257
  onError: (err) => prompts.error(err.message),
258
+ onFinally: () => {
259
+ if (!disposed) setUpdating(null);
260
+ },
250
261
  });
251
262
 
252
263
  const revokeMut = mutation.create<string | null, AccessEntry>({
@@ -257,6 +268,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
257
268
  variant: "danger",
258
269
  });
259
270
  if (!confirmed) return null;
271
+ setRevoking(entry.id);
260
272
  await props.revokeAccess(entry.id);
261
273
  return entry.id;
262
274
  },
@@ -264,6 +276,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
264
276
  if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
265
277
  },
266
278
  onError: (err) => prompts.error(err.message),
279
+ onFinally: () => {
280
+ if (!disposed) setRevoking(null);
281
+ },
267
282
  });
268
283
  const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
269
284
  // The service refuses to remove the last manager; the row says so up front.
@@ -288,6 +303,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
288
303
  coverage={coverage}
289
304
  canEdit={canEdit()}
290
305
  pending={entry.id === pending()}
306
+ updating={updatingRow(entry.id)}
307
+ revoking={entry.id === revoking()}
291
308
  disabled={busy()}
292
309
  lastManager={entry.id === lastManagerId()}
293
310
  allowed={allowed(entry.principal)}
@@ -332,8 +349,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
332
349
  function AccessEntryRow(props: {
333
350
  entry: AccessEntry;
334
351
  canEdit: boolean;
352
+ /** Another change is saving: the row keeps its controls, disabled. */
335
353
  disabled: boolean;
354
+ /** This row's grant is saving. */
336
355
  pending?: boolean;
356
+ /** This row's level change is saving. */
357
+ updating?: { permission: GrantableLevel; scope: string | undefined };
358
+ /** This row's removal is saving. */
359
+ revoking?: boolean;
337
360
  /** The only entry that manages the resource: it keeps its level and cannot be removed. */
338
361
  lastManager: boolean;
339
362
  allowed: ResolvedLevel[];
@@ -352,8 +375,14 @@ function AccessEntryRow(props: {
352
375
  const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
353
376
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
354
377
  const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
355
- const isInteractive = () =>
356
- props.canEdit && !props.disabled && !props.pending && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
378
+ // Whether the row offers a picker depends only on what the viewer may do, never on a change in flight, so a
379
+ // saving change never swaps the picker for the badge and the rows keep their size.
380
+ const hasPicker = () => props.canEdit && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
381
+ const saving = () => props.pending || props.updating !== undefined || props.revoking === true;
382
+ // While a level change saves, the picker already shows the chosen level.
383
+ const shownKey = () => (props.updating ? levelKey(props.updating.permission, props.updating.scope) : entryKey());
384
+ const shownDisplay = () =>
385
+ props.updating ? resolveEntryDisplay(props.updating.permission, props.updating.scope, props.allowed, t()) : display();
357
386
 
358
387
  const badgeClass =
359
388
  "flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
@@ -362,15 +391,12 @@ function AccessEntryRow(props: {
362
391
  <>
363
392
  <i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
364
393
  <span>{display().label}</span>
365
- <Show when={isInteractive()}>
366
- <i class="ti ti-chevron-down text-[10px]" />
367
- </Show>
368
394
  </>
369
395
  );
370
396
 
371
397
  return (
372
398
  <div>
373
- <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={props.pending ? "true" : undefined}>
399
+ <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={saving() ? "true" : undefined}>
374
400
  <Show
375
401
  when={props.entry.principal.type === "user"}
376
402
  fallback={
@@ -415,10 +441,12 @@ function AccessEntryRow(props: {
415
441
 
416
442
  {/* Permission badge — interactive single-value picker when editable,
417
443
  plain span otherwise. */}
418
- <Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
444
+ <Show when={hasPicker()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
419
445
  <SelectChip
420
446
  aria-label={t().permissionFor({ name: displayName() })}
421
- value={entryKey}
447
+ value={shownKey}
448
+ disabled={props.disabled}
449
+ loading={props.pending || props.updating !== undefined}
422
450
  options={props.allowed.map((option) => {
423
451
  const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
424
452
  return {
@@ -429,7 +457,7 @@ function AccessEntryRow(props: {
429
457
  description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
430
458
  };
431
459
  })}
432
- icon={`ti ${display().icon}`}
460
+ icon={`ti ${shownDisplay().icon}`}
433
461
  position="bottom-left"
434
462
  onValueChange={(key) => {
435
463
  const option = props.allowed.find((candidate) => candidate.key === key);
@@ -452,6 +480,7 @@ function AccessEntryRow(props: {
452
480
  size="xs"
453
481
  onClick={props.onRevoke}
454
482
  disabled={props.disabled || props.pending || props.lastManager}
483
+ loading={props.revoking}
455
484
  aria-label={t().remove({ name: displayName() })}
456
485
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
457
486
  aria-description={props.lastManager ? removeLabel() : undefined}
package/src/access/ui.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * from depending on Cloud's broad legacy UI entrypoint.
7
7
  */
8
8
 
9
+ export { isManagerEntry, type ManagerCandidate } from "./managers";
9
10
  export type { AllowedLevel, GrantableLevel } from "./PermissionEditor";
10
11
  export { default as PermissionEditor } from "./PermissionEditor";
11
12
  export { default as PrincipalPicker, principalKey } from "./PrincipalPicker";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Root scheme-less Cloud resource links in sanitized Assistant answer HTML.
3
+ * Normalized links stay root-relative under /app/ or /open/ and can never change origin.
4
+ */
5
+ export function rootResourceLinks(html: string): string {
6
+ // sanitize-html emits double-quoted attributes with ", <, and > escaped in values.
7
+ return html.replace(/<a\b[^>]*>/g, (tag) => tag.replace(/(\shref=")((?:app|open)\/)/g, "$1/$2"));
8
+ }
@@ -34,6 +34,7 @@ const messages = i18n.define({
34
34
  turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
35
35
  if (code === "model_unavailable") return "The model service did not answer.";
36
36
  if (code === "provider_stopped") return "The model provider ended this answer early.";
37
+ if (code === "tool_failed") return "A step failed, and the model stopped without an answer.";
37
38
  if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
38
39
  if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
39
40
  if (code === "time_limit")
@@ -336,6 +337,7 @@ const messages = i18n.define({
336
337
  turnErrorReason: ({ code, limitMinutes }) => {
337
338
  if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
338
339
  if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
340
+ if (code === "tool_failed") return "Ein Schritt ist fehlgeschlagen, und das Modell hat danach nicht geantwortet.";
339
341
  if (code === "quota_exhausted")
340
342
  return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
341
343
  if (code === "context_full")
@@ -1,4 +1,5 @@
1
1
  import { createEffect, onCleanup, onMount } from "solid-js";
2
+ import { rootResourceLinks } from "./answer-links";
2
3
  import { useAiChatActions } from "./message-actions";
3
4
 
4
5
  export function AssistantMarkdownBlock(props: { html: string }) {
@@ -41,5 +42,5 @@ export function AssistantMarkdownBlock(props: { html: string }) {
41
42
  element.addEventListener("click", handleClick);
42
43
  onCleanup(() => element.removeEventListener("click", handleClick));
43
44
  });
44
- return <div ref={element} class="assistant-markdown-block" innerHTML={props.html} />;
45
+ return <div ref={element} class="assistant-markdown-block" innerHTML={rootResourceLinks(props.html)} />;
45
46
  }
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
23
23
  import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
24
24
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
25
25
  import { aiChatMessages } from "./chat/messages";
26
+ import { isFailedTool } from "./chat/tool-groups";
26
27
  import { aiTurnErrorText } from "./chat/turn-error";
27
28
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
28
29
  import { createCloudCompactFn } from "./compaction";
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
134
135
  };
135
136
  };
136
137
 
138
+ const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
139
+ const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
140
+ if (latest?.role !== "assistant") return false;
141
+ const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
142
+ // A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
143
+ return (
144
+ [...callIds].some(isMalformedToolCallId) ||
145
+ blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
146
+ );
147
+ };
148
+
137
149
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
138
150
  try {
139
151
  await aiConversations.indexConversationResources(input);
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
1500
1512
  const outcome = await this.driveChatLoop({
1501
1513
  loop,
1502
1514
  pipeline,
1515
+ initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
1503
1516
  conversationId,
1504
1517
  turnId,
1505
1518
  abortController,
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
1572
1585
  private async driveChatLoop(input: {
1573
1586
  loop: NessiLoop;
1574
1587
  pipeline: StreamPipeline;
1588
+ initialRoundFailed: boolean;
1575
1589
  conversationId: string;
1576
1590
  turnId: string;
1577
1591
  abortController: AbortController;
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
1613
1627
  } = input;
1614
1628
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1615
1629
  let lastIssueMessage: string | null = null;
1630
+ let lastResponse: AssistantMessage | undefined;
1631
+ let roundFailed = input.initialRoundFailed;
1632
+ let followedFailedRound = false;
1633
+ const malformedCallIds = new Set<string>();
1616
1634
 
1617
1635
  try {
1618
1636
  for await (const event of loop) {
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
1672
1690
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1673
1691
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1674
1692
  const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
1693
+ if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
1675
1694
  // The policy keys calls by the name the model called, as the persisted calls it seeds from.
1676
1695
  noteToolCall(
1677
1696
  toolBlock?.kind === "tool"
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
1687
1706
  result: event.result,
1688
1707
  isError: event.isError === true,
1689
1708
  });
1690
- } else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
1691
- noteToolRound();
1709
+ } else if (event.type === "turn_end") {
1710
+ lastResponse = event.message;
1711
+ followedFailedRound = roundFailed;
1712
+ const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
1713
+ // nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
1714
+ // but are added by Cloud's store wrapper, so they are absent from the event's message.
1715
+ roundFailed = pipeline.blocks.some(
1716
+ (block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
1717
+ );
1718
+ malformedCallIds.clear();
1719
+ if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
1692
1720
  } else if (event.type === "issue") {
1721
+ if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
1693
1722
  lastIssueMessage = event.issue.message;
1694
1723
  log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
1695
1724
  } else if (event.type === "loop_end") {
1725
+ const stoppedAfterFailure =
1726
+ event.reason === "stop" &&
1727
+ !abortController.signal.aborted &&
1728
+ followedFailedRound &&
1729
+ lastResponse !== undefined &&
1730
+ !lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
1731
+ const doneReason = stoppedAfterFailure ? "error" : event.reason;
1696
1732
  const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
1697
1733
  if (aggregate.assistantMessageCount > 0) {
1698
1734
  await aiConversations
1699
- .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason: event.reason })
1735
+ .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
1700
1736
  .catch(() => undefined);
1701
1737
  }
1702
1738
  const timing = aggregate.timing;
1703
1739
  if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1740
+ if (stoppedAfterFailure)
1741
+ return {
1742
+ kind: "finished",
1743
+ status: "failed",
1744
+ failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
1745
+ timing,
1746
+ };
1704
1747
  if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1705
1748
  const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1706
1749
  const reason: AiTurnFailureReason =
package/src/ai/types.ts CHANGED
@@ -337,6 +337,7 @@ export type AiConversationTimelineEntry = {
337
337
  export type AiTurnErrorCode =
338
338
  | "model_unavailable"
339
339
  | "provider_stopped"
340
+ | "tool_failed"
340
341
  | "quota_exhausted"
341
342
  | "context_full"
342
343
  | "time_limit"
@@ -0,0 +1,67 @@
1
+ import { shellFocusCookie } from "../_internal/shell-focus";
2
+
3
+ export const SHELL_FOCUS_CHANGE_EVENT = "cloud:shell-focus-change";
4
+
5
+ const canvas = () => document.querySelector<HTMLElement>(".cloud-app-canvas[data-layout-authenticated]");
6
+
7
+ export const readShellFocus = (): boolean => canvas()?.dataset.shellFocus === "true";
8
+
9
+ export const setShellFocus = (hidden: boolean): void => {
10
+ const element = canvas();
11
+ if (!element) return;
12
+ const focused = document.activeElement;
13
+ document.cookie = shellFocusCookie(hidden, location.protocol === "https:");
14
+ if (hidden) element.dataset.shellFocus = "true";
15
+ else delete element.dataset.shellFocus;
16
+ window.dispatchEvent(new CustomEvent(SHELL_FOCUS_CHANGE_EVENT, { detail: { hidden } }));
17
+ if (
18
+ window.matchMedia("(min-width: 768px)").matches &&
19
+ (!focused ||
20
+ focused === document.body ||
21
+ focused.matches("[data-cloud-shell-focus-toggle]") ||
22
+ !focused.isConnected ||
23
+ focused.getClientRects().length === 0)
24
+ ) {
25
+ element.querySelector<HTMLElement>(`[data-cloud-shell-focus-toggle="${hidden ? "show" : "hide"}"]`)?.focus({ preventScroll: true });
26
+ }
27
+ };
28
+
29
+ export const toggleShellFocus = (): void => setShellFocus(!readShellFocus());
30
+
31
+ /** Delegation also handles the server's buttons before their islands hydrate. */
32
+ export const attachShellFocusControls = (): (() => void) => {
33
+ const click = (event: MouseEvent) => {
34
+ if (!(event.target instanceof Element)) return;
35
+ const button = event.target.closest<HTMLElement>("[data-cloud-shell-focus-toggle]");
36
+ if (button) setShellFocus(button.dataset.cloudShellFocusToggle === "hide");
37
+ };
38
+ let drag: { pointerId: number; x: number; y: number } | undefined;
39
+ const start = (event: PointerEvent) => {
40
+ if (event.pointerType === "mouse" || !(event.target instanceof Element) || !event.target.closest("[data-cloud-shell-reveal]")) return;
41
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
42
+ };
43
+ const move = (event: PointerEvent) => {
44
+ if (!drag || event.pointerId !== drag.pointerId) return;
45
+ const dx = event.clientX - drag.x;
46
+ if (dx >= 40 && dx > Math.abs(event.clientY - drag.y)) {
47
+ drag = undefined;
48
+ setShellFocus(false);
49
+ }
50
+ };
51
+ const stop = (event: PointerEvent) => {
52
+ if (event.pointerId === drag?.pointerId) drag = undefined;
53
+ };
54
+ document.addEventListener("click", click);
55
+ document.addEventListener("pointerdown", start);
56
+ window.addEventListener("pointermove", move);
57
+ window.addEventListener("pointerup", stop);
58
+ window.addEventListener("pointercancel", stop);
59
+ return () => {
60
+ document.removeEventListener("click", click);
61
+ document.removeEventListener("pointerdown", start);
62
+ window.removeEventListener("pointermove", move);
63
+ window.removeEventListener("pointerup", stop);
64
+ window.removeEventListener("pointercancel", stop);
65
+ drag = undefined;
66
+ };
67
+ };
@@ -244,6 +244,8 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
244
244
 
245
245
  export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
246
246
  const root = buildCommandTree(config.commands, config.groupSummaries);
247
+ // `cld <module> reference` prints the module's skill references instead.
248
+ if (root.children.has("reference")) throw new Error(`CLI module "${config.name}" must not declare the reserved command "reference"`);
247
249
  if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
248
250
  throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
249
251
  }
package/src/cli/plugin.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `cld` plugin contract shared by the applications that serve plugins and by
3
- * `cld`, which installs and loads them.
2
+ * Verified `cld` bundle contract shared by application-served plugins,
3
+ * package plugins, and `cld`, which installs and loads them.
4
4
  *
5
5
  * An application serves each declared CLI module under
6
6
  * `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
@@ -34,9 +34,9 @@ export type CloudCliPluginManifest = {
34
34
  apiVersion: number;
35
35
  /** Module name and plugin ID. */
36
36
  name: string;
37
- /** ID of the application that serves the plugin. */
37
+ /** ID of the serving application, or name of the publishing package. */
38
38
  app: string;
39
- /** Version of the serving application build. */
39
+ /** Application build or package version. */
40
40
  version: string;
41
41
  entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
42
42
  /** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
@@ -88,7 +88,7 @@ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string
88
88
  .every((segment) => !segment.startsWith("-")),
89
89
  );
90
90
 
91
- /** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
91
+ /** Validate a bundle manifest, including its digest. Throws an `Error` naming the first problem. */
92
92
  export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
93
93
  if (!isRecord(value)) throw new Error("plugin manifest is not an object");
94
94
  const { apiVersion, name, app, version, entry, digest, files } = value;
@@ -221,6 +221,7 @@ export type {
221
221
 
222
222
  export {
223
223
  NetworkTargetBlockedError,
224
+ isNetworkTargetBlocked,
224
225
  pinnedNetworkLookup,
225
226
  resolvePublicNetworkAddresses,
226
227
  resolveAllowedNetworkAddresses,