pi-invisible-continue 0.3.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/continue.ts +117 -33
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -78,6 +78,12 @@ Once loaded, use `/continue`:
78
78
 
79
79
  ## Installation
80
80
 
81
+ ```bash
82
+ pi install npm:pi-invisible-continue
83
+ ```
84
+
85
+ Or install from GitHub:
86
+
81
87
  ```bash
82
88
  pi install https://github.com/monotykamary/pi-invisible-continue
83
89
  ```
package/continue.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Agent } from "@earendil-works/pi-agent-core";
2
3
  import {
3
4
  CONTINUE_COMMAND_DESCRIPTION,
4
5
  getLastAssistantMessageText,
@@ -8,45 +9,125 @@ import {
8
9
  * pi-invisible-continue — resume the agentic loop without the LLM seeing any new prompt.
9
10
  *
10
11
  * Strategy:
11
- * - Use pi.sendMessage() with triggerTurn: true to start a new agent turn
12
- * - The message uses role: "custom" which is filtered by convertToLlm(),
13
- * so the LLM receives the exact same message list it had before
14
- * - This goes through AgentSession._runAgentPrompt, so auto-compaction,
15
- * auto-retry, and other session lifecycle features work correctly
16
- * - No prototype monkey-patching needed — no fragile Agent import, no
17
- * module duplication issues
12
+ * - Monkey-patch Agent.prototype.subscribe to capture the Agent instance
13
+ * - /continue calls agent.prompt([]) directly, starting a fresh agent loop
14
+ * with an empty prompt no message is injected into context at all
15
+ * - The LLM receives the exact same message list it had before
16
+ * - No session JSONL artifact, no convertToLlm involvement, no filter needed
18
17
  *
19
- * Why not agent.prompt([])?
20
- * The previous approach monkey-patched Agent.prototype.subscribe to capture
21
- * the Agent instance, then called agent.prompt([]) directly. This broke when
22
- * the extension's import of Agent resolved to a different class (from the
23
- * package's own node_modules/@earendil-works/pi-agent-core@0.75.4) than the
24
- * one AgentSession uses (from pi's bundled 0.79.1). The subscribe patch was
25
- * applied to the wrong prototype, so the agent was never captured.
18
+ * This bypasses AgentSession._runAgentPrompt, so auto-compaction is not
19
+ * triggered after a manual /continue. Auto-retry is not a concern — pi-retry
20
+ * covers that gap via its agent_end handler, which still fires because the
21
+ * agent's processEvents propagates to AgentSession's subscriber normally.
26
22
  *
27
- * The new approach avoids this entirely by using the extension API's
28
- * sendMessage() with triggerTurn: true, which is resolved at runtime by the
29
- * AgentSession and doesn't depend on importing Agent at all.
23
+ * Module resolution:
24
+ * The Agent class is imported from @earendil-works/pi-agent-core.
25
+ * This package does NOT list pi-agent-core as a devDependency — there is
26
+ * no local node_modules copy. When jiti loads this extension, its alias
27
+ * system rewrites the import specifier to pi's bundled copy, so the
28
+ * subscribe/continue patches apply to the SAME Agent class that
29
+ * AgentSession uses. Previously, a local node_modules/@earendil-works/pi-agent-core
30
+ * caused jiti to resolve the import to a different class — patching the
31
+ * wrong prototype and leaving _agent permanently null.
30
32
  */
31
33
 
34
+ // Capture the live Agent instance when AgentSession subscribes to it.
35
+ // subscribe() is called during AgentSession construction — fires on both
36
+ // fresh sessions and session resumes, unlike prompt().
37
+ //
38
+ // Chain the previous patch (if pi-retry or pi-vcc already patched it)
39
+ // so all extensions can coexist.
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ let _agent: Agent | null = null;
42
+ const _origSubscribe = Agent.prototype.subscribe as (this: Agent, ...args: any[]) => any;
43
+ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
44
+ _agent = this;
45
+ return _origSubscribe.apply(this, args);
46
+ };
47
+
32
48
  // Mutex: only one invisible continue may be in-flight at a time.
49
+ // Without this, concurrent /continue (or /continue during pi-retry/pi-vcc
50
+ // auto-continuation) race through waitForIdle() and both call prompt([]),
51
+ // producing "Agent is already processing".
33
52
  let _continueInProgress = false;
34
53
 
54
+ // Timestamp of the last completed invisible continue.
55
+ // Used to avoid double continuation when continue() unblocks after
56
+ // triggerInvisibleContinue just ran.
57
+ let _lastInvisibleContinueTime = 0;
58
+
59
+ // Monkey-patch continue() so the session's built-in loop cooperates with
60
+ // our mutex AND can convert the "Cannot continue from assistant" error
61
+ // into a prompt([]) call when the agent was mid-task.
62
+ // Without this, the session's continue() would throw when the last message
63
+ // is an assistant (common after compaction), and the agent loop would die
64
+ // leaving mid-task work unfinished.
65
+ //
66
+ // When continue() throws "Cannot continue from message role: assistant":
67
+ // - stopReason "stop" → agent finished cleanly, don't continue
68
+ // - stopReason "aborted" → user cancelled, don't continue
69
+ // - stopReason "error" → pi-retry handles errors, don't race it
70
+ // - stopReason "toolUse" or "length" → mid-task, fall back to prompt([])
71
+ //
72
+ // Chains the previous patch (pi-retry, pi-vcc) so all mutexes are respected.
73
+ const _origContinue = Agent.prototype.continue;
74
+ Agent.prototype.continue = function (this: Agent) {
75
+ const self = this;
76
+ return (async (): Promise<void> => {
77
+ while (_continueInProgress) {
78
+ await new Promise(r => setTimeout(r, 10));
79
+ }
80
+ try {
81
+ await _origContinue.call(self);
82
+ } catch (e: any) {
83
+ const msg = e?.message ?? '';
84
+ if (msg.includes('Cannot continue from message role') ||
85
+ msg.includes('Cannot continue from an assistant message')) {
86
+ // Check stopReason — only continue if the agent was mid-task
87
+ const lastMsg = self.state.messages[self.state.messages.length - 1];
88
+ if (lastMsg?.role === 'assistant' &&
89
+ lastMsg.stopReason !== 'stop' &&
90
+ lastMsg.stopReason !== 'aborted' &&
91
+ lastMsg.stopReason !== 'error') {
92
+ // Agent was mid-task — fall back to prompt([])
93
+ // Guard: if an invisible continue just completed, don't double-run
94
+ if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
95
+ _continueInProgress = true;
96
+ try {
97
+ await self.prompt([]);
98
+ } catch {
99
+ // Agent already processing or other transient error
100
+ } finally {
101
+ _continueInProgress = false;
102
+ }
103
+ }
104
+ }
105
+ // For stop/aborted/error: return void, the session loop exits naturally
106
+ return;
107
+ }
108
+ if (msg.includes('Agent is already processing')) {
109
+ return;
110
+ }
111
+ throw e;
112
+ }
113
+ })();
114
+ };
115
+
35
116
  export default function (pi: ExtensionAPI) {
36
117
  pi.registerCommand("continue", {
37
118
  description: CONTINUE_COMMAND_DESCRIPTION,
38
119
  handler: async (args, ctx) => {
39
- await runContinueCommand(pi, ctx, args);
120
+ await runContinueCommand(ctx, args);
40
121
  },
41
122
  });
42
123
 
43
124
  pi.on("session_start", () => {
44
125
  _continueInProgress = false;
126
+ _lastInvisibleContinueTime = 0;
45
127
  });
46
128
  }
47
129
 
48
130
  async function runContinueCommand(
49
- pi: ExtensionAPI,
50
131
  ctx: ExtensionCommandContext,
51
132
  args: string,
52
133
  ): Promise<void> {
@@ -57,6 +138,7 @@ async function runContinueCommand(
57
138
  [
58
139
  "pi-invisible-continue status:",
59
140
  ` Agent idle: ${idle ? "yes" : "no"}`,
141
+ ` Captured agent: ${_agent ? "yes" : "no"}`,
60
142
  ` Last assistant: ${last ?? "(none)"}`.slice(0, 120),
61
143
  ].join("\n"),
62
144
  "info",
@@ -76,12 +158,20 @@ async function runContinueCommand(
76
158
  return;
77
159
  }
78
160
 
161
+ if (!_agent) {
162
+ ctx.ui.notify(
163
+ "pi-invisible-continue: Agent instance not captured. Internal error?",
164
+ "warning",
165
+ );
166
+ return;
167
+ }
168
+
79
169
  if (!ctx.isIdle()) {
80
170
  await ctx.waitForIdle();
81
171
  }
82
172
 
83
173
  // Guard: if pi-retry or pi-vcc already has an invisible continue in-flight,
84
- // skip — their triggerTurn will resume the loop.
174
+ // skip — their prompt([]) will resume the loop.
85
175
  if (_continueInProgress) {
86
176
  ctx.ui.notify(
87
177
  "pi-invisible-continue: Another invisible continue is already in progress.",
@@ -92,20 +182,14 @@ async function runContinueCommand(
92
182
  _continueInProgress = true;
93
183
 
94
184
  try {
95
- // Send an invisible custom message that triggers a new agent turn.
96
- // convertToLlm() filters role:"custom" messages, so the LLM sees
97
- // the same context as before — no injected prompt.
98
- pi.sendMessage(
99
- { customType: "pi-invisible-continue", content: "", display: false, details: undefined },
100
- { triggerTurn: true },
101
- );
102
-
103
- // Wait for the triggered turn (and any post-run continuations like
104
- // auto-compaction and auto-retry) to complete.
105
- await ctx.waitForIdle();
106
- } catch {
107
- // Agent is already processing — something else is driving.
185
+ await _agent.waitForIdle();
186
+ try {
187
+ await _agent.prompt([]);
188
+ } catch {
189
+ // Agent is already processing something else is driving.
190
+ }
108
191
  } finally {
109
192
  _continueInProgress = false;
193
+ _lastInvisibleContinueTime = Date.now();
110
194
  }
111
195
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-invisible-continue",
3
- "version": "0.3.0",
4
- "description": "Invisible session continuation for pi resume the agentic loop without sending ANY prompt the LLM can see",
3
+ "version": "0.3.2",
4
+ "description": "Invisible session continuation for pi \u2014 resume the agentic loop without sending ANY prompt the LLM can see",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
7
7
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "lint:dead": "knip --no-gitignore"
39
39
  },
40
40
  "devDependencies": {
41
- "@earendil-works/pi-coding-agent": "0.75.4",
41
+ "@earendil-works/pi-agent-core": "^0.79.1",
42
+ "@earendil-works/pi-coding-agent": "0.79.4",
42
43
  "@types/node": "25.9.1",
43
44
  "@vitest/coverage-v8": "4.1.7",
44
45
  "knip": "6.14.1",