@webinson/peer 0.4.14 → 0.4.16

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
@@ -18,9 +18,19 @@ In a Claude Code thread:
18
18
  - `/peer <message>` shares it and passes the message on to the agent as your prompt.
19
19
  - `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
20
20
  is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
21
- - In a shared thread a band above the prompt says `Peer <workspace>` with `/peer stop` to click; after a stop it
22
- turns gray with `/peer start`. It is a Claude Code plugin in this package (`mod/`) that `peer install` adds;
23
- the desktop app draws it only while you type or a turn runs.
21
+ - A band above the prompt shows the thread's memories (`17 memories`), `Use Elephant` and `Use Jellyfish`, and
22
+ `● Active` (shared) or `○ Inactive` at the right; a press says in a toast what the thread now uses. The modes
23
+ decide what memory the thread uses:
24
+ - **Use Elephant** (shared): PeerMemory (your personal OpenViking), recalled with each prompt and saved as the
25
+ thread goes, on top of Claude Code's own memory. It saves only the turns from the moment it was chosen.
26
+ - **Inactive** (not shared): Claude Code's own memory alone; PeerMemory stays off.
27
+ - **Use Jellyfish** (shared): no memory at all, neither PeerMemory nor Claude Code's own (`MEMORY.md`).
28
+ `CLAUDE.md` instructions stay.
29
+
30
+ Pressing the lit mode again stops sharing (Inactive); pressing either while Inactive shares again. Every thread
31
+ that is not shared runs without OpenViking. What a thread was already given stays in it after a switch. The
32
+ band is a Claude Code plugin in this package (`mod/`) that `peer install` adds; the desktop app draws it only
33
+ while you type or a turn runs.
24
34
  - A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
25
35
  back if Claude Code renames the thread, and `/peer stop` takes it off.
26
36
  - `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-thread-mark",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt, with /peer stop and /peer start; panes for /peer-list, /peer-thread and /peer-all (search your memories)",
5
5
  "license": "MIT",
6
6
  "types": "./types/index.d.ts"
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register, RenderChildren, RenderNode, RenderSurface } from 'claude-code'
3
3
 
4
- import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, MemoryUse, Recall, RecallItem, Thread } from '../types'
4
+ import type { AllMemories, AllQuery, Board, Memories, MemoryItem, MemoryRow, MemoryUse, Mode, Recall, RecallItem, Thread } from '../types'
5
5
  import type { Shared } from '../types'
6
6
 
7
7
  // The peer CLI's hook writes ~/.peer/sessions/<session id> on `/peer` and removes it on `/peer stop`;
@@ -28,6 +28,8 @@ const RECALLS_KEPT = 30
28
28
  const recalls = atom({ plugin: 'peer-thread-mark', key: 'recalls' } as const, [], { shape: 'recalls-1' })
29
29
  // The one prompt whose recall line is open, by its message id.
30
30
  const openRecall = atom({ plugin: 'peer-thread-mark', key: 'openRecall' } as const, null)
31
+ // The band's memory mode for this thread: Elephant remembers, Jellyfish forgets.
32
+ const mode = atom({ plugin: 'peer-thread-mark', key: 'mode' } as const, 'elephant')
31
33
 
32
34
  // Peer's team memory block: `- [verified pitfall] Title`, a line of text, then `(who, date, uri)`.
33
35
  function teamItems(context: string): RecallItem[] {
@@ -190,8 +192,73 @@ async function refresh($: EngineInterface) {
190
192
  if (before?.workspace !== now?.workspace || before?.isShared !== now?.isShared) {
191
193
  await update($, shared, () => now)
192
194
  }
195
+ await applyMemory($)
193
196
  }
194
197
 
198
+ // Which memory a thread works with. Elephant (shared) adds OpenViking, its recall and its saving, to Claude Code's own
199
+ // memory; Inactive keeps Claude Code's own memory alone; Jellyfish (shared) keeps none at all.
200
+ type MemoryMode = 'openviking' | 'own' | 'none'
201
+ async function memoryUse($: EngineInterface): Promise<MemoryMode> {
202
+ if (!(await read($, shared))?.isShared) return 'own'
203
+ return (await read($, mode)) === 'elephant' ? 'openviking' : 'none'
204
+ }
205
+ let appliedMemory: MemoryMode | undefined
206
+ // The thread's transcript, as the classic hooks hand it over; OpenViking counts its turns from it.
207
+ let transcriptPath: string | undefined
208
+
209
+ // OpenViking keeps how many of a thread's turns it has captured ($TMPDIR/openviking-cc-capture-state/<session>.json)
210
+ // and takes everything after them at the next turn end. Turned on with Use Elephant, it would take the turns from
211
+ // Inactive or Jellyfish too, so the count moves to the turns there are now, counted by OpenViking's own reader.
212
+ const SKIP_TO_NOW = `
213
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
214
+ import { homedir, tmpdir } from 'node:os'
215
+ import { join } from 'node:path'
216
+ const [id, transcript, cwd] = process.argv.slice(1)
217
+ const installed = JSON.parse(readFileSync(join(homedir(), '.claude/plugins/installed_plugins.json'), 'utf-8'))
218
+ const root = (installed.plugins?.['openviking-memory@openviking'] ?? []).map(e => e.installPath).find(p => p && existsSync(join(p, 'scripts/cc-transcript.mjs')))
219
+ if (!root || !existsSync(transcript)) process.exit(0)
220
+ const { parseTranscript, extractCaptureTurns } = await import(join(root, 'scripts/cc-transcript.mjs'))
221
+ const { loadConfig } = await import(join(root, 'scripts/config.mjs'))
222
+ const turns = extractCaptureTurns(parseTranscript(readFileSync(transcript, 'utf-8')), loadConfig(cwd)).length
223
+ const dir = join(tmpdir(), 'openviking-cc-capture-state')
224
+ const file = join(dir, id.replace(/[^a-zA-Z0-9_-]/g, '_') + '.json')
225
+ let state = {}
226
+ try { state = JSON.parse(readFileSync(file, 'utf-8')) } catch {}
227
+ if ((state.capturedTurnCount ?? 0) < turns) {
228
+ mkdirSync(dir, { recursive: true })
229
+ writeFileSync(file, JSON.stringify({ ...state, capturedTurnCount: turns, baselinedBy: 'peer-thread-mark', ts: Date.now() }))
230
+ }
231
+ `
232
+ async function skipToNow($: EngineInterface) {
233
+ if (!transcriptPath) return
234
+ // The node that runs Peer's own hooks, from Claude Code's settings; any node on PATH where there is none.
235
+ const node = (await promptHook($).catch(() => undefined))?.[0]
236
+ const argv = [node && /node$/.test(node) ? node : 'node', '--input-type=module', '-e', SKIP_TO_NOW, await $.session.id(), transcriptPath, await $.session.cwd()]
237
+ await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => {})
238
+ }
239
+ // The OpenViking plugin's hooks read these on every run, and $.env.set reaches every process the session starts
240
+ // after it, so the switch holds for this thread's next prompt, turn end and compaction. Its MCP server, started
241
+ // with the session, is held back by the tool.call hook instead.
242
+ async function applyMemory($: EngineInterface) {
243
+ const use = await memoryUse($)
244
+ if (use === appliedMemory) return use
245
+ const isOn = use === 'openviking'
246
+ if (isOn && appliedMemory !== undefined) await skipToNow($)
247
+ await $.env.set('OPENVIKING_MEMORY_ENABLED', isOn ? '1' : '0')
248
+ await $.env.set('OPENVIKING_AUTO_RECALL', isOn ? '1' : undefined)
249
+ await $.env.set('OPENVIKING_AUTO_CAPTURE', isOn ? '1' : undefined)
250
+ // Claude Code's own memory goes out of the system prompt and the first message with Jellyfish, back without it.
251
+ if (appliedMemory !== undefined && (appliedMemory === 'none' || use === 'none')) {
252
+ $.ui.invalidate('prompt.section')
253
+ $.ui.invalidate('prompt.context')
254
+ }
255
+ appliedMemory = use
256
+ return use
257
+ }
258
+ // What OpenViking's recall and Peer's team memory add to a prompt, left out where the thread keeps OpenViking off.
259
+ const OPENVIKING_BLOCK = /<openviking-context|<teammates-project-knowledge/
260
+ const OPENVIKING_TOOL = 'mcp__plugin_openviking-memory_'
261
+
195
262
  // The memory badge's color: an explicit value, as a surface may not know a theme key; it reads on light and dark.
196
263
  const SAVED_COLOR = '#8b5cf6'
197
264
  // The soft badge where a surface draws one (desktop, VS Code, mobile): a pale tint and the color as its text, both
@@ -386,7 +453,8 @@ function projects(people: { email: string; threads: Thread[] }[]) {
386
453
 
387
454
  // A press does what typing the command does: the CLI's own hook shares or stops the thread and says so.
388
455
  // (A plugin may not submit a prompt that starts with `/`, so the hook is run directly.)
389
- async function send($: EngineInterface, prompt: string) {
456
+ // `said`, when given, is the toast in place of the CLI's own line, once the press is done; a failure still says why.
457
+ async function send($: EngineInterface, prompt: string, said?: () => Promise<string>) {
390
458
  try {
391
459
  const argv = await promptHook($)
392
460
  if (!argv) {
@@ -396,11 +464,23 @@ async function send($: EngineInterface, prompt: string) {
396
464
  const input = { session_id: await $.session.id(), cwd: await $.env.get('PWD'), prompt }
397
465
  const { stdout } = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: 10_000 })
398
466
  const reason = JSON.parse(stdout.trim().split('\n').pop() || '{}').reason
399
- if (reason) $.ui.toast(String(reason))
467
+ if (reason && !said) $.ui.toast(String(reason))
400
468
  } catch (error) {
401
469
  $.ui.toast(`Peer: ${error instanceof Error ? error.message : String(error)}`)
470
+ await refresh($)
471
+ return
402
472
  }
403
473
  await refresh($)
474
+ if (said) $.ui.toast(await said())
475
+ }
476
+
477
+ // What a press of the band leaves the thread with, in a toast that stands in for a tooltip: the desktop does not
478
+ // follow the pointer over the band, so a hover hint cannot show there.
479
+ async function memoryNote($: EngineInterface) {
480
+ const use = await memoryUse($)
481
+ if (use === 'openviking') return 'Elephant: PeerMemory recalls and saves from now on.'
482
+ if (use === 'none') return 'Jellyfish: no memory in this thread.'
483
+ return 'Inactive: not shared, Claude Code memory only.'
404
484
  }
405
485
 
406
486
  export const register: Register = on => {
@@ -440,10 +520,53 @@ export const register: Register = on => {
440
520
  return next(e)
441
521
  })
442
522
 
523
+ // A session's first hooks run with the switch already set: OpenViking's start hook lists memories unless Elephant.
524
+ on('classic.SessionStart', async ($, e, next) => {
525
+ transcriptPath = e.transcript_path || transcriptPath
526
+ await refresh($).catch(() => {})
527
+ const use = await memoryUse($)
528
+ const result = await next(e)
529
+ if (use === 'openviking' || !result?.additionalContext) return result
530
+ return { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
531
+ })
532
+
533
+ on('tool.call', async ($, e, next) => {
534
+ if (e.tool.startsWith(OPENVIKING_TOOL) && (await memoryUse($)) !== 'openviking') {
535
+ return { deny: 'PeerMemory is off in this thread. Use Elephant turns it on.' }
536
+ }
537
+ return next(e)
538
+ })
539
+
540
+ // Jellyfish: no memory of Claude Code's own either, neither its instructions nor the memory files it loads.
541
+ on('prompt.section', { name: 'memory' }, async ($, e, next) => ((await memoryUse($)) === 'none' ? { text: null } : next(e)))
542
+ on('prompt.context', async ($, e, next) => {
543
+ const result = await next(e)
544
+ const files = (e.instructionFiles ?? []).filter(file => file.kind === 'memory')
545
+ if (!files.length || (await memoryUse($)) !== 'none') return result
546
+ const blocks = result.blocks.map(block => {
547
+ if (block.name !== 'claudeMd') return block
548
+ let text = block.text
549
+ for (const file of files) {
550
+ const at = text.indexOf(`Contents of ${file.path}`)
551
+ if (at < 0) continue
552
+ const end = text.indexOf('\n\nContents of ', at + 1)
553
+ text = text.slice(0, at) + (end < 0 ? '' : text.slice(end + 2))
554
+ }
555
+ return { ...block, text }
556
+ })
557
+ return { ...result, blocks }
558
+ })
559
+
443
560
  // What reached the model with a prompt: the hooks beneath (Peer's from settings, OpenViking's plugin) answer
444
561
  // first, then the mod reads their additionalContext and keeps it for the prompt's row. It changes nothing.
445
562
  on('classic.UserPromptSubmit', async ($, e, next) => {
446
- const result = await next(e)
563
+ transcriptPath = e.transcript_path || transcriptPath
564
+ await refresh($).catch(() => {})
565
+ const use = await memoryUse($)
566
+ let result = await next(e)
567
+ if (use !== 'openviking' && result?.additionalContext) {
568
+ result = { ...result, additionalContext: result.additionalContext.filter(text => !OPENVIKING_BLOCK.test(text)) }
569
+ }
447
570
  try {
448
571
  const context = (result?.additionalContext ?? []).join('\n')
449
572
  const items = [...teamItems(context), ...mineItems(context)]
@@ -562,7 +685,7 @@ export const register: Register = on => {
562
685
  {nearHere.map((memory, index) => savedCard(memory, fromHere.length + index))}
563
686
  {recalled.map(recallCard)}
564
687
  {asked.map((use, index) =>
565
- card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `OpenViking ${use.kind}`),
688
+ card(`a-${e.requestId}-${index}`, 'Asked', <Text bold wrap="truncate-end">{use.items[0] ?? use.kind}</Text>, clock(use.at), `PeerMemory ${use.kind}`),
566
689
  )}
567
690
  </Box>
568
691
  )}
@@ -583,23 +706,72 @@ export const register: Register = on => {
583
706
  const total = saved && 'saves' in saved ? memoryCount(saved.saves) : undefined
584
707
  if (e.props.hasSurvey || (now === null && total === undefined)) return next(e)
585
708
  const isShared = now?.isShared ?? false
709
+ const kind = await read($, mode)
586
710
 
587
711
  const { Box, Button, Text } = $.ui.resolve(e)
712
+ // A hint is the band's tooltip: one line in the free space of the band's own row, between the modes and the
713
+ // status, hidden until the pointer is on its control, so the band never has to grow for it. The control's zone
714
+ // Box and its Button both join the hint's scope, as a desktop's native button may take the pointer from the Box
715
+ // around it; the zones carry keys of their own, apart from their Buttons'.
716
+ const hint = (scope: string, text: string) => (
717
+ <Box key={`hint-${scope}`} display="none" flexGrow={1} hover={{ scope, display: 'flex' }}>
718
+ <Text dimColor size="footnote" wrap="truncate-end">{text}</Text>
719
+ </Box>
720
+ )
721
+ const count = total === undefined ? '–' : String(total)
722
+ const noun = total === 1 ? 'memory' : 'memories'
723
+ // A mode is lit only while the thread is shared: pressing the lit one stops sharing (Inactive), pressing
724
+ // either while Inactive picks it and shares again (Active), pressing the other while Active only switches.
725
+ const pick = async (key: Mode) => {
726
+ const said = () => memoryNote($)
727
+ if (isShared && kind === key) return send($, '/peer stop', said)
728
+ await update($, mode, () => key)
729
+ if (!isShared) return send($, '/peer', said)
730
+ await applyMemory($)
731
+ $.ui.toast(await said())
732
+ }
733
+ // A desktop draws a Button's label as its own native button and leaves Text inside it unstyled, so the lit
734
+ // mode is the primary button and the other the secondary; the terminal draws the primary in the accent color.
735
+ const animal = (key: Mode, name: string) => (
736
+ <Box key={`zone-${key}`} gap={1} alignItems="center" hover={{ scope: `peer-${key}` }}>
737
+ <Button key={key} label={name} variant={isShared && kind === key ? 'primary' : 'secondary'} hover={{ scope: `peer-${key}` }} onPress={() => void pick(key)} />
738
+ </Box>
739
+ )
740
+ const modeHint = (key: Mode, about: string) =>
741
+ `${about} ${isShared && kind === key ? 'Press again to stop.' : isShared ? 'Press to switch.' : 'Press to share with it.'}`
742
+ const hints = {
743
+ memory: total ? 'What this thread saved.' : 'Nothing saved yet.',
744
+ elephant: modeHint('elephant', 'Recall and save with PeerMemory.'),
745
+ jellyfish: modeHint('jellyfish', 'No memory at all.'),
746
+ share: isShared ? 'Shared with your workspace. Press to stop.' : 'Not shared. Press to share.',
747
+ }
588
748
  return (
589
- <Box gap={2}>
590
- <Button key="memory" label={total === undefined ? 'Memory' : `Memory, ${total}`} plain onPress={async () => { memoryOpen = await openThread($) }}>
591
- <Text bold>{'Memory '}</Text>
592
- <Text backgroundColor={total ? 'suggestion' : 'inactive'} color="inverseText" bold tabular>
593
- {` ${total ?? '–'} `}
594
- </Text>
595
- </Button>
596
- <Button
597
- key="toggle"
598
- label={isShared ? '/peer stop' : '/peer start'}
599
- plain
600
- dimColor
601
- onPress={() => send($, isShared ? '/peer stop' : '/peer')}
602
- />
749
+ <Box flexDirection="column">
750
+ <Box width="100%" justifyContent="space-between" gap={2}>
751
+ <Box gap={2}>
752
+ <Box key="zone-memory" gap={1} alignItems="center" hover={{ scope: 'peer-memory' }}>
753
+ <Button key="memory" label={`${count} ${noun}`} plain hover={{ scope: 'peer-memory' }} onPress={async () => { memoryOpen = await openThread($) }}>
754
+ <Text bold tabular>{count}</Text>
755
+ <Text>{` ${noun}`}</Text>
756
+ </Button>
757
+ </Box>
758
+ <Box gap={1}>
759
+ {animal('elephant', 'Use Elephant')}
760
+ {animal('jellyfish', 'Use Jellyfish')}
761
+ </Box>
762
+ </Box>
763
+ <Box flexGrow={1} flexShrink={1} paddingX={2}>
764
+ {e.surface === 'terminal' && hint('peer-memory', hints.memory)}
765
+ {e.surface === 'terminal' && hint('peer-elephant', hints.elephant)}
766
+ {e.surface === 'terminal' && hint('peer-jellyfish', hints.jellyfish)}
767
+ {e.surface === 'terminal' && hint('peer-share', hints.share)}
768
+ </Box>
769
+ {/* The dot is Text beside the Button, where both surfaces keep its color. */}
770
+ <Box key="zone-toggle" gap={1} alignItems="center" hover={{ scope: 'peer-share' }}>
771
+ <Text color={isShared ? 'success' : 'inactive'}>{isShared ? '●' : '○'}</Text>
772
+ <Button key="toggle" label={isShared ? 'Active' : 'Inactive'} plain dimColor={!isShared} hover={{ scope: 'peer-share' }} onPress={() => send($, isShared ? '/peer stop' : '/peer', () => memoryNote($))} />
773
+ </Box>
774
+ </Box>
603
775
  </Box>
604
776
  )
605
777
  })
@@ -755,7 +927,7 @@ export const register: Register = on => {
755
927
  return status(now?.error ? <Text color="error">{`Peer: your memories did not load (${now.error})`}</Text> : <Text dimColor>Loading…</Text>)
756
928
  }
757
929
  if (now.error) return status(<Text color="error">{`Peer: your memories did not load (${now.error})`}</Text>)
758
- if (!found.connected) return status(<Text dimColor>{`No personal OpenViking here (${found.reason}).`}</Text>)
930
+ if (!found.connected) return status(<Text dimColor>{`No PeerMemory here (${found.reason}).`}</Text>)
759
931
  if (found.total === 0) return status(<Text dimColor>{ask.text ? 'Nothing has those words.' : 'No memories yet.'}</Text>)
760
932
 
761
933
  const words = searchWords(ask.text)
@@ -859,7 +1031,7 @@ export const register: Register = on => {
859
1031
  </Box>
860
1032
  )
861
1033
  }
862
- if (!now.connected) return <Text dimColor>{`No personal OpenViking here (${now.reason}).`}</Text>
1034
+ if (!now.connected) return <Text dimColor>{`No PeerMemory here (${now.reason}).`}</Text>
863
1035
 
864
1036
  const groups = sections(now.saves)
865
1037
  const total = groups.check.length + groups.added.length + groups.updated.length + groups.removed.length
@@ -928,7 +1100,7 @@ export const register: Register = on => {
928
1100
  // Where memory went into the thread, oldest first: one line each, what and when; a press opens which memories,
929
1101
  // and Show in thread where it came with a message.
930
1102
  const uses = now.uses ?? []
931
- const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked OpenViking' }
1103
+ const USE_WORDS: Record<string, string> = { start: 'At the start', resume: 'On resume', prompt: 'With your message', tool: 'Asked PeerMemory' }
932
1104
  const useRow = (use: MemoryUse, index: number) => {
933
1105
  const id = `use-${index}`
934
1106
  const isOpen = open === id
@@ -976,7 +1148,7 @@ export const register: Register = on => {
976
1148
  {uses.map(useRow)}
977
1149
  </Box>
978
1150
  )}
979
- {total === 0 && <Text dimColor>Nothing saved yet. OpenViking saves after longer turns.</Text>}
1151
+ {total === 0 && <Text dimColor>Nothing saved yet. PeerMemory saves after longer turns.</Text>}
980
1152
  {section('Check', groups.check, true)}
981
1153
  {section('New', groups.added)}
982
1154
  {section('Updated', groups.updated)}
@@ -26,11 +26,27 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
26
26
  on('fs.read', (_, e) => {
27
27
  const path = String((e as { path?: string }).path ?? '')
28
28
  if (path.endsWith('settings.json')) return { value: JSON.stringify(settings) } as never
29
- if (path.endsWith('/sessions/s1')) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
29
+ if (path.endsWith('/sessions/s1') && !stopped) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
30
30
  throw new Error('none')
31
31
  })
32
- on('process.run', () => ({ value: { stdout: JSON.stringify(SAVES), stderr: '', exitCode: 0 } }) as never)
32
+ // The CLI's hook: `/peer stop` removes the session file, `/peer` writes it back.
33
+ let stopped = false
34
+ const sent: string[] = []
35
+ on('process.run', (_, e) => {
36
+ const stdin = (e as { init?: { stdin?: string } }).init?.stdin
37
+ const prompt = stdin ? (JSON.parse(stdin) as { prompt?: string }).prompt : undefined
38
+ if (prompt) {
39
+ sent.push(prompt)
40
+ stopped = prompt === '/peer stop'
41
+ return { value: { stdout: '{}', stderr: '', exitCode: 0 } } as never
42
+ }
43
+ return { value: { stdout: JSON.stringify(SAVES), stderr: '', exitCode: 0 } } as never
44
+ })
33
45
  on('session.id', () => ({ value: 's1' }) as never)
46
+ on('env.set', () => ({ value: {} }) as never)
47
+ on('ui.invalidate', () => ({ value: undefined }) as never)
48
+ const toasts: string[] = []
49
+ on('ui.toast', (_, e) => { toasts.push(String((e as { text?: string }).text ?? '')); return { value: undefined } as never })
34
50
  on('clock.now', () => ({ value: 1 }) as never)
35
51
  const opened: string[] = []
36
52
  on('ui.open', (_, e) => { opened.push((e as { id: string }).id); return { value: { isPlaced: true } } as never })
@@ -43,7 +59,9 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
43
59
 
44
60
  // Before the thread's memories have loaded the square holds a dash; a press opens the pane and loads them.
45
61
  const before = await $.ui.mount({ ...BAND, surface: 'desktop' })
46
- expect((await before.findAll({ type: 'Text' })).map(t => t.text)).toEqual(['Memory ', ' – '])
62
+ const first = (await before.findAll({ type: 'Text' })).map(t => t.text)
63
+ expect(first.slice(0, 2)).toEqual(['–', ' memories'])
64
+ expect(first).toContain('●')
47
65
  await before.press({ key: 'memory' } as never)
48
66
  await before.unmount()
49
67
  expect(opened).toEqual(['peer-thread-memories'])
@@ -52,8 +70,33 @@ test('the band counts the thread\'s memories, names no workspace, and opens the
52
70
  const ui = await $.ui.mount({ ...BAND, surface })
53
71
  const texts = (await ui.findAll({ type: 'Text' })).map(t => t.text)
54
72
  // New, updated once (two saves of one memory), the removed one gone: 2.
55
- expect(texts.slice(0, 2)).toEqual(['Memory ', ' 2 '])
73
+ expect(texts.slice(0, 2)).toEqual(['2', ' memories'])
56
74
  expect(texts.some(t => t.includes('hotovo'))).toBe(false)
57
75
  await ui.unmount()
58
76
  }
77
+
78
+ // Elephant is lit while shared; pressing Jellyfish only switches, pressing the lit one again stops sharing,
79
+ // and pressing it while Inactive shares again.
80
+ const lit = async (ui: { findAll: (q: { type: 'Button' }) => Promise<{ props?: { label?: string; variant?: string } }[]> }) =>
81
+ (await ui.findAll({ type: 'Button' })).filter(b => b.props?.variant === 'primary').map(b => b.props?.label)
82
+ const modes = await $.ui.mount({ ...BAND, surface: 'desktop' })
83
+ expect(await lit(modes as never)).toEqual(['Use Elephant'])
84
+ await modes.press({ key: 'jellyfish' } as never)
85
+ expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
86
+ expect(sent).toEqual([])
87
+ await modes.press({ key: 'jellyfish' } as never)
88
+ expect(sent).toEqual(['/peer stop'])
89
+ let texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
90
+ expect(texts).toContain('○')
91
+ expect(await modes.findAll({ type: 'Button' } as never)).toContainEqual(expect.objectContaining({ props: expect.objectContaining({ label: 'Inactive' }) }))
92
+ expect(await lit(modes as never)).toEqual([])
93
+ await modes.press({ key: 'jellyfish' } as never)
94
+ expect(sent).toEqual(['/peer stop', '/peer'])
95
+ texts = (await modes.findAll({ type: 'Text' })).map(t => t.text)
96
+ expect(texts).toContain('●')
97
+ expect(await lit(modes as never)).toEqual(['Use Jellyfish'])
98
+ // The desktop does not follow the pointer over the band, so each press says what the thread now uses, as a toast.
99
+ expect(toasts.at(-1)).toBe('Jellyfish: no memory in this thread.')
100
+ expect((await modes.findAll({ type: 'Box' } as never) as { key?: string }[]).some(b => b.key === 'hint-peer-jellyfish')).toBe(false)
101
+ await modes.unmount()
59
102
  })
@@ -0,0 +1,99 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ const BAND = {
4
+ plugin: 'peer-thread-mark',
5
+ component: 'AbovePrompt',
6
+ props: { hasSurvey: false, isWorking: false, maxRows: 12 },
7
+ } as const
8
+
9
+ const MEMORY_FILE = '/home/.claude/projects/p/memory/MEMORY.md'
10
+ const CLAUDE_MD = `Contents of /home/.claude/CLAUDE.md (user's private global instructions):\n\nBe brief.\n\nContents of ${MEMORY_FILE} (user's auto-memory):\n\n- a fact`
11
+
12
+ test('Elephant turns OpenViking on, Inactive keeps Claude Code memory alone, Jellyfish keeps none', async ($, on) => {
13
+ const settings = { hooks: { UserPromptSubmit: [{ hooks: [{ command: '"/n/node" "/p/peer.mjs" hook prompt # peer-hook' }] }] } }
14
+ // The CLI's hook: `/peer stop` removes the session file, `/peer` writes it back.
15
+ let stopped = false
16
+ on('env.get', () => ({ value: '/home' }) as never)
17
+ on('fs.read', (_, e) => {
18
+ const path = String((e as { path?: string }).path ?? '')
19
+ if (path.endsWith('settings.json')) return { value: JSON.stringify(settings) } as never
20
+ if (path.endsWith('/sessions/s1') && !stopped) return { value: JSON.stringify({ workspace: 'hotovo' }) } as never
21
+ throw new Error('none')
22
+ })
23
+ const runs: string[][] = []
24
+ on('process.run', (_, e) => {
25
+ runs.push([...(e as { argv: string[] }).argv])
26
+ const stdin = (e as { init?: { stdin?: string } }).init?.stdin
27
+ const prompt = stdin ? (JSON.parse(stdin) as { prompt?: string }).prompt : undefined
28
+ if (prompt) stopped = prompt === '/peer stop'
29
+ return { value: { stdout: prompt ? '{}' : JSON.stringify({ connected: true, saves: [] }), stderr: '', exitCode: 0 } } as never
30
+ })
31
+ const env: Record<string, string | undefined> = {}
32
+ on('env.set', (_, e) => {
33
+ const { name, value } = e as { name: string; value?: string }
34
+ env[name] = value
35
+ return { value: {} } as never
36
+ })
37
+ on('session.id', () => ({ value: 's1' }) as never)
38
+ on('session.cwd', () => ({ value: '/repo' }) as never)
39
+ on('clock.now', () => ({ value: 1 }) as never)
40
+ on('command.register', (_, e) => ({ value: { command: (e as { name: string }).name } }) as never)
41
+ on('ui.status', () => ({ value: undefined }) as never)
42
+ on('ui.invalidate', () => ({ value: undefined }) as never)
43
+ on('ui.toast', () => ({ value: undefined }) as never)
44
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
45
+ // Beneath the mod: OpenViking's recall and a hook of the person's own, a tool that answers, core's memory.
46
+ on('classic.UserPromptSubmit', () => ({ additionalContext: ['<openviking-context>- [memory 80%] x</openviking-context>', 'kept'] }) as never)
47
+ let toolRuns = 0
48
+ on('tool.call', () => { toolRuns++; return { result: { content: 'ok' } } as never })
49
+ on('prompt.section', () => ({ text: 'how memory works' }) as never)
50
+ on('prompt.context', (_, e) => ({ blocks: (e as { blocks: unknown[] }).blocks }) as never)
51
+
52
+ const context = () =>
53
+ $.prompt.context({
54
+ blocks: [{ name: 'claudeMd', text: CLAUDE_MD }],
55
+ instructionFiles: [{ path: MEMORY_FILE, kind: 'memory', content: '- a fact' }],
56
+ })
57
+ const search = () => $.tool.call({ tool: 'mcp__plugin_openviking-memory_openviking__search', query: 'x' } as never)
58
+ const recalled = async () => (await $.classic.UserPromptSubmit({ prompt: 'hi', transcript_path: '/t/s1.jsonl' } as never)).additionalContext
59
+ const cursorMoves = () => runs.filter(argv => argv.includes('--input-type=module'))
60
+
61
+ await $.session.start({ cwd: '/', surface: 'desktop', isInteractive: true })
62
+
63
+ // Shared, with Elephant: OpenViking on, its recall reaches the model, its tools run, Claude Code's memory stays.
64
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
65
+ expect(env.OPENVIKING_AUTO_CAPTURE).toBe('1')
66
+ expect(await recalled()).toHaveLength(2)
67
+ await search()
68
+ expect(toolRuns).toBe(1)
69
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
70
+
71
+ // Jellyfish: OpenViking off, its recall and tools held back, and no Claude Code memory either.
72
+ const band = await $.ui.mount({ ...BAND, surface: 'desktop' })
73
+ await band.press({ key: 'jellyfish' } as never)
74
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
75
+ expect(env.OPENVIKING_AUTO_CAPTURE).toBeUndefined()
76
+ expect(await recalled()).toEqual(['kept'])
77
+ await search()
78
+ expect(toolRuns).toBe(1)
79
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBeNull()
80
+ const kept = (await context()).blocks.find(b => b.name === 'claudeMd')?.text ?? ''
81
+ expect(kept).toContain('Be brief.')
82
+ expect(kept).not.toContain('a fact')
83
+
84
+ // Pressing the lit Jellyfish stops sharing: Inactive, OpenViking still off, Claude Code's own memory back.
85
+ await band.press({ key: 'jellyfish' } as never)
86
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('0')
87
+ expect(await recalled()).toEqual(['kept'])
88
+ expect((await $.prompt.section({ name: 'memory', text: 'how memory works' })).text).toBe('how memory works')
89
+ expect((await context()).blocks.find(b => b.name === 'claudeMd')?.text).toContain('a fact')
90
+
91
+ // Use Elephant from Inactive shares again, with OpenViking on, and its capture starts at the turns there are now,
92
+ // so what was said under Jellyfish and Inactive is never saved. Starting with Elephant moved nothing.
93
+ expect(cursorMoves()).toHaveLength(0)
94
+ await band.press({ key: 'elephant' } as never)
95
+ expect(env.OPENVIKING_MEMORY_ENABLED).toBe('1')
96
+ expect(cursorMoves()).toHaveLength(1)
97
+ expect(cursorMoves()[0]?.slice(-3)).toEqual(['s1', '/t/s1.jsonl', '/repo'])
98
+ await band.unmount()
99
+ })
@@ -29,7 +29,12 @@ test('a prompt shows under it what Peer and OpenViking recalled for it, and chan
29
29
  on('classic.UserPromptSubmit', () => ({ additionalContext: context }) as never)
30
30
  on('ui.render', (_, e) => ({ type: 'Text', props: {}, children: [String((e as { props: { text: string } }).props.text)] }) as never)
31
31
  on('env.get', () => ({ value: '/home' }) as never)
32
- on('fs.read', () => { throw new Error('none') })
32
+ // A thread shared with Use Elephant: OpenViking's recall and teammates' pages reach the model.
33
+ on('fs.read', (_, e) => {
34
+ if (String((e as { path?: string }).path ?? '').endsWith('/sessions/s1')) return { value: JSON.stringify({ workspace: 'acme' }) } as never
35
+ throw new Error('none')
36
+ })
37
+ on('env.set', () => ({ value: {} }) as never)
33
38
  on('session.id', () => ({ value: 's1' }) as never)
34
39
  on('clock.now', () => ({ value: 1 }) as never)
35
40
  let copied = ''
@@ -118,7 +118,7 @@ test('a message shows under it what OpenViking saved from it, and what it only u
118
118
  olderTexts = (await older.findAll({ type: 'Text' })).map(t => t.text)
119
119
  expect(olderTexts).toContain('The partner API takes at most 100 items')
120
120
  expect(olderTexts).toContain(' Asked ')
121
- expect(olderTexts).toContain('OpenViking search')
121
+ expect(olderTexts).toContain('PeerMemory search')
122
122
  await older.unmount()
123
123
 
124
124
  // The /peer-thread pane lists where memory went into the thread; one with a message can show it.
@@ -130,7 +130,7 @@ test('a message shows under it what OpenViking saved from it, and what it only u
130
130
  expect(usedTexts).toContain('Used')
131
131
  expect(usedTexts).toContain('At the start · 2 memories listed')
132
132
  expect(usedTexts).toContain("With your message · 1 teammate's page")
133
- expect(usedTexts).toContain('Asked OpenViking: search')
133
+ expect(usedTexts).toContain('Asked PeerMemory: search')
134
134
  await usedPane.press({ key: 'use-0' } as never)
135
135
  expect((await usedPane.findAll({ type: 'Text' })).map(t => t.text)).toContain('· This Mac')
136
136
  expect((await usedPane.findAll({ type: 'Button' })).some(b => b.key === 'use-0-show')).toBe(false)
@@ -63,8 +63,11 @@ export type AllMemories = { ask: AllQuery; loading: boolean; found?: AllFound; e
63
63
  export type RecallItem = { from: 'team' | 'mine'; title: string; detail: string; uri: string }
64
64
  export type Recall = { prompt: string; items: RecallItem[] }
65
65
 
66
+ // The band's memory mode for a thread: Elephant saves and recalls, Jellyfish neither.
67
+ export type Mode = 'elephant' | 'jellyfish'
68
+
66
69
  declare module 'claude-code' {
67
70
  interface PluginState {
68
- 'peer-thread-mark': { shared: Shared; board: Shaped<Board>; memories: Shaped<Memories>; all: Shaped<AllMemories>; openMemory: string | null; openAll: string | null; recalls: Shaped<Recall[]>; openRecall: string | null }
71
+ 'peer-thread-mark': { shared: Shared; board: Shaped<Board>; memories: Shaped<Memories>; all: Shaped<AllMemories>; openMemory: string | null; openAll: string | null; recalls: Shaped<Recall[]>; openRecall: string | null; mode: Mode }
69
72
  }
70
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webinson/peer",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
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",