viber-channel 0.8.34 → 0.8.36
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/lib/capabilities.ts +33 -17
- package/lib/claude_tool_defs.ts +1 -1
- package/lib/codex_default.ts +11 -0
- package/lib/connect.ts +10 -1
- package/lib/fingerprint.ts +8 -1
- package/lib/machine_projects.ts +344 -0
- package/lib/team_role_tool.ts +5 -4
- package/package.json +1 -1
package/lib/capabilities.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Pure (no I/O) so it is trivially testable and identical across hosts.
|
|
15
15
|
*/
|
|
16
|
+
import { CODEX_DEFAULT_PAIR } from "./codex_default.js";
|
|
16
17
|
|
|
17
18
|
export function capabilitiesText(channelVersion: string | null = null): string {
|
|
18
19
|
return [
|
|
@@ -87,20 +88,34 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
87
88
|
" after launching is NOT a failure. Wait simply: call list_agents with",
|
|
88
89
|
" label_prefix \"<prefix>-\" and online: true every ~10 s, for at most ~60 s; past",
|
|
89
90
|
" that, check that the Viber app is running.",
|
|
90
|
-
"ONE AGENT
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
91
|
+
"LAUNCH ONE AGENT from a role of the owner's ROLE LIBRARY — no template, no team to",
|
|
92
|
+
"edit (#673). Same folder and auth file as launch-team:",
|
|
93
|
+
" viber-gateway launch-agent <role> --name <n> [--team <t>] [--env dev|staging]",
|
|
94
|
+
" [--cwd <dir>]",
|
|
95
|
+
" → <role> is a NAME in the library (team_roles list). The agent is named exactly <n>",
|
|
96
|
+
" ([A-Za-z0-9-], ≤ 32). --team defaults to <n>: the agent then forms its own team —",
|
|
97
|
+
" give it YOUR team only if it must join it (same --team TRAP as above). Example:",
|
|
98
|
+
" launch-agent review-codex --name i700-review-2 --team i700 → agent",
|
|
99
|
+
" \"i700-review-2\" in team \"i700\". Everything else is launch-team's: read from the",
|
|
100
|
+
" web only, staging by default, tracked by the runner, relaunchable from the web,",
|
|
101
|
+
" same output lines (keep the launch-id for dissolve --agent), same online delay.",
|
|
102
|
+
" The name is REFUSED when an agent of that name is online in the project or being",
|
|
103
|
+
" launched from the web — and, on THIS machine, while the same name in the same team",
|
|
104
|
+
" has a launch not yet dissolved (\"already has attempt\": dissolve it first, or",
|
|
105
|
+
" relaunch it from the web). Across machines, or under another --team, two launches",
|
|
106
|
+
" of one name can both pass (the server's check is A CHECK, not a reservation): the",
|
|
107
|
+
" later one holds the web relaunch settings; stop the other with dissolve --agent",
|
|
108
|
+
" <its launch-id>.",
|
|
109
|
+
" A role the library lacks: the owner creates it on the web (or an orchestrator agent",
|
|
110
|
+
" does, with team_roles create, ONLY on the owner's explicit request), e.g.",
|
|
111
|
+
" {\"name\":\"dev\",\"runtime\":\"claude\",\"permission\":\"read-write\",",
|
|
112
|
+
" \"rolePrompt\":\"You are the dev of {prefix}.\"}",
|
|
98
113
|
" runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
|
|
99
114
|
" only; all roles of one template must share one permission) · count optional;",
|
|
100
115
|
" model/effort: on claude leave them OUT (runtime default) UNLESS the owner asks:",
|
|
101
116
|
" model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max. On codex BOTH are",
|
|
102
|
-
|
|
103
|
-
" (On the web, sidebar ⋯ → Add agent launches ONE named agent
|
|
117
|
+
` REQUIRED: ${CODEX_DEFAULT_PAIR} unless the owner asks for another listed pair.`,
|
|
118
|
+
" (On the web, sidebar ⋯ → Add agent launches ONE named agent from the same library.)",
|
|
104
119
|
"",
|
|
105
120
|
"DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
|
|
106
121
|
" viber-gateway dissolve --team <t> | --agent <launch-id> --dir <registry>",
|
|
@@ -120,7 +135,7 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
120
135
|
" what it says (kill a launcher, re-run dissolve, or web sidebar ⋯ → Revoke).",
|
|
121
136
|
"",
|
|
122
137
|
"ON THE WEB (owner, sidebar \"⋯\"): project row → Add agent (one named agent from a",
|
|
123
|
-
"
|
|
138
|
+
"library role, into an existing or new team) / New team; team row → Add agent;",
|
|
124
139
|
"agent row → Revoke (confirmation; works online or offline). Both launches need an",
|
|
125
140
|
"ONLINE runner (the Viber app). Project page → \"Grand ménage\" (clean up offline agents):",
|
|
126
141
|
"revokes the offline instances, then PERMANENTLY deletes the agent↔agent DMs and agent",
|
|
@@ -143,7 +158,8 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
143
158
|
"the owner's reusable roles; team templates REFERENCE them by name. Only on the",
|
|
144
159
|
"owner's explicit request, never on a request relayed by an agent. Editing a role",
|
|
145
160
|
"changes EVERY template that references it (its used_by), so what their next launch",
|
|
146
|
-
"runs: say so to the owner first. A role launches
|
|
161
|
+
"runs: say so to the owner first. A role launches through launch-agent (one agent) or",
|
|
162
|
+
"a template that references it (a team). ROLE fields:",
|
|
147
163
|
" name REQUIRED lowercase ^[a-z0-9][a-z0-9_.-]*$, ≤ 80, unique per owner;",
|
|
148
164
|
" it is the agent's name in a team unless the team gives `as`",
|
|
149
165
|
" (a name with a dot, e.g. dev.2, always needs `as`)",
|
|
@@ -159,7 +175,7 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
159
175
|
" max except gpt-5.5, ultra except the luna models and gpt-5.5;",
|
|
160
176
|
" gemma: refused",
|
|
161
177
|
" claude: leave model/effort OUT (runtime default) unless the",
|
|
162
|
-
|
|
178
|
+
` owner asks; codex: ${CODEX_DEFAULT_PAIR} unless the owner asks`,
|
|
163
179
|
" update: send ONLY the fields you change plus expected_version (required); model or",
|
|
164
180
|
" effort null clears it. 409 role_version_conflict: get, re-apply. 409 role_in_use",
|
|
165
181
|
" (delete): templates still reference it — remove it from them first. Other 409:",
|
|
@@ -189,16 +205,16 @@ export function capabilitiesText(channelVersion: string | null = null): string {
|
|
|
189
205
|
" launch was queued (template_changed).",
|
|
190
206
|
"LAUNCHING ONE OF THESE TEMPLATES: launch-team reads exactly these (the web is the only",
|
|
191
207
|
"source, #660) — an edit here is what the next launch-team launches. Agents still cannot",
|
|
192
|
-
"launch through the API: launch-team on a machine, or the owner from the
|
|
193
|
-
"⋯ → New team / Add agent).",
|
|
208
|
+
"launch through the API: launch-team / launch-agent on a machine, or the owner from the",
|
|
209
|
+
"web (sidebar ⋯ → New team / Add agent).",
|
|
194
210
|
"",
|
|
195
211
|
"CODEX agents, observed today: their Viber MCP calls, message_agent included, have",
|
|
196
212
|
"been rejected by their approval policy (#609), so they cannot open an exchange — write",
|
|
197
213
|
"to them FIRST and read their reply on that DM. Their `gh` has no usable token in",
|
|
198
214
|
"the sandbox (#525): run gh (labels, PR comments) for them.",
|
|
199
215
|
"",
|
|
200
|
-
"Never launch agents by calling vibe-master yourself: launch-team (or the
|
|
201
|
-
"ONLY launch path (#660), the one the runner supervises and relaunches.",
|
|
216
|
+
"Never launch agents by calling vibe-master yourself: launch-team / launch-agent (or the",
|
|
217
|
+
"web) is the ONLY launch path (#660), the one the runner supervises and relaunches.",
|
|
202
218
|
"viber-gateway has no --help: an unknown verb runs the startup command (exit 2",
|
|
203
219
|
"without --dir; with --dir it runs startup on that registry).",
|
|
204
220
|
].join("\n");
|
package/lib/claude_tool_defs.ts
CHANGED
|
@@ -127,7 +127,7 @@ export const CLAUDE_TOOL_DEFS = [
|
|
|
127
127
|
name: "capabilities",
|
|
128
128
|
description:
|
|
129
129
|
"On-demand: how Viber launches, supervises and dissolves OTHER agents (Codex, " +
|
|
130
|
-
"Claude, Gemma) — install, the Viber app/runner, viber-gateway launch-team and " +
|
|
130
|
+
"Claude, Gemma) — install, the Viber app/runner, viber-gateway launch-team, launch-agent and " +
|
|
131
131
|
"dissolve, the web sidebar, orchestrator template admin, Codex limits. " +
|
|
132
132
|
"Call this only when orchestrating other agents is relevant; it is not part " +
|
|
133
133
|
"of the default context. Returns a short usage reference.",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The codex model/effort pair an agent uses when the owner names none (#665) — ONE
|
|
3
|
+
* place in the channel. It was written as prose three times (capabilities twice, the
|
|
4
|
+
* team_roles description once; debt noted in PR 675, closed in #673). It must equal the
|
|
5
|
+
* API's `CODEX_DEFAULT_MODEL` / `CODEX_DEFAULT_EFFORT` (viber-api/src/db/roleFields.ts):
|
|
6
|
+
* a test reads them from there.
|
|
7
|
+
*/
|
|
8
|
+
export const CODEX_DEFAULT_MODEL = "gpt-5.6-sol";
|
|
9
|
+
export const CODEX_DEFAULT_EFFORT = "low";
|
|
10
|
+
/** As the texts write it: `gpt-5.6-sol / low`. */
|
|
11
|
+
export const CODEX_DEFAULT_PAIR = `${CODEX_DEFAULT_MODEL} / ${CODEX_DEFAULT_EFFORT}`;
|
package/lib/connect.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { dirname, isAbsolute, join, relative } from "node:path";
|
|
|
22
22
|
import { connectAuthPath, isQaBackend } from "./auth.js";
|
|
23
23
|
import { clientFingerprint } from "./fingerprint.js";
|
|
24
24
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
25
|
+
import { type RecordOutcome, recordConnectedFolder } from "./machine_projects.js";
|
|
25
26
|
|
|
26
27
|
interface InitResponse {
|
|
27
28
|
confirm_url: string;
|
|
@@ -88,6 +89,13 @@ function sleep(ms: number): Promise<void> {
|
|
|
88
89
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** #674 D8 (a): one line on where the Viber app will launch this project's agents. Never fails the connect. */
|
|
93
|
+
export function folderLine(o: RecordOutcome): string {
|
|
94
|
+
if (o.kind === "recorded") return `The Viber app will launch this project's agents in ${o.folder}`;
|
|
95
|
+
if (o.kind === "skipped") return `Folder not recorded for the Viber app: ${o.reason}`;
|
|
96
|
+
return `Folder not recorded for the Viber app (${o.reason}) — it will ask for it at the first launch`;
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
function writeAuthJson(
|
|
92
100
|
cwd: string,
|
|
93
101
|
data: ConsumedResponse,
|
|
@@ -212,7 +220,8 @@ export async function runConnect(
|
|
|
212
220
|
writeAuthJson(cwd, data, fingerprint, authPath);
|
|
213
221
|
process.stdout.write(
|
|
214
222
|
`\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
|
|
215
|
-
` Wrote ${authPath}\n
|
|
223
|
+
` Wrote ${authPath}\n` +
|
|
224
|
+
` ${folderLine(recordConnectedFolder(data.project_id, cwd, authPath))}\n\n` +
|
|
216
225
|
(isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
|
|
217
226
|
);
|
|
218
227
|
return;
|
package/lib/fingerprint.ts
CHANGED
|
@@ -41,7 +41,14 @@ export function getMachineId(): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function clientFingerprint(folderPath: string): string {
|
|
44
|
-
|
|
44
|
+
return clientFingerprintWith(getMachineId(), folderPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same fingerprint with the machine id read ONCE by the caller (#674): on Windows
|
|
49
|
+
* `getMachineId` spawns `reg query`, and the Viber app checks several folders in a row.
|
|
50
|
+
*/
|
|
51
|
+
export function clientFingerprintWith(machineId: string, folderPath: string): string {
|
|
45
52
|
const absPath = normalizePath(realpathSync(folderPath) as string);
|
|
46
53
|
return createHash("sha256").update(`${machineId}:${absPath}`).digest("hex").slice(0, 32);
|
|
47
54
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* machine_projects.ts — the Viber app's project -> folder table, and who may write it (#674, D8).
|
|
3
|
+
*
|
|
4
|
+
* The table lives in the app's home (`<home>/projects.json`); the app's runner reads it to know
|
|
5
|
+
* where to launch a project's agents. It is written where the folder is KNOWN: at
|
|
6
|
+
* `viber-channel connect` (which writes `.viber/<auth>` IN that folder), once by the app's
|
|
7
|
+
* catch-up for projects connected before, and by the folder picker. It lives here, not in the
|
|
8
|
+
* gateway, because the channel is published alone on npm and cannot import the gateway; the
|
|
9
|
+
* gateway re-exports it.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ Bound to ONE backend identity: project ids of dev and staging overlap (measured: 4 and 6), so
|
|
12
|
+
* a table read for another identity (a home re-enrolled elsewhere) is EMPTY — never a folder of
|
|
13
|
+
* another backend. ⚠ Several processes write it (connect, the app): every write is serialized by
|
|
14
|
+
* a lock file and lands by rename, so the table is never read half-written.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
import { isDevBackend, isQaBackend } from "./auth.js";
|
|
19
|
+
import { identityBaseUrl } from "./base_urls.js";
|
|
20
|
+
import type { RunnerAuthLite } from "./runner_exec.js";
|
|
21
|
+
import { execFileSync } from "./hidden_proc.js";
|
|
22
|
+
|
|
23
|
+
/** `%LOCALAPPDATA%\Viber`, or `VIBER_APP_HOME` (tests, a second dev instance). */
|
|
24
|
+
export function appHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
25
|
+
const override = env.VIBER_APP_HOME?.trim();
|
|
26
|
+
if (override) return override;
|
|
27
|
+
const local = env.LOCALAPPDATA?.trim();
|
|
28
|
+
if (!local) throw new Error("LOCALAPPDATA is not set: cannot place the Viber app state");
|
|
29
|
+
return join(local, "Viber");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runnerJsonPath(home: string): string {
|
|
33
|
+
return join(home, ".viber", "runner.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The identity a table belongs to: the enrolled host, else the DEFAULT — never guessed from the
|
|
38
|
+
* calling process's env: a `connect` run with `VIBER_BASE_URL=dev` must not reinterpret the
|
|
39
|
+
* app's pre-#627 `runner.json` (the app, started from the Start menu, has no such variable).
|
|
40
|
+
*/
|
|
41
|
+
export function tableIdentity(auth: Pick<RunnerAuthLite, "identity_base_url">): string {
|
|
42
|
+
return auth.identity_base_url ?? identityBaseUrl({});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The identity of the app enrolled in `home`, or undefined when it is not enrolled. */
|
|
46
|
+
export function appIdentity(home: string): string | undefined {
|
|
47
|
+
const path = runnerJsonPath(home);
|
|
48
|
+
if (!existsSync(path)) return undefined;
|
|
49
|
+
return tableIdentity(JSON.parse(readFileSync(path, "utf-8")) as Pick<RunnerAuthLite, "identity_base_url">);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The auth file of a backend, from its identity ALONE. Never `authFilePath()`: it honours
|
|
54
|
+
* `VIBER_AUTH_FILE`, which would make every folder answer through one same file.
|
|
55
|
+
*/
|
|
56
|
+
export function authFileNameFor(identity: string): string {
|
|
57
|
+
if (isDevBackend(identity)) return "dev.auth.json";
|
|
58
|
+
if (isQaBackend(identity)) return "qa.auth.json";
|
|
59
|
+
return "auth.json";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const LOCK_WAIT_MS = 5000;
|
|
63
|
+
const LOCK_RETRY_MS = 25;
|
|
64
|
+
/** A write holds the lock for milliseconds: no OS query before this. NOT a proof of death. */
|
|
65
|
+
const LOCK_JUDGE_AFTER_MS = 1000;
|
|
66
|
+
/** `performance.timeOrigin` is the runtime start, a little after the OS creation time. */
|
|
67
|
+
const START_TOLERANCE_MS = 5000;
|
|
68
|
+
|
|
69
|
+
function pause(ms: number): void {
|
|
70
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* When the process `pid` was created (epoch ms), `null` when there is no such process, `NaN` when
|
|
75
|
+
* it exists but its creation time cannot be read. A pid ALONE does not identify a process on
|
|
76
|
+
* Windows: pids are recycled (repo sheet, trap 5).
|
|
77
|
+
*/
|
|
78
|
+
export function processStart(pid: number): number | null {
|
|
79
|
+
if (process.platform === "win32") {
|
|
80
|
+
try {
|
|
81
|
+
const out = execFileSync(
|
|
82
|
+
"powershell",
|
|
83
|
+
["-NoProfile", "-Command", `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { $p.StartTime.ToUniversalTime().ToString('o') }; exit 0`],
|
|
84
|
+
{ encoding: "utf-8" },
|
|
85
|
+
).trim();
|
|
86
|
+
// `exit 0`: a missing pid makes Get-Process fail even when silenced (measured: exit 1).
|
|
87
|
+
return out === "" ? null : Date.parse(out);
|
|
88
|
+
} catch {
|
|
89
|
+
return Number.NaN;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return Number.NaN;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return (err as NodeJS.ErrnoException).code === "EPERM" ? Number.NaN : null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TableFs {
|
|
101
|
+
rename: (from: string, to: string) => void;
|
|
102
|
+
processStart: (pid: number) => number | null;
|
|
103
|
+
/** This process: its pid and its creation time, written into the lock. */
|
|
104
|
+
self: { pid: number; start: number };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const INTEGER_KEY = /^\d+$/;
|
|
108
|
+
|
|
109
|
+
function integerEntries(o: unknown): Record<string, string> {
|
|
110
|
+
const out: Record<string, string> = {};
|
|
111
|
+
if (typeof o === "object" && o !== null) {
|
|
112
|
+
for (const [k, v] of Object.entries(o)) if (INTEGER_KEY.test(k) && typeof v === "string") out[k] = v;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Table {
|
|
118
|
+
/** Folders of THIS identity, read at launch. */
|
|
119
|
+
folders: Record<string, string>;
|
|
120
|
+
/** Entries of the flat format of before #674: no identity, never read at launch, settled by the catch-up. */
|
|
121
|
+
unverified: Record<string, string>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The project -> local folder table: recorded at connect (or picked), then read at launch. */
|
|
125
|
+
export class ProjectFolders {
|
|
126
|
+
private readonly path: string;
|
|
127
|
+
constructor(
|
|
128
|
+
home: string,
|
|
129
|
+
private readonly identity: string,
|
|
130
|
+
private readonly fs: TableFs = { rename: renameSync, processStart, self: { pid: process.pid, start: Math.round(performance.timeOrigin) } },
|
|
131
|
+
) {
|
|
132
|
+
this.path = join(home, "projects.json");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A damaged file fails closed: it is neither re-asked nor overwritten. */
|
|
136
|
+
private table(): Table {
|
|
137
|
+
if (!existsSync(this.path)) return { folders: {}, unverified: {} };
|
|
138
|
+
const raw: unknown = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
139
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`${this.path} is not a JSON object`);
|
|
140
|
+
const r = raw as Record<string, unknown>;
|
|
141
|
+
if (typeof r.folders !== "object" || r.folders === null) {
|
|
142
|
+
// The flat format (an old binary may also have left an `identity` key in it: integer keys only).
|
|
143
|
+
return { folders: {}, unverified: integerEntries(r) };
|
|
144
|
+
}
|
|
145
|
+
if (r.identity !== this.identity) return { folders: {}, unverified: {} }; // another backend's table
|
|
146
|
+
return { folders: integerEntries(r.folders), unverified: integerEntries(r.unverified) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
all(): Record<string, string> {
|
|
150
|
+
return this.table().folders;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
unverified(): Record<string, string> {
|
|
154
|
+
return this.table().unverified;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get(projectId: number): string | undefined {
|
|
158
|
+
return this.table().folders[String(projectId)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
set(projectId: number, folder: string): void {
|
|
162
|
+
this.locked(() => {
|
|
163
|
+
const t = this.table();
|
|
164
|
+
t.folders[String(projectId)] = folder;
|
|
165
|
+
this.write(t);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The catch-up's single write (D8 b): adds each found folder whose id has no LIVE entry (re-read
|
|
171
|
+
* under the lock — a connect may have written meanwhile), and settles the unverified entries.
|
|
172
|
+
*/
|
|
173
|
+
settleCatchUp(found: Record<string, string>, exists: (folder: string) => boolean): string[] {
|
|
174
|
+
const added: string[] = [];
|
|
175
|
+
this.locked(() => {
|
|
176
|
+
const t = this.table();
|
|
177
|
+
for (const [id, folder] of Object.entries(found)) {
|
|
178
|
+
const current = t.folders[id];
|
|
179
|
+
if (current !== undefined && exists(current)) continue;
|
|
180
|
+
t.folders[id] = folder;
|
|
181
|
+
added.push(id);
|
|
182
|
+
}
|
|
183
|
+
this.write({ folders: t.folders, unverified: {} });
|
|
184
|
+
});
|
|
185
|
+
return added;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private write(t: Table): void {
|
|
189
|
+
const body: Record<string, unknown> = { identity: this.identity, folders: t.folders };
|
|
190
|
+
if (Object.keys(t.unverified).length > 0) body.unverified = t.unverified;
|
|
191
|
+
const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
192
|
+
writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`);
|
|
193
|
+
try {
|
|
194
|
+
this.fs.rename(tmp, this.path);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(tmp);
|
|
198
|
+
} catch {
|
|
199
|
+
/* nothing left behind */
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The lock is PUBLISHED COMPLETE (review Codex/Opus): its content is written to a unique temp
|
|
207
|
+
* file, then hard-linked to the canonical name, which fails if it exists. A crash can therefore
|
|
208
|
+
* never leave an empty or half-written lock of ours behind.
|
|
209
|
+
*/
|
|
210
|
+
private locked(work: () => void): void {
|
|
211
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
212
|
+
const lock = `${this.path}.lock`;
|
|
213
|
+
const mine = JSON.stringify({ pid: this.fs.self.pid, start: this.fs.self.start, at: Date.now(), nonce: Math.random() });
|
|
214
|
+
const draft = `${lock}.${this.fs.self.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
215
|
+
writeFileSync(draft, mine);
|
|
216
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
try {
|
|
220
|
+
linkSync(draft, lock);
|
|
221
|
+
break;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
224
|
+
// A takeover may take a while (the OS probe): retry at once, before judging the deadline.
|
|
225
|
+
if (this.takeOverIfDead(lock)) continue;
|
|
226
|
+
if (Date.now() > deadline) {
|
|
227
|
+
throw new Error(`${lock} is held by another writer — if no Viber process is running, delete that file`);
|
|
228
|
+
}
|
|
229
|
+
pause(LOCK_RETRY_MS);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
try {
|
|
234
|
+
unlinkSync(draft);
|
|
235
|
+
} catch {
|
|
236
|
+
/* best effort */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
work();
|
|
241
|
+
} finally {
|
|
242
|
+
// Only OUR lock: never remove one a taker re-created after judging ours dead.
|
|
243
|
+
try {
|
|
244
|
+
if (readFileSync(lock, "utf-8") === mine) unlinkSync(lock);
|
|
245
|
+
} catch {
|
|
246
|
+
/* already gone */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A lock is taken over only when its holder is GONE: its pid no longer exists, or that pid now
|
|
254
|
+
* belongs to a process created at another time (recycled). Never on age alone — a suspended or
|
|
255
|
+
* sleeping writer is alive. By RENAME to a unique name, then checked to be the very lock judged
|
|
256
|
+
* dead: two takers cannot both win, and a lock re-taken meanwhile is put back.
|
|
257
|
+
*/
|
|
258
|
+
private takeOverIfDead(lock: string): boolean {
|
|
259
|
+
let judged: string;
|
|
260
|
+
try {
|
|
261
|
+
judged = readFileSync(lock, "utf-8");
|
|
262
|
+
} catch {
|
|
263
|
+
return true; // released meanwhile: retry at once
|
|
264
|
+
}
|
|
265
|
+
let owner: { pid?: unknown; start?: unknown; at?: unknown } | undefined;
|
|
266
|
+
try {
|
|
267
|
+
owner = JSON.parse(judged) as typeof owner;
|
|
268
|
+
} catch {
|
|
269
|
+
owner = undefined;
|
|
270
|
+
}
|
|
271
|
+
if (owner === undefined || typeof owner !== "object" || owner === null || typeof owner.pid !== "number") {
|
|
272
|
+
// Unreadable: never one of OURS (published complete) — a crash of an older writer, or damage.
|
|
273
|
+
// Not "being written": dead once it is older than a write could take (review Opus).
|
|
274
|
+
try {
|
|
275
|
+
if (Date.now() - statSync(lock).mtimeMs < LOCK_JUDGE_AFTER_MS) return false;
|
|
276
|
+
} catch {
|
|
277
|
+
return true; // gone meanwhile
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (typeof owner.at === "number" && Date.now() - owner.at < LOCK_JUDGE_AFTER_MS) return false;
|
|
281
|
+
const now = this.fs.processStart(owner.pid);
|
|
282
|
+
const recycled =
|
|
283
|
+
now !== null && !Number.isNaN(now) && typeof owner.start === "number" && Math.abs(now - owner.start) > START_TOLERANCE_MS;
|
|
284
|
+
if (now !== null && !recycled) return false; // alive, or unknown: alive
|
|
285
|
+
}
|
|
286
|
+
const grave = `${lock}.${process.pid}.${Date.now()}.dead`;
|
|
287
|
+
try {
|
|
288
|
+
this.fs.rename(lock, grave);
|
|
289
|
+
} catch {
|
|
290
|
+
return true; // another taker won: retry at once
|
|
291
|
+
}
|
|
292
|
+
let taken = "";
|
|
293
|
+
try {
|
|
294
|
+
taken = readFileSync(grave, "utf-8");
|
|
295
|
+
} catch {
|
|
296
|
+
/* unreadable: treat as not ours */
|
|
297
|
+
}
|
|
298
|
+
const ours = taken === judged;
|
|
299
|
+
if (!ours) {
|
|
300
|
+
try {
|
|
301
|
+
linkSync(grave, lock); // put the live writer's lock back, only if none was created since
|
|
302
|
+
} catch {
|
|
303
|
+
/* a new lock exists: leave it */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
unlinkSync(grave);
|
|
308
|
+
} catch {
|
|
309
|
+
/* best effort */
|
|
310
|
+
}
|
|
311
|
+
return ours;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type RecordOutcome =
|
|
316
|
+
| { kind: "recorded"; home: string; folder: string }
|
|
317
|
+
| { kind: "skipped"; reason: string }
|
|
318
|
+
| { kind: "failed"; reason: string };
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* D8 (a): after a connect wrote `<folder>/.viber/<authFile>`, record `projectId -> folder` in the
|
|
322
|
+
* app's table — ONLY when the app serves that backend. The connect is the authority: it replaces
|
|
323
|
+
* an older entry. The folder is its realpath, the form the fingerprint hashes. Never throws: a
|
|
324
|
+
* connect must succeed even when this does not.
|
|
325
|
+
*/
|
|
326
|
+
export function recordConnectedFolder(
|
|
327
|
+
projectId: number,
|
|
328
|
+
folder: string,
|
|
329
|
+
authPath: string,
|
|
330
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
331
|
+
): RecordOutcome {
|
|
332
|
+
try {
|
|
333
|
+
const home = appHome(env);
|
|
334
|
+
const identity = appIdentity(home);
|
|
335
|
+
if (identity === undefined) return { kind: "skipped", reason: "the Viber app is not enrolled on this machine" };
|
|
336
|
+
const served = authFileNameFor(identity);
|
|
337
|
+
if (basename(authPath) !== served) return { kind: "skipped", reason: `the Viber app serves another backend (${identity})` };
|
|
338
|
+
const real = realpathSync(folder);
|
|
339
|
+
new ProjectFolders(home, identity).set(projectId, real);
|
|
340
|
+
return { kind: "recorded", home, folder: real };
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { kind: "failed", reason: (err as Error).message };
|
|
343
|
+
}
|
|
344
|
+
}
|
package/lib/team_role_tool.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* (one tool, an `action`, one definition shared verbatim by the Claude server and
|
|
8
8
|
* the Codex bridge) and the same owner-consent rule.
|
|
9
9
|
*
|
|
10
|
-
* A role is a DEFINITION: this tool launches nothing
|
|
11
|
-
* role
|
|
10
|
+
* A role is a DEFINITION: this tool launches nothing — one agent is launched from a
|
|
11
|
+
* role by `viber-gateway launch-agent` (#673) or the web's Add agent, never here.
|
|
12
12
|
*/
|
|
13
13
|
import type { AgentToolResult, AgentToolsContext } from "./agent_tools.js";
|
|
14
14
|
import { callAgentAdmin, errorText, type AdminRequest } from "./agent_admin_call.js";
|
|
15
|
+
import { CODEX_DEFAULT_PAIR } from "./codex_default.js";
|
|
15
16
|
|
|
16
17
|
export const TEAM_ROLE_ACTIONS = ["list", "get", "create", "update", "delete"] as const;
|
|
17
18
|
type Action = (typeof TEAM_ROLE_ACTIONS)[number];
|
|
@@ -29,7 +30,7 @@ export const TEAM_ROLES_TOOL_DEF = {
|
|
|
29
30
|
name: "team_roles",
|
|
30
31
|
description:
|
|
31
32
|
"Administer the owner's ROLE LIBRARY (#671): reusable role definitions (name, runtime, permission, rolePrompt, model, effort) " +
|
|
32
|
-
"that team templates reference by name.
|
|
33
|
+
"that team templates reference by name. This tool launches nothing: a role is launched by `viber-gateway launch-agent` (one agent, see the capabilities tool) or by a team template that references it. " +
|
|
33
34
|
"action: list | get | create | update | delete. " +
|
|
34
35
|
ROLE_OWNER_CONSENT_RULE +
|
|
35
36
|
" Updating a role changes EVERY team template that references it (list shows them in `used_by`), and so what the runner " +
|
|
@@ -63,7 +64,7 @@ export const TEAM_ROLES_TOOL_DEF = {
|
|
|
63
64
|
type: ["string", "null"],
|
|
64
65
|
description:
|
|
65
66
|
"claude: leave model and effort OUT (the runtime picks its own default) UNLESS the owner explicitly asks; model is a family (opus, sonnet, haiku or fable). " +
|
|
66
|
-
|
|
67
|
+
`codex: model AND effort are REQUIRED — use ${CODEX_DEFAULT_PAIR} unless the owner asks for another listed model and an effort it accepts. Full rules: the capabilities tool.`,
|
|
67
68
|
},
|
|
68
69
|
effort: {
|
|
69
70
|
type: ["string", "null"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.36",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|