@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 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. `logoff` when done.
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.0",
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",
@@ -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.3.0' },
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.