@code-yeongyu/senpi 2026.10.10-3 → 2026.10.10-5

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 (83) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/bundle/chunks/{app-server-command-DVWM37GF.js → app-server-command-6FTH3TK6.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-2PUAD5TD.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-MT2C4I5R.js → chunk-2UUTN6QT.js} +105 -104
  5. package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
  7. package/dist/bundle/chunks/chunk-TIMLYJMK.js +14 -0
  8. package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-TZVDXVHB.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-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
  12. package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
  13. package/dist/bundle/chunks/{chunk-CTERKXVZ.js → chunk-ZSYRWQWG.js} +2 -2
  14. package/dist/bundle/chunks/{cli-main-B4S4DGBX.js → cli-main-UYTUBG64.js} +1 -1
  15. package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-TOVYSHYH.js} +1 -1
  16. package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
  17. package/dist/bundle/chunks/{interactive-mode-QOFA64JL.js → interactive-mode-KJIOCRS7.js} +1 -1
  18. package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
  19. package/dist/bundle/chunks/{multi-session-host-E5RDPWQN.js → multi-session-host-TEFLJTTA.js} +5 -5
  20. package/dist/bundle/chunks/{package-manager-cli-YGTWXRG5.js → package-manager-cli-QSJSIHUR.js} +1 -1
  21. package/dist/bundle/chunks/{rpc-mode-HDAGQ4X3.js → rpc-mode-RUKK7VBF.js} +1 -1
  22. package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
  23. package/dist/bundle/chunks/session-control-endpoint-GZDV2FOZ.js +3 -0
  24. package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-6D4LC3KR.js} +1 -1
  25. package/dist/bundle/chunks/session-worker.js +178 -176
  26. package/dist/bundle/cli.js +1 -1
  27. package/dist/bundle/index.js +1 -1
  28. package/dist/bundle/rpc-entry.js +1 -1
  29. package/dist/bundle/runtime-manifest.json +1 -1
  30. package/dist/core/agent-session-runtime.d.ts +6 -0
  31. package/dist/core/agent-session-runtime.js +18 -0
  32. package/dist/core/agent-session.js +5 -2
  33. package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
  34. package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
  35. package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
  36. package/dist/core/extensions/builtin/html-render/images.js +155 -0
  37. package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
  38. package/dist/core/extensions/builtin/html-render/index.js +5 -0
  39. package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
  40. package/dist/core/extensions/builtin/html-render/tool.js +74 -0
  41. package/dist/core/extensions/builtin/index.js +4 -0
  42. package/dist/core/extensions/builtin/permission-system/index.js +40 -14
  43. package/dist/core/extensions/session-control-types.d.ts +25 -0
  44. package/dist/core/extensions/session-control-types.js +16 -0
  45. package/dist/core/external-admission.js +7 -1
  46. package/dist/core/resource-loader.js +63 -2
  47. package/dist/core/session-rebind.js +16 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/modes/interactive/components/extension-editor.js +4 -0
  51. package/dist/modes/interactive/components/remote-delivery-message.d.ts +6 -3
  52. package/dist/modes/interactive/components/remote-delivery-message.js +32 -3
  53. package/dist/modes/interactive/interactive-mode.d.ts +17 -0
  54. package/dist/modes/interactive/interactive-mode.js +79 -33
  55. package/dist/modes/interactive/interactive-stderr-guard.d.ts +8 -0
  56. package/dist/modes/interactive/interactive-stderr-guard.js +70 -0
  57. package/dist/modes/interactive/session-control-commands.d.ts +2 -1
  58. package/dist/modes/interactive/session-control-commands.js +21 -7
  59. package/dist/modes/interactive/session-control-session-commands.d.ts +22 -0
  60. package/dist/modes/interactive/session-control-session-commands.js +47 -0
  61. package/dist/modes/interactive/stdout-fd-redirect.d.ts +12 -0
  62. package/dist/modes/interactive/stdout-fd-redirect.js +114 -0
  63. package/dist/modes/rpc/connection-handler.js +6 -15
  64. package/dist/modes/rpc/host-reservations.d.ts +1 -1
  65. package/dist/modes/rpc/host-reservations.js +9 -3
  66. package/dist/modes/rpc/rpc-types.d.ts +13 -0
  67. package/dist/modes/rpc/session-control-actions-data.d.ts +17 -0
  68. package/dist/modes/rpc/session-control-actions-data.js +38 -0
  69. package/dist/modes/rpc/session-registry-attach.js +8 -2
  70. package/dist/modes/rpc/session-registry.js +5 -7
  71. package/dist/modes/rpc/session-teardown.d.ts +6 -1
  72. package/dist/modes/rpc/session-teardown.js +26 -7
  73. package/dist/modes/rpc/session-worker-client.d.ts +2 -0
  74. package/dist/modes/rpc/session-worker-client.js +6 -0
  75. package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
  76. package/dist/modes/rpc/session-worker.js +6 -0
  77. package/dist/modes/rpc/worker-session-registry.js +6 -1
  78. package/docs/extensions.md +1 -1
  79. package/docs/rpc.md +52 -8
  80. package/package.json +6 -6
  81. package/dist/bundle/chunks/chunk-PDSG6VER.js +0 -14
  82. package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
  83. package/dist/bundle/chunks/session-control-endpoint-NABTZHCZ.js +0 -3
@@ -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 },
@@ -52,6 +52,8 @@ export default function permissionSystemExtension(pi) {
52
52
  let activePreset = null;
53
53
  let initialApprovedCount = 0;
54
54
  let setupError = null;
55
+ // The `permission-preset` flag value the rules were last loaded from; absent until session_start.
56
+ let loadedPresetFlag;
55
57
  const nextRequestID = createRequestIDFactory();
56
58
  pi.registerFlag("permission", {
57
59
  description: "Set permission rules (format: tool=action or tool:pattern=action)",
@@ -74,10 +76,12 @@ export default function permissionSystemExtension(pi) {
74
76
  }
75
77
  applyToolDenials();
76
78
  });
77
- const loadPermissionRules = (cwd) => {
79
+ // `sessionApproved` carries this session's "Always" answers, not yet written to disk, across a reload.
80
+ const loadPermissionRules = (cwd, sessionApproved = []) => {
78
81
  const settingsManager = SettingsManager.create(cwd);
79
82
  const permissionFlag = pi.getFlag("permission");
80
83
  const permissionPresetFlag = pi.getFlag("permission-preset");
84
+ loadedPresetFlag = { value: permissionPresetFlag };
81
85
  cliRuleset = typeof permissionFlag === "string" ? parsePermissionFlag(permissionFlag) : [];
82
86
  const cliPreset = typeof permissionPresetFlag === "string" ? parsePermissionPresetFlag(permissionPresetFlag) : undefined;
83
87
  if (typeof permissionPresetFlag === "string" && !cliPreset) {
@@ -88,22 +92,44 @@ export default function permissionSystemExtension(pi) {
88
92
  activePreset = loadedSettings.preset;
89
93
  const approved = loadedSettings.approved;
90
94
  parserRegistry = createBuiltinParserRegistry();
91
- service = new PermissionService(staticRuleset, approved, createEventEmitter(pi));
95
+ service = new PermissionService(staticRuleset, [...approved, ...sessionApproved], createEventEmitter(pi));
92
96
  initialApprovedCount = approved.length;
93
97
  };
94
- const applyToolDenials = () => {
98
+ const applyToolDenials = (onlyWhenNarrowed = false) => {
95
99
  const allTools = pi.getAllTools().map((tool) => tool.name);
96
100
  const disabledTools = disabled(allTools, staticRuleset);
97
- const activeTools = pi
98
- .getActiveTools()
99
- .filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
101
+ const currentTools = pi.getActiveTools();
102
+ const activeTools = currentTools.filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
103
+ if (onlyWhenNarrowed && activeTools.length === currentTools.length)
104
+ return;
100
105
  pi.setActiveTools(activeTools);
101
106
  };
107
+ // A host moves a live session to another preset (an attach naming one) by changing the flag;
108
+ // the rules follow from the next tool call on, and a failed reload refuses calls like session_start.
109
+ const followPresetFlag = (cwd) => {
110
+ if (loadedPresetFlag === undefined || loadedPresetFlag.value === pi.getFlag("permission-preset"))
111
+ return;
112
+ const sessionApproved = service ? service.getApproved().slice(initialApprovedCount) : [];
113
+ setupError = null;
114
+ try {
115
+ loadPermissionRules(cwd, sessionApproved);
116
+ }
117
+ catch (error) {
118
+ setupError = getReason(error);
119
+ return;
120
+ }
121
+ applyToolDenials(true);
122
+ };
102
123
  pi.on("tool_call", async (event, ctx) => {
124
+ followPresetFlag(ctx.cwd);
103
125
  if (setupError !== null) {
104
126
  return { block: true, reason: `Permission setup failed: ${setupError}` };
105
127
  }
106
- if (!service || !parserRegistry) {
128
+ // Pinned for this call: a preset change while it waits on a prompt must not move its reply
129
+ // to another service or its decision to another preset.
130
+ const activeService = service;
131
+ const callPreset = activePreset;
132
+ if (!activeService || !parserRegistry) {
107
133
  return undefined;
108
134
  }
109
135
  const toolOwnedRequests = parserRegistry.has(event.toolName)
@@ -130,18 +156,18 @@ export default function permissionSystemExtension(pi) {
130
156
  ...(event.parentToolCallId === undefined ? {} : { parentCallID: event.parentToolCallId }),
131
157
  },
132
158
  };
133
- const auto = activePreset === "auto"
159
+ const auto = callPreset === "auto"
134
160
  ? await decideAuto(event.toolName, event.input, permissionRequest, ctx.cwd)
135
161
  : undefined;
136
- const askResultPromise = service
162
+ const askResultPromise = activeService
137
163
  .ask(request, {
138
164
  autoApproveAsk: permissionRequest.autoApproveAsk ?? false,
139
165
  approveBlanketAsk: auto?.approveBlanketAsk ?? false,
140
- presetBound: activePreset === "auto",
166
+ presetBound: callPreset === "auto",
141
167
  ...(permissionRequest.ruleAliases ? { ruleAliases: permissionRequest.ruleAliases } : {}),
142
168
  })
143
169
  .then(() => ({ ok: true }), (error) => ({ ok: false, error }));
144
- const isPending = service.list().some((pendingRequest) => pendingRequest.id === request.id);
170
+ const isPending = activeService.list().some((pendingRequest) => pendingRequest.id === request.id);
145
171
  if (!isPending) {
146
172
  const askResult = await askResultPromise;
147
173
  if (!askResult.ok) {
@@ -151,16 +177,16 @@ export default function permissionSystemExtension(pi) {
151
177
  }
152
178
  if (ctx.hasUI) {
153
179
  const reply = await showPermissionPrompt(ctx, request);
154
- service.reply(reply);
180
+ activeService.reply(reply);
155
181
  }
156
182
  else {
157
- service.reply(handleNoUI(request, {
183
+ activeService.reply(handleNoUI(request, {
158
184
  emitEvent: (eventName, data) => {
159
185
  if (eventName !== "permission_asked") {
160
186
  pi.events.emit(eventName, data);
161
187
  }
162
188
  },
163
- presetBound: activePreset === "auto",
189
+ presetBound: callPreset === "auto",
164
190
  }));
165
191
  }
166
192
  const askResult = await askResultPromise;
@@ -25,9 +25,30 @@ export interface SessionControlWakeEvent {
25
25
  readonly delivery_ids?: readonly string[];
26
26
  }
27
27
  export type ExternalDeliverAs = "steer" | "followUp";
28
+ /**
29
+ * Who sent a delivery, for the surfaces a human reads: another session (by id, with the name it had
30
+ * when it sent), the command line, or an external chat. The model reads `text`; this is not shown
31
+ * to it.
32
+ */
33
+ export type SessionControlSender = {
34
+ readonly kind: "agent";
35
+ readonly session_id: string;
36
+ readonly name?: string;
37
+ } | {
38
+ readonly kind: "command_line";
39
+ readonly user?: string;
40
+ } | {
41
+ readonly kind: "external";
42
+ readonly platform: string;
43
+ readonly author?: string;
44
+ };
28
45
  export interface AdmitExternalMessageInput {
29
46
  readonly delivery_id: string;
30
47
  readonly text: string;
48
+ /** Who sent it; a delivery without one renders as a generic remote message. */
49
+ readonly sender?: SessionControlSender;
50
+ /** The message as its sender wrote it, for human surfaces; `text` keeps the provenance the model reads. */
51
+ readonly display_text?: string;
31
52
  readonly deliverAs: ExternalDeliverAs;
32
53
  /** The `turn_epoch` the sender observed; a steer is admitted only while it is still current. */
33
54
  readonly expected_turn_id?: number;
@@ -117,7 +138,11 @@ export interface SessionControlDeliveryDetails {
117
138
  readonly delivery_id: string;
118
139
  readonly source: "session_control";
119
140
  readonly deliverAs: ExternalDeliverAs;
141
+ readonly sender?: SessionControlSender;
142
+ readonly display_text?: string;
120
143
  }
144
+ /** The sender a delivery's `details` names, when it names a well-formed one. */
145
+ export declare function sessionControlSenderOf(details: unknown): SessionControlSender | undefined;
121
146
  /** Whether a custom-message `details` value proves it came through session-control admission. */
122
147
  export declare function isSessionControlDeliveryDetails(value: unknown): value is SessionControlDeliveryDetails;
123
148
  //# sourceMappingURL=session-control-types.d.ts.map
@@ -10,6 +10,22 @@
10
10
  */
11
11
  /** `customType` of the transcript entry an admitted delivery becomes. */
12
12
  export const SESSION_CONTROL_DELIVERY_TYPE = "session_control_delivery";
13
+ /** The sender a delivery's `details` names, when it names a well-formed one. */
14
+ export function sessionControlSenderOf(details) {
15
+ if (!isSessionControlDeliveryDetails(details) || typeof details.sender !== "object" || details.sender === null)
16
+ return undefined;
17
+ const sender = { ...details.sender };
18
+ const optional = (key) => (typeof sender[key] === "string" ? { [key]: sender[key] } : {});
19
+ if (sender.kind === "agent" && typeof sender.session_id === "string") {
20
+ return { kind: "agent", session_id: sender.session_id, ...optional("name") };
21
+ }
22
+ if (sender.kind === "command_line")
23
+ return { kind: "command_line", ...optional("user") };
24
+ if (sender.kind === "external" && typeof sender.platform === "string") {
25
+ return { kind: "external", platform: sender.platform, ...optional("author") };
26
+ }
27
+ return undefined;
28
+ }
13
29
  /** Whether a custom-message `details` value proves it came through session-control admission. */
14
30
  export function isSessionControlDeliveryDetails(value) {
15
31
  if (typeof value !== "object" || value === null)
@@ -173,7 +173,13 @@ function deliveryMessage(input) {
173
173
  customType: SESSION_CONTROL_DELIVERY_TYPE,
174
174
  content: input.text,
175
175
  display: true,
176
- details: { delivery_id: input.delivery_id, source: "session_control", deliverAs: input.deliverAs },
176
+ details: {
177
+ delivery_id: input.delivery_id,
178
+ source: "session_control",
179
+ deliverAs: input.deliverAs,
180
+ ...(input.sender === undefined ? {} : { sender: input.sender }),
181
+ ...(input.display_text === undefined ? {} : { display_text: input.display_text }),
182
+ },
177
183
  timestamp: Date.now(),
178
184
  };
179
185
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import { basename, dirname, join, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { detectCapabilities, getTerminalColorMode } from "@earendil-works/pi-tui";
@@ -171,6 +171,10 @@ function buildGlobalDefaultExtensionShim(modulePath) {
171
171
  function getGlobalDefaultExtensionShimPath(agentDir, extensionId) {
172
172
  return join(agentDir, "extensions", `${extensionId}.js`);
173
173
  }
174
+ function isGlobalDefaultExtensionShimLocation(extensionPath, agentDir) {
175
+ const resolvedExtensionPath = resolve(extensionPath);
176
+ return globalDefaultExtensionIds.some((extensionId) => resolvedExtensionPath === resolve(getGlobalDefaultExtensionShimPath(agentDir, extensionId)));
177
+ }
174
178
  function findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir) {
175
179
  if (resolve(agentDir) !== resolve(getAgentDir())) {
176
180
  return undefined;
@@ -183,7 +187,61 @@ function findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir) {
183
187
  }
184
188
  return undefined;
185
189
  }
190
+ /** The module a generated shim re-exports, when it is a generated shim whose target no longer exists. */
191
+ // Exactly what `buildGlobalDefaultExtensionShim` writes: one export line after the banner, nothing else.
192
+ const GENERATED_SHIM_EXPORT_LINE = /^export \{ default \} from ("[^"\n]*");\n?$/;
193
+ /**
194
+ * The module a generated shim re-exports, when the file is exactly a generated shim (an accepted banner
195
+ * followed by the single export line and nothing else) and that module no longer exists. A file that
196
+ * carries the banner but also anything a person wrote is never treated as a shim.
197
+ */
198
+ function deadGeneratedShimTarget(content) {
199
+ const banner = LEGACY_GENERATED_GLOBAL_EXTENSION_BANNERS.find((candidate) => content.startsWith(candidate));
200
+ if (banner === undefined)
201
+ return undefined;
202
+ const specifier = GENERATED_SHIM_EXPORT_LINE.exec(content.slice(banner.length))?.[1];
203
+ if (specifier === undefined)
204
+ return undefined;
205
+ let target;
206
+ try {
207
+ const parsed = JSON.parse(specifier);
208
+ target = parsed.startsWith("file:") ? fileURLToPath(parsed) : parsed;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ return existsSync(target) ? undefined : target;
214
+ }
215
+ function removeDeadGeneratedShim(shimPath) {
216
+ let content;
217
+ try {
218
+ content = readFileSync(shimPath, "utf-8");
219
+ }
220
+ catch {
221
+ return;
222
+ }
223
+ if (deadGeneratedShimTarget(content) === undefined)
224
+ return;
225
+ try {
226
+ unlinkSync(shimPath);
227
+ }
228
+ catch {
229
+ // The loader skips a dead generated shim on its own; a failed removal only retries next start.
230
+ }
231
+ }
232
+ // A generated shim whose target is gone stands for "load the default extension" from an install that no
233
+ // longer exists. Importing it can only fail with "Cannot find module", so it is skipped without an error.
234
+ const skipDeadGeneratedShim = () => { };
186
235
  function resolveGeneratedGlobalDefaultExtensionFactory(extensionPath, agentDir) {
236
+ if (isGlobalDefaultExtensionShimLocation(extensionPath, agentDir)) {
237
+ try {
238
+ if (deadGeneratedShimTarget(readFileSync(extensionPath, "utf-8")) !== undefined)
239
+ return skipDeadGeneratedShim;
240
+ }
241
+ catch {
242
+ // Unreadable: let the regular load report it.
243
+ }
244
+ }
187
245
  const extensionId = findGeneratedGlobalDefaultExtensionId(extensionPath, agentDir);
188
246
  if (!extensionId) {
189
247
  return undefined;
@@ -591,10 +649,13 @@ export class DefaultResourceLoader {
591
649
  mkdirSync(extensionsDir, { recursive: true });
592
650
  for (const extensionId of globalDefaultExtensionIds) {
593
651
  const modulePath = getGlobalDefaultExtensionModulePath(extensionId);
652
+ const targetPath = getGlobalDefaultExtensionShimPath(this.agentDir, extensionId);
594
653
  if (!existsSync(modulePath)) {
654
+ // This engine has no on-disk builtin to point a shim at (a compiled binary). A shim an
655
+ // earlier install generated, whose target is gone, can only fail to load: remove it (#2765).
656
+ removeDeadGeneratedShim(targetPath);
595
657
  continue;
596
658
  }
597
- const targetPath = getGlobalDefaultExtensionShimPath(this.agentDir, extensionId);
598
659
  const shim = buildGlobalDefaultExtensionShim(modulePath);
599
660
  if (existsSync(targetPath)) {
600
661
  const existing = readFileSync(targetPath, "utf-8");
@@ -47,11 +47,26 @@ export async function rebindSessionFile(sourcePath, targetCwd, sessionDir, optio
47
47
  const target = join(targetDir, basename(source));
48
48
  if (target !== source && !existsSync(source) && existsSync(target))
49
49
  return target;
50
- const { header } = readHeaderLine(source);
50
+ const header = readHeaderBeforeLock(source, target);
51
+ if (header === undefined)
52
+ return target;
51
53
  return withSessionMoveLock(source, header.id, () => moveSessionFile(source, target, cwd), {
52
54
  ...(options.moveLockWaitMs === undefined ? {} : { waitMs: options.moveLockWaitMs }),
53
55
  });
54
56
  }
57
+ // The lock is keyed by the session id, so the header is read before the lock is held. A concurrent rebind can finish
58
+ // the move between the existence check and this read; the mover writes the target before it removes the source, so a
59
+ // missing source next to an existing target means the move is already done (`undefined`).
60
+ function readHeaderBeforeLock(source, target) {
61
+ try {
62
+ return readHeaderLine(source).header;
63
+ }
64
+ catch (error) {
65
+ if (error.code === "ENOENT" && target !== source && existsSync(target))
66
+ return undefined;
67
+ throw error;
68
+ }
69
+ }
55
70
  function moveSessionFile(source, target, cwd) {
56
71
  if (target !== source && !existsSync(source) && existsSync(target))
57
72
  return target;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { sanitizeTerminalLabel } from "@earendil-works/pi-tui";
1
+ export { sanitizeTerminalLabel, visibleWidth } from "@earendil-works/pi-tui";
2
2
  export { type Args, parseArgs } from "./cli/args.ts";
3
3
  export { runHostCommand } from "./cli/host-command.ts";
4
4
  export { CONFIG_DIR_NAME, getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION, } from "./config.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Core session management
2
- export { sanitizeTerminalLabel } from "@earendil-works/pi-tui";
2
+ export { sanitizeTerminalLabel, visibleWidth } from "@earendil-works/pi-tui";
3
3
  export { parseArgs } from "./cli/args.js";
4
4
  // The shared-daemon command surface: one JSON line, one exit code, for launchers without a shell
5
5
  export { runHostCommand } from "./cli/host-command.js";
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Container, Editor, getKeybindings, Spacer, Text, } from "@earendil-works/pi-tui";
6
6
  import { editInExternalEditor } from "../external-editor.js";
7
+ import { restoreInteractiveStderr, takeOverInteractiveStderr } from "../interactive-stderr-guard.js";
7
8
  import { getEditorTheme, theme } from "../theme/theme.js";
8
9
  import { DynamicBorder } from "./dynamic-border.js";
9
10
  import { keyHint } from "./keybinding-hints.js";
@@ -79,6 +80,8 @@ export class ExtensionEditorComponent extends Container {
79
80
  async handleOpenExternalEditor() {
80
81
  const content = this.editor.getText();
81
82
  this.tui.stop();
83
+ // The editor draws on the real terminal: hand fd 1 and fd 2 back first, as the main editor path does.
84
+ restoreInteractiveStderr();
82
85
  try {
83
86
  const result = await editInExternalEditor({
84
87
  command: this.externalEditorCommand,
@@ -89,6 +92,7 @@ export class ExtensionEditorComponent extends Container {
89
92
  }
90
93
  }
91
94
  finally {
95
+ takeOverInteractiveStderr();
92
96
  this.tui.start();
93
97
  this.tui.requestRender(true);
94
98
  }
@@ -1,8 +1,11 @@
1
- import { type MessageRenderer } from "../../../core/extensions/types.ts";
1
+ import { type MessageRenderer, type SessionControlSender } from "../../../core/extensions/types.ts";
2
2
  /**
3
- * A message another session delivered renders as its own provenance block - never as the user's
4
- * own input, so a reader always sees which text came from outside this terminal.
3
+ * A message another session delivered renders as its own block - never as the user's own input, so
4
+ * a reader always sees which text came from outside this terminal. A delivery that names its sender
5
+ * shows one label line ("Sent by another agent · <name>") over the message as it was written; one
6
+ * that does not keeps the generic heading over the text the model read.
5
7
  */
6
8
  export declare const renderRemoteDelivery: MessageRenderer;
9
+ export declare function senderLabel(sender: SessionControlSender): string;
7
10
  export declare function builtInMessageRenderer(customType: string): MessageRenderer | undefined;
8
11
  //# sourceMappingURL=remote-delivery-message.d.ts.map
@@ -1,12 +1,22 @@
1
1
  import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
2
- import { SESSION_CONTROL_DELIVERY_TYPE } from "../../../core/extensions/types.js";
2
+ import { isSessionControlDeliveryDetails, SESSION_CONTROL_DELIVERY_TYPE, sessionControlSenderOf, } from "../../../core/extensions/types.js";
3
3
  import { getMarkdownTheme } from "../theme/theme.js";
4
4
  /**
5
- * A message another session delivered renders as its own provenance block - never as the user's
6
- * own input, so a reader always sees which text came from outside this terminal.
5
+ * A message another session delivered renders as its own block - never as the user's own input, so
6
+ * a reader always sees which text came from outside this terminal. A delivery that names its sender
7
+ * shows one label line ("Sent by another agent · <name>") over the message as it was written; one
8
+ * that does not keeps the generic heading over the text the model read.
7
9
  */
8
10
  export const renderRemoteDelivery = (message, _options, theme) => {
9
11
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
12
+ const sender = sessionControlSenderOf(message.details);
13
+ const shown = sender === undefined ? undefined : displayTextOf(message.details);
14
+ if (sender !== undefined && shown !== undefined) {
15
+ box.addChild(new Text(theme.fg("dim", senderLabel(sender)), 0, 0));
16
+ box.addChild(new Spacer(1));
17
+ box.addChild(new Markdown(shown, 0, 0, getMarkdownTheme(), { color: (line) => theme.fg("customMessageText", line) }));
18
+ return box;
19
+ }
10
20
  const label = theme.fg("customMessageLabel", theme.bold("remote message"));
11
21
  box.addChild(new Text(`${label}${theme.fg("dim", ` · delivery ${deliveryIdOf(message.details)}`)}`, 0, 0));
12
22
  box.addChild(new Spacer(1));
@@ -19,9 +29,28 @@ export const renderRemoteDelivery = (message, _options, theme) => {
19
29
  box.addChild(new Markdown(text, 0, 0, getMarkdownTheme(), { color: (line) => theme.fg("customMessageText", line) }));
20
30
  return box;
21
31
  };
32
+ export function senderLabel(sender) {
33
+ switch (sender.kind) {
34
+ case "agent":
35
+ return sender.name === undefined || sender.name.trim() === ""
36
+ ? "Sent by another agent"
37
+ : `Sent by another agent · ${sender.name}`;
38
+ case "command_line":
39
+ return "Sent from the command line";
40
+ case "external":
41
+ return sender.author === undefined || sender.author.trim() === ""
42
+ ? `Sent from ${sender.platform}`
43
+ : `Sent from ${sender.platform} · ${sender.author}`;
44
+ }
45
+ }
22
46
  export function builtInMessageRenderer(customType) {
23
47
  return customType === SESSION_CONTROL_DELIVERY_TYPE ? renderRemoteDelivery : undefined;
24
48
  }
49
+ function displayTextOf(details) {
50
+ return isSessionControlDeliveryDetails(details) && typeof details.display_text === "string"
51
+ ? details.display_text
52
+ : undefined;
53
+ }
25
54
  function deliveryIdOf(details) {
26
55
  if (typeof details !== "object" || details === null || !("delivery_id" in details))
27
56
  return "unknown";
@@ -623,6 +623,12 @@ export declare class InteractiveMode {
623
623
  private setToolsExpanded;
624
624
  private toggleThinkingBlockVisibility;
625
625
  private handleOpenExternalEditor;
626
+ /**
627
+ * Runs `work` (an external program that draws on the terminal itself) with the terminal handed
628
+ * back: the TUI stops, fd 1 and fd 2 point at the terminal again, and both are taken over again
629
+ * afterwards. An editor launched without this would draw into the debug log (#2815).
630
+ */
631
+ private withTerminalHandedOver;
626
632
  clearEditor(): void;
627
633
  showError(errorMessage: string): void;
628
634
  private getProviderErrors;
@@ -696,10 +702,21 @@ export declare class InteractiveMode {
696
702
  * records it as the model's remembered level (the Ctrl+S path of the selector).
697
703
  */
698
704
  private selectThinkingLevel;
705
+ /**
706
+ * The level switch itself; the control endpoint's `set_thinking_level` runs it too.
707
+ * Static and called through the class, so a handler invoked on a partial `this`
708
+ * reaches exactly the members the switch uses.
709
+ */
710
+ private static applyThinkingLevel;
699
711
  private showThinkingSelector;
700
712
  private handleModelCommand;
701
713
  private findExactModelMatch;
702
714
  private selectModelFromUi;
715
+ /**
716
+ * The `/model` switch itself; the control endpoint's `set_model` runs it too, and reports a throw.
717
+ * Static and called through the class for the same reason as `applyThinkingLevel`.
718
+ */
719
+ private static applyModelSelection;
703
720
  private resolveFavoriteModelsForUi;
704
721
  private getFavoriteModelIdsForUi;
705
722
  private captureFavoritePatternSnapshot;