@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
@@ -3,6 +3,9 @@ import { extractPatchedPaths } from "../gpt-apply-patch/index.js";
3
3
  import { decideAuto } from "./auto-policy.js";
4
4
  import { PERMISSION_PRESET_NAMES, parsePermissionFlag, parsePermissionPresetFlag } from "./cli.js";
5
5
  import { disabled } from "./config.js";
6
+ import { forgetDispatchApproval, registerDispatchAuthorizer } from "./dispatch.js";
7
+ import { prepareMcpDispatchApproval } from "./dispatch-metadata.js";
8
+ import { createDispatchAuthorizer } from "./dispatch-policy.js";
6
9
  import { createEventEmitter } from "./events.js";
7
10
  import { INTERNAL_PERMISSION_TOOLS } from "./internal-tools.js";
8
11
  import { handleNoUI } from "./non-interactive.js";
@@ -52,6 +55,10 @@ export default function permissionSystemExtension(pi) {
52
55
  let activePreset = null;
53
56
  let initialApprovedCount = 0;
54
57
  let setupError = null;
58
+ // The `permission-preset` flag value the rules were last loaded from; absent until session_start.
59
+ let loadedPresetFlag;
60
+ let loadedPermissionFlag;
61
+ const retireAuthorizers = new WeakMap();
55
62
  const nextRequestID = createRequestIDFactory();
56
63
  pi.registerFlag("permission", {
57
64
  description: "Set permission rules (format: tool=action or tool:pattern=action)",
@@ -72,12 +79,17 @@ export default function permissionSystemExtension(pi) {
72
79
  setupError = getReason(error);
73
80
  throw error;
74
81
  }
82
+ retireAuthorizers.get(ctx.sessionManager)?.();
83
+ retireAuthorizers.set(ctx.sessionManager, registerDispatchAuthorizer(ctx.sessionManager, dispatchAuthorizer));
75
84
  applyToolDenials();
76
85
  });
77
- const loadPermissionRules = (cwd) => {
86
+ // `sessionApproved` carries this session's "Always" answers, not yet written to disk, across a reload.
87
+ const loadPermissionRules = (cwd, sessionApproved = []) => {
78
88
  const settingsManager = SettingsManager.create(cwd);
79
89
  const permissionFlag = pi.getFlag("permission");
90
+ loadedPermissionFlag = permissionFlag;
80
91
  const permissionPresetFlag = pi.getFlag("permission-preset");
92
+ loadedPresetFlag = { value: permissionPresetFlag };
81
93
  cliRuleset = typeof permissionFlag === "string" ? parsePermissionFlag(permissionFlag) : [];
82
94
  const cliPreset = typeof permissionPresetFlag === "string" ? parsePermissionPresetFlag(permissionPresetFlag) : undefined;
83
95
  if (typeof permissionPresetFlag === "string" && !cliPreset) {
@@ -88,22 +100,51 @@ export default function permissionSystemExtension(pi) {
88
100
  activePreset = loadedSettings.preset;
89
101
  const approved = loadedSettings.approved;
90
102
  parserRegistry = createBuiltinParserRegistry();
91
- service = new PermissionService(staticRuleset, approved, createEventEmitter(pi));
103
+ service = new PermissionService(staticRuleset, [...approved, ...sessionApproved], createEventEmitter(pi));
92
104
  initialApprovedCount = approved.length;
93
105
  };
94
- const applyToolDenials = () => {
106
+ const applyToolDenials = (onlyWhenNarrowed = false) => {
95
107
  const allTools = pi.getAllTools().map((tool) => tool.name);
96
108
  const disabledTools = disabled(allTools, staticRuleset);
97
- const activeTools = pi
98
- .getActiveTools()
99
- .filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
109
+ const currentTools = pi.getActiveTools();
110
+ const activeTools = currentTools.filter((toolName) => INTERNAL_PERMISSION_TOOLS.has(toolName) || !disabledTools.has(toolName));
111
+ if (onlyWhenNarrowed && activeTools.length === currentTools.length)
112
+ return;
100
113
  pi.setActiveTools(activeTools);
101
114
  };
102
- pi.on("tool_call", async (event, ctx) => {
115
+ // A host moves a live session to another preset (an attach naming one) by changing the flag;
116
+ // the rules follow from the next tool call on, and a failed reload refuses calls like session_start.
117
+ const followPresetFlag = (cwd) => {
118
+ if (loadedPresetFlag === undefined ||
119
+ (loadedPresetFlag.value === pi.getFlag("permission-preset") &&
120
+ loadedPermissionFlag === pi.getFlag("permission"))) {
121
+ return;
122
+ }
123
+ const sessionApproved = service ? service.getApproved().slice(initialApprovedCount) : [];
124
+ setupError = null;
125
+ try {
126
+ loadPermissionRules(cwd, sessionApproved);
127
+ }
128
+ catch (error) {
129
+ setupError = getReason(error);
130
+ return;
131
+ }
132
+ applyToolDenials(true);
133
+ };
134
+ const dispatchAuthorizer = createDispatchAuthorizer(pi, (ctx) => {
135
+ followPresetFlag(ctx.cwd);
136
+ return { service, parsers: parserRegistry, preset: activePreset, setupError };
137
+ }, (event, ctx, signal) => authorizeToolCall(event, ctx, signal));
138
+ const authorizeToolCall = async (event, ctx, signal) => {
139
+ followPresetFlag(ctx.cwd);
103
140
  if (setupError !== null) {
104
141
  return { block: true, reason: `Permission setup failed: ${setupError}` };
105
142
  }
106
- if (!service || !parserRegistry) {
143
+ // Pinned for this call: a preset change while it waits on a prompt must not move its reply
144
+ // to another service or its decision to another preset.
145
+ const activeService = service;
146
+ const callPreset = activePreset;
147
+ if (!activeService || !parserRegistry) {
107
148
  return undefined;
108
149
  }
109
150
  const toolOwnedRequests = parserRegistry.has(event.toolName)
@@ -117,6 +158,7 @@ export default function permissionSystemExtension(pi) {
117
158
  return undefined;
118
159
  }
119
160
  const sessionID = ctx.sessionManager.getSessionId();
161
+ const approve = prepareMcpDispatchApproval(pi, event, ctx, dispatchAuthorizer);
120
162
  for (const permissionRequest of permissionRequests) {
121
163
  const request = {
122
164
  id: nextRequestID(),
@@ -130,18 +172,18 @@ export default function permissionSystemExtension(pi) {
130
172
  ...(event.parentToolCallId === undefined ? {} : { parentCallID: event.parentToolCallId }),
131
173
  },
132
174
  };
133
- const auto = activePreset === "auto"
175
+ const auto = callPreset === "auto"
134
176
  ? await decideAuto(event.toolName, event.input, permissionRequest, ctx.cwd)
135
177
  : undefined;
136
- const askResultPromise = service
178
+ const askResultPromise = activeService
137
179
  .ask(request, {
138
180
  autoApproveAsk: permissionRequest.autoApproveAsk ?? false,
139
181
  approveBlanketAsk: auto?.approveBlanketAsk ?? false,
140
- presetBound: activePreset === "auto",
182
+ presetBound: callPreset === "auto",
141
183
  ...(permissionRequest.ruleAliases ? { ruleAliases: permissionRequest.ruleAliases } : {}),
142
184
  })
143
185
  .then(() => ({ ok: true }), (error) => ({ ok: false, error }));
144
- const isPending = service.list().some((pendingRequest) => pendingRequest.id === request.id);
186
+ const isPending = activeService.list().some((pendingRequest) => pendingRequest.id === request.id);
145
187
  if (!isPending) {
146
188
  const askResult = await askResultPromise;
147
189
  if (!askResult.ok) {
@@ -150,17 +192,17 @@ export default function permissionSystemExtension(pi) {
150
192
  continue;
151
193
  }
152
194
  if (ctx.hasUI) {
153
- const reply = await showPermissionPrompt(ctx, request);
154
- service.reply(reply);
195
+ const reply = await showPermissionPrompt(ctx, request, signal);
196
+ activeService.reply(reply);
155
197
  }
156
198
  else {
157
- service.reply(handleNoUI(request, {
199
+ activeService.reply(handleNoUI(request, {
158
200
  emitEvent: (eventName, data) => {
159
201
  if (eventName !== "permission_asked") {
160
202
  pi.events.emit(eventName, data);
161
203
  }
162
204
  },
163
- presetBound: activePreset === "auto",
205
+ presetBound: callPreset === "auto",
164
206
  }));
165
207
  }
166
208
  const askResult = await askResultPromise;
@@ -168,10 +210,15 @@ export default function permissionSystemExtension(pi) {
168
210
  return { block: true, reason: getReason(askResult.error) };
169
211
  }
170
212
  }
213
+ approve?.();
171
214
  return undefined;
172
- });
215
+ };
216
+ pi.on("tool_call", authorizeToolCall);
217
+ pi.on("tool_result", (event) => forgetDispatchApproval(event.input));
173
218
  pi.on("session_shutdown", async (event, ctx) => {
174
219
  void event;
220
+ retireAuthorizers.get(ctx.sessionManager)?.();
221
+ retireAuthorizers.delete(ctx.sessionManager);
175
222
  if (!service) {
176
223
  return;
177
224
  }
@@ -1,4 +1,4 @@
1
1
  import type { ExtensionContext } from "../../types.ts";
2
2
  import type { ReplyInput, Request } from "./types.ts";
3
- export declare function showPermissionPrompt(ctx: ExtensionContext, request: Request): Promise<ReplyInput>;
3
+ export declare function showPermissionPrompt(ctx: ExtensionContext, request: Request, signal?: AbortSignal): Promise<ReplyInput>;
4
4
  //# sourceMappingURL=prompt.d.ts.map
@@ -1,4 +1,4 @@
1
- export async function showPermissionPrompt(ctx, request) {
1
+ export async function showPermissionPrompt(ctx, request, signal) {
2
2
  const title = `Permission required: ${request.permission}`;
3
3
  const message = formatRequestForDisplay(request);
4
4
  const displayTitle = `${title}\n\n${message}`;
@@ -11,6 +11,8 @@ export async function showPermissionPrompt(ctx, request) {
11
11
  ...(request.tool.parentCallID === undefined ? {} : { parentToolCallId: request.tool.parentCallID }),
12
12
  }
13
13
  : {};
14
+ if (signal !== undefined)
15
+ call.signal = signal;
14
16
  const choice = await ctx.ui.select(displayTitle, options, call);
15
17
  if (choice === "Deny with feedback") {
16
18
  const feedback = await ctx.ui.input("Feedback", "Why are you denying this permission? (optional)", call);
@@ -1,8 +1,13 @@
1
1
  import { type PermissionEventEmitter } from "./events.ts";
2
- import { type ReplyInput, type Request, type Ruleset } from "./types.ts";
2
+ import { type ReplyInput, type Request, type Rule, type Ruleset } from "./types.ts";
3
3
  type RequestInput = Omit<Request, "id"> & {
4
4
  id?: string;
5
5
  };
6
+ interface DecisionOptions {
7
+ readonly approveBlanketAsk?: boolean;
8
+ readonly presetBound?: boolean;
9
+ readonly ruleAliases?: readonly string[] | undefined;
10
+ }
6
11
  /** Core service for managing permission requests and rule evaluation */
7
12
  export declare class PermissionService {
8
13
  private pending;
@@ -24,6 +29,11 @@ export declare class PermissionService {
24
29
  * explicit consent for that pattern) can then turn a remaining ask into allow.
25
30
  */
26
31
  private decide;
32
+ /** Share the decision and its relevant rules with the final dispatch fence. */
33
+ dispatchDecision(permission: string, target: string | readonly string[], options?: DecisionOptions): {
34
+ readonly action: Rule["action"];
35
+ readonly rules: readonly Rule[];
36
+ };
27
37
  /** Request permission for a tool call. Resolves if allowed, throws on denial. */
28
38
  ask(request: RequestInput, { autoApproveAsk, approveBlanketAsk, presetBound, ruleAliases, }?: {
29
39
  readonly autoApproveAsk?: boolean;
@@ -24,8 +24,14 @@ export class PermissionService {
24
24
  * explicit consent for that pattern) can then turn a remaining ask into allow.
25
25
  */
26
26
  decide(permission, target, options) {
27
- if (!options.presetBound)
28
- return evaluate(permission, target, this.staticRuleset, this.approved).action;
27
+ return this.dispatchDecision(permission, target, options).action;
28
+ }
29
+ /** Share the decision and its relevant rules with the final dispatch fence. */
30
+ dispatchDecision(permission, target, options = {}) {
31
+ if (!options.presetBound) {
32
+ const rule = evaluate(permission, target, this.staticRuleset, this.approved);
33
+ return { action: rule.action, rules: [{ ...rule }] };
34
+ }
29
35
  const presetRules = this.staticRuleset.filter((rule) => isPresetRule(rule));
30
36
  const userRules = this.staticRuleset.filter((rule) => !isPresetRule(rule));
31
37
  const presetRule = evaluate(permission, target, presetRules);
@@ -35,10 +41,14 @@ export class PermissionService {
35
41
  const combined = user === undefined || PermissionService.RESTRICTIVENESS[preset] >= PermissionService.RESTRICTIVENESS[user]
36
42
  ? preset
37
43
  : user;
44
+ const rules = [presetRule, ...(user === undefined ? [] : [userRule])].map((rule) => ({ ...rule }));
38
45
  if (combined !== "ask")
39
- return combined;
46
+ return { action: combined, rules };
40
47
  const remembered = evaluate(permission, target, this.approved);
41
- return this.approved.includes(remembered) && remembered.action === "allow" ? "allow" : "ask";
48
+ if (this.approved.includes(remembered) && remembered.action === "allow") {
49
+ return { action: "allow", rules: [...rules, { ...remembered }] };
50
+ }
51
+ return { action: "ask", rules };
42
52
  }
43
53
  /** Request permission for a tool call. Resolves if allowed, throws on denial. */
44
54
  async ask(request, { autoApproveAsk = false, approveBlanketAsk = false, presetBound = false, ruleAliases, } = {}) {
@@ -25,6 +25,7 @@ export interface NearDuplicateState {
25
25
  lineHasContent: boolean;
26
26
  lineStartOffset: number;
27
27
  lineText: string;
28
+ lineLastChar: string;
28
29
  length: number;
29
30
  wordChars: number;
30
31
  startOffset: number;
@@ -11,6 +11,10 @@ export const NEAR_DUPLICATE_TEXT_RETENTION_MAX = 512;
11
11
  const SAMPLE_LENGTH = 80;
12
12
  const WORD_PATTERN = /[\p{L}\p{N}#]+/gu;
13
13
  const FENCE_PATTERN = /^(?:```|~~~)/;
14
+ // An indented line that is not a nested list item (two spaces or a tab, then not `- `, `* `, `+ `, `1. `, `1) `).
15
+ const INDENTED_CODE = /^(?: {2,}|\t)(?![ \t]*(?:[-*+]|\d+[.)])[ \t])/;
16
+ // A line ending in a block, statement or markup delimiter: `{`, `}`, `[`, `]`, `(`, `;`, `,`, `>` (oh-my-pi's set).
17
+ const CODE_LINE_ENDINGS = new Set(["{", "}", "[", "]", "(", ";", ",", ">"]);
14
18
  export function createNearDuplicateState() {
15
19
  return {
16
20
  history: new FixedRing(NEAR_DUPLICATE_LOOKBACK),
@@ -20,6 +24,7 @@ export function createNearDuplicateState() {
20
24
  lineHasContent: false,
21
25
  lineStartOffset: 0,
22
26
  lineText: "",
27
+ lineLastChar: "",
23
28
  length: 0,
24
29
  wordChars: 0,
25
30
  startOffset: 0,
@@ -113,17 +118,28 @@ function completeParagraph(state) {
113
118
  },
114
119
  };
115
120
  }
121
+ /**
122
+ * Code and markup repeat one skeleton with different literals, so a run of same-shaped lines (SVG elements, JSON
123
+ * objects) is not a narration loop. Lines shaped like code are left out of the paragraph, fenced or not, and a
124
+ * paragraph made only of them is never scored (senpi#2865; oh-my-pi v18.8.0 drops the same line shapes before its loop heuristics).
125
+ */
126
+ function isCodeShapedLine(state) {
127
+ return INDENTED_CODE.test(state.lineText) || CODE_LINE_ENDINGS.has(state.lineLastChar);
128
+ }
116
129
  function foldLine(state) {
130
+ const fence = FENCE_PATTERN.test(state.lineText.trimStart());
131
+ if (state.insideFence || fence)
132
+ state.fenced = true;
133
+ if (fence)
134
+ state.insideFence = !state.insideFence;
135
+ if (!state.fenced && isCodeShapedLine(state))
136
+ return;
117
137
  if (state.length === 0)
118
138
  state.startOffset = state.lineStartOffset;
119
139
  state.length += state.lineLength + 1;
120
140
  state.wordChars += state.lineWordChars;
121
- if (state.insideFence || FENCE_PATTERN.test(state.lineText.trimStart()))
122
- state.fenced = true;
123
141
  if (state.retained.length < NEAR_DUPLICATE_TEXT_RETENTION_MAX)
124
142
  state.retained += `${state.lineText}\n`;
125
- if (FENCE_PATTERN.test(state.lineText.trimStart()))
126
- state.insideFence = !state.insideFence;
127
143
  }
128
144
  function resetLine(state, startOffset) {
129
145
  state.lineLength = 0;
@@ -131,6 +147,7 @@ function resetLine(state, startOffset) {
131
147
  state.lineHasContent = false;
132
148
  state.lineStartOffset = startOffset;
133
149
  state.lineText = "";
150
+ state.lineLastChar = "";
134
151
  }
135
152
  export function updateNearDuplicates(state, entry) {
136
153
  if (entry.value.charCodeAt(0) === CharCode.LineFeed) {
@@ -143,8 +160,10 @@ export function updateNearDuplicates(state, entry) {
143
160
  return result;
144
161
  }
145
162
  const codePoint = entry.value.codePointAt(0) ?? 0;
146
- if (!isAsciiWhitespace(codePoint))
163
+ if (!isAsciiWhitespace(codePoint)) {
147
164
  state.lineHasContent = true;
165
+ state.lineLastChar = entry.value;
166
+ }
148
167
  if (isAsciiAlphanumeric(codePoint) || (codePoint > 0x7f && !isBoxDrawing(codePoint)))
149
168
  state.lineWordChars += 1;
150
169
  state.lineLength += entry.width;
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";
@@ -4123,6 +4123,11 @@ export class InteractiveMode {
4123
4123
  switch (event.type) {
4124
4124
  case "agent_start":
4125
4125
  this.agentIdle = false;
4126
+ // Keep a scrolled-up reader in place for this turn: rows re-laid out above the viewport are not
4127
+ // replayed until the next key press (#2836). No catch-up here: a turn nobody typed (auto-retry,
4128
+ // an extension's triggerTurn) can start while the reader is still scrolled up, and a turn the user
4129
+ // started already caught up on their Enter key.
4130
+ this.ui.setScrollbackReplayHold(true);
4126
4131
  this.transcriptWriteNoticeShown = false;
4127
4132
  this.clearPendingTools();
4128
4133
  this.clearActiveToolExecutionStatus();
@@ -4411,6 +4416,8 @@ export class InteractiveMode {
4411
4416
  if (this.settingsManager.getShowTerminalProgress() && this.ui.terminal) {
4412
4417
  this.ui.terminal.setProgress(false);
4413
4418
  }
4419
+ // Keep holding until the next key press: a replay at turn end would snap a reader who is scrolled up.
4420
+ this.ui.setScrollbackReplayHold("until-input");
4414
4421
  this.clearActiveToolExecutionStatus();
4415
4422
  this.clearToolHookStatuses();
4416
4423
  this.streamingReveal.stop();
@@ -4850,8 +4857,13 @@ export class InteractiveMode {
4850
4857
  */
4851
4858
  showStatus(message) {
4852
4859
  const children = this.chatContainer.children;
4853
- const last = children.length > 0 ? children[children.length - 1] : undefined;
4854
- const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
4860
+ // While a turn streams, a notice goes above the live message, like a custom entry does. Appended
4861
+ // after it, a notice taller than the screen pushed the live output above the viewport, and every
4862
+ // streamed delta then replayed the whole scrollback: the view kept jumping to the top (#2836).
4863
+ const streamingIndex = this.streamingComponent ? children.indexOf(this.streamingComponent) : -1;
4864
+ const end = streamingIndex >= 0 ? streamingIndex : children.length;
4865
+ const last = end > 0 ? children[end - 1] : undefined;
4866
+ const secondLast = end > 1 ? children[end - 2] : undefined;
4855
4867
  if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
4856
4868
  this.lastStatusMessage = message;
4857
4869
  this.lastStatusText.invalidate();
@@ -4861,8 +4873,13 @@ export class InteractiveMode {
4861
4873
  const spacer = new Spacer(1);
4862
4874
  this.lastStatusMessage = message;
4863
4875
  const text = new ThemedText(() => theme.fg("dim", this.lastStatusMessage), 1, 0);
4864
- this.chatContainer.addChild(spacer);
4865
- this.chatContainer.addChild(text);
4876
+ if (streamingIndex >= 0) {
4877
+ children.splice(streamingIndex, 0, spacer, text);
4878
+ }
4879
+ else {
4880
+ this.chatContainer.addChild(spacer);
4881
+ this.chatContainer.addChild(text);
4882
+ }
4866
4883
  this.lastStatusSpacer = spacer;
4867
4884
  this.lastStatusText = text;
4868
4885
  this.ui.requestRender();
@@ -14,7 +14,7 @@ export interface SessionPathReservations {
14
14
  /** Records this host as the holder of `sessionPath`, or reports the holder whose claim stands. */
15
15
  claim(sessionPath: string, attached?: boolean): Promise<SessionPathOwner | undefined>;
16
16
  /** Drops this host's claim. A claim made by another generation is never touched. */
17
- release(sessionPath: string): void;
17
+ release(sessionPath: string): Promise<void>;
18
18
  /** Republishes this host's claim with the attachment state that session now has. */
19
19
  setAttached(sessionPath: string, attached: boolean): void;
20
20
  }
@@ -58,6 +58,7 @@ export function createSessionPathReservations(options) {
58
58
  const pid = options.pid ?? process.pid;
59
59
  const startTime = readProcessStartTime(pid).then((value) => value ?? null, () => null);
60
60
  const held = new Set();
61
+ const updates = new Map();
61
62
  const report = (message) => options.onFailure?.(message);
62
63
  const publish = async (sessionPath, attached, stillHeld) => {
63
64
  const owner = {
@@ -100,19 +101,24 @@ export function createSessionPathReservations(options) {
100
101
  }
101
102
  return undefined;
102
103
  },
103
- release(sessionPath) {
104
+ async release(sessionPath) {
104
105
  if (!held.delete(sessionPath))
105
106
  return;
106
- void rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
+ await updates.get(sessionPath);
108
+ updates.delete(sessionPath);
109
+ await rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
110
  report(`session path reservation for ${sessionPath} could not be removed (${errorMessage(cause)})`);
108
111
  });
109
112
  },
110
113
  setAttached(sessionPath, attached) {
111
114
  if (!held.has(sessionPath))
112
115
  return;
113
- void publish(sessionPath, attached, () => held.has(sessionPath)).catch((cause) => {
116
+ const update = (updates.get(sessionPath) ?? Promise.resolve())
117
+ .then(() => publish(sessionPath, attached, () => held.has(sessionPath)))
118
+ .catch((cause) => {
114
119
  report(`session path reservation for ${sessionPath} could not be updated (${errorMessage(cause)})`);
115
120
  });
121
+ updates.set(sessionPath, update);
116
122
  },
117
123
  };
118
124
  }
@@ -18,7 +18,7 @@ export class RpcCommandError extends Error {
18
18
  }
19
19
  }
20
20
  import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
21
- import { armRequestDeadline, OPEN_AFTER_QUEUED_DEADLINE_MS, openStalledMessage, PROMPT_ACK_MAX_WAIT_MS, PROMPT_COMPACTION_DEADLINE_MS, REQUEST_DEADLINE_MS, } from "./rpc-request-deadline.js";
21
+ import { armRequestDeadline, OPEN_AFTER_QUEUED_DEADLINE_MS, openStalledMessage, PROMPT_ACK_MAX_WAIT_MS, PROMPT_AFTER_QUEUED_DEADLINE_MS, PROMPT_COMPACTION_DEADLINE_MS, promptStalledMessage, REQUEST_DEADLINE_MS, } from "./rpc-request-deadline.js";
22
22
  import { RPC_ERROR_UNKNOWN_COMMAND } from "./rpc-types.js";
23
23
  import { readSocketSecret, resolveSocketTransportAddress, sendSocketHandshake, socketSecretPath, } from "./socket-transport.js";
24
24
  import { socketNeedsHandshake } from "./tui-socket.js";
@@ -960,6 +960,7 @@ export class RpcClient {
960
960
  return new Promise((resolve, reject) => {
961
961
  const timeoutMessage = () => `Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`;
962
962
  let promptCap;
963
+ let promptReceived = false;
963
964
  const expire = (timeoutError) => {
964
965
  clearTimeout(promptCap);
965
966
  const pending = this.pendingRequests.get(id);
@@ -991,7 +992,18 @@ export class RpcClient {
991
992
  },
992
993
  ...(command.type === "prompt"
993
994
  ? {
994
- onCompaction: (compacting) => deadline.extend(compacting ? PROMPT_COMPACTION_DEADLINE_MS : REQUEST_DEADLINE_MS, timeoutMessage),
995
+ onCompaction: (compacting) => compacting
996
+ ? deadline.extend(PROMPT_COMPACTION_DEADLINE_MS, timeoutMessage)
997
+ : promptReceived
998
+ ? deadline.extend(PROMPT_AFTER_QUEUED_DEADLINE_MS, promptStalledMessage)
999
+ : deadline.extend(REQUEST_DEADLINE_MS, timeoutMessage),
1000
+ // The host received the prompt: its preflight may outlive the plain request deadline (senpi#2871).
1001
+ // A compaction's own wait is longer: the acknowledgement must never shorten it.
1002
+ onQueued: () => {
1003
+ promptReceived = true;
1004
+ if (!this.isCompacting())
1005
+ deadline.extend(PROMPT_AFTER_QUEUED_DEADLINE_MS, promptStalledMessage);
1006
+ },
995
1007
  }
996
1008
  : {}),
997
1009
  ...(command.type === "open_session"
@@ -14,6 +14,14 @@ export declare const PROMPT_ACK_MAX_WAIT_MS: number;
14
14
  * (senpi#2209).
15
15
  */
16
16
  export declare const OPEN_AFTER_QUEUED_DEADLINE_MS: number;
17
+ /**
18
+ * How long a prompt the host acknowledged as received may take to be accepted. Its preflight runs on the
19
+ * session's loop, which on a starved shared host outlived the 30 s request deadline: the client gave up,
20
+ * discarded the session, and the host answered into its closed scope (senpi#2871). A lost transport still
21
+ * rejects at once; this only bounds a host that received the prompt and then went silent.
22
+ */
23
+ export declare const PROMPT_AFTER_QUEUED_DEADLINE_MS: number;
24
+ export declare function promptStalledMessage(): string;
17
25
  export declare function openStalledMessage(position: unknown): string;
18
26
  export interface RequestDeadline {
19
27
  /** Restarts the wait with a new budget and the message it fails with. */
@@ -15,6 +15,16 @@ export const PROMPT_ACK_MAX_WAIT_MS = PROMPT_COMPACTION_DEADLINE_MS + REQUEST_DE
15
15
  * (senpi#2209).
16
16
  */
17
17
  export const OPEN_AFTER_QUEUED_DEADLINE_MS = 10 * 60_000;
18
+ /**
19
+ * How long a prompt the host acknowledged as received may take to be accepted. Its preflight runs on the
20
+ * session's loop, which on a starved shared host outlived the 30 s request deadline: the client gave up,
21
+ * discarded the session, and the host answered into its closed scope (senpi#2871). A lost transport still
22
+ * rejects at once; this only bounds a host that received the prompt and then went silent.
23
+ */
24
+ export const PROMPT_AFTER_QUEUED_DEADLINE_MS = 5 * 60_000;
25
+ export function promptStalledMessage() {
26
+ return `prompt was received by the host but not accepted within ${PROMPT_AFTER_QUEUED_DEADLINE_MS / 60_000} minutes`;
27
+ }
18
28
  export function openStalledMessage(position) {
19
29
  const queued = typeof position === "number" ? ` at queue position ${position}` : "";
20
30
  const minutes = OPEN_AFTER_QUEUED_DEADLINE_MS / 60_000;
@@ -1595,7 +1595,10 @@ export interface RpcAuthAccountsChangedEvent {
1595
1595
  /**
1596
1596
  * Sent to ONE opener the moment its `open_session` is accepted, before the open enters the
1597
1597
  * session loop. The in-process host serves opens one at a time, so a burst queues; without this
1598
- * the client's only signal is a deadline it cannot explain (senpi#1844).
1598
+ * the client's only signal is a deadline it cannot explain (senpi#1844). A multi-session host sends
1599
+ * the same record for a session-routed `prompt` the moment it is received, before its preflight runs
1600
+ * on the session's loop, so a client waits out a slow preflight instead of discarding the session at
1601
+ * its plain request deadline (senpi#2871).
1599
1602
  *
1600
1603
  * `for_request` carries the opener's request id deliberately, NOT the response-id field: a client
1601
1604
  * settles pending requests by response id, and a queued record wearing the open's id would be
@@ -1603,11 +1606,11 @@ export interface RpcAuthAccountsChangedEvent {
1603
1606
  */
1604
1607
  export interface RpcOpenQueuedEvent {
1605
1608
  type: "queued";
1606
- /** The `open_session` request this position belongs to. */
1609
+ /** The `open_session` or `prompt` request this position belongs to. */
1607
1610
  for_request: string;
1608
- /** 1-based place in the open queue at the moment of acceptance. */
1611
+ /** 1-based place in the open queue at acceptance; for a `prompt`, its place among that session's requests in flight. */
1609
1612
  position: number;
1610
- /** Opens already in flight when this one arrived; `position` is this plus one. */
1613
+ /** Opens (for a `prompt`, that session's requests) already in flight when this one arrived; `position` is this plus one. */
1611
1614
  in_flight: number;
1612
1615
  }
1613
1616
  /** Emitted when the SDK failover engine advances to a different account slot. */
@@ -42,9 +42,23 @@ export async function createRpcSessionBinding(sessionId, entry, writer, requestC
42
42
  return typeof value === "function" ? value.bind(runtime) : value;
43
43
  },
44
44
  });
45
+ // A reply can be written after its session closed: a prompt's preflight that outlived the client's
46
+ // deadline answers once the client already discarded the session. That record has no reader left, so it
47
+ // is dropped with one line naming the session instead of throwing "Provider scope is closed" out of
48
+ // an unawaited prompt (senpi#2871).
49
+ let droppedAfterClose = false;
45
50
  const handler = await runWithProviderScope(entry.scope, async () => {
51
+ const writeInScope = bindToProviderScope(enqueueRecords);
46
52
  const taggedSink = {
47
- writeRaw: bindToProviderScope(enqueueRecords),
53
+ writeRaw: (chunk) => {
54
+ if (entry.scope.state === "closed") {
55
+ if (!droppedAfterClose)
56
+ process.stderr.write(`senpi rpc host: dropped a record for closed session ${sessionId} at ${new Date().toISOString()}\n`);
57
+ droppedAfterClose = true;
58
+ return;
59
+ }
60
+ writeInScope(chunk);
61
+ },
48
62
  waitForBackpressure: bindToProviderScope(async () => { }),
49
63
  };
50
64
  return createRpcConnectionHandler(runtimeHost, taggedSink, {
@@ -140,6 +140,7 @@ export declare class SessionCommandRouter {
140
140
  /** Drops per-connection records for a handle the host closed on its own. */
141
141
  private forgetSessionOwnership;
142
142
  private stopSweep;
143
+ private acknowledgePrompt;
143
144
  private openWithBarrier;
144
145
  private open;
145
146
  /**
@@ -283,6 +283,10 @@ export class SessionCommandRouter {
283
283
  const binding = this.bindings.get(command.sessionId);
284
284
  if (!binding)
285
285
  return error(command.id, command.type, RPC_ERROR_UNKNOWN_SESSION);
286
+ // A prompt's preflight runs on the session's loop and can outlive the client's request deadline on a
287
+ // starved host; acknowledging receipt first lets the client wait it out (senpi#2871).
288
+ if (command.type === "prompt")
289
+ this.acknowledgePrompt(command.id, command.sessionId);
286
290
  await binding.handle(command);
287
291
  return undefined;
288
292
  }
@@ -438,6 +442,18 @@ export class SessionCommandRouter {
438
442
  clearInterval(this.sweepTimer);
439
443
  this.sweepTimer = undefined;
440
444
  }
445
+ acknowledgePrompt(id, sessionId) {
446
+ const connection = this.writer.currentConnection();
447
+ if (id === undefined || connection === undefined)
448
+ return;
449
+ const inFlight = Math.max(0, (this.activeRequests.get(sessionId) ?? 1) - 1);
450
+ this.writer.sendOpenQueued(connection, {
451
+ type: "queued",
452
+ for_request: id,
453
+ position: inFlight + 1,
454
+ in_flight: inFlight,
455
+ });
456
+ }
441
457
  openWithBarrier(command) {
442
458
  const owner = this.writer.currentConnection();
443
459
  if (owner === undefined)
@@ -16,8 +16,8 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
16
16
  entry.retainOnDisconnect = true;
17
17
  if (!entry.durableSessionId)
18
18
  throw new RpcSessionRegistryError("session_path_in_use");
19
- // The surface follows the client that renders the replies: an attach that names one moves
20
- // the live session to it; an attach without one keeps what the session has.
19
+ // The surface, browser engine and permission preset follow the client: an attach that names one
20
+ // moves the live session to it; an attach without one keeps what the session has.
21
21
  if (profile.promptSurface !== undefined && profile.promptSurface !== entry.profile.promptSurface) {
22
22
  entry.profile = frozenProfile({ ...entry.profile, promptSurface: profile.promptSurface });
23
23
  entry.runtime?.setPromptSurface(profile.promptSurface);
@@ -26,6 +26,12 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
26
26
  entry.profile = frozenProfile({ ...entry.profile, browserEngine: profile.browserEngine });
27
27
  entry.runtime?.setBrowserEngine(profile.browserEngine);
28
28
  }
29
+ // The preset is applied even when the record already names it: the record is not the live session,
30
+ // so a resent attach also repairs a session that drifted from it (senpi#2842).
31
+ if (profile.permissionPreset !== undefined) {
32
+ entry.profile = frozenProfile({ ...entry.profile, permissionPreset: profile.permissionPreset });
33
+ entry.runtime?.setPermissionPreset(profile.permissionPreset);
34
+ }
29
35
  entry.lastCommandAt = now;
30
36
  if (wasParked) {
31
37
  entry.lifecycleMutex = entry.lifecycleMutex.then(() => entry.runtime?.emitAttachmentEvent("session_resumed"));