pi-invisible-continue 0.3.0 → 0.3.1
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.
- package/continue.ts +117 -33
- package/package.json +2 -1
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
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-invisible-continue",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Invisible session continuation for pi — resume the agentic loop without sending ANY prompt the LLM can see",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"lint:dead": "knip --no-gitignore"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
+
"@earendil-works/pi-agent-core": "^0.79.1",
|
|
41
42
|
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
42
43
|
"@types/node": "25.9.1",
|
|
43
44
|
"@vitest/coverage-v8": "4.1.7",
|