switchroom 0.15.41 → 0.15.43
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/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +157 -13
- package/dist/cli/ui/index.html +31 -0
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +397 -226
- package/telegram-plugin/gateway/context-occupancy.ts +91 -0
- package/telegram-plugin/gateway/gateway.ts +204 -63
- package/telegram-plugin/gateway/hostd-dispatch.ts +1 -1
- package/telegram-plugin/gateway/idle-clear.ts +72 -0
- package/telegram-plugin/gateway/poll-health.ts +9 -4
- package/telegram-plugin/gateway/poll-stall-recovery.ts +59 -0
- package/telegram-plugin/tests/context-occupancy.test.ts +55 -0
- package/telegram-plugin/tests/idle-clear.test.ts +62 -0
- package/telegram-plugin/tests/poll-stall-recovery.test.ts +32 -0
- package/telegram-plugin/tests/welcome-text.test.ts +10 -11
- package/telegram-plugin/welcome-text.ts +11 -12
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
buildContextOccupancy,
|
|
4
|
+
writeContextOccupancySnapshot,
|
|
5
|
+
TIGHT_FRACTION,
|
|
6
|
+
} from '../gateway/context-occupancy.js'
|
|
7
|
+
|
|
8
|
+
describe('buildContextOccupancy', () => {
|
|
9
|
+
it('computes headroom + pct + ok state under the cap', () => {
|
|
10
|
+
const s = buildContextOccupancy(47000, 300000, 123)
|
|
11
|
+
expect(s).toMatchObject({ occupancy: 47000, cap: 300000, headroom: 253000, state: 'ok', computedAt: 123 })
|
|
12
|
+
expect(s.pct).toBeCloseTo(0.1567, 3)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('flags "tight" at/above TIGHT_FRACTION of the cap', () => {
|
|
16
|
+
const atThreshold = buildContextOccupancy(Math.ceil(300000 * TIGHT_FRACTION), 300000, 1)
|
|
17
|
+
expect(atThreshold.state).toBe('tight')
|
|
18
|
+
expect(buildContextOccupancy(250000, 300000, 1).state).toBe('tight') // 83% ≥ 80%
|
|
19
|
+
expect(buildContextOccupancy(231000, 300000, 1).state).toBe('ok') // 77% < 80%
|
|
20
|
+
expect(buildContextOccupancy(239999, 300000, 1).state).toBe('ok') // just under 80%
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('no cap → ok, null ratio (occupancy known, no ceiling)', () => {
|
|
24
|
+
const s = buildContextOccupancy(50000, null, 1)
|
|
25
|
+
expect(s).toMatchObject({ occupancy: 50000, cap: null, headroom: null, pct: null, state: 'ok' })
|
|
26
|
+
expect(buildContextOccupancy(50000, 0, 1).cap).toBeNull() // cap<=0 treated as none
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('unmeasurable occupancy → unknown', () => {
|
|
30
|
+
expect(buildContextOccupancy(NaN, 300000, 1).state).toBe('unknown')
|
|
31
|
+
expect(buildContextOccupancy(-5, 300000, 1).state).toBe('unknown')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('writeContextOccupancySnapshot', () => {
|
|
36
|
+
it('writes <stateDir>/context-occupancy.json via injected fs', () => {
|
|
37
|
+
let path = ''
|
|
38
|
+
let data = ''
|
|
39
|
+
writeContextOccupancySnapshot('/state/agent', buildContextOccupancy(47000, 300000, 1), {
|
|
40
|
+
mkdir: () => {},
|
|
41
|
+
writeFile: (p, d) => { path = p; data = d },
|
|
42
|
+
})
|
|
43
|
+
expect(path).toBe('/state/agent/context-occupancy.json')
|
|
44
|
+
expect(JSON.parse(data).occupancy).toBe(47000)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('never throws when the write fails (best-effort)', () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
writeContextOccupancySnapshot('/x', buildContextOccupancy(1, 2, 1), {
|
|
50
|
+
mkdir: () => {},
|
|
51
|
+
writeFile: () => { throw new Error('EACCES') },
|
|
52
|
+
}),
|
|
53
|
+
).not.toThrow()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
decideIdleClear,
|
|
4
|
+
idleDurationToMs,
|
|
5
|
+
DEFAULT_IDLE_CLEAR_MS,
|
|
6
|
+
type IdleClearState,
|
|
7
|
+
} from '../gateway/idle-clear.js'
|
|
8
|
+
|
|
9
|
+
const H = 3_600_000
|
|
10
|
+
function state(p: Partial<IdleClearState>): IdleClearState {
|
|
11
|
+
return { lastActivityAt: 0, idleClearMs: 3 * H, alreadyCleared: false, turnInFlight: false, ...p }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('decideIdleClear', () => {
|
|
15
|
+
it('fires once the idle window has elapsed', () => {
|
|
16
|
+
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
|
|
17
|
+
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('does NOT fire before the window', () => {
|
|
21
|
+
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H - 1).clear).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('never fires mid-turn (turnInFlight)', () => {
|
|
25
|
+
expect(decideIdleClear(state({ lastActivityAt: 0, turnInFlight: true }), 10 * H).clear).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('fires once per idle period (alreadyCleared guard)', () => {
|
|
29
|
+
expect(decideIdleClear(state({ lastActivityAt: 0, alreadyCleared: true }), 10 * H).clear).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is disabled when idleClearMs <= 0', () => {
|
|
33
|
+
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
|
|
34
|
+
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
|
|
38
|
+
// Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
|
|
39
|
+
const now = 100 * H
|
|
40
|
+
const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
|
|
41
|
+
expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
|
|
42
|
+
expect(decideIdleClear(reArmed, now + 3 * H).clear).toBe(true) // again, one window later
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('idleDurationToMs', () => {
|
|
47
|
+
it('parses s/m/h', () => {
|
|
48
|
+
expect(idleDurationToMs('3h')).toBe(3 * H)
|
|
49
|
+
expect(idleDurationToMs('90m')).toBe(90 * 60_000)
|
|
50
|
+
expect(idleDurationToMs('7200s')).toBe(7_200_000)
|
|
51
|
+
expect(idleDurationToMs('0s')).toBe(0) // disable sentinel
|
|
52
|
+
})
|
|
53
|
+
it('returns null on malformed input (caller falls back to default)', () => {
|
|
54
|
+
expect(idleDurationToMs('3')).toBeNull()
|
|
55
|
+
expect(idleDurationToMs('3d')).toBeNull()
|
|
56
|
+
expect(idleDurationToMs('abc')).toBeNull()
|
|
57
|
+
expect(idleDurationToMs('')).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
it('default is 3h', () => {
|
|
60
|
+
expect(DEFAULT_IDLE_CLEAR_MS).toBe(3 * H)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
recoverFromPollStall,
|
|
4
|
+
POLL_STALL_EXIT_CODE,
|
|
5
|
+
} from '../gateway/poll-stall-recovery.js'
|
|
6
|
+
|
|
7
|
+
describe('recoverFromPollStall', () => {
|
|
8
|
+
it('exits with code 1 — and NEVER 78 (78 = permanent quarantine)', () => {
|
|
9
|
+
const codes: number[] = []
|
|
10
|
+
recoverFromPollStall({ exit: (c) => { codes.push(c) }, log: () => {} })
|
|
11
|
+
expect(codes).toEqual([1])
|
|
12
|
+
expect(POLL_STALL_EXIT_CODE).toBe(1)
|
|
13
|
+
expect(codes).not.toContain(78) // the sharp edge: quarantine on a transient stall
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('logs a recovery line carrying the agent name + the reason (no stop() await)', () => {
|
|
17
|
+
const lines: string[] = []
|
|
18
|
+
recoverFromPollStall({ exit: () => {}, log: (m) => { lines.push(m) }, agentName: 'carrie' })
|
|
19
|
+
expect(lines).toHaveLength(1)
|
|
20
|
+
expect(lines[0]).toContain('stall_recovery')
|
|
21
|
+
expect(lines[0]).toContain('agent=carrie')
|
|
22
|
+
expect(lines[0]).toContain('code=1')
|
|
23
|
+
// documents WHY we don't await stop() — guards against a future revert
|
|
24
|
+
expect(lines[0]).toMatch(/backoff|stop\(\)/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('exits exactly once', () => {
|
|
28
|
+
let calls = 0
|
|
29
|
+
recoverFromPollStall({ exit: () => { calls++ }, log: () => {} })
|
|
30
|
+
expect(calls).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
switchroomHelpCommandNames,
|
|
13
13
|
restartAckText,
|
|
14
14
|
newSessionAckText,
|
|
15
|
-
resetSessionAckText,
|
|
16
15
|
TELEGRAM_BASE_COMMANDS,
|
|
17
16
|
TELEGRAM_SWITCHROOM_COMMANDS,
|
|
18
17
|
TELEGRAM_MENU_COMMANDS,
|
|
@@ -137,7 +136,8 @@ describe("helpText", () => {
|
|
|
137
136
|
expect(out).toContain("/deny");
|
|
138
137
|
expect(out).toContain("/pending");
|
|
139
138
|
expect(out).toContain("/new");
|
|
140
|
-
expect(out).toContain("/
|
|
139
|
+
expect(out).toContain("/compact");
|
|
140
|
+
expect(out).toContain("/clear");
|
|
141
141
|
});
|
|
142
142
|
it("points at the richer /commands", () => {
|
|
143
143
|
expect(helpText("assistant")).toContain("/commands");
|
|
@@ -347,9 +347,11 @@ describe("switchroomHelpText + switchroomHelpCommandNames", () => {
|
|
|
347
347
|
expect(out).toContain("<b>Auth & config</b>");
|
|
348
348
|
});
|
|
349
349
|
it("the name array contains the Sprint 2/3 additions", () => {
|
|
350
|
-
for (const needed of ["new", "
|
|
350
|
+
for (const needed of ["new", "compact", "clear", "approve", "deny", "pending"]) {
|
|
351
351
|
expect(switchroomHelpCommandNames).toContain(needed);
|
|
352
352
|
}
|
|
353
|
+
// /reset was removed (it was a pure alias of /new).
|
|
354
|
+
expect(switchroomHelpCommandNames).not.toContain("reset");
|
|
353
355
|
});
|
|
354
356
|
});
|
|
355
357
|
|
|
@@ -370,9 +372,11 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
|
|
|
370
372
|
it("menu includes the session-control commands (the most-used trio)", () => {
|
|
371
373
|
const names = TELEGRAM_MENU_COMMANDS.map(c => c.command);
|
|
372
374
|
// These MUST be in the menu — they're the primary mobile UX flows
|
|
373
|
-
for (const must of ["new", "
|
|
375
|
+
for (const must of ["new", "compact", "clear", "approve", "deny", "pending", "restart", "logs", "commands"]) {
|
|
374
376
|
expect(names, `missing /${must} from Telegram menu`).toContain(must);
|
|
375
377
|
}
|
|
378
|
+
// /reset removed (alias of /new).
|
|
379
|
+
expect(names, "/reset should be gone from the menu").not.toContain("reset");
|
|
376
380
|
});
|
|
377
381
|
|
|
378
382
|
it("menu drops the ops primitives that cluttered the old catalogue", () => {
|
|
@@ -451,7 +455,7 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
|
|
|
451
455
|
});
|
|
452
456
|
});
|
|
453
457
|
|
|
454
|
-
describe("restart / new
|
|
458
|
+
describe("restart / new ack text", () => {
|
|
455
459
|
it("restartAckText is consistent", () => {
|
|
456
460
|
expect(restartAckText("assistant")).toBe("🔄 Restarting <b>assistant</b>…");
|
|
457
461
|
});
|
|
@@ -463,13 +467,8 @@ describe("restart / new / reset ack text", () => {
|
|
|
463
467
|
expect(newSessionAckText("assistant", false))
|
|
464
468
|
.toBe("🆕 Started fresh session for <b>assistant</b> · restarting…");
|
|
465
469
|
});
|
|
466
|
-
it("
|
|
467
|
-
expect(resetSessionAckText("assistant", true))
|
|
468
|
-
.toBe("🔄 Reset session for <b>assistant</b> · flushed handoff · restarting…");
|
|
469
|
-
});
|
|
470
|
-
it("HTML-escapes agent name in all three", () => {
|
|
470
|
+
it("HTML-escapes agent name in both", () => {
|
|
471
471
|
expect(restartAckText("<x>")).toContain("<x>");
|
|
472
472
|
expect(newSessionAckText("<x>", true)).toContain("<x>");
|
|
473
|
-
expect(resetSessionAckText("<x>", true)).toContain("<x>");
|
|
474
473
|
});
|
|
475
474
|
});
|
|
@@ -170,7 +170,7 @@ export function helpText(agentName: string): string {
|
|
|
170
170
|
``,
|
|
171
171
|
`This bot is the <b>${escapeHtml(agentName)}</b> agent. Text and photos route through to it; replies, reactions and progress cards come back.`,
|
|
172
172
|
``,
|
|
173
|
-
`Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code
|
|
173
|
+
`Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code>, or trim/clear context with <code>/compact</code> / <code>/clear</code>.`,
|
|
174
174
|
``,
|
|
175
175
|
`<code>/start</code> — pairing instructions`,
|
|
176
176
|
`<code>/status</code> — agent, model, auth`,
|
|
@@ -265,7 +265,7 @@ export function statusUnpairedText(): string {
|
|
|
265
265
|
*/
|
|
266
266
|
export const switchroomHelpCommandNames = [
|
|
267
267
|
// Session & approvals
|
|
268
|
-
"new", "
|
|
268
|
+
"new", "compact", "clear", "approve", "deny", "pending", "interrupt",
|
|
269
269
|
// Agents
|
|
270
270
|
"agents", "agentstart", "stop", "restart", "logs", "memory",
|
|
271
271
|
// Auth & config — consolidated onto the `/auth` dashboard.
|
|
@@ -298,7 +298,8 @@ export const TELEGRAM_MENU_COMMANDS = [
|
|
|
298
298
|
{ command: "status", description: "Agent, model, auth" },
|
|
299
299
|
// Session control (most-used)
|
|
300
300
|
{ command: "new", description: "Fresh session (flush handoff, restart)" },
|
|
301
|
-
{ command: "
|
|
301
|
+
{ command: "compact", description: "Compact context (summarize, keep the thread)" },
|
|
302
|
+
{ command: "clear", description: "Clear context (fresh slate; memory in Hindsight)" },
|
|
302
303
|
// Inline approvals
|
|
303
304
|
{ command: "approve", description: "Approve pending tool permission" },
|
|
304
305
|
{ command: "deny", description: "Deny pending tool permission" },
|
|
@@ -312,8 +313,10 @@ export const TELEGRAM_MENU_COMMANDS = [
|
|
|
312
313
|
// #725 Phase 2 — inject a Claude Code REPL slash command into the agent's
|
|
313
314
|
// tmux pane (allowlisted: /cost, /status, /model, /clear, /compact,
|
|
314
315
|
// /memory, /hooks). Requires the tmux supervisor (the default — refused
|
|
315
|
-
// when the agent has experimental.legacy_pty=true).
|
|
316
|
-
|
|
316
|
+
// when the agent has experimental.legacy_pty=true). NOT in the slash-menu
|
|
317
|
+
// (kept the 20-entry mobile cap; the common injects /compact, /clear,
|
|
318
|
+
// /model, /effort are first-class menu commands). Still typable + in
|
|
319
|
+
// /commands.
|
|
317
320
|
// /model — show or switch the Claude model (session-scoped; rides the
|
|
318
321
|
// same inject primitive as `/inject /model` but with a typed argument,
|
|
319
322
|
// so it never opens the undriveable no-arg picker modal).
|
|
@@ -356,7 +359,8 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
356
359
|
``,
|
|
357
360
|
`<b>Session & approvals</b>`,
|
|
358
361
|
`<code>/new</code> — fresh session (flush handoff, restart)`,
|
|
359
|
-
`<code>/
|
|
362
|
+
`<code>/compact</code> — compact context (summarize, keep the thread)`,
|
|
363
|
+
`<code>/clear</code> — clear context (fresh slate; memory in Hindsight)`,
|
|
360
364
|
`<code>/approve [id]</code> — approve pending tool permission`,
|
|
361
365
|
`<code>/deny [id]</code> — deny pending tool permission`,
|
|
362
366
|
`<code>/pending</code> — list pending permission prompts`,
|
|
@@ -404,7 +408,7 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
404
408
|
}
|
|
405
409
|
|
|
406
410
|
/**
|
|
407
|
-
* Ack shown when a self-targeting /restart (or /new
|
|
411
|
+
* Ack shown when a self-targeting /restart (or /new) kicks off.
|
|
408
412
|
* Centralized so gateway and monolith agree on wording.
|
|
409
413
|
*/
|
|
410
414
|
export function restartAckText(agentName: string): string {
|
|
@@ -415,8 +419,3 @@ export function newSessionAckText(agentName: string, flushedHandoff: boolean): s
|
|
|
415
419
|
const tail = flushedHandoff ? " · flushed handoff" : "";
|
|
416
420
|
return `🆕 Started fresh session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
|
|
417
421
|
}
|
|
418
|
-
|
|
419
|
-
export function resetSessionAckText(agentName: string, flushedHandoff: boolean): string {
|
|
420
|
-
const tail = flushedHandoff ? " · flushed handoff" : "";
|
|
421
|
-
return `🔄 Reset session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
|
|
422
|
-
}
|