@kaoruisaac/pedelec 0.0.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 +9 -0
- package/dist/extension-id.d.ts +1 -0
- package/dist/extension-id.js +1 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +526 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Lin
|
|
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,9 @@
|
|
|
1
|
+
# Pedelec SDK
|
|
2
|
+
[](https://www.npmjs.com/package/@kaoruisaac/pedelec)
|
|
3
|
+
Browser SDK for connecting a web app to the Pedelec Chrome Extension and Desktop Runtime.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Pedelec } from "@kaoruisaac/pedelec";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
See the repository README for full setup and usage documentation.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PEDELEC_EXTENSION_ID = "ogccgaminlphbkeghldidiiimajfdpag";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PEDELEC_EXTENSION_ID = "ogccgaminlphbkeghldidiiimajfdpag";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type PedelecOptions = {
|
|
2
|
+
bridgeTimeoutMs?: number;
|
|
3
|
+
};
|
|
4
|
+
export type ProviderCode = "codex" | "gemini" | "opencode" | "cursor" | "claude";
|
|
5
|
+
type CreateSessionInputWithProvider = {
|
|
6
|
+
provider: ProviderCode;
|
|
7
|
+
model?: string;
|
|
8
|
+
skillsUrls?: string[];
|
|
9
|
+
};
|
|
10
|
+
type CreateSessionInputWithDefaults = {
|
|
11
|
+
provider?: undefined;
|
|
12
|
+
model?: never;
|
|
13
|
+
skillsUrls?: string[];
|
|
14
|
+
};
|
|
15
|
+
export type CreateSessionInput = CreateSessionInputWithProvider | CreateSessionInputWithDefaults;
|
|
16
|
+
export type ProviderInfo = {
|
|
17
|
+
name: string;
|
|
18
|
+
code: ProviderCode;
|
|
19
|
+
path: string | null;
|
|
20
|
+
available: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type PedelecSettings = {
|
|
24
|
+
defaultProvider: ProviderCode | null;
|
|
25
|
+
defaultModel: string | null;
|
|
26
|
+
};
|
|
27
|
+
export type ApprovalStatus = {
|
|
28
|
+
installed: boolean;
|
|
29
|
+
approved: boolean;
|
|
30
|
+
origin: string | null;
|
|
31
|
+
};
|
|
32
|
+
export type PedelecError = {
|
|
33
|
+
code: string;
|
|
34
|
+
message: string;
|
|
35
|
+
details?: unknown;
|
|
36
|
+
};
|
|
37
|
+
export type PedelecSessionStatus = "idle" | "running" | "waiting_tool_result" | "ended" | "error";
|
|
38
|
+
type SessionEvent = {
|
|
39
|
+
type: "chat_delta";
|
|
40
|
+
channelId: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
seq?: number;
|
|
43
|
+
text: string;
|
|
44
|
+
} | {
|
|
45
|
+
type: "status_changed";
|
|
46
|
+
channelId: string;
|
|
47
|
+
sessionId: string;
|
|
48
|
+
seq?: number;
|
|
49
|
+
status: PedelecSessionStatus;
|
|
50
|
+
} | {
|
|
51
|
+
type: "tool_call";
|
|
52
|
+
channelId: string;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
seq?: number;
|
|
55
|
+
toolRequestId: string;
|
|
56
|
+
tool: string;
|
|
57
|
+
args: unknown;
|
|
58
|
+
} | {
|
|
59
|
+
type: "done";
|
|
60
|
+
channelId: string;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
seq?: number;
|
|
63
|
+
} | {
|
|
64
|
+
type: "error";
|
|
65
|
+
channelId?: string;
|
|
66
|
+
sessionId?: string;
|
|
67
|
+
seq?: number;
|
|
68
|
+
error?: PedelecError;
|
|
69
|
+
} | {
|
|
70
|
+
type: "ended";
|
|
71
|
+
channelId: string;
|
|
72
|
+
sessionId: string;
|
|
73
|
+
seq?: number;
|
|
74
|
+
};
|
|
75
|
+
type ChatHandler = (text: string) => void;
|
|
76
|
+
type ToolHandler = (tool: string, args: unknown) => unknown | Promise<unknown>;
|
|
77
|
+
type ErrorHandler = (error: PedelecError) => void;
|
|
78
|
+
type StatusHandler = (status: PedelecSessionStatus) => void;
|
|
79
|
+
type EndedHandler = () => void;
|
|
80
|
+
export declare class Pedelec {
|
|
81
|
+
private readonly pageWindow;
|
|
82
|
+
private readonly channelId;
|
|
83
|
+
private readonly bridgeTimeoutMs;
|
|
84
|
+
private readonly pendingRequests;
|
|
85
|
+
private readonly sessions;
|
|
86
|
+
private readonly lastSeqBySession;
|
|
87
|
+
private nextRequestNumber;
|
|
88
|
+
private disconnectedError;
|
|
89
|
+
private port;
|
|
90
|
+
constructor(options?: PedelecOptions);
|
|
91
|
+
createSession(input?: CreateSessionInput): Promise<PedelecSession>;
|
|
92
|
+
listProviders(): Promise<ProviderInfo[]>;
|
|
93
|
+
getSettings(): Promise<PedelecSettings>;
|
|
94
|
+
getApprovalStatus(): Promise<ApprovalStatus>;
|
|
95
|
+
resumeSession(sessionId: string): Promise<PedelecSession>;
|
|
96
|
+
request<T>(type: string, payload?: Record<string, unknown>): Promise<T>;
|
|
97
|
+
unregisterSession(sessionId: string): void;
|
|
98
|
+
private resolveCreateSessionInput;
|
|
99
|
+
private resolveDefaultCreateSessionInput;
|
|
100
|
+
private resolveProviderOnlyCreateSessionInput;
|
|
101
|
+
private assertDefaultProviderAvailable;
|
|
102
|
+
private registerSession;
|
|
103
|
+
private connectExtension;
|
|
104
|
+
private handlePortMessage;
|
|
105
|
+
private handleResponse;
|
|
106
|
+
private isNewEvent;
|
|
107
|
+
private handleDisconnect;
|
|
108
|
+
private broadcastError;
|
|
109
|
+
}
|
|
110
|
+
export declare class PedelecSession {
|
|
111
|
+
private readonly client;
|
|
112
|
+
readonly sessionId: string;
|
|
113
|
+
readonly provider: string;
|
|
114
|
+
readonly model?: string;
|
|
115
|
+
private status;
|
|
116
|
+
private pendingSend;
|
|
117
|
+
private toolHandler;
|
|
118
|
+
private readonly chatHandlers;
|
|
119
|
+
private readonly errorHandlers;
|
|
120
|
+
private readonly statusHandlers;
|
|
121
|
+
private readonly endedHandlers;
|
|
122
|
+
constructor(client: Pedelec, sessionId: string, provider: string, model?: string);
|
|
123
|
+
sendText(text: string): Promise<void>;
|
|
124
|
+
onChat(handler: ChatHandler): () => void;
|
|
125
|
+
onTool(handler: ToolHandler): () => void;
|
|
126
|
+
onError(handler: ErrorHandler): () => void;
|
|
127
|
+
onStatus(handler: StatusHandler): () => void;
|
|
128
|
+
onEnded(handler: EndedHandler): () => void;
|
|
129
|
+
getStatus(): PedelecSessionStatus;
|
|
130
|
+
end(): Promise<void>;
|
|
131
|
+
handleEvent(event: SessionEvent): void;
|
|
132
|
+
private handleToolCall;
|
|
133
|
+
private resolvePendingSend;
|
|
134
|
+
private rejectPendingSend;
|
|
135
|
+
private clearPendingSend;
|
|
136
|
+
private markEnded;
|
|
137
|
+
private emitError;
|
|
138
|
+
private setStatus;
|
|
139
|
+
}
|
|
140
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { PEDELEC_EXTENSION_ID } from "./extension-id.js";
|
|
2
|
+
const SDK_EXTERNAL_PORT_NAME = "pedelec-sdk-external";
|
|
3
|
+
const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
|
|
4
|
+
export class Pedelec {
|
|
5
|
+
pageWindow;
|
|
6
|
+
channelId;
|
|
7
|
+
bridgeTimeoutMs;
|
|
8
|
+
pendingRequests = new Map();
|
|
9
|
+
sessions = new Map();
|
|
10
|
+
lastSeqBySession = new Map();
|
|
11
|
+
nextRequestNumber = 1;
|
|
12
|
+
disconnectedError = null;
|
|
13
|
+
port = null;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.pageWindow = typeof window === "undefined" ? null : window;
|
|
16
|
+
this.channelId = createChannelId();
|
|
17
|
+
this.bridgeTimeoutMs = Math.max(1, options.bridgeTimeoutMs ?? DEFAULT_BRIDGE_TIMEOUT_MS);
|
|
18
|
+
if (!this.pageWindow) {
|
|
19
|
+
this.disconnectedError = makeError("EXTENSION_UNAVAILABLE", "Pedelec SDK must run in a browser page context.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.connectExtension();
|
|
23
|
+
}
|
|
24
|
+
async createSession(input = {}) {
|
|
25
|
+
const resolvedOrPromise = this.resolveCreateSessionInput(input);
|
|
26
|
+
const resolvedInput = resolvedOrPromise instanceof Promise ? await resolvedOrPromise : resolvedOrPromise;
|
|
27
|
+
const result = await this.request("create_session", {
|
|
28
|
+
input: {
|
|
29
|
+
provider: resolvedInput.provider,
|
|
30
|
+
model: resolvedInput.model,
|
|
31
|
+
skillsUrls: resolvedInput.skillsUrls,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (!result.sessionId) {
|
|
35
|
+
throw makeError("SDK_PROTOCOL_ERROR", "create_session response did not include sessionId");
|
|
36
|
+
}
|
|
37
|
+
return this.registerSession(result.sessionId, resolvedInput.provider, resolvedInput.model);
|
|
38
|
+
}
|
|
39
|
+
async listProviders() {
|
|
40
|
+
const result = await this.request("list_providers");
|
|
41
|
+
if (!Array.isArray(result)) {
|
|
42
|
+
throw makeError("SDK_PROTOCOL_ERROR", "list_providers response was not an array");
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
async getSettings() {
|
|
47
|
+
const result = await this.request("get_settings");
|
|
48
|
+
if (!isSettings(result)) {
|
|
49
|
+
throw makeError("SDK_PROTOCOL_ERROR", "get_settings response had invalid shape");
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
async getApprovalStatus() {
|
|
54
|
+
if (this.disconnectedError) {
|
|
55
|
+
return {
|
|
56
|
+
installed: false,
|
|
57
|
+
approved: false,
|
|
58
|
+
origin: getCurrentOrigin(this.pageWindow),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = await this.request("get_approval_status");
|
|
63
|
+
if (!isApprovalStatus(result)) {
|
|
64
|
+
throw makeError("SDK_PROTOCOL_ERROR", "get_approval_status response had invalid shape");
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const error = normalizeError(err, "EXTENSION_UNAVAILABLE", "Pedelec extension is unavailable.");
|
|
70
|
+
if (error.code === "EXTENSION_UNAVAILABLE" || error.code === "EXTENSION_DISCONNECTED") {
|
|
71
|
+
return {
|
|
72
|
+
installed: false,
|
|
73
|
+
approved: false,
|
|
74
|
+
origin: getCurrentOrigin(this.pageWindow),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async resumeSession(sessionId) {
|
|
81
|
+
if (!sessionId?.trim()) {
|
|
82
|
+
throw makeError("INVALID_INPUT", "sessionId is required");
|
|
83
|
+
}
|
|
84
|
+
const result = await this.request("resume_session", {
|
|
85
|
+
sessionId,
|
|
86
|
+
});
|
|
87
|
+
if (!result.sessionId) {
|
|
88
|
+
throw makeError("SDK_PROTOCOL_ERROR", "resume_session response did not include sessionId");
|
|
89
|
+
}
|
|
90
|
+
return this.registerSession(result.sessionId, "", undefined);
|
|
91
|
+
}
|
|
92
|
+
request(type, payload = {}) {
|
|
93
|
+
if (this.disconnectedError) {
|
|
94
|
+
return Promise.reject(this.disconnectedError);
|
|
95
|
+
}
|
|
96
|
+
if (!this.port) {
|
|
97
|
+
return Promise.reject(makeError("EXTENSION_UNAVAILABLE", "Pedelec extension is unavailable."));
|
|
98
|
+
}
|
|
99
|
+
const requestId = `sdk_${Date.now()}_${this.nextRequestNumber++}`;
|
|
100
|
+
const message = { channelId: this.channelId, type, requestId, ...payload };
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const timeoutId = setTimeout(() => {
|
|
103
|
+
this.pendingRequests.delete(requestId);
|
|
104
|
+
reject(makeError("SDK_BRIDGE_TIMEOUT", "Pedelec extension did not respond.", {
|
|
105
|
+
requestId,
|
|
106
|
+
type,
|
|
107
|
+
}));
|
|
108
|
+
}, this.bridgeTimeoutMs);
|
|
109
|
+
this.pendingRequests.set(requestId, {
|
|
110
|
+
resolve: (value) => resolve(value),
|
|
111
|
+
reject,
|
|
112
|
+
timeoutId,
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
this.port?.postMessage(message);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
this.pendingRequests.delete(requestId);
|
|
119
|
+
clearTimeout(timeoutId);
|
|
120
|
+
reject(normalizeError(err, "EXTENSION_DISCONNECTED", "Pedelec extension disconnected."));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
unregisterSession(sessionId) {
|
|
125
|
+
this.sessions.delete(sessionId);
|
|
126
|
+
this.lastSeqBySession.delete(sessionId);
|
|
127
|
+
}
|
|
128
|
+
resolveCreateSessionInput(input) {
|
|
129
|
+
const raw = (input ?? {});
|
|
130
|
+
const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
|
|
131
|
+
const hasProvider = provider.length > 0;
|
|
132
|
+
const hasModel = raw.model !== undefined;
|
|
133
|
+
if (!hasProvider && hasModel) {
|
|
134
|
+
throw makeError("INVALID_INPUT", "model cannot be provided without provider");
|
|
135
|
+
}
|
|
136
|
+
const skillsUrls = Array.isArray(raw.skillsUrls) ? raw.skillsUrls : [];
|
|
137
|
+
const userModel = typeof raw.model === "string" ? raw.model : undefined;
|
|
138
|
+
if (!hasProvider) {
|
|
139
|
+
return this.resolveDefaultCreateSessionInput(skillsUrls);
|
|
140
|
+
}
|
|
141
|
+
if (!isProviderCode(provider)) {
|
|
142
|
+
throw makeError("INVALID_INPUT", "provider is not supported", { provider });
|
|
143
|
+
}
|
|
144
|
+
let model = userModel;
|
|
145
|
+
if (model === undefined) {
|
|
146
|
+
return this.resolveProviderOnlyCreateSessionInput(provider, skillsUrls);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
provider: provider,
|
|
150
|
+
model,
|
|
151
|
+
skillsUrls,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async resolveDefaultCreateSessionInput(skillsUrls) {
|
|
155
|
+
const settings = await this.getSettings();
|
|
156
|
+
if (!settings.defaultProvider) {
|
|
157
|
+
throw makeError("DEFAULT_PROVIDER_NOT_SET", "Default provider is not set. Open the Pedelec desktop app Settings page and choose a default provider.");
|
|
158
|
+
}
|
|
159
|
+
await this.assertDefaultProviderAvailable(settings.defaultProvider);
|
|
160
|
+
return {
|
|
161
|
+
provider: settings.defaultProvider,
|
|
162
|
+
model: settings.defaultModel ?? undefined,
|
|
163
|
+
skillsUrls,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async resolveProviderOnlyCreateSessionInput(provider, skillsUrls) {
|
|
167
|
+
const settings = await this.getSettings();
|
|
168
|
+
return {
|
|
169
|
+
provider,
|
|
170
|
+
model: settings.defaultProvider === provider && settings.defaultModel
|
|
171
|
+
? settings.defaultModel
|
|
172
|
+
: undefined,
|
|
173
|
+
skillsUrls,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
async assertDefaultProviderAvailable(provider) {
|
|
177
|
+
const providers = await this.listProviders();
|
|
178
|
+
const info = providers.find((candidate) => candidate.code === provider);
|
|
179
|
+
if (!info?.available) {
|
|
180
|
+
throw makeError("DEFAULT_PROVIDER_UNAVAILABLE", "Default provider is not currently available. Open the Pedelec desktop app Settings page and choose an available provider.", { provider });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
registerSession(sessionId, provider, model) {
|
|
184
|
+
const existing = this.sessions.get(sessionId);
|
|
185
|
+
if (existing)
|
|
186
|
+
return existing;
|
|
187
|
+
const session = new PedelecSession(this, sessionId, provider, model);
|
|
188
|
+
this.sessions.set(sessionId, session);
|
|
189
|
+
return session;
|
|
190
|
+
}
|
|
191
|
+
connectExtension() {
|
|
192
|
+
const runtime = globalThis.chrome?.runtime;
|
|
193
|
+
if (!runtime?.connect) {
|
|
194
|
+
this.disconnectedError = makeError("EXTENSION_UNAVAILABLE", "Pedelec extension is unavailable.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
this.port = runtime.connect(PEDELEC_EXTENSION_ID, { name: SDK_EXTERNAL_PORT_NAME });
|
|
199
|
+
this.port.onMessage.addListener((message) => this.handlePortMessage(message));
|
|
200
|
+
this.port.onDisconnect.addListener(() => this.handleDisconnect());
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
this.disconnectedError = normalizeError(err, "EXTENSION_UNAVAILABLE", "Pedelec extension is unavailable.");
|
|
204
|
+
this.port = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
handlePortMessage(raw) {
|
|
208
|
+
const message = raw;
|
|
209
|
+
if (!message || typeof message !== "object")
|
|
210
|
+
return;
|
|
211
|
+
if (message.channelId && message.channelId !== this.channelId)
|
|
212
|
+
return;
|
|
213
|
+
if (message.type === "response") {
|
|
214
|
+
this.handleResponse(message);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (isSessionEvent(message) && this.isNewEvent(message)) {
|
|
218
|
+
if (message.sessionId) {
|
|
219
|
+
this.sessions.get(message.sessionId)?.handleEvent(message);
|
|
220
|
+
}
|
|
221
|
+
else if (message.type === "error") {
|
|
222
|
+
this.broadcastError(normalizeError(message.error, "SDK_TRANSPORT_ERROR", "Pedelec transport error"));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
handleResponse(message) {
|
|
227
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
228
|
+
if (!pending)
|
|
229
|
+
return;
|
|
230
|
+
this.pendingRequests.delete(message.requestId);
|
|
231
|
+
clearTimeout(pending.timeoutId);
|
|
232
|
+
if (message.ok) {
|
|
233
|
+
pending.resolve(message.result);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
pending.reject(normalizeError(message.error, "SDK_TRANSPORT_ERROR", "SDK transport request failed"));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
isNewEvent(event) {
|
|
240
|
+
if (!event.sessionId || typeof event.seq !== "number")
|
|
241
|
+
return true;
|
|
242
|
+
const lastSeq = this.lastSeqBySession.get(event.sessionId);
|
|
243
|
+
if (typeof lastSeq === "number" && event.seq <= lastSeq) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
this.lastSeqBySession.set(event.sessionId, event.seq);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
handleDisconnect() {
|
|
250
|
+
const runtime = globalThis.chrome?.runtime;
|
|
251
|
+
const error = normalizeError(runtime?.lastError, "EXTENSION_DISCONNECTED", "Pedelec extension disconnected.");
|
|
252
|
+
this.disconnectedError = error;
|
|
253
|
+
this.port = null;
|
|
254
|
+
for (const pending of this.pendingRequests.values()) {
|
|
255
|
+
clearTimeout(pending.timeoutId);
|
|
256
|
+
pending.reject(error);
|
|
257
|
+
}
|
|
258
|
+
this.pendingRequests.clear();
|
|
259
|
+
this.broadcastError(error);
|
|
260
|
+
}
|
|
261
|
+
broadcastError(error) {
|
|
262
|
+
for (const session of this.sessions.values()) {
|
|
263
|
+
session.handleEvent({ type: "error", sessionId: session.sessionId, error });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export class PedelecSession {
|
|
268
|
+
client;
|
|
269
|
+
sessionId;
|
|
270
|
+
provider;
|
|
271
|
+
model;
|
|
272
|
+
status = "idle";
|
|
273
|
+
pendingSend = null;
|
|
274
|
+
toolHandler = null;
|
|
275
|
+
chatHandlers = new Set();
|
|
276
|
+
errorHandlers = new Set();
|
|
277
|
+
statusHandlers = new Set();
|
|
278
|
+
endedHandlers = new Set();
|
|
279
|
+
constructor(client, sessionId, provider, model) {
|
|
280
|
+
this.client = client;
|
|
281
|
+
this.sessionId = sessionId;
|
|
282
|
+
this.provider = provider;
|
|
283
|
+
this.model = model;
|
|
284
|
+
}
|
|
285
|
+
sendText(text) {
|
|
286
|
+
if (this.status === "ended") {
|
|
287
|
+
return Promise.reject(makeError("SESSION_ENDED", "session has ended", { sessionId: this.sessionId }));
|
|
288
|
+
}
|
|
289
|
+
if (this.pendingSend) {
|
|
290
|
+
return Promise.reject(makeError("SESSION_BUSY", "session is already running", { sessionId: this.sessionId }));
|
|
291
|
+
}
|
|
292
|
+
this.setStatus("running");
|
|
293
|
+
const donePromise = new Promise((resolve, reject) => {
|
|
294
|
+
this.pendingSend = { resolve, reject };
|
|
295
|
+
});
|
|
296
|
+
const requestPromise = this.client
|
|
297
|
+
.request("send_text", {
|
|
298
|
+
sessionId: this.sessionId,
|
|
299
|
+
text,
|
|
300
|
+
})
|
|
301
|
+
.catch((err) => {
|
|
302
|
+
const error = normalizeError(err, "SEND_TEXT_FAILED", "sendText failed");
|
|
303
|
+
this.clearPendingSend();
|
|
304
|
+
this.emitError(error);
|
|
305
|
+
throw error;
|
|
306
|
+
});
|
|
307
|
+
return Promise.all([requestPromise, donePromise]).then(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
onChat(handler) {
|
|
310
|
+
this.chatHandlers.add(handler);
|
|
311
|
+
return () => this.chatHandlers.delete(handler);
|
|
312
|
+
}
|
|
313
|
+
onTool(handler) {
|
|
314
|
+
this.toolHandler = handler;
|
|
315
|
+
return () => {
|
|
316
|
+
if (this.toolHandler === handler) {
|
|
317
|
+
this.toolHandler = null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
onError(handler) {
|
|
322
|
+
this.errorHandlers.add(handler);
|
|
323
|
+
return () => this.errorHandlers.delete(handler);
|
|
324
|
+
}
|
|
325
|
+
onStatus(handler) {
|
|
326
|
+
this.statusHandlers.add(handler);
|
|
327
|
+
return () => this.statusHandlers.delete(handler);
|
|
328
|
+
}
|
|
329
|
+
onEnded(handler) {
|
|
330
|
+
this.endedHandlers.add(handler);
|
|
331
|
+
return () => this.endedHandlers.delete(handler);
|
|
332
|
+
}
|
|
333
|
+
getStatus() {
|
|
334
|
+
return this.status;
|
|
335
|
+
}
|
|
336
|
+
async end() {
|
|
337
|
+
if (this.status === "ended")
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
await this.client.request("end_session", {
|
|
341
|
+
sessionId: this.sessionId,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
const error = normalizeError(err, "END_SESSION_FAILED", "end failed");
|
|
346
|
+
this.emitError(error);
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
this.markEnded();
|
|
350
|
+
this.client.unregisterSession(this.sessionId);
|
|
351
|
+
}
|
|
352
|
+
handleEvent(event) {
|
|
353
|
+
if (event.type === "chat_delta") {
|
|
354
|
+
for (const handler of this.chatHandlers) {
|
|
355
|
+
handler(event.text);
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (event.type === "status_changed") {
|
|
360
|
+
this.setStatus(event.status);
|
|
361
|
+
if (event.status === "idle") {
|
|
362
|
+
this.resolvePendingSend();
|
|
363
|
+
}
|
|
364
|
+
else if (event.status === "ended") {
|
|
365
|
+
this.markEnded();
|
|
366
|
+
}
|
|
367
|
+
else if (event.status === "error") {
|
|
368
|
+
const error = makeError("SESSION_ERROR", "session entered error status", { sessionId: this.sessionId });
|
|
369
|
+
this.rejectPendingSend(error);
|
|
370
|
+
this.emitError(error);
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (event.type === "tool_call") {
|
|
375
|
+
this.setStatus("waiting_tool_result");
|
|
376
|
+
this.handleToolCall(event);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (event.type === "done") {
|
|
380
|
+
this.setStatus("idle");
|
|
381
|
+
this.resolvePendingSend();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (event.type === "error") {
|
|
385
|
+
this.setStatus("error");
|
|
386
|
+
const error = normalizeError(event.error, "SESSION_ERROR", "session error");
|
|
387
|
+
this.rejectPendingSend(error);
|
|
388
|
+
this.emitError(error);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (event.type === "ended") {
|
|
392
|
+
this.markEnded();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async handleToolCall(event) {
|
|
396
|
+
let result;
|
|
397
|
+
if (!this.toolHandler) {
|
|
398
|
+
result = {
|
|
399
|
+
error: makeError("TOOL_HANDLER_NOT_FOUND", `No tool handler registered for ${event.tool}`, {
|
|
400
|
+
tool: event.tool,
|
|
401
|
+
}),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
try {
|
|
406
|
+
result = await this.toolHandler(event.tool, event.args);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
result = {
|
|
410
|
+
error: normalizeError(err, "TOOL_HANDLER_ERROR", "Tool handler failed"),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
await this.client.request("submit_tool_result", {
|
|
416
|
+
sessionId: this.sessionId,
|
|
417
|
+
toolRequestId: event.toolRequestId,
|
|
418
|
+
result,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
this.emitError(normalizeError(err, "SUBMIT_TOOL_RESULT_FAILED", "submit_tool_result failed"));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
resolvePendingSend() {
|
|
426
|
+
const pending = this.pendingSend;
|
|
427
|
+
this.pendingSend = null;
|
|
428
|
+
pending?.resolve();
|
|
429
|
+
}
|
|
430
|
+
rejectPendingSend(error) {
|
|
431
|
+
const pending = this.pendingSend;
|
|
432
|
+
this.pendingSend = null;
|
|
433
|
+
pending?.reject(error);
|
|
434
|
+
}
|
|
435
|
+
clearPendingSend() {
|
|
436
|
+
this.pendingSend = null;
|
|
437
|
+
}
|
|
438
|
+
markEnded() {
|
|
439
|
+
const wasEnded = this.status === "ended";
|
|
440
|
+
this.setStatus("ended");
|
|
441
|
+
this.rejectPendingSend(makeError("SESSION_ENDED", "session has ended", { sessionId: this.sessionId }));
|
|
442
|
+
if (!wasEnded) {
|
|
443
|
+
for (const handler of this.endedHandlers) {
|
|
444
|
+
handler();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
emitError(error) {
|
|
449
|
+
for (const handler of this.errorHandlers) {
|
|
450
|
+
handler(error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
setStatus(status) {
|
|
454
|
+
if (this.status === status)
|
|
455
|
+
return;
|
|
456
|
+
this.status = status;
|
|
457
|
+
for (const handler of this.statusHandlers) {
|
|
458
|
+
handler(status);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function isSessionEvent(message) {
|
|
463
|
+
return (message.type === "chat_delta" ||
|
|
464
|
+
message.type === "status_changed" ||
|
|
465
|
+
message.type === "tool_call" ||
|
|
466
|
+
message.type === "done" ||
|
|
467
|
+
message.type === "error" ||
|
|
468
|
+
message.type === "ended");
|
|
469
|
+
}
|
|
470
|
+
function isSettings(value) {
|
|
471
|
+
if (!value || typeof value !== "object")
|
|
472
|
+
return false;
|
|
473
|
+
const settings = value;
|
|
474
|
+
return ((settings.defaultProvider === null ||
|
|
475
|
+
settings.defaultProvider === "codex" ||
|
|
476
|
+
settings.defaultProvider === "gemini" ||
|
|
477
|
+
settings.defaultProvider === "opencode" ||
|
|
478
|
+
settings.defaultProvider === "cursor" ||
|
|
479
|
+
settings.defaultProvider === "claude") &&
|
|
480
|
+
(settings.defaultModel === null || typeof settings.defaultModel === "string"));
|
|
481
|
+
}
|
|
482
|
+
function isApprovalStatus(value) {
|
|
483
|
+
if (!value || typeof value !== "object")
|
|
484
|
+
return false;
|
|
485
|
+
const status = value;
|
|
486
|
+
return (typeof status.installed === "boolean" &&
|
|
487
|
+
typeof status.approved === "boolean" &&
|
|
488
|
+
(status.origin === null || typeof status.origin === "string"));
|
|
489
|
+
}
|
|
490
|
+
function isProviderCode(value) {
|
|
491
|
+
return (value === "codex" ||
|
|
492
|
+
value === "gemini" ||
|
|
493
|
+
value === "opencode" ||
|
|
494
|
+
value === "cursor" ||
|
|
495
|
+
value === "claude");
|
|
496
|
+
}
|
|
497
|
+
function makeError(code, message, details) {
|
|
498
|
+
return details === undefined ? { code, message } : { code, message, details };
|
|
499
|
+
}
|
|
500
|
+
function createChannelId() {
|
|
501
|
+
const random = typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
502
|
+
? crypto.randomUUID()
|
|
503
|
+
: Math.random().toString(36).slice(2);
|
|
504
|
+
return `pedelec_${Date.now()}_${random}`;
|
|
505
|
+
}
|
|
506
|
+
function getCurrentOrigin(pageWindow) {
|
|
507
|
+
const origin = pageWindow?.location?.origin;
|
|
508
|
+
return typeof origin === "string" && origin ? origin : null;
|
|
509
|
+
}
|
|
510
|
+
function normalizeError(err, fallbackCode, fallbackMessage) {
|
|
511
|
+
if (!err)
|
|
512
|
+
return makeError(fallbackCode, fallbackMessage);
|
|
513
|
+
if (typeof err === "string")
|
|
514
|
+
return makeError(fallbackCode, err);
|
|
515
|
+
if (err instanceof Error)
|
|
516
|
+
return makeError(fallbackCode, err.message || fallbackMessage);
|
|
517
|
+
const value = err;
|
|
518
|
+
if (typeof value.code === "string" && typeof value.message === "string") {
|
|
519
|
+
return {
|
|
520
|
+
code: value.code,
|
|
521
|
+
message: value.message,
|
|
522
|
+
details: value.details,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return makeError(fallbackCode, fallbackMessage, err);
|
|
526
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kaoruisaac/pedelec",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.8.3",
|
|
28
|
+
"vitest": "^4.1.8"
|
|
29
|
+
}
|
|
30
|
+
}
|