switchroom 0.13.58 → 0.13.60
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/dist/cli/switchroom.js +451 -343
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +252 -310
- package/telegram-plugin/gateway/gateway.ts +0 -35
- package/dist/cli/ack-first-pretool.mjs +0 -75
- package/telegram-plugin/ack-flag.ts +0 -66
- package/telegram-plugin/tests/ack-flag.test.ts +0 -65
|
@@ -86,7 +86,6 @@ import { classifyInbound } from '../inbound-classifier.js'
|
|
|
86
86
|
import * as silencePoke from '../silence-poke.js'
|
|
87
87
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
88
88
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
89
|
-
import { markAckSent, clearAckSent } from '../ack-flag.js'
|
|
90
89
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
91
90
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
92
91
|
import { type SessionEvent } from '../session-tail.js'
|
|
@@ -4984,16 +4983,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4984
4983
|
// silence-poke clock so the next poke is measured from this send.
|
|
4985
4984
|
signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4986
4985
|
silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4987
|
-
// Ack-first gate (`reference/conversational-pacing.md` beat 1):
|
|
4988
|
-
// touch the state-dir flag so the ack-first-pretool hook lets
|
|
4989
|
-
// subsequent non-reply tool calls through this turn. Cleared at
|
|
4990
|
-
// turn_started. Best-effort — a write failure shouldn't break
|
|
4991
|
-
// reply, and the hook is kill-switched anyway.
|
|
4992
|
-
try {
|
|
4993
|
-
markAckSent()
|
|
4994
|
-
} catch (err) {
|
|
4995
|
-
process.stderr.write(`telegram gateway: markAckSent failed: ${err}\n`)
|
|
4996
|
-
}
|
|
4997
4986
|
// #1741 — only clear silent-end state on a plausibly-final reply.
|
|
4998
4987
|
// An interim ack (disable_notification:true, short text, no done)
|
|
4999
4988
|
// must NOT clear the state file; otherwise a turn that ends with
|
|
@@ -5589,13 +5578,6 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5589
5578
|
const sKey = statusKey(streamChatId, streamThreadId)
|
|
5590
5579
|
signalTracker.noteOutbound(sKey, Date.now())
|
|
5591
5580
|
silencePoke.noteOutbound(sKey, Date.now())
|
|
5592
|
-
// Ack-first gate: stream_reply's first emit also unlocks subsequent
|
|
5593
|
-
// tool calls. See ack-flag.ts + ack-first-pretool.ts.
|
|
5594
|
-
try {
|
|
5595
|
-
markAckSent()
|
|
5596
|
-
} catch (err) {
|
|
5597
|
-
process.stderr.write(`telegram gateway: markAckSent (stream_reply) failed: ${err}\n`)
|
|
5598
|
-
}
|
|
5599
5581
|
// #1741 — see executeReply for the rationale: only a plausibly-
|
|
5600
5582
|
// final stream_reply clears the silent-end state. An interim
|
|
5601
5583
|
// ack via stream_reply must NOT clear; the Stop hook needs
|
|
@@ -6944,14 +6926,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6944
6926
|
statusKey(ev.chatId, enqThreadId),
|
|
6945
6927
|
'handback',
|
|
6946
6928
|
)
|
|
6947
|
-
// Ack-first gate (`reference/conversational-pacing.md` beat 1):
|
|
6948
|
-
// wipe the prior turn's `ack-sent.flag` so the ack-first-
|
|
6949
|
-
// pretool hook re-arms for this fresh turn. Centralised HERE
|
|
6950
|
-
// (not in handleInbound) because `enqueue` is the single
|
|
6951
|
-
// canonical fresh-turn atom — fires for real inbounds, cron
|
|
6952
|
-
// fires, subagent-handback channel wakes, vault-grant resumes,
|
|
6953
|
-
// and restart markers alike. Best-effort — see ack-flag.ts.
|
|
6954
|
-
clearAckSent()
|
|
6955
6929
|
}
|
|
6956
6930
|
if (ev.chatId) {
|
|
6957
6931
|
// Issue #195: if a previous turn left an answer-lane stream open
|
|
@@ -7139,15 +7113,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7139
7113
|
if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7140
7114
|
const rendered = registerAndRender(turn.toolActivity, name)
|
|
7141
7115
|
if (rendered != null) {
|
|
7142
|
-
// Mark the ack-flag synchronously so a PreToolUse hook firing
|
|
7143
|
-
// concurrently for THIS tool call (#1921) sees the flag set
|
|
7144
|
-
// and allows the tool through. The drain runs async; failure
|
|
7145
|
-
// is logged but does not block the model.
|
|
7146
|
-
try {
|
|
7147
|
-
markAckSent()
|
|
7148
|
-
} catch (err) {
|
|
7149
|
-
process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}\n`)
|
|
7150
|
-
}
|
|
7151
7116
|
turn.activityPendingRender = rendered
|
|
7152
7117
|
if (turn.activityInFlight == null) {
|
|
7153
7118
|
turn.activityInFlight = drainActivitySummary(turn)
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// src/cli/ack-first-pretool.ts
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
var REPLY_TOOL_NAMES = new Set([
|
|
5
|
-
"mcp__switchroom-telegram__reply",
|
|
6
|
-
"mcp__switchroom-telegram__stream_reply"
|
|
7
|
-
]);
|
|
8
|
-
var ACK_SENT_MARKER = "ack-sent.flag";
|
|
9
|
-
function decide(input, stateDir) {
|
|
10
|
-
const toolName = input.tool_name ?? "";
|
|
11
|
-
if (toolName === "") {
|
|
12
|
-
return { decision: "allow" };
|
|
13
|
-
}
|
|
14
|
-
if (REPLY_TOOL_NAMES.has(toolName)) {
|
|
15
|
-
return { decision: "allow" };
|
|
16
|
-
}
|
|
17
|
-
if (!stateDir) {
|
|
18
|
-
return { decision: "allow" };
|
|
19
|
-
}
|
|
20
|
-
const markerPath = join(stateDir, ACK_SENT_MARKER);
|
|
21
|
-
let ackAlreadySent = false;
|
|
22
|
-
try {
|
|
23
|
-
ackAlreadySent = existsSync(markerPath);
|
|
24
|
-
} catch {
|
|
25
|
-
return { decision: "allow" };
|
|
26
|
-
}
|
|
27
|
-
if (ackAlreadySent) {
|
|
28
|
-
return { decision: "allow" };
|
|
29
|
-
}
|
|
30
|
-
return {
|
|
31
|
-
decision: "block",
|
|
32
|
-
reason: "First call `mcp__switchroom-telegram__reply` with a brief one-line " + "acknowledgement in your persona's voice before using any other tool \u2014 " + 'e.g. "on it \u2014 checking", "good question \u2014 one sec", "let me dig in". ' + "This is the framework's enforcement of the human-feel design " + "(`reference/conversational-pacing.md` beat 1): a colleague answers " + "in a beat. The reply can be the entire short answer if you have one " + "ready (then no further work needed); otherwise it's a brief status " + "and you continue with the work after."
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
async function runHook() {
|
|
36
|
-
if (process.env.SWITCHROOM_DISABLE_ACK_FIRST_GATE === "1") {
|
|
37
|
-
process.exit(0);
|
|
38
|
-
}
|
|
39
|
-
const stdin = await readStdin();
|
|
40
|
-
let input;
|
|
41
|
-
try {
|
|
42
|
-
input = JSON.parse(stdin);
|
|
43
|
-
} catch {
|
|
44
|
-
process.exit(0);
|
|
45
|
-
}
|
|
46
|
-
const stateDir = process.env.TELEGRAM_STATE_DIR;
|
|
47
|
-
const decision = decide(input, stateDir);
|
|
48
|
-
if (decision.decision === "block") {
|
|
49
|
-
process.stdout.write(JSON.stringify({ decision: "block", reason: decision.reason }) + `
|
|
50
|
-
`);
|
|
51
|
-
}
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
async function readStdin() {
|
|
55
|
-
return new Promise((resolve, reject) => {
|
|
56
|
-
const chunks = [];
|
|
57
|
-
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
58
|
-
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
59
|
-
process.stdin.on("error", reject);
|
|
60
|
-
if (process.stdin.isTTY)
|
|
61
|
-
resolve("");
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
if (typeof process !== "undefined" && process.argv?.[1]?.endsWith("ack-first-pretool.mjs")) {
|
|
65
|
-
runHook().catch((err) => {
|
|
66
|
-
process.stderr.write(`ack-first-pretool: hook failed unexpectedly: ${err}
|
|
67
|
-
`);
|
|
68
|
-
process.exit(0);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
export {
|
|
72
|
-
runHook,
|
|
73
|
-
decide,
|
|
74
|
-
ACK_SENT_MARKER
|
|
75
|
-
};
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ack-first state flag — load-bearing for the ack-first-pretool hook
|
|
3
|
-
* (see `src/cli/ack-first-pretool.ts` and
|
|
4
|
-
* `reference/conversational-pacing.md` beat 1).
|
|
5
|
-
*
|
|
6
|
-
* The gateway is the source of truth for "has the model called reply
|
|
7
|
-
* yet this turn?". The PreToolUse hook runs as a short-lived child
|
|
8
|
-
* process so it can't read gateway in-memory state; the hand-off is
|
|
9
|
-
* a single file inside `$TELEGRAM_STATE_DIR`:
|
|
10
|
-
*
|
|
11
|
-
* - `markAckSent()` touches the file on the first reply per turn.
|
|
12
|
-
* - `clearAckSent()` removes it at turn_started.
|
|
13
|
-
* - The hook checks `existsSync(path)` → allow / block.
|
|
14
|
-
*
|
|
15
|
-
* Per-agent isolation is built-in: `$TELEGRAM_STATE_DIR` is the
|
|
16
|
-
* agent's per-container state dir (~/.switchroom/agents/<name>/telegram,
|
|
17
|
-
* bind-mounted into /state/agent/home/.switchroom/agents/<name>/telegram
|
|
18
|
-
* inside the container).
|
|
19
|
-
*
|
|
20
|
-
* All operations are best-effort: a write or unlink failure logs to
|
|
21
|
-
* stderr and returns; the gate is informational UX, not a safety
|
|
22
|
-
* primitive, so a broken state-dir must never wedge reply itself.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { closeSync, existsSync, openSync, unlinkSync } from "node:fs";
|
|
26
|
-
import { join } from "node:path";
|
|
27
|
-
|
|
28
|
-
export const ACK_SENT_MARKER = "ack-sent.flag";
|
|
29
|
-
|
|
30
|
-
function markerPath(): string | null {
|
|
31
|
-
const dir = process.env.TELEGRAM_STATE_DIR;
|
|
32
|
-
if (!dir) return null;
|
|
33
|
-
return join(dir, ACK_SENT_MARKER);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Create the ack-sent marker. Idempotent (no-op if it exists). */
|
|
37
|
-
export function markAckSent(): void {
|
|
38
|
-
const path = markerPath();
|
|
39
|
-
if (path == null) return;
|
|
40
|
-
if (existsSync(path)) return;
|
|
41
|
-
try {
|
|
42
|
-
const fd = openSync(path, "w");
|
|
43
|
-
closeSync(fd);
|
|
44
|
-
} catch (err) {
|
|
45
|
-
process.stderr.write(
|
|
46
|
-
`ack-flag: markAckSent failed path=${path}: ${err}\n`,
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Remove the ack-sent marker. Idempotent. */
|
|
52
|
-
export function clearAckSent(): void {
|
|
53
|
-
const path = markerPath();
|
|
54
|
-
if (path == null) return;
|
|
55
|
-
try {
|
|
56
|
-
unlinkSync(path);
|
|
57
|
-
} catch (err: unknown) {
|
|
58
|
-
// ENOENT is the common case (turn started before any prior turn
|
|
59
|
-
// ran a reply); silently swallow.
|
|
60
|
-
const code = (err as { code?: string } | undefined)?.code;
|
|
61
|
-
if (code === "ENOENT") return;
|
|
62
|
-
process.stderr.write(
|
|
63
|
-
`ack-flag: clearAckSent failed path=${path}: ${err}\n`,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import { markAckSent, clearAckSent, ACK_SENT_MARKER } from "../ack-flag.js";
|
|
7
|
-
|
|
8
|
-
describe("ack-flag — gateway-side state file for ack-first hook", () => {
|
|
9
|
-
let stateDir: string;
|
|
10
|
-
let prevEnv: string | undefined;
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
stateDir = mkdtempSync(join(tmpdir(), "ack-flag-"));
|
|
14
|
-
prevEnv = process.env.TELEGRAM_STATE_DIR;
|
|
15
|
-
process.env.TELEGRAM_STATE_DIR = stateDir;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
if (prevEnv != null) process.env.TELEGRAM_STATE_DIR = prevEnv;
|
|
20
|
-
else delete process.env.TELEGRAM_STATE_DIR;
|
|
21
|
-
rmSync(stateDir, { recursive: true, force: true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("markAckSent creates the marker file", () => {
|
|
25
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(false);
|
|
26
|
-
markAckSent();
|
|
27
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("markAckSent is idempotent (second call is a no-op)", () => {
|
|
31
|
-
markAckSent();
|
|
32
|
-
expect(() => markAckSent()).not.toThrow();
|
|
33
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("clearAckSent removes the marker file", () => {
|
|
37
|
-
markAckSent();
|
|
38
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(true);
|
|
39
|
-
clearAckSent();
|
|
40
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("clearAckSent is idempotent (works when marker doesn't exist)", () => {
|
|
44
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(false);
|
|
45
|
-
expect(() => clearAckSent()).not.toThrow();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("round-trip: mark → clear → mark cycles work across turns", () => {
|
|
49
|
-
// turn 1
|
|
50
|
-
markAckSent();
|
|
51
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(true);
|
|
52
|
-
// turn end / next turn start
|
|
53
|
-
clearAckSent();
|
|
54
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(false);
|
|
55
|
-
// turn 2 first reply
|
|
56
|
-
markAckSent();
|
|
57
|
-
expect(existsSync(join(stateDir, ACK_SENT_MARKER))).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("noop when TELEGRAM_STATE_DIR is unset (best-effort)", () => {
|
|
61
|
-
delete process.env.TELEGRAM_STATE_DIR;
|
|
62
|
-
expect(() => markAckSent()).not.toThrow();
|
|
63
|
-
expect(() => clearAckSent()).not.toThrow();
|
|
64
|
-
});
|
|
65
|
-
});
|