@immediately-run/sdk 0.45.2 → 0.46.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 (59) hide show
  1. package/dist/hostAttention.cjs +57 -0
  2. package/dist/hostAttention.cjs.map +1 -0
  3. package/dist/hostAttention.d.cts +39 -0
  4. package/dist/hostAttention.d.ts +39 -0
  5. package/dist/hostAttention.js +31 -0
  6. package/dist/hostAttention.js.map +1 -0
  7. package/dist/hostTransport.cjs +55 -0
  8. package/dist/hostTransport.cjs.map +1 -0
  9. package/dist/hostTransport.d.cts +12 -0
  10. package/dist/hostTransport.d.ts +12 -0
  11. package/dist/hostTransport.js +30 -0
  12. package/dist/hostTransport.js.map +1 -0
  13. package/dist/index.cjs +4 -0
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +3 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/linkSpace.cjs +11 -2
  20. package/dist/linkSpace.cjs.map +1 -1
  21. package/dist/linkSpace.d.cts +23 -0
  22. package/dist/linkSpace.d.ts +23 -0
  23. package/dist/linkSpace.js +11 -2
  24. package/dist/linkSpace.js.map +1 -1
  25. package/dist/llm.cjs +18 -3
  26. package/dist/llm.cjs.map +1 -1
  27. package/dist/llm.d.cts +38 -3
  28. package/dist/llm.d.ts +38 -3
  29. package/dist/llm.js +14 -2
  30. package/dist/llm.js.map +1 -1
  31. package/dist/protocolDeadline.cjs +205 -0
  32. package/dist/protocolDeadline.cjs.map +1 -0
  33. package/dist/protocolDeadline.d.cts +146 -0
  34. package/dist/protocolDeadline.d.ts +146 -0
  35. package/dist/protocolDeadline.js +168 -0
  36. package/dist/protocolDeadline.js.map +1 -0
  37. package/dist/protocolStream.cjs +60 -6
  38. package/dist/protocolStream.cjs.map +1 -1
  39. package/dist/protocolStream.d.cts +9 -2
  40. package/dist/protocolStream.d.ts +9 -2
  41. package/dist/protocolStream.js +67 -6
  42. package/dist/protocolStream.js.map +1 -1
  43. package/dist/pushChannel.cjs +13 -9
  44. package/dist/pushChannel.cjs.map +1 -1
  45. package/dist/pushChannel.js +12 -8
  46. package/dist/pushChannel.js.map +1 -1
  47. package/dist/sandboxUtils.cjs +74 -24
  48. package/dist/sandboxUtils.cjs.map +1 -1
  49. package/dist/sandboxUtils.d.cts +37 -4
  50. package/dist/sandboxUtils.d.ts +37 -4
  51. package/dist/sandboxUtils.js +79 -22
  52. package/dist/sandboxUtils.js.map +1 -1
  53. package/dist/version.cjs +1 -1
  54. package/dist/version.cjs.map +1 -1
  55. package/dist/version.d.cts +1 -1
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/dist/version.js.map +1 -1
  59. package/package.json +2 -2
@@ -5,6 +5,26 @@ interface LinkSpace {
5
5
  /** Absolute filesystem path of the enclosing corpus's root (e.g. `/app/content`),
6
6
  * or `null` when the document is not corpus-hosted (default). */
7
7
  corpusRoot: string | null;
8
+ /**
9
+ * True when the filesystem this document resolves against is **chroot'd to the
10
+ * bundle** — i.e. the port the app holds was scoped to the bundle's subtree, so
11
+ * the mount root and the bundle root are the same directory
12
+ * (`BUNDLE_LAYERS_SPEC §9`; the `T2`/`T4` wrapper, R3-319 / BL-2).
13
+ *
14
+ * Under that grant `$fs:` **collapses to the scoped root**: `$fs:/p` and `/p`
15
+ * name the same byte, because there is no longer any "mount-absolute" space
16
+ * outside the bundle for `$fs:` to reach into. Without this flag the resolver
17
+ * would hand back a mount-absolute path that the chroot then re-roots anyway —
18
+ * a link that renders as valid and resolves somewhere the author did not mean.
19
+ *
20
+ * **This is an invariant to CREATE, not one to inherit** (`BUNDLE_LAYERS_SPEC
21
+ * §11`): the shipped resolver reads `{currentFile, corpusRoot}` and nothing
22
+ * else, so `$fs:` is bundle-anchored only if something says so. It lives here,
23
+ * in the resolver, rather than as a rule each caller applies by passing
24
+ * `corpusRoot: '/'` — an invariant the arithmetic carries cannot be forgotten
25
+ * at one call site out of five.
26
+ */
27
+ bundleChrooted?: boolean;
8
28
  }
9
29
  /** Ambient link space. A corpus-rendering app wraps its document tree in
10
30
  * `<LinkSpaceContext value={{ corpusRoot }}>`; nesting a second provider inside a
@@ -39,6 +59,9 @@ type ResolvedLinkTarget =
39
59
  declare function resolveLinkTarget(raw: string, opts?: {
40
60
  currentFile?: string;
41
61
  corpusRoot?: string | null;
62
+ /** See `LinkSpace.bundleChrooted`. Under a bundle-chroot'd grant `$fs:`
63
+ * resolves in the corpus space, because they are the same space. */
64
+ bundleChrooted?: boolean;
42
65
  }): ResolvedLinkTarget;
43
66
 
44
67
  export { FS_PREFIX, type LinkSpace, LinkSpaceContext, type ResolvedLinkTarget, normalizeAbsolute, resolveLinkTarget };
package/dist/linkSpace.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import "./chunk-VHAA22YE.js";
2
2
  import { createContext } from "react";
3
3
  const FS_PREFIX = "$fs:";
4
- const LinkSpaceContext = createContext({ corpusRoot: null });
4
+ const LinkSpaceContext = createContext({
5
+ corpusRoot: null,
6
+ bundleChrooted: false
7
+ });
5
8
  const normalizeAbsolute = (path) => {
6
9
  const out = [];
7
10
  for (const seg of path.split("/")) {
@@ -11,16 +14,22 @@ const normalizeAbsolute = (path) => {
11
14
  }
12
15
  return "/" + out.join("/");
13
16
  };
17
+ function resolveCorpusAbsolute(path, corpusRoot) {
18
+ const inner = normalizeAbsolute(path);
19
+ if (corpusRoot === null || corpusRoot === "/") return { state: "resolved", path: inner };
20
+ return { state: "resolved", path: normalizeAbsolute(corpusRoot + inner) };
21
+ }
14
22
  function resolveLinkTarget(raw, opts = {}) {
15
23
  if (raw.startsWith(FS_PREFIX)) {
16
24
  const rest = raw.slice(FS_PREFIX.length);
17
25
  if (!rest.startsWith("/")) return { state: "invalid" };
26
+ if (opts.bundleChrooted) return resolveCorpusAbsolute(rest, opts.corpusRoot ?? null);
18
27
  return { state: "resolved", path: normalizeAbsolute(rest) };
19
28
  }
20
29
  if (raw.startsWith("/")) {
21
30
  const corpusRoot = opts.corpusRoot ?? null;
22
31
  if (corpusRoot !== null) {
23
- return { state: "resolved", path: normalizeAbsolute(corpusRoot + normalizeAbsolute(raw)) };
32
+ return resolveCorpusAbsolute(raw, corpusRoot);
24
33
  }
25
34
  return { state: "resolved", path: normalizeAbsolute(raw) };
26
35
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/linkSpace.ts"],"sourcesContent":["// Link path spaces (R3-273; REPO_CONTENT_DISPATCH_SPEC §9 decision, 2026-08-17).\n//\n// A document link's target resolves in one of two spaces:\n//\n// - DEFAULT — the enclosing corpus's virtual filesystem. RELATIVE targets resolve\n// against the authoring file (identical in both spaces); ABSOLUTE targets\n// (`/x/y.mdx`) resolve from the corpus root when an enclosing `LinkSpaceContext`\n// declares one, else from the filesystem root. A non-corpus app declares nothing\n// and keeps today's behavior bit-for-bit (its fs root IS its only root).\n//\n// - `$fs:` — the explicit filesystem space: `$fs:/content/x.mdx` resolves from the\n// root of the filesystem the app reads, escaping corpus-relative addressing.\n//\n// `$fs:` changes ADDRESSING, never REACH: resolution is pure path arithmetic and\n// existence is checked against the same in-mount metadata the default space uses —\n// nothing is fetched, and a link can never name what the app cannot already read.\n// A malformed `$fs:` target (anything not mount-absolute — which also catches\n// scheme smuggling like `$fs:javascript:…`) is INVALID and must render as a broken\n// link, never an anchor.\n//\n// Corpus nesting: `LinkSpaceContext` providers nest, and the NEAREST one wins —\n// which is exactly the innermost-enclosing-corpus rule (bundle encapsulation): a\n// document rendered inside a nested corpus resolves against the nested corpus.\n\nimport { createContext } from 'react';\n\nexport const FS_PREFIX = '$fs:';\n\nexport interface LinkSpace {\n /** Absolute filesystem path of the enclosing corpus's root (e.g. `/app/content`),\n * or `null` when the document is not corpus-hosted (default). */\n corpusRoot: string | null;\n}\n\n/** Ambient link space. A corpus-rendering app wraps its document tree in\n * `<LinkSpaceContext value={{ corpusRoot }}>`; nesting a second provider inside a\n * rendered sub-corpus makes the innermost root win. */\nexport const LinkSpaceContext = createContext<LinkSpace>({ corpusRoot: null });\n\n/** Collapse `.`/`..`/empty segments into a clean absolute path. `..` can never\n * climb above the root — a (virtual) root's parent is itself, which is what keeps\n * both the mount space and the corpus space closed under traversal. */\nexport const normalizeAbsolute = (path: string): string => {\n const out: string[] = [];\n for (const seg of path.split('/')) {\n if (seg === '' || seg === '.') continue;\n if (seg === '..') out.pop();\n else out.push(seg);\n }\n return '/' + out.join('/');\n};\n\nexport type ResolvedLinkTarget =\n /** Resolved to an absolute filesystem path (existence NOT checked here). */\n | { state: 'resolved'; path: string }\n /** A relative target with no known authoring file — the caller may route\n * optimistically (it cannot check existence or self-ness generically). */\n | { state: 'unresolvable' }\n /** A malformed `$fs:` target (not mount-absolute; includes scheme smuggling).\n * Callers MUST render this broken/inert — never as an anchor. */\n | { state: 'invalid' };\n\n/**\n * Resolve a raw link target (a wikilink target or an in-app href's path half —\n * fragment already split off) to an absolute filesystem path. THE shared resolver:\n * the default `WikiLink`, the markdown `a` override, and safe-content consumers\n * all route through this one function so the two render pipelines cannot drift.\n */\nexport function resolveLinkTarget(\n raw: string,\n opts: { currentFile?: string; corpusRoot?: string | null } = {},\n): ResolvedLinkTarget {\n if (raw.startsWith(FS_PREFIX)) {\n const rest = raw.slice(FS_PREFIX.length);\n // Must be mount-absolute. This single rule also fails `$fs:javascript:…`,\n // `$fs:https://…`, and every other smuggled scheme closed.\n if (!rest.startsWith('/')) return { state: 'invalid' };\n return { state: 'resolved', path: normalizeAbsolute(rest) };\n }\n if (raw.startsWith('/')) {\n const corpusRoot = opts.corpusRoot ?? null;\n if (corpusRoot !== null) {\n // Clamp the corpus-relative half FIRST (the virtual FS is closed — `/../x`\n // stays inside the corpus), THEN anchor it at the corpus root.\n return { state: 'resolved', path: normalizeAbsolute(corpusRoot + normalizeAbsolute(raw)) };\n }\n return { state: 'resolved', path: normalizeAbsolute(raw) };\n }\n // Relative: against the authoring file's directory — the same in both spaces.\n if (!opts.currentFile) return { state: 'unresolvable' };\n const dir = opts.currentFile.slice(0, opts.currentFile.lastIndexOf('/'));\n return { state: 'resolved', path: normalizeAbsolute(`${dir}/${raw}`) };\n}\n"],"mappings":";AAwBA,SAAS,qBAAqB;AAEvB,MAAM,YAAY;AAWlB,MAAM,mBAAmB,cAAyB,EAAE,YAAY,KAAK,CAAC;AAKtE,MAAM,oBAAoB,CAAC,SAAyB;AACzD,QAAM,MAAgB,CAAC;AACvB,aAAW,OAAO,KAAK,MAAM,GAAG,GAAG;AACjC,QAAI,QAAQ,MAAM,QAAQ,IAAK;AAC/B,QAAI,QAAQ,KAAM,KAAI,IAAI;AAAA,QACrB,KAAI,KAAK,GAAG;AAAA,EACnB;AACA,SAAO,MAAM,IAAI,KAAK,GAAG;AAC3B;AAkBO,SAAS,kBACd,KACA,OAA6D,CAAC,GAC1C;AACpB,MAAI,IAAI,WAAW,SAAS,GAAG;AAC7B,UAAM,OAAO,IAAI,MAAM,UAAU,MAAM;AAGvC,QAAI,CAAC,KAAK,WAAW,GAAG,EAAG,QAAO,EAAE,OAAO,UAAU;AACrD,WAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,IAAI,EAAE;AAAA,EAC5D;AACA,MAAI,IAAI,WAAW,GAAG,GAAG;AACvB,UAAM,aAAa,KAAK,cAAc;AACtC,QAAI,eAAe,MAAM;AAGvB,aAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,aAAa,kBAAkB,GAAG,CAAC,EAAE;AAAA,IAC3F;AACA,WAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,GAAG,EAAE;AAAA,EAC3D;AAEA,MAAI,CAAC,KAAK,YAAa,QAAO,EAAE,OAAO,eAAe;AACtD,QAAM,MAAM,KAAK,YAAY,MAAM,GAAG,KAAK,YAAY,YAAY,GAAG,CAAC;AACvE,SAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,GAAG,GAAG,IAAI,GAAG,EAAE,EAAE;AACvE;","names":[]}
1
+ {"version":3,"sources":["../src/linkSpace.ts"],"sourcesContent":["// Link path spaces (R3-273; REPO_CONTENT_DISPATCH_SPEC §9 decision, 2026-08-17).\n//\n// A document link's target resolves in one of two spaces:\n//\n// - DEFAULT — the enclosing corpus's virtual filesystem. RELATIVE targets resolve\n// against the authoring file (identical in both spaces); ABSOLUTE targets\n// (`/x/y.mdx`) resolve from the corpus root when an enclosing `LinkSpaceContext`\n// declares one, else from the filesystem root. A non-corpus app declares nothing\n// and keeps today's behavior bit-for-bit (its fs root IS its only root).\n//\n// - `$fs:` — the explicit filesystem space: `$fs:/content/x.mdx` resolves from the\n// root of the filesystem the app reads, escaping corpus-relative addressing.\n//\n// `$fs:` changes ADDRESSING, never REACH: resolution is pure path arithmetic and\n// existence is checked against the same in-mount metadata the default space uses —\n// nothing is fetched, and a link can never name what the app cannot already read.\n// A malformed `$fs:` target (anything not mount-absolute — which also catches\n// scheme smuggling like `$fs:javascript:…`) is INVALID and must render as a broken\n// link, never an anchor.\n//\n// Corpus nesting: `LinkSpaceContext` providers nest, and the NEAREST one wins —\n// which is exactly the innermost-enclosing-corpus rule (bundle encapsulation): a\n// document rendered inside a nested corpus resolves against the nested corpus.\n\nimport { createContext } from 'react';\n\nexport const FS_PREFIX = '$fs:';\n\nexport interface LinkSpace {\n /** Absolute filesystem path of the enclosing corpus's root (e.g. `/app/content`),\n * or `null` when the document is not corpus-hosted (default). */\n corpusRoot: string | null;\n /**\n * True when the filesystem this document resolves against is **chroot'd to the\n * bundle** — i.e. the port the app holds was scoped to the bundle's subtree, so\n * the mount root and the bundle root are the same directory\n * (`BUNDLE_LAYERS_SPEC §9`; the `T2`/`T4` wrapper, R3-319 / BL-2).\n *\n * Under that grant `$fs:` **collapses to the scoped root**: `$fs:/p` and `/p`\n * name the same byte, because there is no longer any \"mount-absolute\" space\n * outside the bundle for `$fs:` to reach into. Without this flag the resolver\n * would hand back a mount-absolute path that the chroot then re-roots anyway —\n * a link that renders as valid and resolves somewhere the author did not mean.\n *\n * **This is an invariant to CREATE, not one to inherit** (`BUNDLE_LAYERS_SPEC\n * §11`): the shipped resolver reads `{currentFile, corpusRoot}` and nothing\n * else, so `$fs:` is bundle-anchored only if something says so. It lives here,\n * in the resolver, rather than as a rule each caller applies by passing\n * `corpusRoot: '/'` — an invariant the arithmetic carries cannot be forgotten\n * at one call site out of five.\n */\n bundleChrooted?: boolean;\n}\n\n/** Ambient link space. A corpus-rendering app wraps its document tree in\n * `<LinkSpaceContext value={{ corpusRoot }}>`; nesting a second provider inside a\n * rendered sub-corpus makes the innermost root win. */\nexport const LinkSpaceContext = createContext<LinkSpace>({\n corpusRoot: null,\n bundleChrooted: false,\n});\n\n/** Collapse `.`/`..`/empty segments into a clean absolute path. `..` can never\n * climb above the root — a (virtual) root's parent is itself, which is what keeps\n * both the mount space and the corpus space closed under traversal. */\nexport const normalizeAbsolute = (path: string): string => {\n const out: string[] = [];\n for (const seg of path.split('/')) {\n if (seg === '' || seg === '.') continue;\n if (seg === '..') out.pop();\n else out.push(seg);\n }\n return '/' + out.join('/');\n};\n\nexport type ResolvedLinkTarget =\n /** Resolved to an absolute filesystem path (existence NOT checked here). */\n | { state: 'resolved'; path: string }\n /** A relative target with no known authoring file — the caller may route\n * optimistically (it cannot check existence or self-ness generically). */\n | { state: 'unresolvable' }\n /** A malformed `$fs:` target (not mount-absolute; includes scheme smuggling).\n * Callers MUST render this broken/inert — never as an anchor. */\n | { state: 'invalid' };\n\n/** Anchor a corpus-absolute path at `corpusRoot`, clamping the corpus-relative\n * half FIRST so the virtual corpus space stays closed under traversal. Shared by\n * the `/p` branch and — under a bundle chroot — the `$fs:/p` branch, so the two\n * spellings cannot drift into resolving differently. */\nfunction resolveCorpusAbsolute(path: string, corpusRoot: string | null): ResolvedLinkTarget {\n const inner = normalizeAbsolute(path);\n if (corpusRoot === null || corpusRoot === '/') return { state: 'resolved', path: inner };\n return { state: 'resolved', path: normalizeAbsolute(corpusRoot + inner) };\n}\n\n/**\n * Resolve a raw link target (a wikilink target or an in-app href's path half —\n * fragment already split off) to an absolute filesystem path. THE shared resolver:\n * the default `WikiLink`, the markdown `a` override, and safe-content consumers\n * all route through this one function so the two render pipelines cannot drift.\n */\nexport function resolveLinkTarget(\n raw: string,\n opts: {\n currentFile?: string;\n corpusRoot?: string | null;\n /** See `LinkSpace.bundleChrooted`. Under a bundle-chroot'd grant `$fs:`\n * resolves in the corpus space, because they are the same space. */\n bundleChrooted?: boolean;\n } = {},\n): ResolvedLinkTarget {\n if (raw.startsWith(FS_PREFIX)) {\n const rest = raw.slice(FS_PREFIX.length);\n // Must be mount-absolute. This single rule also fails `$fs:javascript:…`,\n // `$fs:https://…`, and every other smuggled scheme closed.\n if (!rest.startsWith('/')) return { state: 'invalid' };\n // Under a bundle chroot the mount root IS the bundle root, so `$fs:` has\n // nowhere outside to name: it takes the corpus-absolute branch below and the\n // two spellings collapse. `normalizeAbsolute` clamps `..` at the root either\n // way, so neither spelling can climb out — the collapse changes WHERE a\n // `$fs:` link points, never whether it can escape.\n if (opts.bundleChrooted) return resolveCorpusAbsolute(rest, opts.corpusRoot ?? null);\n return { state: 'resolved', path: normalizeAbsolute(rest) };\n }\n if (raw.startsWith('/')) {\n const corpusRoot = opts.corpusRoot ?? null;\n if (corpusRoot !== null) {\n // Clamp the corpus-relative half FIRST (the virtual FS is closed — `/../x`\n // stays inside the corpus), THEN anchor it at the corpus root.\n return resolveCorpusAbsolute(raw, corpusRoot);\n }\n return { state: 'resolved', path: normalizeAbsolute(raw) };\n }\n // Relative: against the authoring file's directory — the same in both spaces.\n if (!opts.currentFile) return { state: 'unresolvable' };\n const dir = opts.currentFile.slice(0, opts.currentFile.lastIndexOf('/'));\n return { state: 'resolved', path: normalizeAbsolute(`${dir}/${raw}`) };\n}\n"],"mappings":";AAwBA,SAAS,qBAAqB;AAEvB,MAAM,YAAY;AA+BlB,MAAM,mBAAmB,cAAyB;AAAA,EACvD,YAAY;AAAA,EACZ,gBAAgB;AAClB,CAAC;AAKM,MAAM,oBAAoB,CAAC,SAAyB;AACzD,QAAM,MAAgB,CAAC;AACvB,aAAW,OAAO,KAAK,MAAM,GAAG,GAAG;AACjC,QAAI,QAAQ,MAAM,QAAQ,IAAK;AAC/B,QAAI,QAAQ,KAAM,KAAI,IAAI;AAAA,QACrB,KAAI,KAAK,GAAG;AAAA,EACnB;AACA,SAAO,MAAM,IAAI,KAAK,GAAG;AAC3B;AAgBA,SAAS,sBAAsB,MAAc,YAA+C;AAC1F,QAAM,QAAQ,kBAAkB,IAAI;AACpC,MAAI,eAAe,QAAQ,eAAe,IAAK,QAAO,EAAE,OAAO,YAAY,MAAM,MAAM;AACvF,SAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,aAAa,KAAK,EAAE;AAC1E;AAQO,SAAS,kBACd,KACA,OAMI,CAAC,GACe;AACpB,MAAI,IAAI,WAAW,SAAS,GAAG;AAC7B,UAAM,OAAO,IAAI,MAAM,UAAU,MAAM;AAGvC,QAAI,CAAC,KAAK,WAAW,GAAG,EAAG,QAAO,EAAE,OAAO,UAAU;AAMrD,QAAI,KAAK,eAAgB,QAAO,sBAAsB,MAAM,KAAK,cAAc,IAAI;AACnF,WAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,IAAI,EAAE;AAAA,EAC5D;AACA,MAAI,IAAI,WAAW,GAAG,GAAG;AACvB,UAAM,aAAa,KAAK,cAAc;AACtC,QAAI,eAAe,MAAM;AAGvB,aAAO,sBAAsB,KAAK,UAAU;AAAA,IAC9C;AACA,WAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,GAAG,EAAE;AAAA,EAC3D;AAEA,MAAI,CAAC,KAAK,YAAa,QAAO,EAAE,OAAO,eAAe;AACtD,QAAM,MAAM,KAAK,YAAY,MAAM,GAAG,KAAK,YAAY,YAAY,GAAG,CAAC;AACvE,SAAO,EAAE,OAAO,YAAY,MAAM,kBAAkB,GAAG,GAAG,IAAI,GAAG,EAAE,EAAE;AACvE;","names":[]}
package/dist/llm.cjs CHANGED
@@ -20,8 +20,11 @@ var llm_exports = {};
20
20
  __export(llm_exports, {
21
21
  chat: () => chat,
22
22
  describeChat: () => describeChat,
23
+ describeChatState: () => describeChatState,
23
24
  onChatProviderChange: () => onChatProviderChange,
24
- useChatProvider: () => useChatProvider
25
+ onChatProviderStateChange: () => onChatProviderStateChange,
26
+ useChatProvider: () => useChatProvider,
27
+ useChatProviderState: () => useChatProviderState
25
28
  });
26
29
  module.exports = __toCommonJS(llm_exports);
27
30
  var import_catalog = require("./catalog");
@@ -35,20 +38,32 @@ function chat(req) {
35
38
  signal
36
39
  );
37
40
  }
41
+ let answered = false;
38
42
  const channel = (0, import_pushChannel.createPushChannel)({
39
43
  pushType: import_protocol.LLM_PROVIDER,
40
44
  requestType: import_protocol.REQUEST_LLM_PROVIDER,
41
45
  initial: null,
42
- parse: (msg) => "provider" in msg ? msg.provider : void 0
46
+ parse: (msg) => {
47
+ if (!("provider" in msg)) return void 0;
48
+ answered = true;
49
+ return msg.provider ?? null;
50
+ }
43
51
  });
52
+ const stateOf = (provider) => !answered ? { status: "unknown" } : provider ? { status: "configured", provider } : { status: "not-configured" };
44
53
  const describeChat = () => channel.get();
54
+ const describeChatState = () => stateOf(channel.get());
45
55
  const onChatProviderChange = (listener) => channel.onChange(listener);
56
+ const onChatProviderStateChange = (listener) => channel.onChange((p) => listener(stateOf(p)));
46
57
  const useChatProvider = () => channel.use();
58
+ const useChatProviderState = () => stateOf(channel.use());
47
59
  // Annotate the CommonJS export names for ESM import in node:
48
60
  0 && (module.exports = {
49
61
  chat,
50
62
  describeChat,
63
+ describeChatState,
51
64
  onChatProviderChange,
52
- useChatProvider
65
+ onChatProviderStateChange,
66
+ useChatProvider,
67
+ useChatProviderState
53
68
  });
54
69
  //# sourceMappingURL=llm.cjs.map
package/dist/llm.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/llm.ts"],"sourcesContent":["// Provider-agnostic LLM chat — the `llm.chat@1` slot (SERVICE_PROVIDERS_SPEC;\n// LLM_AND_AGENTS_SPEC §8 D5).\n//\n// An app calls ONE chat slot and never worries about which provider the user has a\n// key for: the HOST resolves which vendor answers from the key the user holds\n// (`SecretView.boundOrigin`) plus their `preferredImplementation` choice, normalizes\n// the wire format, injects the key host-side at the §6 net:fetch point (the\n// look-at-nothing proxy), and streams normalized deltas back. The app never names a\n// vendor, never sees the key, and needs NO `net:fetch`/`secrets` grant of its own —\n// only the `llm:chat` capability (elevated, app-scoped: a fork earns it by consent).\n//\n// Inert until the host implements `protocol-llm` (the `chat` stream) + the\n// `llm-provider` describe channel; the contract ships here so apps (the file-explorer\n// summarize fork) can be written against it — exactly how `secrets.ts` shipped ahead\n// of `protocol-secrets`.\nimport { invokeStream } from './catalog';\nimport { createPushChannel } from './pushChannel';\nimport { LLM_PROVIDER, REQUEST_LLM_PROVIDER } from './generated/protocol';\n\n/** Who authored a {@link ChatMessage}. */\nexport type ChatRole = 'system' | 'user' | 'assistant' | 'tool';\n\n/** A part of a message. `image` is only honored when the resolved provider\n * advertises `features.vision` (§2.5); `tool-use`/`tool-result` only when it\n * advertises `features.tools` — branch on {@link describeChat} first. */\nexport type ContentPart =\n | { type: 'text'; text: string }\n | { type: 'image'; mimeType: string; data: string } // data: base64, no data: URL prefix\n // A tool call the model emitted on a prior `assistant` turn — replay it in the\n // conversation so a follow-up request carries the agentic history. Pairs with the\n // streamed `tool-call` {@link ChatDelta} that first surfaced it.\n | { type: 'tool-use'; id: string; name: string; input: Record<string, unknown> }\n // The result of executing a `tool-use`, fed back so the model can continue. Carried\n // on a `user`/`tool`-role message; `toolCallId` matches the `tool-use` `id`.\n | { type: 'tool-result'; toolCallId: string; content: string; isError?: boolean };\n\n/** One message in a {@link ChatRequest}: a role plus its content parts. */\nexport interface ChatMessage {\n role: ChatRole;\n content: ContentPart[];\n}\n\n/** A tool the model may call — honored only when `features.tools`. */\nexport interface ToolDef {\n name: string;\n description?: string;\n /** JSON-Schema for the tool's arguments. */\n inputSchema: Record<string, unknown>;\n}\n\n/** A host-brokered chat completion request: the messages plus optional tools,\n * response format, and model hint (each honored per the provider's features). */\nexport interface ChatRequest {\n messages: ChatMessage[];\n /** Honored only when the resolved provider advertises `features.tools`. */\n tools?: ToolDef[];\n /** `'json'` honored only when `features.jsonMode`. Defaults to `'text'`. */\n responseFormat?: 'text' | 'json';\n maxTokens?: number;\n /** An ABSTRACT tier hint, never a vendor model id — the host maps it to a concrete\n * model on the resolved provider. Omit to take the provider's default. */\n modelHint?: 'fast' | 'smart';\n /** Abort the completion mid-stream. When it fires, the SDK sends the host a cancel\n * frame so the host aborts the upstream provider request and STOPS BILLING the\n * user's key — not merely stops the app-side iterator (LLM_AND_AGENTS_SPEC §3.3\n * \"abort the in-flight LLM request\", R3-224). Not sent over the wire (an\n * `AbortSignal` isn't serializable); handled SDK-side. */\n signal?: AbortSignal;\n}\n\n/** One streamed chunk. Consumers typically accumulate `text-delta`s. */\nexport type ChatDelta =\n | { type: 'text-delta'; text: string }\n | { type: 'tool-call'; id: string; name: string; input: unknown }\n | { type: 'usage'; inputTokens: number; outputTokens: number };\n\n/** Why generation stopped: natural `end`, `length` cap, a `tool` call, or content `filtered`. */\nexport type ChatStopReason = 'end' | 'length' | 'tool' | 'filtered';\n\n/** The terminal value of the {@link chat} stream. */\nexport interface ChatResult {\n stopReason: ChatStopReason;\n}\n\n/**\n * Stream a chat completion from whichever provider the user has configured.\n *\n * ```ts\n * let summary = '';\n * for await (const d of chat({ messages: [{ role: 'user', content: [{ type: 'text', text }] }] })) {\n * if (d.type === 'text-delta') summary += d.text;\n * }\n * ```\n *\n * Requires the `llm:chat` capability. If no provider is bound the host fails the\n * stream into the SP-7 connect-me prompt (the user adds a key) — the generator\n * throws with `code: 'auth-required'`; an un-granted call throws `forbidden`.\n */\nexport function chat(req: ChatRequest): AsyncGenerator<ChatDelta, ChatResult, void> {\n // Peel `signal` out of the request before it becomes wire params — an AbortSignal\n // can't cross the postMessage boundary as data; it drives the SDK-side cancel frame.\n const { signal, ...params } = req;\n return invokeStream<ChatDelta, ChatResult>(\n 'llm:chat',\n params as unknown as Record<string, unknown>,\n signal,\n );\n}\n\n/** The resolved provider's advertised abilities (SERVICE_PROVIDERS_SPEC §2.5) — read\n * to branch/degrade (offer image upload only when `vision`). */\nexport interface ChatFeatures {\n vision: boolean;\n tools: boolean;\n jsonMode: boolean;\n maxContextTokens: number;\n}\n\n/** Info about the provider the host resolved for this app. `null` when no provider\n * is bound (SP-7: prompt the user to add a key before calling {@link chat}). */\nexport interface ChatProviderInfo {\n /** Opaque provider id, e.g. `llm.chat.anthropic` — never a vendor secret or model id. */\n providerId: string;\n /** True for Host-proxied providers (host-vouched, SP-9); false for app-level ones,\n * whose `features` are an untrusted claim. */\n hostVouched: boolean;\n features: ChatFeatures;\n}\n\n// The `llm-provider` describe channel (Recipe A): the host pushes the resolved\n// provider info on change and replays it on register-frame, gated by `llm:chat`.\n// A message with no `provider` key is ignored; an explicit `null` means \"no provider\n// bound\" (distinct from \"not yet answered\", which keeps the `initial` null).\nconst channel = createPushChannel<ChatProviderInfo | null>({\n pushType: LLM_PROVIDER,\n requestType: REQUEST_LLM_PROVIDER,\n initial: null,\n parse: (msg) =>\n 'provider' in msg ? (msg.provider as ChatProviderInfo | null) : undefined,\n});\n\n/** The provider the host resolved for this app (or `null` if none bound). Poll for a\n * one-off read; use {@link onChatProviderChange}/{@link useChatProvider} to react. */\nexport const describeChat = (): ChatProviderInfo | null => channel.get();\n\n/** Subscribe to provider changes (key added/revoked, preference changed). Invoked\n * immediately with the current value, then on every change. Returns unsubscribe. */\nexport const onChatProviderChange = (\n listener: (provider: ChatProviderInfo | null) => void,\n): (() => void) => channel.onChange(listener);\n\n/** React hook returning the resolved chat provider (or `null`), re-rendering on\n * change — gate the summarize affordance on `provider !== null`. */\nexport const useChatProvider = (): ChatProviderInfo | null => channel.use();\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,qBAA6B;AAC7B,yBAAkC;AAClC,sBAAmD;AAiF5C,SAAS,KAAK,KAA+D;AAGlF,QAAM,EAAE,QAAQ,GAAG,OAAO,IAAI;AAC9B,aAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AA0BA,MAAM,cAAU,sCAA2C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,OAAO,CAAC,QACN,cAAc,MAAO,IAAI,WAAuC;AACpE,CAAC;AAIM,MAAM,eAAe,MAA+B,QAAQ,IAAI;AAIhE,MAAM,uBAAuB,CAClC,aACiB,QAAQ,SAAS,QAAQ;AAIrC,MAAM,kBAAkB,MAA+B,QAAQ,IAAI;","names":[]}
1
+ {"version":3,"sources":["../src/llm.ts"],"sourcesContent":["// Provider-agnostic LLM chat — the `llm.chat@1` slot (SERVICE_PROVIDERS_SPEC;\n// LLM_AND_AGENTS_SPEC §8 D5).\n//\n// An app calls ONE chat slot and never worries about which provider the user has a\n// key for: the HOST resolves which vendor answers from the key the user holds\n// (`SecretView.boundOrigin`) plus their `preferredImplementation` choice, normalizes\n// the wire format, injects the key host-side at the §6 net:fetch point (the\n// look-at-nothing proxy), and streams normalized deltas back. The app never names a\n// vendor, never sees the key, and needs NO `net:fetch`/`secrets` grant of its own —\n// only the `llm:chat` capability (elevated, app-scoped: a fork earns it by consent).\n//\n// Inert until the host implements `protocol-llm` (the `chat` stream) + the\n// `llm-provider` describe channel; the contract ships here so apps (the file-explorer\n// summarize fork) can be written against it — exactly how `secrets.ts` shipped ahead\n// of `protocol-secrets`.\nimport { invokeStream } from './catalog';\nimport { createPushChannel } from './pushChannel';\nimport { LLM_PROVIDER, REQUEST_LLM_PROVIDER } from './generated/protocol';\n\n/** Who authored a {@link ChatMessage}. */\nexport type ChatRole = 'system' | 'user' | 'assistant' | 'tool';\n\n/** A part of a message. `image` is only honored when the resolved provider\n * advertises `features.vision` (§2.5); `tool-use`/`tool-result` only when it\n * advertises `features.tools` — branch on {@link describeChat} first. */\nexport type ContentPart =\n | { type: 'text'; text: string }\n | { type: 'image'; mimeType: string; data: string } // data: base64, no data: URL prefix\n // A tool call the model emitted on a prior `assistant` turn — replay it in the\n // conversation so a follow-up request carries the agentic history. Pairs with the\n // streamed `tool-call` {@link ChatDelta} that first surfaced it.\n | { type: 'tool-use'; id: string; name: string; input: Record<string, unknown> }\n // The result of executing a `tool-use`, fed back so the model can continue. Carried\n // on a `user`/`tool`-role message; `toolCallId` matches the `tool-use` `id`.\n | { type: 'tool-result'; toolCallId: string; content: string; isError?: boolean };\n\n/** One message in a {@link ChatRequest}: a role plus its content parts. */\nexport interface ChatMessage {\n role: ChatRole;\n content: ContentPart[];\n}\n\n/** A tool the model may call — honored only when `features.tools`. */\nexport interface ToolDef {\n name: string;\n description?: string;\n /** JSON-Schema for the tool's arguments. */\n inputSchema: Record<string, unknown>;\n}\n\n/** A host-brokered chat completion request: the messages plus optional tools,\n * response format, and model hint (each honored per the provider's features). */\nexport interface ChatRequest {\n messages: ChatMessage[];\n /** Honored only when the resolved provider advertises `features.tools`. */\n tools?: ToolDef[];\n /** `'json'` honored only when `features.jsonMode`. Defaults to `'text'`. */\n responseFormat?: 'text' | 'json';\n maxTokens?: number;\n /** An ABSTRACT tier hint, never a vendor model id — the host maps it to a concrete\n * model on the resolved provider. Omit to take the provider's default. */\n modelHint?: 'fast' | 'smart';\n /** Abort the completion mid-stream. When it fires, the SDK sends the host a cancel\n * frame so the host aborts the upstream provider request and STOPS BILLING the\n * user's key — not merely stops the app-side iterator (LLM_AND_AGENTS_SPEC §3.3\n * \"abort the in-flight LLM request\", R3-224). Not sent over the wire (an\n * `AbortSignal` isn't serializable); handled SDK-side. */\n signal?: AbortSignal;\n}\n\n/** One streamed chunk. Consumers typically accumulate `text-delta`s. */\nexport type ChatDelta =\n | { type: 'text-delta'; text: string }\n | { type: 'tool-call'; id: string; name: string; input: unknown }\n | { type: 'usage'; inputTokens: number; outputTokens: number };\n\n/** Why generation stopped: natural `end`, `length` cap, a `tool` call, or content `filtered`. */\nexport type ChatStopReason = 'end' | 'length' | 'tool' | 'filtered';\n\n/** The terminal value of the {@link chat} stream. */\nexport interface ChatResult {\n stopReason: ChatStopReason;\n}\n\n/**\n * Stream a chat completion from whichever provider the user has configured.\n *\n * ```ts\n * let summary = '';\n * for await (const d of chat({ messages: [{ role: 'user', content: [{ type: 'text', text }] }] })) {\n * if (d.type === 'text-delta') summary += d.text;\n * }\n * ```\n *\n * Requires the `llm:chat` capability. If no provider is bound the host fails the\n * stream into the SP-7 connect-me prompt (the user adds a key) — the generator\n * throws with `code: 'auth-required'`; an un-granted call throws `forbidden`.\n */\nexport function chat(req: ChatRequest): AsyncGenerator<ChatDelta, ChatResult, void> {\n // Peel `signal` out of the request before it becomes wire params — an AbortSignal\n // can't cross the postMessage boundary as data; it drives the SDK-side cancel frame.\n const { signal, ...params } = req;\n return invokeStream<ChatDelta, ChatResult>(\n 'llm:chat',\n params as unknown as Record<string, unknown>,\n signal,\n );\n}\n\n/** The resolved provider's advertised abilities (SERVICE_PROVIDERS_SPEC §2.5) — read\n * to branch/degrade (offer image upload only when `vision`). */\nexport interface ChatFeatures {\n vision: boolean;\n tools: boolean;\n jsonMode: boolean;\n maxContextTokens: number;\n}\n\n/** Info about the provider the host resolved for this app. `null` when no provider\n * is bound (SP-7: prompt the user to add a key before calling {@link chat}). */\nexport interface ChatProviderInfo {\n /** Opaque provider id, e.g. `llm.chat.anthropic` — never a vendor secret or model id. */\n providerId: string;\n /** True for Host-proxied providers (host-vouched, SP-9); false for app-level ones,\n * whose `features` are an untrusted claim. */\n hostVouched: boolean;\n features: ChatFeatures;\n // NOTE (R3-300): `displayName`, `executor` and the resolved per-tier `models` belong\n // here — an app rendering provider state wants all three. They are NOT added yet,\n // deliberately: this interface IS the `llm-provider` channel's declared value, so\n // adding a field is a WIRE change, and the wire is owned by\n // `@immediately-run/sandbox-protocol` (descriptor edit → publish → pin bump on both\n // sides). The protocol snapshot gate enforces exactly that, and it is right to. The\n // enrichment rides R3-307's publish, which already has to touch those descriptors —\n // one publish for two additions rather than two.\n}\n\n/**\n * Whether the host has told us about a provider yet, and if so whether one is bound.\n *\n * THREE states, because two is the bug (R3-300). `describeChat()` returns `null` both\n * when no provider is configured AND when the channel has not answered — so an app\n * cannot tell \"you need a key\" from \"ask again in a moment\", and consuming apps\n * rendered a misleading \"connect a key\" banner at users who had one. `unknown` is the\n * state before the host answers; it is not an error and not a prompt to act.\n */\nexport type ChatProviderState =\n | { status: 'unknown' }\n | { status: 'not-configured' }\n | { status: 'configured'; provider: ChatProviderInfo };\n\n// The `llm-provider` describe channel (Recipe A): the host pushes the resolved\n// provider info on change and replays it on register-frame, gated by `llm:chat`.\n// A message with no `provider` key is ignored; an explicit `null` means \"no provider\n// bound\", which is now REPRESENTABLE as distinct from \"not yet answered\".\n// The channel's VALUE stays exactly what the wire carries — `ChatProviderInfo | null` —\n// because the wire did not change here and the protocol snapshot gate reads this type as\n// the channel's shape. The three-state lives BESIDE it: `answered` records whether the host\n// has ever spoken on this channel, which is the one bit `null` cannot carry. Deriving the\n// state rather than widening the channel keeps the wire contract byte-identical, which it\n// is (SDK_PACKAGING_SPEC §9: the wire is additive-only, and this is not a wire change).\nlet answered = false;\nconst channel = createPushChannel<ChatProviderInfo | null>({\n pushType: LLM_PROVIDER,\n requestType: REQUEST_LLM_PROVIDER,\n initial: null,\n parse: (msg) => {\n if (!('provider' in msg)) return undefined;\n answered = true;\n return (msg.provider as ChatProviderInfo | null) ?? null;\n },\n});\n\n/** Derive the three-state from the wire value plus whether the host has answered. */\nconst stateOf = (provider: ChatProviderInfo | null): ChatProviderState =>\n !answered ? { status: 'unknown' } : provider ? { status: 'configured', provider } : { status: 'not-configured' };\n\n/**\n * The provider the host resolved for this app, or `null`.\n *\n * Kept for compatibility (`ways_of_working §6`, additive-only): it collapses `unknown`\n * and `not-configured` to `null`. Prefer {@link describeChatState} when the difference\n * matters — which is any time you would render \"connect a key\", because doing that in\n * the `unknown` state is exactly the false banner R3-300 fixes.\n */\nexport const describeChat = (): ChatProviderInfo | null => channel.get();\n\n/** The three-state read: `unknown` before the host answers, then configured or not. */\nexport const describeChatState = (): ChatProviderState => stateOf(channel.get());\n\n/** Subscribe to provider changes (key added/revoked, preference changed). Invoked\n * immediately with the current value, then on every change. Returns unsubscribe. */\nexport const onChatProviderChange = (\n listener: (provider: ChatProviderInfo | null) => void,\n): (() => void) => channel.onChange(listener);\n\n/** Subscribe to the three-state provider description. */\nexport const onChatProviderStateChange = (\n listener: (state: ChatProviderState) => void,\n): (() => void) => channel.onChange((p) => listener(stateOf(p)));\n\n/** React hook returning the resolved chat provider (or `null`), re-rendering on\n * change — gate the summarize affordance on `provider !== null`. */\nexport const useChatProvider = (): ChatProviderInfo | null => channel.use();\n\n/**\n * React hook returning the three-state description.\n *\n * Use this to render provider state honestly: show nothing (or a neutral placeholder)\n * while `unknown`, the connect affordance only on `not-configured`, and the provider's\n * name on `configured`.\n */\nexport const useChatProviderState = (): ChatProviderState => stateOf(channel.use());\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeA,qBAA6B;AAC7B,yBAAkC;AAClC,sBAAmD;AAiF5C,SAAS,KAAK,KAA+D;AAGlF,QAAM,EAAE,QAAQ,GAAG,OAAO,IAAI;AAC9B,aAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAsDA,IAAI,WAAW;AACf,MAAM,cAAU,sCAA2C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,OAAO,CAAC,QAAQ;AACd,QAAI,EAAE,cAAc,KAAM,QAAO;AACjC,eAAW;AACX,WAAQ,IAAI,YAAwC;AAAA,EACtD;AACF,CAAC;AAGD,MAAM,UAAU,CAAC,aACf,CAAC,WAAW,EAAE,QAAQ,UAAU,IAAI,WAAW,EAAE,QAAQ,cAAc,SAAS,IAAI,EAAE,QAAQ,iBAAiB;AAU1G,MAAM,eAAe,MAA+B,QAAQ,IAAI;AAGhE,MAAM,oBAAoB,MAAyB,QAAQ,QAAQ,IAAI,CAAC;AAIxE,MAAM,uBAAuB,CAClC,aACiB,QAAQ,SAAS,QAAQ;AAGrC,MAAM,4BAA4B,CACvC,aACiB,QAAQ,SAAS,CAAC,MAAM,SAAS,QAAQ,CAAC,CAAC,CAAC;AAIxD,MAAM,kBAAkB,MAA+B,QAAQ,IAAI;AASnE,MAAM,uBAAuB,MAAyB,QAAQ,QAAQ,IAAI,CAAC;","names":[]}
package/dist/llm.d.cts CHANGED
@@ -105,14 +105,49 @@ interface ChatProviderInfo {
105
105
  hostVouched: boolean;
106
106
  features: ChatFeatures;
107
107
  }
108
- /** The provider the host resolved for this app (or `null` if none bound). Poll for a
109
- * one-off read; use {@link onChatProviderChange}/{@link useChatProvider} to react. */
108
+ /**
109
+ * Whether the host has told us about a provider yet, and if so whether one is bound.
110
+ *
111
+ * THREE states, because two is the bug (R3-300). `describeChat()` returns `null` both
112
+ * when no provider is configured AND when the channel has not answered — so an app
113
+ * cannot tell "you need a key" from "ask again in a moment", and consuming apps
114
+ * rendered a misleading "connect a key" banner at users who had one. `unknown` is the
115
+ * state before the host answers; it is not an error and not a prompt to act.
116
+ */
117
+ type ChatProviderState = {
118
+ status: 'unknown';
119
+ } | {
120
+ status: 'not-configured';
121
+ } | {
122
+ status: 'configured';
123
+ provider: ChatProviderInfo;
124
+ };
125
+ /**
126
+ * The provider the host resolved for this app, or `null`.
127
+ *
128
+ * Kept for compatibility (`ways_of_working §6`, additive-only): it collapses `unknown`
129
+ * and `not-configured` to `null`. Prefer {@link describeChatState} when the difference
130
+ * matters — which is any time you would render "connect a key", because doing that in
131
+ * the `unknown` state is exactly the false banner R3-300 fixes.
132
+ */
110
133
  declare const describeChat: () => ChatProviderInfo | null;
134
+ /** The three-state read: `unknown` before the host answers, then configured or not. */
135
+ declare const describeChatState: () => ChatProviderState;
111
136
  /** Subscribe to provider changes (key added/revoked, preference changed). Invoked
112
137
  * immediately with the current value, then on every change. Returns unsubscribe. */
113
138
  declare const onChatProviderChange: (listener: (provider: ChatProviderInfo | null) => void) => (() => void);
139
+ /** Subscribe to the three-state provider description. */
140
+ declare const onChatProviderStateChange: (listener: (state: ChatProviderState) => void) => (() => void);
114
141
  /** React hook returning the resolved chat provider (or `null`), re-rendering on
115
142
  * change — gate the summarize affordance on `provider !== null`. */
116
143
  declare const useChatProvider: () => ChatProviderInfo | null;
144
+ /**
145
+ * React hook returning the three-state description.
146
+ *
147
+ * Use this to render provider state honestly: show nothing (or a neutral placeholder)
148
+ * while `unknown`, the connect affordance only on `not-configured`, and the provider's
149
+ * name on `configured`.
150
+ */
151
+ declare const useChatProviderState: () => ChatProviderState;
117
152
 
118
- export { type ChatDelta, type ChatFeatures, type ChatMessage, type ChatProviderInfo, type ChatRequest, type ChatResult, type ChatRole, type ChatStopReason, type ContentPart, type ToolDef, chat, describeChat, onChatProviderChange, useChatProvider };
153
+ export { type ChatDelta, type ChatFeatures, type ChatMessage, type ChatProviderInfo, type ChatProviderState, type ChatRequest, type ChatResult, type ChatRole, type ChatStopReason, type ContentPart, type ToolDef, chat, describeChat, describeChatState, onChatProviderChange, onChatProviderStateChange, useChatProvider, useChatProviderState };
package/dist/llm.d.ts CHANGED
@@ -105,14 +105,49 @@ interface ChatProviderInfo {
105
105
  hostVouched: boolean;
106
106
  features: ChatFeatures;
107
107
  }
108
- /** The provider the host resolved for this app (or `null` if none bound). Poll for a
109
- * one-off read; use {@link onChatProviderChange}/{@link useChatProvider} to react. */
108
+ /**
109
+ * Whether the host has told us about a provider yet, and if so whether one is bound.
110
+ *
111
+ * THREE states, because two is the bug (R3-300). `describeChat()` returns `null` both
112
+ * when no provider is configured AND when the channel has not answered — so an app
113
+ * cannot tell "you need a key" from "ask again in a moment", and consuming apps
114
+ * rendered a misleading "connect a key" banner at users who had one. `unknown` is the
115
+ * state before the host answers; it is not an error and not a prompt to act.
116
+ */
117
+ type ChatProviderState = {
118
+ status: 'unknown';
119
+ } | {
120
+ status: 'not-configured';
121
+ } | {
122
+ status: 'configured';
123
+ provider: ChatProviderInfo;
124
+ };
125
+ /**
126
+ * The provider the host resolved for this app, or `null`.
127
+ *
128
+ * Kept for compatibility (`ways_of_working §6`, additive-only): it collapses `unknown`
129
+ * and `not-configured` to `null`. Prefer {@link describeChatState} when the difference
130
+ * matters — which is any time you would render "connect a key", because doing that in
131
+ * the `unknown` state is exactly the false banner R3-300 fixes.
132
+ */
110
133
  declare const describeChat: () => ChatProviderInfo | null;
134
+ /** The three-state read: `unknown` before the host answers, then configured or not. */
135
+ declare const describeChatState: () => ChatProviderState;
111
136
  /** Subscribe to provider changes (key added/revoked, preference changed). Invoked
112
137
  * immediately with the current value, then on every change. Returns unsubscribe. */
113
138
  declare const onChatProviderChange: (listener: (provider: ChatProviderInfo | null) => void) => (() => void);
139
+ /** Subscribe to the three-state provider description. */
140
+ declare const onChatProviderStateChange: (listener: (state: ChatProviderState) => void) => (() => void);
114
141
  /** React hook returning the resolved chat provider (or `null`), re-rendering on
115
142
  * change — gate the summarize affordance on `provider !== null`. */
116
143
  declare const useChatProvider: () => ChatProviderInfo | null;
144
+ /**
145
+ * React hook returning the three-state description.
146
+ *
147
+ * Use this to render provider state honestly: show nothing (or a neutral placeholder)
148
+ * while `unknown`, the connect affordance only on `not-configured`, and the provider's
149
+ * name on `configured`.
150
+ */
151
+ declare const useChatProviderState: () => ChatProviderState;
117
152
 
118
- export { type ChatDelta, type ChatFeatures, type ChatMessage, type ChatProviderInfo, type ChatRequest, type ChatResult, type ChatRole, type ChatStopReason, type ContentPart, type ToolDef, chat, describeChat, onChatProviderChange, useChatProvider };
153
+ export { type ChatDelta, type ChatFeatures, type ChatMessage, type ChatProviderInfo, type ChatProviderState, type ChatRequest, type ChatResult, type ChatRole, type ChatStopReason, type ContentPart, type ToolDef, chat, describeChat, describeChatState, onChatProviderChange, onChatProviderStateChange, useChatProvider, useChatProviderState };
package/dist/llm.js CHANGED
@@ -10,19 +10,31 @@ function chat(req) {
10
10
  signal
11
11
  );
12
12
  }
13
+ let answered = false;
13
14
  const channel = createPushChannel({
14
15
  pushType: LLM_PROVIDER,
15
16
  requestType: REQUEST_LLM_PROVIDER,
16
17
  initial: null,
17
- parse: (msg) => "provider" in msg ? msg.provider : void 0
18
+ parse: (msg) => {
19
+ if (!("provider" in msg)) return void 0;
20
+ answered = true;
21
+ return msg.provider ?? null;
22
+ }
18
23
  });
24
+ const stateOf = (provider) => !answered ? { status: "unknown" } : provider ? { status: "configured", provider } : { status: "not-configured" };
19
25
  const describeChat = () => channel.get();
26
+ const describeChatState = () => stateOf(channel.get());
20
27
  const onChatProviderChange = (listener) => channel.onChange(listener);
28
+ const onChatProviderStateChange = (listener) => channel.onChange((p) => listener(stateOf(p)));
21
29
  const useChatProvider = () => channel.use();
30
+ const useChatProviderState = () => stateOf(channel.use());
22
31
  export {
23
32
  chat,
24
33
  describeChat,
34
+ describeChatState,
25
35
  onChatProviderChange,
26
- useChatProvider
36
+ onChatProviderStateChange,
37
+ useChatProvider,
38
+ useChatProviderState
27
39
  };
28
40
  //# sourceMappingURL=llm.js.map
package/dist/llm.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/llm.ts"],"sourcesContent":["// Provider-agnostic LLM chat — the `llm.chat@1` slot (SERVICE_PROVIDERS_SPEC;\n// LLM_AND_AGENTS_SPEC §8 D5).\n//\n// An app calls ONE chat slot and never worries about which provider the user has a\n// key for: the HOST resolves which vendor answers from the key the user holds\n// (`SecretView.boundOrigin`) plus their `preferredImplementation` choice, normalizes\n// the wire format, injects the key host-side at the §6 net:fetch point (the\n// look-at-nothing proxy), and streams normalized deltas back. The app never names a\n// vendor, never sees the key, and needs NO `net:fetch`/`secrets` grant of its own —\n// only the `llm:chat` capability (elevated, app-scoped: a fork earns it by consent).\n//\n// Inert until the host implements `protocol-llm` (the `chat` stream) + the\n// `llm-provider` describe channel; the contract ships here so apps (the file-explorer\n// summarize fork) can be written against it — exactly how `secrets.ts` shipped ahead\n// of `protocol-secrets`.\nimport { invokeStream } from './catalog';\nimport { createPushChannel } from './pushChannel';\nimport { LLM_PROVIDER, REQUEST_LLM_PROVIDER } from './generated/protocol';\n\n/** Who authored a {@link ChatMessage}. */\nexport type ChatRole = 'system' | 'user' | 'assistant' | 'tool';\n\n/** A part of a message. `image` is only honored when the resolved provider\n * advertises `features.vision` (§2.5); `tool-use`/`tool-result` only when it\n * advertises `features.tools` — branch on {@link describeChat} first. */\nexport type ContentPart =\n | { type: 'text'; text: string }\n | { type: 'image'; mimeType: string; data: string } // data: base64, no data: URL prefix\n // A tool call the model emitted on a prior `assistant` turn — replay it in the\n // conversation so a follow-up request carries the agentic history. Pairs with the\n // streamed `tool-call` {@link ChatDelta} that first surfaced it.\n | { type: 'tool-use'; id: string; name: string; input: Record<string, unknown> }\n // The result of executing a `tool-use`, fed back so the model can continue. Carried\n // on a `user`/`tool`-role message; `toolCallId` matches the `tool-use` `id`.\n | { type: 'tool-result'; toolCallId: string; content: string; isError?: boolean };\n\n/** One message in a {@link ChatRequest}: a role plus its content parts. */\nexport interface ChatMessage {\n role: ChatRole;\n content: ContentPart[];\n}\n\n/** A tool the model may call — honored only when `features.tools`. */\nexport interface ToolDef {\n name: string;\n description?: string;\n /** JSON-Schema for the tool's arguments. */\n inputSchema: Record<string, unknown>;\n}\n\n/** A host-brokered chat completion request: the messages plus optional tools,\n * response format, and model hint (each honored per the provider's features). */\nexport interface ChatRequest {\n messages: ChatMessage[];\n /** Honored only when the resolved provider advertises `features.tools`. */\n tools?: ToolDef[];\n /** `'json'` honored only when `features.jsonMode`. Defaults to `'text'`. */\n responseFormat?: 'text' | 'json';\n maxTokens?: number;\n /** An ABSTRACT tier hint, never a vendor model id — the host maps it to a concrete\n * model on the resolved provider. Omit to take the provider's default. */\n modelHint?: 'fast' | 'smart';\n /** Abort the completion mid-stream. When it fires, the SDK sends the host a cancel\n * frame so the host aborts the upstream provider request and STOPS BILLING the\n * user's key — not merely stops the app-side iterator (LLM_AND_AGENTS_SPEC §3.3\n * \"abort the in-flight LLM request\", R3-224). Not sent over the wire (an\n * `AbortSignal` isn't serializable); handled SDK-side. */\n signal?: AbortSignal;\n}\n\n/** One streamed chunk. Consumers typically accumulate `text-delta`s. */\nexport type ChatDelta =\n | { type: 'text-delta'; text: string }\n | { type: 'tool-call'; id: string; name: string; input: unknown }\n | { type: 'usage'; inputTokens: number; outputTokens: number };\n\n/** Why generation stopped: natural `end`, `length` cap, a `tool` call, or content `filtered`. */\nexport type ChatStopReason = 'end' | 'length' | 'tool' | 'filtered';\n\n/** The terminal value of the {@link chat} stream. */\nexport interface ChatResult {\n stopReason: ChatStopReason;\n}\n\n/**\n * Stream a chat completion from whichever provider the user has configured.\n *\n * ```ts\n * let summary = '';\n * for await (const d of chat({ messages: [{ role: 'user', content: [{ type: 'text', text }] }] })) {\n * if (d.type === 'text-delta') summary += d.text;\n * }\n * ```\n *\n * Requires the `llm:chat` capability. If no provider is bound the host fails the\n * stream into the SP-7 connect-me prompt (the user adds a key) — the generator\n * throws with `code: 'auth-required'`; an un-granted call throws `forbidden`.\n */\nexport function chat(req: ChatRequest): AsyncGenerator<ChatDelta, ChatResult, void> {\n // Peel `signal` out of the request before it becomes wire params — an AbortSignal\n // can't cross the postMessage boundary as data; it drives the SDK-side cancel frame.\n const { signal, ...params } = req;\n return invokeStream<ChatDelta, ChatResult>(\n 'llm:chat',\n params as unknown as Record<string, unknown>,\n signal,\n );\n}\n\n/** The resolved provider's advertised abilities (SERVICE_PROVIDERS_SPEC §2.5) — read\n * to branch/degrade (offer image upload only when `vision`). */\nexport interface ChatFeatures {\n vision: boolean;\n tools: boolean;\n jsonMode: boolean;\n maxContextTokens: number;\n}\n\n/** Info about the provider the host resolved for this app. `null` when no provider\n * is bound (SP-7: prompt the user to add a key before calling {@link chat}). */\nexport interface ChatProviderInfo {\n /** Opaque provider id, e.g. `llm.chat.anthropic` — never a vendor secret or model id. */\n providerId: string;\n /** True for Host-proxied providers (host-vouched, SP-9); false for app-level ones,\n * whose `features` are an untrusted claim. */\n hostVouched: boolean;\n features: ChatFeatures;\n}\n\n// The `llm-provider` describe channel (Recipe A): the host pushes the resolved\n// provider info on change and replays it on register-frame, gated by `llm:chat`.\n// A message with no `provider` key is ignored; an explicit `null` means \"no provider\n// bound\" (distinct from \"not yet answered\", which keeps the `initial` null).\nconst channel = createPushChannel<ChatProviderInfo | null>({\n pushType: LLM_PROVIDER,\n requestType: REQUEST_LLM_PROVIDER,\n initial: null,\n parse: (msg) =>\n 'provider' in msg ? (msg.provider as ChatProviderInfo | null) : undefined,\n});\n\n/** The provider the host resolved for this app (or `null` if none bound). Poll for a\n * one-off read; use {@link onChatProviderChange}/{@link useChatProvider} to react. */\nexport const describeChat = (): ChatProviderInfo | null => channel.get();\n\n/** Subscribe to provider changes (key added/revoked, preference changed). Invoked\n * immediately with the current value, then on every change. Returns unsubscribe. */\nexport const onChatProviderChange = (\n listener: (provider: ChatProviderInfo | null) => void,\n): (() => void) => channel.onChange(listener);\n\n/** React hook returning the resolved chat provider (or `null`), re-rendering on\n * change — gate the summarize affordance on `provider !== null`. */\nexport const useChatProvider = (): ChatProviderInfo | null => channel.use();\n"],"mappings":";AAeA,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,cAAc,4BAA4B;AAiF5C,SAAS,KAAK,KAA+D;AAGlF,QAAM,EAAE,QAAQ,GAAG,OAAO,IAAI;AAC9B,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AA0BA,MAAM,UAAU,kBAA2C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,OAAO,CAAC,QACN,cAAc,MAAO,IAAI,WAAuC;AACpE,CAAC;AAIM,MAAM,eAAe,MAA+B,QAAQ,IAAI;AAIhE,MAAM,uBAAuB,CAClC,aACiB,QAAQ,SAAS,QAAQ;AAIrC,MAAM,kBAAkB,MAA+B,QAAQ,IAAI;","names":[]}
1
+ {"version":3,"sources":["../src/llm.ts"],"sourcesContent":["// Provider-agnostic LLM chat — the `llm.chat@1` slot (SERVICE_PROVIDERS_SPEC;\n// LLM_AND_AGENTS_SPEC §8 D5).\n//\n// An app calls ONE chat slot and never worries about which provider the user has a\n// key for: the HOST resolves which vendor answers from the key the user holds\n// (`SecretView.boundOrigin`) plus their `preferredImplementation` choice, normalizes\n// the wire format, injects the key host-side at the §6 net:fetch point (the\n// look-at-nothing proxy), and streams normalized deltas back. The app never names a\n// vendor, never sees the key, and needs NO `net:fetch`/`secrets` grant of its own —\n// only the `llm:chat` capability (elevated, app-scoped: a fork earns it by consent).\n//\n// Inert until the host implements `protocol-llm` (the `chat` stream) + the\n// `llm-provider` describe channel; the contract ships here so apps (the file-explorer\n// summarize fork) can be written against it — exactly how `secrets.ts` shipped ahead\n// of `protocol-secrets`.\nimport { invokeStream } from './catalog';\nimport { createPushChannel } from './pushChannel';\nimport { LLM_PROVIDER, REQUEST_LLM_PROVIDER } from './generated/protocol';\n\n/** Who authored a {@link ChatMessage}. */\nexport type ChatRole = 'system' | 'user' | 'assistant' | 'tool';\n\n/** A part of a message. `image` is only honored when the resolved provider\n * advertises `features.vision` (§2.5); `tool-use`/`tool-result` only when it\n * advertises `features.tools` — branch on {@link describeChat} first. */\nexport type ContentPart =\n | { type: 'text'; text: string }\n | { type: 'image'; mimeType: string; data: string } // data: base64, no data: URL prefix\n // A tool call the model emitted on a prior `assistant` turn — replay it in the\n // conversation so a follow-up request carries the agentic history. Pairs with the\n // streamed `tool-call` {@link ChatDelta} that first surfaced it.\n | { type: 'tool-use'; id: string; name: string; input: Record<string, unknown> }\n // The result of executing a `tool-use`, fed back so the model can continue. Carried\n // on a `user`/`tool`-role message; `toolCallId` matches the `tool-use` `id`.\n | { type: 'tool-result'; toolCallId: string; content: string; isError?: boolean };\n\n/** One message in a {@link ChatRequest}: a role plus its content parts. */\nexport interface ChatMessage {\n role: ChatRole;\n content: ContentPart[];\n}\n\n/** A tool the model may call — honored only when `features.tools`. */\nexport interface ToolDef {\n name: string;\n description?: string;\n /** JSON-Schema for the tool's arguments. */\n inputSchema: Record<string, unknown>;\n}\n\n/** A host-brokered chat completion request: the messages plus optional tools,\n * response format, and model hint (each honored per the provider's features). */\nexport interface ChatRequest {\n messages: ChatMessage[];\n /** Honored only when the resolved provider advertises `features.tools`. */\n tools?: ToolDef[];\n /** `'json'` honored only when `features.jsonMode`. Defaults to `'text'`. */\n responseFormat?: 'text' | 'json';\n maxTokens?: number;\n /** An ABSTRACT tier hint, never a vendor model id — the host maps it to a concrete\n * model on the resolved provider. Omit to take the provider's default. */\n modelHint?: 'fast' | 'smart';\n /** Abort the completion mid-stream. When it fires, the SDK sends the host a cancel\n * frame so the host aborts the upstream provider request and STOPS BILLING the\n * user's key — not merely stops the app-side iterator (LLM_AND_AGENTS_SPEC §3.3\n * \"abort the in-flight LLM request\", R3-224). Not sent over the wire (an\n * `AbortSignal` isn't serializable); handled SDK-side. */\n signal?: AbortSignal;\n}\n\n/** One streamed chunk. Consumers typically accumulate `text-delta`s. */\nexport type ChatDelta =\n | { type: 'text-delta'; text: string }\n | { type: 'tool-call'; id: string; name: string; input: unknown }\n | { type: 'usage'; inputTokens: number; outputTokens: number };\n\n/** Why generation stopped: natural `end`, `length` cap, a `tool` call, or content `filtered`. */\nexport type ChatStopReason = 'end' | 'length' | 'tool' | 'filtered';\n\n/** The terminal value of the {@link chat} stream. */\nexport interface ChatResult {\n stopReason: ChatStopReason;\n}\n\n/**\n * Stream a chat completion from whichever provider the user has configured.\n *\n * ```ts\n * let summary = '';\n * for await (const d of chat({ messages: [{ role: 'user', content: [{ type: 'text', text }] }] })) {\n * if (d.type === 'text-delta') summary += d.text;\n * }\n * ```\n *\n * Requires the `llm:chat` capability. If no provider is bound the host fails the\n * stream into the SP-7 connect-me prompt (the user adds a key) — the generator\n * throws with `code: 'auth-required'`; an un-granted call throws `forbidden`.\n */\nexport function chat(req: ChatRequest): AsyncGenerator<ChatDelta, ChatResult, void> {\n // Peel `signal` out of the request before it becomes wire params — an AbortSignal\n // can't cross the postMessage boundary as data; it drives the SDK-side cancel frame.\n const { signal, ...params } = req;\n return invokeStream<ChatDelta, ChatResult>(\n 'llm:chat',\n params as unknown as Record<string, unknown>,\n signal,\n );\n}\n\n/** The resolved provider's advertised abilities (SERVICE_PROVIDERS_SPEC §2.5) — read\n * to branch/degrade (offer image upload only when `vision`). */\nexport interface ChatFeatures {\n vision: boolean;\n tools: boolean;\n jsonMode: boolean;\n maxContextTokens: number;\n}\n\n/** Info about the provider the host resolved for this app. `null` when no provider\n * is bound (SP-7: prompt the user to add a key before calling {@link chat}). */\nexport interface ChatProviderInfo {\n /** Opaque provider id, e.g. `llm.chat.anthropic` — never a vendor secret or model id. */\n providerId: string;\n /** True for Host-proxied providers (host-vouched, SP-9); false for app-level ones,\n * whose `features` are an untrusted claim. */\n hostVouched: boolean;\n features: ChatFeatures;\n // NOTE (R3-300): `displayName`, `executor` and the resolved per-tier `models` belong\n // here — an app rendering provider state wants all three. They are NOT added yet,\n // deliberately: this interface IS the `llm-provider` channel's declared value, so\n // adding a field is a WIRE change, and the wire is owned by\n // `@immediately-run/sandbox-protocol` (descriptor edit → publish → pin bump on both\n // sides). The protocol snapshot gate enforces exactly that, and it is right to. The\n // enrichment rides R3-307's publish, which already has to touch those descriptors —\n // one publish for two additions rather than two.\n}\n\n/**\n * Whether the host has told us about a provider yet, and if so whether one is bound.\n *\n * THREE states, because two is the bug (R3-300). `describeChat()` returns `null` both\n * when no provider is configured AND when the channel has not answered — so an app\n * cannot tell \"you need a key\" from \"ask again in a moment\", and consuming apps\n * rendered a misleading \"connect a key\" banner at users who had one. `unknown` is the\n * state before the host answers; it is not an error and not a prompt to act.\n */\nexport type ChatProviderState =\n | { status: 'unknown' }\n | { status: 'not-configured' }\n | { status: 'configured'; provider: ChatProviderInfo };\n\n// The `llm-provider` describe channel (Recipe A): the host pushes the resolved\n// provider info on change and replays it on register-frame, gated by `llm:chat`.\n// A message with no `provider` key is ignored; an explicit `null` means \"no provider\n// bound\", which is now REPRESENTABLE as distinct from \"not yet answered\".\n// The channel's VALUE stays exactly what the wire carries — `ChatProviderInfo | null` —\n// because the wire did not change here and the protocol snapshot gate reads this type as\n// the channel's shape. The three-state lives BESIDE it: `answered` records whether the host\n// has ever spoken on this channel, which is the one bit `null` cannot carry. Deriving the\n// state rather than widening the channel keeps the wire contract byte-identical, which it\n// is (SDK_PACKAGING_SPEC §9: the wire is additive-only, and this is not a wire change).\nlet answered = false;\nconst channel = createPushChannel<ChatProviderInfo | null>({\n pushType: LLM_PROVIDER,\n requestType: REQUEST_LLM_PROVIDER,\n initial: null,\n parse: (msg) => {\n if (!('provider' in msg)) return undefined;\n answered = true;\n return (msg.provider as ChatProviderInfo | null) ?? null;\n },\n});\n\n/** Derive the three-state from the wire value plus whether the host has answered. */\nconst stateOf = (provider: ChatProviderInfo | null): ChatProviderState =>\n !answered ? { status: 'unknown' } : provider ? { status: 'configured', provider } : { status: 'not-configured' };\n\n/**\n * The provider the host resolved for this app, or `null`.\n *\n * Kept for compatibility (`ways_of_working §6`, additive-only): it collapses `unknown`\n * and `not-configured` to `null`. Prefer {@link describeChatState} when the difference\n * matters — which is any time you would render \"connect a key\", because doing that in\n * the `unknown` state is exactly the false banner R3-300 fixes.\n */\nexport const describeChat = (): ChatProviderInfo | null => channel.get();\n\n/** The three-state read: `unknown` before the host answers, then configured or not. */\nexport const describeChatState = (): ChatProviderState => stateOf(channel.get());\n\n/** Subscribe to provider changes (key added/revoked, preference changed). Invoked\n * immediately with the current value, then on every change. Returns unsubscribe. */\nexport const onChatProviderChange = (\n listener: (provider: ChatProviderInfo | null) => void,\n): (() => void) => channel.onChange(listener);\n\n/** Subscribe to the three-state provider description. */\nexport const onChatProviderStateChange = (\n listener: (state: ChatProviderState) => void,\n): (() => void) => channel.onChange((p) => listener(stateOf(p)));\n\n/** React hook returning the resolved chat provider (or `null`), re-rendering on\n * change — gate the summarize affordance on `provider !== null`. */\nexport const useChatProvider = (): ChatProviderInfo | null => channel.use();\n\n/**\n * React hook returning the three-state description.\n *\n * Use this to render provider state honestly: show nothing (or a neutral placeholder)\n * while `unknown`, the connect affordance only on `not-configured`, and the provider's\n * name on `configured`.\n */\nexport const useChatProviderState = (): ChatProviderState => stateOf(channel.use());\n"],"mappings":";AAeA,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,cAAc,4BAA4B;AAiF5C,SAAS,KAAK,KAA+D;AAGlF,QAAM,EAAE,QAAQ,GAAG,OAAO,IAAI;AAC9B,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAsDA,IAAI,WAAW;AACf,MAAM,UAAU,kBAA2C;AAAA,EACzD,UAAU;AAAA,EACV,aAAa;AAAA,EACb,SAAS;AAAA,EACT,OAAO,CAAC,QAAQ;AACd,QAAI,EAAE,cAAc,KAAM,QAAO;AACjC,eAAW;AACX,WAAQ,IAAI,YAAwC;AAAA,EACtD;AACF,CAAC;AAGD,MAAM,UAAU,CAAC,aACf,CAAC,WAAW,EAAE,QAAQ,UAAU,IAAI,WAAW,EAAE,QAAQ,cAAc,SAAS,IAAI,EAAE,QAAQ,iBAAiB;AAU1G,MAAM,eAAe,MAA+B,QAAQ,IAAI;AAGhE,MAAM,oBAAoB,MAAyB,QAAQ,QAAQ,IAAI,CAAC;AAIxE,MAAM,uBAAuB,CAClC,aACiB,QAAQ,SAAS,QAAQ;AAGrC,MAAM,4BAA4B,CACvC,aACiB,QAAQ,SAAS,CAAC,MAAM,SAAS,QAAQ,CAAC,CAAC,CAAC;AAIxD,MAAM,kBAAkB,MAA+B,QAAQ,IAAI;AASnE,MAAM,uBAAuB,MAAyB,QAAQ,QAAQ,IAAI,CAAC;","names":[]}
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var protocolDeadline_exports = {};
20
+ __export(protocolDeadline_exports, {
21
+ ATTENDED_FIRST_FRAME_MS: () => ATTENDED_FIRST_FRAME_MS,
22
+ ATTENDED_TIMEOUT_MS: () => ATTENDED_TIMEOUT_MS,
23
+ NETWORK_TIMEOUT_MS: () => NETWORK_TIMEOUT_MS,
24
+ PENDING_NOTICE_MS: () => PENDING_NOTICE_MS,
25
+ ProtocolCancelledError: () => ProtocolCancelledError,
26
+ ProtocolTimeoutError: () => ProtocolTimeoutError,
27
+ STREAM_IDLE_TIMEOUT_MS: () => STREAM_IDLE_TIMEOUT_MS,
28
+ UNATTENDED_TIMEOUT_MS: () => UNATTENDED_TIMEOUT_MS,
29
+ attendanceOf: () => attendanceOf,
30
+ attendanceReason: () => attendanceReason,
31
+ boundsFor: () => boundsFor,
32
+ createSuspendableDeadline: () => createSuspendableDeadline,
33
+ firstFrameBoundsFor: () => firstFrameBoundsFor,
34
+ firstFrameTimeoutFor: () => firstFrameTimeoutFor,
35
+ timeoutFor: () => timeoutFor
36
+ });
37
+ module.exports = __toCommonJS(protocolDeadline_exports);
38
+ const UNATTENDED_TIMEOUT_MS = 3e4;
39
+ const NETWORK_TIMEOUT_MS = 12e4;
40
+ const ATTENDED_TIMEOUT_MS = 6e5;
41
+ const ATTENDED_FIRST_FRAME_MS = 3e5;
42
+ const STREAM_IDLE_TIMEOUT_MS = 12e4;
43
+ const PENDING_NOTICE_MS = 3e3;
44
+ const ATTENDED = {
45
+ // The powerbox and the add-secret modal are host-drawn and wait for the user to type or
46
+ // pick; the first use of any stored secret additionally raises a WebAuthn assertion
47
+ // (SECRETS_SPEC §3 — one unlock per session, from a live gesture). All three are wrapped
48
+ // presenters, so the signal covers this scheme completely.
49
+ secrets: {
50
+ reason: "host-drawn key entry / picker, and the per-session passkey unlock",
51
+ idleMs: UNATTENDED_TIMEOUT_MS
52
+ },
53
+ // Consent is raised INSIDE the request: presentMountConsent, presentGrantPicker,
54
+ // presentCreateConsent, presentShareDisclosure, presentReferenceConsent — every one of
55
+ // them a wrapped presenter. Unattended once the grant is held, attended on first use, and
56
+ // since R3-307 the host says which of those is happening.
57
+ spaces: {
58
+ reason: "first-use mount/share/create consent is drawn inside the request",
59
+ idleMs: UNATTENDED_TIMEOUT_MS
60
+ },
61
+ settings: {
62
+ reason: "settings verbs reach the same consent and picker surfaces as spaces",
63
+ idleMs: UNATTENDED_TIMEOUT_MS
64
+ },
65
+ // The contribute flow shows the full diff for approval before anything is written
66
+ // (TRUST_AND_SAFETY TS-19b: the approval MUST show the real diff, so a human reads it).
67
+ // NOT a wrapped presenter — no `idleMs`.
68
+ contribute: { reason: "the diff-approval step is a human read of the whole change" },
69
+ // A task is an app bound to a transient slot that the user interacts with; it returns
70
+ // when they finish, which is human-paced by construction. That is an APP's interaction,
71
+ // not a host prompt, so the attention channel never fires for it — no `idleMs`.
72
+ task: { reason: "a task app runs an interaction and returns when the user finishes" },
73
+ // Launching a target can raise consent for a not-yet-granted app — through the launch
74
+ // flow's own surface, not one of the wrapped presenters. No `idleMs`.
75
+ launch: { reason: "may raise first-use consent for the launched target" },
76
+ // A drag is a gesture in progress — its duration is the user's hand, and no host prompt
77
+ // is up while it happens. No `idleMs`.
78
+ dnd: { reason: "a drag is a human gesture in flight" },
79
+ // The chat stream's FIRST frame sits behind the session's first passkey unseal — the
80
+ // exact hang the dogfood run found — and that unseal IS a wrapped presenter. But the idle
81
+ // bound here is the NETWORK one, not the channel one: with no prompt up, this call is
82
+ // waiting on an arbitrary upstream model, and thirty seconds is a normal generation.
83
+ llm: {
84
+ reason: "the first frame can sit behind the session passkey unseal",
85
+ idleMs: NETWORK_TIMEOUT_MS
86
+ }
87
+ };
88
+ function attendedEntry(scheme, method) {
89
+ return ATTENDED[`${scheme}:${method}`] ?? ATTENDED[scheme];
90
+ }
91
+ function attendedReason(scheme, method) {
92
+ return attendedEntry(scheme, method)?.reason;
93
+ }
94
+ function attendanceOf(scheme, method) {
95
+ return attendedReason(scheme, method) ? "attended" : "unattended";
96
+ }
97
+ function attendanceReason(scheme, method) {
98
+ return attendedReason(scheme, method);
99
+ }
100
+ function timeoutFor(scheme, method) {
101
+ if (attendanceOf(scheme, method) === "attended") return ATTENDED_TIMEOUT_MS;
102
+ if (scheme === "fetch") return NETWORK_TIMEOUT_MS;
103
+ return UNATTENDED_TIMEOUT_MS;
104
+ }
105
+ function firstFrameTimeoutFor(scheme, method) {
106
+ return attendanceOf(scheme, method) === "attended" ? ATTENDED_FIRST_FRAME_MS : NETWORK_TIMEOUT_MS;
107
+ }
108
+ function boundsFor(scheme, method) {
109
+ const ceilingMs = timeoutFor(scheme, method);
110
+ const idleMs = attendedEntry(scheme, method)?.idleMs;
111
+ return { idleMs: idleMs === void 0 ? ceilingMs : Math.min(idleMs, ceilingMs), ceilingMs };
112
+ }
113
+ function firstFrameBoundsFor(scheme, method) {
114
+ const ceilingMs = firstFrameTimeoutFor(scheme, method);
115
+ const idleMs = attendedEntry(scheme, method)?.idleMs;
116
+ return { idleMs: idleMs === void 0 ? ceilingMs : Math.min(idleMs, ceilingMs), ceilingMs };
117
+ }
118
+ function createSuspendableDeadline(opts) {
119
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
120
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
121
+ const { idleMs, ceilingMs } = opts.bounds;
122
+ const hasIdleLeg = Number.isFinite(idleMs) && idleMs < ceilingMs;
123
+ let done = false;
124
+ let idle;
125
+ let ceiling;
126
+ const expire = (bound, boundMs) => {
127
+ if (done) return;
128
+ done = true;
129
+ opts.onExpire(bound, boundMs);
130
+ };
131
+ const armIdle = () => {
132
+ if (done || !hasIdleLeg || idle !== void 0) return;
133
+ idle = setTimer(() => {
134
+ idle = void 0;
135
+ expire("idle", idleMs);
136
+ }, idleMs);
137
+ };
138
+ const disarmIdle = () => {
139
+ if (idle !== void 0) {
140
+ clearTimer(idle);
141
+ idle = void 0;
142
+ }
143
+ };
144
+ if (Number.isFinite(ceilingMs)) {
145
+ ceiling = setTimer(() => {
146
+ ceiling = void 0;
147
+ expire("ceiling", ceilingMs);
148
+ }, ceilingMs);
149
+ }
150
+ armIdle();
151
+ return {
152
+ setAwaiting(awaiting) {
153
+ if (done) return;
154
+ if (awaiting) disarmIdle();
155
+ else armIdle();
156
+ },
157
+ dispose() {
158
+ done = true;
159
+ disarmIdle();
160
+ if (ceiling !== void 0) {
161
+ clearTimer(ceiling);
162
+ ceiling = void 0;
163
+ }
164
+ }
165
+ };
166
+ }
167
+ class ProtocolTimeoutError extends Error {
168
+ constructor(call, timeoutMs, attendance, bound = attendance === "attended" ? "ceiling" : "idle") {
169
+ super(
170
+ attendance === "attended" && bound === "ceiling" ? `immediately.run: ${call} was abandoned after ${Math.round(timeoutMs / 1e3)}s waiting for you` : `immediately.run: ${call} did not respond within ${Math.round(timeoutMs / 1e3)}s`
171
+ );
172
+ this.code = "timeout";
173
+ this.name = "ProtocolTimeoutError";
174
+ this.call = call;
175
+ this.timeoutMs = timeoutMs;
176
+ this.attendance = attendance;
177
+ this.bound = bound;
178
+ }
179
+ }
180
+ class ProtocolCancelledError extends Error {
181
+ constructor(call) {
182
+ super(`immediately.run: ${call} was cancelled`);
183
+ this.code = "cancelled";
184
+ this.name = "ProtocolCancelledError";
185
+ }
186
+ }
187
+ // Annotate the CommonJS export names for ESM import in node:
188
+ 0 && (module.exports = {
189
+ ATTENDED_FIRST_FRAME_MS,
190
+ ATTENDED_TIMEOUT_MS,
191
+ NETWORK_TIMEOUT_MS,
192
+ PENDING_NOTICE_MS,
193
+ ProtocolCancelledError,
194
+ ProtocolTimeoutError,
195
+ STREAM_IDLE_TIMEOUT_MS,
196
+ UNATTENDED_TIMEOUT_MS,
197
+ attendanceOf,
198
+ attendanceReason,
199
+ boundsFor,
200
+ createSuspendableDeadline,
201
+ firstFrameBoundsFor,
202
+ firstFrameTimeoutFor,
203
+ timeoutFor
204
+ });
205
+ //# sourceMappingURL=protocolDeadline.cjs.map