@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11
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/CHANGELOG.md +58 -0
- package/dist/cli.js +3402 -3443
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +52 -0
- package/dist/types/collab/host.d.ts +2 -2
- package/dist/types/collab/protocol.d.ts +4 -5
- package/dist/types/commit/agentic/agent.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +11 -2
- package/dist/types/config/settings-schema.d.ts +12 -6
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +6 -1
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/registry/agent-registry.d.ts +10 -3
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/compact-modes.d.ts +60 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- package/dist/types/tools/index.d.ts +9 -1
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/dist/types/utils/qrcode.d.ts +48 -0
- package/package.json +12 -12
- package/src/advisor/index.ts +1 -0
- package/src/advisor/transcript-recorder.ts +136 -0
- package/src/cli/args.ts +7 -1
- package/src/cli/stats-cli.ts +2 -11
- package/src/collab/host.ts +29 -17
- package/src/collab/protocol.ts +48 -15
- package/src/commit/agentic/agent.ts +2 -1
- package/src/commit/agentic/tools/git-file-diff.ts +2 -2
- package/src/commit/changelog/index.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/utils.ts +1 -1
- package/src/config/config-file.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-registry.ts +16 -4
- package/src/config/model-resolver.ts +193 -35
- package/src/config/settings-schema.ts +14 -7
- package/src/config/settings.ts +3 -9
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/eval/js/tool-bridge.ts +3 -2
- package/src/eval/py/prelude.py +3 -2
- package/src/export/html/tool-views.generated.js +28 -28
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/history-protocol.ts +8 -3
- package/src/irc/bus.ts +8 -0
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +6 -3
- package/src/modes/acp/acp-agent.ts +63 -0
- package/src/modes/components/agent-hub.ts +97 -920
- package/src/modes/components/agent-transcript-viewer.ts +461 -0
- package/src/modes/components/chat-transcript-builder.ts +462 -0
- package/src/modes/components/diff.ts +12 -35
- package/src/modes/components/oauth-selector.ts +31 -2
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/types.ts +2 -1
- package/src/prompts/tools/inspect-image.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +27 -8
- package/src/session/agent-session.ts +185 -17
- package/src/session/compact-modes.ts +105 -0
- package/src/session/session-dump-format.ts +1 -1
- package/src/session/session-history-format.ts +1 -1
- package/src/session/streaming-output.ts +5 -5
- package/src/slash-commands/builtin-registry.ts +45 -15
- package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
- package/src/task/executor.ts +1 -1
- package/src/task/output-manager.ts +5 -0
- package/src/thinking.ts +25 -5
- package/src/tools/__tests__/json-tree.test.ts +35 -0
- package/src/tools/approval.ts +1 -1
- package/src/tools/bash.ts +0 -1
- package/src/tools/browser.ts +0 -1
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh.ts +1 -1
- package/src/tools/index.ts +10 -1
- package/src/tools/inspect-image.ts +72 -9
- package/src/tools/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- package/src/utils/file-mentions.ts +5 -2
- package/src/utils/image-loading.ts +58 -0
- package/src/utils/qrcode.ts +535 -0
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/github.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/metacpan.ts +2 -2
- package/src/web/scrapers/nvd.ts +2 -2
- package/src/web/scrapers/ollama.ts +1 -1
- package/src/web/scrapers/opencorporates.ts +1 -1
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/repology.ts +1 -1
- package/src/web/scrapers/sourcegraph.ts +1 -1
- package/src/web/scrapers/terraform.ts +6 -6
- package/src/web/scrapers/wikidata.ts +2 -2
- package/src/workspace-tree.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.11",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
49
49
|
"@babel/parser": "^7.29.7",
|
|
50
50
|
"@mozilla/readability": "^0.6.0",
|
|
51
|
-
"@oh-my-pi/hashline": "16.0.
|
|
52
|
-
"@oh-my-pi/omp-stats": "16.0.
|
|
53
|
-
"@oh-my-pi/pi-agent-core": "16.0.
|
|
54
|
-
"@oh-my-pi/pi-ai": "16.0.
|
|
55
|
-
"@oh-my-pi/pi-catalog": "16.0.
|
|
56
|
-
"@oh-my-pi/pi-mnemopi": "16.0.
|
|
57
|
-
"@oh-my-pi/pi-natives": "16.0.
|
|
58
|
-
"@oh-my-pi/pi-tui": "16.0.
|
|
59
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
60
|
-
"@oh-my-pi/pi-wire": "16.0.
|
|
61
|
-
"@oh-my-pi/snapcompact": "16.0.
|
|
51
|
+
"@oh-my-pi/hashline": "16.0.11",
|
|
52
|
+
"@oh-my-pi/omp-stats": "16.0.11",
|
|
53
|
+
"@oh-my-pi/pi-agent-core": "16.0.11",
|
|
54
|
+
"@oh-my-pi/pi-ai": "16.0.11",
|
|
55
|
+
"@oh-my-pi/pi-catalog": "16.0.11",
|
|
56
|
+
"@oh-my-pi/pi-mnemopi": "16.0.11",
|
|
57
|
+
"@oh-my-pi/pi-natives": "16.0.11",
|
|
58
|
+
"@oh-my-pi/pi-tui": "16.0.11",
|
|
59
|
+
"@oh-my-pi/pi-utils": "16.0.11",
|
|
60
|
+
"@oh-my-pi/pi-wire": "16.0.11",
|
|
61
|
+
"@oh-my-pi/snapcompact": "16.0.11",
|
|
62
62
|
"@opentelemetry/api": "^1.9.1",
|
|
63
63
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
64
64
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
package/src/advisor/index.ts
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { Message, UserMessage } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { SessionManager } from "../session/session-manager";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reserved transcript stem for advisor session files. Chosen so it cannot
|
|
9
|
+
* collide with a task subagent's `<id>.jsonl` (task ids are reserved against
|
|
10
|
+
* this exact stem in {@link AgentOutputManager}).
|
|
11
|
+
*/
|
|
12
|
+
export const ADVISOR_TRANSCRIPT_STEM = "__advisor";
|
|
13
|
+
export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`;
|
|
14
|
+
|
|
15
|
+
const JSONL_SUFFIX = ".jsonl";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Append-only persister for an advisor agent's transcript.
|
|
19
|
+
*
|
|
20
|
+
* The advisor is a passive reviewer with its own model usage, so — like a task
|
|
21
|
+
* subagent — its turns are written to a JSONL inside the owning session's
|
|
22
|
+
* artifacts dir (`<session>/__advisor.jsonl`, `<session>/<SubId>/__advisor.jsonl`
|
|
23
|
+
* for subagent advisors). That single file gives the advisor model proper usage
|
|
24
|
+
* attribution in `omp stats` (the stats parser scans the session dir
|
|
25
|
+
* recursively) and a read-only transcript in the Agent Hub, without making the
|
|
26
|
+
* advisor a registered, messageable peer.
|
|
27
|
+
*
|
|
28
|
+
* The target is derived from the *session file* (`getSessionFile()`), never
|
|
29
|
+
* `getArtifactsDir()` — subagents adopt the parent's artifact manager, so the
|
|
30
|
+
* artifacts dir points at the parent root and every subagent advisor would
|
|
31
|
+
* collide. The file path is resolved synchronously when a message finalizes and
|
|
32
|
+
* captured for the queued write, so a `/new`, resume, or session switch in
|
|
33
|
+
* flight can never misattribute an old advisor turn into the new session's file.
|
|
34
|
+
* On such a switch the previous writer is closed and the new file opened on the
|
|
35
|
+
* next recorded turn. The recorder never truncates: the advisor's in-memory
|
|
36
|
+
* context resets/compacts independently, but every billed turn is appended here.
|
|
37
|
+
*/
|
|
38
|
+
export class AdvisorTranscriptRecorder {
|
|
39
|
+
#manager: SessionManager | undefined;
|
|
40
|
+
#file: string | undefined;
|
|
41
|
+
/** Serializes the async open/close against synchronous appends so records land in order. */
|
|
42
|
+
#queue: Promise<void>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param after Optional barrier the queue starts behind — used on the advisor
|
|
46
|
+
* on→off→on toggle so a fresh recorder's first `open` waits for the prior
|
|
47
|
+
* recorder's `close` and the two never hold the same `__advisor.jsonl` at once.
|
|
48
|
+
*/
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly resolveSessionFile: () => string | undefined,
|
|
51
|
+
private readonly resolveCwd: () => string,
|
|
52
|
+
after?: Promise<unknown>,
|
|
53
|
+
) {
|
|
54
|
+
this.#queue = after
|
|
55
|
+
? after.then(
|
|
56
|
+
() => {},
|
|
57
|
+
() => {},
|
|
58
|
+
)
|
|
59
|
+
: Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Persist one finalized advisor message. Assistant turns carry the usage the
|
|
64
|
+
* stats parser reads; tool results round out the Hub transcript; user deltas
|
|
65
|
+
* (the advisor's "session update" prompts) are persisted but flagged
|
|
66
|
+
* `synthetic`/agent-attributed so they never inflate user-message metrics.
|
|
67
|
+
* Non-conversational message kinds are skipped.
|
|
68
|
+
*/
|
|
69
|
+
record(message: AgentMessage): void {
|
|
70
|
+
let persisted: Message;
|
|
71
|
+
switch (message.role) {
|
|
72
|
+
case "assistant":
|
|
73
|
+
case "toolResult":
|
|
74
|
+
persisted = message;
|
|
75
|
+
break;
|
|
76
|
+
case "user":
|
|
77
|
+
// Clone so the live advisor message stays untouched; mark synthetic so
|
|
78
|
+
// stats' user-message metrics skip these agent-internal review prompts.
|
|
79
|
+
persisted = { ...(message as UserMessage), synthetic: true, attribution: "agent" };
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const sessionFile = this.resolveSessionFile();
|
|
85
|
+
if (!sessionFile?.endsWith(JSONL_SUFFIX)) return;
|
|
86
|
+
const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME);
|
|
87
|
+
const cwd = this.resolveCwd();
|
|
88
|
+
this.#enqueue(async () => {
|
|
89
|
+
if (file !== this.#file) {
|
|
90
|
+
await this.#closeManager();
|
|
91
|
+
this.#manager = await SessionManager.open(file, undefined, undefined, {
|
|
92
|
+
initialCwd: cwd,
|
|
93
|
+
suppressBreadcrumb: true,
|
|
94
|
+
});
|
|
95
|
+
this.#file = file;
|
|
96
|
+
}
|
|
97
|
+
this.#manager?.appendMessage(persisted);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Flush pending writes (best-effort). */
|
|
102
|
+
flush(): Promise<void> {
|
|
103
|
+
return this.#enqueueResult(async () => {
|
|
104
|
+
if (this.#manager) await this.#manager.flush();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Flush and close the writer, releasing the session file. */
|
|
109
|
+
close(): Promise<void> {
|
|
110
|
+
return this.#enqueueResult(() => this.#closeManager());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async #closeManager(): Promise<void> {
|
|
114
|
+
const manager = this.#manager;
|
|
115
|
+
this.#manager = undefined;
|
|
116
|
+
this.#file = undefined;
|
|
117
|
+
if (!manager) return;
|
|
118
|
+
try {
|
|
119
|
+
await manager.close();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger.debug("advisor transcript close failed", { err: String(err) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#enqueue(work: () => Promise<void>): void {
|
|
126
|
+
this.#queue = this.#queue.then(work, work).catch(err => {
|
|
127
|
+
logger.debug("advisor transcript record failed", { err: String(err) });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#enqueueResult(work: () => Promise<void>): Promise<void> {
|
|
132
|
+
const next = this.#queue.then(work, work);
|
|
133
|
+
this.#queue = next.catch(() => {});
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/cli/args.ts
CHANGED
|
@@ -214,7 +214,13 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
|
|
214
214
|
} else if (arg === "--auto-approve" || arg === "--yolo") {
|
|
215
215
|
result.autoApprove = true;
|
|
216
216
|
} else if (arg.startsWith("@")) {
|
|
217
|
-
|
|
217
|
+
let filePath = arg.slice(1);
|
|
218
|
+
if (filePath.startsWith('"') && filePath.endsWith('"') && filePath.length > 1) {
|
|
219
|
+
filePath = filePath.slice(1, -1);
|
|
220
|
+
} else if (filePath.startsWith("'") && filePath.endsWith("'") && filePath.length > 1) {
|
|
221
|
+
filePath = filePath.slice(1, -1);
|
|
222
|
+
}
|
|
223
|
+
result.fileArgs.push(filePath);
|
|
218
224
|
} else if (!arg.startsWith("-") || arg === "-") {
|
|
219
225
|
// Plain positional or lone `-` (stdin marker) — pass through as a
|
|
220
226
|
// message rather than flagging it.
|
package/src/cli/stats-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Handles `omp stats` subcommand for viewing AI usage statistics.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
|
|
7
8
|
import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
|
|
8
9
|
import chalk from "chalk";
|
|
9
10
|
import { openPath } from "../utils/open";
|
|
@@ -32,7 +33,7 @@ function createSyncProgressReporter(): {
|
|
|
32
33
|
const counter = chalk.cyan(`[${event.current}/${event.total}]`);
|
|
33
34
|
const line = `${counter} ${pct}% ${label}`;
|
|
34
35
|
const columns = stream.columns ?? 120;
|
|
35
|
-
const trimmed =
|
|
36
|
+
const trimmed = truncateToWidth(line, columns - 1);
|
|
36
37
|
stream.write(`\r${trimmed.padEnd(lastWidth)}`);
|
|
37
38
|
lastWidth = trimmed.length;
|
|
38
39
|
},
|
|
@@ -50,16 +51,6 @@ function shortenSessionFile(p: string): string {
|
|
|
50
51
|
return idx >= 0 ? p.slice(idx + marker.length) : p;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
function truncateToColumns(s: string, max: number): string {
|
|
54
|
-
if (max <= 0) return "";
|
|
55
|
-
const width = Bun.stringWidth(s, { countAnsiEscapeCodes: false });
|
|
56
|
-
if (width <= max) return s;
|
|
57
|
-
// Cheap right-trim with an ellipsis - we don't need ANSI-aware slicing
|
|
58
|
-
// because the colored prefix is short and the truncated tail is the
|
|
59
|
-
// dim filename, where dropping bytes is fine.
|
|
60
|
-
return `${s.slice(0, Math.max(0, max - 1))}\u2026`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
54
|
// =============================================================================
|
|
64
55
|
// Types
|
|
65
56
|
// =============================================================================
|
package/src/collab/host.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
17
17
|
import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
|
|
18
18
|
import type { InteractiveModeContext } from "../modes/types";
|
|
19
19
|
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
|
|
20
|
-
import { AgentRegistry } from "../registry/agent-registry";
|
|
20
|
+
import { type AgentRef, AgentRegistry } from "../registry/agent-registry";
|
|
21
21
|
import type { AgentSessionEvent } from "../session/agent-session";
|
|
22
22
|
import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
|
|
23
23
|
import type { SessionEntry as StoredSessionEntry } from "../session/session-entries";
|
|
@@ -133,7 +133,7 @@ export class CollabHost {
|
|
|
133
133
|
return this.#link;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/** Browser deep link
|
|
136
|
+
/** Browser deep link for the configured collab web UI. */
|
|
137
137
|
get webLink(): string {
|
|
138
138
|
return this.#webLink;
|
|
139
139
|
}
|
|
@@ -156,15 +156,15 @@ export class CollabHost {
|
|
|
156
156
|
return list;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
async start(relayUrl: string): Promise<void> {
|
|
159
|
+
async start(relayUrl: string, webUrl = ""): Promise<void> {
|
|
160
160
|
const rawKey = generateRoomKey();
|
|
161
161
|
const writeToken = generateWriteToken();
|
|
162
162
|
const roomId = generateRoomId();
|
|
163
163
|
this.#writeToken = writeToken;
|
|
164
164
|
this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
|
|
165
|
-
this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken);
|
|
165
|
+
this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl);
|
|
166
166
|
this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey);
|
|
167
|
-
this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey);
|
|
167
|
+
this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl);
|
|
168
168
|
const parsed = parseCollabLink(this.#link);
|
|
169
169
|
if ("error" in parsed) throw new Error(parsed.error);
|
|
170
170
|
const key = await importRoomKey(rawKey);
|
|
@@ -445,18 +445,24 @@ export class CollabHost {
|
|
|
445
445
|
}
|
|
446
446
|
|
|
447
447
|
#snapshotAgents(): AgentSnapshot[] {
|
|
448
|
-
return
|
|
449
|
-
.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
448
|
+
return (
|
|
449
|
+
AgentRegistry.global()
|
|
450
|
+
.list()
|
|
451
|
+
// Advisor transcripts are local observability only; never mirror them to
|
|
452
|
+
// guests (the wire AgentSnapshot kind has no `advisor`, and guests must not
|
|
453
|
+
// be able to chat/kill/revive them).
|
|
454
|
+
.filter((ref): ref is AgentRef & { kind: "main" | "sub" } => ref.kind !== "advisor")
|
|
455
|
+
.map(ref => ({
|
|
456
|
+
id: ref.id,
|
|
457
|
+
displayName: ref.displayName,
|
|
458
|
+
kind: ref.kind,
|
|
459
|
+
parentId: ref.parentId,
|
|
460
|
+
status: ref.status,
|
|
461
|
+
hasSessionFile: !!ref.sessionFile,
|
|
462
|
+
createdAt: ref.createdAt,
|
|
463
|
+
lastActivity: ref.lastActivity,
|
|
464
|
+
}))
|
|
465
|
+
);
|
|
460
466
|
}
|
|
461
467
|
|
|
462
468
|
#scheduleAgentsBroadcast(): void {
|
|
@@ -472,6 +478,12 @@ export class CollabHost {
|
|
|
472
478
|
this.#rejectReadOnly("agent control", fromPeer);
|
|
473
479
|
return;
|
|
474
480
|
}
|
|
481
|
+
// Advisor refs are excluded from snapshots, but reject control by id defensively:
|
|
482
|
+
// a stale/malicious client must never chat/kill/revive a read-only advisor transcript.
|
|
483
|
+
if (AgentRegistry.global().get(agentId)?.kind === "advisor") {
|
|
484
|
+
this.#socket?.send({ t: "error", message: `agent ${agentId}: advisor transcripts are read-only` }, fromPeer);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
475
487
|
const fail = (err: unknown) => {
|
|
476
488
|
logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) });
|
|
477
489
|
this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer);
|
package/src/collab/protocol.ts
CHANGED
|
@@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/;
|
|
|
116
116
|
const B64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
117
117
|
const LOCAL_HOSTNAMES: Record<string, true> = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true };
|
|
118
118
|
|
|
119
|
+
function isLocalHostname(hostname: string): boolean {
|
|
120
|
+
return LOCAL_HOSTNAMES[hostname] === true;
|
|
121
|
+
}
|
|
122
|
+
|
|
119
123
|
export function generateRoomId(): string {
|
|
120
124
|
const bytes = new Uint8Array(ROOM_ID_BYTES);
|
|
121
125
|
crypto.getRandomValues(bytes);
|
|
@@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s
|
|
|
143
147
|
default:
|
|
144
148
|
return { error: `Unsupported relay URL scheme: ${url.protocol}` };
|
|
145
149
|
}
|
|
146
|
-
if (scheme === "ws:" && !
|
|
150
|
+
if (scheme === "ws:" && !isLocalHostname(url.hostname)) {
|
|
147
151
|
return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
|
|
148
152
|
}
|
|
149
153
|
const port = url.port ? `:${url.port}` : "";
|
|
@@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr
|
|
|
178
182
|
return `${compact}/r/${roomId}.${keyText}`;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string {
|
|
186
|
+
const explicitWebUrl = webUrl?.trim();
|
|
187
|
+
if (!explicitWebUrl) {
|
|
188
|
+
const normalized = normalizeRelayOrigin(relayUrl);
|
|
189
|
+
if ("error" in normalized) throw new Error(normalized.error);
|
|
190
|
+
return normalized.origin.startsWith("wss://")
|
|
191
|
+
? `https://${normalized.origin.slice("wss://".length)}`
|
|
192
|
+
: `http://${normalized.origin.slice("ws://".length)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let url: URL;
|
|
196
|
+
try {
|
|
197
|
+
url = new URL(explicitWebUrl);
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error("collab.webUrl must start with http:// or https://");
|
|
200
|
+
}
|
|
201
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
202
|
+
throw new Error("collab.webUrl must start with http:// or https://");
|
|
203
|
+
}
|
|
204
|
+
if (url.protocol === "http:" && !isLocalHostname(url.hostname)) {
|
|
205
|
+
throw new Error("collab.webUrl must use https:// unless it targets localhost");
|
|
206
|
+
}
|
|
207
|
+
if (url.search || url.hash) {
|
|
208
|
+
throw new Error("collab.webUrl must not include a query string or fragment");
|
|
209
|
+
}
|
|
210
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
211
|
+
return `${url.origin}${path}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
181
214
|
/**
|
|
182
|
-
* Render the browser deep link
|
|
183
|
-
* relay
|
|
184
|
-
* room
|
|
185
|
-
* Terminals auto-link the https form, making it click-to-join.
|
|
215
|
+
* Render the browser deep link. The browser UI may be hosted separately from
|
|
216
|
+
* the relay; the fragment always carries the relay-specific collab link, so
|
|
217
|
+
* room secrets stay out of HTTP path and query bytes.
|
|
186
218
|
*/
|
|
187
219
|
export function formatCollabWebLink(
|
|
188
220
|
relayUrl: string,
|
|
189
221
|
roomId: string,
|
|
190
222
|
key: Uint8Array,
|
|
191
223
|
writeToken?: Uint8Array,
|
|
224
|
+
webUrl?: string,
|
|
192
225
|
): string {
|
|
193
|
-
|
|
194
|
-
if ("error" in normalized) throw new Error(normalized.error);
|
|
195
|
-
const httpOrigin = normalized.origin.startsWith("wss://")
|
|
196
|
-
? `https://${normalized.origin.slice("wss://".length)}`
|
|
197
|
-
: `http://${normalized.origin.slice("ws://".length)}`;
|
|
198
|
-
return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
|
|
226
|
+
return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
|
|
199
227
|
}
|
|
200
228
|
|
|
201
229
|
export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
|
|
@@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
|
|
|
213
241
|
} catch {
|
|
214
242
|
return { error: `Invalid collab link: ${link}` };
|
|
215
243
|
}
|
|
244
|
+
if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) {
|
|
245
|
+
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
|
246
|
+
const parsed = parseCollabLink(inner);
|
|
247
|
+
if (!("error" in parsed)) return parsed;
|
|
248
|
+
}
|
|
216
249
|
const normalized = normalizeRelayOrigin(url.origin);
|
|
217
250
|
if ("error" in normalized) return normalized;
|
|
218
251
|
const match = ROOM_PATH_RE.exec(url.pathname);
|
|
219
252
|
if (!match) {
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
253
|
+
// Non-http(s) deep links may also carry a complete collab link in the
|
|
254
|
+
// fragment. http(s) links are handled once above so invalid fragments
|
|
255
|
+
// fall through to direct relay validation instead of double-recursing.
|
|
223
256
|
const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
|
224
|
-
if (inner) return parseCollabLink(inner);
|
|
257
|
+
if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner);
|
|
225
258
|
return { error: "Collab link must contain a /r/<roomId> path" };
|
|
226
259
|
}
|
|
227
260
|
const roomId = match[1]!;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import { Markdown } from "@oh-my-pi/pi-tui";
|
|
4
4
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
5
6
|
import chalk from "chalk";
|
|
6
7
|
import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" };
|
|
7
8
|
import type { ModelRegistry } from "../../config/model-registry";
|
|
@@ -87,7 +87,7 @@ function truncateDiffContent(diff: string): { content: string; truncated: boolea
|
|
|
87
87
|
const truncatedCount = lines.length - KEEP_HEAD_LINES - KEEP_TAIL_LINES;
|
|
88
88
|
|
|
89
89
|
return {
|
|
90
|
-
content: [...head, `\n
|
|
90
|
+
content: [...head, `\n[…${truncatedCount}ln elided…]\n`, ...tail].join("\n"),
|
|
91
91
|
truncated: true,
|
|
92
92
|
};
|
|
93
93
|
}
|
|
@@ -117,7 +117,7 @@ function processDiffs(files: string[], diffs: Map<string, string>): { result: st
|
|
|
117
117
|
}
|
|
118
118
|
content = truncated;
|
|
119
119
|
if (content.length > remaining) {
|
|
120
|
-
content = `${content.slice(0, remaining)}\n
|
|
120
|
+
content = `${content.slice(0, remaining)}\n[…${content.length - remaining}ch elided…]`;
|
|
121
121
|
if (!truncatedFiles.includes(file)) {
|
|
122
122
|
truncatedFiles.push(file);
|
|
123
123
|
}
|
|
@@ -138,7 +138,7 @@ export async function applyChangelogProposals({
|
|
|
138
138
|
|
|
139
139
|
function truncateDiff(diff: string, maxChars: number): string {
|
|
140
140
|
if (diff.length <= maxChars) return diff;
|
|
141
|
-
return `${diff.slice(0, maxChars)}\n
|
|
141
|
+
return `${diff.slice(0, maxChars)}\n[…${diff.length - maxChars}ch elided…]`;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function formatExistingEntries(entries: Record<string, string[]>): string {
|
|
@@ -126,7 +126,7 @@ function generateContextHeader(files: FileDiff[], currentFile: string): string {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
if (toShow.length < sorted.length) {
|
|
129
|
-
lines.push(
|
|
129
|
+
lines.push(`[…${sorted.length - toShow.length} files elided…]`);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
return lines.join("\n");
|
|
@@ -5,5 +5,5 @@ export function estimateTokens(text: string): number {
|
|
|
5
5
|
export function truncateToTokenLimit(text: string, maxTokens: number): string {
|
|
6
6
|
const maxChars = maxTokens * 4;
|
|
7
7
|
if (text.length <= maxChars) return text;
|
|
8
|
-
return `${text.slice(0, maxChars)}\n
|
|
8
|
+
return `${text.slice(0, maxChars)}\n[…${text.length - maxChars}ch elided…]`;
|
|
9
9
|
}
|
|
@@ -40,7 +40,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const content = fs.readFileSync(jsonPath, "utf-8");
|
|
43
|
-
const parsed =
|
|
43
|
+
const parsed = JSONC.parse(content);
|
|
44
44
|
if (!parsed) {
|
|
45
45
|
logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
|
|
46
46
|
migratedPaths.add(key);
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
KeybindingsManager as TuiKeybindingsManager,
|
|
11
11
|
} from "@oh-my-pi/pi-tui";
|
|
12
12
|
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
13
|
-
import { YAML } from "bun";
|
|
13
|
+
import { JSONC, YAML } from "bun";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Application-level keybindings (coding agent specific).
|
|
@@ -381,7 +381,7 @@ function loadRawConfig(filePath: string): unknown {
|
|
|
381
381
|
try {
|
|
382
382
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
383
383
|
if (filePath.endsWith(".json")) {
|
|
384
|
-
return
|
|
384
|
+
return JSONC.parse(content);
|
|
385
385
|
}
|
|
386
386
|
if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) {
|
|
387
387
|
return YAML.parse(content);
|
|
@@ -563,10 +563,16 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
|
|
|
563
563
|
} as ModelSpec<Api>);
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
function normalizeSuppressedSelector(
|
|
566
|
+
function normalizeSuppressedSelector(
|
|
567
|
+
selector: string,
|
|
568
|
+
hasLiveModel?: (provider: string, id: string) => boolean,
|
|
569
|
+
): string {
|
|
567
570
|
const trimmed = selector.trim();
|
|
568
571
|
if (!trimmed) return trimmed;
|
|
569
|
-
const parsed = parseModelString(trimmed
|
|
572
|
+
const parsed = parseModelString(trimmed, {
|
|
573
|
+
allowMaxAlias: true,
|
|
574
|
+
isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true,
|
|
575
|
+
});
|
|
570
576
|
if (!parsed) return trimmed;
|
|
571
577
|
// Retired effort-tier variant ids normalize to their collapsed logical id
|
|
572
578
|
// so persisted suppressions keyed by raw member ids still bind.
|
|
@@ -2155,14 +2161,20 @@ export class ModelRegistry {
|
|
|
2155
2161
|
* Suppress a specific model selector (e.g., "provider/id") until a specific timestamp.
|
|
2156
2162
|
*/
|
|
2157
2163
|
suppressSelector(selector: string, untilMs: number): void {
|
|
2158
|
-
this.#suppressedSelectors.set(
|
|
2164
|
+
this.#suppressedSelectors.set(
|
|
2165
|
+
normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
|
|
2166
|
+
untilMs,
|
|
2167
|
+
);
|
|
2159
2168
|
}
|
|
2160
2169
|
|
|
2161
2170
|
/**
|
|
2162
2171
|
* Check if a model selector is currently suppressed due to rate limits.
|
|
2163
2172
|
*/
|
|
2164
2173
|
isSelectorSuppressed(selector: string): boolean {
|
|
2165
|
-
const normalizedSelector = normalizeSuppressedSelector(
|
|
2174
|
+
const normalizedSelector = normalizeSuppressedSelector(
|
|
2175
|
+
selector,
|
|
2176
|
+
(provider, id) => this.find(provider, id) !== undefined,
|
|
2177
|
+
);
|
|
2166
2178
|
const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector);
|
|
2167
2179
|
if (!suppressedUntil) return false;
|
|
2168
2180
|
if (suppressedUntil <= Date.now()) {
|