@code-yeongyu/senpi 2026.10.10-5 → 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 (93) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bundle/chunks/{anthropic-messages-OE3QBX4P.js → anthropic-messages-DUULMQBF.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-6FTH3TK6.js → app-server-command-GJ5NXQSY.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-2UUTN6QT.js → chunk-C7LEZMWS.js} +97 -97
  5. package/dist/bundle/chunks/{chunk-ZSYRWQWG.js → chunk-JMI5OCIJ.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-ODYQK5XJ.js → chunk-TALS4X7S.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-2PUAD5TD.js → chunk-VGYIPYOK.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-4TK7VCZZ.js → chunk-WKMUAG23.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-TIMLYJMK.js → chunk-YV2XQFXG.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-TZVDXVHB.js → chunk-ZYHIRFGT.js} +1 -1
  11. package/dist/bundle/chunks/{cli-main-UYTUBG64.js → cli-main-SZ54SQLA.js} +1 -1
  12. package/dist/bundle/chunks/{help-fast-path-EHRZEBRO.js → help-fast-path-W5W3FYYM.js} +1 -1
  13. package/dist/bundle/chunks/{host-command-TOVYSHYH.js → host-command-YG2FIDPN.js} +1 -1
  14. package/dist/bundle/chunks/{interactive-mode-KJIOCRS7.js → interactive-mode-SCNDQ4R6.js} +1 -1
  15. package/dist/bundle/chunks/{multi-session-host-TEFLJTTA.js → multi-session-host-ZDZMPNP5.js} +4 -3
  16. package/dist/bundle/chunks/{package-manager-cli-QSJSIHUR.js → package-manager-cli-4LMYHHKH.js} +1 -1
  17. package/dist/bundle/chunks/{rotation-stream-C5BM7TMM.js → rotation-stream-E3XKSST7.js} +1 -1
  18. package/dist/bundle/chunks/{rpc-mode-RUKK7VBF.js → rpc-mode-ZFNFREN5.js} +1 -1
  19. package/dist/bundle/chunks/{session-control-endpoint-GZDV2FOZ.js → session-control-endpoint-MVSYBGM3.js} +1 -1
  20. package/dist/bundle/chunks/{session-picker-6D4LC3KR.js → session-picker-TNXWY5QY.js} +1 -1
  21. package/dist/bundle/chunks/session-worker.js +158 -157
  22. package/dist/bundle/cli.js +1 -1
  23. package/dist/bundle/index.js +1 -1
  24. package/dist/bundle/rpc-entry.js +1 -1
  25. package/dist/bundle/runtime-manifest.json +1 -1
  26. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +3 -2
  27. package/dist/core/extensions/builtin/anthropic-subscription/options.js +11 -2
  28. package/dist/core/extensions/builtin/anthropic-subscription/session-reattach.js +4 -1
  29. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +3 -0
  30. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +5 -0
  31. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +11 -0
  32. package/dist/core/extensions/builtin/anthropic-subscription/stream.d.ts +1 -0
  33. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +17 -1
  34. package/dist/core/extensions/builtin/compaction/lane-policy.d.ts +20 -12
  35. package/dist/core/extensions/builtin/compaction/lane-policy.js +39 -34
  36. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.d.ts +4 -0
  37. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +41 -0
  38. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.d.ts +3 -0
  39. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.js +39 -0
  40. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.d.ts +14 -0
  41. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.js +91 -0
  42. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +1 -1
  43. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +1 -0
  44. package/dist/core/extensions/builtin/mcp/auth/token-store.js +19 -90
  45. package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +7 -3
  46. package/dist/core/extensions/builtin/mcp/catalog-cache.js +61 -12
  47. package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -1
  48. package/dist/core/extensions/builtin/mcp/catalog.js +2 -0
  49. package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
  50. package/dist/core/extensions/builtin/mcp/expose/call.d.ts +12 -0
  51. package/dist/core/extensions/builtin/mcp/expose/call.js +60 -0
  52. package/dist/core/extensions/builtin/mcp/expose/proxy.js +16 -3
  53. package/dist/core/extensions/builtin/mcp/expose/register.d.ts +3 -1
  54. package/dist/core/extensions/builtin/mcp/expose/register.js +41 -50
  55. package/dist/core/extensions/builtin/mcp/expose/session.d.ts +4 -0
  56. package/dist/core/extensions/builtin/mcp/expose/session.js +5 -1
  57. package/dist/core/extensions/builtin/mcp/expose/tier-b.js +15 -4
  58. package/dist/core/extensions/builtin/mcp/index.js +5 -26
  59. package/dist/core/extensions/builtin/mcp/invocation.d.ts +42 -0
  60. package/dist/core/extensions/builtin/mcp/invocation.js +118 -0
  61. package/dist/core/extensions/builtin/mcp/service-connection.d.ts +3 -0
  62. package/dist/core/extensions/builtin/mcp/service-connection.js +6 -0
  63. package/dist/core/extensions/builtin/mcp/service-register.d.ts +4 -0
  64. package/dist/core/extensions/builtin/mcp/service-register.js +45 -1
  65. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +26 -3
  66. package/dist/core/extensions/builtin/mcp/service-types.d.ts +6 -0
  67. package/dist/core/extensions/builtin/mcp/service.js +83 -49
  68. package/dist/core/extensions/builtin/mcp/shared-connection.js +17 -4
  69. package/dist/core/extensions/builtin/mcp/sharing-policy.js +1 -0
  70. package/dist/core/extensions/builtin/mcp/startup-race.js +47 -9
  71. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.d.ts +8 -0
  72. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.js +23 -0
  73. package/dist/core/extensions/builtin/permission-system/dispatch-policy.d.ts +14 -0
  74. package/dist/core/extensions/builtin/permission-system/dispatch-policy.js +53 -0
  75. package/dist/core/extensions/builtin/permission-system/dispatch.d.ts +33 -0
  76. package/dist/core/extensions/builtin/permission-system/dispatch.js +106 -0
  77. package/dist/core/extensions/builtin/permission-system/index.js +25 -4
  78. package/dist/core/extensions/builtin/permission-system/prompt.d.ts +1 -1
  79. package/dist/core/extensions/builtin/permission-system/prompt.js +3 -1
  80. package/dist/core/extensions/builtin/permission-system/service.d.ts +11 -1
  81. package/dist/core/extensions/builtin/permission-system/service.js +14 -4
  82. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.d.ts +1 -0
  83. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.js +24 -5
  84. package/dist/modes/rpc/rpc-client.js +14 -2
  85. package/dist/modes/rpc/rpc-request-deadline.d.ts +8 -0
  86. package/dist/modes/rpc/rpc-request-deadline.js +10 -0
  87. package/dist/modes/rpc/rpc-types.d.ts +7 -4
  88. package/dist/modes/rpc/session-binding.js +15 -1
  89. package/dist/modes/rpc/session-command-router.d.ts +1 -0
  90. package/dist/modes/rpc/session-command-router.js +16 -0
  91. package/docs/providers.md +3 -2
  92. package/docs/rpc.md +12 -6
  93. package/package.json +6 -6
@@ -0,0 +1,106 @@
1
+ import { DeniedError } from "./types.js";
2
+ const authorizers = new WeakMap();
3
+ // AgentSession passes this validated object unchanged from preflight to execution.
4
+ // Identical calls, including calls with duplicate provider IDs, get distinct objects.
5
+ const approvals = new WeakMap();
6
+ /** Retirement cannot remove the authorizer installed by a newer extension load. */
7
+ export function registerDispatchAuthorizer(session, authorizer) {
8
+ const previous = authorizers.get(session);
9
+ const registration = { authorizer, retired: new AbortController() };
10
+ authorizers.set(session, registration);
11
+ previous?.retired.abort(new Error("Permission authorizer was retired"));
12
+ return () => {
13
+ // Retain the retired registration so required authority cannot become
14
+ // indistinguishable from a session where enforcement was never loaded.
15
+ registration.retired.abort(new Error("Permission authorizer was retired"));
16
+ };
17
+ }
18
+ export function hasDispatchAuthorizer(session) {
19
+ return authorizers.has(session);
20
+ }
21
+ /** Keep the policy and operation that were actually presented, not their post-reply replacements. */
22
+ export function prepareDispatchApproval(session, request, policy) {
23
+ const registration = authorizers.get(session);
24
+ if (!registration)
25
+ throw new Error("Permission authorizer is unavailable");
26
+ registration.retired.signal.throwIfAborted();
27
+ const approval = {
28
+ registration,
29
+ toolCallId: request.toolCallId,
30
+ toolName: request.toolName,
31
+ input: JSON.stringify(request.input),
32
+ identity: { ...request.identity },
33
+ policy: { ...policy },
34
+ };
35
+ return () => {
36
+ registration.retired.signal.throwIfAborted();
37
+ if (authorizers.get(session) !== registration)
38
+ throw new Error("Permission authorizer was retired");
39
+ approvals.set(request.input, approval);
40
+ };
41
+ }
42
+ export function forgetDispatchApproval(input) {
43
+ approvals.delete(input);
44
+ }
45
+ function matches(approval, registration, request, policy) {
46
+ return (approval?.registration === registration &&
47
+ approval.toolCallId === request.toolCallId &&
48
+ approval.toolName === request.toolName &&
49
+ approval.input === JSON.stringify(request.input) &&
50
+ approval.identity.owner === request.identity.owner &&
51
+ approval.identity.metadata === request.identity.metadata &&
52
+ approval.policy.action === policy.action &&
53
+ approval.policy.fingerprint === policy.fingerprint);
54
+ }
55
+ /** Await only this caller's approval; aborting it does not retire another caller's authority. */
56
+ async function awaitApproval(pending, signal) {
57
+ if (!signal)
58
+ return pending;
59
+ let onAbort = () => { };
60
+ const aborted = new Promise((_resolve, reject) => {
61
+ onAbort = () => reject(signal.reason);
62
+ signal.addEventListener("abort", onAbort, { once: true });
63
+ if (signal.aborted)
64
+ onAbort();
65
+ });
66
+ try {
67
+ await Promise.race([pending, aborted]);
68
+ }
69
+ finally {
70
+ signal.removeEventListener("abort", onAbort);
71
+ }
72
+ }
73
+ /**
74
+ * Resolve live permission after MCP readiness. The returned synchronous fence must
75
+ * pass immediately before dispatch; a false result requires preparation again.
76
+ */
77
+ export async function authorizeToolDispatch(session, request, ctx, signal) {
78
+ signal?.throwIfAborted();
79
+ const registration = authorizers.get(session);
80
+ if (!registration)
81
+ throw new Error("Permission authorizer is unavailable");
82
+ registration.retired.signal.throwIfAborted();
83
+ const policy = registration.authorizer.policy(request, ctx);
84
+ if (policy.action === "deny")
85
+ throw new DeniedError(["*"]);
86
+ if (policy.action === "ask" && !matches(approvals.get(request.input), registration, request, policy)) {
87
+ const approve = prepareDispatchApproval(session, request, policy);
88
+ const waitSignal = signal === undefined ? registration.retired.signal : AbortSignal.any([signal, registration.retired.signal]);
89
+ waitSignal.throwIfAborted();
90
+ await awaitApproval(registration.authorizer.ask(request, ctx, waitSignal), waitSignal);
91
+ signal?.throwIfAborted();
92
+ approve();
93
+ }
94
+ const approvedInput = JSON.stringify(request.input);
95
+ return () => {
96
+ if (signal?.aborted || registration.retired.signal.aborted || authorizers.get(session) !== registration)
97
+ return false;
98
+ if (JSON.stringify(request.input) !== approvedInput)
99
+ return false;
100
+ const current = registration.authorizer.policy(request, ctx);
101
+ if (current.action === "deny")
102
+ return false;
103
+ return current.action === "allow" || matches(approvals.get(request.input), registration, request, current);
104
+ };
105
+ }
106
+ //# sourceMappingURL=dispatch.js.map
@@ -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";
@@ -54,6 +57,8 @@ export default function permissionSystemExtension(pi) {
54
57
  let setupError = null;
55
58
  // The `permission-preset` flag value the rules were last loaded from; absent until session_start.
56
59
  let loadedPresetFlag;
60
+ let loadedPermissionFlag;
61
+ const retireAuthorizers = new WeakMap();
57
62
  const nextRequestID = createRequestIDFactory();
58
63
  pi.registerFlag("permission", {
59
64
  description: "Set permission rules (format: tool=action or tool:pattern=action)",
@@ -74,12 +79,15 @@ export default function permissionSystemExtension(pi) {
74
79
  setupError = getReason(error);
75
80
  throw error;
76
81
  }
82
+ retireAuthorizers.get(ctx.sessionManager)?.();
83
+ retireAuthorizers.set(ctx.sessionManager, registerDispatchAuthorizer(ctx.sessionManager, dispatchAuthorizer));
77
84
  applyToolDenials();
78
85
  });
79
86
  // `sessionApproved` carries this session's "Always" answers, not yet written to disk, across a reload.
80
87
  const loadPermissionRules = (cwd, sessionApproved = []) => {
81
88
  const settingsManager = SettingsManager.create(cwd);
82
89
  const permissionFlag = pi.getFlag("permission");
90
+ loadedPermissionFlag = permissionFlag;
83
91
  const permissionPresetFlag = pi.getFlag("permission-preset");
84
92
  loadedPresetFlag = { value: permissionPresetFlag };
85
93
  cliRuleset = typeof permissionFlag === "string" ? parsePermissionFlag(permissionFlag) : [];
@@ -107,8 +115,11 @@ export default function permissionSystemExtension(pi) {
107
115
  // A host moves a live session to another preset (an attach naming one) by changing the flag;
108
116
  // the rules follow from the next tool call on, and a failed reload refuses calls like session_start.
109
117
  const followPresetFlag = (cwd) => {
110
- if (loadedPresetFlag === undefined || loadedPresetFlag.value === pi.getFlag("permission-preset"))
118
+ if (loadedPresetFlag === undefined ||
119
+ (loadedPresetFlag.value === pi.getFlag("permission-preset") &&
120
+ loadedPermissionFlag === pi.getFlag("permission"))) {
111
121
  return;
122
+ }
112
123
  const sessionApproved = service ? service.getApproved().slice(initialApprovedCount) : [];
113
124
  setupError = null;
114
125
  try {
@@ -120,7 +131,11 @@ export default function permissionSystemExtension(pi) {
120
131
  }
121
132
  applyToolDenials(true);
122
133
  };
123
- pi.on("tool_call", async (event, ctx) => {
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) => {
124
139
  followPresetFlag(ctx.cwd);
125
140
  if (setupError !== null) {
126
141
  return { block: true, reason: `Permission setup failed: ${setupError}` };
@@ -143,6 +158,7 @@ export default function permissionSystemExtension(pi) {
143
158
  return undefined;
144
159
  }
145
160
  const sessionID = ctx.sessionManager.getSessionId();
161
+ const approve = prepareMcpDispatchApproval(pi, event, ctx, dispatchAuthorizer);
146
162
  for (const permissionRequest of permissionRequests) {
147
163
  const request = {
148
164
  id: nextRequestID(),
@@ -176,7 +192,7 @@ export default function permissionSystemExtension(pi) {
176
192
  continue;
177
193
  }
178
194
  if (ctx.hasUI) {
179
- const reply = await showPermissionPrompt(ctx, request);
195
+ const reply = await showPermissionPrompt(ctx, request, signal);
180
196
  activeService.reply(reply);
181
197
  }
182
198
  else {
@@ -194,10 +210,15 @@ export default function permissionSystemExtension(pi) {
194
210
  return { block: true, reason: getReason(askResult.error) };
195
211
  }
196
212
  }
213
+ approve?.();
197
214
  return undefined;
198
- });
215
+ };
216
+ pi.on("tool_call", authorizeToolCall);
217
+ pi.on("tool_result", (event) => forgetDispatchApproval(event.input));
199
218
  pi.on("session_shutdown", async (event, ctx) => {
200
219
  void event;
220
+ retireAuthorizers.get(ctx.sessionManager)?.();
221
+ retireAuthorizers.delete(ctx.sessionManager);
201
222
  if (!service) {
202
223
  return;
203
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;
@@ -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)
package/docs/providers.md CHANGED
@@ -54,7 +54,7 @@ The `anthropic-subscription` provider routes LLM calls through the official [Cla
54
54
  - Settings (`anthropicSubscriptionProvider`):
55
55
  - `systemPromptMode` — controls how the system prompt is delivered. **`full`** (default) sends senpi's composed system prompt verbatim; the lane no longer rebuilds from the SDK `claude_code` preset, so all prompt regions (project rules, response-language instructions, etc.) reach the model. **`preset-append`** is the previous behaviour (deprecated, kept for one release; emits a one-time warning). **`override`** loads the system prompt from a file (`systemPromptFile`). The legacy `appendSystemPrompt` key still works: `false` → `preset-append`, `true`/unset → `full`; setting both keys makes `systemPromptMode` win and warns.
56
56
  - In `full` and `override` modes, `settingSources` defaults to `[]` on every lane because senpi's prompt already carries project context — loading the SDK's own CLAUDE.md would double-inject it. The CLI always prepends its own `"You are a Claude agent, built on Anthropic's Claude Agent SDK."` block, which senpi cannot suppress; `full` means the prompt is delivered intact, not that it is the only system-prompt text.
57
- - `settingSources` (filesystem settings load only in the ambient lane, so they cannot override your selected account), `strictMcpConfig`, `pinnedAccount`, `tokenInjection` (`oauth-slots` | `config-dir` | `ambient`), `resumeMode` (`auto` default | `off` restores per-turn sessions), `systemPromptFile`, `enabled` (default `false`; gates the ambient lane only).
57
+ - `settingSources` (filesystem settings load only in the ambient lane, so they cannot override your selected account), `strictMcpConfig`, `pinnedAccount`, `tokenInjection` (`oauth-slots` | `config-dir` | `ambient`), `resumeMode` (`auto` default | `off` restores per-turn sessions), `compactionOwner` (`senpi` default | `sdk` hands compaction to Claude Code's native auto-compact), `systemPromptFile`, `enabled` (default `false`; gates the ambient lane only).
58
58
  - **Environment overrides** (precedence: `env > project settings > global settings > default`; no new CLI flags):
59
59
 
60
60
  | Variable | Purpose |
@@ -66,9 +66,10 @@ The `anthropic-subscription` provider routes LLM calls through the official [Cla
66
66
  | `SENPI_CLAUDE_SDK_OAUTH_TOKEN_INJECTION` | `oauth-slots` \| `config-dir` \| `ambient` |
67
67
  | `SENPI_CLAUDE_SDK_OAUTH_SETTING_SOURCES` | Overrides `settingSources` |
68
68
  | `SENPI_CLAUDE_SDK_OAUTH_PINNED_ACCOUNT` | Overrides `pinnedAccount` |
69
+ | `SENPI_CLAUDE_SDK_OAUTH_COMPACTION_OWNER` | Overrides `compactionOwner` (`senpi` \| `sdk`) |
69
70
 
70
71
  Every `SENPI_*` variable is stripped from the Claude Code subprocess environment on all three lanes; other inherited variables are preserved.
71
- - **Native compaction.** This lane pins the SDK's session-scoped `autoCompactEnabled: true`, overriding the user's global Claude Code preference because native compaction is part of the lane contract. It does not set `autoCompactWindow`. With `resumeMode: "auto"`, the resident Claude Code session owns compaction; `resumeMode: "off"` restores per-turn sessions and returns compaction ownership to senpi.
72
+ - **Compaction.** senpi owns compaction on this lane by default: speculative and idle compaction, restoration, degradation recovery and per-model thresholds apply, the summary is written by the session's own model unless `compaction.model` is set, and an accepted compaction cold-seeds a fresh resident session from the compacted branch. So that only one side compacts, the lane pins the SDK's session-scoped `autoCompactEnabled: false` (overriding the user's global Claude Code preference) and sets `CLAUDE_CODE_TOTAL_TOKENS_REMINDER=off` for the Claude Code subprocess, whose per-turn token reminder otherwise defeats prompt-cache reuse; at the hard limit Claude Code rejects the turn and senpi's overflow recovery compacts and retries it. If that compaction fails, the turn ends with the error instead of Claude Code compacting natively. Set `compactionOwner: "sdk"` (or `SENPI_CLAUDE_SDK_OAUTH_COMPACTION_OWNER=sdk`) to let the resident Claude Code session compact instead: the lane pins `autoCompactEnabled: true`, does not set `autoCompactWindow`, and senpi stands down its automatic compaction (manual `/compact` stays senpi's). The owner is fixed into the resident Claude Code process when it starts, so changing it mid-session restarts that process on the next turn. Under either owner senpi never rewrites a message the resident session already received, so its per-turn context reduction stays off on this lane. `resumeMode: "off"` restores per-turn sessions, where senpi always owns compaction.
72
73
  - **Session reuse.** By default one long-lived SDK query spans the entire senpi session instead of a fresh one per turn, so conversations continue with only the new delta sent. An accepted senpi compaction rewrites the transcript and forks to a fresh resident session; a rejected compaction leaves continuity unchanged. Reuse also fails closed on branch/fork navigation, account failover, an aborted turn, or any configuration change. Idle sessions retire after 30 minutes (max 32 resident); a session with an in-flight turn is never evicted. After a senpi process restart the lane always starts fresh. Set `resumeMode: "off"` (or `SENPI_CLAUDE_SDK_OAUTH_RESUME=off`) to restore the old per-turn behaviour. Accepted values are `"auto"` (default) and `"off"`; any other value is silently ignored.
73
74
  - Account state is exposed to desktop/automation clients: RPC `get_provider_accounts`, `account_pin`, `account_remove` and the `auth_accounts_changed` / `account_failover` events, mirrored through the app-server protocol. Token material is never included.
74
75
 
package/docs/rpc.md CHANGED
@@ -1308,6 +1308,12 @@ REPORT: nothing here aborts a turn, kills a session, or refuses an `open_session
1308
1308
  - **Event-loop stalls**: a 200 ms timer measures how late it is actually invoked; that lateness is the time the loop
1309
1309
  could serve nobody. Drift above `SENPI_RPC_LOOP_LAG_WARN_MS` (default 500) writes one stderr line per 10 seconds,
1310
1310
  `senpi rpc host stall: event loop blocked <drift>ms (sessionId=<handle> tool=<tool>)`. Drift above
1311
+ `SENPI_RPC_LOOP_LAG_ERROR_MS` (default 5000) additionally broadcasts a `host_stalled` record
1312
+ (`{ type, driftMs, sessionId?, tool?, processCpuMs?, heapDeltaMb? }`) to every connection, like the other content-free lifecycle records.
1313
+ `processCpuMs` is the process CPU time spent during the stalled window and `heapDeltaMb` the JS heap change across it, so a
1314
+ stall explains itself: CPU close to `driftMs` means the host was busy (a large heap drop in the same window points at a
1315
+ collection), and CPU close to zero means the process did not run at all (the machine starved it, or it sat in a blocking
1316
+ wait). The stderr line carries the same two numbers as `cpu=<ms> heap=<+/-MB>`.
1311
1317
  - On accepting an `open_session`, the host sends that connection a `queued` record
1312
1318
  (`{ type, for_request, position, in_flight }`) before the open enters the session loop.
1313
1319
  `position` is 1-based and `in_flight` counts opens already accepted across the whole host,
@@ -1317,12 +1323,12 @@ REPORT: nothing here aborts a turn, kills a session, or refuses an `open_session
1317
1323
  acknowledged open waits up to 10 minutes for its response instead of the 30 s request
1318
1324
  deadline (a busy host was measured answering after 57 s), and a timeout after it names the
1319
1325
  queue position instead of a bare deadline. A lost transport still rejects at once.
1320
- `SENPI_RPC_LOOP_LAG_ERROR_MS` (default 5000) additionally broadcasts a `host_stalled` record
1321
- (`{ type, driftMs, sessionId?, tool?, processCpuMs?, heapDeltaMb? }`) to every connection, like the other content-free lifecycle records.
1322
- `processCpuMs` is the process CPU time spent during the stalled window and `heapDeltaMb` the JS heap change across it, so a
1323
- stall explains itself: CPU close to `driftMs` means the host was busy (a large heap drop in the same window points at a
1324
- collection), and CPU close to zero means the process did not run at all (the machine starved it, or it sat in a blocking
1325
- wait). The stderr line carries the same two numbers as `cpu=<ms> heap=<+/-MB>`.
1326
+ - A multi-session host sends the same `queued` record for a session-routed `prompt` the moment it receives it,
1327
+ before the prompt's preflight runs on the session's loop (`position` counts that session's requests in flight).
1328
+ The bundled `RpcClient` then waits up to 5 minutes for the prompt to be accepted instead of the 30 s request
1329
+ deadline (a compaction's longer wait is never shortened by it), and a timeout after it says the host received the prompt. A prompt the host never acknowledged still
1330
+ fails at the request deadline. A reply written for a session that already closed is dropped, with one stderr
1331
+ line naming the session.
1326
1332
  - **Stall attribution**: each routed command is dispatched inside an `AsyncLocalStorage` scope carrying its routing
1327
1333
  `sessionId`, and an in-process session's tool executions open a span carrying `{ sessionId, tool }` for as long as
1328
1334
  the tool runs. A stall is blamed on the synchronous work that finished inside the measured window, or on the tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.10-5",
3
+ "version": "2026.10.10-6",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-5",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-5",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-5",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-6",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-6",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-6",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -78,8 +78,8 @@
78
78
  "yaml": "2.9.1",
79
79
  "@anthropic-ai/claude-agent-sdk": "0.3.289",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.10-5",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-5",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.10-6",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-6",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",