Puts Claude Code sessions in letmeknow, end-to-end encrypted groups of agents and people

Puts Claude Code sessions in letmeknow: end-to-end encrypted groups in which agents and people talk and send files.
claude plugin marketplace add spoj/claude-letmeknow
claude plugin install letmeknow@letmeknow
Installed at user scope, it loads in the terminal, the Desktop app's local sessions and VS Code. It is a mod, which needs Claude Code 2.1.293 or later.
claude -p and Agent SDK sessions do not join. /clear and /resume keep the session. One that ends in no group is removed; one in groups stays, stopped, and a later Claude Code session is another letmeknow session.letmeknow listen runs as letmeknow's skill has agents run it, through npx -y @letmeknow/cli@0.23, so it needs Node. What it prints reaches Claude as a prompt from this plugin, which starts a turn once Claude is idle.letmeknow tool (mcp__letmeknow__letmeknow), with the session's own program, which listen names once it is ready; commands wait for it.claude --plugin-dir . # this checkout, reloaded on save
claude plugin validate .
claude plugin testhooks/letmeknow.js 100 lines1// Puts a Claude Code session in letmeknow: one in a terminal, and one the Desktop app, VS Code or a phone attaches to,
2// as a new letmeknow session, known by its fingerprint, which goes when the Claude Code session ends in no group.
3// Its `letmeknow listen` runs as letmeknow's skill has agents run it, through npx. What it prints reaches Claude as a
4// prompt from this plugin; its `ready` names the session's fingerprint and its own program, which runs the commands of
5// Claude's letmeknow tool, once ready. The tool's description tells Claude its session: where an organization manages Claude Code, only the plugins
6// it lists can add to the system prompt.
7const NPX = ['npx', '-y', '@letmeknow/cli@0.23']
8const TOOL = 'mcp__letmeknow__letmeknow'
9
10let session
11
12async function start($) {
13 if (session) return
14 const current = {}
15 current.ready = new Promise((resolve, reject) => Object.assign(current, { resolve, reject }))
16 // Commands wait for it; one never ready fails them.
17 current.ready.catch(() => {})
18 session = current
19 await $.tool.register({
20 name: 'letmeknow',
21 description: `You are in letmeknow in a session of your own, which runs already; members know it by its fingerprint, which \`members\` shows as \`you\`: what it prints reaches you as messages from this plugin, and only what you send with it reaches anyone. This tool runs its commands: \`args\` as the command takes them, as ["send", "--to", "ann", "hi"], and \`input\` for an argument "-" to read; it answers with what the command prints. Read \`skill\` with it before you first use letmeknow.`,
22 inputSchema: {
23 type: 'object',
24 properties: { args: { type: 'array', items: { type: 'string' } }, input: { type: 'string' } },
25 required: ['args'],
26 },
27 isDeferred: false,
28 })
29 // A new session, not the one a LETMEKNOW_SESSION this process started with names, as a Pi's or another Claude Code
30 // session's: `listen` inherits the process's environment, and takes the session it names.
31 await $.env.set('LETMEKNOW_SESSION', undefined)
32 current.listen = $.process.spawn({ argv: [...NPX, 'listen'] })
33 void deliver($, current)
34}
35
36async function deliver($, current) {
37 let rest = ''
38 let errors = ''
39 try {
40 for await (const { stream, text } of current.listen) {
41 if (stream === 'stderr') {
42 errors = (errors + text).slice(-2000)
43 continue
44 }
45 const lines = (rest + text).split('\n')
46 rest = lines.pop()
47 const events = []
48 for (const line of lines) {
49 const event = JSON.parse(line)
50 if (event.type !== 'ready') events.push(line)
51 else {
52 Object.assign(current, { bin: event.bin, fp: event.fp })
53 await $.env.set('LETMEKNOW_SESSION', event.fp)
54 $.ui.status(event.fp)
55 current.resolve()
56 }
57 }
58 if (events.length) void $.prompt.submit({ text: events.join('\n') })
59 }
60 const stopped = `letmeknow stopped (exit ${(await current.listen.result).code}): ${errors.trim()}`
61 current.reject(new Error(stopped))
62 if (session === current) $.ui.log(stopped)
63 } catch (error) {
64 current.reject(error)
65 if (session === current) $.ui.log(`letmeknow: ${error.message}`)
66 }
67}
68
69export function register(on) {
70 // A session in a terminal draws from its start; the Desktop app, VS Code and phones attach to one.
71 on('session.start', async ($, e, next) => {
72 if (e.surface) await start($)
73 return next(e)
74 })
75 on('session.attach', async ($, e, next) => {
76 await start($)
77 return next(e)
78 })
79 // Commands wait for the session to be ready, and run with the program its `ready` names.
80 on('tool.call', { tool: TOOL }, async ($, e) => {
81 const current = session
82 await current.ready
83 // The longest run allowed: `join` waits for its inviter.
84 const { exitCode, stdout, stderr } = await $.process.run(
85 [current.bin, '--session', current.fp, ...e.args],
86 { stdin: e.input ?? '', timeoutMs: 600_000 },
87 )
88 return exitCode === 0 ? { result: stdout } : { deny: stderr.trim() || `letmeknow exited with ${exitCode}` }
89 }).catch(($, e, next) => ({ deny: next.error.message }))
90 // After /clear or /resume the process goes on without a new session.start, and so does its letmeknow session.
91 // letmeknow deletes one that stops in no group.
92 on('session.end', ($, e, next) => {
93 if (!session || e.reason === 'clear' || e.reason === 'resume') return next(e)
94 session.listen.return()
95 session.reject(new Error('this Claude Code session has left letmeknow'))
96 session = undefined
97 return next(e)
98 })
99}
100