pi-ghcp-headers 0.1.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 +54 -0
- package/index.ts +559 -0
- package/package.json +38 -0
- package/tsconfig.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# pi-ghcp-headers
|
|
2
|
+
|
|
3
|
+
Pi extension that customizes GitHub Copilot `X-Initiator` behavior with first-message/follow-up percentages.
|
|
4
|
+
|
|
5
|
+
## Install (local dev)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi -e ./index.ts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install (package)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-ghcp-headers
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Config
|
|
18
|
+
|
|
19
|
+
Config is loaded from:
|
|
20
|
+
|
|
21
|
+
- Global: `~/.pi/agent/ghcp-headers.json`
|
|
22
|
+
- Project: `.pi/ghcp-headers.json`
|
|
23
|
+
|
|
24
|
+
Project config overrides global config.
|
|
25
|
+
|
|
26
|
+
### Keys
|
|
27
|
+
|
|
28
|
+
- `firstMessageAgentPercent` (default `0`)
|
|
29
|
+
- `followupMessageAgentPercent` (default `100`)
|
|
30
|
+
- `debugEnabled` (default `false`)
|
|
31
|
+
- `debugLogPath` (default `/tmp/pi-ghcp-headers-debug.log`)
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"firstMessageAgentPercent": 0,
|
|
38
|
+
"followupMessageAgentPercent": 100,
|
|
39
|
+
"debugEnabled": false,
|
|
40
|
+
"debugLogPath": "/tmp/pi-ghcp-headers-debug.log"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
- `/ghcp-headers-status`
|
|
47
|
+
- `/ghcp-headers-set <key> <value> [global|project]`
|
|
48
|
+
- `/ghcp-headers-reset [global|project]`
|
|
49
|
+
- `/ghcp-headers-debug <on|off> [global|project]`
|
|
50
|
+
|
|
51
|
+
## Notes
|
|
52
|
+
|
|
53
|
+
- Applies only to models from provider `github-copilot`.
|
|
54
|
+
- Extension overrides Copilot provider runtime behavior and injects request headers via provider stream options.
|
package/index.ts
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type Api,
|
|
6
|
+
createAssistantMessageEventStream,
|
|
7
|
+
getApiProvider,
|
|
8
|
+
getModels,
|
|
9
|
+
type Model,
|
|
10
|
+
type SimpleStreamOptions,
|
|
11
|
+
type Context,
|
|
12
|
+
type AssistantMessage,
|
|
13
|
+
type AssistantMessageEvent,
|
|
14
|
+
type AssistantMessageEventStream,
|
|
15
|
+
} from "@mariozechner/pi-ai";
|
|
16
|
+
import type {
|
|
17
|
+
ExtensionAPI,
|
|
18
|
+
ExtensionCommandContext,
|
|
19
|
+
ExtensionContext,
|
|
20
|
+
} from "@mariozechner/pi-coding-agent";
|
|
21
|
+
import { Text, type Component, matchesKey } from "@mariozechner/pi-tui";
|
|
22
|
+
|
|
23
|
+
type Scope = "global" | "project";
|
|
24
|
+
|
|
25
|
+
type HeadersConfig = {
|
|
26
|
+
firstMessageAgentPercent: number;
|
|
27
|
+
followupMessageAgentPercent: number;
|
|
28
|
+
debugEnabled: boolean;
|
|
29
|
+
debugLogPath: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type PartialHeadersConfig = Partial<HeadersConfig>;
|
|
33
|
+
|
|
34
|
+
type DecisionMode = "first" | "followup";
|
|
35
|
+
|
|
36
|
+
type LastDecision = {
|
|
37
|
+
mode: DecisionMode;
|
|
38
|
+
initiator: "agent" | "user";
|
|
39
|
+
randomPercent: number;
|
|
40
|
+
thresholdPercent: number;
|
|
41
|
+
firstMessageAgentPercent: number;
|
|
42
|
+
followupMessageAgentPercent: number;
|
|
43
|
+
messageCount: number;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULTS: HeadersConfig = {
|
|
48
|
+
firstMessageAgentPercent: 0,
|
|
49
|
+
followupMessageAgentPercent: 100,
|
|
50
|
+
debugEnabled: false,
|
|
51
|
+
debugLogPath: "/tmp/pi-ghcp-headers-debug.log",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "ghcp-headers.json");
|
|
55
|
+
const PROVIDER_ID = "github-copilot";
|
|
56
|
+
|
|
57
|
+
let lastDecision: LastDecision | undefined;
|
|
58
|
+
|
|
59
|
+
function clampPercent(value: unknown, fallback: number): number {
|
|
60
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
61
|
+
return Math.max(0, Math.min(100, value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseBoolean(value: string): boolean | undefined {
|
|
65
|
+
const v = value.trim().toLowerCase();
|
|
66
|
+
if (["1", "true", "on", "yes", "y"].includes(v)) return true;
|
|
67
|
+
if (["0", "false", "off", "no", "n"].includes(v)) return false;
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseScope(value?: string): Scope | undefined {
|
|
72
|
+
if (!value) return undefined;
|
|
73
|
+
const v = value.trim().toLowerCase();
|
|
74
|
+
if (v === "global" || v === "project") return v;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseJsonConfig(filePath: string): PartialHeadersConfig {
|
|
79
|
+
if (!fs.existsSync(filePath)) return {};
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as PartialHeadersConfig;
|
|
82
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getProjectConfigPath(cwd: string): string {
|
|
89
|
+
return path.join(cwd, ".pi", "ghcp-headers.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveConfig(cwd: string): HeadersConfig {
|
|
93
|
+
const globalCfg = parseJsonConfig(GLOBAL_CONFIG_PATH);
|
|
94
|
+
const projectCfg = parseJsonConfig(getProjectConfigPath(cwd));
|
|
95
|
+
const merged = { ...DEFAULTS, ...globalCfg, ...projectCfg };
|
|
96
|
+
return {
|
|
97
|
+
firstMessageAgentPercent: clampPercent(merged.firstMessageAgentPercent, DEFAULTS.firstMessageAgentPercent),
|
|
98
|
+
followupMessageAgentPercent: clampPercent(merged.followupMessageAgentPercent, DEFAULTS.followupMessageAgentPercent),
|
|
99
|
+
debugEnabled: Boolean(merged.debugEnabled),
|
|
100
|
+
debugLogPath: typeof merged.debugLogPath === "string" && merged.debugLogPath.trim().length > 0
|
|
101
|
+
? merged.debugLogPath
|
|
102
|
+
: DEFAULTS.debugLogPath,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeConfig(filePath: string, patch: PartialHeadersConfig): void {
|
|
107
|
+
const current = parseJsonConfig(filePath);
|
|
108
|
+
const next = { ...current, ...patch };
|
|
109
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
110
|
+
fs.writeFileSync(filePath, JSON.stringify(next, null, 2));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resetConfig(filePath: string): void {
|
|
114
|
+
if (fs.existsSync(filePath)) fs.rmSync(filePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function log(config: HeadersConfig, msg: string): void {
|
|
118
|
+
if (!config.debugEnabled) return;
|
|
119
|
+
try {
|
|
120
|
+
fs.appendFileSync(config.debugLogPath, `${new Date().toISOString()} ${msg}\n`);
|
|
121
|
+
} catch {
|
|
122
|
+
// no-op
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getPriorMessages(messages: Context["messages"]): Context["messages"] {
|
|
127
|
+
if (messages.length === 0) return messages;
|
|
128
|
+
const copy = [...messages];
|
|
129
|
+
const last = copy[copy.length - 1];
|
|
130
|
+
if (last.role === "user") {
|
|
131
|
+
copy.pop();
|
|
132
|
+
}
|
|
133
|
+
return copy;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getMessageRole(message: Context["messages"][number]): string {
|
|
137
|
+
return (message as { role: string }).role;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasAssistantOrToolHistory(messages: Context["messages"]): boolean {
|
|
141
|
+
return messages.some((m) => {
|
|
142
|
+
const role = getMessageRole(m);
|
|
143
|
+
return role === "assistant" ||
|
|
144
|
+
role === "toolResult" ||
|
|
145
|
+
// Compacted and branch-switched contexts can contain only summaries.
|
|
146
|
+
// Those are still continuations and should be treated as followups.
|
|
147
|
+
role === "compactionSummary" ||
|
|
148
|
+
role === "branchSummary";
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summarizeRoles(messages: Context["messages"]): string {
|
|
153
|
+
const counts = new Map<string, number>();
|
|
154
|
+
for (const m of messages) {
|
|
155
|
+
const role = getMessageRole(m);
|
|
156
|
+
counts.set(role, (counts.get(role) ?? 0) + 1);
|
|
157
|
+
}
|
|
158
|
+
const countSummary = [...counts.entries()]
|
|
159
|
+
.map(([role, count]) => `${role}:${count}`)
|
|
160
|
+
.join(",");
|
|
161
|
+
const tail = messages.slice(-8).map((m) => getMessageRole(m)).join(">");
|
|
162
|
+
return `count=[${countSummary}] tail=[${tail}]`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pickInitiator(agentPercent: number): { initiator: "agent" | "user"; randomPercent: number; thresholdPercent: number } {
|
|
166
|
+
const randomPercent = Math.random() * 100;
|
|
167
|
+
return {
|
|
168
|
+
initiator: randomPercent < agentPercent ? "agent" : "user",
|
|
169
|
+
randomPercent,
|
|
170
|
+
thresholdPercent: agentPercent,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createErrorAssistantMessage(model: Model<Api>, message: string): AssistantMessage {
|
|
175
|
+
return {
|
|
176
|
+
role: "assistant",
|
|
177
|
+
content: [],
|
|
178
|
+
api: model.api,
|
|
179
|
+
provider: model.provider,
|
|
180
|
+
model: model.id,
|
|
181
|
+
usage: {
|
|
182
|
+
input: 0,
|
|
183
|
+
output: 0,
|
|
184
|
+
cacheRead: 0,
|
|
185
|
+
cacheWrite: 0,
|
|
186
|
+
totalTokens: 0,
|
|
187
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
188
|
+
},
|
|
189
|
+
stopReason: "error",
|
|
190
|
+
errorMessage: message,
|
|
191
|
+
timestamp: Date.now(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createStreamWrapper(
|
|
196
|
+
cwdProvider: () => string,
|
|
197
|
+
resolveOriginalApi: (modelId: string) => Api,
|
|
198
|
+
) {
|
|
199
|
+
return (model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream => {
|
|
200
|
+
const stream = createAssistantMessageEventStream();
|
|
201
|
+
|
|
202
|
+
(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const originalApi = resolveOriginalApi(model.id);
|
|
205
|
+
const base = getApiProvider(originalApi);
|
|
206
|
+
if (!base) {
|
|
207
|
+
throw new Error(`No base API provider for ${originalApi}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const config = resolveConfig(cwdProvider());
|
|
211
|
+
const priorMessages = getPriorMessages(context.messages);
|
|
212
|
+
const isFollowup = hasAssistantOrToolHistory(priorMessages);
|
|
213
|
+
const threshold = isFollowup ? config.followupMessageAgentPercent : config.firstMessageAgentPercent;
|
|
214
|
+
const sampled = pickInitiator(threshold);
|
|
215
|
+
const mode: DecisionMode = isFollowup ? "followup" : "first";
|
|
216
|
+
|
|
217
|
+
lastDecision = {
|
|
218
|
+
mode,
|
|
219
|
+
initiator: sampled.initiator,
|
|
220
|
+
randomPercent: sampled.randomPercent,
|
|
221
|
+
thresholdPercent: sampled.thresholdPercent,
|
|
222
|
+
firstMessageAgentPercent: config.firstMessageAgentPercent,
|
|
223
|
+
followupMessageAgentPercent: config.followupMessageAgentPercent,
|
|
224
|
+
messageCount: context.messages.length,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
log(
|
|
229
|
+
config,
|
|
230
|
+
`[HEADERS] mode=${mode} X-Initiator=${sampled.initiator} random=${sampled.randomPercent.toFixed(2)} threshold=${sampled.thresholdPercent.toFixed(2)} first=${config.firstMessageAgentPercent.toFixed(2)} followup=${config.followupMessageAgentPercent.toFixed(2)} priorMessages=${priorMessages.length} ${summarizeRoles(priorMessages)}`,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const inner = base.streamSimple(
|
|
234
|
+
{
|
|
235
|
+
...(model as Model<Api>),
|
|
236
|
+
api: originalApi,
|
|
237
|
+
} as never,
|
|
238
|
+
context,
|
|
239
|
+
{
|
|
240
|
+
...options,
|
|
241
|
+
headers: {
|
|
242
|
+
...(options?.headers ?? {}),
|
|
243
|
+
"X-Initiator": sampled.initiator,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
for await (const event of inner) {
|
|
249
|
+
stream.push(event as AssistantMessageEvent);
|
|
250
|
+
if (event.type === "done" || event.type === "error") {
|
|
251
|
+
stream.end();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
stream.end();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
259
|
+
stream.push({
|
|
260
|
+
type: "error",
|
|
261
|
+
reason: "error",
|
|
262
|
+
error: createErrorAssistantMessage(model, `ghcp-headers failed: ${msg}`),
|
|
263
|
+
});
|
|
264
|
+
stream.end();
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
|
|
268
|
+
return stream;
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const OVERRIDE_API = "ghcp-headers-api" as Api;
|
|
273
|
+
|
|
274
|
+
function getCopilotModelsWithApiOverride() {
|
|
275
|
+
const sourceModels = getModels(PROVIDER_ID);
|
|
276
|
+
const apiByModelId = new Map<string, Api>();
|
|
277
|
+
|
|
278
|
+
const overriddenModels = sourceModels.map((m) => {
|
|
279
|
+
apiByModelId.set(m.id, m.api);
|
|
280
|
+
return {
|
|
281
|
+
id: m.id,
|
|
282
|
+
name: m.name,
|
|
283
|
+
api: OVERRIDE_API,
|
|
284
|
+
reasoning: m.reasoning,
|
|
285
|
+
input: m.input,
|
|
286
|
+
cost: m.cost,
|
|
287
|
+
contextWindow: m.contextWindow,
|
|
288
|
+
maxTokens: m.maxTokens,
|
|
289
|
+
headers: m.headers,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return { overriddenModels, apiByModelId };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function statusText(cwd: string): string {
|
|
297
|
+
const cfg = resolveConfig(cwd);
|
|
298
|
+
const globalPath = GLOBAL_CONFIG_PATH;
|
|
299
|
+
const projectPath = getProjectConfigPath(cwd);
|
|
300
|
+
const globalState = fs.existsSync(globalPath) ? "found" : "not found";
|
|
301
|
+
const projectState = fs.existsSync(projectPath) ? "found" : "not found";
|
|
302
|
+
|
|
303
|
+
const parts = [
|
|
304
|
+
`config(global): ${globalPath} (${globalState})`,
|
|
305
|
+
`config(project): ${projectPath} (${projectState})`,
|
|
306
|
+
`firstMessageAgentPercent=${cfg.firstMessageAgentPercent}`,
|
|
307
|
+
`followupMessageAgentPercent=${cfg.followupMessageAgentPercent}`,
|
|
308
|
+
`debugEnabled=${cfg.debugEnabled}`,
|
|
309
|
+
`debugLogPath=${cfg.debugLogPath}`,
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
if (lastDecision) {
|
|
313
|
+
parts.push(
|
|
314
|
+
`lastDecision: mode=${lastDecision.mode} initiator=${lastDecision.initiator} random=${lastDecision.randomPercent.toFixed(2)} threshold=${lastDecision.thresholdPercent.toFixed(2)} at=${new Date(lastDecision.timestamp).toISOString()}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return parts.join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function targetConfigPath(cwd: string, scope?: Scope): string {
|
|
322
|
+
return (scope ?? "global") === "project" ? getProjectConfigPath(cwd) : GLOBAL_CONFIG_PATH;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function registerCommands(pi: ExtensionAPI, cwdProvider: () => string) {
|
|
326
|
+
pi.registerCommand("ghcp-headers-status", {
|
|
327
|
+
description: "Show ghcp-headers config and latest decision",
|
|
328
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
329
|
+
const cwd = cwdProvider();
|
|
330
|
+
const cfg = resolveConfig(cwd);
|
|
331
|
+
const globalPath = GLOBAL_CONFIG_PATH;
|
|
332
|
+
const projectPath = getProjectConfigPath(cwd);
|
|
333
|
+
const globalExists = fs.existsSync(globalPath);
|
|
334
|
+
const projectExists = fs.existsSync(projectPath);
|
|
335
|
+
|
|
336
|
+
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
|
|
337
|
+
const globalLineRaw = `config(global): ${globalPath} (${globalExists ? "found" : "not found"})`;
|
|
338
|
+
const projectLineRaw = `config(project): ${projectPath} (${projectExists ? "found" : "not found"})`;
|
|
339
|
+
|
|
340
|
+
const globalLine = globalExists ? globalLineRaw : theme.fg("muted", globalLineRaw);
|
|
341
|
+
const projectLine = projectExists ? projectLineRaw : theme.fg("muted", projectLineRaw);
|
|
342
|
+
|
|
343
|
+
const lines = [
|
|
344
|
+
theme.fg("toolTitle", theme.bold("ghcp-headers status")),
|
|
345
|
+
"",
|
|
346
|
+
globalLine,
|
|
347
|
+
projectLine,
|
|
348
|
+
`firstMessageAgentPercent=${cfg.firstMessageAgentPercent}`,
|
|
349
|
+
`followupMessageAgentPercent=${cfg.followupMessageAgentPercent}`,
|
|
350
|
+
`debugEnabled=${cfg.debugEnabled}`,
|
|
351
|
+
`debugLogPath=${cfg.debugLogPath}`,
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
if (lastDecision) {
|
|
355
|
+
lines.push(
|
|
356
|
+
`lastDecision: mode=${lastDecision.mode} initiator=${lastDecision.initiator} random=${lastDecision.randomPercent.toFixed(2)} threshold=${lastDecision.thresholdPercent.toFixed(2)} at=${new Date(lastDecision.timestamp).toISOString()}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
lines.push("", theme.fg("muted", "[Esc or Enter to close]"));
|
|
361
|
+
|
|
362
|
+
const text = new Text(lines.join("\n"), 1, 1);
|
|
363
|
+
|
|
364
|
+
const component: Component = {
|
|
365
|
+
render: (width: number) => text.render(width),
|
|
366
|
+
invalidate: () => text.invalidate(),
|
|
367
|
+
handleInput: (data: string) => {
|
|
368
|
+
if (matchesKey(data, "escape") || matchesKey(data, "return")) {
|
|
369
|
+
done();
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return component;
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
pi.registerCommand("ghcp-headers-set", {
|
|
380
|
+
description: "Interactively edit config (or: /ghcp-headers-set <key> <value> [global|project])",
|
|
381
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
382
|
+
const applySetting = (key: keyof HeadersConfig, valueRaw: string, scope?: Scope): boolean => {
|
|
383
|
+
const patch: PartialHeadersConfig = {};
|
|
384
|
+
|
|
385
|
+
if (key === "firstMessageAgentPercent" || key === "followupMessageAgentPercent") {
|
|
386
|
+
const num = Number(valueRaw);
|
|
387
|
+
if (!Number.isFinite(num)) {
|
|
388
|
+
ctx.ui.notify("Value must be a number between 0 and 100", "error");
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
patch[key] = clampPercent(num, 0);
|
|
392
|
+
} else if (key === "debugEnabled") {
|
|
393
|
+
const bool = parseBoolean(valueRaw);
|
|
394
|
+
if (bool === undefined) {
|
|
395
|
+
ctx.ui.notify("debugEnabled must be on/off, true/false, 1/0", "error");
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
patch.debugEnabled = bool;
|
|
399
|
+
} else if (key === "debugLogPath") {
|
|
400
|
+
patch.debugLogPath = valueRaw;
|
|
401
|
+
} else {
|
|
402
|
+
ctx.ui.notify("Unknown key", "error");
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const file = targetConfigPath(cwdProvider(), scope);
|
|
407
|
+
writeConfig(file, patch);
|
|
408
|
+
ctx.ui.notify(`Updated ${key} in ${file}`, "info");
|
|
409
|
+
return true;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const trimmed = args.trim();
|
|
413
|
+
|
|
414
|
+
// Backwards-compatible non-interactive mode
|
|
415
|
+
if (trimmed.length > 0) {
|
|
416
|
+
const [keyRaw, valueRaw, scopeRaw] = trimmed.split(/\s+/, 3);
|
|
417
|
+
if (!keyRaw || valueRaw === undefined) {
|
|
418
|
+
ctx.ui.notify("Usage: /ghcp-headers-set <key> <value> [global|project]", "warning");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const key = keyRaw as keyof HeadersConfig;
|
|
423
|
+
const scope = parseScope(scopeRaw);
|
|
424
|
+
if (scopeRaw && !scope) {
|
|
425
|
+
ctx.ui.notify("Scope must be global or project", "error");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
applySetting(key, valueRaw, scope);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Interactive mode
|
|
434
|
+
const scopeChoice = await ctx.ui.select("Select config scope", [
|
|
435
|
+
`global (${GLOBAL_CONFIG_PATH})`,
|
|
436
|
+
`project (${getProjectConfigPath(cwdProvider())})`,
|
|
437
|
+
]);
|
|
438
|
+
if (!scopeChoice) return;
|
|
439
|
+
const scope: Scope = scopeChoice.startsWith("project") ? "project" : "global";
|
|
440
|
+
|
|
441
|
+
while (true) {
|
|
442
|
+
const cfg = resolveConfig(cwdProvider());
|
|
443
|
+
const keyChoice = await ctx.ui.select("Select setting to change (Esc to finish)", [
|
|
444
|
+
`firstMessageAgentPercent (current: ${cfg.firstMessageAgentPercent})`,
|
|
445
|
+
`followupMessageAgentPercent (current: ${cfg.followupMessageAgentPercent})`,
|
|
446
|
+
`debugEnabled (current: ${cfg.debugEnabled})`,
|
|
447
|
+
`debugLogPath (current: ${cfg.debugLogPath})`,
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
// Escape/cancel exits interactive editor
|
|
451
|
+
if (!keyChoice) {
|
|
452
|
+
ctx.ui.notify("ghcp-headers-set: done", "info");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const key = keyChoice.split(" ")[0] as keyof HeadersConfig;
|
|
457
|
+
|
|
458
|
+
if (key === "debugEnabled") {
|
|
459
|
+
const boolChoice = await ctx.ui.select("Set debugEnabled", ["on", "off"]);
|
|
460
|
+
if (!boolChoice) continue;
|
|
461
|
+
applySetting(key, boolChoice, scope);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const input = await ctx.ui.input(`New value for ${key}:`);
|
|
466
|
+
if (input === undefined || input.trim().length === 0) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
applySetting(key, input.trim(), scope);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
pi.registerCommand("ghcp-headers-reset", {
|
|
476
|
+
description: "Reset config file: /ghcp-headers-reset [global|project]",
|
|
477
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
478
|
+
const scope = parseScope(args.trim() || "global");
|
|
479
|
+
if (args.trim() && !scope) {
|
|
480
|
+
ctx.ui.notify("Scope must be global or project", "error");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const file = targetConfigPath(cwdProvider(), scope);
|
|
484
|
+
resetConfig(file);
|
|
485
|
+
ctx.ui.notify(`Reset ${file}`, "info");
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
pi.registerCommand("ghcp-headers-debug", {
|
|
490
|
+
description: "Toggle debug: /ghcp-headers-debug <on|off> [global|project]",
|
|
491
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
492
|
+
const [valueRaw, scopeRaw] = args.trim().split(/\s+/, 2);
|
|
493
|
+
const bool = parseBoolean(valueRaw || "");
|
|
494
|
+
if (bool === undefined) {
|
|
495
|
+
ctx.ui.notify("Usage: /ghcp-headers-debug <on|off> [global|project]", "warning");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const scope = parseScope(scopeRaw);
|
|
499
|
+
if (scopeRaw && !scope) {
|
|
500
|
+
ctx.ui.notify("Scope must be global or project", "error");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const file = targetConfigPath(cwdProvider(), scope);
|
|
504
|
+
writeConfig(file, { debugEnabled: bool });
|
|
505
|
+
ctx.ui.notify(`debugEnabled=${bool} in ${file}`, "info");
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export default function ghcpHeadersExtension(pi: ExtensionAPI) {
|
|
511
|
+
let currentCwd = process.cwd();
|
|
512
|
+
let providerRegistered = false;
|
|
513
|
+
const cwdProvider = () => currentCwd;
|
|
514
|
+
|
|
515
|
+
const tryRegisterProvider = () => {
|
|
516
|
+
const { overriddenModels, apiByModelId } = getCopilotModelsWithApiOverride();
|
|
517
|
+
if (overriddenModels.length === 0) {
|
|
518
|
+
const cfg = resolveConfig(cwdProvider());
|
|
519
|
+
log(cfg, "[INIT] github-copilot models unavailable; provider not registered yet");
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const resolveOriginalApi = (modelId: string): Api =>
|
|
524
|
+
apiByModelId.get(modelId) ?? "openai-completions";
|
|
525
|
+
|
|
526
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
527
|
+
baseUrl: getModels(PROVIDER_ID)[0]?.baseUrl ?? "https://api.individual.githubcopilot.com",
|
|
528
|
+
apiKey: "GITHUB_COPILOT_TOKEN",
|
|
529
|
+
api: OVERRIDE_API,
|
|
530
|
+
streamSimple: createStreamWrapper(cwdProvider, resolveOriginalApi),
|
|
531
|
+
models: overriddenModels,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
providerRegistered = true;
|
|
535
|
+
const cfg = resolveConfig(cwdProvider());
|
|
536
|
+
log(cfg, `[INIT] provider registered with ${overriddenModels.length} copilot model(s), api=${OVERRIDE_API}`);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
registerCommands(pi, cwdProvider);
|
|
540
|
+
tryRegisterProvider();
|
|
541
|
+
|
|
542
|
+
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
543
|
+
currentCwd = ctx.cwd;
|
|
544
|
+
const cfg = resolveConfig(cwdProvider());
|
|
545
|
+
log(cfg, `[SESSION_START] cwd=${currentCwd}`);
|
|
546
|
+
if (!providerRegistered) {
|
|
547
|
+
tryRegisterProvider();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
pi.on("session_switch", (_event: unknown, ctx: ExtensionContext) => {
|
|
552
|
+
currentCwd = ctx.cwd;
|
|
553
|
+
const cfg = resolveConfig(cwdProvider());
|
|
554
|
+
log(cfg, `[SESSION_SWITCH] cwd=${currentCwd}`);
|
|
555
|
+
if (!providerRegistered) {
|
|
556
|
+
tryRegisterProvider();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-ghcp-headers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension to customize GitHub Copilot X-Initiator header behavior",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"extension",
|
|
12
|
+
"github-copilot",
|
|
13
|
+
"x-initiator"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"tsconfig.json"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"type-check": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-ai": "*",
|
|
30
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@mariozechner/pi-ai": "^0.55.3",
|
|
34
|
+
"@mariozechner/pi-coding-agent": "^0.55.3",
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"typescript": "^5.9.3"
|
|
37
|
+
}
|
|
38
|
+
}
|