@pushary/agent-hooks 0.95.6 → 0.95.7
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/CHANGELOG.md +7 -0
- package/data/cursor-plugin/.cursor-plugin/plugin.json +1 -1
- package/data/cursor-plugin/CHANGELOG.md +7 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +72 -17
- package/data/vscode-plugin/.claude-plugin/plugin.json +1 -1
- package/data/vscode-plugin/CHANGELOG.md +6 -0
- package/data/vscode-plugin/scripts/pushary-gate.mjs +76 -24
- package/dist/bin/pushary-bell-hook.js +18 -18
- package/dist/bin/pushary-bell.js +5 -5
- package/dist/bin/pushary-claude.js +24 -24
- package/dist/bin/pushary-clean.js +16 -16
- package/dist/bin/pushary-codex-bridge.js +29 -12
- package/dist/bin/pushary-codex-hook.js +29 -29
- package/dist/bin/pushary-codex.js +7 -7
- package/dist/bin/pushary-connect.js +6 -6
- package/dist/bin/pushary-cowork.js +3 -3
- package/dist/bin/pushary-daemon.js +22 -22
- package/dist/bin/pushary-disconnect.js +6 -6
- package/dist/bin/pushary-doctor.js +22 -21
- package/dist/bin/pushary-elicitation-hook.js +17 -17
- package/dist/bin/pushary-gemini-bridge.js +9 -9
- package/dist/bin/pushary-gemini-hook.js +65 -50
- package/dist/bin/pushary-hook.js +22 -22
- package/dist/bin/pushary-login.js +7 -7
- package/dist/bin/pushary-logout.js +4 -4
- package/dist/bin/pushary-mode.js +3 -3
- package/dist/bin/pushary-notification-hook.js +19 -19
- package/dist/bin/pushary-opencode-hook.js +29 -29
- package/dist/bin/pushary-permission-denied-hook.js +22 -22
- package/dist/bin/pushary-permission-hook.js +22 -22
- package/dist/bin/pushary-post-hook.js +19 -19
- package/dist/bin/pushary-prompt-hook.js +19 -19
- package/dist/bin/pushary-session-end-hook.js +19 -19
- package/dist/bin/pushary-session-start-hook.js +22 -22
- package/dist/bin/pushary-setup.js +24 -24
- package/dist/bin/pushary-stats.js +2 -2
- package/dist/bin/pushary-status.js +10 -10
- package/dist/bin/pushary-stop-hook.js +19 -19
- package/dist/bin/pushary-stopfailure-hook.js +19 -19
- package/dist/bin/pushary-transcript-register.js +16 -16
- package/dist/bin/pushary-transcripts.js +5 -5
- package/dist/bin/pushary-upgrade.js +14 -14
- package/dist/bin/pushary-wait.js +6 -5
- package/dist/{chunk-C7BFOC35.js → chunk-4OSQZ5TB.js} +2 -2
- package/dist/{chunk-HKQ6CD32.js → chunk-54W7ELDQ.js} +1 -1
- package/dist/{chunk-32WFHA77.js → chunk-5GKNAQTG.js} +1 -1
- package/dist/{chunk-XJ6CXTSB.js → chunk-6W2WGNO5.js} +7 -7
- package/dist/{chunk-CDBTCEKE.js → chunk-7RNY3UHJ.js} +3 -3
- package/dist/{chunk-FBO5WS3D.js → chunk-BGX4QUQF.js} +1 -1
- package/dist/{chunk-6II5RBAS.js → chunk-BQL7UDIB.js} +1 -1
- package/dist/{chunk-HHODMHTH.js → chunk-CNUDZS4V.js} +3 -3
- package/dist/chunk-CVWG2N3Y.js +16 -0
- package/dist/{chunk-WZ63WTAZ.js → chunk-EMGGUO2Q.js} +1 -1
- package/dist/{chunk-KNIVXEW4.js → chunk-FRXZMCVA.js} +1 -1
- package/dist/{chunk-A2M6FGUF.js → chunk-GQRU4KJF.js} +3 -3
- package/dist/{chunk-XKIB2FQY.js → chunk-HAXZ6CBF.js} +8 -8
- package/dist/{chunk-R4MBUCPQ.js → chunk-IZVK6FKD.js} +1 -1
- package/dist/{chunk-KAWLZTRJ.js → chunk-L7NOG547.js} +1 -1
- package/dist/{chunk-RPHXG4HI.js → chunk-NKUE5SJB.js} +2 -2
- package/dist/{chunk-DSCGKZO5.js → chunk-PQGLDN4V.js} +2 -2
- package/dist/{chunk-FVTFSYKB.js → chunk-QJPYJVL3.js} +1 -1
- package/dist/{chunk-ZQREURP6.js → chunk-SDNTRS76.js} +1 -1
- package/dist/{chunk-DH4INZW3.js → chunk-SKPXQRKN.js} +1 -1
- package/dist/{chunk-SLVXQOYU.js → chunk-TY6IJY6I.js} +1 -1
- package/dist/{chunk-43TFQOAT.js → chunk-U5RIXR7I.js} +1 -1
- package/dist/{chunk-AXECWTTZ.js → chunk-UKMLSQXA.js} +2 -2
- package/dist/{chunk-OIZVYUED.js → chunk-USWT4PTR.js} +1 -1
- package/dist/{chunk-SRFA5BOO.js → chunk-VDI645ZX.js} +1 -1
- package/dist/{chunk-JZSWA3VT.js → chunk-VERXHIQF.js} +1 -1
- package/dist/{chunk-FHIVWF4B.js → chunk-VIUOGMT5.js} +14 -14
- package/dist/{chunk-6MRPTYYH.js → chunk-VMQU7YEJ.js} +1 -1
- package/dist/{chunk-7N3D4GN7.js → chunk-VMZ4SWQD.js} +4 -4
- package/dist/{chunk-WRK6RAA5.js → chunk-WAUAYRUQ.js} +4 -4
- package/dist/{chunk-BS5XR4J7.js → chunk-WPS5IM7B.js} +3 -3
- package/dist/{chunk-ZJ6QXXBC.js → chunk-XJQOVWBQ.js} +11 -11
- package/dist/{chunk-O5RQ7RYJ.js → chunk-XO3JR3OP.js} +2 -2
- package/dist/{chunk-32WWVRXN.js → chunk-XUTSZYYJ.js} +1 -1
- package/dist/{chunk-4XFBO3GW.js → chunk-XZWUOCJZ.js} +1 -1
- package/dist/{chunk-HBBQO77U.js → chunk-Z4JVEL7R.js} +1 -1
- package/dist/src/index.js +10 -10
- package/package.json +1 -1
- package/dist/chunk-IIENZE2J.js +0 -15
- /package/dist/{chunk-VZADMT7R.js → chunk-MC5WUOJJ.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.95.7
|
|
4
|
+
|
|
5
|
+
- Let Gemini's own prompt decide in When I'm out when Pushary cannot create the approval question, instead of refusing the call. Every time still refuses, and says Pushary could not create a verifiable approval.
|
|
6
|
+
- Refuse approvals an unattended Gemini bridge cannot show locally with a specific reason (no local input, no phone answer in time, handled on the machine, no device connected), instead of passing them to Gemini's unexplained headless denial.
|
|
7
|
+
- Withdraw the phone question before a Codex bridge gives up on a Terminal or Updates question, so an answer that arrives during the withdrawal still counts.
|
|
8
|
+
- Describe what happens when you don't answer in `pushary doctor` and `pushary wait` in the current mode words: When I'm out asks your phone when you are away or Pushary cannot tell, then hands back to the agent's own prompt.
|
|
9
|
+
|
|
3
10
|
## 0.95.6
|
|
4
11
|
|
|
5
12
|
- Separate synthetic answer tests from real hook activation and report the actual answering surface.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "pushary",
|
|
3
3
|
"displayName": "Pushary — Control Panel for AI Agents",
|
|
4
4
|
"description": "Push notifications, human-in-the-loop questions, and permission gating for your AI coding agent. Get a push when a task finishes, answer the agent from your phone, and approve risky commands before they run.",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.3",
|
|
6
6
|
"author": { "name": "Pushary", "email": "business@pushary.com" },
|
|
7
7
|
"homepage": "https://pushary.com",
|
|
8
8
|
"repository": "https://github.com/Pushary/cursor-plugin",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
- Updates and Terminal hand an approval back to Cursor promptly. The gate no longer waits for a phone answer that Updates never asks for, and it withdraws the phone question before Cursor decides. An answer that arrives in that moment still counts.
|
|
6
|
+
- A file change that goes back to the agent's own permissions is refused with the reason, because Cursor file hooks cannot show a local approval. Choose Every time for file changes you want to approve from your phone.
|
|
7
|
+
- With no phone or other notification channel connected, a file change is refused with a message that says so. Shell commands and MCP calls still open Cursor's own prompt with "No device connected, approve here."
|
|
8
|
+
- Approval questions carry the repository and the tool name the gate judged, so routing and repository rules apply to the question as they did to the gate. A file change is asked about with its full path, so path rules and routing match it. A path longer than 80 characters falls back to the file type instead of making the question fail.
|
|
9
|
+
|
|
3
10
|
## 0.2.2
|
|
4
11
|
|
|
5
12
|
- Allow file edits when the server explicitly says the action is not gated.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
6
|
import { hostname } from 'node:os'
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
-
import { basename, dirname, join } from 'node:path'
|
|
8
|
+
import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
|
|
11
11
|
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || 'https://pushary.com'
|
|
@@ -22,6 +22,13 @@ const ALLOW = { permission: 'allow' }
|
|
|
22
22
|
let genericToolHook = false
|
|
23
23
|
const ask = (agentMessage) => genericToolHook ? deny(agentMessage ?? 'Approval requires a user decision. Ask through Pushary, then retry.') : (agentMessage ? { permission: 'ask', agent_message: agentMessage } : { permission: 'ask' })
|
|
24
24
|
const deny = (agentMessage) => ({ permission: 'deny', user_message: 'Command denied via Pushary.', agent_message: agentMessage })
|
|
25
|
+
// A quiet handoff returns to the agent's permissions, but a file hook has no
|
|
26
|
+
// local prompt to return to. Retrying cannot help, so say why. Same text as the gate route.
|
|
27
|
+
const QUIET_FILE_HOOK_REASON = 'This Cursor file hook cannot show a local approval. Switch to Every time for this action.'
|
|
28
|
+
const handBack = (agentMessage) => genericToolHook ? deny(QUIET_FILE_HOOK_REASON) : ask(agentMessage)
|
|
29
|
+
// No channel can reach anyone, and a file hook has no local prompt to fall back to either.
|
|
30
|
+
const NO_DEVICE_FILE_HOOK_REASON = 'No phone or other notification channel is connected to Pushary, and this Cursor file hook cannot show a local approval. Connect one in Pushary, then retry.'
|
|
31
|
+
const noDevice = () => genericToolHook ? deny(NO_DEVICE_FILE_HOOK_REASON) : ask('No device connected, approve here.')
|
|
25
32
|
const unresolved = (verdict) => genericToolHook && verdict?.reason === 'not_gated'
|
|
26
33
|
? ALLOW
|
|
27
34
|
: ask(genericToolHook ? 'Pushary could not reach a verdict for this change. Retry shortly.' : undefined)
|
|
@@ -317,7 +324,42 @@ const deriveActionBody = (command) => capActionBody(redactSecretsDeep(command))
|
|
|
317
324
|
// one-tap Approve on a dangerous call, and record a decision at the same grain as
|
|
318
325
|
// every other agent. Without them a destructive Cursor command arrived on the
|
|
319
326
|
// lock screen ungated.
|
|
320
|
-
|
|
327
|
+
// ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
|
|
328
|
+
// @pushary/contracts). A longer one fails the whole ask.
|
|
329
|
+
const TOOL_TARGET_MAX = 80
|
|
330
|
+
|
|
331
|
+
// A file change is routed and matched by its path, so the ask carries the path the
|
|
332
|
+
// gate judged, resolved against cwd the way the server resolves it. A path too long
|
|
333
|
+
// for ask_user is left out and the server's own target is used instead.
|
|
334
|
+
const filePathTarget = (path, cwd) => {
|
|
335
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
336
|
+
const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
|
|
337
|
+
return full.length <= TOOL_TARGET_MAX ? full : undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
|
|
341
|
+
// rejects the whole ask for anything else.
|
|
342
|
+
const TOOL_PATH_MAX = 4096
|
|
343
|
+
|
|
344
|
+
// The exact path, so routing and path rules still match a file whose path is too
|
|
345
|
+
// long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
|
|
346
|
+
// with no absolute cwd, or a Windows path, is left out rather than failing the ask.
|
|
347
|
+
const filePathForAsk = (path, cwd) => {
|
|
348
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
349
|
+
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
|
|
350
|
+
const full = path.startsWith('/') ? path
|
|
351
|
+
: typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
|
|
352
|
+
return full && full.length <= TOOL_PATH_MAX ? full : undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The canonical tool and target from a gate verdict, taken by name. Anything else a
|
|
356
|
+
// server adds to questionContext must never overwrite what this gate asks with.
|
|
357
|
+
const canonicalQuestion = (context) => ({
|
|
358
|
+
toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
|
|
359
|
+
toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
const commandHead = (command) => command.trim().split(/\s+/).slice(0, 2).join(' ').slice(0, TOOL_TARGET_MAX)
|
|
321
363
|
|
|
322
364
|
const askArgs = (request, project, ident) => ({
|
|
323
365
|
question: `${request.prompt}\n\n${redactSecrets(request.display)}`,
|
|
@@ -326,8 +368,10 @@ const askArgs = (request, project, ident) => ({
|
|
|
326
368
|
agentName: ident.agentName,
|
|
327
369
|
sessionId: ident.sessionId,
|
|
328
370
|
machineId: ident.machineId,
|
|
371
|
+
repoKey: ident.repoKey,
|
|
329
372
|
toolName: request.toolName,
|
|
330
373
|
toolTarget: request.toolTarget,
|
|
374
|
+
...(request.toolPath ? { toolPath: request.toolPath } : {}),
|
|
331
375
|
actionBody: deriveActionBody(request.display),
|
|
332
376
|
wait: false,
|
|
333
377
|
waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
|
|
@@ -377,10 +421,11 @@ const withdraw = async (apiKey, correlationId) => {
|
|
|
377
421
|
}
|
|
378
422
|
}
|
|
379
423
|
|
|
380
|
-
const handedOff = (asked) => asked.suppressed || asked.status === 'terminal'
|
|
424
|
+
const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
|
|
425
|
+
|| asked.handoffAction === 'cancel_then_ask_in_current_client'
|
|
381
426
|
|
|
382
427
|
const handoffMessage = (asked) =>
|
|
383
|
-
asked.suppressed ? 'You are at the keyboard,
|
|
428
|
+
asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
|
|
384
429
|
|
|
385
430
|
const denialFor = (noun) =>
|
|
386
431
|
`The user denied this ${noun} via a Pushary push approval. Do not run it — propose an alternative or ask how to proceed.`
|
|
@@ -404,8 +449,10 @@ const describeRequest = (input) => {
|
|
|
404
449
|
if (!['Write', 'Delete', 'Edit'].includes(input.tool_name)) return null
|
|
405
450
|
const params = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {}
|
|
406
451
|
const display = `${input.tool_name}\n${JSON.stringify(params)}`
|
|
452
|
+
const toolPath = filePathForAsk(params.file_path ?? params.path, input.cwd)
|
|
407
453
|
return { toolName: input.tool_name, toolInputs: [params], display,
|
|
408
|
-
toolTarget:
|
|
454
|
+
toolTarget: filePathTarget(params.file_path ?? params.path, input.cwd),
|
|
455
|
+
...(toolPath ? { toolPath } : {}),
|
|
409
456
|
prompt: 'Allow this file change?', verb: 'change this file', denied: denialFor('file change') }
|
|
410
457
|
}
|
|
411
458
|
|
|
@@ -434,7 +481,7 @@ const describeRequest = (input) => {
|
|
|
434
481
|
toolName,
|
|
435
482
|
toolInputs: [{ tool: toolName, params }],
|
|
436
483
|
display: params ? `${toolName}\n${params}` : toolName,
|
|
437
|
-
toolTarget: toolName.slice(0,
|
|
484
|
+
toolTarget: toolName.slice(0, TOOL_TARGET_MAX),
|
|
438
485
|
prompt: 'Allow this tool call?',
|
|
439
486
|
verb: 'call this tool',
|
|
440
487
|
denied: denialFor('tool call'),
|
|
@@ -471,17 +518,19 @@ const handlePushOnly = async (apiKey, request, project, ident, timeoutSeconds, t
|
|
|
471
518
|
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
472
519
|
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
473
520
|
|
|
474
|
-
|
|
521
|
+
// No device first. The server checks for a channel before any quiet mode, and
|
|
522
|
+
// that reply carries the same handoffAction as a quiet handoff.
|
|
523
|
+
if (asked.noDevices) {
|
|
475
524
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
476
525
|
if (stopped(late)) return deny(STOP_REASON)
|
|
477
526
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
478
|
-
return
|
|
527
|
+
return noDevice()
|
|
479
528
|
}
|
|
480
|
-
if (asked
|
|
529
|
+
if (handedOff(asked)) {
|
|
481
530
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
482
531
|
if (stopped(late)) return deny(STOP_REASON)
|
|
483
532
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
484
|
-
return
|
|
533
|
+
return handBack(handoffMessage(asked))
|
|
485
534
|
}
|
|
486
535
|
|
|
487
536
|
const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
|
|
@@ -518,17 +567,17 @@ const handlePushFirst = async (apiKey, request, project, ident, pushFirstSeconds
|
|
|
518
567
|
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
519
568
|
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
520
569
|
|
|
521
|
-
if (
|
|
570
|
+
if (asked.noDevices) {
|
|
522
571
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
523
572
|
if (stopped(late)) return deny(STOP_REASON)
|
|
524
573
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
525
|
-
return
|
|
574
|
+
return noDevice()
|
|
526
575
|
}
|
|
527
|
-
if (asked
|
|
576
|
+
if (handedOff(asked)) {
|
|
528
577
|
const late = await withdraw(apiKey, asked.correlationId)
|
|
529
578
|
if (stopped(late)) return deny(STOP_REASON)
|
|
530
579
|
if (late.answered) return fromAnswer(late, request.denied)
|
|
531
|
-
return
|
|
580
|
+
return handBack(handoffMessage(asked))
|
|
532
581
|
}
|
|
533
582
|
|
|
534
583
|
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
@@ -557,7 +606,7 @@ const handleNotifyOnly = async (apiKey, request, project, ident) => {
|
|
|
557
606
|
machineId: ident.machineId,
|
|
558
607
|
})
|
|
559
608
|
} catch {}
|
|
560
|
-
return
|
|
609
|
+
return handBack()
|
|
561
610
|
}
|
|
562
611
|
|
|
563
612
|
const TELEMETRY_EVENTS = ["postToolUse", "stop", "sessionStart", "sessionEnd", "beforeSubmitPrompt", "preCompact", "subagentStart", "subagentStop"]
|
|
@@ -620,7 +669,7 @@ setTimeout(async () => {
|
|
|
620
669
|
|
|
621
670
|
const project = basename(input.cwd || process.cwd()) || 'workspace'
|
|
622
671
|
const sessionId = typeof input.conversation_id === 'string' ? input.conversation_id : undefined
|
|
623
|
-
const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId() }
|
|
672
|
+
const ident = { agentName: `Cursor - ${project}`, sessionId, machineId: getMachineId(), repoKey: deriveRepoKey(input.cwd) }
|
|
624
673
|
|
|
625
674
|
try {
|
|
626
675
|
const verdict = await decide(apiKey, request, input.cwd, sessionId)
|
|
@@ -632,11 +681,17 @@ setTimeout(async () => {
|
|
|
632
681
|
if (verdict.kind === 'deny') return respond(deny(verdict.reason))
|
|
633
682
|
if (verdict.kind !== 'ask') return respond(ask())
|
|
634
683
|
|
|
684
|
+
// Reuse the server's canonical tool and target, so asking resolves the same rules
|
|
685
|
+
// as the gate. A file change keeps its own path: the server's target for a file
|
|
686
|
+
// is only its extension, which loses what routing and path rules match on.
|
|
687
|
+
const canonical = canonicalQuestion(verdict.questionContext)
|
|
688
|
+
request.toolName = canonical.toolName ?? request.toolName
|
|
689
|
+
request.toolTarget = (genericToolHook && request.toolTarget) || canonical.toolTarget || request.toolTarget
|
|
635
690
|
const tool = verdict.policy
|
|
636
691
|
|
|
637
692
|
switch (tool.mode) {
|
|
638
693
|
case 'terminal_only':
|
|
639
|
-
return respond(
|
|
694
|
+
return respond(handBack())
|
|
640
695
|
case 'notify_only':
|
|
641
696
|
return respond(await handleNotifyOnly(apiKey, request, project, ident))
|
|
642
697
|
case 'push_only':
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pushary",
|
|
3
3
|
"description": "Push notifications, human-in-the-loop questions, and permission gating for your AI agent. Get a push when a task finishes, answer the agent from your phone, and approve risky commands before they run.",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pushary",
|
|
7
7
|
"email": "business@pushary.com",
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
- Updates hands an approval back to VS Code promptly. The gate no longer waits for a phone answer that Updates never asks for, and it withdraws the phone question before VS Code asks. An answer that arrives in that moment still counts.
|
|
6
|
+
- With no phone or other notification channel connected, VS Code's prompt says "No device connected, approve here." instead of naming a delivery mode.
|
|
7
|
+
- Approval questions carry the repository and the tool name the gate judged, so routing and repository rules apply to the question as they did to the gate. A file edit is asked about with its full path, so path rules and routing match it. A path longer than 80 characters falls back to the file type instead of making the question fail.
|
|
8
|
+
|
|
3
9
|
## 0.2.2
|
|
4
10
|
|
|
5
11
|
- Classify local reads, searches, diagnostics, and terminal/task output inspection as reads under the shared policy.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
6
|
import { homedir, hostname } from 'node:os'
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
-
import { basename, dirname, join } from 'node:path'
|
|
8
|
+
import { basename, dirname, isAbsolute, join, posix, resolve } from 'node:path'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
|
|
11
11
|
const BASE_URL = process.env.PUSHARY_BASE_URL?.trim() || process.env.PUSHARY_API_URL?.trim() || 'https://pushary.com'
|
|
@@ -213,16 +213,8 @@ const GATE_TIMEOUT_MS = 5000
|
|
|
213
213
|
* Null is not a denial and not an approval: every failure hands the call back to
|
|
214
214
|
* VS Code's own prompt, exactly as if this gate were not installed.
|
|
215
215
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* is which policy profile answers, and a site without an explicit Cursor profile
|
|
219
|
-
* falls back to the all-agents one, which is what this gate already received.
|
|
220
|
-
*
|
|
221
|
-
* No `repoKey` is sent, because deriving one means walking parent directories
|
|
222
|
-
* for a `.git` and parsing its config, and a second implementation of that rule
|
|
223
|
-
* is how the last drift started. Without one the server correctly declines to
|
|
224
|
-
* apply repo-scoped rules, since an unknown repository is not evidence of a
|
|
225
|
-
* match -- which is what this gate already got, by being filtered server-side.
|
|
216
|
+
* Uses the VS Code policy profile and the same repository identity carried
|
|
217
|
+
* by the later question. Tool aliases and targets are canonicalized server-side.
|
|
226
218
|
*/
|
|
227
219
|
const decide = async (apiKey, command, cwd, sessionId, ident = {}) => {
|
|
228
220
|
try {
|
|
@@ -424,6 +416,51 @@ export const describeNetworkFailure = (error, env = process.env) => {
|
|
|
424
416
|
// Bearer ..."` left the machine and landed in the question. The server scrubs
|
|
425
417
|
// this field too, but a credential should never travel to be scrubbed on
|
|
426
418
|
// arrival, and the notify body below was scrubbed nowhere at all.
|
|
419
|
+
// ask_user accepts a target of at most 80 characters (TOOL_TARGET_MAX_LENGTH in
|
|
420
|
+
// @pushary/contracts). A longer one fails the whole ask.
|
|
421
|
+
const TOOL_TARGET_MAX = 80
|
|
422
|
+
|
|
423
|
+
// A file change is routed and matched by its path, so the ask carries the path the
|
|
424
|
+
// gate judged, resolved against cwd the way the server resolves it. A path too long
|
|
425
|
+
// for ask_user is left out and the server's own target is used instead.
|
|
426
|
+
const filePathTarget = (path, cwd) => {
|
|
427
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
428
|
+
const full = cwd && !isAbsolute(path) ? resolve(cwd, path) : path
|
|
429
|
+
return full.length <= TOOL_TARGET_MAX ? full : undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ask_user takes toolPath as an absolute POSIX path of at most 4096 characters and
|
|
433
|
+
// rejects the whole ask for anything else.
|
|
434
|
+
const TOOL_PATH_MAX = 4096
|
|
435
|
+
|
|
436
|
+
// The exact path, so routing and path rules still match a file whose path is too
|
|
437
|
+
// long for toolTarget. Sent only when it is an absolute POSIX path: a relative path
|
|
438
|
+
// with no absolute cwd, or a Windows path, is left out rather than failing the ask.
|
|
439
|
+
const filePathForAsk = (path, cwd) => {
|
|
440
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
441
|
+
if (/^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\')) return undefined
|
|
442
|
+
const full = path.startsWith('/') ? path
|
|
443
|
+
: typeof cwd === 'string' && cwd.startsWith('/') ? posix.resolve(cwd, path) : undefined
|
|
444
|
+
return full && full.length <= TOOL_PATH_MAX ? full : undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The canonical tool and target from a gate verdict, taken by name. Anything else a
|
|
448
|
+
// server adds to questionContext must never overwrite what this gate asks with.
|
|
449
|
+
const canonicalQuestion = (context) => ({
|
|
450
|
+
toolName: typeof context?.toolName === 'string' && context.toolName ? context.toolName : undefined,
|
|
451
|
+
toolTarget: typeof context?.toolTarget === 'string' && context.toolTarget ? context.toolTarget : undefined,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// The tools the server targets by file (FILE_TARGET_TOOLS in @pushary/contracts).
|
|
455
|
+
const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
|
|
456
|
+
|
|
457
|
+
// The same fields the server reads a VS Code file tool's path from.
|
|
458
|
+
const extractFilePath = (toolInput) => {
|
|
459
|
+
if (!toolInput || typeof toolInput !== 'object') return undefined
|
|
460
|
+
const path = toolInput.file_path ?? toolInput.filePath ?? toolInput.path
|
|
461
|
+
return typeof path === 'string' && path ? path : undefined
|
|
462
|
+
}
|
|
463
|
+
|
|
427
464
|
const askArgs = (command, project, ident) => ({
|
|
428
465
|
question: `Allow this action?\n\n${redactSecrets(command)}`,
|
|
429
466
|
type: 'confirm',
|
|
@@ -431,7 +468,10 @@ const askArgs = (command, project, ident) => ({
|
|
|
431
468
|
agentName: ident.agentName,
|
|
432
469
|
sessionId: ident.sessionId,
|
|
433
470
|
machineId: ident.machineId,
|
|
471
|
+
repoKey: ident.repoKey,
|
|
434
472
|
toolName: ident.toolName ?? 'Bash',
|
|
473
|
+
toolTarget: ident.toolTarget,
|
|
474
|
+
...(ident.toolPath ? { toolPath: ident.toolPath } : {}),
|
|
435
475
|
actionBody: deriveActionBody(command),
|
|
436
476
|
wait: false,
|
|
437
477
|
waitEndsAt: new Date(Date.now() + MAX_BLOCK_MS).toISOString(),
|
|
@@ -483,10 +523,11 @@ const withdrawQuestion = async (apiKey, correlationId) => {
|
|
|
483
523
|
}
|
|
484
524
|
}
|
|
485
525
|
|
|
486
|
-
const handedOff = (asked) => asked.suppressed || asked.status === 'terminal'
|
|
526
|
+
const handedOff = (asked) => asked.suppressed || asked.status === 'terminal' || asked.status === 'notified'
|
|
527
|
+
|| asked.handoffAction === 'cancel_then_ask_in_current_client'
|
|
487
528
|
|
|
488
529
|
const handoffMessage = (asked) =>
|
|
489
|
-
asked.suppressed ? 'You are at the keyboard,
|
|
530
|
+
asked.suppressed ? 'You are at the keyboard, continue with the agent’s permissions.' : 'Continue with the agent’s permissions.'
|
|
490
531
|
|
|
491
532
|
// push_only: wait up to the policy timeout, then apply the timeout action.
|
|
492
533
|
const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, timeoutAction) => {
|
|
@@ -502,19 +543,21 @@ const handlePushOnly = async (apiKey, command, project, ident, timeoutSeconds, t
|
|
|
502
543
|
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
503
544
|
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
504
545
|
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
if (
|
|
546
|
+
// No device first. The server checks for a channel before any quiet mode, and
|
|
547
|
+
// that reply carries the same handoffAction as a quiet handoff.
|
|
548
|
+
if (asked.noDevices) {
|
|
508
549
|
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
509
550
|
if (stopped(late)) return deny(STOP_REASON)
|
|
510
551
|
if (late.answered) return fromAnswer(late)
|
|
511
|
-
return ask(
|
|
552
|
+
return ask('No device connected, approve here.')
|
|
512
553
|
}
|
|
513
|
-
|
|
554
|
+
// Keyboard bypass: the user is at the keyboard, so VS Code's own prompt is the
|
|
555
|
+
// faster channel.
|
|
556
|
+
if (handedOff(asked)) {
|
|
514
557
|
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
515
558
|
if (stopped(late)) return deny(STOP_REASON)
|
|
516
559
|
if (late.answered) return fromAnswer(late)
|
|
517
|
-
return ask(
|
|
560
|
+
return ask(handoffMessage(asked))
|
|
518
561
|
}
|
|
519
562
|
|
|
520
563
|
const realMs = timeoutAction === 'wait' ? MAX_BLOCK_MS : Math.max(timeoutSeconds, 1) * 1000
|
|
@@ -551,17 +594,17 @@ const handlePushFirst = async (apiKey, command, project, ident, pushFirstSeconds
|
|
|
551
594
|
if (!asked?.correlationId) return deny('Pushary did not return a verifiable approval.')
|
|
552
595
|
activeQuestion = { apiKey, correlationId: asked.correlationId }
|
|
553
596
|
|
|
554
|
-
if (
|
|
597
|
+
if (asked.noDevices) {
|
|
555
598
|
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
556
599
|
if (stopped(late)) return deny(STOP_REASON)
|
|
557
600
|
if (late.answered) return fromAnswer(late)
|
|
558
|
-
return ask(
|
|
601
|
+
return ask('No device connected, approve here.')
|
|
559
602
|
}
|
|
560
|
-
if (asked
|
|
603
|
+
if (handedOff(asked)) {
|
|
561
604
|
const late = await withdrawQuestion(apiKey, asked.correlationId)
|
|
562
605
|
if (stopped(late)) return deny(STOP_REASON)
|
|
563
606
|
if (late.answered) return fromAnswer(late)
|
|
564
|
-
return ask(
|
|
607
|
+
return ask(handoffMessage(asked))
|
|
565
608
|
}
|
|
566
609
|
|
|
567
610
|
const cap = Math.min(Math.max(pushFirstSeconds, 1) * 1000, MAX_BLOCK_MS)
|
|
@@ -665,7 +708,7 @@ const main = async () => {
|
|
|
665
708
|
|
|
666
709
|
const project = basename(input.cwd || process.cwd()) || 'workspace'
|
|
667
710
|
const sessionId = typeof (input.session_id ?? input.sessionId) === 'string' ? (input.session_id ?? input.sessionId) : undefined
|
|
668
|
-
const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId(), toolName: input.tool_name, toolInputs: [input.tool_input ?? {}] }
|
|
711
|
+
const ident = { agentName: `VS Code - ${project}`, sessionId, machineId: getMachineId(), toolName: input.tool_name, toolInputs: [input.tool_input ?? {}], repoKey: deriveRepoKey(input.cwd) }
|
|
669
712
|
|
|
670
713
|
try {
|
|
671
714
|
const verdict = await decide(apiKey, command, input.cwd, sessionId, ident)
|
|
@@ -678,6 +721,15 @@ const main = async () => {
|
|
|
678
721
|
if (verdict.kind === 'deny') return respond(deny(verdict.reason))
|
|
679
722
|
if (verdict.kind !== 'ask') return respond(ask())
|
|
680
723
|
|
|
724
|
+
// Reuse the server's canonical tool and target so asking resolves the same rules
|
|
725
|
+
// as the gate, taken by name so nothing else in questionContext can overwrite this
|
|
726
|
+
// gate's identity. A file change keeps its own path: the server's target for a
|
|
727
|
+
// file is only its extension, which loses what routing and path rules match on.
|
|
728
|
+
const canonical = canonicalQuestion(verdict.questionContext)
|
|
729
|
+
if (canonical.toolName) ident.toolName = canonical.toolName
|
|
730
|
+
const ownFile = FILE_TOOLS.has(ident.toolName) ? extractFilePath(input.tool_input) : undefined
|
|
731
|
+
ident.toolTarget = filePathTarget(ownFile, input.cwd) ?? canonical.toolTarget
|
|
732
|
+
ident.toolPath = filePathForAsk(ownFile, input.cwd)
|
|
681
733
|
const tool = verdict.policy
|
|
682
734
|
|
|
683
735
|
switch (tool.mode) {
|
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
handleBell
|
|
4
|
-
} from "../chunk-
|
|
4
|
+
} from "../chunk-TY6IJY6I.js";
|
|
5
5
|
import {
|
|
6
6
|
readHookInput
|
|
7
|
-
} from "../chunk-
|
|
8
|
-
import "../chunk-
|
|
9
|
-
import "../chunk-
|
|
10
|
-
import "../chunk-
|
|
11
|
-
import "../chunk-
|
|
12
|
-
import "../chunk-
|
|
13
|
-
import "../chunk-
|
|
14
|
-
import "../chunk-
|
|
15
|
-
import "../chunk-
|
|
7
|
+
} from "../chunk-IZVK6FKD.js";
|
|
8
|
+
import "../chunk-HAXZ6CBF.js";
|
|
9
|
+
import "../chunk-EMGGUO2Q.js";
|
|
10
|
+
import "../chunk-WPS5IM7B.js";
|
|
11
|
+
import "../chunk-XZWUOCJZ.js";
|
|
12
|
+
import "../chunk-QJPYJVL3.js";
|
|
13
|
+
import "../chunk-GQRU4KJF.js";
|
|
14
|
+
import "../chunk-VMZ4SWQD.js";
|
|
15
|
+
import "../chunk-7RNY3UHJ.js";
|
|
16
16
|
import "../chunk-GOPIMNVC.js";
|
|
17
17
|
import "../chunk-3AJIK5AO.js";
|
|
18
|
-
import "../chunk-
|
|
18
|
+
import "../chunk-SKPXQRKN.js";
|
|
19
19
|
import "../chunk-EQRS3SGM.js";
|
|
20
|
-
import "../chunk-
|
|
20
|
+
import "../chunk-VERXHIQF.js";
|
|
21
21
|
import "../chunk-XHKBHWLX.js";
|
|
22
22
|
import "../chunk-L4ZCFMWW.js";
|
|
23
|
-
import "../chunk-
|
|
24
|
-
import "../chunk-
|
|
25
|
-
import "../chunk-
|
|
23
|
+
import "../chunk-XUTSZYYJ.js";
|
|
24
|
+
import "../chunk-4OSQZ5TB.js";
|
|
25
|
+
import "../chunk-BGX4QUQF.js";
|
|
26
26
|
import "../chunk-GMXKITVA.js";
|
|
27
27
|
import "../chunk-DINDL2CF.js";
|
|
28
|
-
import "../chunk-
|
|
28
|
+
import "../chunk-VMQU7YEJ.js";
|
|
29
29
|
import "../chunk-7QLSKOSU.js";
|
|
30
30
|
import "../chunk-KZERVKTD.js";
|
|
31
|
-
import "../chunk-
|
|
31
|
+
import "../chunk-L7NOG547.js";
|
|
32
32
|
import "../chunk-2UMNXADU.js";
|
|
33
|
-
import "../chunk-
|
|
33
|
+
import "../chunk-MC5WUOJJ.js";
|
|
34
34
|
|
|
35
35
|
// bin/pushary-bell-hook.ts
|
|
36
36
|
var main = async () => {
|
package/dist/bin/pushary-bell.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
liveAgentCount,
|
|
6
6
|
ring,
|
|
7
7
|
upgradeThreshold
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-TY6IJY6I.js";
|
|
9
9
|
import {
|
|
10
10
|
addBellHooks,
|
|
11
11
|
bellInstalled,
|
|
@@ -15,7 +15,7 @@ import "../chunk-3AJIK5AO.js";
|
|
|
15
15
|
import "../chunk-L4ZCFMWW.js";
|
|
16
16
|
import {
|
|
17
17
|
claudeSettings
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-BGX4QUQF.js";
|
|
19
19
|
import {
|
|
20
20
|
createIo
|
|
21
21
|
} from "../chunk-5RUIFDTP.js";
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
parseFlags
|
|
28
28
|
} from "../chunk-GMXKITVA.js";
|
|
29
29
|
import "../chunk-DINDL2CF.js";
|
|
30
|
-
import "../chunk-
|
|
30
|
+
import "../chunk-VMQU7YEJ.js";
|
|
31
31
|
import "../chunk-7QLSKOSU.js";
|
|
32
32
|
import {
|
|
33
33
|
EXIT
|
|
@@ -35,9 +35,9 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
readJsonSafe,
|
|
37
37
|
writeJsonAtomic
|
|
38
|
-
} from "../chunk-
|
|
38
|
+
} from "../chunk-L7NOG547.js";
|
|
39
39
|
import "../chunk-2UMNXADU.js";
|
|
40
|
-
import "../chunk-
|
|
40
|
+
import "../chunk-MC5WUOJJ.js";
|
|
41
41
|
|
|
42
42
|
// bin/pushary-bell.ts
|
|
43
43
|
exitOnHelpFlag("bell");
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
describeIdleWindow,
|
|
9
9
|
startProcessOwnership,
|
|
10
10
|
withSpawnProcessOwnership
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-PQGLDN4V.js";
|
|
12
12
|
import {
|
|
13
13
|
isDeferAnswer
|
|
14
14
|
} from "../chunk-YHG74UFF.js";
|
|
@@ -17,25 +17,25 @@ import {
|
|
|
17
17
|
loadSessionKey,
|
|
18
18
|
registerTranscript,
|
|
19
19
|
registeredTranscriptEnabled
|
|
20
|
-
} from "../chunk-
|
|
21
|
-
import "../chunk-
|
|
22
|
-
import "../chunk-
|
|
23
|
-
import "../chunk-
|
|
24
|
-
import "../chunk-
|
|
25
|
-
import "../chunk-
|
|
20
|
+
} from "../chunk-HAXZ6CBF.js";
|
|
21
|
+
import "../chunk-EMGGUO2Q.js";
|
|
22
|
+
import "../chunk-WPS5IM7B.js";
|
|
23
|
+
import "../chunk-XZWUOCJZ.js";
|
|
24
|
+
import "../chunk-QJPYJVL3.js";
|
|
25
|
+
import "../chunk-GQRU4KJF.js";
|
|
26
26
|
import {
|
|
27
27
|
ensureDaemonRunning
|
|
28
|
-
} from "../chunk-
|
|
29
|
-
import "../chunk-
|
|
28
|
+
} from "../chunk-VMZ4SWQD.js";
|
|
29
|
+
import "../chunk-7RNY3UHJ.js";
|
|
30
30
|
import "../chunk-GOPIMNVC.js";
|
|
31
31
|
import "../chunk-3AJIK5AO.js";
|
|
32
32
|
import {
|
|
33
33
|
encryptRecord
|
|
34
|
-
} from "../chunk-
|
|
34
|
+
} from "../chunk-SKPXQRKN.js";
|
|
35
35
|
import "../chunk-EQRS3SGM.js";
|
|
36
36
|
import {
|
|
37
37
|
transcriptsEnabled
|
|
38
|
-
} from "../chunk-
|
|
38
|
+
} from "../chunk-VERXHIQF.js";
|
|
39
39
|
import {
|
|
40
40
|
needsShell,
|
|
41
41
|
spawnClaude
|
|
@@ -54,7 +54,14 @@ import {
|
|
|
54
54
|
repoKeyFor,
|
|
55
55
|
reportEvent,
|
|
56
56
|
savePendingCommand
|
|
57
|
-
} from "../chunk-
|
|
57
|
+
} from "../chunk-XJQOVWBQ.js";
|
|
58
|
+
import {
|
|
59
|
+
UNNAMED_ACTION,
|
|
60
|
+
deriveToolTarget,
|
|
61
|
+
describeToolCall,
|
|
62
|
+
scopePathFor
|
|
63
|
+
} from "../chunk-Z4JVEL7R.js";
|
|
64
|
+
import "../chunk-XUTSZYYJ.js";
|
|
58
65
|
import {
|
|
59
66
|
DECISION_STOP_REASON,
|
|
60
67
|
askUser,
|
|
@@ -65,25 +72,18 @@ import {
|
|
|
65
72
|
waitForAnswer,
|
|
66
73
|
withdrawQuestion
|
|
67
74
|
} from "../chunk-XS33QBJA.js";
|
|
68
|
-
import {
|
|
69
|
-
UNNAMED_ACTION,
|
|
70
|
-
deriveToolTarget,
|
|
71
|
-
describeToolCall,
|
|
72
|
-
scopePathFor
|
|
73
|
-
} from "../chunk-HBBQO77U.js";
|
|
74
|
-
import "../chunk-32WWVRXN.js";
|
|
75
75
|
import {
|
|
76
76
|
getMachineId
|
|
77
|
-
} from "../chunk-
|
|
78
|
-
import "../chunk-
|
|
77
|
+
} from "../chunk-4OSQZ5TB.js";
|
|
78
|
+
import "../chunk-BGX4QUQF.js";
|
|
79
79
|
import "../chunk-GMXKITVA.js";
|
|
80
80
|
import "../chunk-DINDL2CF.js";
|
|
81
|
-
import "../chunk-
|
|
81
|
+
import "../chunk-VMQU7YEJ.js";
|
|
82
82
|
import "../chunk-EYRGBYZG.js";
|
|
83
83
|
import "../chunk-SAF6HGAA.js";
|
|
84
84
|
import "../chunk-7QLSKOSU.js";
|
|
85
85
|
import "../chunk-KZERVKTD.js";
|
|
86
|
-
import "../chunk-
|
|
86
|
+
import "../chunk-L7NOG547.js";
|
|
87
87
|
import {
|
|
88
88
|
getApiKey,
|
|
89
89
|
getBaseUrl
|
|
@@ -92,7 +92,7 @@ import {
|
|
|
92
92
|
KILL_REASON,
|
|
93
93
|
redactSecrets,
|
|
94
94
|
resolveGate
|
|
95
|
-
} from "../chunk-
|
|
95
|
+
} from "../chunk-MC5WUOJJ.js";
|
|
96
96
|
|
|
97
97
|
// src/wrapper/localPassthrough.ts
|
|
98
98
|
var SIGNAL_NUMBERS = {
|