@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.0

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 (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -11
  3. package/src/autoresearch/git.ts +25 -30
  4. package/src/autoresearch/tools/log-experiment.ts +61 -74
  5. package/src/commit/agentic/agent.ts +0 -3
  6. package/src/commit/agentic/index.ts +19 -22
  7. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  8. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  9. package/src/commit/agentic/tools/git-overview.ts +6 -9
  10. package/src/commit/agentic/tools/index.ts +6 -8
  11. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  12. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  13. package/src/commit/agentic/tools/split-commit.ts +4 -4
  14. package/src/commit/changelog/index.ts +5 -9
  15. package/src/commit/pipeline.ts +10 -12
  16. package/src/config/keybindings.ts +7 -6
  17. package/src/config/settings-schema.ts +44 -0
  18. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  19. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  20. package/src/extensibility/custom-tools/types.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -1
  22. package/src/extensibility/hooks/types.ts +1 -1
  23. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  24. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  25. package/src/index.ts +1 -0
  26. package/src/main.ts +24 -2
  27. package/src/modes/components/footer.ts +9 -29
  28. package/src/modes/components/hook-editor.ts +3 -3
  29. package/src/modes/components/hook-selector.ts +6 -1
  30. package/src/modes/components/session-observer-overlay.ts +472 -0
  31. package/src/modes/components/settings-defs.ts +19 -0
  32. package/src/modes/components/status-line.ts +15 -61
  33. package/src/modes/controllers/command-controller.ts +1 -0
  34. package/src/modes/controllers/event-controller.ts +59 -2
  35. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  36. package/src/modes/controllers/input-controller.ts +3 -0
  37. package/src/modes/controllers/selector-controller.ts +26 -0
  38. package/src/modes/interactive-mode.ts +195 -43
  39. package/src/modes/session-observer-registry.ts +146 -0
  40. package/src/modes/shared.ts +0 -42
  41. package/src/modes/types.ts +2 -0
  42. package/src/modes/utils/keybinding-matchers.ts +9 -0
  43. package/src/prompts/system/custom-system-prompt.md +5 -0
  44. package/src/prompts/system/system-prompt.md +6 -0
  45. package/src/sdk.ts +28 -13
  46. package/src/secrets/index.ts +1 -1
  47. package/src/secrets/obfuscator.ts +24 -16
  48. package/src/session/agent-session.ts +75 -30
  49. package/src/session/session-manager.ts +15 -5
  50. package/src/system-prompt.ts +4 -0
  51. package/src/task/executor.ts +28 -0
  52. package/src/task/index.ts +88 -78
  53. package/src/task/types.ts +25 -0
  54. package/src/task/worktree.ts +127 -145
  55. package/src/tools/exit-plan-mode.ts +1 -0
  56. package/src/tools/gh.ts +120 -297
  57. package/src/tools/read.ts +13 -79
  58. package/src/utils/external-editor.ts +11 -5
  59. package/src/utils/git.ts +1400 -0
  60. package/src/web/search/render.ts +6 -4
  61. package/src/commit/git/errors.ts +0 -9
  62. package/src/commit/git/index.ts +0 -210
  63. package/src/commit/git/operations.ts +0 -54
  64. package/src/tools/gh-cli.ts +0 -125
@@ -59,6 +59,7 @@ import {
59
59
  extractExplicitThinkingSelector,
60
60
  formatModelString,
61
61
  parseModelString,
62
+ type ResolvedModelRoleValue,
62
63
  resolveModelRoleValue,
63
64
  } from "../config/model-resolver";
64
65
  import { expandPromptTemplate, type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates";
@@ -114,7 +115,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
114
115
  type: "text",
115
116
  };
116
117
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
117
- import type { SecretObfuscator } from "../secrets/obfuscator";
118
+ import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
118
119
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
119
120
  import type { CheckpointState } from "../tools/checkpoint";
120
121
  import { outputMeta } from "../tools/output-meta";
@@ -162,7 +163,7 @@ import { getLatestCompactionEntry } from "./session-manager";
162
163
  /** Session-specific events that extend the core AgentEvent */
163
164
  export type AgentSessionEvent =
164
165
  | AgentEvent
165
- | { type: "auto_compaction_start"; reason: "threshold" | "overflow"; action: "context-full" | "handoff" }
166
+ | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
166
167
  | {
167
168
  type: "auto_compaction_end";
168
169
  action: "context-full" | "handoff";
@@ -539,7 +540,7 @@ export class AgentSession {
539
540
  this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
540
541
  this.#defaultSelectedMCPToolNames = new Set(config.defaultSelectedMCPToolNames ?? []);
541
542
  this.#pruneSelectedMCPToolNames();
542
- const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
543
+ const persistedSelectedMCPToolNames = this.buildDisplaySessionContext().selectedMCPToolNames;
543
544
  const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
544
545
  const persistInitialMCPToolSelection =
545
546
  config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
@@ -668,7 +669,22 @@ export class AgentSession {
668
669
  }
669
670
  }
670
671
 
671
- await this.#emitSessionEvent(event);
672
+ // Deobfuscate assistant message content for display emission — the LLM echoes back
673
+ // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
674
+ // values. The original event.message stays obfuscated so the persistence path below
675
+ // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
676
+ // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
677
+ let displayEvent: AgentEvent = event;
678
+ const obfuscator = this.#obfuscator;
679
+ if (obfuscator && event.type === "message_end" && event.message.role === "assistant") {
680
+ const message = event.message;
681
+ const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
682
+ if (deobfuscatedContent !== message.content) {
683
+ displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
684
+ }
685
+ }
686
+
687
+ await this.#emitSessionEvent(displayEvent);
672
688
 
673
689
  if (event.type === "turn_start") {
674
690
  this.#resetStreamingEditState();
@@ -1968,7 +1984,7 @@ export class AgentSession {
1968
1984
 
1969
1985
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1970
1986
  this.#pruneSelectedMCPToolNames();
1971
- if (!this.sessionManager.buildSessionContext().hasPersistedMCPToolSelection) {
1987
+ if (!this.buildDisplaySessionContext().hasPersistedMCPToolSelection) {
1972
1988
  this.#selectedMCPToolNames = new Set([
1973
1989
  ...this.#selectedMCPToolNames,
1974
1990
  ...this.#getConfiguredDefaultSelectedMCPToolNames(),
@@ -1993,6 +2009,10 @@ export class AgentSession {
1993
2009
  return this.agent.state.messages;
1994
2010
  }
1995
2011
 
2012
+ buildDisplaySessionContext(): SessionContext {
2013
+ return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
2014
+ }
2015
+
1996
2016
  /** Convert session messages using the same pre-LLM pipeline as the active session. */
1997
2017
  async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
1998
2018
  const transformedMessages = await this.#transformContext(messages, signal);
@@ -2103,7 +2123,16 @@ export class AgentSession {
2103
2123
  }
2104
2124
 
2105
2125
  resolveRoleModel(role: string): Model | undefined {
2106
- return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
2126
+ return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
2127
+ }
2128
+
2129
+ /**
2130
+ * Resolve a role to its model AND thinking level.
2131
+ * Unlike resolveRoleModel(), this preserves the thinking level suffix
2132
+ * from role configuration (e.g., "anthropic/claude-sonnet-4-5:xhigh").
2133
+ */
2134
+ resolveRoleModelWithThinking(role: string): ResolvedModelRoleValue {
2135
+ return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model);
2107
2136
  }
2108
2137
 
2109
2138
  get promptTemplates(): ReadonlyArray<PromptTemplate> {
@@ -3182,7 +3211,7 @@ export class AgentSession {
3182
3211
  * Validates API key, saves to session log but NOT to settings.
3183
3212
  * @throws Error if no API key available for the model
3184
3213
  */
3185
- async setModelTemporary(model: Model): Promise<void> {
3214
+ async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
3186
3215
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3187
3216
  if (!apiKey) {
3188
3217
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -3193,8 +3222,8 @@ export class AgentSession {
3193
3222
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
3194
3223
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3195
3224
 
3196
- // Re-apply the current thinking level for the newly selected model
3197
- this.setThinkingLevel(this.thinkingLevel);
3225
+ // Apply explicit thinking level, or re-clamp current level to new model's capabilities
3226
+ this.setThinkingLevel(thinkingLevel ?? this.thinkingLevel);
3198
3227
  }
3199
3228
 
3200
3229
  /**
@@ -3267,13 +3296,12 @@ export class AgentSession {
3267
3296
  const next = roleModels[nextIndex];
3268
3297
 
3269
3298
  if (options?.temporary) {
3270
- await this.setModelTemporary(next.model);
3299
+ await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined);
3271
3300
  } else {
3272
3301
  await this.setModel(next.model, next.role);
3273
- }
3274
-
3275
- if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) {
3276
- this.setThinkingLevel(next.thinkingLevel);
3302
+ if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) {
3303
+ this.setThinkingLevel(next.thinkingLevel);
3304
+ }
3277
3305
  }
3278
3306
 
3279
3307
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
@@ -3473,7 +3501,7 @@ export class AgentSession {
3473
3501
  }
3474
3502
 
3475
3503
  await this.sessionManager.rewriteEntries();
3476
- const sessionContext = this.sessionManager.buildSessionContext();
3504
+ const sessionContext = this.buildDisplaySessionContext();
3477
3505
  this.agent.replaceMessages(sessionContext.messages);
3478
3506
  this.#syncTodoPhasesFromBranch();
3479
3507
  this.#closeCodexProviderSessionsForHistoryRewrite();
@@ -3599,7 +3627,7 @@ export class AgentSession {
3599
3627
  preserveData,
3600
3628
  );
3601
3629
  const newEntries = this.sessionManager.getEntries();
3602
- const sessionContext = this.sessionManager.buildSessionContext();
3630
+ const sessionContext = this.buildDisplaySessionContext();
3603
3631
  this.agent.replaceMessages(sessionContext.messages);
3604
3632
  this.#syncTodoPhasesFromBranch();
3605
3633
  this.#closeCodexProviderSessionsForHistoryRewrite();
@@ -3646,6 +3674,12 @@ export class AgentSession {
3646
3674
  this.#handoffAbortController?.abort();
3647
3675
  }
3648
3676
 
3677
+ /** Trigger idle compaction through the auto-compaction flow (with UI events). */
3678
+ async runIdleCompaction(): Promise<void> {
3679
+ if (this.isStreaming || this.isCompacting) return;
3680
+ await this.#runAutoCompaction("idle", false, true);
3681
+ }
3682
+
3649
3683
  /**
3650
3684
  * Cancel in-progress branch summarization.
3651
3685
  */
@@ -3814,7 +3848,7 @@ export class AgentSession {
3814
3848
  }
3815
3849
 
3816
3850
  // Rebuild agent messages from session
3817
- const sessionContext = this.sessionManager.buildSessionContext();
3851
+ const sessionContext = this.buildDisplaySessionContext();
3818
3852
  this.agent.replaceMessages(sessionContext.messages);
3819
3853
  this.#syncTodoPhasesFromBranch();
3820
3854
 
@@ -4364,19 +4398,25 @@ export class AgentSession {
4364
4398
  return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
4365
4399
  }
4366
4400
 
4367
- #resolveRoleModel(role: string, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
4401
+ #resolveRoleModelFull(
4402
+ role: string,
4403
+ availableModels: Model[],
4404
+ currentModel: Model | undefined,
4405
+ ): ResolvedModelRoleValue {
4368
4406
  const roleModelStr =
4369
4407
  role === "default"
4370
4408
  ? (this.settings.getModelRole("default") ??
4371
4409
  (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
4372
4410
  : this.settings.getModelRole(role);
4373
4411
 
4374
- if (!roleModelStr) return undefined;
4412
+ if (!roleModelStr) {
4413
+ return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
4414
+ }
4375
4415
 
4376
4416
  return resolveModelRoleValue(roleModelStr, availableModels, {
4377
4417
  settings: this.settings,
4378
4418
  matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
4379
- }).model;
4419
+ });
4380
4420
  }
4381
4421
 
4382
4422
  #getCompactionModelCandidates(availableModels: Model[]): Model[] {
@@ -4393,7 +4433,7 @@ export class AgentSession {
4393
4433
 
4394
4434
  const currentModel = this.model;
4395
4435
  for (const role of MODEL_ROLE_IDS) {
4396
- addCandidate(this.#resolveRoleModel(role, availableModels, currentModel));
4436
+ addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
4397
4437
  }
4398
4438
 
4399
4439
  const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
@@ -4410,11 +4450,16 @@ export class AgentSession {
4410
4450
  /**
4411
4451
  * Internal: Run auto-compaction with events.
4412
4452
  */
4413
- async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean, deferred = false): Promise<void> {
4453
+ async #runAutoCompaction(
4454
+ reason: "overflow" | "threshold" | "idle",
4455
+ willRetry: boolean,
4456
+ deferred = false,
4457
+ ): Promise<void> {
4414
4458
  const compactionSettings = this.settings.getGroup("compaction");
4415
- if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
4459
+ if (compactionSettings.strategy === "off") return;
4460
+ if (reason !== "idle" && !compactionSettings.enabled) return;
4416
4461
  const generation = this.#promptGeneration;
4417
- if (!deferred && reason !== "overflow" && compactionSettings.strategy === "handoff") {
4462
+ if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
4418
4463
  this.#schedulePostPromptTask(
4419
4464
  async signal => {
4420
4465
  await Promise.resolve();
@@ -4689,7 +4734,7 @@ export class AgentSession {
4689
4734
  preserveData,
4690
4735
  );
4691
4736
  const newEntries = this.sessionManager.getEntries();
4692
- const sessionContext = this.sessionManager.buildSessionContext();
4737
+ const sessionContext = this.buildDisplaySessionContext();
4693
4738
  this.agent.replaceMessages(sessionContext.messages);
4694
4739
  this.#syncTodoPhasesFromBranch();
4695
4740
  this.#closeCodexProviderSessionsForHistoryRewrite();
@@ -4717,7 +4762,7 @@ export class AgentSession {
4717
4762
  };
4718
4763
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
4719
4764
 
4720
- if (!willRetry && compactionSettings.autoContinue !== false) {
4765
+ if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
4721
4766
  const continuePrompt = async () => {
4722
4767
  await this.#promptWithMessage(
4723
4768
  {
@@ -5516,7 +5561,7 @@ export class AgentSession {
5516
5561
  // Flush pending writes before switching so restore snapshots reflect committed state.
5517
5562
  await this.sessionManager.flush();
5518
5563
  const previousSessionState = this.sessionManager.captureState();
5519
- const previousSessionContext = this.sessionManager.buildSessionContext();
5564
+ const previousSessionContext = this.buildDisplaySessionContext();
5520
5565
  // switchSession replaces these arrays wholesale during load/rollback, so retaining
5521
5566
  // the existing message objects is sufficient and avoids structured-clone failures for
5522
5567
  // extension/custom metadata that is valid to persist but not cloneable.
@@ -5545,7 +5590,7 @@ export class AgentSession {
5545
5590
  await this.sessionManager.setSessionFile(sessionPath);
5546
5591
  this.agent.sessionId = this.sessionManager.getSessionId();
5547
5592
 
5548
- const sessionContext = this.sessionManager.buildSessionContext();
5593
+ const sessionContext = this.buildDisplaySessionContext();
5549
5594
  const didReloadConversationChange =
5550
5595
  !switchingToDifferentSession &&
5551
5596
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
@@ -5711,7 +5756,7 @@ export class AgentSession {
5711
5756
  this.agent.sessionId = this.sessionManager.getSessionId();
5712
5757
 
5713
5758
  // Reload messages from entries (works for both file and in-memory mode)
5714
- const sessionContext = this.sessionManager.buildSessionContext();
5759
+ const sessionContext = this.buildDisplaySessionContext();
5715
5760
 
5716
5761
  await this.#restoreMCPSelectionsForSessionContext(sessionContext);
5717
5762
 
@@ -5882,7 +5927,7 @@ export class AgentSession {
5882
5927
  }
5883
5928
 
5884
5929
  // Update agent state
5885
- const sessionContext = this.sessionManager.buildSessionContext();
5930
+ const sessionContext = this.buildDisplaySessionContext();
5886
5931
  await this.#restoreMCPSelectionsForSessionContext(sessionContext);
5887
5932
  this.agent.replaceMessages(sessionContext.messages);
5888
5933
  this.#syncTodoPhasesFromBranch();
@@ -1390,6 +1390,7 @@ export class SessionManager {
1390
1390
  #sessionFile: string | undefined;
1391
1391
  #flushed: boolean = false;
1392
1392
  #needsFullRewriteOnNextPersist: boolean = false;
1393
+ #ensuredOnDisk: boolean = false;
1393
1394
  #fileEntries: FileEntry[] = [];
1394
1395
  #byId: Map<string, SessionEntry> = new Map();
1395
1396
  #labelsById: Map<string, string> = new Map();
@@ -1496,12 +1497,14 @@ export class SessionManager {
1496
1497
 
1497
1498
  this.#buildIndex();
1498
1499
  this.#flushed = true;
1500
+ this.#ensuredOnDisk = true;
1499
1501
  } else {
1500
1502
  const explicitPath = this.#sessionFile;
1501
1503
  this.#newSessionSync();
1502
1504
  this.#sessionFile = explicitPath; // preserve explicit path from --session flag
1503
1505
  await this.#rewriteFile();
1504
1506
  this.#flushed = true;
1507
+ this.#ensuredOnDisk = true;
1505
1508
  return;
1506
1509
  }
1507
1510
  }
@@ -1678,6 +1681,7 @@ export class SessionManager {
1678
1681
  this.#leafId = null;
1679
1682
  this.#flushed = false;
1680
1683
  this.#needsFullRewriteOnNextPersist = false;
1684
+ this.#ensuredOnDisk = false;
1681
1685
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
1682
1686
  this.#inMemoryArtifacts = null;
1683
1687
  this.#inMemoryArtifactCounter = 0;
@@ -1839,6 +1843,7 @@ export class SessionManager {
1839
1843
  if (!this.persist || !this.#sessionFile) return;
1840
1844
  if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
1841
1845
  await this.#rewriteFile();
1846
+ this.#ensuredOnDisk = true;
1842
1847
  }
1843
1848
 
1844
1849
  /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
@@ -1972,11 +1977,16 @@ export class SessionManager {
1972
1977
  if (!this.persist || !this.#sessionFile) return;
1973
1978
  if (this.#persistError) throw this.#persistError;
1974
1979
 
1975
- const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
1976
- if (!hasAssistant) {
1977
- // Mark as not flushed so when assistant arrives, all entries get written.
1978
- this.#flushed = false;
1979
- return;
1980
+ // Normally we wait for the first assistant message before persisting to avoid
1981
+ // creating files for sessions that never produce output. Once ensureOnDisk() has
1982
+ // been called, the session is already on disk and every entry must be flushed.
1983
+ if (!this.#ensuredOnDisk) {
1984
+ const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
1985
+ if (!hasAssistant) {
1986
+ // Mark as not flushed so when assistant arrives, all entries get written.
1987
+ this.#flushed = false;
1988
+ return;
1989
+ }
1980
1990
  }
1981
1991
 
1982
1992
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
@@ -432,6 +432,8 @@ export interface BuildSystemPromptOptions {
432
432
  eagerTasks?: boolean;
433
433
  /** Rules with alwaysApply=true — their full content is injected into the prompt. */
434
434
  alwaysApplyRules?: AlwaysApplyRule[];
435
+ /** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */
436
+ secretsEnabled?: boolean;
435
437
  }
436
438
 
437
439
  /** Build the system prompt with tools, guidelines, and context */
@@ -456,6 +458,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
456
458
  mcpDiscoveryMode = false,
457
459
  mcpDiscoveryServerSummaries = [],
458
460
  eagerTasks = false,
461
+ secretsEnabled = false,
459
462
  } = options;
460
463
  const resolvedCwd = cwd ?? getProjectDir();
461
464
 
@@ -603,6 +606,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
603
606
  hasMCPDiscoveryServers: mcpDiscoveryServerSummaries.length > 0,
604
607
  mcpDiscoveryServerSummaries,
605
608
  eagerTasks,
609
+ secretsEnabled,
606
610
  };
607
611
  return renderPromptTemplate(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
608
612
  }
@@ -38,6 +38,7 @@ import {
38
38
  type ReviewFinding,
39
39
  type SingleResult,
40
40
  TASK_SUBAGENT_EVENT_CHANNEL,
41
+ TASK_SUBAGENT_LIFECYCLE_CHANNEL,
41
42
  TASK_SUBAGENT_PROGRESS_CHANNEL,
42
43
  } from "./types";
43
44
 
@@ -630,6 +631,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
630
631
  task,
631
632
  assignment,
632
633
  progress: { ...progress },
634
+ sessionFile: subtaskSessionFile,
633
635
  });
634
636
  }
635
637
  lastProgressEmitMs = Date.now();
@@ -983,6 +985,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
983
985
 
984
986
  activeSession = session;
985
987
 
988
+ // Emit lifecycle start event
989
+ if (options.eventBus) {
990
+ options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
991
+ id,
992
+ agent: agent.name,
993
+ agentSource: agent.source,
994
+ description: options.description,
995
+ status: "started",
996
+ sessionFile: subtaskSessionFile,
997
+ index,
998
+ });
999
+ }
1000
+
986
1001
  const subagentToolNames = session.getActiveToolNames();
987
1002
  const parentOwnedToolNames = new Set(["todo_write"]);
988
1003
  const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
@@ -1238,6 +1253,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1238
1253
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1239
1254
  scheduleProgress(true);
1240
1255
 
1256
+ // Emit lifecycle end event after finalization so submit_result status is reflected
1257
+ if (options.eventBus) {
1258
+ options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1259
+ id,
1260
+ agent: agent.name,
1261
+ agentSource: agent.source,
1262
+ description: options.description,
1263
+ status: progress.status as "completed" | "failed" | "aborted",
1264
+ sessionFile: subtaskSessionFile,
1265
+ index,
1266
+ });
1267
+ }
1268
+
1241
1269
  return {
1242
1270
  index,
1243
1271
  id,
package/src/task/index.ts CHANGED
@@ -18,7 +18,6 @@ import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { $env, Snowflake } from "@oh-my-pi/pi-utils";
21
- import { $ } from "bun";
22
21
  import type { ToolSession } from "..";
23
22
  import { resolveAgentModelPatterns } from "../config/model-resolver";
24
23
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -30,6 +29,7 @@ import { formatBytes, formatDuration } from "../tools/render-utils";
30
29
  // Import review tools for side effects (registers subagent tool handlers)
31
30
  import "../tools/review";
32
31
  import { generateCommitMessage } from "../utils/commit-message-generator";
32
+ import * as git from "../utils/git";
33
33
  import { discoverAgents, getAgent } from "./discovery";
34
34
  import { runSubprocess } from "./executor";
35
35
  import { resolveIsolationBackendForTaskExecution } from "./isolation-backend";
@@ -109,8 +109,21 @@ export { loadBundledAgents as BUNDLED_AGENTS } from "./agents";
109
109
  export { discoverCommands, expandCommand, getCommand } from "./commands";
110
110
  export { discoverAgents, getAgent } from "./discovery";
111
111
  export { AgentOutputManager } from "./output-manager";
112
- export type { AgentDefinition, AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types";
113
- export { taskSchema } from "./types";
112
+ export type {
113
+ AgentDefinition,
114
+ AgentProgress,
115
+ SingleResult,
116
+ SubagentLifecyclePayload,
117
+ SubagentProgressPayload,
118
+ TaskParams,
119
+ TaskToolDetails,
120
+ } from "./types";
121
+ export {
122
+ TASK_SUBAGENT_EVENT_CHANNEL,
123
+ TASK_SUBAGENT_LIFECYCLE_CHANNEL,
124
+ TASK_SUBAGENT_PROGRESS_CHANNEL,
125
+ taskSchema,
126
+ } from "./types";
114
127
 
115
128
  /**
116
129
  * Render the tool description from a cached agent list and current settings.
@@ -766,7 +779,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
766
779
  contextFile: contextFilePath,
767
780
  enableLsp: false,
768
781
  signal,
769
- eventBus: undefined,
782
+ eventBus: this.session.eventBus,
770
783
  onProgress: progress => {
771
784
  progressMap.set(index, {
772
785
  ...structuredClone(progress),
@@ -820,7 +833,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
820
833
  contextFile: contextFilePath,
821
834
  enableLsp: false,
822
835
  signal,
823
- eventBus: undefined,
836
+ eventBus: this.session.eventBus,
824
837
  onProgress: progress => {
825
838
  progressMap.set(index, {
826
839
  ...structuredClone(progress),
@@ -864,7 +877,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
864
877
  } catch (mergeErr) {
865
878
  // Agent succeeded but branch commit failed — clean up stale branch
866
879
  const branchName = `omp/task/${task.id}`;
867
- await $`git branch -D ${branchName}`.cwd(repoRoot).quiet().nothrow();
880
+ await git.branch.tryDelete(repoRoot, branchName);
868
881
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
869
882
  return { ...result, error: `Merge failed: ${msg}` };
870
883
  }
@@ -978,93 +991,90 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
978
991
  let changesApplied: boolean | null = null;
979
992
  let mergedBranchesForNestedPatches: Set<string> | null = null;
980
993
  if (isIsolated && repoRoot) {
981
- if (mergeMode === "branch") {
982
- // Branch mode: merge task branches sequentially
983
- const branchEntries = results
984
- .filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
985
- .map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
986
-
987
- if (branchEntries.length === 0) {
988
- changesApplied = true;
989
- } else {
990
- const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
991
- mergedBranchesForNestedPatches = new Set(mergeResult.merged);
992
- changesApplied = mergeResult.failed.length === 0;
994
+ try {
995
+ if (mergeMode === "branch") {
996
+ // Branch mode: merge task branches sequentially
997
+ const branchEntries = results
998
+ .filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
999
+ .map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
993
1000
 
994
- if (changesApplied) {
995
- mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
1001
+ if (branchEntries.length === 0) {
1002
+ changesApplied = true;
996
1003
  } else {
997
- const mergedPart =
998
- mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
999
- const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
1000
- const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1001
- mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
1004
+ const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
1005
+ mergedBranchesForNestedPatches = new Set(mergeResult.merged);
1006
+ changesApplied = mergeResult.failed.length === 0;
1007
+
1008
+ if (changesApplied) {
1009
+ mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
1010
+ } else {
1011
+ const mergedPart =
1012
+ mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
1013
+ const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
1014
+ const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1015
+ mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
1016
+ }
1002
1017
  }
1003
- }
1004
1018
 
1005
- // Clean up merged branches (keep failed ones for manual resolution)
1006
- const allBranches = branchEntries.map(b => b.branchName);
1007
- if (changesApplied) {
1008
- await cleanupTaskBranches(repoRoot, allBranches);
1009
- }
1010
- } else {
1011
- // Patch mode: combine and apply patches
1012
- const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
1013
- const missingPatch = results.some(result => !result.patchPath);
1014
- if (missingPatch) {
1015
- changesApplied = false;
1019
+ // Clean up merged branches (keep failed ones for manual resolution)
1020
+ const allBranches = branchEntries.map(b => b.branchName);
1021
+ if (changesApplied) {
1022
+ await cleanupTaskBranches(repoRoot, allBranches);
1023
+ }
1016
1024
  } else {
1017
- const patchStats = await Promise.all(
1018
- patchesInOrder.map(async patchPath => ({
1019
- patchPath,
1020
- size: (await fs.stat(patchPath)).size,
1021
- })),
1022
- );
1023
- const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1024
- if (nonEmptyPatches.length === 0) {
1025
- changesApplied = true;
1025
+ // Patch mode: combine and apply patches
1026
+ const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
1027
+ const missingPatch = results.some(result => !result.patchPath);
1028
+ if (missingPatch) {
1029
+ changesApplied = false;
1026
1030
  } else {
1027
- const patchTexts = await Promise.all(
1028
- nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1031
+ const patchStats = await Promise.all(
1032
+ patchesInOrder.map(async patchPath => ({
1033
+ patchPath,
1034
+ size: (await fs.stat(patchPath)).size,
1035
+ })),
1029
1036
  );
1030
- const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
1031
- if (!combinedPatch.trim()) {
1037
+ const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1038
+ if (nonEmptyPatches.length === 0) {
1032
1039
  changesApplied = true;
1033
1040
  } else {
1034
- const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
1035
- try {
1036
- await Bun.write(combinedPatchPath, combinedPatch);
1037
- const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
1038
- .cwd(repoRoot)
1039
- .quiet()
1040
- .nothrow();
1041
- if (checkResult.exitCode !== 0) {
1042
- changesApplied = false;
1043
- } else {
1044
- const applyResult = await $`git apply --binary ${combinedPatchPath}`
1045
- .cwd(repoRoot)
1046
- .quiet()
1047
- .nothrow();
1048
- changesApplied = applyResult.exitCode === 0;
1041
+ const patchTexts = await Promise.all(
1042
+ nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1043
+ );
1044
+ const combinedPatch = patchTexts
1045
+ .map(text => (text.endsWith("\n") ? text : `${text}\n`))
1046
+ .join("");
1047
+ if (!combinedPatch.trim()) {
1048
+ changesApplied = true;
1049
+ } else {
1050
+ changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
1051
+ if (changesApplied) {
1052
+ try {
1053
+ await git.patch.applyText(repoRoot, combinedPatch);
1054
+ } catch {
1055
+ changesApplied = false;
1056
+ }
1049
1057
  }
1050
- } finally {
1051
- await fs.rm(combinedPatchPath, { force: true });
1052
1058
  }
1053
1059
  }
1054
1060
  }
1055
- }
1056
1061
 
1057
- if (changesApplied) {
1058
- mergeSummary = "\n\nApplied patches: yes";
1059
- } else {
1060
- const notification =
1061
- "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
1062
- const patchList =
1063
- patchPaths.length > 0
1064
- ? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
1065
- : "";
1066
- mergeSummary = `\n\n${notification}${patchList}`;
1062
+ if (changesApplied) {
1063
+ mergeSummary = "\n\nApplied patches: yes";
1064
+ } else {
1065
+ const notification =
1066
+ "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
1067
+ const patchList =
1068
+ patchPaths.length > 0
1069
+ ? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
1070
+ : "";
1071
+ mergeSummary = `\n\n${notification}${patchList}`;
1072
+ }
1067
1073
  }
1074
+ } catch (mergeErr) {
1075
+ const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1076
+ changesApplied = false;
1077
+ mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
1068
1078
  }
1069
1079
  }
1070
1080