@webinson/peer 0.4.1 → 0.4.5
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 +26 -7
- package/bin/peer.mjs +351 -16
- package/mod/.claude-plugin/marketplace.json +11 -0
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +7 -0
- package/mod/peer-thread-mark/hooks/hooks.json +1 -0
- package/mod/peer-thread-mark/hooks/register.tsx +685 -0
- package/mod/peer-thread-mark/tests/all.test.tsx +107 -0
- package/mod/peer-thread-mark/tests/memories.test.tsx +54 -0
- package/mod/peer-thread-mark/types/index.d.ts +52 -0
- package/package.json +2 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
const PANE = {
|
|
4
|
+
plugin: 'peer-thread-mark',
|
|
5
|
+
component: 'Pane',
|
|
6
|
+
requestId: 'peer-all-memories',
|
|
7
|
+
props: { title: 'Your memory', isFocused: false, bodyColumns: 44, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} },
|
|
8
|
+
} as const
|
|
9
|
+
|
|
10
|
+
const row = (n: number) => ({
|
|
11
|
+
uri: `viking://user/ana/peers/github.com-acme-shop/memories/events/day_${n}.md`,
|
|
12
|
+
repo: 'github.com-acme-shop',
|
|
13
|
+
type: 'events',
|
|
14
|
+
title: `day ${n}`,
|
|
15
|
+
at: '2026-11-03',
|
|
16
|
+
lines: n === 1 ? ['The batch limit is 100.'] : [],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('the search pane lists, searches on Enter, keeps to the repository and pages', async ($, on) => {
|
|
20
|
+
const settings = { hooks: { UserPromptSubmit: [{ hooks: [{ command: '"/n/node" "/p/peer.mjs" hook prompt # peer-hook' }] }] } }
|
|
21
|
+
const runs: { argv: string[]; cwd?: string }[] = []
|
|
22
|
+
on('env.get', (_, e) => ({ value: (e as { name?: string }).name === 'PWD' ? '/work/shop' : '/home' }) as never)
|
|
23
|
+
on('fs.read', (_, e) => ({ value: String((e as { path?: string }).path ?? '').endsWith('settings.json') ? JSON.stringify(settings) : (() => { throw new Error('none') })() }) as never)
|
|
24
|
+
on('process.run', (_, e) => {
|
|
25
|
+
const { argv, init } = e as { argv: string[]; init?: { cwd?: string } }
|
|
26
|
+
runs.push({ argv, cwd: init?.cwd })
|
|
27
|
+
const page = Number(argv[argv.indexOf('page') + 1])
|
|
28
|
+
const text = argv.slice(argv.indexOf('--json') + 1).filter(w => !w.startsWith('--')).slice(0, -2).join(' ')
|
|
29
|
+
const found = text
|
|
30
|
+
? { connected: true, url: 'http://127.0.0.1:1933', repo: argv.includes('--here') ? 'github.com-acme-shop' : '', total: 1, page: 1, pages: 1, rows: [row(1)] }
|
|
31
|
+
: { connected: true, url: 'http://127.0.0.1:1933', repo: '', total: 23, page, pages: 2, rows: page === 1 ? [row(23), row(22)] : [row(3)] }
|
|
32
|
+
return { value: { stdout: JSON.stringify(found), stderr: '', exitCode: 0 } } as never
|
|
33
|
+
})
|
|
34
|
+
on('session.id', () => ({ value: 's1' }) as never)
|
|
35
|
+
on('clock.now', () => ({ value: Date.parse('2026-11-10T12:00:00Z') }) as never)
|
|
36
|
+
on('clock.sleep', () => ({ value: undefined }) as never)
|
|
37
|
+
on('ui.open', () => ({ value: { isPlaced: true } }) as never)
|
|
38
|
+
on('command.run', () => ({ text: 'engine' }) as never)
|
|
39
|
+
let copied = ''
|
|
40
|
+
on('ui.copy', (_, e) => { copied = (e as { text: string }).text; return { value: { isCopied: true } } as never })
|
|
41
|
+
on('ui.toast', () => ({ value: undefined }) as never)
|
|
42
|
+
|
|
43
|
+
const opened = await $.command.run({ command: 'peer-all', args: '' } as never)
|
|
44
|
+
expect((opened as { text: string }).text).toContain('in the pane')
|
|
45
|
+
expect(runs[0]!.argv.slice(2)).toEqual(['all', '--json', 'page', '1'])
|
|
46
|
+
expect(runs[0]!.cwd).toBe('/work/shop')
|
|
47
|
+
|
|
48
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'desktop' })
|
|
49
|
+
let texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
|
|
50
|
+
expect(texts).toContain('Day 23')
|
|
51
|
+
expect(texts).toContain('23 memories')
|
|
52
|
+
expect(texts).toContain('1 / 2')
|
|
53
|
+
expect(texts).not.toContain('Today')
|
|
54
|
+
|
|
55
|
+
// Time: unsorted, then newest first, then oldest first, then unsorted again; each press starts at page 1.
|
|
56
|
+
const sortLabel = async () => (await ui.findAll({ type: 'Button' })).find(b => b.key === 'sort')?.props.label
|
|
57
|
+
expect(await sortLabel()).toBe('Time')
|
|
58
|
+
await ui.press({ key: 'sort' } as never)
|
|
59
|
+
expect(runs.at(-1)!.argv).toContain('--newest')
|
|
60
|
+
expect(await sortLabel()).toBe('Newest ↓')
|
|
61
|
+
// Ordered by time, the page falls into days with their counts.
|
|
62
|
+
expect((await ui.findAll({ type: 'Text' })).map(t => t.text)).toContain('3 Nov')
|
|
63
|
+
await ui.press({ key: 'sort' } as never)
|
|
64
|
+
expect(runs.at(-1)!.argv).toContain('--oldest')
|
|
65
|
+
expect(await sortLabel()).toBe('Oldest ↑')
|
|
66
|
+
await ui.press({ key: 'sort' } as never)
|
|
67
|
+
expect(runs.at(-1)!.argv.some(w => w === '--newest' || w === '--oldest')).toBe(false)
|
|
68
|
+
expect(await sortLabel()).toBe('Time')
|
|
69
|
+
|
|
70
|
+
await ui.press({ key: 'next' } as never)
|
|
71
|
+
expect(runs.at(-1)!.argv.slice(-2)).toEqual(['page', '2'])
|
|
72
|
+
texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
|
|
73
|
+
expect(texts).toContain('Day 3')
|
|
74
|
+
|
|
75
|
+
// Typing searches by itself once it pauses: only the last text typed is searched.
|
|
76
|
+
const before = runs.length
|
|
77
|
+
await $.ui.input({ plugin: 'peer-thread-mark', key: 'q', text: 'batch li', kind: 'change' })
|
|
78
|
+
expect(runs.length).toBe(before + 1)
|
|
79
|
+
expect(runs.at(-1)!.argv.slice(2)).toEqual(['all', '--json', 'batch', 'li', 'page', '1'])
|
|
80
|
+
await $.ui.input({ plugin: 'peer-thread-mark', key: 'q', text: 'batch limit' })
|
|
81
|
+
expect(runs.at(-1)!.argv.slice(2)).toEqual(['all', '--json', 'batch', 'limit', 'page', '1'])
|
|
82
|
+
texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
|
|
83
|
+
// The matched line sits under its memory, the searched words drawn bold.
|
|
84
|
+
expect(texts.some(t => t.includes('limit is 100'))).toBe(true)
|
|
85
|
+
const bold = (await ui.findAll({ type: 'Text' })).filter(t => t.props.bold === true).map(t => t.text)
|
|
86
|
+
expect(bold).toContain('batch')
|
|
87
|
+
expect(bold).toContain('limit')
|
|
88
|
+
|
|
89
|
+
await ui.press({ key: 'here' } as never)
|
|
90
|
+
expect(runs.at(-1)!.argv).toContain('--here')
|
|
91
|
+
expect(runs.at(-1)!.argv).toContain('batch')
|
|
92
|
+
|
|
93
|
+
// A press opens the memory in place; Copy takes its address; a second press closes it.
|
|
94
|
+
const uri = 'viking://user/ana/peers/github.com-acme-shop/memories/events/day_1.md'
|
|
95
|
+
await ui.press({ key: uri } as never)
|
|
96
|
+
await ui.press({ key: `${uri}-copy` } as never)
|
|
97
|
+
expect(copied).toBe(uri)
|
|
98
|
+
await ui.press({ key: uri } as never)
|
|
99
|
+
expect((await ui.findAll({ type: 'Button' })).some(b => b.key === `${uri}-copy`)).toBe(false)
|
|
100
|
+
await ui.unmount()
|
|
101
|
+
|
|
102
|
+
// Mobile has no field: the list still shows.
|
|
103
|
+
const phone = await $.ui.mount({ ...PANE, surface: 'mobile' })
|
|
104
|
+
expect(await phone.findAll({ type: 'Input' })).toHaveLength(0)
|
|
105
|
+
expect((await phone.findAll({ type: 'Text' })).map(t => t.text).some(t => t.includes('limit is 100'))).toBe(true)
|
|
106
|
+
await phone.unmount()
|
|
107
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
const PANE = {
|
|
4
|
+
plugin: 'peer-thread-mark',
|
|
5
|
+
component: 'Pane',
|
|
6
|
+
requestId: 'peer-thread-memories',
|
|
7
|
+
props: { title: 'This thread', isFocused: false, bodyColumns: 44, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} },
|
|
8
|
+
} as const
|
|
9
|
+
|
|
10
|
+
const SAVES = {
|
|
11
|
+
connected: true,
|
|
12
|
+
loadedAt: 1,
|
|
13
|
+
saves: [
|
|
14
|
+
{ at: '2026-11-03T13:42:14Z', items: [
|
|
15
|
+
{ change: 'added', type: 'events', name: 'panel scope clarified', lines: ['The panel shows people who share threads.'], systemText: false },
|
|
16
|
+
{ change: 'updated', type: 'entities', name: 'peer', lines: ['Shared threads list people who typed /peer.'], systemText: false },
|
|
17
|
+
] },
|
|
18
|
+
{ at: '2026-11-03T13:29:28Z', items: [
|
|
19
|
+
{ change: 'added', type: 'events', name: 'project alpha displayed', lines: ['Studio showed project-alpha.'], systemText: true },
|
|
20
|
+
{ change: 'updated', type: 'entities', name: 'peer', lines: ['older'], systemText: false },
|
|
21
|
+
] },
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('the memory pane groups by meaning and copies on press', async ($, on) => {
|
|
26
|
+
const settings = { hooks: { UserPromptSubmit: [{ hooks: [{ command: '"/n/node" "/p/peer.mjs" hook prompt # peer-hook' }] }] } }
|
|
27
|
+
on('env.get', () => ({ value: '/home' }) as never)
|
|
28
|
+
on('fs.read', (_, e) => ({ value: String((e as { path?: string }).path ?? '').endsWith('settings.json') ? JSON.stringify(settings) : (() => { throw new Error('none') })() }) as never)
|
|
29
|
+
on('process.run', () => ({ value: { stdout: JSON.stringify(SAVES), stderr: '', exitCode: 0 } }) as never)
|
|
30
|
+
on('session.id', () => ({ value: 's1' }) as never)
|
|
31
|
+
on('clock.now', () => ({ value: 1 }) as never)
|
|
32
|
+
on('ui.open', () => ({ value: { isPlaced: true } }) as never)
|
|
33
|
+
on('command.run', () => ({ text: 'engine' }) as never)
|
|
34
|
+
let copied = ''
|
|
35
|
+
on('ui.copy', (_, e) => { copied = (e as { text: string }).text; return { value: { isCopied: true } } as never })
|
|
36
|
+
on('ui.toast', () => ({ value: undefined }) as never)
|
|
37
|
+
await $.command.run({ command: 'peer-thread', args: '' } as never)
|
|
38
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
39
|
+
const ui = await $.ui.mount({ ...PANE, surface })
|
|
40
|
+
const texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
|
|
41
|
+
expect(texts.slice(0, 2)).toEqual(['This thread', '3 memories'])
|
|
42
|
+
expect(texts.filter(t => t === 'Project alpha displayed')).toHaveLength(1)
|
|
43
|
+
expect(texts).toContain('Updated')
|
|
44
|
+
expect(texts.some(t => t.endsWith(':42, 2×'))).toBe(true)
|
|
45
|
+
// Closed rows show no sentence; a press opens one row with its text and Copy.
|
|
46
|
+
expect(texts).not.toContain('The panel shows people who share threads.')
|
|
47
|
+
await ui.press({ key: 'New-2026-11-03T13:42:14Z-0' } as never)
|
|
48
|
+
expect((await ui.find({ type: 'Text', text: /people who share threads/ }))?.text).toBe('The panel shows people who share threads.')
|
|
49
|
+
await ui.press({ key: 'New-2026-11-03T13:42:14Z-0-copy' } as never)
|
|
50
|
+
expect(copied).toBe('Panel scope clarified\nThe panel shows people who share threads.')
|
|
51
|
+
await ui.press({ key: 'New-2026-11-03T13:42:14Z-0' } as never)
|
|
52
|
+
await ui.unmount()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Whether this thread is shared, in which Peer workspace; null when Peer is not signed in on this computer
|
|
2
|
+
// (the band then draws nothing).
|
|
3
|
+
export type Shared = { workspace: string; isShared: boolean } | null
|
|
4
|
+
|
|
5
|
+
// One shared agent thread as the hub lists it (`peer list --json`).
|
|
6
|
+
export type Thread = {
|
|
7
|
+
session: string
|
|
8
|
+
repository?: string | null
|
|
9
|
+
repoKey?: string | null
|
|
10
|
+
branch?: string | null
|
|
11
|
+
status: 'active' | 'silent' | 'stopped'
|
|
12
|
+
working: boolean
|
|
13
|
+
seenAt: string
|
|
14
|
+
stoppedAt?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// What the threads pane shows: the workspace's list as it last loaded, or why it did not; null before the first look.
|
|
18
|
+
export type Board =
|
|
19
|
+
| {
|
|
20
|
+
workspace: string
|
|
21
|
+
you: string
|
|
22
|
+
people: { email: string; threads: Thread[] }[]
|
|
23
|
+
silentAfterMins: number
|
|
24
|
+
stoppedAfterMins: number
|
|
25
|
+
stoppedShownHours: number
|
|
26
|
+
loadedAt: number
|
|
27
|
+
}
|
|
28
|
+
| { error: string; loadedAt: number }
|
|
29
|
+
| null
|
|
30
|
+
|
|
31
|
+
// What this thread saved to the person's own OpenViking, as `peer thread --json` says it; null before the first look.
|
|
32
|
+
export type MemoryItem = { change: 'added' | 'updated' | 'removed'; type: string; name: string; lines: string[]; systemText: boolean }
|
|
33
|
+
export type Memories =
|
|
34
|
+
| { connected: true; saves: { at: string; items: MemoryItem[] }[]; loadedAt: number }
|
|
35
|
+
| { connected: false; reason: string; loadedAt: number }
|
|
36
|
+
| { error: string; loadedAt: number }
|
|
37
|
+
| null
|
|
38
|
+
|
|
39
|
+
// The search pane (/peer-all): what was asked, and one page of the person's memories as `peer all --json` gave it.
|
|
40
|
+
// sort: '' is OpenViking's own order; 'new' and 'old' order by the time a memory last changed.
|
|
41
|
+
export type AllQuery = { text: string; here: boolean; page: number; sort: '' | 'new' | 'old' }
|
|
42
|
+
export type MemoryRow = { uri: string; repo: string; type: string; title: string; at: string; lines: string[] }
|
|
43
|
+
export type AllFound =
|
|
44
|
+
| { connected: true; url: string; repo: string; total: number; page: number; pages: number; rows: MemoryRow[] }
|
|
45
|
+
| { connected: false; reason: string }
|
|
46
|
+
export type AllMemories = { ask: AllQuery; loading: boolean; found?: AllFound; error?: string } | null
|
|
47
|
+
|
|
48
|
+
declare module 'claude-code' {
|
|
49
|
+
interface PluginState {
|
|
50
|
+
'peer-thread-mark': { shared: Shared; board: Shaped<Board>; memories: Shaped<Memories>; all: Shaped<AllMemories>; openMemory: string | null; openAll: string | null }
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webinson/peer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Share chosen Claude Code threads with your team: who has agents running and on what.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
+
"mod",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|