@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -1950,6 +1950,21 @@ export class SessionManager {
1950
1950
  return this.#sessionFile;
1951
1951
  }
1952
1952
 
1953
+ /**
1954
+ * Whether the current session has actually been materialized to durable
1955
+ * storage (the JSONL exists on disk / in the active storage backend).
1956
+ *
1957
+ * Session persistence is lazy: the file is only written once the history
1958
+ * contains an assistant message (or an explicit {@link ensureOnDisk}
1959
+ * caller forces it). Until then {@link getSessionFile} returns an allocated
1960
+ * path that leads nowhere, so a `--resume <id>` hint built from it would
1961
+ * always fail. Consumers that advertise a resume command must gate on this
1962
+ * (issue #8860).
1963
+ */
1964
+ isSessionOnDisk(): boolean {
1965
+ return !!this.#sessionFile && this.#storage.existsSync(this.#sessionFile);
1966
+ }
1967
+
1953
1968
  getArtifactsDir(): string | null {
1954
1969
  if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
1955
1970
  return artifactsDirectoryFor(this.#sessionFile);
@@ -22,6 +22,12 @@ interface PendingContextSnapshot {
22
22
  promptTokens: number;
23
23
  nonMessageTokens: number;
24
24
  cutoffCount: number;
25
+ /**
26
+ * Compaction epoch at rebase time. Distinguishes a genuinely fresh in-turn
27
+ * anchor (same epoch) from a post-cutoff anchor that predates a mid-run
28
+ * compaction (older epoch) so the latter never out-ranks this snapshot.
29
+ */
30
+ epoch: number;
25
31
  }
26
32
 
27
33
  /** Capabilities the stats tracker borrows from its owning session. */
@@ -44,6 +50,7 @@ export class SessionStatsTracker {
44
50
  readonly #host: SessionStatsTrackerHost;
45
51
  #pendingContextSnapshot: PendingContextSnapshot | undefined;
46
52
  #contextUsageRevision = 0;
53
+ #compactionEpoch = 0;
47
54
 
48
55
  constructor(host: SessionStatsTrackerHost) {
49
56
  this.#host = host;
@@ -162,8 +169,11 @@ export class SessionStatsTracker {
162
169
  }
163
170
  }
164
171
 
172
+ const anchorEpoch = anchorAssistant?.contextSnapshot?.compactionEpoch ?? 0;
165
173
  const useAnchor =
166
- anchorAssistant !== undefined && anchorIndex !== -1 && (!pending || anchorIndex >= pending.cutoffCount);
174
+ anchorAssistant !== undefined &&
175
+ anchorIndex !== -1 &&
176
+ (!pending || (anchorIndex >= pending.cutoffCount && anchorEpoch >= pending.epoch));
167
177
  if (useAnchor && anchorAssistant) {
168
178
  const promptTokens = correctedPromptTokens(anchorAssistant);
169
179
  const nonMessageTokens =
@@ -255,6 +265,15 @@ export class SessionStatsTracker {
255
265
  return this.#contextUsageRevision;
256
266
  }
257
267
 
268
+ /**
269
+ * Monotonic compaction epoch, bumped whenever history is compacted. Stamped
270
+ * onto each assistant snapshot at record time so {@link getContextBreakdown}
271
+ * can reject a post-cutoff anchor whose usage predates the last compaction.
272
+ */
273
+ get compactionEpoch(): number {
274
+ return this.#compactionEpoch;
275
+ }
276
+
258
277
  /** Non-message token count captured for the active provider request. */
259
278
  get pendingNonMessageTokens(): number | undefined {
260
279
  return this.#pendingContextSnapshot?.nonMessageTokens;
@@ -292,6 +311,7 @@ export class SessionStatsTracker {
292
311
  assistant.contextSnapshot = {
293
312
  promptTokens: calculatePromptTokens(assistant.usage),
294
313
  nonMessageTokens: computeNonMessageTokens(this.#host.session),
314
+ compactionEpoch: this.#compactionEpoch,
295
315
  };
296
316
  }
297
317
  const snapshot = assistant.contextSnapshot;
@@ -302,13 +322,14 @@ export class SessionStatsTracker {
302
322
  }
303
323
 
304
324
  /** Sets or clears the in-flight context snapshot. */
305
- setPendingSnapshot(snapshot: PendingContextSnapshot | undefined): void {
306
- this.#pendingContextSnapshot = snapshot;
325
+ setPendingSnapshot(snapshot: Omit<PendingContextSnapshot, "epoch"> | undefined): void {
326
+ this.#pendingContextSnapshot = snapshot ? { ...snapshot, epoch: this.#compactionEpoch } : undefined;
307
327
  this.#contextUsageRevision++;
308
328
  }
309
329
 
310
330
  /** Recomputes an in-flight snapshot after history is compacted or rewritten. */
311
331
  rebaseAfterCompaction(): void {
332
+ this.#compactionEpoch++;
312
333
  if (!this.#pendingContextSnapshot) return;
313
334
  const nonMessageTokens = computeNonMessageTokens(this.#host.session);
314
335
  const messages = this.#host.agent.state.messages;
@@ -41,6 +41,12 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
41
41
  : model.api === "openai-responses"
42
42
  ? settings.get("textVerbosity")
43
43
  : undefined;
44
+ // "auto" leaves the option unset so provider defaults and the
45
+ // PI_CACHE_RETENTION env override keep working; anything else is an
46
+ // explicit per-request retention (long restores 1h Anthropic TTLs and
47
+ // implicitly disables the short-entry keep-alive refresh loop).
48
+ const cacheRetentionSetting = settings.get("providers.cacheRetention");
49
+ const cacheRetention = cacheRetentionSetting === "auto" ? undefined : cacheRetentionSetting;
44
50
  const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
45
51
  const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
46
52
  // Server-side fallback (opt-in): when the user enables it AND the
@@ -60,6 +66,7 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
60
66
  openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
61
67
  antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
62
68
  textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
69
+ cacheRetention: streamOptions?.cacheRetention ?? cacheRetention,
63
70
  streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
64
71
  streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
65
72
  maxRetryDelayMs: streamOptions?.maxRetryDelayMs ?? settings.get("retry.maxDelayMs"),
@@ -0,0 +1,32 @@
1
+ /** Compact title-model input for a user-invoked `/skill:<name>` prompt. */
2
+ export function skillPromptTitleInput(input: { name?: string; args?: string; queueChipText?: string }): string {
3
+ const chip = input.queueChipText?.trim();
4
+ if (chip) return chip;
5
+ const name = input.name?.trim();
6
+ const args = input.args?.trim();
7
+ if (name && args) return `/skill:${name} ${args}`;
8
+ if (name) return `/skill:${name}`;
9
+ return args ?? "";
10
+ }
11
+
12
+ /** Title text for a persisted skill-prompt custom message. Never the expanded SKILL.md body. */
13
+ export function titleTextFromSkillPrompt(message: {
14
+ role: string;
15
+ customType?: string;
16
+ attribution?: string;
17
+ details?: unknown;
18
+ }): string | undefined {
19
+ if (message.role !== "custom" || message.customType !== "skill-prompt" || message.attribution !== "user") {
20
+ return undefined;
21
+ }
22
+ let name: string | undefined;
23
+ let args: string | undefined;
24
+ let queueChipText: string | undefined;
25
+ if (message.details && typeof message.details === "object") {
26
+ const details = message.details as Record<string, unknown>;
27
+ if (typeof details.name === "string") name = details.name;
28
+ if (typeof details.args === "string") args = details.args;
29
+ if (typeof details.__queueChipText === "string") queueChipText = details.__queueChipText;
30
+ }
31
+ return skillPromptTitleInput({ name, args, queueChipText }) || undefined;
32
+ }
@@ -20,7 +20,6 @@ import type {
20
20
  } from "@oh-my-pi/pi-ai";
21
21
  import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi-ai";
22
22
  import * as AIError from "@oh-my-pi/pi-ai/error";
23
- import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
24
23
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
25
24
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
26
25
  import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -1177,25 +1176,15 @@ export class TurnRecovery {
1177
1176
  if (!reasonlessAbort && !streamStall && !transportReset) return undefined;
1178
1177
  if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
1179
1178
 
1180
- // The Cursor server-execution marker gate applies only to the idle stream-stall
1181
- // path: an unmarked/unresolved Cursor block there means the server has not
1182
- // finished executing, so resuming would race it. A reasonless abort instead
1183
- // ends the turn and the agent loop pairs every un-run call (Cursor's unmarked
1184
- // `todo`/MCP blocks included) with a synthetic `executed: false` result, so
1185
- // the tool-result reconciliation below is the safety gate and the marker is
1186
- // irrelevant. An HTTP/2 RST_STREAM / NGHTTP2_* close also ends the Connect
1187
- // stream, so there is no in-flight server exec to race — unmarked MCP/todo
1188
- // blocks are safe to continue once every emitted call has a result.
1179
+ // Idle stall and HTTP/2 RST both close the Cursor Connect stream:
1180
+ // the lazy watchdog aborts the request signal, and cursor.ts then
1181
+ // calls `h2Request.close()`. There is no in-flight server exec to
1182
+ // race, so unmarked MCP/todo blocks can continue once every emitted
1183
+ // call has a matching result. A reasonless abort ends the turn and
1184
+ // the agent loop pairs leftover calls with `executed: false`.
1189
1185
  const resolvedToolCallIds: string[] = [];
1190
1186
  for (const block of message.content) {
1191
1187
  if (block.type !== "toolCall") continue;
1192
- if (
1193
- streamStall &&
1194
- message.provider === "cursor" &&
1195
- (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true)
1196
- ) {
1197
- return undefined;
1198
- }
1199
1188
  resolvedToolCallIds.push(block.id);
1200
1189
  }
1201
1190
  if (resolvedToolCallIds.length === 0) return undefined;
@@ -1660,6 +1649,9 @@ export class TurnRecovery {
1660
1649
  if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
1661
1650
  if (AIError.is(id, AIError.Flag.UsageLimit)) return false;
1662
1651
  if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
1652
+ // A thinking loop is a same-model resample signal, not a router fault, so a
1653
+ // base-model swap would abandon the loop-guard redirect (issue #8760).
1654
+ if (AIError.is(id, AIError.Flag.ThinkingLoop)) return false;
1663
1655
  return this.#host.modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
1664
1656
  }
1665
1657
 
@@ -1969,10 +1961,23 @@ export class TurnRecovery {
1969
1961
  );
1970
1962
  if (switchedCredential) delayMs = 0;
1971
1963
  }
1964
+ // A thinking-loop abort is not a provider failure — it is the loop guard
1965
+ // asking for a same-model resample, paired with a hidden
1966
+ // `thinking-loop-redirect` notice that only makes sense on the model that
1967
+ // looped. Walking `fallbackChains` (or parking the selector on a cooldown)
1968
+ // would swap a healthy planning turn to another family based on chain
1969
+ // contents, not model health (issue #8760). Keep it on the same model; the
1970
+ // retry budget still bounds a genuinely stuck stream.
1971
+ const thinkingLoop = AIError.is(id, AIError.Flag.ThinkingLoop);
1972
1972
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
1973
1973
  // A refusal chain stops at the retry budget: the exhausted-attempt
1974
1974
  // last resort is for provider failures, not classifier decisions.
1975
- if (allowModelFallback && retrySettings.modelFallback && !(retryBudgetExhausted && classifierRefusal)) {
1975
+ if (
1976
+ allowModelFallback &&
1977
+ retrySettings.modelFallback &&
1978
+ !thinkingLoop &&
1979
+ !(retryBudgetExhausted && classifierRefusal)
1980
+ ) {
1976
1981
  if (!classifierRefusal) {
1977
1982
  this.noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
1978
1983
  }
@@ -336,7 +336,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
336
336
  {
337
337
  name: "stats",
338
338
  description: "Launch the local stats dashboard",
339
- inlineHint: "[--port <port>]",
339
+ inlineHint: "[--port <port>] [--host <host>]",
340
340
  allowArgs: true,
341
341
  handle: async (command, runtime) => {
342
342
  const parsed = parseStatsDashboardArgs(command.args);
@@ -11,6 +11,7 @@ interface StatsDashboardServer {
11
11
 
12
12
  export interface StatsDashboardArgs {
13
13
  port: number;
14
+ host: string;
14
15
  }
15
16
 
16
17
  export interface StatsDashboardLaunchResult {
@@ -20,7 +21,7 @@ export interface StatsDashboardLaunchResult {
20
21
 
21
22
  let activeStatsServer: StatsDashboardServer | undefined;
22
23
 
23
- const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>]";
24
+ const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>] [--host <host>]";
24
25
 
25
26
  function parsePort(value: string | undefined): number | string {
26
27
  if (!value) return `Missing port. ${STATS_DASHBOARD_USAGE}`;
@@ -33,6 +34,7 @@ function parsePort(value: string | undefined): number | string {
33
34
  export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { error: string } {
34
35
  const tokens = args.split(/\s+/).filter(Boolean);
35
36
  let port = DEFAULT_STATS_DASHBOARD_PORT;
37
+ let host = "127.0.0.1";
36
38
 
37
39
  for (let i = 0; i < tokens.length; i++) {
38
40
  const token = tokens[i];
@@ -48,28 +50,40 @@ export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { er
48
50
  port = parsed;
49
51
  continue;
50
52
  }
53
+ if (token === "--host") {
54
+ const value = tokens[++i];
55
+ if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
56
+ host = value;
57
+ continue;
58
+ }
59
+ if (token.startsWith("--host=")) {
60
+ const value = token.slice("--host=".length);
61
+ if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
62
+ host = value;
63
+ continue;
64
+ }
51
65
  return { error: `Unknown option: ${token}. ${STATS_DASHBOARD_USAGE}` };
52
66
  }
53
67
 
54
- return { port };
68
+ return { port, host };
55
69
  }
56
70
 
57
71
  export async function launchStatsDashboard(args: StatsDashboardArgs): Promise<StatsDashboardLaunchResult> {
58
72
  const { processed, files } = await stats.syncAllSessions();
59
73
  const total = await stats.getTotalMessageCount();
60
- let requestedPortIgnored = false;
74
+ let requestedAddressIgnored = false;
61
75
 
62
76
  if (!activeStatsServer) {
63
- activeStatsServer = await stats.startServer(args.port);
64
- } else if (args.port !== activeStatsServer.port) {
65
- requestedPortIgnored = true;
77
+ activeStatsServer = await stats.startServer(args.port, args.host);
78
+ } else if (args.port !== activeStatsServer.port || args.host !== activeStatsServer.hostname) {
79
+ requestedAddressIgnored = true;
66
80
  }
67
81
 
68
- const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`;
82
+ const url = stats.formatStatsDashboardUrl(activeStatsServer.hostname, activeStatsServer.port);
69
83
  openUtils.openPath(url);
70
84
 
71
- const serverLine = requestedPortIgnored
72
- ? `Dashboard already running at: ${url} (requested port ${args.port} ignored)`
85
+ const serverLine = requestedAddressIgnored
86
+ ? `Dashboard already running at: ${url} (requested ${args.host}:${args.port} ignored)`
73
87
  : `Dashboard available at: ${url}`;
74
88
 
75
89
  return {
@@ -108,17 +108,20 @@ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
108
108
  /**
109
109
  * Resolve the command used to relaunch the agent CLI into worker mode. In a
110
110
  * compiled binary the entry point is the binary itself; otherwise re-enter the
111
- * declared worker-host entry with a cwd-relative script path (Bun's subprocess
112
- * IPC is more reliable that way under `bun test`), falling back to this
113
- * package's own `src/cli.ts` when no host entry is declared (bun test, SDK
114
- * embedding).
111
+ * declared worker-host entry by absolute path. Workers deliberately spawn
112
+ * without a pinned cwd there: they share the parent's foreground process
113
+ * group, and terminal cwd heuristics (kitty's new_tab_with_cwd) read the
114
+ * newest process in that group, so anchoring them to the install dir leaks
115
+ * into newly opened terminal tabs. With no declared host entry (bun test, SDK
116
+ * embedding) fall back to a cwd-relative `src/cli.ts`, which Bun subprocess
117
+ * IPC handles more reliably under `bun test`.
115
118
  */
116
119
  export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
117
120
  const executable = stripWindowsExtendedLengthPathPrefix(process.execPath);
118
121
  if (isCompiledBinary()) return { cmd: [executable, workerArg] };
119
122
  const hostEntry = workerHostEntry();
120
123
  if (hostEntry) {
121
- return { cmd: [executable, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
124
+ return { cmd: [executable, hostEntry, workerArg] };
122
125
  }
123
126
  const packageRoot = path.resolve(import.meta.dir, "..", "..");
124
127
  return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
@@ -35,6 +35,7 @@ import type { HindsightSessionState } from "../hindsight/state";
35
35
  import type { LocalProtocolOptions } from "../internal-urls";
36
36
  import type { MCPManager } from "../mcp/manager";
37
37
  import type { MnemopiSessionState } from "../mnemopi/state";
38
+ import { initializeExtensions } from "../modes/runtime-init";
38
39
  import subagentAsyncPendingTemplate from "../prompts/system/subagent-async-pending.md" with { type: "text" };
39
40
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
40
41
  import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
@@ -3167,6 +3168,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3167
3168
  const { session: revived } = await createAgentSession(
3168
3169
  buildSubagentSessionOptions(reopened, expectedAgentRef),
3169
3170
  );
3171
+ // Re-run the executor's extension wiring on the rebuilt session.
3172
+ // Skipping it leaves the runner pre-init, so a `tool_call` handler
3173
+ // touching a runtime action trips the fail-closed gate and blocks
3174
+ // every tool (including `yield`) in the revived agent (issue #8824).
3175
+ await initializeExtensions(revived, {
3176
+ reportSendError: (action, err) =>
3177
+ logger.error("Extension send failed", { action, error: err.message }),
3178
+ reportRuntimeError: err =>
3179
+ logger.error("Extension error", { path: err.extensionPath, error: err.error }),
3180
+ });
3170
3181
  AgentRegistry.global().syncSessionStatus(id, revived);
3171
3182
  installIrcWakeTurnMonitor(revived);
3172
3183
  return revived;
package/src/task/index.ts CHANGED
@@ -1424,6 +1424,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1424
1424
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
1425
1425
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
1426
1426
  ...(params.effort !== undefined ? { effort: params.effort } : {}),
1427
+ // `name` is the spawn handle: keep it for id allocation when this
1428
+ // path did not pre-reserve one. Do not treat it as a HUD description.
1427
1429
  identity: { id: preAllocatedId, label: params.name },
1428
1430
  index: spawnIndex,
1429
1431
  parentToolCallId: toolCallId,
package/src/task/label.ts CHANGED
@@ -9,6 +9,17 @@ import { generateSessionTitle } from "../utils/title-generator";
9
9
 
10
10
  const TASK_LABEL_SYSTEM_PROMPT = prompt.render(taskLabelSystemPrompt);
11
11
 
12
+ /** True when a generated label is just the spawn handle, including `Name-2`. */
13
+ export function labelEchoesHandle(handle: string | undefined, label: string): boolean {
14
+ if (!handle) return false;
15
+ if (label.localeCompare(handle, undefined, { sensitivity: "accent" }) === 0) return true;
16
+ const separator = handle.lastIndexOf("-");
17
+ if (separator <= 0) return false;
18
+ const prefix = handle.slice(0, separator);
19
+ const suffix = handle.slice(separator + 1);
20
+ return /^\d+$/.test(suffix) && prefix.localeCompare(label, undefined, { sensitivity: "accent" }) === 0;
21
+ }
22
+
12
23
  /** Compresses a delegated assignment into a one-sentence UI label via the tiny title model — fired by the executor spawn path because the task wire schema no longer carries a `description`; null on empty input or failure. */
13
24
  export async function generateTaskLabel(
14
25
  assignment: string,
@@ -20,7 +31,7 @@ export async function generateTaskLabel(
20
31
  const text = assignment.trim();
21
32
  if (!text) return null;
22
33
  try {
23
- return await generateSessionTitle(
34
+ const label = await generateSessionTitle(
24
35
  text,
25
36
  registry,
26
37
  settings,
@@ -30,6 +41,8 @@ export async function generateTaskLabel(
30
41
  TASK_LABEL_SYSTEM_PROMPT,
31
42
  signal,
32
43
  );
44
+ if (!label || labelEchoesHandle(sessionId, label)) return null;
45
+ return label;
33
46
  } catch (err) {
34
47
  logger.debug("task-label: generation failed", {
35
48
  sessionId,
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import { logger } from "@oh-my-pi/pi-utils";
2
3
  import type { ModelRegistry } from "../config/model-registry";
3
4
  import { formatModelRoleAlias } from "../config/model-roles";
4
5
  import type { Settings } from "../config/settings";
5
6
  import { MCPManager } from "../mcp/manager";
7
+ import { initializeExtensions } from "../modes/runtime-init";
6
8
  import type { PersistedSubagentReviverFactory } from "../registry/agent-lifecycle";
7
9
  import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
8
10
  import { createAgentSession } from "../sdk";
@@ -153,6 +155,17 @@ export function createPersistedSubagentReviverFactory(
153
155
  // `alwaysInclude` can re-add non-defaultInactive extension/custom tools
154
156
  // the original run didn't carry. Unknown/missing names are ignored.
155
157
  await session.setActiveToolsByName([...init.tools, ...session.getMountedXdevToolNames()]);
158
+ // Wire the extension runtime exactly as the live executor does. Without
159
+ // this the runner stays pre-init, every action method throws
160
+ // `ExtensionRuntimeNotInitializedError`, and a `tool_call` handler that
161
+ // touches a runtime action trips the fail-closed gate in `emitToolCall`,
162
+ // blocking every tool — including the hidden `yield` — in the revived
163
+ // agent. `session_start` also re-runs so extensions restore per-session
164
+ // state (issue #8824).
165
+ await initializeExtensions(session, {
166
+ reportSendError: (action, err) => logger.error("Extension send failed", { action, error: err.message }),
167
+ reportRuntimeError: err => logger.error("Extension error", { path: err.extensionPath, error: err.error }),
168
+ });
156
169
  // Cold revives must drive registry status themselves — createAgentSession
157
170
  // doesn't wire this generically (the live path does it in the executor).
158
171
  // The internal run-state signal precedes deferrable public `agent_end`,
@@ -710,7 +710,7 @@ function formatAgentHeaderLabel(args: Partial<TaskParams> | undefined): string |
710
710
  }
711
711
 
712
712
  /** Dim `⟨agent⟩` badge for a non-default agent type; empty for the generic worker. */
713
- function agentTypeBadge(agent: string | undefined, theme: Theme): string {
713
+ export function agentTypeBadge(agent: string | undefined, theme: Theme): string {
714
714
  const trimmed = agent?.trim();
715
715
  if (!trimmed || trimmed === "task") return "";
716
716
  return ` ${theme.fg("dim", `${theme.format.bracketLeft}${trimmed}${theme.format.bracketRight}`)}`;
@@ -245,6 +245,7 @@ function assertDepthAndSpawnAllowed(request: StructuredSubagentRequest, agentNam
245
245
  export async function resolveEffectiveSubagentPolicy(
246
246
  request: StructuredSubagentRequest,
247
247
  ): Promise<EffectiveSubagentPolicy> {
248
+ await request.session.settings.reloadFromDisk();
248
249
  const spawnPolicy = resolveSpawnPolicy(request.session.getSessionSpawns());
249
250
  const agentName = request.agent?.trim() || spawnPolicy.defaultAgent;
250
251
  const planMode = request.session.getPlanModeState?.()?.enabled === true;
@@ -393,7 +394,9 @@ function buildExecutorOptions(
393
394
  assignment: request.assignment.trim(),
394
395
  context: request.context?.trim() || undefined,
395
396
  planReference: undefined,
396
- description: trimToUndefined(request.identity?.label),
397
+ // Task `name` is the spawn handle (id allocation). Eval `label` is a
398
+ // real UI description. Copy it only for eval so generateTaskLabel can run.
399
+ description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined,
397
400
  index: request.index ?? 0,
398
401
  parentToolCallId: request.parentToolCallId,
399
402
  detached: request.detached,
@@ -476,7 +479,7 @@ function buildFailureResult(
476
479
  agentSource: policy.agent.source,
477
480
  task: renderSubagentPrompt(request.assignment),
478
481
  assignment: request.assignment.trim(),
479
- description: trimToUndefined(request.identity?.label),
482
+ description: request.invocationKind === "eval" ? trimToUndefined(request.identity?.label) : undefined,
480
483
  exitCode: 1,
481
484
  output: "",
482
485
  stderr: message,
@@ -0,0 +1,16 @@
1
+ import type { TextGenerationPipeline } from "@huggingface/transformers";
2
+
3
+ export function buildCompletionPrompt(
4
+ tokenizer: TextGenerationPipeline["tokenizer"],
5
+ promptText: string,
6
+ systemPrompt?: string,
7
+ ): string {
8
+ const userMessage = { role: "user", content: promptText };
9
+ const chat = systemPrompt?.trim() ? [{ role: "system", content: systemPrompt.trim() }, userMessage] : [userMessage];
10
+ const chatTemplateOptions = {
11
+ add_generation_prompt: true,
12
+ tokenize: false,
13
+ enable_thinking: false,
14
+ };
15
+ return `${tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
16
+ }
@@ -52,6 +52,12 @@ export interface TinyTitleGenerateOptions {
52
52
  systemPrompt?: string;
53
53
  }
54
54
 
55
+ export interface TinyModelCompletionOptions {
56
+ maxTokens?: number;
57
+ signal?: AbortSignal;
58
+ systemPrompt?: string;
59
+ }
60
+
55
61
  function normalizeTinyTitleGenerateOptions(
56
62
  options: AbortSignal | TinyTitleGenerateOptions | undefined,
57
63
  ): TinyTitleGenerateOptions {
@@ -260,11 +266,7 @@ export class TinyTitleClient {
260
266
  }
261
267
  }
262
268
 
263
- async complete(
264
- modelKey: string,
265
- prompt: string,
266
- options: { maxTokens?: number; signal?: AbortSignal } = {},
267
- ): Promise<string | null> {
269
+ async complete(modelKey: string, prompt: string, options: TinyModelCompletionOptions = {}): Promise<string | null> {
268
270
  if (!isTinyMemoryLocalModelKey(modelKey)) return null;
269
271
  if (options.signal?.aborted || this.#failedModels.has(modelKey)) return null;
270
272
 
@@ -281,7 +283,14 @@ export class TinyTitleClient {
281
283
  };
282
284
  options.signal?.addEventListener("abort", abort, { once: true });
283
285
  try {
284
- worker.send({ type: "complete", id, modelKey, prompt, maxTokens: options.maxTokens });
286
+ worker.send({
287
+ type: "complete",
288
+ id,
289
+ modelKey,
290
+ prompt,
291
+ maxTokens: options.maxTokens,
292
+ systemPrompt: options.systemPrompt,
293
+ });
285
294
  return await promise;
286
295
  } finally {
287
296
  options.signal?.removeEventListener("abort", abort);
@@ -30,7 +30,14 @@ export interface TinyTitleProgressEvent {
30
30
  export type TinyTitleWorkerInbound =
31
31
  | { type: "ping"; id: string }
32
32
  | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string; systemPrompt?: string }
33
- | { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
33
+ | {
34
+ type: "complete";
35
+ id: string;
36
+ modelKey: TinyLocalModelKey;
37
+ prompt: string;
38
+ maxTokens?: number;
39
+ systemPrompt?: string;
40
+ }
34
41
  | { type: "download"; id: string; modelKey: TinyLocalModelKey };
35
42
 
36
43
  export type TinyTitleWorkerOutbound =
@@ -19,6 +19,7 @@ import {
19
19
  sendProgress,
20
20
  type TransformersRuntimeMetadata,
21
21
  } from "../subprocess/worker-runtime";
22
+ import { buildCompletionPrompt } from "./completion-prompt";
22
23
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
23
24
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
24
25
  import { formatTitleUserMessage } from "./message-preproc";
@@ -42,7 +43,7 @@ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
42
43
  const tinyModelDevicePreference = resolveTinyModelDevicePreference();
43
44
  const tinyModelDtypeOverride = resolveTinyModelDtypeOverride();
44
45
 
45
- interface TransformersRuntime extends TransformersRuntimeMetadata {
46
+ export interface TransformersRuntime extends TransformersRuntimeMetadata {
46
47
  env: {
47
48
  cacheDir?: string;
48
49
  allowLocalModels?: boolean;
@@ -79,7 +80,13 @@ function getTinyTitleRuntimeDir(): string {
79
80
  );
80
81
  }
81
82
 
82
- function createStopOnTextCriteria(
83
+ /** Stops generation at the first occurrence of `text` in the *generated* tokens.
84
+ *
85
+ * The window must be anchored to the generation boundary, not to the end of the
86
+ * whole sequence: a prompt that itself contains the stop string (chat-level
87
+ * few-shot examples ending in `</title>`, for instance) would otherwise match on
88
+ * prompt tokens and stop before the model emits anything. */
89
+ export function createStopOnTextCriteria(
83
90
  transformers: TransformersRuntime,
84
91
  tokenizer: TextGenerationPipeline["tokenizer"],
85
92
  text: string,
@@ -87,6 +94,8 @@ function createStopOnTextCriteria(
87
94
  class StopOnTextCriteria extends transformers.StoppingCriteria {
88
95
  #tokenizer: TextGenerationPipeline["tokenizer"];
89
96
  #text: string;
97
+ /** First generated index per batch entry, captured on the first call. */
98
+ #generatedStarts: number[] = [];
90
99
 
91
100
  constructor() {
92
101
  super();
@@ -95,8 +104,10 @@ function createStopOnTextCriteria(
95
104
  }
96
105
 
97
106
  override _call(inputIds: number[][]): boolean[] {
98
- return inputIds.map(ids => {
99
- const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS);
107
+ return inputIds.map((ids, index) => {
108
+ const generatedStart = this.#generatedStarts[index] ?? Math.max(0, ids.length - 1);
109
+ this.#generatedStarts[index] = generatedStart;
110
+ const tail = ids.slice(Math.max(generatedStart, ids.length - STOP_DECODE_WINDOW_TOKENS));
100
111
  const decoded = this.#tokenizer.decode(tail, {
101
112
  skip_special_tokens: false,
102
113
  clean_up_tokenization_spaces: false,
@@ -265,21 +276,10 @@ async function generateTitle(
265
276
  return extractTinyTitle(output[0]?.generated_text ?? "", message);
266
277
  }
267
278
 
268
- function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string {
269
- const chat = [{ role: "user", content: promptText }];
270
- const chatTemplateOptions = {
271
- add_generation_prompt: true,
272
- tokenize: false,
273
- enable_thinking: false,
274
- };
275
- return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
276
- }
277
-
278
279
  /**
279
- * Generic single-turn completion used by Mnemopi memory tasks (fact extraction
280
- * and consolidation). The caller (Mnemopi) supplies the full task prompt; we
281
- * wrap it as the user turn, decode greedily, and return the raw text for the
282
- * caller's own parser. Output is capped to keep local inference latency bounded.
280
+ * Completion path for Mnemopi memory tasks. Extraction can carry a dedicated
281
+ * system prompt and user payload; consolidation retains the generic user-only
282
+ * prompt. Output is capped to keep local inference latency bounded.
283
283
  */
284
284
  async function generateCompletion(
285
285
  transport: TinyTitleTransport,
@@ -287,9 +287,10 @@ async function generateCompletion(
287
287
  modelKey: TinyLocalModelKey,
288
288
  promptText: string,
289
289
  maxTokens: number | undefined,
290
+ systemPrompt: string | undefined,
290
291
  ): Promise<string | null> {
291
292
  const generator = await loadPipeline(modelKey, transport, requestId);
292
- const text = buildCompletionPrompt(generator, promptText);
293
+ const text = buildCompletionPrompt(generator.tokenizer, promptText, systemPrompt);
293
294
  const requested = maxTokens ?? MEMORY_COMPLETION_DEFAULT_MAX_NEW_TOKENS;
294
295
  const maxNewTokens = Math.min(Math.max(1, requested), COMPLETION_MAX_NEW_TOKENS);
295
296
  const output = (await generator(text, {
@@ -332,6 +333,7 @@ async function handleQueuedRequest(
332
333
  request.modelKey,
333
334
  request.prompt,
334
335
  request.maxTokens,
336
+ request.systemPrompt,
335
337
  );
336
338
  transport.send({ type: "completion", id: request.id, text });
337
339
  return;