@wakeshift/pi 1.4.40 → 1.4.42

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.
Files changed (2) hide show
  1. package/index.js +113 -11
  2. package/package.json +5 -5
package/index.js CHANGED
@@ -120,6 +120,83 @@ const linked = (sid) => {
120
120
  }
121
121
  }
122
122
 
123
+ // The session key of the session that installed this extension (issue
124
+ // #1106). Its result that offered the install went by before there was an
125
+ // extension, so no beacon came with it (nothing named the session yet), and
126
+ // it connects from the next result of the app's tools, whose beacon the core
127
+ // adds only for a call that names the session: by the session_key that
128
+ // result told the agent to pass, and the session's ID. Where the agent
129
+ // leaves the key out, the extension passes it, while the session has no
130
+ // link: taken from the session's own results of that server's tools from
131
+ // the hour before the extension was installed (installed_at, which no
132
+ // result after the install can change), and only if they hold one key (one
133
+ // quoted in what a tool showed, beside the session's own, passes nothing).
134
+ const KEY_LINE = /Pass session_key="(wsc_[A-Za-z0-9_-]{22,26})" to this app's tools/g
135
+ const KEY_WINDOW_MS = 3600000
136
+ const installedAt = () => {
137
+ try {
138
+ const t = Date.parse(JSON.parse(readFileSync(join(home(), "pi", "config.json"), "utf8")).installed_at)
139
+ return Number.isFinite(t) ? t : 0
140
+ } catch {
141
+ return 0
142
+ }
143
+ }
144
+ // pi's name for server's MCP tool tool (its createMcpToolName, as the
145
+ // connector's PiToolName checks beacons): mcp__<server>__<tool> with every
146
+ // character but ASCII letters, digits and _ turned into _, or, when that's
147
+ // too long or taken, its first 55 characters, _ and 8 hex digits of
148
+ // SHA-256(server NUL tool).
149
+ const PI_MAX_TOOL = 64
150
+ const piToolName = (server, tool, name) => {
151
+ const full = (MCP_PREFIX + server + "__" + tool).replace(/[^A-Za-z0-9_]/g, "_")
152
+ if (name === full && full.length <= PI_MAX_TOOL) return true
153
+ const hash = createHash("sha256").update(server + "\0" + tool).digest("hex").slice(0, 8)
154
+ return name === full.slice(0, PI_MAX_TOOL - hash.length - 1) + "_" + hash
155
+ }
156
+ // The MCP server whose tool name is (pi's own record of it: the tool's
157
+ // namespace, whose one alias is the server's exact name), only if no other
158
+ // server pi has could have a tool of that name: two servers whose names
159
+ // pi's naming makes the same (a-b and a_b), or one whose name starts
160
+ // another's (foo and foo__bar), leave the name to neither.
161
+ const serverOfTool = (tools, name) => {
162
+ const serverOf = (t) => {
163
+ const ns = t?.namespace
164
+ const a = Array.isArray(ns?.aliases) && ns.aliases.length === 1 ? ns.aliases[0] : undefined
165
+ return typeof a === "string" && a && ns.name === MCP_PREFIX + a.replace(/-/g, "_") ? a : undefined
166
+ }
167
+ if (!Array.isArray(tools)) return undefined
168
+ const server = serverOf(tools.find((t) => t?.name === name))
169
+ if (!server) return undefined
170
+ const servers = new Set(tools.map(serverOf).filter(Boolean))
171
+ const could = (s) => {
172
+ const prefix = (MCP_PREFIX + s + "__").replace(/[^A-Za-z0-9_]/g, "_")
173
+ return name.startsWith(prefix) || name.startsWith(prefix.slice(0, PI_MAX_TOOL - 9))
174
+ }
175
+ const owners = [...servers].filter(could)
176
+ return owners.length === 1 && owners[0] === server ? server : undefined
177
+ }
178
+ // The key, from the session's results of the exact server the called tool
179
+ // is (server, from serverOfTool): each result pi says came from that
180
+ // server's tool (details), named as pi names it.
181
+ const sessionKey = (entries, server) => {
182
+ const at = installedAt()
183
+ if (!at || !server || !Array.isArray(entries)) return ""
184
+ const keys = new Set()
185
+ for (const e of entries) {
186
+ const m = e && e.type === "message" ? e.message : undefined
187
+ const d = m?.details
188
+ if (m?.role !== "toolResult" || typeof m.toolName !== "string" || !d || d.server !== server || typeof d.tool !== "string") continue
189
+ if (!piToolName(d.server, d.tool, m.toolName)) continue
190
+ const t = typeof m.timestamp === "number" ? m.timestamp : Date.parse(e.timestamp)
191
+ if (!(t < at && t >= at - KEY_WINDOW_MS)) continue
192
+ for (const b of Array.isArray(m.content) ? m.content : []) {
193
+ if (b?.type !== "text" || typeof b.text !== "string") continue
194
+ for (const x of b.text.matchAll(KEY_LINE)) keys.add(x[1])
195
+ }
196
+ }
197
+ return keys.size === 1 ? [...keys][0] : ""
198
+ }
199
+
123
200
  // The connector's errors, for the user to look at.
124
201
  const log = () => {
125
202
  try {
@@ -172,7 +249,12 @@ export default function wakeshift(pi) {
172
249
  let ctx // the session's context, from session_start
173
250
  let sid = ""
174
251
  let child // the session's bridge, once it's connected
175
- let starting // a bridge still starting
252
+ // Bridges still starting. More than one only for beacons: two results in
253
+ // one turn each carry one, and each bridge redeems its own (the connector
254
+ // takes one redeem at a time, the second handing back the first's link),
255
+ // so neither is stopped for the other.
256
+ const starting = new Set()
257
+ const carrying = new WeakSet() // bridges started with a beacon
176
258
  let disposed = false
177
259
  let retry // { timer, delay, input }
178
260
  const stopped = new WeakSet() // bridges this extension ended on purpose
@@ -198,7 +280,7 @@ export default function wakeshift(pi) {
198
280
  retry.delay = Math.min(retry.delay * 2, RETRY_MAX_MS)
199
281
  retry.timer = setTimeout(() => {
200
282
  retry.timer = undefined
201
- if (!child && !starting && !disposed) void start(retry.input)
283
+ if (!child && !starting.size && !disposed) void start(retry.input)
202
284
  }, wait)
203
285
  retry.timer.unref?.()
204
286
  }
@@ -241,13 +323,14 @@ export default function wakeshift(pi) {
241
323
  } finally {
242
324
  if (typeof errs === "number") { try { closeSync(errs) } catch {} }
243
325
  }
244
- starting = c
326
+ starting.add(c)
327
+ if (input?.hook_event_name === "PostToolUse") carrying.add(c)
245
328
  let first = true
246
329
  let connected = false
247
330
  const done = (text, noteId) => {
248
331
  if (!first) return
249
332
  first = false
250
- if (starting === c) starting = undefined
333
+ starting.delete(c)
251
334
  clearTimeout(timer)
252
335
  const ack = noteId ? () => { try { c.stdin.write(JSON.stringify({ ack: noteId }) + "\n") } catch {} } : undefined
253
336
  resolve(text ? { text, ack } : undefined)
@@ -323,27 +406,43 @@ export default function wakeshift(pi) {
323
406
  settle()
324
407
  if (nudged) clearTimeout(nudged)
325
408
  nudged = undefined
326
- stop(starting)
409
+ for (const c of starting) stop(c)
327
410
  stop(child)
328
- starting = child = undefined
411
+ starting.clear()
412
+ child = undefined
329
413
  queue.length = 0
330
414
  inflight = []
331
415
  })
332
416
 
333
417
  // The app's tools that take Wakeshift's arguments learn which program and
334
418
  // session the call is from: harness "pi", and conversation_id this session's
335
- // ID, whatever the model passed. Only tools whose schema declares both.
419
+ // ID, whatever the model passed. Tools whose schema declares both; and,
420
+ // while this session has no link, the app's tools that declare only
421
+ // session_key (those that subscribe to nothing, which take all three),
422
+ // with the session's key where the agent left it out (sessionKey), so
423
+ // their result can connect the session that installed the extension.
336
424
  pi.on("tool_call", async (event, c) => {
337
425
  if (!isActive()) return
338
426
  try {
339
427
  if (typeof event.toolName !== "string" || !event.toolName.startsWith(MCP_PREFIX) || !event.input || typeof event.input !== "object") return
340
428
  const id = c?.sessionManager?.getSessionId?.() || sid
341
429
  if (!id) return
342
- const tool = pi.getAllTools().find((t) => t.name === event.toolName)
430
+ const tools = pi.getAllTools()
431
+ const tool = tools.find((t) => t.name === event.toolName)
343
432
  const props = tool?.parameters?.properties
344
- if (!props || typeof props !== "object" || !("harness" in props) || !("conversation_id" in props)) return
433
+ if (!props || typeof props !== "object") return
434
+ const both = "harness" in props && "conversation_id" in props
435
+ const unlinked = "session_key" in props && !child && !starting.size && !linked(id)
436
+ if (!both && !unlinked) return
345
437
  event.input.harness = "pi"
346
438
  event.input.conversation_id = id
439
+ const passed = event.input.session_key
440
+ if (unlinked && !(typeof passed === "string" && passed.trim())) {
441
+ let entries
442
+ try { entries = (c?.sessionManager ?? ctx?.sessionManager)?.getBranch?.() } catch {}
443
+ const key = sessionKey(entries, serverOfTool(tools, event.toolName))
444
+ if (key) event.input.session_key = key
445
+ }
347
446
  } catch {}
348
447
  })
349
448
 
@@ -372,9 +471,12 @@ export default function wakeshift(pi) {
372
471
  const d = event.details
373
472
  const mode = c?.mode ?? ctx?.mode
374
473
  if (isActive() && id && mode !== "print" && mode !== "json" && event.toolName?.startsWith(MCP_PREFIX) && d && typeof d.server === "string" && typeof d.tool === "string" && trailer(raw ?? event.content)) {
375
- // A fresh beacon replaces a pending retry, and a bridge still starting.
474
+ // A fresh beacon replaces a pending retry, and a bridge still
475
+ // starting without one; one still redeeming another beacon (an
476
+ // earlier result of the same turn) carries on, so that beacon's
477
+ // session is merged into this one's rather than lost.
376
478
  settle()
377
- if (starting) stop(starting)
479
+ for (const pending of [...starting]) if (!carrying.has(pending)) stop(pending)
378
480
  const note = await start({ hook_event_name: "PostToolUse", session_id: id, call_id: event.toolCallId, tool_name: event.toolName,
379
481
  server: d.server, mcp_tool: d.tool, tool_response: { content: raw ?? event.content } })
380
482
  if (note) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wakeshift/pi",
3
- "version": "1.4.40",
3
+ "version": "1.4.42",
4
4
  "description": "Wakeshift's extension for pi: messages from the apps your agent watches through Wakeshift wake your pi sessions, even while they're idle.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://wakeshift.ai/docs/agents",
@@ -31,10 +31,10 @@
31
31
  "node": ">=22"
32
32
  },
33
33
  "optionalDependencies": {
34
- "@wakeshift/connect-darwin-arm64": "1.4.40",
35
- "@wakeshift/connect-darwin-x64": "1.4.40",
36
- "@wakeshift/connect-linux-arm64": "1.4.40",
37
- "@wakeshift/connect-linux-x64": "1.4.40"
34
+ "@wakeshift/connect-darwin-arm64": "1.4.42",
35
+ "@wakeshift/connect-darwin-x64": "1.4.42",
36
+ "@wakeshift/connect-linux-arm64": "1.4.42",
37
+ "@wakeshift/connect-linux-x64": "1.4.42"
38
38
  },
39
39
  "bugs": {
40
40
  "url": "https://github.com/wakeshift/connectors/issues"