@wastedtokens/agent-switchboard 1.3.0 → 1.4.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 +34 -1
- package/package.json +1 -1
- package/switchboard-channel.mjs +75 -3
package/README.md
CHANGED
|
@@ -75,7 +75,8 @@ In-session: `announce` once, `who_is_online`, `send_message(to, body)`, and answ
|
|
|
75
75
|
`<channel source="switchboard" from="X" thread="Y">` messages with
|
|
76
76
|
`reply(to="X", thread="Y", body=...)`. `message_status(id)` returns the delivery receipt
|
|
77
77
|
(`queued | leased | delivered | expired`) plus `delivered_to` — which identity actually
|
|
78
|
-
consumed the message. `
|
|
78
|
+
consumed the message. `thread_history(thread)` reads a thread's prior messages when you join
|
|
79
|
+
mid-conversation (see *Taking over a thread* below). `logoff` when done.
|
|
79
80
|
|
|
80
81
|
### Shared mailboxes (work queues)
|
|
81
82
|
|
|
@@ -99,6 +100,38 @@ Against an older relay (pre-1.6) these tools degrade to a clear error ("does not
|
|
|
99
100
|
shared mailboxes/tasks yet") instead of failing cryptically; the reconcile block is simply
|
|
100
101
|
omitted.
|
|
101
102
|
|
|
103
|
+
### Taking over a thread (`thread_history`)
|
|
104
|
+
|
|
105
|
+
When you `claim_next` a message, you land mid-conversation: the relay hands you exactly one
|
|
106
|
+
message row, but that thread may have a long history — the previous consumer went offline,
|
|
107
|
+
their stickiness lapsed, and the requester's next message fell back to the pool for you to
|
|
108
|
+
pick up. `thread_history(thread)` reads the prior messages so you can catch up before you
|
|
109
|
+
reply. It's a **read-only observation** — it never claims or consumes, and it's tenant-scoped
|
|
110
|
+
(you can read any of *your* threads).
|
|
111
|
+
|
|
112
|
+
- Every claimed (and pushed) message carries its `thread`; a claimed message also carries
|
|
113
|
+
`task_id`/`task_state` so you can drive the task with `task_update`.
|
|
114
|
+
- `thread_history(thread)` returns the thread's messages oldest→newest, plus `has_more`,
|
|
115
|
+
`next_before`, and `control_omitted` (relay lifecycle frames are filtered out of the
|
|
116
|
+
conversation by default and only counted). The `task` and `affinity` blocks tell you the
|
|
117
|
+
thread's open work and its sticky consumer.
|
|
118
|
+
- **Paging:** the newest page comes back first (default 100, max 200 messages). When
|
|
119
|
+
`has_more` is `true`, call again with `before: <next_before>` to walk older messages until
|
|
120
|
+
`has_more` is `false`.
|
|
121
|
+
|
|
122
|
+
A typical takeover:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
claim_next(mailbox: "support") # → { messages: [{ thread, task_id, task_state, … }] }
|
|
126
|
+
thread_history(thread: "<that thread>") # → catch up on the conversation so far
|
|
127
|
+
# … page older with before: next_before while has_more …
|
|
128
|
+
reply(to: "<requester>", thread: "<that thread>", body: "…") # answer in-context
|
|
129
|
+
task_update(id: "<task_id>", state: "completed") # close the loop
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Against a relay older than **1.8.0** (no thread-read route) `thread_history` returns a clear
|
|
133
|
+
"relay too old — upgrade to ≥1.8.0" error rather than failing cryptically.
|
|
134
|
+
|
|
102
135
|
### Instant presence + clean shutdown
|
|
103
136
|
|
|
104
137
|
By default the channel is idle until the agent calls `announce`. Set
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wastedtokens/agent-switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io) — pushes peer-agent messages into a live session, no polling.",
|
|
6
6
|
"license": "UNLICENSED",
|
package/switchboard-channel.mjs
CHANGED
|
@@ -504,7 +504,7 @@ const fail = (obj) => ({ isError: true, content: [{ type: 'text', text: JSON.str
|
|
|
504
504
|
|
|
505
505
|
// --- MCP server + tools -----------------------------------------------------
|
|
506
506
|
const server = new Server(
|
|
507
|
-
{ name: 'switchboard', version: '1.
|
|
507
|
+
{ name: 'switchboard', version: '1.4.0' },
|
|
508
508
|
{
|
|
509
509
|
capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
|
|
510
510
|
instructions:
|
|
@@ -516,7 +516,9 @@ const server = new Server(
|
|
|
516
516
|
'`logoff` when done. A message with a non-empty control attribute is a lifecycle signal, ' +
|
|
517
517
|
'not conversation. Shared work queues: `claim_next` pulls the oldest unclaimed message ' +
|
|
518
518
|
'from a shared mailbox (claiming also moves its open task to working under you); ' +
|
|
519
|
-
'`task_update` reports task progress (input-required / completed / failed).'
|
|
519
|
+
'`task_update` reports task progress (input-required / completed / failed). When a claim ' +
|
|
520
|
+
'or a wake lands you in an unfamiliar thread, `thread_history` reads the prior messages so ' +
|
|
521
|
+
'you can catch up before replying.',
|
|
520
522
|
},
|
|
521
523
|
)
|
|
522
524
|
|
|
@@ -525,10 +527,16 @@ const TOOLS = [
|
|
|
525
527
|
inputSchema: { type: 'object', properties: {
|
|
526
528
|
mailboxes: { type: 'array', items: { type: 'string' }, description: 'shared mailboxes to attach for reconcile counts (ignored when AGENT_MAILBOXES is set — env wins)' },
|
|
527
529
|
} } },
|
|
528
|
-
{ name: 'claim_next', description: 'Claim the oldest unclaimed message from a shared mailbox (work queue). The claimed message is delivered to this session and acked under YOUR identity; any open task on its thread moves to working, claimed by you. Empty result = nothing queued.',
|
|
530
|
+
{ name: 'claim_next', description: 'Claim the oldest unclaimed message from a shared mailbox (work queue). The claimed message is delivered to this session and acked under YOUR identity; any open task on its thread moves to working, claimed by you. Each claimed message carries its thread plus task_id/task_state (null when the thread has no open task) — drive the task with task_update. Landed mid-conversation? Call thread_history with that thread to read what came before. Empty result = nothing queued.',
|
|
529
531
|
inputSchema: { type: 'object', properties: {
|
|
530
532
|
mailbox: { type: 'string', description: 'shared mailbox name (kind=mailbox) to claim from' },
|
|
531
533
|
}, required: ['mailbox'] } },
|
|
534
|
+
{ name: 'thread_history', description: 'Read the prior messages of a thread for takeover context — a tenant-scoped, read-only observation that never claims or consumes. Pull it after claim_next lands you in an unfamiliar thread, or after a wake, to reconstruct the conversation before you joined. Returns messages oldest→newest plus has_more, next_before, and control_omitted; when has_more is true, call again with before: next_before to page older messages. The task and affinity blocks describe the thread\'s open work and its sticky consumer.',
|
|
535
|
+
inputSchema: { type: 'object', properties: {
|
|
536
|
+
thread: { type: 'string', description: 'thread id to read (the thread of a claimed/pushed message)' },
|
|
537
|
+
limit: { type: 'number', description: 'max messages this page (default 100, max 200)' },
|
|
538
|
+
before: { type: 'string', description: 'pagination cursor — pass the previous response\'s next_before to page older' },
|
|
539
|
+
}, required: ['thread'] } },
|
|
532
540
|
{ name: 'task_update', description: 'Update a task\'s state: working | input-required (escalated, waiting on the requester) | completed | failed. Tasks are created automatically when a message lands in a shared mailbox; claiming moves them to working. Closed tasks stay closed.',
|
|
533
541
|
inputSchema: { type: 'object', properties: {
|
|
534
542
|
id: { type: 'string', description: 'task id (task_…)' },
|
|
@@ -556,8 +564,57 @@ const TOOLS = [
|
|
|
556
564
|
|
|
557
565
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
558
566
|
|
|
567
|
+
// --- argument validation (issue #36 nit 2) ----------------------------------
|
|
568
|
+
// Reject a call with a missing/mistyped required arg or an unknown arg BEFORE any relay
|
|
569
|
+
// hit — a bare `message_status` used to send GET /messages/undefined (encodeURIComponent of
|
|
570
|
+
// undefined is the literal "undefined"), silently querying a nonexistent id. The check is
|
|
571
|
+
// driven off each tool's own inputSchema so every handler is covered uniformly. Required
|
|
572
|
+
// strings must be present and non-empty (they land in URL paths); optional strings may be
|
|
573
|
+
// empty; numbers accept a numeric value or numeric string (the relay parses either).
|
|
574
|
+
function typeError(k, v, spec, requireNonEmpty) {
|
|
575
|
+
const t = spec.type
|
|
576
|
+
if (t === 'string') {
|
|
577
|
+
if (typeof v !== 'string') return `parameter '${k}' must be a string`
|
|
578
|
+
if (requireNonEmpty && v.trim() === '') return `parameter '${k}' must not be empty`
|
|
579
|
+
} else if (t === 'number' || t === 'integer') {
|
|
580
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
|
|
581
|
+
if (!Number.isFinite(n)) return `parameter '${k}' must be a number`
|
|
582
|
+
} else if (t === 'array') {
|
|
583
|
+
if (!Array.isArray(v)) return `parameter '${k}' must be an array`
|
|
584
|
+
}
|
|
585
|
+
return null
|
|
586
|
+
}
|
|
587
|
+
function validateArgs(name, args) {
|
|
588
|
+
const tool = TOOLS.find((t) => t.name === name)
|
|
589
|
+
if (!tool) return null // unknown tool name — let the switch default throw its own error
|
|
590
|
+
const props = tool.inputSchema?.properties || {}
|
|
591
|
+
const required = tool.inputSchema?.required || []
|
|
592
|
+
for (const k of Object.keys(args)) {
|
|
593
|
+
if (!(k in props))
|
|
594
|
+
return `unknown parameter '${k}' for ${name} (allowed: ${Object.keys(props).join(', ') || 'none'})`
|
|
595
|
+
}
|
|
596
|
+
for (const k of required) {
|
|
597
|
+
const v = args[k]
|
|
598
|
+
if (v === undefined || v === null) return `missing required parameter '${k}' for ${name}`
|
|
599
|
+
const err = typeError(k, v, props[k] || {}, true)
|
|
600
|
+
if (err) return err
|
|
601
|
+
}
|
|
602
|
+
for (const k of Object.keys(props)) {
|
|
603
|
+
if (required.includes(k)) continue
|
|
604
|
+
const v = args[k]
|
|
605
|
+
if (v === undefined || v === null) continue // optional + absent is fine
|
|
606
|
+
const err = typeError(k, v, props[k] || {}, false)
|
|
607
|
+
if (err) return err
|
|
608
|
+
}
|
|
609
|
+
return null
|
|
610
|
+
}
|
|
611
|
+
|
|
559
612
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
560
613
|
const a = req.params.arguments || {}
|
|
614
|
+
// Param validation runs first so a malformed call fails clearly (and cheaply) before we
|
|
615
|
+
// touch identity/convergence or the relay — no GET /messages/undefined (issue #36 nit 2).
|
|
616
|
+
const paramErr = validateArgs(req.params.name, a)
|
|
617
|
+
if (paramErr) return fail({ error: paramErr })
|
|
561
618
|
// logoff must stay functional even when inert (a demoted client still cleans up on exit).
|
|
562
619
|
// Every other tool re-runs the same-session scan first (active convergence, routing-v2 §1c):
|
|
563
620
|
// an already-running client demotes the instant a live same-session peer beats it, and an
|
|
@@ -593,6 +650,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
593
650
|
return ok((await relay('GET', '/directory')).data)
|
|
594
651
|
case 'message_status':
|
|
595
652
|
return ok((await relay('GET', `/messages/${encodeURIComponent(a.id)}`)).data)
|
|
653
|
+
case 'thread_history': {
|
|
654
|
+
// Takeover context (spec §3): read-only passthrough of GET /api/v1/threads/{thread}.
|
|
655
|
+
// The pagination surface (has_more / next_before / control_omitted) and the task/affinity
|
|
656
|
+
// blocks are returned UNCHANGED so the consumer can page and reason about them directly.
|
|
657
|
+
const q = new URLSearchParams()
|
|
658
|
+
if (a.limit !== undefined && a.limit !== null) q.set('limit', String(a.limit))
|
|
659
|
+
if (a.before) q.set('before', String(a.before))
|
|
660
|
+
const qs = q.toString()
|
|
661
|
+
const r = await relay('GET', `/threads/${encodeURIComponent(a.thread)}${qs ? `?${qs}` : ''}`)
|
|
662
|
+
// Graceful degrade on a pre-1.8 relay: the route 404s with a non-JSON body (preRoutingV2),
|
|
663
|
+
// distinct from the route's own JSON 404 "no such thread" — same pattern as the receipt
|
|
664
|
+
// fallback. Wire errors (404 no such thread / 400 bad cursor) pass straight through.
|
|
665
|
+
if (preRoutingV2(r)) return fail({ error: 'relay too old for thread history — upgrade the relay to ≥1.8.0' })
|
|
666
|
+
return r.status === 200 ? ok(r.data) : fail(r.data)
|
|
667
|
+
}
|
|
596
668
|
case 'claim_next': {
|
|
597
669
|
// §2e: per-message claim from a shared work queue. Needs a registered consumer identity
|
|
598
670
|
// (the relay refuses mailbox/unknown consumers); lazy-register like send_message does.
|