talon-agent 3.29.0 → 3.31.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 +1 -1
- package/package.json +2 -2
- package/src/core/mesh/service.ts +2 -2
- package/src/core/tools/mesh.ts +2 -2
- package/src/frontend/telegram/handlers/access.ts +28 -2
- package/src/frontend/telegram/handlers/state.ts +2 -0
- package/src/frontend/telegram/index.ts +1 -0
- package/src/frontend/whatsapp/index.ts +19 -5
- package/src/frontend/whatsapp/pairing.ts +19 -0
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/util/config.ts +4 -0
package/README.md
CHANGED
|
@@ -329,7 +329,7 @@ On first boot Talon creates a venv at `~/.talon/mempalace-venv` and installs the
|
|
|
329
329
|
"backend": "mempalace",
|
|
330
330
|
"mempalace": {
|
|
331
331
|
"palacePath": "~/.talon/workspace/palace",
|
|
332
|
-
"version": "3.
|
|
332
|
+
"version": "3.9.0",
|
|
333
333
|
"autoUpdate": true,
|
|
334
334
|
"autoProvision": true
|
|
335
335
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.31.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"@kilocode/sdk": "^7.2.22",
|
|
107
107
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
108
108
|
"@openai/agents": "^0.17.0",
|
|
109
|
-
"@openai/codex-sdk": "^0.
|
|
109
|
+
"@openai/codex-sdk": "^0.150.1",
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
111
|
"@playwright/mcp": "0.0.79",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -888,7 +888,7 @@ export class MeshService {
|
|
|
888
888
|
|
|
889
889
|
/**
|
|
890
890
|
* `update_device`: remote self-update for the companion. Streams a new APK
|
|
891
|
-
* to the device, then tells it to silently install (
|
|
891
|
+
* to the device, then tells it to silently install (root or Shizuku) and
|
|
892
892
|
* restart. The mesh foreground service's autoRunOnMyPackageReplaced brings
|
|
893
893
|
* the connection back on its own — the link drops only for the seconds the
|
|
894
894
|
* process is swapped, no manual reopen.
|
|
@@ -914,7 +914,7 @@ export class MeshService {
|
|
|
914
914
|
if (target.capabilities && !target.capabilities.includes("install_apk")) {
|
|
915
915
|
return {
|
|
916
916
|
ok: false,
|
|
917
|
-
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and Shizuku enabled (device control on).`,
|
|
917
|
+
text: `${target.name} can't self-update — it needs a companion build with the install_apk capability and root or Shizuku enabled (device control on).`,
|
|
918
918
|
};
|
|
919
919
|
}
|
|
920
920
|
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -85,7 +85,7 @@ export const meshTools: ToolDefinition[] = [
|
|
|
85
85
|
{
|
|
86
86
|
name: "device_exec",
|
|
87
87
|
description:
|
|
88
|
-
"Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it
|
|
88
|
+
"Run a shell command ON a Talon companion device (e.g. the phone) and return its stdout/stderr/exit code. The device executes it at the highest privilege it has (Android: root if the device grants it, else Shizuku's shell UID, else the app UID; the result's `via` says which). Use for on-device tasks like tidying a folder. Prefer `teleport` for a sustained session. For persistent streams (log tailing, watchers), background with output redirected to a file — `cmd > /tmp/out.log 2>&1 &` returns immediately; poll the file with device_read_file.",
|
|
89
89
|
schema: {
|
|
90
90
|
device: deviceParam,
|
|
91
91
|
cmd: z.string().describe("The shell command to run on the device."),
|
|
@@ -167,7 +167,7 @@ export const meshTools: ToolDefinition[] = [
|
|
|
167
167
|
{
|
|
168
168
|
name: "update_device",
|
|
169
169
|
description:
|
|
170
|
-
"Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it
|
|
170
|
+
"Remotely update a Talon companion (Android): stream a new APK from the daemon to the device and silently reinstall it at an elevated tier (root or Shizuku), keeping app data. The companion's mesh runs in a foreground service that auto-restarts after the package is replaced, so the connection returns on its own within seconds — no manual reopen. The APK is hashed and the device verifies it before installing (a truncated push is refused; a differently-signed APK is refused by Android). Requires the device to have device control on and either root or Shizuku granted. Confirm success with get_device_status afterwards (appVersion should change).",
|
|
171
171
|
schema: {
|
|
172
172
|
device: deviceParam,
|
|
173
173
|
apk_path: z
|
|
@@ -41,14 +41,31 @@ export function trackDmUser(
|
|
|
41
41
|
|
|
42
42
|
export function setAccessControl(cfg: {
|
|
43
43
|
allowedUsers?: number[];
|
|
44
|
+
blockedUsers?: number[];
|
|
44
45
|
adminUserId?: number;
|
|
45
46
|
}): void {
|
|
46
47
|
accessConfig.allowedUserIds = cfg.allowedUsers?.length
|
|
47
48
|
? new Set(cfg.allowedUsers)
|
|
48
49
|
: null;
|
|
50
|
+
accessConfig.blockedUserIds = cfg.blockedUsers?.length
|
|
51
|
+
? new Set(cfg.blockedUsers)
|
|
52
|
+
: null;
|
|
49
53
|
accessConfig.adminId = cfg.adminUserId ?? 0;
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Blocked senders are dropped before any other check, in silence.
|
|
58
|
+
*
|
|
59
|
+
* The whitelist already stops an unknown sender being *acted on*, but it still
|
|
60
|
+
* answers them with a warning and pings the admin. For a spammer or a
|
|
61
|
+
* prompt-injection sender that reply is the payoff — it confirms a live bot is
|
|
62
|
+
* reading — and the admin ping is the cost. Blocking removes both.
|
|
63
|
+
*/
|
|
64
|
+
function isBlocked(senderId: number | undefined): boolean {
|
|
65
|
+
if (!accessConfig.blockedUserIds || senderId === undefined) return false;
|
|
66
|
+
return accessConfig.blockedUserIds.has(senderId);
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/**
|
|
53
70
|
* Check if a DM user is allowed. Returns true if no whitelist is set.
|
|
54
71
|
*/
|
|
@@ -114,15 +131,24 @@ export function shouldHandleInGroup(ctx: Context): boolean {
|
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/**
|
|
117
|
-
* Full access check: DM whitelist + group admin membership.
|
|
134
|
+
* Full access check: denylist, then DM whitelist + group admin membership.
|
|
118
135
|
* Returns true if the message should be processed.
|
|
119
|
-
* Warns unauthorized users and notifies the admin
|
|
136
|
+
* Warns unauthorized users and notifies the admin — except blocked users,
|
|
137
|
+
* who are dropped silently.
|
|
120
138
|
*/
|
|
121
139
|
export async function isAccessAllowed(
|
|
122
140
|
ctx: Context,
|
|
123
141
|
bot: Bot,
|
|
124
142
|
): Promise<boolean> {
|
|
125
143
|
if (!ctx.chat) return false;
|
|
144
|
+
|
|
145
|
+
// Denylist wins over everything, including the group path: a blocked user
|
|
146
|
+
// gets no reply and generates no notification, anywhere.
|
|
147
|
+
if (isBlocked(ctx.from?.id)) {
|
|
148
|
+
log("users", `Dropped message from blocked user [id:${ctx.from?.id}]`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
|
|
127
153
|
|
|
128
154
|
if (!isGroup) {
|
|
@@ -20,9 +20,11 @@ export const KNOWN_DM_USERS_CAP = 10_000;
|
|
|
20
20
|
/** Reassignable access config — holder object so setAccessControl can mutate. */
|
|
21
21
|
export const accessConfig: {
|
|
22
22
|
allowedUserIds: Set<number> | null; // null = no whitelist (allow all)
|
|
23
|
+
blockedUserIds: Set<number> | null; // null = no denylist
|
|
23
24
|
adminId: number;
|
|
24
25
|
} = {
|
|
25
26
|
allowedUserIds: null,
|
|
27
|
+
blockedUserIds: null,
|
|
26
28
|
adminId: 0,
|
|
27
29
|
};
|
|
28
30
|
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
seedMessageStore,
|
|
54
54
|
} from "./message-store.js";
|
|
55
55
|
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
56
|
-
import { classifyClose, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
56
|
+
import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
57
57
|
import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
|
|
58
58
|
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
59
59
|
import { makeWaLogger } from "./wa-logger.js";
|
|
@@ -527,11 +527,15 @@ export function createWhatsAppFrontend(
|
|
|
527
527
|
case "logged-out":
|
|
528
528
|
return resolve("logged-out");
|
|
529
529
|
default:
|
|
530
|
-
// A socket that died without ever
|
|
531
|
-
// window that expired — reconnecting would just open QR
|
|
530
|
+
// A socket that died without ever completing a login is a
|
|
531
|
+
// pairing window that expired — reconnecting would just open QR
|
|
532
532
|
// session after QR session against WhatsApp's servers.
|
|
533
533
|
// Park instead; /whatsapp pair opens the next one.
|
|
534
|
-
|
|
534
|
+
//
|
|
535
|
+
// Test with isPaired, not creds.registered: a QR-linked session
|
|
536
|
+
// never sets that flag, so the flag alone parks a healthy
|
|
537
|
+
// session on its first ordinary disconnect.
|
|
538
|
+
if (!isPaired(state.creds)) return resolve("unpaired");
|
|
535
539
|
log(
|
|
536
540
|
"whatsapp",
|
|
537
541
|
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
@@ -556,7 +560,17 @@ export function createWhatsAppFrontend(
|
|
|
556
560
|
resolvePath(dirs.whatsappAuth, "creds.json"),
|
|
557
561
|
"utf-8",
|
|
558
562
|
);
|
|
559
|
-
|
|
563
|
+
// Same trap as above: waiting on `registered` alone means a QR
|
|
564
|
+
// re-link can never end the park, because that flag stays false.
|
|
565
|
+
if (
|
|
566
|
+
isPaired(
|
|
567
|
+
JSON.parse(raw) as {
|
|
568
|
+
registered?: boolean;
|
|
569
|
+
me?: { id?: string } | null;
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
return;
|
|
560
574
|
} catch {
|
|
561
575
|
/* no creds yet — stay parked */
|
|
562
576
|
}
|
|
@@ -41,5 +41,24 @@ export function classifyClose(
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Has this auth state ever completed a login?
|
|
46
|
+
*
|
|
47
|
+
* `creds.registered` looks like the obvious test and is a trap: Baileys only
|
|
48
|
+
* sets it on the pairing-CODE path. A session linked by scanning the QR stays
|
|
49
|
+
* `registered: false` forever while being completely authenticated — it has
|
|
50
|
+
* `me`, `account`, app-state keys, and it sends and receives normally.
|
|
51
|
+
*
|
|
52
|
+
* Reading the flag alone therefore cannot tell a live QR session apart from a
|
|
53
|
+
* pairing window that expired without anyone scanning it, which is the
|
|
54
|
+
* distinction the reconnect loop actually needs.
|
|
55
|
+
*/
|
|
56
|
+
export function isPaired(creds: {
|
|
57
|
+
registered?: boolean;
|
|
58
|
+
me?: { id?: string } | null;
|
|
59
|
+
}): boolean {
|
|
60
|
+
return Boolean(creds.registered || creds.me?.id);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
/** How long to sit out after a 440 — another client owns the session. */
|
|
45
64
|
export const REPLACED_BACKOFF_MS = 60_000;
|
|
@@ -47,7 +47,7 @@ import { dirs, files } from "../../util/paths.js";
|
|
|
47
47
|
* to. Bump deliberately, with the canary workflow green — see
|
|
48
48
|
* .github/workflows/native-provision.yml.
|
|
49
49
|
*/
|
|
50
|
-
export const MEMPALACE_PINNED_VERSION = "3.
|
|
50
|
+
export const MEMPALACE_PINNED_VERSION = "3.9.0";
|
|
51
51
|
|
|
52
52
|
/** Minimum python for the managed venv (matches mempalace's supported floor). */
|
|
53
53
|
const PYTHON_MIN = { major: 3, minor: 10 };
|
package/src/util/config.ts
CHANGED
|
@@ -395,6 +395,10 @@ const configSchema = z.object({
|
|
|
395
395
|
apiHash: z.string().optional(),
|
|
396
396
|
adminUserId: z.number().int().optional(),
|
|
397
397
|
allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
|
|
398
|
+
// Denylist of user IDs dropped in silence — no warning reply, no admin
|
|
399
|
+
// notification. For spam and prompt-injection senders, where the warning
|
|
400
|
+
// itself is the reward: it confirms a live bot is reading.
|
|
401
|
+
blockedUsers: z.array(z.number().int()).optional(),
|
|
398
402
|
pulse: z.boolean().default(true),
|
|
399
403
|
pulseIntervalMs: z.number().int().min(60000).default(300000),
|
|
400
404
|
/**
|