pi-optchat 0.9.1 → 0.9.2

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.
@@ -18,7 +18,7 @@ If you open the same profile in a second terminal, Pi offers to connect it to th
18
18
 
19
19
  - Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
20
20
  - Closing or force-quitting the window also produces a handoff, marked **interrupted**.
21
- - If the original window is closed cleanly, handoffs are delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
21
+ - If the original window is closed cleanly or reloaded, it doesn't wait for the compactor: handoffs, including one still being written after `/complete`, are written and delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
22
22
  - Closing the original window, or running `/reload` in it, ends every connected conversation; Pi gives no chance to warn before a reload. The connected window then shows **disconnected**. `/new` there starts over: after the profile picker if Pi was started without `--optchat-profile`, a profile still open elsewhere offers **Start a connected subagent conversation here** again, and a free one opens in this window.
23
23
 
24
24
  ### Handoff limits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -49,7 +49,7 @@ interface Options { parentSession?: string; usage?: UsageLedger; createSession?:
49
49
  hold?: (batch: string, texts: string[]) => void;
50
50
  /** Read on every spawn, so a changed profile setting applies to the next one. Missing settings take their defaults. */
51
51
  settings?: () => Partial<Settings>;
52
- summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
52
+ summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[], signal?: AbortSignal) => Promise<string> }
53
53
 
54
54
  // Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
55
55
  const packageName = (path: string): string | undefined => {
@@ -133,6 +133,8 @@ export class Children {
133
133
  private settling = 0;
134
134
  private readonly launches = new Set<Promise<unknown>>();
135
135
  private readonly completions = new Set<Promise<void>>();
136
+ /** Aborted when Pi starts closing: see deferHandoffs(). */
137
+ private readonly deferring = new AbortController();
136
138
  constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
137
139
  private readonly choice: () => ModelChoice, private readonly instructions: () => string,
138
140
  private readonly report: (text: string, options?: { once?: boolean; count?: number }) => Promise<void>, private readonly warn: (text: string) => void,
@@ -681,6 +683,8 @@ export class Children {
681
683
  const handoff = run.handoff;
682
684
  if (!handoff || handoff.delivered) return;
683
685
  if (!handoff.text) {
686
+ const deferred = this.deferring.signal;
687
+ if (deferred.aborted) return;
684
688
  const evidence = [run, ...this.history.descendants(run.id)].map((record): HandoffEvidence => {
685
689
  try { return { run: record, messages: this.messages(record.id) }; }
686
690
  catch (error) { return { run: record, messages: [], transcriptError: String(error) }; }
@@ -688,8 +692,9 @@ export class Children {
688
692
  let summary: string;
689
693
  try {
690
694
  if (!this.options.summarizeHandoff) throw new Error('No handoff summarizer configured');
691
- summary = await this.options.summarizeHandoff(run, evidence[0].messages, evidence.slice(1));
695
+ summary = await this.options.summarizeHandoff(run, evidence[0].messages, evidence.slice(1), deferred);
692
696
  } catch (error) {
697
+ if (deferred.aborted) return;
693
698
  summary = `Automatic summary unavailable: ${String(error)}\nInitial request: ${run.task}\nLast recorded result: ${run.report ?? 'No final answer recorded.'}\nUndelivered guidance: ${run.guidance.filter(g => g.state === 'undelivered').map(g => g.text).join('\n')}\nRead the saved transcript or run metadata for the full work and user corrections.`;
694
699
  }
695
700
  const source = evidence.map(({ run: record, transcriptError }) => {
@@ -747,8 +752,11 @@ export class Children {
747
752
  if (!reports) this.memory.append('work', `[${id}] deleted by the user`);
748
753
  } finally { for (const run of marked) this.deleting.delete(run); this.changed(); }
749
754
  }
755
+ /** Pi is closing: a handoff not written yet is left undelivered for the next start's recoverHandoffs(), and one being
756
+ * written is aborted, so closing or /reload never waits on a model call. */
757
+ deferHandoffs() { this.deferring.abort(); }
750
758
  async close() {
751
- this.closing = true;
759
+ this.closing = true; this.deferHandoffs();
752
760
  // Only work in progress is picked up at the next start: not paused runs waiting for the user, runs being stopped, or connected windows (they hand off).
753
761
  for (const { info } of this.running.values()) if (!info.connected && (info.state === 'running' || info.state === 'waiting')) info.cutOff = true;
754
762
  await Promise.allSettled([...this.running.keys()].map(id => this.stop(id, 'pi')));
package/src/handoff.ts CHANGED
@@ -31,7 +31,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
31
31
  /** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
32
32
  export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
33
33
  usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
34
- return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
34
+ return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = [], signal?: AbortSignal) => {
35
35
  const selected = choice(), model = registry.find(selected.provider, selected.model);
36
36
  if (!model) throw new Error('Profile compactor model unavailable');
37
37
  const transcript = Buffer.from([{ run, messages }, ...descendants].map(formatEvidence).join('\n\n'));
@@ -53,7 +53,7 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
53
53
  const reply = await retryTransient(() => registry.streamSimple(model, {
54
54
  systemPrompt: HANDOFF,
55
55
  messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
56
- }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
56
+ }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.any([AbortSignal.timeout(300_000), ...signal ? [signal] : []]) }).result(), usage, retryDelays, signal);
57
57
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
58
58
  if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
59
59
  summary = textContent(reply.content).trim();
package/src/index.ts CHANGED
@@ -198,6 +198,8 @@ export default function optchat(pi: ExtensionAPI) {
198
198
  if (!active) return;
199
199
  const old = active;
200
200
  try {
201
+ // Ending connected windows and recovery must not wait on handoff summaries: the next start writes and delivers them.
202
+ old.children.deferHandoffs();
201
203
  await closeWindows?.(); closeWindows = undefined;
202
204
  await recovery;
203
205
  await old.children.close(); flush(); if (!pendingImport(old.dir)) old.inbox.recover(old.memory);