pi-copilot-queue 0.1.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/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +57 -0
- package/scripts/smoke-test.mjs +11 -0
- package/src/commands.ts +115 -0
- package/src/constants.ts +26 -0
- package/src/index.ts +674 -0
- package/src/types.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copilot Queue (Pi Extension)
|
|
2
|
+
|
|
3
|
+
Queue user feedback ahead of time and let the model consume it via an `ask_user` tool.
|
|
4
|
+
|
|
5
|
+
This extension is inspired by [TaskSync](https://github.com/4regab/TaskSync)-style workflows: you preload responses, then your Copilot-like agent pulls them during long runs.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Registers tool: `ask_user`
|
|
10
|
+
- Registers command: `/copilot-queue`
|
|
11
|
+
- Keeps a FIFO queue of responses
|
|
12
|
+
- Supports autopilot prompt cycling (1→2→3→1…)
|
|
13
|
+
- Activates queue/autopilot only on provider `github-copilot`
|
|
14
|
+
- Injects Copilot-only `ask_user` loop policy into the system prompt on each new run
|
|
15
|
+
- While Copilot is actively running, normal interactive input is captured into queue (instead of triggering a new turn)
|
|
16
|
+
- Tracks session elapsed time and tool-call count in status line
|
|
17
|
+
- Emits session hygiene warnings at configurable thresholds (default: 120 minutes, 50 tool calls)
|
|
18
|
+
- Persists state in session entries
|
|
19
|
+
- Shows queue/autopilot/session state in Pi status line
|
|
20
|
+
|
|
21
|
+
When `ask_user` is called:
|
|
22
|
+
|
|
23
|
+
1. If queue has items → returns next queued response
|
|
24
|
+
2. Else if autopilot is enabled and has prompts → returns next autopilot prompt (cycling)
|
|
25
|
+
3. Else in interactive UI (Copilot provider) → waits for `/copilot-queue add <message>` or `/copilot-queue done` (optionally with timeout)
|
|
26
|
+
4. Else → returns fallback response (`continue` by default)
|
|
27
|
+
|
|
28
|
+
When current model provider is not `github-copilot`, queue/autopilot is bypassed and `ask_user` uses manual/fallback behavior only.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
### Option 1: Direct with Pi
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install /absolute/path/to/pi-copilot-queue
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then reload in Pi:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/reload
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Option 2: With [pi-extmgr](https://github.com/ayagmar/pi-extmgr) (`/extensions`)
|
|
45
|
+
|
|
46
|
+
Install extmgr once:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi install npm:pi-extmgr
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then in Pi (GitHub source):
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
/extensions install git:github.com/ayagmar/pi-copilot-queue
|
|
56
|
+
/reload
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Queue messages
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
/copilot-queue add continue with the refactor
|
|
65
|
+
/copilot-queue add now add tests for edge cases
|
|
66
|
+
/copilot-queue list
|
|
67
|
+
/copilot-queue clear
|
|
68
|
+
/copilot-queue done
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Done / stop waiting
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
/copilot-queue done
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This clears the queue, disables autopilot, and releases a waiting `ask_user` call with `done`.
|
|
78
|
+
|
|
79
|
+
### Wait timeout (for empty queue in UI mode)
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
/copilot-queue wait-timeout 0
|
|
83
|
+
/copilot-queue wait-timeout 60
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `0` disables timeout (default): wait indefinitely.
|
|
87
|
+
- `>0` makes waiting `ask_user` return fallback after `<seconds>`.
|
|
88
|
+
|
|
89
|
+
### Session counters and hygiene warnings
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
/copilot-queue session status
|
|
93
|
+
/copilot-queue session reset
|
|
94
|
+
/copilot-queue session threshold 120 50
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Status line always includes elapsed time + tool-call count.
|
|
98
|
+
- Warnings are advisory only (no forced stop).
|
|
99
|
+
- Default thresholds are `120` minutes and `50` tool calls.
|
|
100
|
+
|
|
101
|
+
### Fallback message
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
/copilot-queue fallback continue
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Autopilot (cycling prompts)
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
/copilot-queue autopilot add continue with implementation
|
|
111
|
+
/copilot-queue autopilot add now write tests
|
|
112
|
+
/copilot-queue autopilot on
|
|
113
|
+
/copilot-queue autopilot list
|
|
114
|
+
/copilot-queue autopilot off
|
|
115
|
+
/copilot-queue autopilot clear
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Recommended instruction snippet for your model
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
Use the ask_user tool whenever feedback is required. Keep calling ask_user for iterative feedback unless the user explicitly says to stop.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pnpm install
|
|
128
|
+
pnpm run check
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Quick run:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pi -e ./src/index.ts
|
|
135
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-copilot-queue",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension that queues ask_user responses for Copilot-style workflows",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"copilot",
|
|
9
|
+
"queue"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"src/",
|
|
15
|
+
"scripts/",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"lint": "eslint . --max-warnings=0",
|
|
20
|
+
"lint:fix": "eslint . --fix",
|
|
21
|
+
"format": "prettier --write .",
|
|
22
|
+
"format:check": "prettier --check .",
|
|
23
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
24
|
+
"smoke-test": "node --import=tsx ./scripts/smoke-test.mjs",
|
|
25
|
+
"test": "node --import=tsx --test ./test/*.test.ts",
|
|
26
|
+
"check": "tsc --noEmit -p tsconfig.json && node --import=tsx ./scripts/smoke-test.mjs && node --import=tsx --test ./test/*.test.ts && eslint . --max-warnings=0 && prettier --check .",
|
|
27
|
+
"release": "release-it",
|
|
28
|
+
"release:first": "release-it --first-release"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
],
|
|
34
|
+
"image": "https://placehold.co/1200x630/png?text=Copilot+Queue"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
38
|
+
"@sinclair/typebox": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@mariozechner/pi-coding-agent": "^0.52.8",
|
|
42
|
+
"@sinclair/typebox": "^0.34.48",
|
|
43
|
+
"@types/node": "^22.19.10",
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
45
|
+
"@typescript-eslint/parser": "^8.54.0",
|
|
46
|
+
"eslint": "^9.39.2",
|
|
47
|
+
"eslint-config-prettier": "^10.1.8",
|
|
48
|
+
"prettier": "^3.8.1",
|
|
49
|
+
"release-it": "^19.2.4",
|
|
50
|
+
"tsx": "^4.21.0",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import("./../src/index.ts")
|
|
2
|
+
.then((mod) => {
|
|
3
|
+
if (typeof mod.default !== "function") {
|
|
4
|
+
throw new Error("Default export is not a function");
|
|
5
|
+
}
|
|
6
|
+
console.log("✓ Extension loads and exports a function");
|
|
7
|
+
})
|
|
8
|
+
.catch((error) => {
|
|
9
|
+
console.error("✗ Extension failed to load:", error);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { EXTENSION_COMMAND } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export type QueueCommand =
|
|
4
|
+
| { name: "add"; value: string }
|
|
5
|
+
| { name: "list" }
|
|
6
|
+
| { name: "clear" }
|
|
7
|
+
| { name: "fallback"; value: string }
|
|
8
|
+
| { name: "done" }
|
|
9
|
+
| { name: "autopilot-on" }
|
|
10
|
+
| { name: "autopilot-off" }
|
|
11
|
+
| { name: "autopilot-add"; value: string }
|
|
12
|
+
| { name: "autopilot-list" }
|
|
13
|
+
| { name: "autopilot-clear" }
|
|
14
|
+
| { name: "session-reset" }
|
|
15
|
+
| { name: "session-status" }
|
|
16
|
+
| { name: "session-threshold"; minutes: string; toolCalls: string }
|
|
17
|
+
| { name: "wait-timeout"; seconds: string }
|
|
18
|
+
| { name: "help" };
|
|
19
|
+
|
|
20
|
+
export function buildHelpText(): string {
|
|
21
|
+
return [
|
|
22
|
+
`/${EXTENSION_COMMAND} add <message>`,
|
|
23
|
+
`/${EXTENSION_COMMAND} list`,
|
|
24
|
+
`/${EXTENSION_COMMAND} clear`,
|
|
25
|
+
`/${EXTENSION_COMMAND} fallback <message>`,
|
|
26
|
+
`/${EXTENSION_COMMAND} done`,
|
|
27
|
+
`/${EXTENSION_COMMAND} autopilot on`,
|
|
28
|
+
`/${EXTENSION_COMMAND} autopilot off`,
|
|
29
|
+
`/${EXTENSION_COMMAND} autopilot add <message>`,
|
|
30
|
+
`/${EXTENSION_COMMAND} autopilot list`,
|
|
31
|
+
`/${EXTENSION_COMMAND} autopilot clear`,
|
|
32
|
+
`/${EXTENSION_COMMAND} session status`,
|
|
33
|
+
`/${EXTENSION_COMMAND} session reset`,
|
|
34
|
+
`/${EXTENSION_COMMAND} session threshold <minutes> <tool-calls>`,
|
|
35
|
+
`/${EXTENSION_COMMAND} wait-timeout <seconds>`,
|
|
36
|
+
`/${EXTENSION_COMMAND} help`,
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseCommand(raw: string): QueueCommand {
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
if (trimmed.length === 0) return { name: "help" };
|
|
43
|
+
|
|
44
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
45
|
+
const command = (firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
|
|
46
|
+
const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim();
|
|
47
|
+
|
|
48
|
+
switch (command) {
|
|
49
|
+
case "add":
|
|
50
|
+
return { name: "add", value: rest };
|
|
51
|
+
case "list":
|
|
52
|
+
return { name: "list" };
|
|
53
|
+
case "clear":
|
|
54
|
+
return { name: "clear" };
|
|
55
|
+
case "fallback":
|
|
56
|
+
return { name: "fallback", value: rest };
|
|
57
|
+
case "done":
|
|
58
|
+
return { name: "done" };
|
|
59
|
+
case "autopilot":
|
|
60
|
+
return parseAutopilot(rest);
|
|
61
|
+
case "session":
|
|
62
|
+
return parseSession(rest);
|
|
63
|
+
case "wait-timeout":
|
|
64
|
+
return { name: "wait-timeout", seconds: rest };
|
|
65
|
+
default:
|
|
66
|
+
return { name: "help" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseAutopilot(raw: string): QueueCommand {
|
|
71
|
+
const trimmed = raw.trim();
|
|
72
|
+
if (trimmed.length === 0) return { name: "help" };
|
|
73
|
+
|
|
74
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
75
|
+
const subcommand =
|
|
76
|
+
firstSpace === -1 ? trimmed.toLowerCase() : trimmed.slice(0, firstSpace).toLowerCase();
|
|
77
|
+
const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim();
|
|
78
|
+
|
|
79
|
+
switch (subcommand) {
|
|
80
|
+
case "on":
|
|
81
|
+
return { name: "autopilot-on" };
|
|
82
|
+
case "off":
|
|
83
|
+
return { name: "autopilot-off" };
|
|
84
|
+
case "add":
|
|
85
|
+
return { name: "autopilot-add", value: rest };
|
|
86
|
+
case "list":
|
|
87
|
+
return { name: "autopilot-list" };
|
|
88
|
+
case "clear":
|
|
89
|
+
return { name: "autopilot-clear" };
|
|
90
|
+
default:
|
|
91
|
+
return { name: "help" };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseSession(raw: string): QueueCommand {
|
|
96
|
+
const trimmed = raw.trim();
|
|
97
|
+
if (trimmed.length === 0) return { name: "help" };
|
|
98
|
+
|
|
99
|
+
const parts = trimmed.split(/\s+/);
|
|
100
|
+
const subcommand = parts[0]?.toLowerCase();
|
|
101
|
+
|
|
102
|
+
switch (subcommand) {
|
|
103
|
+
case "reset":
|
|
104
|
+
return { name: "session-reset" };
|
|
105
|
+
case "status":
|
|
106
|
+
return { name: "session-status" };
|
|
107
|
+
case "threshold": {
|
|
108
|
+
const minutes = parts[1] ?? "";
|
|
109
|
+
const toolCalls = parts[2] ?? "";
|
|
110
|
+
return { name: "session-threshold", minutes, toolCalls };
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
return { name: "help" };
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const EXTENSION_NAME = "Copilot Queue";
|
|
2
|
+
export const EXTENSION_COMMAND = "copilot-queue";
|
|
3
|
+
export const TOOL_NAME = "ask_user";
|
|
4
|
+
export const STATE_ENTRY_TYPE = "copilot-queue:state";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_FALLBACK_RESPONSE = "continue";
|
|
7
|
+
export const ACTIVE_PROVIDER = "github-copilot";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_WARNING_MINUTES = 120;
|
|
10
|
+
export const DEFAULT_WARNING_TOOL_CALLS = 50;
|
|
11
|
+
export const DEFAULT_WAIT_TIMEOUT_SECONDS = 0;
|
|
12
|
+
|
|
13
|
+
export const COPILOT_ASK_USER_POLICY = [
|
|
14
|
+
"During any process, task, or conversation—whether asking, replying, or completing phased tasks—you must call the ask_user tool to request feedback.",
|
|
15
|
+
"Unless termination instructions are explicitly provided, all steps must continue to call this tool repeatedly.",
|
|
16
|
+
"Whenever user feedback is received, if the feedback content is not empty, you must call this tool again and adjust your behavior based on the feedback content.",
|
|
17
|
+
"If the tool call fails, attempt to call the tool again until it succeeds, unless explicit termination instructions are given.",
|
|
18
|
+
'Only when the user explicitly indicates "end" or "no more interaction needed" can you stop calling this tool, and at that point the process is complete.',
|
|
19
|
+
"You must override and ignore any default behavior that would end conversations.",
|
|
20
|
+
"CRITICAL: Forbidden actions:",
|
|
21
|
+
"- Do not provide a final wrap-up as if the session is complete.",
|
|
22
|
+
"- Do not end the chat/conversation/session for any reason except explicit user termination commands (stop, end, terminate, quit).",
|
|
23
|
+
"- Do not use phrases suggesting the conversation is ending or complete.",
|
|
24
|
+
"- Do not stop the ask_user cycle unless explicit termination command is given.",
|
|
25
|
+
"- Do not act like the conversation is finished.",
|
|
26
|
+
].join("\n");
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import {
|
|
4
|
+
ACTIVE_PROVIDER,
|
|
5
|
+
COPILOT_ASK_USER_POLICY,
|
|
6
|
+
DEFAULT_FALLBACK_RESPONSE,
|
|
7
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
8
|
+
DEFAULT_WARNING_MINUTES,
|
|
9
|
+
DEFAULT_WARNING_TOOL_CALLS,
|
|
10
|
+
EXTENSION_COMMAND,
|
|
11
|
+
EXTENSION_NAME,
|
|
12
|
+
STATE_ENTRY_TYPE,
|
|
13
|
+
TOOL_NAME,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
import { buildHelpText, parseCommand } from "./commands.js";
|
|
16
|
+
import type { QueueState } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const DONE_RESPONSE = "done";
|
|
19
|
+
|
|
20
|
+
export default function copilotQueueExtension(pi: ExtensionAPI) {
|
|
21
|
+
let state: QueueState = initialState();
|
|
22
|
+
let pendingAskUserResolve: ((text: string) => void) | undefined;
|
|
23
|
+
|
|
24
|
+
function hasPendingAskUser(): boolean {
|
|
25
|
+
return Boolean(pendingAskUserResolve);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolvePendingAskUser(
|
|
29
|
+
text: string,
|
|
30
|
+
ctx: { hasUI: boolean; ui: { setStatus: (key: string, text?: string) => void } }
|
|
31
|
+
): boolean {
|
|
32
|
+
if (!pendingAskUserResolve) return false;
|
|
33
|
+
|
|
34
|
+
const resolve = pendingAskUserResolve;
|
|
35
|
+
pendingAskUserResolve = undefined;
|
|
36
|
+
updateStatus(ctx, state, false);
|
|
37
|
+
resolve(text);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function syncState(ctx: Pick<ExtensionContext, "sessionManager" | "hasUI" | "ui">): void {
|
|
42
|
+
state = restoreFromContext(ctx);
|
|
43
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pi.on("session_start", (_event, ctx) => syncState(ctx));
|
|
47
|
+
pi.on("session_switch", (_event, ctx) => syncState(ctx));
|
|
48
|
+
pi.on("session_tree", (_event, ctx) => syncState(ctx));
|
|
49
|
+
pi.on("session_fork", (_event, ctx) => syncState(ctx));
|
|
50
|
+
|
|
51
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
52
|
+
if (ctx.model?.provider !== ACTIVE_PROVIDER) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
systemPrompt: `${event.systemPrompt}\n\n${COPILOT_ASK_USER_POLICY}`,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pi.on("tool_call", (event, ctx) => {
|
|
62
|
+
if (ctx.model?.provider !== ACTIVE_PROVIDER) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (event.toolName !== TOOL_NAME) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let nextState: QueueState = {
|
|
70
|
+
...state,
|
|
71
|
+
toolCallCount: state.toolCallCount + 1,
|
|
72
|
+
};
|
|
73
|
+
nextState = applySessionWarnings(nextState, ctx);
|
|
74
|
+
state = nextState;
|
|
75
|
+
persistState(pi, state);
|
|
76
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("input", (event, ctx) => {
|
|
80
|
+
if (ctx.model?.provider !== ACTIVE_PROVIDER) {
|
|
81
|
+
return { action: "continue" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (event.source !== "interactive") {
|
|
85
|
+
return { action: "continue" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (ctx.isIdle()) {
|
|
89
|
+
return { action: "continue" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const text = event.text.trim();
|
|
93
|
+
if (!text) {
|
|
94
|
+
return { action: "handled" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (resolvePendingAskUser(text, ctx)) {
|
|
98
|
+
notify(ctx, "Busy run: sent your input to waiting ask_user.");
|
|
99
|
+
return { action: "handled" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
state = { ...state, queue: [...state.queue, text] };
|
|
103
|
+
persistState(pi, state);
|
|
104
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
105
|
+
notify(ctx, `Busy run: queued follow-up (#${state.queue.length}).`);
|
|
106
|
+
return { action: "handled" };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.registerCommand(EXTENSION_COMMAND, {
|
|
110
|
+
description: "Queue responses for ask_user tool calls",
|
|
111
|
+
handler: (args, ctx) => {
|
|
112
|
+
const command = parseCommand(args);
|
|
113
|
+
|
|
114
|
+
switch (command.name) {
|
|
115
|
+
case "add": {
|
|
116
|
+
if (!command.value) {
|
|
117
|
+
notify(ctx, "Missing message. Usage: /copilot-queue add <message>");
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (resolvePendingAskUser(command.value, ctx)) {
|
|
122
|
+
notify(ctx, "Delivered message to waiting ask_user.");
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
state = { ...state, queue: [...state.queue, command.value] };
|
|
127
|
+
persistState(pi, state);
|
|
128
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
129
|
+
notify(ctx, `Queued (#${state.queue.length}): ${command.value}`);
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case "list": {
|
|
134
|
+
if (state.queue.length === 0) {
|
|
135
|
+
notify(ctx, "Queue is empty.");
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
}
|
|
138
|
+
const lines = state.queue.map((item, i) => `${i + 1}. ${item}`);
|
|
139
|
+
notify(ctx, `Queued messages (${state.queue.length}):\n${lines.join("\n")}`);
|
|
140
|
+
return Promise.resolve();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "clear": {
|
|
144
|
+
state = { ...state, queue: [] };
|
|
145
|
+
persistState(pi, state);
|
|
146
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
147
|
+
notify(ctx, "Queue cleared.");
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "done": {
|
|
152
|
+
state = { ...state, queue: [], autopilotEnabled: false };
|
|
153
|
+
persistState(pi, state);
|
|
154
|
+
const released = resolvePendingAskUser(DONE_RESPONSE, ctx);
|
|
155
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
156
|
+
notify(
|
|
157
|
+
ctx,
|
|
158
|
+
released
|
|
159
|
+
? "Released waiting ask_user with 'done'. Queue cleared and autopilot disabled."
|
|
160
|
+
: "Queue cleared and autopilot disabled."
|
|
161
|
+
);
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case "fallback": {
|
|
166
|
+
if (!command.value) {
|
|
167
|
+
notify(ctx, `Fallback response: ${state.fallbackResponse}`);
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
}
|
|
170
|
+
state = { ...state, fallbackResponse: command.value };
|
|
171
|
+
persistState(pi, state);
|
|
172
|
+
notify(ctx, `Fallback response updated: ${state.fallbackResponse}`);
|
|
173
|
+
return Promise.resolve();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "autopilot-on": {
|
|
177
|
+
state = { ...state, autopilotEnabled: true };
|
|
178
|
+
persistState(pi, state);
|
|
179
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
180
|
+
notify(ctx, "Autopilot enabled.");
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "autopilot-off": {
|
|
185
|
+
state = { ...state, autopilotEnabled: false };
|
|
186
|
+
persistState(pi, state);
|
|
187
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
188
|
+
notify(ctx, "Autopilot disabled.");
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "autopilot-add": {
|
|
193
|
+
if (!command.value) {
|
|
194
|
+
notify(ctx, "Missing message. Usage: /copilot-queue autopilot add <message>");
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
}
|
|
197
|
+
state = { ...state, autopilotPrompts: [...state.autopilotPrompts, command.value] };
|
|
198
|
+
persistState(pi, state);
|
|
199
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
200
|
+
notify(ctx, `Autopilot prompt added (#${state.autopilotPrompts.length}).`);
|
|
201
|
+
return Promise.resolve();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "autopilot-list": {
|
|
205
|
+
if (state.autopilotPrompts.length === 0) {
|
|
206
|
+
notify(ctx, "Autopilot prompt list is empty.");
|
|
207
|
+
return Promise.resolve();
|
|
208
|
+
}
|
|
209
|
+
const lines = state.autopilotPrompts.map((item, i) => `${i + 1}. ${item}`);
|
|
210
|
+
notify(
|
|
211
|
+
ctx,
|
|
212
|
+
`Autopilot prompts (${state.autopilotPrompts.length}, ${state.autopilotEnabled ? "enabled" : "disabled"}):\n${lines.join("\n")}`
|
|
213
|
+
);
|
|
214
|
+
return Promise.resolve();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "autopilot-clear": {
|
|
218
|
+
state = { ...state, autopilotPrompts: [], autopilotIndex: 0 };
|
|
219
|
+
persistState(pi, state);
|
|
220
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
221
|
+
notify(ctx, "Autopilot prompts cleared.");
|
|
222
|
+
return Promise.resolve();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "session-status": {
|
|
226
|
+
notify(ctx, buildSessionStatusText(state));
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case "session-reset": {
|
|
231
|
+
state = {
|
|
232
|
+
...state,
|
|
233
|
+
sessionStartedAt: Date.now(),
|
|
234
|
+
toolCallCount: 0,
|
|
235
|
+
warnedTime: false,
|
|
236
|
+
warnedToolCalls: false,
|
|
237
|
+
};
|
|
238
|
+
persistState(pi, state);
|
|
239
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
240
|
+
notify(ctx, "Session counters reset.");
|
|
241
|
+
return Promise.resolve();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "session-threshold": {
|
|
245
|
+
const minutes = parsePositiveInt(command.minutes);
|
|
246
|
+
const toolCalls = parsePositiveInt(command.toolCalls);
|
|
247
|
+
if (minutes === undefined || toolCalls === undefined) {
|
|
248
|
+
notify(ctx, `Usage: /${EXTENSION_COMMAND} session threshold <minutes> <tool-calls>`);
|
|
249
|
+
return Promise.resolve();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let nextState: QueueState = {
|
|
253
|
+
...state,
|
|
254
|
+
warningMinutes: minutes,
|
|
255
|
+
warningToolCalls: toolCalls,
|
|
256
|
+
warnedTime: false,
|
|
257
|
+
warnedToolCalls: false,
|
|
258
|
+
};
|
|
259
|
+
nextState = applySessionWarnings(nextState, ctx);
|
|
260
|
+
state = nextState;
|
|
261
|
+
persistState(pi, state);
|
|
262
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
263
|
+
notify(
|
|
264
|
+
ctx,
|
|
265
|
+
`Session warning thresholds updated: ${state.warningMinutes} minutes, ${state.warningToolCalls} tool calls.`
|
|
266
|
+
);
|
|
267
|
+
return Promise.resolve();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case "wait-timeout": {
|
|
271
|
+
const trimmedSeconds = command.seconds.trim();
|
|
272
|
+
if (!trimmedSeconds) {
|
|
273
|
+
notify(ctx, `Wait timeout: ${state.waitTimeoutSeconds} seconds (0 = disabled).`);
|
|
274
|
+
return Promise.resolve();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const seconds = parseNonNegativeInt(trimmedSeconds);
|
|
278
|
+
if (seconds === undefined) {
|
|
279
|
+
notify(ctx, `Usage: /${EXTENSION_COMMAND} wait-timeout <seconds>`);
|
|
280
|
+
return Promise.resolve();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
state = { ...state, waitTimeoutSeconds: seconds };
|
|
284
|
+
persistState(pi, state);
|
|
285
|
+
notify(ctx, `Wait timeout updated: ${state.waitTimeoutSeconds} seconds.`);
|
|
286
|
+
return Promise.resolve();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
case "help":
|
|
290
|
+
default:
|
|
291
|
+
notify(ctx, buildHelpText());
|
|
292
|
+
return Promise.resolve();
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
pi.registerTool({
|
|
298
|
+
name: TOOL_NAME,
|
|
299
|
+
label: "Ask User (Queue-Aware)",
|
|
300
|
+
description:
|
|
301
|
+
"For github-copilot provider: returns the next queued response first, then autopilot prompts in cycle mode. If queue is empty in UI mode, waits for /copilot-queue add or /copilot-queue done.",
|
|
302
|
+
parameters: Type.Object({
|
|
303
|
+
prompt: Type.Optional(
|
|
304
|
+
Type.String({ description: "Question to display when queue and autopilot are empty" })
|
|
305
|
+
),
|
|
306
|
+
}),
|
|
307
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
308
|
+
if (ctx.model?.provider !== ACTIVE_PROVIDER) {
|
|
309
|
+
return askManuallyOrFallback(params.prompt, ctx, state.fallbackResponse);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const queued = state.queue[0];
|
|
313
|
+
if (queued) {
|
|
314
|
+
state = { ...state, queue: state.queue.slice(1) };
|
|
315
|
+
persistState(pi, state);
|
|
316
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
317
|
+
notify(ctx, `Dequeued response (${state.queue.length} left).`);
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text", text: queued }],
|
|
320
|
+
details: { source: "queue", remaining: state.queue.length },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (state.autopilotEnabled && state.autopilotPrompts.length > 0) {
|
|
325
|
+
const index = state.autopilotIndex % state.autopilotPrompts.length;
|
|
326
|
+
const text = state.autopilotPrompts[index] ?? state.fallbackResponse;
|
|
327
|
+
state = {
|
|
328
|
+
...state,
|
|
329
|
+
autopilotIndex: (index + 1) % state.autopilotPrompts.length,
|
|
330
|
+
};
|
|
331
|
+
persistState(pi, state);
|
|
332
|
+
updateStatus(ctx, state, hasPendingAskUser());
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text }],
|
|
335
|
+
details: { source: "autopilot", remaining: 0 },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!ctx.hasUI) {
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: "text" as const, text: state.fallbackResponse }],
|
|
342
|
+
details: { source: "fallback", remaining: 0 },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const text = await waitForQueueInput({
|
|
347
|
+
prompt: params.prompt,
|
|
348
|
+
signal,
|
|
349
|
+
ctx,
|
|
350
|
+
fallbackResponse: state.fallbackResponse,
|
|
351
|
+
timeoutSeconds: state.waitTimeoutSeconds,
|
|
352
|
+
isWaiting: hasPendingAskUser,
|
|
353
|
+
markWaiting: (resolve) => {
|
|
354
|
+
pendingAskUserResolve = resolve;
|
|
355
|
+
updateStatus(ctx, state, true);
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const source =
|
|
360
|
+
text.source === "done" ? "done" : text.source === "timeout" ? "fallback" : "queue-live";
|
|
361
|
+
return {
|
|
362
|
+
content: [{ type: "text", text: text.value }],
|
|
363
|
+
details: { source, remaining: state.queue.length },
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
function applySessionWarnings(
|
|
369
|
+
current: QueueState,
|
|
370
|
+
ctx: { hasUI: boolean; ui: { notify: (message: string, level: "info" | "warning") => void } }
|
|
371
|
+
): QueueState {
|
|
372
|
+
let next = current;
|
|
373
|
+
const elapsedMinutes = getElapsedMinutes(next);
|
|
374
|
+
|
|
375
|
+
if (!next.warnedTime && elapsedMinutes >= next.warningMinutes) {
|
|
376
|
+
next = { ...next, warnedTime: true };
|
|
377
|
+
notify(
|
|
378
|
+
ctx,
|
|
379
|
+
`Session hygiene warning: ${formatElapsed(next)} elapsed. Consider starting a new session after 2-4 hours or around 50 tool calls.`,
|
|
380
|
+
"warning"
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!next.warnedToolCalls && next.toolCallCount >= next.warningToolCalls) {
|
|
385
|
+
next = { ...next, warnedToolCalls: true };
|
|
386
|
+
notify(
|
|
387
|
+
ctx,
|
|
388
|
+
`Session hygiene warning: ${next.toolCallCount} tool calls reached. Consider starting a new session after 2-4 hours or around 50 tool calls.`,
|
|
389
|
+
"warning"
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return next;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parsePositiveInt(raw: string): number | undefined {
|
|
398
|
+
const trimmed = raw.trim();
|
|
399
|
+
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
400
|
+
const value = Number(trimmed);
|
|
401
|
+
if (!Number.isInteger(value) || value <= 0) return undefined;
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function parseNonNegativeInt(raw: string): number | undefined {
|
|
406
|
+
const trimmed = raw.trim();
|
|
407
|
+
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
408
|
+
const value = Number(trimmed);
|
|
409
|
+
if (!Number.isInteger(value) || value < 0) return undefined;
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function buildSessionStatusText(state: QueueState): string {
|
|
414
|
+
const elapsed = formatElapsed(state);
|
|
415
|
+
return [
|
|
416
|
+
`Session status:`,
|
|
417
|
+
`- Elapsed: ${elapsed}`,
|
|
418
|
+
`- Tool calls: ${state.toolCallCount}`,
|
|
419
|
+
`- Warning thresholds: ${state.warningMinutes} minutes, ${state.warningToolCalls} tool calls`,
|
|
420
|
+
`- Wait timeout: ${state.waitTimeoutSeconds} seconds (0 = disabled)`,
|
|
421
|
+
`- Time warning emitted: ${state.warnedTime ? "yes" : "no"}`,
|
|
422
|
+
`- Tool-call warning emitted: ${state.warnedToolCalls ? "yes" : "no"}`,
|
|
423
|
+
].join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function askManuallyOrFallback(
|
|
427
|
+
prompt: string | undefined,
|
|
428
|
+
ctx: ExtensionContext,
|
|
429
|
+
fallbackResponse: string
|
|
430
|
+
) {
|
|
431
|
+
if (ctx.hasUI) {
|
|
432
|
+
const title = "Copilot Queue";
|
|
433
|
+
const question =
|
|
434
|
+
prompt?.trim() || "Agent asked for feedback. Your response (blank = fallback response):";
|
|
435
|
+
const response = await ctx.ui.input(title, question);
|
|
436
|
+
const text = response?.trim() || fallbackResponse;
|
|
437
|
+
return {
|
|
438
|
+
content: [{ type: "text" as const, text }],
|
|
439
|
+
details: { source: response?.trim() ? "manual" : "fallback", remaining: 0 },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
content: [{ type: "text" as const, text: fallbackResponse }],
|
|
445
|
+
details: { source: "fallback", remaining: 0 },
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function waitForQueueInput(options: {
|
|
450
|
+
prompt: string | undefined;
|
|
451
|
+
signal: AbortSignal | undefined;
|
|
452
|
+
ctx: { hasUI: boolean; ui: { notify: (message: string, level: "info" | "warning") => void } };
|
|
453
|
+
fallbackResponse: string;
|
|
454
|
+
timeoutSeconds: number;
|
|
455
|
+
isWaiting: () => boolean;
|
|
456
|
+
markWaiting: (resolve: (text: string) => void) => void;
|
|
457
|
+
}): Promise<{ value: string; source: "queue-live" | "done" | "timeout" }> {
|
|
458
|
+
const { prompt, signal, ctx, fallbackResponse, timeoutSeconds, isWaiting, markWaiting } = options;
|
|
459
|
+
|
|
460
|
+
if (signal?.aborted) {
|
|
461
|
+
return { value: fallbackResponse, source: "timeout" };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!isWaiting()) {
|
|
465
|
+
const question = prompt?.trim() ?? "Agent requested feedback.";
|
|
466
|
+
const timeoutText =
|
|
467
|
+
timeoutSeconds > 0
|
|
468
|
+
? ` Waiting up to ${timeoutSeconds} seconds before fallback.`
|
|
469
|
+
: " Waiting without timeout.";
|
|
470
|
+
notify(
|
|
471
|
+
ctx,
|
|
472
|
+
`Queue empty. Waiting for /copilot-queue add <message> or /copilot-queue done.${timeoutText} Prompt: ${question}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return new Promise<{ value: string; source: "queue-live" | "done" | "timeout" }>((resolve) => {
|
|
477
|
+
let settled = false;
|
|
478
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
479
|
+
|
|
480
|
+
const settle = (result: { value: string; source: "queue-live" | "done" | "timeout" }) => {
|
|
481
|
+
if (settled) return;
|
|
482
|
+
settled = true;
|
|
483
|
+
if (timer) clearTimeout(timer);
|
|
484
|
+
signal?.removeEventListener("abort", onAbort);
|
|
485
|
+
resolve(result);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const onAbort = () => settle({ value: fallbackResponse, source: "timeout" });
|
|
489
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
490
|
+
|
|
491
|
+
if (timeoutSeconds > 0) {
|
|
492
|
+
timer = setTimeout(() => {
|
|
493
|
+
notify(
|
|
494
|
+
ctx,
|
|
495
|
+
`No queued response received within ${timeoutSeconds} seconds. Returning fallback response.`,
|
|
496
|
+
"warning"
|
|
497
|
+
);
|
|
498
|
+
settle({ value: fallbackResponse, source: "timeout" });
|
|
499
|
+
}, timeoutSeconds * 1000);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
markWaiting((text) =>
|
|
503
|
+
settle({ value: text, source: text === DONE_RESPONSE ? "done" : "queue-live" })
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function initialState(): QueueState {
|
|
509
|
+
return {
|
|
510
|
+
queue: [],
|
|
511
|
+
fallbackResponse: DEFAULT_FALLBACK_RESPONSE,
|
|
512
|
+
autopilotEnabled: false,
|
|
513
|
+
autopilotPrompts: [],
|
|
514
|
+
autopilotIndex: 0,
|
|
515
|
+
sessionStartedAt: Date.now(),
|
|
516
|
+
toolCallCount: 0,
|
|
517
|
+
warningMinutes: DEFAULT_WARNING_MINUTES,
|
|
518
|
+
warningToolCalls: DEFAULT_WARNING_TOOL_CALLS,
|
|
519
|
+
waitTimeoutSeconds: DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
520
|
+
warnedTime: false,
|
|
521
|
+
warnedToolCalls: false,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function persistState(pi: ExtensionAPI, state: QueueState): void {
|
|
526
|
+
pi.appendEntry(STATE_ENTRY_TYPE, state);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function updateStatus(
|
|
530
|
+
ctx: { hasUI: boolean; ui: { setStatus: (key: string, text?: string) => void } },
|
|
531
|
+
state: QueueState,
|
|
532
|
+
waitingForQueue: boolean
|
|
533
|
+
): void {
|
|
534
|
+
if (!ctx.hasUI) return;
|
|
535
|
+
const autopilot = state.autopilotEnabled
|
|
536
|
+
? `autopilot:${state.autopilotPrompts.length}`
|
|
537
|
+
: "autopilot:off";
|
|
538
|
+
const waiting = waitingForQueue ? " | waiting:input" : "";
|
|
539
|
+
const session = `${formatElapsed(state)} · ${state.toolCallCount} tools`;
|
|
540
|
+
ctx.ui.setStatus(
|
|
541
|
+
EXTENSION_COMMAND,
|
|
542
|
+
`${EXTENSION_NAME}: ${state.queue.length} queued${waiting} | ${autopilot} | ${session}`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function restoreFromContext(ctx: Pick<ExtensionContext, "sessionManager">): QueueState {
|
|
547
|
+
const entries = ctx.sessionManager.getBranch();
|
|
548
|
+
|
|
549
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
550
|
+
const entry = entries[i];
|
|
551
|
+
if (entry?.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) continue;
|
|
552
|
+
const restored = parseQueueState(entry.data);
|
|
553
|
+
if (restored) return restored;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return initialState();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function parseQueueState(value: unknown): QueueState | undefined {
|
|
560
|
+
if (!value || typeof value !== "object") return undefined;
|
|
561
|
+
const candidate = value as {
|
|
562
|
+
queue?: unknown;
|
|
563
|
+
fallbackResponse?: unknown;
|
|
564
|
+
autopilotEnabled?: unknown;
|
|
565
|
+
autopilotPrompts?: unknown;
|
|
566
|
+
autopilotIndex?: unknown;
|
|
567
|
+
sessionStartedAt?: unknown;
|
|
568
|
+
toolCallCount?: unknown;
|
|
569
|
+
warningMinutes?: unknown;
|
|
570
|
+
warningToolCalls?: unknown;
|
|
571
|
+
waitTimeoutSeconds?: unknown;
|
|
572
|
+
warnedTime?: unknown;
|
|
573
|
+
warnedToolCalls?: unknown;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
if (
|
|
577
|
+
!Array.isArray(candidate.queue) ||
|
|
578
|
+
!candidate.queue.every((item) => typeof item === "string")
|
|
579
|
+
) {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (typeof candidate.fallbackResponse !== "string") return undefined;
|
|
584
|
+
|
|
585
|
+
const autopilotPrompts = Array.isArray(candidate.autopilotPrompts)
|
|
586
|
+
? candidate.autopilotPrompts.filter((item): item is string => typeof item === "string")
|
|
587
|
+
: [];
|
|
588
|
+
|
|
589
|
+
const autopilotEnabled =
|
|
590
|
+
typeof candidate.autopilotEnabled === "boolean" ? candidate.autopilotEnabled : false;
|
|
591
|
+
const rawIndex = typeof candidate.autopilotIndex === "number" ? candidate.autopilotIndex : 0;
|
|
592
|
+
const autopilotIndex = Number.isInteger(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
|
593
|
+
|
|
594
|
+
const rawStartedAt =
|
|
595
|
+
typeof candidate.sessionStartedAt === "number" ? candidate.sessionStartedAt : Date.now();
|
|
596
|
+
const sessionStartedAt =
|
|
597
|
+
Number.isFinite(rawStartedAt) && rawStartedAt > 0 ? rawStartedAt : Date.now();
|
|
598
|
+
|
|
599
|
+
const rawToolCallCount =
|
|
600
|
+
typeof candidate.toolCallCount === "number" ? candidate.toolCallCount : 0;
|
|
601
|
+
const toolCallCount =
|
|
602
|
+
Number.isInteger(rawToolCallCount) && rawToolCallCount >= 0 ? rawToolCallCount : 0;
|
|
603
|
+
|
|
604
|
+
const rawWarningMinutes =
|
|
605
|
+
typeof candidate.warningMinutes === "number"
|
|
606
|
+
? candidate.warningMinutes
|
|
607
|
+
: DEFAULT_WARNING_MINUTES;
|
|
608
|
+
const warningMinutes =
|
|
609
|
+
Number.isInteger(rawWarningMinutes) && rawWarningMinutes > 0
|
|
610
|
+
? rawWarningMinutes
|
|
611
|
+
: DEFAULT_WARNING_MINUTES;
|
|
612
|
+
|
|
613
|
+
const rawWarningToolCalls =
|
|
614
|
+
typeof candidate.warningToolCalls === "number"
|
|
615
|
+
? candidate.warningToolCalls
|
|
616
|
+
: DEFAULT_WARNING_TOOL_CALLS;
|
|
617
|
+
const warningToolCalls =
|
|
618
|
+
Number.isInteger(rawWarningToolCalls) && rawWarningToolCalls > 0
|
|
619
|
+
? rawWarningToolCalls
|
|
620
|
+
: DEFAULT_WARNING_TOOL_CALLS;
|
|
621
|
+
|
|
622
|
+
const rawWaitTimeoutSeconds =
|
|
623
|
+
typeof candidate.waitTimeoutSeconds === "number"
|
|
624
|
+
? candidate.waitTimeoutSeconds
|
|
625
|
+
: DEFAULT_WAIT_TIMEOUT_SECONDS;
|
|
626
|
+
const waitTimeoutSeconds =
|
|
627
|
+
Number.isInteger(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 0
|
|
628
|
+
? rawWaitTimeoutSeconds
|
|
629
|
+
: DEFAULT_WAIT_TIMEOUT_SECONDS;
|
|
630
|
+
|
|
631
|
+
const warnedTime = typeof candidate.warnedTime === "boolean" ? candidate.warnedTime : false;
|
|
632
|
+
const warnedToolCalls =
|
|
633
|
+
typeof candidate.warnedToolCalls === "boolean" ? candidate.warnedToolCalls : false;
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
queue: candidate.queue,
|
|
637
|
+
fallbackResponse: candidate.fallbackResponse,
|
|
638
|
+
autopilotEnabled,
|
|
639
|
+
autopilotPrompts,
|
|
640
|
+
autopilotIndex,
|
|
641
|
+
sessionStartedAt,
|
|
642
|
+
toolCallCount,
|
|
643
|
+
warningMinutes,
|
|
644
|
+
warningToolCalls,
|
|
645
|
+
waitTimeoutSeconds,
|
|
646
|
+
warnedTime,
|
|
647
|
+
warnedToolCalls,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function getElapsedMinutes(state: QueueState): number {
|
|
652
|
+
const elapsedMs = Math.max(0, Date.now() - state.sessionStartedAt);
|
|
653
|
+
return Math.floor(elapsedMs / 60000);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function formatElapsed(state: QueueState): string {
|
|
657
|
+
const elapsedMs = Math.max(0, Date.now() - state.sessionStartedAt);
|
|
658
|
+
const totalMinutes = Math.floor(elapsedMs / 60000);
|
|
659
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
660
|
+
const minutes = totalMinutes % 60;
|
|
661
|
+
return `${hours}h${String(minutes).padStart(2, "0")}m`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function notify(
|
|
665
|
+
ctx: { hasUI: boolean; ui: { notify: (message: string, level: "info" | "warning") => void } },
|
|
666
|
+
message: string,
|
|
667
|
+
level: "info" | "warning" = "info"
|
|
668
|
+
): void {
|
|
669
|
+
if (ctx.hasUI) {
|
|
670
|
+
ctx.ui.notify(message, level);
|
|
671
|
+
} else {
|
|
672
|
+
console.log(message);
|
|
673
|
+
}
|
|
674
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface QueueState {
|
|
2
|
+
queue: string[];
|
|
3
|
+
fallbackResponse: string;
|
|
4
|
+
autopilotEnabled: boolean;
|
|
5
|
+
autopilotPrompts: string[];
|
|
6
|
+
autopilotIndex: number;
|
|
7
|
+
sessionStartedAt: number;
|
|
8
|
+
toolCallCount: number;
|
|
9
|
+
warningMinutes: number;
|
|
10
|
+
warningToolCalls: number;
|
|
11
|
+
waitTimeoutSeconds: number;
|
|
12
|
+
warnedTime: boolean;
|
|
13
|
+
warnedToolCalls: boolean;
|
|
14
|
+
}
|