@code-yeongyu/senpi 2026.10.10-4 → 2026.10.10-6

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 (130) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/bundle/chunks/{anthropic-messages-OE3QBX4P.js → anthropic-messages-DUULMQBF.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-N3L2LNP7.js → app-server-command-GJ5NXQSY.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
  6. package/dist/bundle/chunks/{chunk-MP2DMTI7.js → chunk-C7LEZMWS.js} +124 -124
  7. package/dist/bundle/chunks/{chunk-ZSYRWQWG.js → chunk-JMI5OCIJ.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-ODYQK5XJ.js → chunk-TALS4X7S.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-7GVD54RM.js → chunk-U3IL5N76.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-H67EVDWG.js → chunk-URQSNRHZ.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-VGYIPYOK.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-4TK7VCZZ.js → chunk-WKMUAG23.js} +1 -1
  14. package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
  15. package/dist/bundle/chunks/chunk-YV2XQFXG.js +14 -0
  16. package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-ZYHIRFGT.js} +1 -1
  17. package/dist/bundle/chunks/{cli-main-CHDR33FZ.js → cli-main-SZ54SQLA.js} +1 -1
  18. package/dist/bundle/chunks/{help-fast-path-EHRZEBRO.js → help-fast-path-W5W3FYYM.js} +1 -1
  19. package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-YG2FIDPN.js} +1 -1
  20. package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
  21. package/dist/bundle/chunks/{interactive-mode-RVEM62WX.js → interactive-mode-SCNDQ4R6.js} +1 -1
  22. package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
  23. package/dist/bundle/chunks/{multi-session-host-CFND7CXH.js → multi-session-host-ZDZMPNP5.js} +7 -6
  24. package/dist/bundle/chunks/{package-manager-cli-NWNECBF7.js → package-manager-cli-4LMYHHKH.js} +1 -1
  25. package/dist/bundle/chunks/{rotation-stream-C5BM7TMM.js → rotation-stream-E3XKSST7.js} +1 -1
  26. package/dist/bundle/chunks/{rpc-mode-JQFUDLNB.js → rpc-mode-ZFNFREN5.js} +1 -1
  27. package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
  28. package/dist/bundle/chunks/{session-control-endpoint-HMB5T5VD.js → session-control-endpoint-MVSYBGM3.js} +1 -1
  29. package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-TNXWY5QY.js} +1 -1
  30. package/dist/bundle/chunks/session-worker.js +224 -222
  31. package/dist/bundle/cli.js +1 -1
  32. package/dist/bundle/index.js +1 -1
  33. package/dist/bundle/rpc-entry.js +1 -1
  34. package/dist/bundle/runtime-manifest.json +1 -1
  35. package/dist/core/agent-session-runtime.d.ts +6 -0
  36. package/dist/core/agent-session-runtime.js +18 -0
  37. package/dist/core/agent-session.js +5 -2
  38. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +3 -2
  39. package/dist/core/extensions/builtin/anthropic-subscription/options.js +11 -2
  40. package/dist/core/extensions/builtin/anthropic-subscription/session-reattach.js +4 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +3 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +5 -0
  43. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +11 -0
  44. package/dist/core/extensions/builtin/anthropic-subscription/stream.d.ts +1 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +17 -1
  46. package/dist/core/extensions/builtin/compaction/lane-policy.d.ts +20 -12
  47. package/dist/core/extensions/builtin/compaction/lane-policy.js +39 -34
  48. package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
  49. package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
  50. package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
  51. package/dist/core/extensions/builtin/html-render/images.js +155 -0
  52. package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
  53. package/dist/core/extensions/builtin/html-render/index.js +5 -0
  54. package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
  55. package/dist/core/extensions/builtin/html-render/tool.js +74 -0
  56. package/dist/core/extensions/builtin/index.js +4 -0
  57. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.d.ts +4 -0
  58. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +41 -0
  59. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.d.ts +3 -0
  60. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.js +39 -0
  61. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.d.ts +14 -0
  62. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.js +91 -0
  63. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +1 -1
  64. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +1 -0
  65. package/dist/core/extensions/builtin/mcp/auth/token-store.js +19 -90
  66. package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +7 -3
  67. package/dist/core/extensions/builtin/mcp/catalog-cache.js +61 -12
  68. package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -1
  69. package/dist/core/extensions/builtin/mcp/catalog.js +2 -0
  70. package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
  71. package/dist/core/extensions/builtin/mcp/expose/call.d.ts +12 -0
  72. package/dist/core/extensions/builtin/mcp/expose/call.js +60 -0
  73. package/dist/core/extensions/builtin/mcp/expose/proxy.js +16 -3
  74. package/dist/core/extensions/builtin/mcp/expose/register.d.ts +3 -1
  75. package/dist/core/extensions/builtin/mcp/expose/register.js +41 -50
  76. package/dist/core/extensions/builtin/mcp/expose/session.d.ts +4 -0
  77. package/dist/core/extensions/builtin/mcp/expose/session.js +5 -1
  78. package/dist/core/extensions/builtin/mcp/expose/tier-b.js +15 -4
  79. package/dist/core/extensions/builtin/mcp/index.js +5 -26
  80. package/dist/core/extensions/builtin/mcp/invocation.d.ts +42 -0
  81. package/dist/core/extensions/builtin/mcp/invocation.js +118 -0
  82. package/dist/core/extensions/builtin/mcp/service-connection.d.ts +3 -0
  83. package/dist/core/extensions/builtin/mcp/service-connection.js +6 -0
  84. package/dist/core/extensions/builtin/mcp/service-register.d.ts +4 -0
  85. package/dist/core/extensions/builtin/mcp/service-register.js +45 -1
  86. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +26 -3
  87. package/dist/core/extensions/builtin/mcp/service-types.d.ts +6 -0
  88. package/dist/core/extensions/builtin/mcp/service.js +83 -49
  89. package/dist/core/extensions/builtin/mcp/shared-connection.js +17 -4
  90. package/dist/core/extensions/builtin/mcp/sharing-policy.js +1 -0
  91. package/dist/core/extensions/builtin/mcp/startup-race.js +47 -9
  92. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.d.ts +8 -0
  93. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.js +23 -0
  94. package/dist/core/extensions/builtin/permission-system/dispatch-policy.d.ts +14 -0
  95. package/dist/core/extensions/builtin/permission-system/dispatch-policy.js +53 -0
  96. package/dist/core/extensions/builtin/permission-system/dispatch.d.ts +33 -0
  97. package/dist/core/extensions/builtin/permission-system/dispatch.js +106 -0
  98. package/dist/core/extensions/builtin/permission-system/index.js +64 -17
  99. package/dist/core/extensions/builtin/permission-system/prompt.d.ts +1 -1
  100. package/dist/core/extensions/builtin/permission-system/prompt.js +3 -1
  101. package/dist/core/extensions/builtin/permission-system/service.d.ts +11 -1
  102. package/dist/core/extensions/builtin/permission-system/service.js +14 -4
  103. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.d.ts +1 -0
  104. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.js +24 -5
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.js +1 -1
  107. package/dist/modes/interactive/interactive-mode.js +21 -4
  108. package/dist/modes/rpc/host-reservations.d.ts +1 -1
  109. package/dist/modes/rpc/host-reservations.js +9 -3
  110. package/dist/modes/rpc/rpc-client.js +14 -2
  111. package/dist/modes/rpc/rpc-request-deadline.d.ts +8 -0
  112. package/dist/modes/rpc/rpc-request-deadline.js +10 -0
  113. package/dist/modes/rpc/rpc-types.d.ts +7 -4
  114. package/dist/modes/rpc/session-binding.js +15 -1
  115. package/dist/modes/rpc/session-command-router.d.ts +1 -0
  116. package/dist/modes/rpc/session-command-router.js +16 -0
  117. package/dist/modes/rpc/session-registry-attach.js +8 -2
  118. package/dist/modes/rpc/session-registry.js +5 -7
  119. package/dist/modes/rpc/session-teardown.d.ts +6 -1
  120. package/dist/modes/rpc/session-teardown.js +26 -7
  121. package/dist/modes/rpc/session-worker-client.d.ts +2 -0
  122. package/dist/modes/rpc/session-worker-client.js +6 -0
  123. package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
  124. package/dist/modes/rpc/session-worker.js +6 -0
  125. package/dist/modes/rpc/worker-session-registry.js +6 -1
  126. package/docs/providers.md +3 -2
  127. package/docs/rpc.md +26 -10
  128. package/package.json +6 -6
  129. package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
  130. package/dist/bundle/chunks/chunk-USRAHAW4.js +0 -14
@@ -1,22 +1,28 @@
1
1
  /**
2
- * Provider-scoped compaction opt-out for the `anthropic-subscription` main lane.
2
+ * Provider-scoped compaction ownership for the `anthropic-subscription` main lane.
3
3
  *
4
- * That lane keeps one resident SDK session per senpi session, and the Claude
5
- * Agent SDK runs its own native auto-compaction over that session's transcript.
6
- * Running senpi's compaction on top of it would rewrite a history senpi no
7
- * longer owns, so senpi stands down for the lane: no auto-compaction triggers,
8
- * no context reduction. Every other provider is untouched.
4
+ * That lane keeps one resident SDK session per senpi session. By default senpi
5
+ * owns its compaction (speculative, idle, restoration, degradation recovery): an
6
+ * accepted compaction invalidates the resident binding and the next turn
7
+ * cold-seeds the compacted branch (`tainted_compaction` always flattens), while
8
+ * the SDK's native auto-compact is pinned off so exactly one side compacts.
9
9
  *
10
- * The stand-down is conditional on the lane actually being resident: with the
11
- * `resumeMode: "off"` escape hatch senpi flattens its own history into every
12
- * request, so senpi's compaction must stay fully active there.
10
+ * `compactionOwner: "sdk"` opts out: the Claude Agent SDK runs its own native
11
+ * auto-compaction over the resident transcript and senpi stands down for the
12
+ * lane (no auto-compaction triggers, no context reduction). The stand-down lifts
13
+ * with the `resumeMode: "off"` escape hatch, where senpi flattens its own
14
+ * history into every request. Every other provider is untouched.
15
+ *
16
+ * The owner is read per call, from the same settings snapshot the query options
17
+ * use for the next turn, so a mid-session change can never leave the two sides
18
+ * disagreeing about who compacts.
13
19
  *
14
20
  * This module also owns the shape of the mirrored `compact_boundary` ledger
15
21
  * entry, so the SDK's native compactions stay visible in senpi history.
16
22
  */
17
23
  import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "../anthropic-subscription/account-management.js";
18
24
  import { isColdSeedOverflowMessage } from "../anthropic-subscription/cold-seed-budget.js";
19
- import { loadAnthropicSubscriptionProviderSettingsFromDisk } from "../anthropic-subscription/settings.js";
25
+ import { loadAnthropicSubscriptionProviderSettingsFromDisk, resolveCompactionOwner, } from "../anthropic-subscription/settings.js";
20
26
  /** Custom session entry type carrying a mirrored SDK compaction boundary. */
21
27
  export const ANTHROPIC_SUBSCRIPTION_COMPACT_ENTRY_TYPE = "claude-sdk-oauth-compact";
22
28
  /** Assistant-message diagnostic the lane uses to transport a received boundary. */
@@ -55,33 +61,27 @@ function isRecord(value) {
55
61
  export function isSdkNativeCompactionLane(input) {
56
62
  if (input.model?.provider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
57
63
  return false;
58
- return input.resumeMode !== "off";
64
+ return input.compactionOwner === "sdk" && input.resumeMode !== "off";
59
65
  }
60
66
  /**
61
- * Per-extension-instance policy holding one memoized provider-settings read.
62
- * Settings are only read when the active model actually belongs to the lane, so
63
- * other providers never pay for the lookup.
67
+ * Per-extension-instance lane policy. Settings are only read when the active
68
+ * model actually belongs to the lane, so other providers never pay for the
69
+ * lookup; the loader itself is cached by settings-file revision.
64
70
  */
65
71
  export function createCompactionLanePolicy(options = {}) {
66
72
  const load = options.loadProviderSettings ?? loadAnthropicSubscriptionProviderSettingsFromDisk;
67
- let cachedCwd;
68
- let cachedResumeMode;
69
- // Per-cwd cache is the intended contract (pinned by lane-policy.test.ts):
70
- // resumeMode is read once per cwd. A mid-session switch takes effect on
71
- // the next cwd or session. Returns false when the settings cannot be read,
72
- // so callers fail closed to senpi's full behavior.
73
- const resolveResumeMode = (cwd) => {
74
- if (cachedCwd === cwd)
75
- return true;
73
+ // Read on every call, never memoized here: the query options re-read the same
74
+ // settings for every turn, and a cached owner would let a mid-session change
75
+ // leave senpi and the resident process disagreeing about who compacts. Returns
76
+ // undefined when the settings cannot be read, so callers fail closed to
77
+ // senpi's full behavior.
78
+ const readProviderSettings = (cwd) => {
76
79
  try {
77
- cachedResumeMode = load(cwd).resumeMode;
80
+ return load(cwd);
78
81
  }
79
82
  catch {
80
- cachedCwd = undefined;
81
- return false;
83
+ return undefined;
82
84
  }
83
- cachedCwd = cwd;
84
- return true;
85
85
  };
86
86
  // Declared as a local so `ownsCompaction` never depends on `this`: the policy object
87
87
  // is routinely destructured at call sites, which would otherwise unbind the receiver.
@@ -93,20 +93,25 @@ export function createCompactionLanePolicy(options = {}) {
93
93
  // the escape hatch for lanes whose SDK never fires native compaction.
94
94
  if (context.getCompactionSettings?.().model)
95
95
  return false;
96
- // A settings read failure must never silently disable senpi compaction:
97
- // fail closed by keeping senpi's own compaction fully active.
98
- if (!resolveResumeMode(context.cwd))
96
+ // A settings read failure must never silently disable senpi compaction.
97
+ const providerSettings = readProviderSettings(context.cwd);
98
+ if (!providerSettings)
99
99
  return false;
100
- return isSdkNativeCompactionLane({ model: context.model, resumeMode: cachedResumeMode });
100
+ return isSdkNativeCompactionLane({
101
+ model: context.model,
102
+ resumeMode: providerSettings.resumeMode,
103
+ compactionOwner: resolveCompactionOwner(providerSettings),
104
+ });
101
105
  };
102
106
  return {
103
107
  disablesSenpiCompaction,
104
108
  hasAppendOnlyTranscript(context) {
105
109
  if (context.model?.provider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
106
110
  return false;
107
- if (!resolveResumeMode(context.cwd))
111
+ const providerSettings = readProviderSettings(context.cwd);
112
+ if (!providerSettings)
108
113
  return false;
109
- return cachedResumeMode !== "off";
114
+ return providerSettings.resumeMode !== "off";
110
115
  },
111
116
  ownsCompaction(context, reason) {
112
117
  // Manual is senpi-owned everywhere: it is the user's explicit recovery path,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A page is a static snapshot: everything it shows is in the document (inline,
3
+ * data: or blob:), and it reaches no network. Its scripts run whenever it
4
+ * opens, but fetch, XHR, WebSocket, EventSource, remote scripts, styles,
5
+ * images and fonts, frames, form posts and <base> are refused. The policy is
6
+ * the document's first element, so nothing the page writes loads before it.
7
+ * Mirrors the desktop's packages/shared/src/htmlRenderBootstrap.ts.
8
+ */
9
+ export declare const HTML_RENDER_CONTENT_SECURITY_POLICY: string;
10
+ /**
11
+ * Inserts the theme bootstrap at the start of the document head, and the
12
+ * snapshot policy ahead of everything the page wrote.
13
+ */
14
+ export declare function injectHtmlRenderBootstrap(html: string): string;
15
+ //# sourceMappingURL=bootstrap.d.ts.map
@@ -0,0 +1,123 @@
1
+ // The theme bootstrap injection and base stylesheet, mirroring the desktop's
2
+ // packages/shared/src/htmlRender.ts (upstream t3code #15968). Kept small and
3
+ // dependency-light: pure string functions, no Effect, no server. A page the
4
+ // agent writes here is the same shape the desktop stores on publish.
5
+ const BASE_CSS = "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" +
6
+ "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}";
7
+ const DARK_VARS = {
8
+ "--background": "#0a0a0a",
9
+ "--foreground": "#f5f5f5",
10
+ "--muted-foreground": "#818181",
11
+ "--chart-1": "#60a5fa",
12
+ "--font-sans": '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
13
+ "--font-mono": '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
14
+ "--radius": "0.625rem",
15
+ };
16
+ const LIGHT_VARS = {
17
+ ...DARK_VARS,
18
+ "--background": "#ffffff",
19
+ "--foreground": "#0a0a0a",
20
+ "--muted-foreground": "#525252",
21
+ "--chart-1": "#1d4ed8",
22
+ };
23
+ function rootRule(appearance, variables) {
24
+ const declarations = Object.entries(variables)
25
+ .map(([name, value]) => `${name}:${value};`)
26
+ .join("");
27
+ return `:root{color-scheme:${appearance};${declarations}}`;
28
+ }
29
+ const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme");if(!s)return;var b=${JSON.stringify(BASE_CSS)};try{var m=window.matchMedia("(prefers-color-scheme: light)");var a=function(){s.textContent=m.matches?${JSON.stringify(rootRule("light", LIGHT_VARS))}:${JSON.stringify(rootRule("dark", DARK_VARS))}+b;};a();if(m.addEventListener)m.addEventListener("change",a);}catch(e){}})();`;
30
+ function bootstrapMarkup(markup) {
31
+ const defaultCss = `${rootRule("dark", DARK_VARS)}@media (prefers-color-scheme: light){${rootRule("light", LIGHT_VARS)}}${BASE_CSS}`;
32
+ return [
33
+ /<meta\s[^>]*charset/i.test(markup.slice(0, 4096)) ? "" : '<meta charset="utf-8">',
34
+ /<meta\s[^>]*name\s*=\s*["']?viewport/i.test(markup)
35
+ ? ""
36
+ : '<meta name="viewport" content="width=device-width, initial-scale=1">',
37
+ `<style id="t3-theme">${defaultCss}</style>`,
38
+ `<script>${BOOTSTRAP_SCRIPT}</script>`,
39
+ ].join("");
40
+ }
41
+ const blankNonMarkup = (html) => {
42
+ const scan = html.replace(/<!--[\s\S]*?(?:-->|$)|<(script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)|<plaintext\b[\s\S]*$/gi, (match) => " ".repeat(match.length));
43
+ const parts = [];
44
+ let depth = 0;
45
+ let start = 0;
46
+ let at = 0;
47
+ for (const match of scan.matchAll(/<(\/?)template(?:\s[^>]*)?\/?>/gi)) {
48
+ if (!match[1]) {
49
+ if (depth++ === 0)
50
+ start = match.index;
51
+ }
52
+ else if (depth > 0 && --depth === 0) {
53
+ const end = match.index + match[0].length;
54
+ parts.push(scan.slice(at, start), " ".repeat(end - start));
55
+ at = end;
56
+ }
57
+ }
58
+ if (depth > 0) {
59
+ parts.push(scan.slice(at, start), " ".repeat(scan.length - start));
60
+ at = scan.length;
61
+ }
62
+ parts.push(scan.slice(at));
63
+ return parts.join("");
64
+ };
65
+ /**
66
+ * A page is a static snapshot: everything it shows is in the document (inline,
67
+ * data: or blob:), and it reaches no network. Its scripts run whenever it
68
+ * opens, but fetch, XHR, WebSocket, EventSource, remote scripts, styles,
69
+ * images and fonts, frames, form posts and <base> are refused. The policy is
70
+ * the document's first element, so nothing the page writes loads before it.
71
+ * Mirrors the desktop's packages/shared/src/htmlRenderBootstrap.ts.
72
+ */
73
+ export const HTML_RENDER_CONTENT_SECURITY_POLICY = [
74
+ "default-src 'none'",
75
+ "script-src 'unsafe-inline' 'unsafe-eval' data: blob:",
76
+ "style-src 'unsafe-inline' data:",
77
+ "img-src data: blob:",
78
+ "font-src data:",
79
+ "media-src data: blob:",
80
+ "worker-src blob:",
81
+ "connect-src 'none'",
82
+ "frame-src 'none'",
83
+ "form-action 'none'",
84
+ "base-uri 'none'",
85
+ ].join("; ");
86
+ const POLICY_META = `<meta http-equiv="Content-Security-Policy" content="${HTML_RENDER_CONTENT_SECURITY_POLICY}">`;
87
+ // Every written page starts with a UTF-8 byte order mark, a standards-mode
88
+ // doctype and the policy, in that order and in plain ASCII. The mark makes the
89
+ // browser decode the file as UTF-8 whatever charset the page declares, so no
90
+ // declared encoding can turn the policy into text, and nothing the page wrote
91
+ // comes before it. A leading doctype of the page's own is dropped only when it is
92
+ // printable ASCII; anything else stays where it is, after the policy, where the
93
+ // parser ignores a doctype.
94
+ const PREAMBLE = `\uFEFF<!doctype html>${POLICY_META}`;
95
+ const LEADING_ASCII_DOCTYPE = /^\uFEFF?[\t\n\f\r ]*<!doctype[ -=?-~]*>/i;
96
+ /**
97
+ * Inserts the theme bootstrap at the start of the document head, and the
98
+ * snapshot policy ahead of everything the page wrote.
99
+ */
100
+ export function injectHtmlRenderBootstrap(html) {
101
+ return PREAMBLE + injectThemeBootstrap(html).replace(LEADING_ASCII_DOCTYPE, "");
102
+ }
103
+ function injectThemeBootstrap(html) {
104
+ const scan = blankNonMarkup(html);
105
+ const markup = bootstrapMarkup(scan);
106
+ const headOpen = /<head(?:\s[^>]*)?>/i.exec(scan);
107
+ if (headOpen) {
108
+ const at = headOpen.index + headOpen[0].length;
109
+ return html.slice(0, at) + markup + html.slice(at);
110
+ }
111
+ const htmlOpen = /<html(?:\s[^>]*)?>/i.exec(scan);
112
+ if (htmlOpen) {
113
+ const at = htmlOpen.index + htmlOpen[0].length;
114
+ return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
115
+ }
116
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
117
+ if (doctype) {
118
+ const at = doctype[0].length;
119
+ return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
120
+ }
121
+ return `<!doctype html><head>${markup}</head>${html}`;
122
+ }
123
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1,16 @@
1
+ export declare class HtmlRenderImagesNotFoundError extends Error {
2
+ readonly paths: readonly string[];
3
+ constructor(paths: readonly string[]);
4
+ }
5
+ export declare class HtmlRenderImageTooLargeError extends Error {
6
+ constructor(path: string, sizeBytes: number);
7
+ }
8
+ export declare class HtmlRenderPageTooLargeError extends Error {
9
+ constructor(sizeBytes: number);
10
+ }
11
+ /** Replaces every local image reference with a data URI; unreadable paths throw. */
12
+ export declare function inlineLocalImages(html: string): Promise<{
13
+ html: string;
14
+ missing: string[];
15
+ }>;
16
+ //# sourceMappingURL=images.d.ts.map
@@ -0,0 +1,155 @@
1
+ // Local-image preparation, mirroring the desktop's apps/server/src/htmlRender/
2
+ // HtmlRender.ts (upstream t3code #15968): absolute-path images are inlined as
3
+ // data URIs only after a magic-byte/SVG-root check, so a renamed secret is
4
+ // refused, and the page stays within the size caps. Pure byte/string work.
5
+ import { readFile, stat } from "node:fs/promises";
6
+ const MIB = 1024 * 1024;
7
+ const MAX_IMAGE_BYTES = 10 * MIB;
8
+ const MAX_PAGE_BYTES = 25 * MIB;
9
+ export class HtmlRenderImagesNotFoundError extends Error {
10
+ constructor(paths) {
11
+ super(`These local images could not be read: ${paths.join(", ")}. Use absolute paths to existing image files, or remove them.`);
12
+ this.name = "HtmlRenderImagesNotFoundError";
13
+ this.paths = paths;
14
+ }
15
+ }
16
+ export class HtmlRenderImageTooLargeError extends Error {
17
+ constructor(path, sizeBytes) {
18
+ super(`${path} is ${(sizeBytes / MIB).toFixed(1)} MiB; each local image must be at most 10 MiB.`);
19
+ this.name = "HtmlRenderImageTooLargeError";
20
+ }
21
+ }
22
+ export class HtmlRenderPageTooLargeError extends Error {
23
+ constructor(sizeBytes) {
24
+ super(`With its images inlined the page is ${(sizeBytes / MIB).toFixed(1)} MiB; the limit is 25 MiB. Use smaller images.`);
25
+ this.name = "HtmlRenderPageTooLargeError";
26
+ }
27
+ }
28
+ const IMAGE_MIME_TYPES = {
29
+ png: "image/png",
30
+ jpg: "image/jpeg",
31
+ jpeg: "image/jpeg",
32
+ gif: "image/gif",
33
+ webp: "image/webp",
34
+ avif: "image/avif",
35
+ svg: "image/svg+xml",
36
+ bmp: "image/bmp",
37
+ ico: "image/x-icon",
38
+ };
39
+ const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|");
40
+ const ABSOLUTE_PATH = String.raw `(?:/(?!/)|[a-z]:[\\/])`;
41
+ const LOCAL_IMAGE_PATTERN = new RegExp(String.raw `(["'\x60])(${ABSOLUTE_PATH}(?:(?!\1)[^\r\n]){0,2048}?\.(?:${IMAGE_EXTENSIONS}))\1` +
42
+ String.raw `|url\(\s*(${ABSOLUTE_PATH}[^\s"'\x60()]{0,2048}?\.(?:${IMAGE_EXTENSIONS}))\s*\)`, "gid");
43
+ const findLocalImages = (html) => Array.from(html.matchAll(LOCAL_IMAGE_PATTERN)).flatMap((match) => {
44
+ const span = match.indices?.[2] ?? match.indices?.[3];
45
+ return span ? [{ start: span[0], end: span[1], path: html.slice(span[0], span[1]) }] : [];
46
+ });
47
+ const filePathFor = (reference) => /^[a-z]:/i.test(reference) ? reference.replaceAll("\\\\", "\\") : reference;
48
+ const dataUriPrefix = (path) => `data:${IMAGE_MIME_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"};base64,`;
49
+ const latin1 = (bytes, start, end) => String.fromCharCode(...bytes.subarray(start, end));
50
+ const after = (text, token, from) => {
51
+ const at = text.indexOf(token, from);
52
+ return at === -1 ? -1 : at + token.length;
53
+ };
54
+ const afterDoctype = (text, from) => {
55
+ let inSubset = false;
56
+ let at = from;
57
+ while (at !== -1 && at < text.length) {
58
+ const char = text[at];
59
+ if (char === '"' || char === "'")
60
+ at = after(text, char, at + 1);
61
+ else if (inSubset && text.startsWith("<!--", at))
62
+ at = after(text, "-->", at + 4);
63
+ else if (inSubset && text.startsWith("<?", at))
64
+ at = after(text, "?>", at + 2);
65
+ else if (char === ">" && !inSubset)
66
+ return at + 1;
67
+ else {
68
+ if (char === "[")
69
+ inSubset = true;
70
+ else if (char === "]")
71
+ inSubset = false;
72
+ at += 1;
73
+ }
74
+ }
75
+ return -1;
76
+ };
77
+ const hasSvgRoot = (text) => {
78
+ let at = 0;
79
+ while (at !== -1) {
80
+ while (/\s/.test(text.charAt(at)))
81
+ at += 1;
82
+ if (text.startsWith("<?", at))
83
+ at = after(text, "?>", at + 2);
84
+ else if (text.startsWith("<!--", at))
85
+ at = after(text, "-->", at + 4);
86
+ else if (text.slice(at, at + 9).toLowerCase() === "<!doctype")
87
+ at = afterDoctype(text, at + 9);
88
+ else
89
+ return /^<svg[ \t\r\n/>]/.test(text.slice(at, at + 5));
90
+ }
91
+ return false;
92
+ };
93
+ /** Whether file bytes are an image, whatever the file is named. */
94
+ const isImageBytes = (bytes) => {
95
+ const head = latin1(bytes, 0, 12);
96
+ if (head.startsWith("\x89PNG") ||
97
+ head.startsWith("\xff\xd8\xff") ||
98
+ head.startsWith("GIF8") ||
99
+ head.startsWith("\0\0\x01\0") ||
100
+ (head.startsWith("BM") && head.slice(6, 10) === "\0\0\0\0") ||
101
+ (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") ||
102
+ /^ftyp(?:avif|avis|mif1)$/.test(head.slice(4, 12))) {
103
+ return true;
104
+ }
105
+ return hasSvgRoot(new TextDecoder().decode(bytes.subarray(0, 4096)));
106
+ };
107
+ /** Replaces every local image reference with a data URI; unreadable paths throw. */
108
+ export async function inlineLocalImages(html) {
109
+ const references = findLocalImages(html);
110
+ const uniquePaths = [...new Set(references.map((reference) => reference.path))];
111
+ const files = await Promise.all(uniquePaths.map(async (path) => {
112
+ try {
113
+ const info = await stat(filePathFor(path));
114
+ return { path, size: info.isFile() ? Number(info.size) : undefined };
115
+ }
116
+ catch {
117
+ return { path, size: undefined };
118
+ }
119
+ }));
120
+ const oversized = files.find((file) => file.size !== undefined && file.size > MAX_IMAGE_BYTES);
121
+ if (oversized?.size !== undefined) {
122
+ throw new HtmlRenderImageTooLargeError(oversized.path, oversized.size);
123
+ }
124
+ const images = new Map();
125
+ let readBytes = 0;
126
+ for (const file of files.filter((entry) => entry.size !== undefined)) {
127
+ const bytes = new Uint8Array(await readFile(filePathFor(file.path)));
128
+ if (!isImageBytes(bytes))
129
+ continue;
130
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
131
+ throw new HtmlRenderImageTooLargeError(file.path, bytes.byteLength);
132
+ }
133
+ readBytes += Math.ceil(bytes.byteLength / 3) * 4;
134
+ if (readBytes > MAX_PAGE_BYTES)
135
+ throw new HtmlRenderPageTooLargeError(readBytes);
136
+ images.set(file.path, bytes);
137
+ }
138
+ const parts = [];
139
+ let cursor = 0;
140
+ for (const reference of references) {
141
+ const bytes = images.get(reference.path);
142
+ if (bytes === undefined)
143
+ continue;
144
+ parts.push(html.slice(cursor, reference.start), dataUriPrefix(reference.path) + Buffer.from(bytes).toString("base64"));
145
+ cursor = reference.end;
146
+ }
147
+ parts.push(html.slice(cursor));
148
+ const inlined = parts.join("");
149
+ if (Buffer.byteLength(inlined) > MAX_PAGE_BYTES) {
150
+ throw new HtmlRenderPageTooLargeError(Buffer.byteLength(inlined));
151
+ }
152
+ const missing = files.filter((file) => !images.has(file.path)).map((file) => file.path);
153
+ return { html: inlined, missing };
154
+ }
155
+ //# sourceMappingURL=images.js.map
@@ -0,0 +1,3 @@
1
+ import type { ExtensionAPI } from "../../types.ts";
2
+ export default function htmlRenderExtension(pi: ExtensionAPI): void;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ import { showHtmlPageTool } from "./tool.js";
2
+ export default function htmlRenderExtension(pi) {
3
+ pi.registerTool(showHtmlPageTool);
4
+ }
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ import { Type } from "typebox";
2
+ export interface ShowHtmlPageDetails {
3
+ path: string;
4
+ title: string;
5
+ height: number | undefined;
6
+ prepared: boolean;
7
+ missingImages?: string[];
8
+ /**
9
+ * The page as the agent wrote it, for a host that publishes it itself (the
10
+ * desktop renders it in the thread). Never part of the model-visible content.
11
+ */
12
+ html: string;
13
+ }
14
+ export declare const showHtmlPageTool: import("../../types.ts").ToolDefinition<Type.TObject<{
15
+ html: Type.TString;
16
+ title: Type.TString;
17
+ height: Type.TOptional<Type.TInteger>;
18
+ }>, ShowHtmlPageDetails, any> & import("../../types.ts").ToolDefinition<any, any, any>;
19
+ //# sourceMappingURL=tool.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { defineTool } from "../../types.js";
5
+ import { injectHtmlRenderBootstrap } from "./bootstrap.js";
6
+ import { inlineLocalImages } from "./images.js";
7
+ // The same input limit as the desktop's html_render. Images belong as absolute
8
+ // file paths, which are inlined after the check, so the document itself stays
9
+ // small: it travels in the tool call and again in the result details.
10
+ const MAX_HTML_CHARACTERS = 512_000;
11
+ const Params = Type.Object({
12
+ html: Type.String({
13
+ description: "A complete, self-contained HTML document.",
14
+ minLength: 1,
15
+ maxLength: MAX_HTML_CHARACTERS,
16
+ }),
17
+ title: Type.String({ description: "Short name for the page.", minLength: 1, maxLength: 200 }),
18
+ height: Type.Optional(Type.Integer({
19
+ description: "Frame height hint in CSS pixels, 80-2000. Defaults to the page's natural height.",
20
+ })),
21
+ });
22
+ const OUTPUT_DIR = ".senpi/html-pages";
23
+ const clampHeight = (height) => height === undefined ? undefined : Math.min(2000, Math.max(80, Math.round(height)));
24
+ export const showHtmlPageTool = defineTool({
25
+ name: "show_html_page",
26
+ label: "Show HTML Page",
27
+ description: "Show a finished self-contained HTML page (chart, table, diagram, mockup) to the reader. " +
28
+ "No network: inline every script, style and image (data: URIs). A <script src>, stylesheet link, font, image " +
29
+ "or fetch that points at a URL (a CDN included) is refused, and the page comes out blank or broken. " +
30
+ "Local images written as absolute file paths " +
31
+ "are inlined automatically after a byte check. In the desktop thread this renders inline above your " +
32
+ "reply; elsewhere it is written to a file you can open in the desktop.",
33
+ promptSnippet: "show_html_page: show a self-contained HTML page (chart/table/diagram) to the reader",
34
+ promptGuidelines: [
35
+ "No network: inline every script, style and image (data: URIs); a CDN <script src> or remote font is refused and leaves the page blank.",
36
+ "Absolute-path local images are inlined after a magic-byte check; a renamed non-image is refused.",
37
+ ],
38
+ parameters: Params,
39
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
40
+ const inlined = await inlineLocalImages(params.html);
41
+ const prepared = injectHtmlRenderBootstrap(inlined.html);
42
+ const height = clampHeight(params.height);
43
+ const dir = join(ctx.cwd, OUTPUT_DIR);
44
+ await mkdir(dir, { recursive: true });
45
+ const safeName = params.title
46
+ .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ")
47
+ .replace(/\s+/g, " ")
48
+ .trim()
49
+ .slice(0, 60) || "page";
50
+ const path = join(dir, `${safeName}-${Date.now().toString(36)}.html`);
51
+ await writeFile(path, prepared, "utf8");
52
+ const missing = inlined.missing.length === 0 ? undefined : inlined.missing;
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: `Wrote the page to ${path}. Open it in the desktop to see it rendered with your theme. ` +
58
+ (missing === undefined
59
+ ? "Local images were inlined."
60
+ : `Some local images could not be read and were left as written: ${missing.join(", ")}.`),
61
+ },
62
+ ],
63
+ details: {
64
+ path,
65
+ title: params.title,
66
+ height,
67
+ prepared: true,
68
+ ...(missing ? { missingImages: missing } : {}),
69
+ html: params.html,
70
+ },
71
+ };
72
+ },
73
+ });
74
+ //# sourceMappingURL=tool.js.map
@@ -19,6 +19,7 @@ import helpExtension from "./help/index.js";
19
19
  import herdrExtension from "./herdr/index.js";
20
20
  import historySearchExtension from "./history-search/index.js";
21
21
  import hooksExtension from "./hooks/index.js";
22
+ import htmlRenderExtension from "./html-render/index.js";
22
23
  import imageGenExtension from "./imagegen/index.js";
23
24
  import importReproExtension from "./import-repro.js";
24
25
  import lookAtExtension from "./look-at/index.js";
@@ -103,6 +104,9 @@ export const builtinExtensions = [
103
104
  { id: "cache-keepalive", factory: cacheKeepAliveExtension },
104
105
  { id: "ttsr", factory: ttsrExtension },
105
106
  { id: "btw", factory: btwExtension },
107
+ // HTML page rendering for standalone senpi (TUI/local); the desktop thread reaches the
108
+ // same capability through the desktop's MCP server instead.
109
+ { id: "html-render", factory: htmlRenderExtension },
106
110
  // Provider-neutral account listing; sits before the provider lanes so their
107
111
  // dedicated commands (claude-account, cursor accounts) keep their own names.
108
112
  { id: "account", factory: accountExtension },
@@ -0,0 +1,4 @@
1
+ import type { McpServerConfig } from "../config-schema.ts";
2
+ /** Local snapshot only: never migrates credentials, acquires a lock, or refreshes authentication. */
3
+ export declare function mcpCredentialIdentity(config: McpServerConfig, serverName: string, agentDir: string | undefined, env: Record<string, string | undefined> | undefined): string | undefined;
4
+ //# sourceMappingURL=catalog-identity.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolveAuthMode } from "./context.js";
4
+ import { McpTokenStore } from "./token-store.js";
5
+ /** Local snapshot only: never migrates credentials, acquires a lock, or refreshes authentication. */
6
+ export function mcpCredentialIdentity(config, serverName, agentDir, env) {
7
+ const mode = resolveAuthMode(config);
8
+ if (mode === "none")
9
+ return "none";
10
+ if (mode === "bearer") {
11
+ const name = config.bearerTokenEnv;
12
+ if (name === undefined)
13
+ return undefined;
14
+ const token = env?.[name] ?? process.env[name];
15
+ if (token === undefined || token.length === 0)
16
+ return undefined;
17
+ return createHash("sha256")
18
+ .update(JSON.stringify(["mcp-catalog-bearer", token]))
19
+ .digest("hex");
20
+ }
21
+ try {
22
+ const store = new McpTokenStore({ serverName, serverUrl: config.url ?? "", agentDir });
23
+ const value = JSON.parse(readFileSync(store.tokensPath, "utf8"));
24
+ if (typeof value !== "object" || value === null || !("accessToken" in value))
25
+ return undefined;
26
+ if (typeof value.accessToken !== "string" || value.accessToken.length === 0)
27
+ return undefined;
28
+ const refreshToken = "refreshToken" in value ? value.refreshToken : undefined;
29
+ if (refreshToken !== undefined && typeof refreshToken !== "string")
30
+ return undefined;
31
+ return createHash("sha256")
32
+ .update(JSON.stringify(["mcp-catalog-oauth", value.accessToken, refreshToken]))
33
+ .digest("hex");
34
+ }
35
+ catch (error) {
36
+ if (error instanceof Error)
37
+ return undefined;
38
+ throw error;
39
+ }
40
+ }
41
+ //# sourceMappingURL=catalog-identity.js.map
@@ -0,0 +1,3 @@
1
+ /** Shares the existing O_EXCL file with synchronous migrators without blocking the event loop. */
2
+ export declare function acquireLegacyLock(path: string, stale: number): Promise<() => Promise<void>>;
3
+ //# sourceMappingURL=legacy-lock.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { open, rm, stat } from "node:fs/promises";
2
+ import { safeDelay } from "../wrap.js";
3
+ /** Shares the existing O_EXCL file with synchronous migrators without blocking the event loop. */
4
+ export async function acquireLegacyLock(path, stale) {
5
+ const deadline = Date.now() + stale;
6
+ for (;;) {
7
+ try {
8
+ const file = await open(path, "wx", 0o600);
9
+ return async () => {
10
+ try {
11
+ await file.close();
12
+ }
13
+ finally {
14
+ await rm(path, { force: true });
15
+ }
16
+ };
17
+ }
18
+ catch (error) {
19
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST")
20
+ throw error;
21
+ try {
22
+ const age = Date.now() - (await stat(path)).mtimeMs;
23
+ if (age >= stale) {
24
+ await rm(path, { force: true });
25
+ continue;
26
+ }
27
+ }
28
+ catch (error) {
29
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
30
+ continue;
31
+ throw error;
32
+ }
33
+ if (Date.now() >= deadline)
34
+ throw new Error("Legacy OAuth migration lock acquisition timed out");
35
+ await safeDelay(20);
36
+ }
37
+ }
38
+ }
39
+ //# sourceMappingURL=legacy-lock.js.map