@code-yeongyu/senpi 2026.10.10-8 → 2026.10.10-9

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 (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/bundle/chunks/{anthropic-messages-6EZ2RZQI.js → anthropic-messages-2B35OVB7.js} +2 -2
  3. package/dist/bundle/chunks/{app-server-command-WVQZIVCM.js → app-server-command-2ZXLBILW.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-KKJJVAVT.js → chunk-4WZF7G6N.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-TUGQPBQG.js → chunk-NTH33AXY.js} +15 -4
  6. package/dist/bundle/chunks/{chunk-56NFYCQ3.js → chunk-QMUL36OS.js} +19 -24
  7. package/dist/bundle/chunks/{chunk-TLK72OXY.js → chunk-QQZREV6B.js} +1 -1
  8. package/dist/bundle/chunks/chunk-Z72GD25U.js +9 -0
  9. package/dist/bundle/chunks/{chunk-QYKYTDMI.js → chunk-ZCYTYHDN.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-DOMETRRO.js → chunk-ZPJHS64R.js} +1 -1
  11. package/dist/bundle/chunks/{cli-main-CED4CUJM.js → cli-main-VTGNVET5.js} +1 -1
  12. package/dist/bundle/chunks/{help-fast-path-PSP2GJSG.js → help-fast-path-5P73752B.js} +1 -1
  13. package/dist/bundle/chunks/{host-command-5ILDFZVI.js → host-command-MWQNTWFA.js} +1 -1
  14. package/dist/bundle/chunks/{interactive-mode-ZKESRBOA.js → interactive-mode-ERBFHQQJ.js} +1 -1
  15. package/dist/bundle/chunks/{multi-session-host-AL2DTLN2.js → multi-session-host-S37LFCMH.js} +1 -1
  16. package/dist/bundle/chunks/{package-manager-cli-U4UWVUUX.js → package-manager-cli-FVEIJBD5.js} +1 -1
  17. package/dist/bundle/chunks/{rotation-stream-TAARAJGY.js → rotation-stream-FQH63DMY.js} +1 -1
  18. package/dist/bundle/chunks/{rpc-mode-MXEWGXIB.js → rpc-mode-KJFVBWIP.js} +1 -1
  19. package/dist/bundle/chunks/{session-control-endpoint-EQXHX4WI.js → session-control-endpoint-OH7XV2FN.js} +1 -1
  20. package/dist/bundle/chunks/{session-picker-LIZG5P3Q.js → session-picker-UUSW6YSU.js} +1 -1
  21. package/dist/bundle/chunks/session-worker.js +31 -30
  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/agent-session.d.ts +16 -0
  27. package/dist/core/agent-session.js +61 -0
  28. package/dist/core/engine-turn-limit.d.ts +36 -0
  29. package/dist/core/engine-turn-limit.js +74 -0
  30. package/dist/core/extensions/builtin/ask-user/format.d.ts +9 -4
  31. package/dist/core/extensions/builtin/ask-user/format.js +37 -9
  32. package/dist/core/extensions/builtin/ask-user/params.d.ts +2 -0
  33. package/dist/core/extensions/builtin/ask-user/params.js +6 -0
  34. package/dist/core/extensions/builtin/ask-user/resume.js +8 -1
  35. package/dist/core/extensions/builtin/ask-user/schema.js +4 -0
  36. package/dist/core/extensions/builtin/ask-user/tool.js +18 -7
  37. package/dist/core/extensions/builtin/hooks/index.js +1 -1
  38. package/dist/core/extensions/builtin/ttsr/follow-up-limit.d.ts +11 -0
  39. package/dist/core/extensions/builtin/ttsr/follow-up-limit.js +30 -0
  40. package/dist/core/extensions/builtin/ttsr/index.js +9 -1
  41. package/dist/core/extensions/runner.d.ts +2 -0
  42. package/dist/core/extensions/runner.js +4 -0
  43. package/dist/core/extensions/types.d.ts +2 -0
  44. package/dist/core/settings-manager.d.ts +9 -0
  45. package/dist/core/settings-manager.js +9 -0
  46. package/dist/modes/interactive/interactive-mode.js +1 -1
  47. package/docs/settings.md +9 -0
  48. package/package.json +6 -6
  49. package/dist/bundle/chunks/chunk-V43EN5GQ.js +0 -14
@@ -1,16 +1,23 @@
1
1
  import { WAKE_SOURCE_STATE_EVENT } from "../monitor-state-event.js";
2
2
  import { TOOL_NAMES } from "./family.js";
3
- import { formatModelAnswer, formatResultDetails, formatUserMessage } from "./format.js";
3
+ import { formatGatedCancellation, formatModelAnswer, formatResultDetails, formatUserMessage } from "./format.js";
4
4
  import { ASK_USER_ASKED_EVENT, ASK_USER_QUESTION_ENTRY, ASK_USER_SETTLEMENT_ENTRY, emitAskUserClosed, emitAskUserNotification, } from "./notify.js";
5
5
  import { createPendingQuestion } from "./pending.js";
6
6
  import { getPendingQuestions, queueQuestionOutcome, registerPendingQuestion, } from "./registry.js";
7
7
  import { renderCall, renderResult } from "./render.js";
8
8
  import { AskUserSchemaError, CLAUDE_PARAMS, CODEX_PARAMS, toCanonical, } from "./schema.js";
9
9
  const REASK = "The user did not answer the previous question this turn; continue without asking again.";
10
+ const REASK_REQUIRED = "The user did not answer the previous question this turn, so this one gets no answer either. No answer: do not take the action it gates. Keep that action pending and end the turn.";
10
11
  function result(variant, response, request, text) {
11
12
  return {
12
- content: [{ type: "text", text: text ?? formatModelAnswer(response, request.requestId, request.questions).text }],
13
- details: formatResultDetails(variant, response, request.requestId, request.questions),
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: text ??
17
+ formatModelAnswer(response, request.requestId, request.questions, request.required === true).text,
18
+ },
19
+ ],
20
+ details: formatResultDetails(variant, response, request.requestId, request.questions, request.required === true),
14
21
  };
15
22
  }
16
23
  /**
@@ -24,7 +31,7 @@ function result(variant, response, request, text) {
24
31
  export function deliverAnswer(pi, ctx, request, response, variant) {
25
32
  if (response.status === "cancelled")
26
33
  return;
27
- pi.sendUserMessage(formatUserMessage(response, request.requestId, request.questions), {
34
+ pi.sendUserMessage(formatUserMessage(response, request.requestId, request.questions, request.required === true), {
28
35
  deliverAs: ctx.isIdle() ? "followUp" : "steer",
29
36
  });
30
37
  void emitAskUserNotification(pi, ctx, request, response, variant);
@@ -258,7 +265,7 @@ export function createAskUserTool(variant, pi, state) {
258
265
  allowLazyActivation: false,
259
266
  parameters: variant === "codex" ? CODEX_PARAMS : CLAUDE_PARAMS,
260
267
  promptSnippet: "Ask a material question, explicitly choosing whether to wait or receive the answer later.",
261
- description: `${opener} Set ${flag} true to pause here until the user answers (the answer returns as this tool's result, or a timeout result after 30 idle minutes); set ${flag} false to keep working while the question stays open (the answer arrives later as a user message). Use it only when the answer materially changes the work; if it returns no answers, continue with best judgment instead of asking again. Never use it for permission requests; ask those directly in your message. Unavailable to subagents.${variant === "claude" ? " Users will always be able to type a free-text answer or one comment covering everything; do not add an Other option." : ""} Call this tool directly, never from inside an eval cell (a cell that waits on the user would hold the kernel).`,
268
+ description: `${opener} Set ${flag} true to pause here until the user answers (the answer returns as this tool's result, or a timeout result after 30 idle minutes); set ${flag} false to keep working while the question stays open (the answer arrives later as a user message). Use it only when the answer materially changes the work; if it returns no answers, continue with best judgment instead of asking again, unless you set required true because the answer decides whether you take an action: then no answer means do not take it. Never use it for permission requests; ask those directly in your message. Unavailable to subagents.${variant === "claude" ? " Users will always be able to type a free-text answer or one comment covering everything; do not add an Other option." : ""} Call this tool directly, never from inside an eval cell (a cell that waits on the user would hold the kernel).`,
262
269
  prepareArguments(args) {
263
270
  toCanonical(variant, args);
264
271
  return args;
@@ -284,7 +291,7 @@ export function createAskUserTool(variant, pi, state) {
284
291
  };
285
292
  if (state.timedOut) {
286
293
  emitAskUserClosed(pi, request.requestId, unavailable);
287
- return result(variant, unavailable, request, REASK);
294
+ return result(variant, unavailable, request, request.required === true ? REASK_REQUIRED : REASK);
288
295
  }
289
296
  if (state.unavailable ||
290
297
  ctx.getAskUserSettings?.().enabled === false ||
@@ -307,7 +314,11 @@ export function createAskUserTool(variant, pi, state) {
307
314
  if (response.status !== "cancelled") {
308
315
  void emitAskUserNotification(pi, ctx, request, response, variant);
309
316
  }
310
- return result(variant, response, request, response.status === "cancelled" && response.comment ? response.comment : undefined);
317
+ return result(variant, response, request, response.status === "cancelled" && response.comment
318
+ ? request.required === true
319
+ ? formatGatedCancellation(response.comment, response, request.requestId, request.questions)
320
+ : response.comment
321
+ : undefined);
311
322
  },
312
323
  renderCall,
313
324
  renderResult,
@@ -54,7 +54,7 @@ export default function hooksExtension(pi) {
54
54
  message: response === undefined
55
55
  ? `Question asked (${headers})`
56
56
  : response.status === "timed_out"
57
- ? `Question timed out (${headers}): ${formatResultText(variant, response, request.questions)}`
57
+ ? `Question timed out (${headers}): ${formatResultText(variant, response, request.questions, request.required === true)}`
58
58
  : `Question ${response.status} (${headers})`,
59
59
  requestId: request.requestId,
60
60
  source: "ask-user",
@@ -0,0 +1,11 @@
1
+ import type { SessionEntry } from "../../../session-manager.ts";
2
+ export declare const TTSR_LOOP_STOPPED_ENTRY_TYPE = "ttsr-loop-stopped";
3
+ export declare const TTSR_LOOP_STOPPED_EVENT = "ttsr:loop-stopped";
4
+ export declare function ttsrLoopStoppedNotice(rule: string): string;
5
+ /**
6
+ * Whether this rule already sent its one corrective follow-up since the user's last message. Read from the
7
+ * session, so a host that rebuilds the extension between turns cannot re-arm it (senpi#2967); the engine-wide
8
+ * turn bound covers every other source.
9
+ */
10
+ export declare function ruleAlreadyCorrected(entries: readonly SessionEntry[], rule: string): boolean;
11
+ //# sourceMappingURL=follow-up-limit.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { TTSR_INJECTION_CUSTOM_TYPE } from "./types.js";
2
+ export const TTSR_LOOP_STOPPED_ENTRY_TYPE = "ttsr-loop-stopped";
3
+ export const TTSR_LOOP_STOPPED_EVENT = "ttsr:loop-stopped";
4
+ export function ttsrLoopStoppedNotice(rule) {
5
+ return `The ${rule} stream rule flagged the reply again after its correction, so no further correction was sent. Send any message to continue.`;
6
+ }
7
+ function nudgedRules(entry) {
8
+ if (entry.type !== "custom_message" || entry.customType !== TTSR_INJECTION_CUSTOM_TYPE)
9
+ return [];
10
+ const details = entry.details;
11
+ if (typeof details !== "object" || details === null || !("rules" in details))
12
+ return [];
13
+ return Array.isArray(details.rules) ? details.rules : [];
14
+ }
15
+ /**
16
+ * Whether this rule already sent its one corrective follow-up since the user's last message. Read from the
17
+ * session, so a host that rebuilds the extension between turns cannot re-arm it (senpi#2967); the engine-wide
18
+ * turn bound covers every other source.
19
+ */
20
+ export function ruleAlreadyCorrected(entries, rule) {
21
+ for (let index = entries.length - 1; index >= 0; index--) {
22
+ const entry = entries[index];
23
+ if (entry.type === "message" && entry.message.role === "user")
24
+ return false;
25
+ if (nudgedRules(entry).includes(rule))
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ //# sourceMappingURL=follow-up-limit.js.map
@@ -6,6 +6,7 @@ import { registerTtsrCommands } from "./commands.js";
6
6
  import { claimAbort, createGenerationState, markUserCancelled } from "./coordinator.js";
7
7
  import { REPETITIVE_TURNS_RULE_NAME } from "./detectors/repetitive-turns.js";
8
8
  import { discoverTtsrRulesSync } from "./discovery.js";
9
+ import { ruleAlreadyCorrected, TTSR_LOOP_STOPPED_ENTRY_TYPE, TTSR_LOOP_STOPPED_EVENT, ttsrLoopStoppedNotice, } from "./follow-up-limit.js";
9
10
  import { TtsrManager } from "./manager.js";
10
11
  import { getTtsrStreamDelta } from "./message-update.js";
11
12
  import { REPETITIVE_TURNS_RULE_CONTENT } from "./prompts.js";
@@ -242,7 +243,7 @@ export default function ttsrExtension(pi) {
242
243
  repetitiveTurns.recordCompletedTurn(turnText);
243
244
  return undefined;
244
245
  });
245
- pi.on("agent_settled", () => {
246
+ pi.on("agent_settled", (_event, ctx) => {
246
247
  if (pendingNudge === null || genState.userCancelled || settlingAgentEnd?.abortSource === "user") {
247
248
  pendingNudge = null;
248
249
  settlingAgentEnd = null;
@@ -250,6 +251,13 @@ export default function ttsrExtension(pi) {
250
251
  }
251
252
  const nudge = pendingNudge;
252
253
  pendingNudge = null;
254
+ const [rule] = nudge.details.rules;
255
+ if (rule !== undefined && ruleAlreadyCorrected(ctx.sessionManager.getEntries(), rule)) {
256
+ pi.appendEntry(TTSR_LOOP_STOPPED_ENTRY_TYPE, { rules: nudge.details.rules, at: Date.now() });
257
+ pi.events.emit(TTSR_LOOP_STOPPED_EVENT, { rules: nudge.details.rules });
258
+ ctx.ui.notify(ttsrLoopStoppedNotice(rule), "warning");
259
+ return;
260
+ }
253
261
  pi.sendMessage(nudge, { triggerTurn: true });
254
262
  });
255
263
  }
@@ -206,6 +206,8 @@ export declare class ExtensionRunner {
206
206
  * signals extensions publish about work that outlives a turn (`wake_source_state`).
207
207
  */
208
208
  onBusEvent(channel: string, handler: (data: unknown) => void): () => void;
209
+ /** Publish a session-originated signal on the shared bus, for extensions and hosts that observe it. */
210
+ emitBusEvent(channel: string, data: unknown): void;
209
211
  /** Get extension-declared MCP servers (first declaration per name wins). */
210
212
  getRegisteredMcpServers(): readonly RegisteredMcpServerDeclaration[];
211
213
  /** Get a tool definition by name. Returns undefined if not found. */
@@ -594,6 +594,10 @@ export class ExtensionRunner {
594
594
  onBusEvent(channel, handler) {
595
595
  return this.eventBus.on(channel, handler);
596
596
  }
597
+ /** Publish a session-originated signal on the shared bus, for extensions and hosts that observe it. */
598
+ emitBusEvent(channel, data) {
599
+ this.eventBus.emit(channel, data);
600
+ }
597
601
  /** Get extension-declared MCP servers (first declaration per name wins). */
598
602
  getRegisteredMcpServers() {
599
603
  const serversByName = new Map();
@@ -94,6 +94,8 @@ export interface QuestionRequest {
94
94
  }>;
95
95
  waitForAnswer: boolean;
96
96
  timeoutMs: number;
97
+ /** The question gates an action: a missing answer means the action is not taken (senpi#2949). */
98
+ required?: boolean;
97
99
  }
98
100
  /** Outcome of ExtensionUIContext.question. */
99
101
  export interface QuestionResponse {
@@ -103,6 +103,11 @@ export interface Settings {
103
103
  images?: ImageSettings;
104
104
  lookAt?: LookAtSettings;
105
105
  askUser?: AskUserSettings;
106
+ /** Bounds on turns the engine starts without a user message (senpi#2967). */
107
+ engineTurns?: {
108
+ maxPerUserInput?: number;
109
+ maxToolFreePerMinute?: number;
110
+ };
106
111
  todo?: TodoSettings;
107
112
  recommendedModels?: string[];
108
113
  favoriteModels?: string[];
@@ -254,6 +259,10 @@ export declare class SettingsManager {
254
259
  timeoutMinutes: number;
255
260
  bell: boolean;
256
261
  };
262
+ getEngineTurnSettings(): {
263
+ maxPerUserInput: number;
264
+ maxToolFreePerMinute: number;
265
+ };
257
266
  getTodoFirstTurnPlan(): TodoFirstTurnPlan;
258
267
  getTodoTurnEndBackstop(): boolean;
259
268
  isProjectTrusted(): boolean;
@@ -12,6 +12,7 @@ import { normalizePath, resolvePath } from "../utils/paths.js";
12
12
  import { envValue } from "./brand.js";
13
13
  import { compactionEnabled, compactionKeepRecentTokens, compactionReserveTokens, } from "./compaction-settings-access.js";
14
14
  import { resolveCompactionSettings } from "./compaction-settings-resolver.js";
15
+ import { MAX_ENGINE_TURNS_PER_USER_INPUT, MAX_TOOL_FREE_ENGINE_TURNS_PER_WINDOW } from "./engine-turn-limit.js";
15
16
  import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.js";
16
17
  import { CredentialStoreBusyError, FILE_STORAGE_LOCK_OPTIONS, FILE_STORAGE_LOCK_RETRY_BUDGET_MS, FILE_STORAGE_LOCK_RETRY_MAX_DELAY_MS, FILE_STORAGE_LOCK_RETRY_MIN_DELAY_MS, FILE_STORAGE_SYNC_LOCK_BUDGET_MS, isLockError, } from "./lockfile-policy.js";
17
18
  import { resolveFallbackCircuitSettings, } from "./retry-fallback/circuit.js";
@@ -675,6 +676,14 @@ export class SettingsManager {
675
676
  bell: typeof configured?.bell === "boolean" ? configured.bell : true,
676
677
  };
677
678
  }
679
+ getEngineTurnSettings() {
680
+ const configured = this.settings.engineTurns;
681
+ const limit = (value, fallback) => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
682
+ return {
683
+ maxPerUserInput: limit(configured?.maxPerUserInput, MAX_ENGINE_TURNS_PER_USER_INPUT),
684
+ maxToolFreePerMinute: limit(configured?.maxToolFreePerMinute, MAX_TOOL_FREE_ENGINE_TURNS_PER_WINDOW),
685
+ };
686
+ }
678
687
  getTodoFirstTurnPlan() {
679
688
  const configured = this.settings.todo?.firstTurnPlan;
680
689
  return configured === "remind" || configured === "off" ? configured : "force";
@@ -3249,7 +3249,7 @@ export class InteractiveMode {
3249
3249
  this.showStatus("The user dismissed the question.");
3250
3250
  // Ordinary cancellation/abort stays silent in the builtin. Only this
3251
3251
  // explicit command acknowledges dismissal, including on host bridges.
3252
- await this.session.sendUserMessage(formatUserMessage(response, state.request.requestId, state.request.questions), {
3252
+ await this.session.sendUserMessage(formatUserMessage(response, state.request.requestId, state.request.questions, state.request.required === true), {
3253
3253
  deliverAs: this.session.isStreaming ? "steer" : "followUp",
3254
3254
  });
3255
3255
  return;
package/docs/settings.md CHANGED
@@ -528,6 +528,15 @@ await tool.monitor({ description: "build", command: "bun run build", filter: "^d
528
528
 
529
529
  This is the default and has no setting. Tools may declare `exposure: "eval"` to join this policy; `bash`, `powershell` and `grep` use that declaration. They remain registered and discoverable through `tool_schema` inside eval. Hooks and permission checks apply unchanged to calls made this way, and the prompt surfaces that document these tools render the `tool.<name>(` form to match. If the model attempts a direct call anyway, the call returns a hint naming the eval form instead of executing the tool. When the `eval` tool is unavailable (codemode not loaded, or a child agent whose allowlist omits it), the policy stays inert and otherwise enabled tools remain directly callable, so shell, text search, workflow and monitor access is never lost.
530
530
 
531
+ ### Automatic Turns
532
+
533
+ | Setting | Type | Default | Description |
534
+ |---------|------|---------|-------------|
535
+ | `engineTurns.maxPerUserInput` | number | `150` | Turns the agent may start on its own (stream-rule nudges, goal continuations, other extension follow-ups) after one user message before it pauses; `0` turns the limit off |
536
+ | `engineTurns.maxToolFreePerMinute` | number | `12` | Turns started on its own within 60 s that called no tool before it pauses; `0` turns the limit off |
537
+
538
+ When a limit is reached the session pauses with a notice instead of starting another turn; any message you send continues it, because only turns since your last message count.
539
+
531
540
  ### Ask User
532
541
 
533
542
  | Setting | Type | Default | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.10-8",
3
+ "version": "2026.10.10-9",
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-8",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-8",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-8",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-9",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-9",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-9",
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.292",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.10-8",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-8",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.10-9",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-9",
83
83
  "@modelcontextprotocol/sdk": "1.32.1",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",