talon-agent 5.0.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/talon.js +35 -0
- package/package.json +3 -3
- package/prompts/identity.md +10 -2
- package/prompts/system/agent-brief.md +43 -0
- package/src/app.ts +19 -26
- package/src/backend/builtins.ts +26 -7
- package/src/backend/claude-sdk/handler.ts +191 -69
- package/src/backend/claude-sdk/one-shot.ts +30 -7
- package/src/backend/claude-sdk/stream.ts +9 -0
- package/src/backend/codex/one-shot.ts +18 -4
- package/src/backend/remote-server/index.ts +6 -4
- package/src/backend/remote-server/model-catalog/index.ts +4 -10
- package/src/backend/remote-server/model-catalog/provider.ts +3 -3
- package/src/backend/remote-server/one-shot.ts +16 -3
- package/src/backend/remote-server/profiles/bind.ts +225 -0
- package/src/backend/remote-server/profiles/index.ts +10 -0
- package/src/backend/remote-server/profiles/kilo.ts +82 -0
- package/src/backend/remote-server/profiles/opencode.ts +61 -0
- package/src/backend/remote-server/server-bindings.ts +3 -4
- package/src/backend/runtime/one-shot-hooks.ts +45 -0
- package/src/bootstrap.ts +15 -1
- package/src/cli/chat.ts +5 -0
- package/src/cli/events.ts +9 -0
- package/src/core/agent-runtime/agent-host.ts +7 -6
- package/src/core/agent-runtime/capabilities.ts +3 -0
- package/src/core/agents/context.ts +48 -0
- package/src/core/agents/delivery.ts +167 -0
- package/src/core/agents/index.ts +37 -0
- package/src/core/agents/prompt.ts +116 -0
- package/src/core/agents/registry.ts +426 -0
- package/src/core/agents/runner.ts +448 -0
- package/src/core/agents/types.ts +124 -0
- package/src/core/background/cron/job-oneshot.ts +7 -12
- package/src/core/background/cron/job-prompt.ts +1 -1
- package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
- package/src/core/background/run-log.ts +33 -0
- package/src/core/bus/events.ts +49 -2
- package/src/core/config/index.ts +25 -0
- package/src/core/engine/gateway-actions/agents/control.ts +299 -0
- package/src/core/engine/gateway-actions/agents/index.ts +31 -0
- package/src/core/engine/gateway-actions/agents/report.ts +107 -0
- package/src/core/engine/gateway-actions/index.ts +30 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
- package/src/core/engine/gateway-actions/native/exec.ts +1 -1
- package/src/core/engine/gateway-actions/native/read.ts +1 -1
- package/src/core/engine/gateway-actions/native/search.ts +1 -1
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway-actions/native/write.ts +1 -1
- package/src/core/engine/gateway-routes.ts +12 -0
- package/src/core/engine/gateway.ts +96 -25
- package/src/core/frontend-runtime/capabilities.ts +18 -0
- package/src/core/frontend-runtime/index.ts +4 -0
- package/src/core/frontend-runtime/lifecycle.ts +33 -0
- package/src/core/frontend-runtime/registry.ts +3 -3
- package/src/core/frontend-runtime/run-loop.ts +59 -0
- package/src/core/mcp-hub/children.ts +21 -5
- package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
- package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
- package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
- package/src/core/mesh/index.ts +6 -2
- package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
- package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
- package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
- package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
- package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
- package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
- package/src/core/prompt/embedded-prompts.ts +38 -36
- package/src/core/tasks/types.ts +2 -2
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/ops/agents.ts +195 -0
- package/src/core/tools/ops/bridge.ts +4 -0
- package/src/core/tools/types.ts +1 -0
- package/src/core/types.ts +12 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/routes/mesh.ts +1 -1
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/presentation/reports.ts +1 -1
- package/src/frontend/teams/index.ts +4 -3
- package/src/frontend/telegram/commands/info.ts +61 -20
- package/src/frontend/telegram/index.ts +27 -4
- package/src/frontend/telegram/render/reports.ts +1 -1
- package/src/frontend/terminal/index.ts +6 -2
- package/src/frontend/whatsapp/connection/connection.ts +62 -11
- package/src/frontend/whatsapp/index.ts +25 -1
- package/src/frontend/whatsapp/runtime.ts +7 -0
- package/src/util/log.ts +1 -0
- package/src/backend/kilo/factory.ts +0 -53
- package/src/backend/kilo/handler/index.ts +0 -2
- package/src/backend/kilo/handler/message.ts +0 -44
- package/src/backend/kilo/index.ts +0 -61
- package/src/backend/kilo/model-provider.ts +0 -36
- package/src/backend/kilo/models/index.ts +0 -55
- package/src/backend/kilo/one-shot.ts +0 -42
- package/src/backend/kilo/server.ts +0 -98
- package/src/backend/kilo/sessions.ts +0 -37
- package/src/backend/opencode/factory.ts +0 -53
- package/src/backend/opencode/handler/index.ts +0 -2
- package/src/backend/opencode/handler/message.ts +0 -44
- package/src/backend/opencode/index.ts +0 -42
- package/src/backend/opencode/model-provider.ts +0 -36
- package/src/backend/opencode/models/index.ts +0 -54
- package/src/backend/opencode/one-shot.ts +0 -42
- package/src/backend/opencode/server.ts +0 -80
- package/src/backend/opencode/sessions.ts +0 -35
- /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { isAuthorizedAdmin } from "./state.js";
|
|
15
15
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
16
16
|
import { getMeshService } from "../../../core/mesh/index.js";
|
|
17
|
-
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
17
|
+
import type { MeshPingResult } from "../../../core/mesh/devices/service.js";
|
|
18
18
|
|
|
19
19
|
export function registerInfoCommands(bot: Bot): void {
|
|
20
20
|
bot.command("start", (ctx) =>
|
|
@@ -113,6 +113,9 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
113
113
|
}
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
/** True only in a 1:1 chat with the bot — never a group or channel. */
|
|
117
|
+
const isPrivate = (ctx: Context): boolean => ctx.chat?.type === "private";
|
|
118
|
+
|
|
116
119
|
/**
|
|
117
120
|
* The bridge footer `/mesh` prints for this caller.
|
|
118
121
|
*
|
|
@@ -121,10 +124,16 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
121
124
|
* itself should get an answer rather than a scavenger hunt through config
|
|
122
125
|
* files. Everyone else gets the address only: a group member reading the
|
|
123
126
|
* fleet has no business holding the key to it.
|
|
127
|
+
*
|
|
128
|
+
* Being the admin is not enough on its own: the secrets are withheld in
|
|
129
|
+
* any room that isn't a 1:1 with the bot. A group message is readable by
|
|
130
|
+
* every member, forwardable out of the group, and retained in their
|
|
131
|
+
* clients — so "the admin asked" says nothing about who ends up holding
|
|
132
|
+
* the bearer token. The admin can re-run the command in a DM.
|
|
124
133
|
*/
|
|
125
134
|
const bridgeFor = (ctx: Context): MeshReachability => {
|
|
126
135
|
const reach = getMeshService().bridgeReachability();
|
|
127
|
-
if (!reach.ok || isAuthorizedAdmin(ctx)) return reach;
|
|
136
|
+
if (!reach.ok || (isAuthorizedAdmin(ctx) && isPrivate(ctx))) return reach;
|
|
128
137
|
return {
|
|
129
138
|
ok: true,
|
|
130
139
|
url: reach.url,
|
|
@@ -137,24 +146,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
137
146
|
// `/mesh link` mints a bridge credential and posts it into the chat, so
|
|
138
147
|
// it is admin-gated even though plain `/mesh` is not — reading the fleet
|
|
139
148
|
// is not the same act as handing out the keys to it.
|
|
140
|
-
if (
|
|
141
|
-
if (!isAuthorizedAdmin(ctx)) {
|
|
142
|
-
await ctx.reply("Only the configured admin can mint a pairing link.");
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
// `/mesh link Car` names the connection on the phone; with no name it
|
|
146
|
-
// inherits the bot's, which is what the operator already calls this
|
|
147
|
-
// daemon everywhere else.
|
|
148
|
-
const named = arg.replace(/^(link|pair)\b/i, "").trim();
|
|
149
|
-
const minted = getMeshService().makeCompanionPairLink(
|
|
150
|
-
named || ctx.me.first_name,
|
|
151
|
-
);
|
|
152
|
-
await ctx.reply(renderMeshPairLink(minted), {
|
|
153
|
-
parse_mode: "HTML",
|
|
154
|
-
link_preview_options: { is_disabled: true },
|
|
155
|
-
});
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
149
|
+
if (await handleMeshLinkCommand(bot, ctx, arg)) return;
|
|
158
150
|
|
|
159
151
|
const sent = await ctx.reply("Pinging mesh devices…");
|
|
160
152
|
let results: MeshPingResult[];
|
|
@@ -207,6 +199,55 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
207
199
|
});
|
|
208
200
|
}
|
|
209
201
|
|
|
202
|
+
/**
|
|
203
|
+
* `/mesh link [name]` — mint and deliver a companion pairing link. Returns
|
|
204
|
+
* true when `arg` was a link request (handled here, reply sent), false when
|
|
205
|
+
* the caller should treat the command as a plain `/mesh` fleet ping.
|
|
206
|
+
*/
|
|
207
|
+
async function handleMeshLinkCommand(
|
|
208
|
+
bot: Bot,
|
|
209
|
+
ctx: Context & { match?: unknown },
|
|
210
|
+
arg: string,
|
|
211
|
+
): Promise<boolean> {
|
|
212
|
+
if (!/^(link|pair)\b/i.test(arg)) return false;
|
|
213
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
214
|
+
await ctx.reply("Only the configured admin can mint a pairing link.");
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// `/mesh link Car` names the connection on the phone; with no name it
|
|
218
|
+
// inherits the bot's, which is what the operator already calls this
|
|
219
|
+
// daemon everywhere else.
|
|
220
|
+
const named = arg.replace(/^(link|pair)\b/i, "").trim();
|
|
221
|
+
const minted = getMeshService().makeCompanionPairLink(
|
|
222
|
+
named || ctx.me.first_name,
|
|
223
|
+
);
|
|
224
|
+
const rendered = renderMeshPairLink(minted);
|
|
225
|
+
// The pairing block is a live credential: a single-use grant plus the
|
|
226
|
+
// bearer token and certificate for the manual fallback. In a group
|
|
227
|
+
// that is a key handed to every member, so deliver it to the admin's
|
|
228
|
+
// DM and leave only a receipt behind. The grant is minted either way,
|
|
229
|
+
// so a failed DM must say so rather than look like it worked.
|
|
230
|
+
if (ctx.chat?.type !== "private") {
|
|
231
|
+
try {
|
|
232
|
+
await bot.api.sendMessage(ctx.from!.id, rendered, {
|
|
233
|
+
parse_mode: "HTML",
|
|
234
|
+
link_preview_options: { is_disabled: true },
|
|
235
|
+
});
|
|
236
|
+
await ctx.reply("Sent the pairing link to your DM.");
|
|
237
|
+
} catch {
|
|
238
|
+
await ctx.reply(
|
|
239
|
+
"A pairing link carries the bridge token, so I won't post it in a group — and I couldn't DM you. Message me directly once, then run /mesh link there.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
await ctx.reply(rendered, {
|
|
245
|
+
parse_mode: "HTML",
|
|
246
|
+
link_preview_options: { is_disabled: true },
|
|
247
|
+
});
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
210
251
|
/** Edit the placeholder in place, falling back to a fresh reply. */
|
|
211
252
|
async function editOrReply(
|
|
212
253
|
bot: Bot,
|
|
@@ -12,6 +12,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler";
|
|
|
12
12
|
import type { TalonConfig } from "../../core/config/index.js";
|
|
13
13
|
import type { ContextManager } from "../../core/types.js";
|
|
14
14
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
15
|
+
import { runUntilStopped } from "../../core/frontend-runtime/run-loop.js";
|
|
15
16
|
import { createTelegramActionHandler, sendText } from "./actions/index.js";
|
|
16
17
|
import { ambientThreadId } from "./topics.js";
|
|
17
18
|
import { initUserClient, disconnectUserClient } from "./userbot.js";
|
|
@@ -56,6 +57,9 @@ export function createTelegramFrontend(
|
|
|
56
57
|
getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
/** The long-poll, from start() until stop() awaits it. */
|
|
61
|
+
let polling: Promise<void> | null = null;
|
|
62
|
+
|
|
59
63
|
return {
|
|
60
64
|
name: "telegram",
|
|
61
65
|
context,
|
|
@@ -131,15 +135,34 @@ export function createTelegramFrontend(
|
|
|
131
135
|
...API_CONSTANTS.DEFAULT_UPDATE_TYPES,
|
|
132
136
|
"chat_join_request" as const,
|
|
133
137
|
];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
// grammY's bot.start() promise is the long-poll: it resolves when
|
|
139
|
+
// POLLING STOPS, i.e. at shutdown. Readiness is onStart, which
|
|
140
|
+
// fires once getMe() succeeded and the first poll is out — so that
|
|
141
|
+
// is what start() waits for, while the poll itself is kept for
|
|
142
|
+
// stop().
|
|
143
|
+
const run = runUntilStopped(
|
|
144
|
+
(signalReady) =>
|
|
145
|
+
bot.start({
|
|
146
|
+
allowed_updates: allowedUpdates,
|
|
147
|
+
onStart: (info) => {
|
|
148
|
+
log("bot", `Talon running as @${info.username}`);
|
|
149
|
+
signalReady();
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
(err) => logError("bot", "Long-poll ended with an error", err),
|
|
153
|
+
);
|
|
154
|
+
polling = run.stopped;
|
|
155
|
+
await run.ready;
|
|
138
156
|
},
|
|
139
157
|
|
|
140
158
|
async stop() {
|
|
141
159
|
try {
|
|
142
160
|
await bot.stop();
|
|
161
|
+
// The long-poll is the promise bot.start() returned — awaiting it
|
|
162
|
+
// here is what makes "stopped" mean stopped, before the offset is
|
|
163
|
+
// confirmed below.
|
|
164
|
+
await polling;
|
|
165
|
+
polling = null;
|
|
143
166
|
// grammY advances the update offset on its NEXT poll, which never
|
|
144
167
|
// comes once we are shutting down — so confirm it explicitly or
|
|
145
168
|
// Telegram redelivers the command that triggered this shutdown.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { escapeHtml } from "../formatting.js";
|
|
11
11
|
import type { DoctorReport } from "../../../core/doctor/index.js";
|
|
12
|
-
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
12
|
+
import type { MeshPingResult } from "../../../core/mesh/devices/service.js";
|
|
13
13
|
import type { BackendUsageEntry } from "../../presentation/plan-usage-report.js";
|
|
14
14
|
import type { SettingsButton } from "./menu.js";
|
|
15
15
|
import {
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* - User presses Enter → rl.pause() immediately → processing begins
|
|
7
7
|
* - Processing finishes → all output written → rl.resume() + rl.prompt()
|
|
8
8
|
* - Renderer NEVER touches readline. Only this file does.
|
|
9
|
+
*
|
|
10
|
+
* `start()` returns once that prompt is drawn — the frontend lifecycle
|
|
11
|
+
* contract (core/frontend-runtime/capabilities.ts). It used to park on a
|
|
12
|
+
* promise that never resolved, which is why the composition root had to
|
|
13
|
+
* special-case the terminal and start it without awaiting.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import pc from "picocolors";
|
|
@@ -287,8 +292,7 @@ export function createTerminalFrontend(
|
|
|
287
292
|
}
|
|
288
293
|
});
|
|
289
294
|
|
|
290
|
-
input.prompt();
|
|
291
|
-
await new Promise(() => {});
|
|
295
|
+
input.prompt(); // started: turns run from the input callbacks above
|
|
292
296
|
},
|
|
293
297
|
|
|
294
298
|
async stop() {
|
|
@@ -132,8 +132,34 @@ function onClose(
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Wait `ms`, or until `stop()` asks the loop to end — whichever comes
|
|
137
|
+
* first. A shutdown awaits this loop, so no wait may outlast it.
|
|
138
|
+
*/
|
|
139
|
+
function sleep(runtime: WhatsAppRuntime, ms: number): Promise<void> {
|
|
140
|
+
const { signal } = runtime.stopRequest;
|
|
141
|
+
if (signal.aborted || runtime.stopping) return Promise.resolve();
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const done = (): void => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
signal.removeEventListener("abort", done);
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
const timer = setTimeout(done, ms);
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
signal.addEventListener("abort", done, { once: true });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One socket lifetime. Resolves with what the caller should do next.
|
|
156
|
+
* `onSocket` fires once the socket exists — that is the earliest honest
|
|
157
|
+
* "connecting", and what the frontend's `start()` resolves on.
|
|
158
|
+
*/
|
|
159
|
+
async function connectOnce(
|
|
160
|
+
runtime: WhatsAppRuntime,
|
|
161
|
+
onSocket: () => void,
|
|
162
|
+
): Promise<ConnectOutcome> {
|
|
137
163
|
// Atomic replacement for Baileys' useMultiFileAuthState — same disk
|
|
138
164
|
// format, torn-write-proof (see auth-state.ts for why that matters).
|
|
139
165
|
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
@@ -155,6 +181,7 @@ async function connectOnce(runtime: WhatsAppRuntime): Promise<ConnectOutcome> {
|
|
|
155
181
|
keepAliveIntervalMs: 25_000,
|
|
156
182
|
});
|
|
157
183
|
runtime.sock = socket;
|
|
184
|
+
onSocket();
|
|
158
185
|
socket.ev.on("creds.update", saveCreds);
|
|
159
186
|
bindInbound(runtime, socket);
|
|
160
187
|
|
|
@@ -204,40 +231,65 @@ async function parkUntilPaired(runtime: WhatsAppRuntime): Promise<void> {
|
|
|
204
231
|
}
|
|
205
232
|
const paired = await new Promise<boolean>((r) => {
|
|
206
233
|
const off = onPairingComplete(() => {
|
|
207
|
-
clearTimeout(timer);
|
|
208
234
|
off();
|
|
209
235
|
r(true);
|
|
210
236
|
});
|
|
211
|
-
|
|
237
|
+
// Wakes on the next poll, or at once when stop() aborts the wait.
|
|
238
|
+
void sleep(runtime, 30_000).then(() => {
|
|
212
239
|
off();
|
|
213
240
|
r(false);
|
|
214
|
-
}
|
|
215
|
-
timer.unref?.();
|
|
241
|
+
});
|
|
216
242
|
});
|
|
217
243
|
if (paired) return;
|
|
218
244
|
}
|
|
219
245
|
}
|
|
220
246
|
|
|
221
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* The frontend's run loop: connect, reconnect, park, until stopped. It
|
|
249
|
+
* ends at shutdown, so it is NOT what `start()` resolves on —
|
|
250
|
+
* `signalReady` is: it fires as soon as the first socket exists, or as
|
|
251
|
+
* soon as the loop parks waiting for a human to pair, whichever comes
|
|
252
|
+
* first. A loop that ends without either still releases the boot
|
|
253
|
+
* (`finally`), because a boot may fail but must never hang.
|
|
254
|
+
*/
|
|
222
255
|
export async function runConnectionLoop(
|
|
223
256
|
runtime: WhatsAppRuntime,
|
|
257
|
+
signalReady: () => void,
|
|
224
258
|
): Promise<void> {
|
|
225
259
|
log("whatsapp", "WhatsApp frontend starting (Baileys multi-device)");
|
|
260
|
+
try {
|
|
261
|
+
await connectionLoop(runtime, signalReady);
|
|
262
|
+
} finally {
|
|
263
|
+
signalReady();
|
|
264
|
+
log("whatsapp", "WhatsApp connection loop ended");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function connectionLoop(
|
|
269
|
+
runtime: WhatsAppRuntime,
|
|
270
|
+
signalReady: () => void,
|
|
271
|
+
): Promise<void> {
|
|
226
272
|
while (!runtime.stopping) {
|
|
227
273
|
// A manual pairing attempt owns the auth dir: two sockets on one
|
|
228
274
|
// keypair corrupt it and burn rate-limited pairing attempts.
|
|
229
275
|
if (isManualPairingActive()) {
|
|
230
|
-
|
|
276
|
+
// Boot is not held behind someone typing a pairing code.
|
|
277
|
+
signalReady();
|
|
278
|
+
await sleep(runtime, 5_000);
|
|
231
279
|
continue;
|
|
232
280
|
}
|
|
233
281
|
let outcome: ConnectOutcome;
|
|
234
282
|
try {
|
|
235
|
-
outcome = await connectOnce(runtime);
|
|
283
|
+
outcome = await connectOnce(runtime, signalReady);
|
|
236
284
|
} catch (err) {
|
|
237
285
|
logError(
|
|
238
286
|
"whatsapp",
|
|
239
287
|
`Socket error: ${err instanceof Error ? err.message : err}`,
|
|
240
288
|
);
|
|
289
|
+
// Up and retrying is still up: a socket that cannot be built (a
|
|
290
|
+
// half-written auth dir, say) must not hold the boot open while
|
|
291
|
+
// the loop keeps trying.
|
|
292
|
+
signalReady();
|
|
241
293
|
outcome = "reconnect";
|
|
242
294
|
}
|
|
243
295
|
runtime.sock = null;
|
|
@@ -270,11 +322,10 @@ export async function runConnectionLoop(
|
|
|
270
322
|
runtime.reconnectDelay = RECONNECT_BASE_MS;
|
|
271
323
|
continue;
|
|
272
324
|
}
|
|
273
|
-
await
|
|
325
|
+
await sleep(runtime, runtime.reconnectDelay);
|
|
274
326
|
runtime.reconnectDelay = Math.min(
|
|
275
327
|
runtime.reconnectDelay * 2,
|
|
276
328
|
RECONNECT_MAX_MS,
|
|
277
329
|
);
|
|
278
330
|
}
|
|
279
|
-
log("whatsapp", "WhatsApp connection loop ended");
|
|
280
331
|
}
|
|
@@ -35,6 +35,7 @@ import { flushAuthWrites } from "./connection/auth-state.js";
|
|
|
35
35
|
import { registerPairingProvider } from "../../core/frontend-runtime/pairing-broker.js";
|
|
36
36
|
import { beginPairingAttempt } from "./connection/pairing-service.js";
|
|
37
37
|
import { runConnectionLoop } from "./connection/connection.js";
|
|
38
|
+
import { runUntilStopped } from "../../core/frontend-runtime/run-loop.js";
|
|
38
39
|
import { lookupWhatsAppChat, type WhatsAppChatInfo } from "./registry.js";
|
|
39
40
|
import { createWhatsAppRuntime } from "./runtime.js";
|
|
40
41
|
|
|
@@ -75,6 +76,9 @@ export function createWhatsAppFrontend(
|
|
|
75
76
|
getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
/** The connection loop, from start() until stop() awaits it. */
|
|
80
|
+
let connectionLoop: Promise<void> | null = null;
|
|
81
|
+
|
|
78
82
|
return {
|
|
79
83
|
name: "whatsapp",
|
|
80
84
|
context,
|
|
@@ -132,10 +136,28 @@ export function createWhatsAppFrontend(
|
|
|
132
136
|
log("whatsapp", `Gateway on port ${port}`);
|
|
133
137
|
},
|
|
134
138
|
|
|
135
|
-
|
|
139
|
+
// The connection loop reconnects and re-pairs for the whole process
|
|
140
|
+
// lifetime, so it is the run promise, not the start: start() is over
|
|
141
|
+
// once the first socket is up (or the loop has parked waiting to be
|
|
142
|
+
// paired), and stop() below awaits the loop itself.
|
|
143
|
+
async start() {
|
|
144
|
+
const run = runUntilStopped(
|
|
145
|
+
(signalReady) => runConnectionLoop(runtime, signalReady),
|
|
146
|
+
(err) =>
|
|
147
|
+
logError(
|
|
148
|
+
"whatsapp",
|
|
149
|
+
`Connection loop failed: ${err instanceof Error ? err.message : err}`,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
connectionLoop = run.stopped;
|
|
153
|
+
await run.ready;
|
|
154
|
+
},
|
|
136
155
|
|
|
137
156
|
async stop() {
|
|
138
157
|
runtime.stopping = true;
|
|
158
|
+
// Wakes the reconnect backoff and the park-until-paired wait, so
|
|
159
|
+
// the loop we are about to await ends now rather than on its timer.
|
|
160
|
+
runtime.stopRequest.abort();
|
|
139
161
|
registerPairingProvider(null);
|
|
140
162
|
try {
|
|
141
163
|
runtime.sock?.end(undefined);
|
|
@@ -143,6 +165,8 @@ export function createWhatsAppFrontend(
|
|
|
143
165
|
/* already closed */
|
|
144
166
|
}
|
|
145
167
|
runtime.sock = null;
|
|
168
|
+
await connectionLoop;
|
|
169
|
+
connectionLoop = null;
|
|
146
170
|
// Drain queued credential writes before the process exits — a key
|
|
147
171
|
// half-written at shutdown is invisible until the server starts
|
|
148
172
|
// rejecting stanzas with it.
|
|
@@ -38,6 +38,12 @@ export type WhatsAppRuntime = {
|
|
|
38
38
|
/** The live socket. Reconnects replace it, so read it at each use. */
|
|
39
39
|
sock: WASocket | null;
|
|
40
40
|
stopping: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Aborted by `stop()`. The connection loop's waits (reconnect backoff,
|
|
43
|
+
* the park that holds an unpaired account) listen for it, so a
|
|
44
|
+
* shutdown that awaits the loop never sits out a timer it could skip.
|
|
45
|
+
*/
|
|
46
|
+
readonly stopRequest: AbortController;
|
|
41
47
|
reconnectDelay: number;
|
|
42
48
|
/** One "not linked" admin note per outage, not one per QR window. */
|
|
43
49
|
unpairedNotified: boolean;
|
|
@@ -67,6 +73,7 @@ export function createWhatsAppRuntime(
|
|
|
67
73
|
groupAllowCache: new Map(),
|
|
68
74
|
sock: null,
|
|
69
75
|
stopping: false,
|
|
76
|
+
stopRequest: new AbortController(),
|
|
70
77
|
reconnectDelay: RECONNECT_BASE_MS,
|
|
71
78
|
unpairedNotified: false,
|
|
72
79
|
selfIds: [],
|
package/src/util/log.ts
CHANGED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kilo backend factory — wires Kilo into the registry.
|
|
3
|
-
*
|
|
4
|
-
* Side-effect import (`import "./factory.js"` from `bootstrap.ts`) calls
|
|
5
|
-
* `registerBackend(...)` at module load, making Kilo available under
|
|
6
|
-
* `config.backend === "kilo"`. The capability wiring is the shared
|
|
7
|
-
* remote-server composition; only the bound functions are Kilo's.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
|
|
11
|
-
import { createRemoteBackendFactory } from "../remote-server/factory.js";
|
|
12
|
-
import {
|
|
13
|
-
initKiloAgent,
|
|
14
|
-
stopKiloServer,
|
|
15
|
-
handleMessage,
|
|
16
|
-
runOneShotAgent,
|
|
17
|
-
getKiloSessionSnapshot,
|
|
18
|
-
resolveModel,
|
|
19
|
-
getModelInfo,
|
|
20
|
-
getSettingsPresentation,
|
|
21
|
-
getProviders,
|
|
22
|
-
getProviderModels,
|
|
23
|
-
formatModelError,
|
|
24
|
-
listModels,
|
|
25
|
-
refreshPluginMcpServers,
|
|
26
|
-
updateSystemPrompt,
|
|
27
|
-
warmSession,
|
|
28
|
-
} from "./index.js";
|
|
29
|
-
|
|
30
|
-
registerBackend(
|
|
31
|
-
createRemoteBackendFactory({
|
|
32
|
-
id: "kilo",
|
|
33
|
-
label: "Kilo",
|
|
34
|
-
sdkPackage: "@kilocode/sdk",
|
|
35
|
-
init: initKiloAgent,
|
|
36
|
-
stop: stopKiloServer,
|
|
37
|
-
handleMessage,
|
|
38
|
-
runOneShotAgent,
|
|
39
|
-
getSessionSnapshot: getKiloSessionSnapshot,
|
|
40
|
-
models: {
|
|
41
|
-
resolveModel,
|
|
42
|
-
getModelInfo,
|
|
43
|
-
getSettingsPresentation,
|
|
44
|
-
getProviders,
|
|
45
|
-
getProviderModels,
|
|
46
|
-
formatModelError,
|
|
47
|
-
listModels,
|
|
48
|
-
},
|
|
49
|
-
refreshPluginMcpServers,
|
|
50
|
-
warmSession,
|
|
51
|
-
updateSystemPrompt,
|
|
52
|
-
}),
|
|
53
|
-
);
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kilo main message handler — the shared remote-server chat turn bound to
|
|
3
|
-
* Kilo's server bindings.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
QueryParams,
|
|
8
|
-
QueryResult,
|
|
9
|
-
} from "../../runtime/turn/handler-types.js";
|
|
10
|
-
import { runRemoteChatTurn } from "../../remote-server/chat-turn.js";
|
|
11
|
-
import {
|
|
12
|
-
ensureServer,
|
|
13
|
-
trackActiveTurn,
|
|
14
|
-
ensureSession,
|
|
15
|
-
ensureChatMcpServer,
|
|
16
|
-
ensurePluginMcpServers,
|
|
17
|
-
buildToolOverrides,
|
|
18
|
-
resolveProviderID,
|
|
19
|
-
parseStoredKiloModelSelection,
|
|
20
|
-
getConfig,
|
|
21
|
-
kiloSystemPromptSuffix,
|
|
22
|
-
} from "../server.js";
|
|
23
|
-
|
|
24
|
-
export function handleMessage(params: QueryParams): Promise<QueryResult> {
|
|
25
|
-
// Assembled per call, not at module load: tests mock `../server.js`
|
|
26
|
-
// with a partial surface and only touch the members they need.
|
|
27
|
-
return runRemoteChatTurn(
|
|
28
|
-
{
|
|
29
|
-
id: "kilo",
|
|
30
|
-
label: "Kilo",
|
|
31
|
-
getConfig,
|
|
32
|
-
ensureServer,
|
|
33
|
-
trackActiveTurn,
|
|
34
|
-
parseModelSelection: parseStoredKiloModelSelection,
|
|
35
|
-
resolveProviderID,
|
|
36
|
-
ensureSession,
|
|
37
|
-
ensureChatMcpServer,
|
|
38
|
-
ensurePluginMcpServers,
|
|
39
|
-
buildToolOverrides,
|
|
40
|
-
systemPromptSuffix: kiloSystemPromptSuffix,
|
|
41
|
-
},
|
|
42
|
-
params,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kilo backend — barrel re-export.
|
|
3
|
-
*
|
|
4
|
-
* Uses the `@kilocode/sdk` (a fork of `@opencode-ai/sdk`) and exposes a
|
|
5
|
-
* Backend-compatible API. The implementation is split across
|
|
6
|
-
* focused modules for readability:
|
|
7
|
-
*
|
|
8
|
-
* - `models.ts` — model catalog, search, resolution, presentation
|
|
9
|
-
* - `sessions.ts` — message parsing, usage summaries, snapshots
|
|
10
|
-
* - `server.ts` — server lifecycle, MCP, session management
|
|
11
|
-
* - `events.ts` — Kilo-side configurator over shared SSE events
|
|
12
|
-
* - `handler.ts` — main message handler (streaming, end_turn,
|
|
13
|
-
* delivery routing)
|
|
14
|
-
* - `one-shot.ts` — heartbeat / dream one-shot runner
|
|
15
|
-
* - `model-provider.ts` — adapts the catalog to the Backend interface
|
|
16
|
-
*
|
|
17
|
-
* Note: internal `OpenCode*` type names in `models.ts` (e.g.
|
|
18
|
-
* `OpenCodeModelCatalogEntry`) are retained on purpose — Kilo's
|
|
19
|
-
* provider-bucket API is forked from OpenCode's wire shape, so the
|
|
20
|
-
* names match what the upstream actually emits.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
// ── Models ─────────────────────────────────────────────────────────────────
|
|
24
|
-
export {
|
|
25
|
-
getOpenCodeModelSelectionValue,
|
|
26
|
-
resolveOpenCodeModelInput,
|
|
27
|
-
getOpenCodeQuickPickModels,
|
|
28
|
-
formatOpenCodeSelectionError,
|
|
29
|
-
} from "./models/index.js";
|
|
30
|
-
|
|
31
|
-
// ── Sessions ───────────────────────────────────────────────────────────────
|
|
32
|
-
export {
|
|
33
|
-
summarizeKiloAssistantMessages,
|
|
34
|
-
getKiloSessionSnapshot,
|
|
35
|
-
} from "./sessions.js";
|
|
36
|
-
|
|
37
|
-
// ── Server / lifecycle ─────────────────────────────────────────────────────
|
|
38
|
-
export {
|
|
39
|
-
initKiloAgent,
|
|
40
|
-
stopKiloServer,
|
|
41
|
-
refreshPluginMcpServers,
|
|
42
|
-
updateSystemPrompt,
|
|
43
|
-
warmSession,
|
|
44
|
-
} from "./server.js";
|
|
45
|
-
|
|
46
|
-
// ── Handler ────────────────────────────────────────────────────────────────
|
|
47
|
-
export { handleMessage } from "./handler/index.js";
|
|
48
|
-
|
|
49
|
-
// ── Model provider (Backend adapter) ──────────────────────────────────
|
|
50
|
-
export {
|
|
51
|
-
resolveModel,
|
|
52
|
-
getModelInfo,
|
|
53
|
-
getSettingsPresentation,
|
|
54
|
-
getProviders,
|
|
55
|
-
getProviderModels,
|
|
56
|
-
listModels,
|
|
57
|
-
formatModelError,
|
|
58
|
-
} from "./model-provider.js";
|
|
59
|
-
|
|
60
|
-
// ── One-shot agent runner ──────────────────────────────────────────────────
|
|
61
|
-
export { runOneShotAgent } from "./one-shot.js";
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kilo model provider — adapts the internal catalog to the Backend model
|
|
3
|
-
* interface via the shared remote-server factory. Each export matches a
|
|
4
|
-
* method on `Backend.models`.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { createRemoteModelProvider } from "../remote-server/model-catalog/index.js";
|
|
8
|
-
import {
|
|
9
|
-
getOpenCodeModelCatalog,
|
|
10
|
-
getOpenCodeModelInfo,
|
|
11
|
-
getOpenCodeModelSelectionValue,
|
|
12
|
-
resolveOpenCodeModelInput,
|
|
13
|
-
getOpenCodeSettingsPresentation,
|
|
14
|
-
formatOpenCodeUnavailableModel,
|
|
15
|
-
} from "./models/index.js";
|
|
16
|
-
|
|
17
|
-
const provider = createRemoteModelProvider({
|
|
18
|
-
label: "Kilo",
|
|
19
|
-
getCatalog: (forceRefresh) => getOpenCodeModelCatalog(forceRefresh),
|
|
20
|
-
getModelInfo: (id) => getOpenCodeModelInfo(id),
|
|
21
|
-
resolveModelInput: (query, catalog) =>
|
|
22
|
-
resolveOpenCodeModelInput(query, catalog),
|
|
23
|
-
getSelectionValue: (model, catalog) =>
|
|
24
|
-
getOpenCodeModelSelectionValue(model, catalog),
|
|
25
|
-
formatUnavailableModel: (model) => formatOpenCodeUnavailableModel(model),
|
|
26
|
-
getSettingsPresentation: (activeModel, callbackPrefix) =>
|
|
27
|
-
getOpenCodeSettingsPresentation(activeModel, callbackPrefix),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const resolveModel = provider.resolveModel;
|
|
31
|
-
export const getModelInfo = provider.getModelInfo;
|
|
32
|
-
export const getSettingsPresentation = provider.getSettingsPresentation;
|
|
33
|
-
export const getProviders = provider.getProviders;
|
|
34
|
-
export const getProviderModels = provider.getProviderModels;
|
|
35
|
-
export const listModels = provider.listModels;
|
|
36
|
-
export const formatModelError = provider.formatModelError;
|