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 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
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./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
+ }
@@ -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
+ });