pi-invisible-continue 0.2.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.
- package/README.md +128 -0
- package/continue.ts +86 -0
- package/package.json +60 -0
- package/src/index.ts +42 -0
- package/vitest.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 👻 pi-invisible-continue
|
|
4
|
+
|
|
5
|
+
**Invisible session continuation for [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_Resume the agentic loop without the LLM seeing any new prompt at all._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The Problem
|
|
17
|
+
|
|
18
|
+
Every existing "continue" extension sends a **visible user message** to the LLM:
|
|
19
|
+
|
|
20
|
+
| Package | What the LLM sees |
|
|
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 |
|
|
26
|
+
|
|
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
|
+
---
|
|
30
|
+
|
|
31
|
+
## The Solution
|
|
32
|
+
|
|
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:
|
|
34
|
+
|
|
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
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## How It Works
|
|
43
|
+
|
|
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
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Why `agent.prompt([])` and not `agent.continue()`?
|
|
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.
|
|
60
|
+
|
|
61
|
+
### Trade-off: bypasses AgentSession
|
|
62
|
+
|
|
63
|
+
`agent.prompt([])` is called on the `Agent` directly, bypassing `AgentSession._runAgentPrompt()`. This means auto-retry on errors and auto-compaction are not triggered after a `/continue`. For a manual command where the user explicitly said "keep going," this is acceptable — they can always `/continue` again.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
Once loaded, use `/continue`:
|
|
70
|
+
|
|
71
|
+
| Command | What it does |
|
|
72
|
+
|---------|-------------|
|
|
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
|
+
---
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pi install https://github.com/monotykamary/pi-invisible-continue
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or in `~/.pi/agent/settings.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"packages": [
|
|
90
|
+
"https://github.com/monotykamary/pi-invisible-continue"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then `/reload` or restart pi.
|
|
96
|
+
|
|
97
|
+
For quick one-off tests:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pi -e ./continue.ts
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Comparison with Other Packages
|
|
106
|
+
|
|
107
|
+
| Feature | pi-invisible-continue | pi-continue | pi-hodor | pi-auto-continue |
|
|
108
|
+
|---------|----------------------|-------------|----------|-------------------|
|
|
109
|
+
| LLM sees new user text | ❌ No | ✅ Handoff doc | ✅ "continue" | ✅ "continue" |
|
|
110
|
+
| Session pollution | None | Full compaction entry + user message | 1 user message | 1 user message |
|
|
111
|
+
| Mechanism | `agent.prompt([])` | `sendMessage` + handoff | `sendMessage` | `sendMessage` |
|
|
112
|
+
| Auto-triggered | ❌ Manual only | ✅ On compaction | ✅ On error/length | ✅ On agent_end |
|
|
113
|
+
| Retry integration | ❌ | ❌ | ✅ Error patterns | ❌ |
|
|
114
|
+
| Complexity | Prototype patch + 1 call | 49 files, multi-stage | 2 files, config-driven | 1 file, loop-based |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## About the Hack
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
package/continue.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Agent } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import {
|
|
4
|
+
CONTINUE_COMMAND_DESCRIPTION,
|
|
5
|
+
getLastAssistantMessageText,
|
|
6
|
+
} from "./src/index.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* pi-invisible-continue — resume the agentic loop without the LLM seeing any new prompt.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
* - Monkey-patch Agent.prototype.prompt 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-retry and auto-compaction
|
|
19
|
+
* are not triggered after a manual /continue. The user can always /continue again.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Capture the live Agent instance when AgentSession subscribes to it.
|
|
23
|
+
// subscribe() is called during AgentSession construction — fires on both
|
|
24
|
+
// fresh sessions and session resumes, unlike prompt().
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
let _agent: Agent | null = null;
|
|
27
|
+
const _origSubscribe = Agent.prototype.subscribe as (this: Agent, ...args: any[]) => any;
|
|
28
|
+
Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
|
|
29
|
+
_agent = this;
|
|
30
|
+
return _origSubscribe.apply(this, args);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function (pi: ExtensionAPI) {
|
|
34
|
+
pi.registerCommand("continue", {
|
|
35
|
+
description: CONTINUE_COMMAND_DESCRIPTION,
|
|
36
|
+
handler: async (args, ctx) => {
|
|
37
|
+
await runContinueCommand(ctx, args);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runContinueCommand(
|
|
43
|
+
ctx: ExtensionCommandContext,
|
|
44
|
+
args: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (args.trim().toLowerCase() === "status") {
|
|
47
|
+
const last = getLastAssistantMessageText(ctx.sessionManager.getEntries());
|
|
48
|
+
const idle = ctx.isIdle();
|
|
49
|
+
ctx.ui.notify(
|
|
50
|
+
[
|
|
51
|
+
"pi-invisible-continue status:",
|
|
52
|
+
` Agent idle: ${idle ? "yes" : "no"}`,
|
|
53
|
+
` Captured agent: ${_agent ? "yes" : "no"}`,
|
|
54
|
+
` Last assistant: ${last ?? "(none)"}`.slice(0, 120),
|
|
55
|
+
].join("\n"),
|
|
56
|
+
"info",
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (args.trim().toLowerCase() === "help") {
|
|
62
|
+
ctx.ui.notify(
|
|
63
|
+
[
|
|
64
|
+
"pi-invisible-continue /continue Resume loop invisibly",
|
|
65
|
+
" /continue status Show diagnostics",
|
|
66
|
+
" /continue help This message",
|
|
67
|
+
].join("\n"),
|
|
68
|
+
"info",
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!_agent) {
|
|
74
|
+
ctx.ui.notify(
|
|
75
|
+
"pi-invisible-continue: Agent instance not captured. Internal error?",
|
|
76
|
+
"warning",
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!ctx.isIdle()) {
|
|
82
|
+
await ctx.waitForIdle();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await _agent.prompt([]);
|
|
86
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-invisible-continue",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Invisible session continuation for pi — resume the agentic loop without sending ANY prompt the LLM can see",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Tom X Nguyen",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/monotykamary/pi-invisible-continue.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/monotykamary/pi-invisible-continue#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/monotykamary/pi-invisible-continue/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"extension",
|
|
21
|
+
"continue",
|
|
22
|
+
"continuation",
|
|
23
|
+
"invisible",
|
|
24
|
+
"no-prompt",
|
|
25
|
+
"resume",
|
|
26
|
+
"agent-loop"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"*.ts",
|
|
30
|
+
"src/",
|
|
31
|
+
"README.md"
|
|
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
|
+
"devDependencies": {
|
|
41
|
+
"@earendil-works/pi-agent-core": "0.75.4",
|
|
42
|
+
"@earendil-works/pi-ai": "0.75.4",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
44
|
+
"@types/node": "25.9.1",
|
|
45
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
46
|
+
"knip": "6.14.1",
|
|
47
|
+
"typescript": "6.0.3",
|
|
48
|
+
"vitest": "4.1.7"
|
|
49
|
+
},
|
|
50
|
+
"pi": {
|
|
51
|
+
"extensions": [
|
|
52
|
+
"./continue.ts"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"overrides": {
|
|
56
|
+
"brace-expansion": "5.0.6",
|
|
57
|
+
"protobufjs": "8.4.0",
|
|
58
|
+
"ws": "8.20.1"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Description shown in the / commands list. */
|
|
9
|
+
export const CONTINUE_COMMAND_DESCRIPTION =
|
|
10
|
+
"Resume the agentic loop without sending a prompt the LLM can read";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract the text content of the last assistant message in the session.
|
|
14
|
+
* Returns undefined if no assistant message exists.
|
|
15
|
+
*/
|
|
16
|
+
export function getLastAssistantMessageText(
|
|
17
|
+
entries: ReadonlyArray<{ type: string; message?: { role?: string; content?: unknown } }>,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
20
|
+
const entry = entries[i];
|
|
21
|
+
if (
|
|
22
|
+
entry.type === "message" &&
|
|
23
|
+
entry.message?.role === "assistant" &&
|
|
24
|
+
entry.message?.content
|
|
25
|
+
) {
|
|
26
|
+
const content = entry.message.content;
|
|
27
|
+
if (typeof content === "string") return content;
|
|
28
|
+
if (Array.isArray(content)) {
|
|
29
|
+
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",
|
|
35
|
+
);
|
|
36
|
+
if (textBlocks.length === 0) return undefined;
|
|
37
|
+
return textBlocks.map((block) => block.text).join("\n");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["__tests__/**/*.test.ts"],
|
|
8
|
+
exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: "v8",
|
|
11
|
+
reporter: ["text", "json", "html"],
|
|
12
|
+
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|