pi-invisible-continue 0.3.3 → 0.3.5

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 (4) hide show
  1. package/README.md +48 -59
  2. package/continue.ts +31 -158
  3. package/package.json +11 -12
  4. package/src/index.ts +18 -17
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Invisible session continuation for [pi](https://github.com/earendil-works/pi-coding-agent)**
6
6
 
7
- _Resume the agentic loop without the LLM seeing any new prompt at all._
7
+ _Resume the agentic loop without the LLM seeing a new prompt._
8
8
 
9
9
  [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
10
  [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
@@ -15,66 +15,66 @@ _Resume the agentic loop without the LLM seeing any new prompt at all._
15
15
 
16
16
  ## The Problem
17
17
 
18
- Every existing "continue" extension sends a **visible user message** to the LLM:
18
+ Most continue extensions send user text to the LLM:
19
19
 
20
20
  | Package | What the LLM sees |
21
21
  |---------|-------------------|
22
- | `pi-continue` | `"Continue from the same-session pi-continue/v3 handoff Pi just saved..."` (full handoff doc) |
23
- | `pi-hodor` | `"continue"` (literal text) |
24
- | `pi-auto-continue` | `"continue"` (literal text) |
25
- | `pi-retry` | `"Continue"` on max-tokens, or hidden custom trigger on errors |
22
+ | `pi-continue` | A full handoff document |
23
+ | `pi-hodor` | `"continue"` |
24
+ | `pi-auto-continue` | `"continue"` |
26
25
 
27
- Every one of those **changes the LLM's context** with new user text. That text influences the next response, sometimes in unintended ways — model changes course, re-reads things it already processed, or treats a bare `"continue"` as a new task.
28
-
29
- ---
26
+ That text can influence the next response or be interpreted as a new task.
30
27
 
31
28
  ## The Solution
32
29
 
33
- `pi-invisible-continue` captures the internal `Agent` instance via a prototype monkey-patch on `Agent.prototype.prompt`. When `/continue` is invoked, it calls `agent.prompt([])` directly starting a fresh agent loop with an **empty prompt array**. No message is injected into the context at all:
30
+ `pi-invisible-continue` starts a normal Pi session turn with a hidden custom marker. A `context` hook removes that marker before provider serialization, so the LLM receives no new prompt text.
34
31
 
35
- - The agent loop restarts
36
- - The LLM receives **the exact same message list it had before**
37
- - No new text, no handoff, no pollution, no session artifact
38
- - Nothing in `convertToLlm`'s path — no filtering needed
32
+ The continuation deliberately goes through `AgentSession`:
39
33
 
40
- ---
34
+ - Pi’s session-level busy state remains accurate.
35
+ - A concurrent prompt is queued through the native follow-up path rather than racing it.
36
+ - Auto-retry, compaction, abort, and `agent_settled` behavior remain intact.
37
+ - The marker is hidden from the TUI and filtered from every LLM request.
41
38
 
42
39
  ## How It Works
43
40
 
44
- ```
45
- Extension loads
46
- → Monkey-patches Agent.prototype.prompt to capture the Agent instance
47
- → First real prompt stores the reference
48
-
49
- User types "/continue"
50
- agent.prompt([]) called directly
51
- runAgentLoop([], contextSnapshot, ...)
52
- → prompts array is empty — no message emitted, no message pushed to context
53
- → runLoop → streamAssistantResponse → convertToLlm(unmodified messages)
54
- LLM sees same messages as before → responds naturally
41
+ `/continue` calls:
42
+
43
+ ```typescript
44
+ pi.sendMessage(
45
+ {
46
+ customType: "pi-invisible-continue:resume",
47
+ content: [],
48
+ display: false,
49
+ },
50
+ {
51
+ triggerTurn: true,
52
+ deliverAs: "followUp",
53
+ },
54
+ );
55
55
  ```
56
56
 
57
- ### Why `agent.prompt([])` and not `agent.continue()`?
57
+ Pi starts the turn immediately when idle or queues it when another turn is active. Before the provider request, the extension removes its custom marker from `event.messages`.
58
58
 
59
- `agent.continue()` has a guard that throws `Cannot continue from message role: assistant` when the last message is from the assistant — which it always is when the agent stops. `agent.prompt([])` starts a fresh loop from the current context snapshot without that restriction.
59
+ ### Why not `Agent.prompt([])`?
60
60
 
61
- ### Trade-off: bypasses AgentSession
61
+ Calling the low-level agent directly bypasses `AgentSession._runAgentPrompt()`. Pi can then report the session as idle while `Agent.activeRun` is already processing. A user prompt may be routed as a fresh prompt and fail with:
62
62
 
63
- `agent.prompt([])` is called on the `Agent` directly, bypassing `AgentSession._runAgentPrompt()`. This means auto-compaction is not triggered after a `/continue`. Auto-retry is not a concern — [pi-retry](https://github.com/monotykamary/pi-retry) covers that gap by listening for `agent_end` events and re-triggering the loop on errors. Since the agent still emits events through `AgentSession`'s subscriber, pi-retry's handlers fire normally even after an `agent.prompt([])` continuation.
63
+ > Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.
64
64
 
65
- ---
65
+ Using `sendMessage` keeps one authoritative lifecycle and avoids that split-brain busy state.
66
66
 
67
- ## Usage
67
+ ### Session artifact
68
+
69
+ The session journal contains one hidden custom entry per continuation. It has no display content and is removed before every provider call, but it is still bookkeeping in the saved session. This is the correctness trade-off until Pi exposes a public session-level resume API that adds no entry.
68
70
 
69
- Once loaded, use `/continue`:
71
+ ## Usage
70
72
 
71
73
  | Command | What it does |
72
74
  |---------|-------------|
73
- | `/continue` | Resume the loop invisibly. Waits for idle, then fires. |
74
- | `/continue status` | Show agent idle state, captured-agent status, and last assistant text (debug). |
75
- | `/continue help` | Show this reference. |
76
-
77
- ---
75
+ | `/continue` | Start or queue an invisible continuation. |
76
+ | `/continue status` | Show session idle state and the last assistant text. |
77
+ | `/continue help` | Show the command reference. |
78
78
 
79
79
  ## Installation
80
80
 
@@ -88,7 +88,7 @@ Or install from GitHub:
88
88
  pi install https://github.com/monotykamary/pi-invisible-continue
89
89
  ```
90
90
 
91
- Or in `~/.pi/agent/settings.json`:
91
+ Or add it to `~/.pi/agent/settings.json`:
92
92
 
93
93
  ```json
94
94
  {
@@ -98,36 +98,25 @@ Or in `~/.pi/agent/settings.json`:
98
98
  }
99
99
  ```
100
100
 
101
- Then `/reload` or restart pi.
101
+ Then run `/reload` or restart Pi.
102
102
 
103
- For quick one-off tests:
103
+ For a one-off test:
104
104
 
105
105
  ```bash
106
106
  pi -e ./continue.ts
107
107
  ```
108
108
 
109
- ---
110
-
111
- ## Comparison with Other Packages
109
+ ## Comparison
112
110
 
113
111
  | Feature | pi-invisible-continue | pi-continue | pi-hodor | pi-auto-continue |
114
112
  |---------|----------------------|-------------|----------|-------------------|
115
- | LLM sees new user text | No | Handoff doc | "continue" | "continue" |
116
- | Session pollution | None | Full compaction entry + user message | 1 user message | 1 user message |
117
- | Mechanism | `agent.prompt([])` | `sendMessage` + handoff | `sendMessage` | `sendMessage` |
118
- | Auto-triggered | Manual only | On compaction | On error/length | On agent_end |
119
- | Retry integration | | | Error patterns | |
120
- | Complexity | Prototype patch + 1 call | 49 files, multi-stage | 2 files, config-driven | 1 file, loop-based |
121
-
122
- ---
123
-
124
- ## About the Hack
125
-
126
- The extension uses the public `@earendil-works/pi-agent-core` package (which pi's extension loader resolves to the same module instance used internally) to import `Agent` and monkey-patch `Agent.prototype.prompt`. This captures the live `Agent` instance when pi first calls `agent.prompt()` during normal operation.
127
-
128
- Then `/continue` calls `agent.prompt([])` — an empty prompt array. The `runAgentLoop` function spreads the prompts into the context messages, so with an empty array, nothing is added. The loop starts from the unmodified context snapshot and the LLM continues naturally.
113
+ | LLM sees new user text | No | Handoff doc | `"continue"` | `"continue"` |
114
+ | Visible transcript entry | No | Yes | Yes | Yes |
115
+ | Hidden session bookkeeping | One custom entry | Compaction + user entry | User entry | User entry |
116
+ | Native session lifecycle | Yes | Yes | Yes | Yes |
117
+ | Auto-triggered | No | On compaction | On error/length | On `agent_end` |
129
118
 
130
- This is the approach discussed in [pi issue #3721](https://github.com/earendil-works/pi/issues/3721) ("Feature request: Resume agentic loop without sending a message"). The upstream fix would be exposing `agent.continue()` (or a variant that works from `assistant` last-message) on `AgentSession`, but this extension achieves the same effect without waiting for a core change.
119
+ The desired upstream primitive is a public `AgentSession.resume()` operation that preserves native lifecycle behavior without adding a marker. Until then, a filtered custom message is safer than bypassing the session.
131
120
 
132
121
  ## License
133
122
 
package/continue.ts CHANGED
@@ -1,148 +1,43 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { Agent } from "@earendil-works/pi-agent-core";
3
2
  import {
4
3
  CONTINUE_COMMAND_DESCRIPTION,
4
+ INVISIBLE_CONTINUE_CUSTOM_TYPE,
5
5
  getLastAssistantMessageText,
6
+ isInvisibleContinueMarker,
6
7
  } from "./src/index.js";
7
8
 
8
9
  /**
9
- * pi-invisible-continue resume the agentic loop without the LLM seeing any new prompt.
10
- *
11
- * Strategy:
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
17
- *
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.
22
- *
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.
10
+ * Resume through AgentSession instead of calling Agent.prompt([]) directly.
11
+ * The hidden custom message starts or queues a canonical session turn, while
12
+ * the context hook removes it before provider serialization.
32
13
  */
14
+ export default function invisibleContinueExtension(pi: ExtensionAPI) {
15
+ pi.on("context", (event) => {
16
+ const messages = event.messages.filter((message) => !isInvisibleContinueMarker(message));
17
+ if (messages.length !== event.messages.length) return { messages };
18
+ });
33
19
 
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
-
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".
52
- let _continueInProgress = false;
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
- // Note (pi 0.79+): Agent.continue() now drains queued steering/follow-up
67
- // messages before throwing, so this throw path only fires when there are
68
- // genuinely no queued messages — the prompt([]) fallback is still correct.
69
- //
70
- // When continue() throws "Cannot continue from message role: assistant":
71
- // - stopReason "stop" → agent finished cleanly, don't continue
72
- // - stopReason "aborted" → user cancelled, don't continue
73
- // - stopReason "error" → pi-retry handles errors, don't race it
74
- // - stopReason "toolUse" or "length" → mid-task, fall back to prompt([])
75
- //
76
- // Chains the previous patch (pi-retry, pi-vcc) so all mutexes are respected.
77
- const _origContinue = Agent.prototype.continue;
78
- Agent.prototype.continue = function (this: Agent) {
79
- const self = this;
80
- return (async (): Promise<void> => {
81
- while (_continueInProgress) {
82
- await new Promise(r => setTimeout(r, 10));
83
- }
84
- try {
85
- await _origContinue.call(self);
86
- } catch (e: any) {
87
- const msg = e?.message ?? '';
88
- if (msg.includes('Cannot continue from message role') ||
89
- msg.includes('Cannot continue from an assistant message')) {
90
- // Check stopReason — only continue if the agent was mid-task
91
- const lastMsg = self.state.messages[self.state.messages.length - 1];
92
- if (lastMsg?.role === 'assistant' &&
93
- lastMsg.stopReason !== 'stop' &&
94
- lastMsg.stopReason !== 'aborted' &&
95
- lastMsg.stopReason !== 'error') {
96
- // Agent was mid-task — fall back to prompt([])
97
- // Guard: if an invisible continue just completed, don't double-run
98
- if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
99
- _continueInProgress = true;
100
- try {
101
- await self.prompt([]);
102
- } catch {
103
- // Agent already processing or other transient error
104
- } finally {
105
- _continueInProgress = false;
106
- }
107
- }
108
- }
109
- // For stop/aborted/error: return void, the session loop exits naturally
110
- return;
111
- }
112
- if (msg.includes('Agent is already processing')) {
113
- return;
114
- }
115
- throw e;
116
- }
117
- })();
118
- };
119
-
120
- export default function (pi: ExtensionAPI) {
121
20
  pi.registerCommand("continue", {
122
21
  description: CONTINUE_COMMAND_DESCRIPTION,
123
22
  handler: async (args, ctx) => {
124
- await runContinueCommand(ctx, args);
23
+ runContinueCommand(pi, ctx, args);
125
24
  },
126
25
  });
127
-
128
- pi.on("session_start", () => {
129
- _continueInProgress = false;
130
- _lastInvisibleContinueTime = 0;
131
- });
132
26
  }
133
27
 
134
- async function runContinueCommand(
28
+ function runContinueCommand(
29
+ pi: ExtensionAPI,
135
30
  ctx: ExtensionCommandContext,
136
31
  args: string,
137
- ): Promise<void> {
138
- if (args.trim().toLowerCase() === "status") {
32
+ ): void {
33
+ const subcommand = args.trim().toLowerCase();
34
+
35
+ if (subcommand === "status") {
139
36
  const last = getLastAssistantMessageText(ctx.sessionManager.getEntries());
140
- const idle = ctx.isIdle();
141
37
  ctx.ui.notify(
142
38
  [
143
39
  "pi-invisible-continue status:",
144
- ` Agent idle: ${idle ? "yes" : "no"}`,
145
- ` Captured agent: ${_agent ? "yes" : "no"}`,
40
+ ` Agent idle: ${ctx.isIdle() ? "yes" : "no"}`,
146
41
  ` Last assistant: ${last ?? "(none)"}`.slice(0, 120),
147
42
  ].join("\n"),
148
43
  "info",
@@ -150,7 +45,7 @@ async function runContinueCommand(
150
45
  return;
151
46
  }
152
47
 
153
- if (args.trim().toLowerCase() === "help") {
48
+ if (subcommand === "help") {
154
49
  ctx.ui.notify(
155
50
  [
156
51
  "pi-invisible-continue /continue Resume loop invisibly",
@@ -162,38 +57,16 @@ async function runContinueCommand(
162
57
  return;
163
58
  }
164
59
 
165
- if (!_agent) {
166
- ctx.ui.notify(
167
- "pi-invisible-continue: Agent instance not captured. Internal error?",
168
- "warning",
169
- );
170
- return;
171
- }
172
-
173
- if (!ctx.isIdle()) {
174
- await ctx.waitForIdle();
175
- }
176
-
177
- // Guard: if pi-retry or pi-vcc already has an invisible continue in-flight,
178
- // skip — their prompt([]) will resume the loop.
179
- if (_continueInProgress) {
180
- ctx.ui.notify(
181
- "pi-invisible-continue: Another invisible continue is already in progress.",
182
- "info",
183
- );
184
- return;
185
- }
186
- _continueInProgress = true;
187
-
188
- try {
189
- await _agent.waitForIdle();
190
- try {
191
- await _agent.prompt([]);
192
- } catch {
193
- // Agent is already processing — something else is driving.
194
- }
195
- } finally {
196
- _continueInProgress = false;
197
- _lastInvisibleContinueTime = Date.now();
198
- }
60
+ pi.sendMessage(
61
+ {
62
+ customType: INVISIBLE_CONTINUE_CUSTOM_TYPE,
63
+ content: [],
64
+ display: false,
65
+ details: undefined,
66
+ },
67
+ {
68
+ triggerTurn: true,
69
+ deliverAs: "followUp",
70
+ },
71
+ );
199
72
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-invisible-continue",
3
- "version": "0.3.3",
4
- "description": "Invisible session continuation for pi \u2014 resume the agentic loop without sending ANY prompt the LLM can see",
3
+ "version": "0.3.5",
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",
7
7
  "license": "MIT",
@@ -30,16 +30,8 @@
30
30
  "src/",
31
31
  "README.md"
32
32
  ],
33
- "scripts": {
34
- "test": "vitest run",
35
- "test:watch": "vitest",
36
- "test:coverage": "vitest run --coverage",
37
- "typecheck": "tsc --noEmit",
38
- "lint:dead": "knip --no-gitignore"
39
- },
40
33
  "devDependencies": {
41
- "@earendil-works/pi-agent-core": "^0.79.8",
42
- "@earendil-works/pi-coding-agent": "^0.79.8",
34
+ "@earendil-works/pi-coding-agent": "^0.83.0",
43
35
  "@types/node": "25.9.1",
44
36
  "@vitest/coverage-v8": "4.1.7",
45
37
  "knip": "6.14.1",
@@ -55,5 +47,12 @@
55
47
  "brace-expansion": "5.0.6",
56
48
  "protobufjs": "8.4.0",
57
49
  "ws": "8.20.1"
50
+ },
51
+ "scripts": {
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:coverage": "vitest run --coverage",
55
+ "typecheck": "tsc --noEmit",
56
+ "lint:dead": "knip --no-gitignore"
58
57
  }
59
- }
58
+ }
package/src/index.ts CHANGED
@@ -1,18 +1,19 @@
1
- /**
2
- * Shared constants and utilities for pi-invisible-continue.
3
- *
4
- * The extension is small enough that heavy shared logic is unnecessary.
5
- * The command description and a session introspection helper are the only exports.
6
- */
1
+ /** Shared constants and utilities for pi-invisible-continue. */
7
2
 
8
- /** Description shown in the / commands list. */
9
3
  export const CONTINUE_COMMAND_DESCRIPTION =
10
4
  "Resume the agentic loop without sending a prompt the LLM can read";
11
5
 
12
- /**
13
- * Extract the text content of the last assistant message in the session.
14
- * Returns undefined if no assistant message exists.
15
- */
6
+ export const INVISIBLE_CONTINUE_CUSTOM_TYPE = "pi-invisible-continue:resume";
7
+
8
+ export function isInvisibleContinueMarker(message: unknown): boolean {
9
+ if (!message || typeof message !== "object") return false;
10
+ const candidate = message as { role?: unknown; customType?: unknown };
11
+ return (
12
+ candidate.role === "custom" &&
13
+ candidate.customType === INVISIBLE_CONTINUE_CUSTOM_TYPE
14
+ );
15
+ }
16
+
16
17
  export function getLastAssistantMessageText(
17
18
  entries: ReadonlyArray<{ type: string; message?: { role?: string; content?: unknown } }>,
18
19
  ): string | undefined {
@@ -21,17 +22,17 @@ export function getLastAssistantMessageText(
21
22
  if (
22
23
  entry.type === "message" &&
23
24
  entry.message?.role === "assistant" &&
24
- entry.message?.content
25
+ entry.message.content
25
26
  ) {
26
27
  const content = entry.message.content;
27
28
  if (typeof content === "string") return content;
28
29
  if (Array.isArray(content)) {
29
30
  const textBlocks = content.filter(
30
- (block: any): block is { type: "text"; text: string } =>
31
- typeof block === "object" &&
32
- block !== null &&
33
- block.type === "text" &&
34
- typeof block.text === "string",
31
+ (block: unknown): block is { type: "text"; text: string } => {
32
+ if (!block || typeof block !== "object") return false;
33
+ const candidate = block as { type?: unknown; text?: unknown };
34
+ return candidate.type === "text" && typeof candidate.text === "string";
35
+ },
35
36
  );
36
37
  if (textBlocks.length === 0) return undefined;
37
38
  return textBlocks.map((block) => block.text).join("\n");