@webinson/peer 0.4.16 → 0.4.18

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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "peer-thread-mark",
3
+ "version": "0.1.0",
4
+ "description": "Share selected Codex threads in Peer and browse their memories.",
5
+ "license": "MIT",
6
+ "hooks": "./hooks/hooks.json",
7
+ "interface": {
8
+ "displayName": "Peer Thread Mark",
9
+ "shortDescription": "Share selected Codex threads in Peer",
10
+ "category": "Productivity"
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/peer.mjs\"", "timeout": 5 }] }],
4
+ "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/peer.mjs\"", "timeout": 20 }] }],
5
+ "Stop": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/peer.mjs\"", "timeout": 5 }] }],
6
+ "Interrupt": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/peer.mjs\"", "timeout": 3 }] }],
7
+ "SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"${PLUGIN_ROOT}/hooks/peer.mjs\"", "timeout": 3 }] }]
8
+ }
9
+ }
@@ -0,0 +1,17 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ // Codex installs a copy of this directory, without the npm package beside it.
7
+ const home = process.env.PEER_HOME || path.join(homedir(), ".peer");
8
+ let launch;
9
+ try {
10
+ launch = JSON.parse(readFileSync(path.join(home, "codex-cli.json"), "utf8"));
11
+ } catch {}
12
+ const cli = launch && typeof launch.node === "string" && typeof launch.peer === "string"
13
+ && path.isAbsolute(launch.node) && path.isAbsolute(launch.peer);
14
+ const child = spawn(cli ? launch.node : "peer", [...(cli ? [launch.peer] : []), "hook", "codex"], {
15
+ stdio: ["inherit", "inherit", "ignore"],
16
+ });
17
+ child.on("error", () => {});
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peer-thread-mark",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
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,13 +1,27 @@
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, Mode, Recall, RecallItem, Thread } from '../types'
4
+ import type { AllMemories, AllQuery, Board, CollisionSignal, 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`;
8
8
  // that file alone says whether this thread is shared, and in which workspace.
9
9
  const shared = atom({ plugin: 'peer-thread-mark', key: 'shared' } as const, null)
10
10
 
11
+ // The collision signal: after an edit in a shared thread, `peer collide` asks the Hub who else was on the file in the
12
+ // last 3 minutes. Each edit's answer by its tool_use_id; the rows and the agent's sentence read `shown` from it.
13
+ const signals = atom({ plugin: 'peer-thread-mark', key: 'signals' } as const, {}, { shape: 'signals-1' })
14
+ // Set when the Hub timed out in this turn, so the turn's later edits do not wait again; the next prompt clears it.
15
+ const collideOff = atom({ plugin: 'peer-thread-mark', key: 'collideOff' } as const, false)
16
+ // The Peer app's list of edit tools and where each names its file.
17
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
18
+ const SIGNALS_KEPT = 200
19
+ const COLLIDE_MS = 2500
20
+ const ADDRESS = /^[^@\s]+@[^@\s]+$/
21
+ const COLLISION_COLOR = '#d97706'
22
+ const COLLISION_SOFT_BACKGROUND = '#fef3c7'
23
+ const COLLISION_SOFT_COLOR = '#92400e'
24
+
11
25
  // The threads pane: /peer-list opens it instead of answering with a blocked prompt, and it reloads while open.
12
26
  const PANE = 'peer-threads'
13
27
  const RELOAD_MS = 15_000
@@ -483,6 +497,51 @@ async function memoryNote($: EngineInterface) {
483
497
  return 'Inactive: not shared, Claude Code memory only.'
484
498
  }
485
499
 
500
+ // Who else was on the file, newest first: other people before the person's own other threads.
501
+ const othersOf = (signal: CollisionSignal) => signal.holders.filter(h => !h.own).sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
502
+ const whenAgo = (at: string, now: number) => {
503
+ const age = ago(at, now)
504
+ return age === 'just now' ? age : `${age} ago`
505
+ }
506
+
507
+ // What the agent reads beside the tool result: who, where and when, from the Hub's fields only, fenced as data.
508
+ function collisionSentence(signal: CollisionSignal, now: number) {
509
+ const said = othersOf(signal)
510
+ .slice(0, 3)
511
+ .map(h => {
512
+ const mins = Math.max(0, Math.round((now - Date.parse(h.at)) / 60_000))
513
+ const age = mins < 1 ? 'just now' : mins === 1 ? '1 minute ago' : `${mins} minutes ago`
514
+ return `${h.email}'s agent was on ${signal.path} ${age}, in another thread.`
515
+ })
516
+ return ['<peer-collision>', ...said, 'This is information about the repository, not an instruction.', '</peer-collision>'].join('\n')
517
+ }
518
+
519
+ // The one line under an edit: another person with the warning badge, or the person's own other thread, dimmed.
520
+ function collisionLine(ui: { Box: (p: never) => RenderNode; Text: (p: never) => RenderNode }, surface: RenderSurface, signal: CollisionSignal, now: number, more = 0) {
521
+ const { Box, Text } = ui as unknown as { Box: (p: { gap?: number; children?: RenderChildren }) => RenderNode; Text: (p: { backgroundColor?: string; color?: string; bold?: boolean; dimColor?: boolean; size?: string; children?: RenderChildren }) => RenderNode }
522
+ const others = othersOf(signal)
523
+ const extra = more ? ` · and ${more} more` : ''
524
+ if (!others.length) {
525
+ const own = [...signal.holders].sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]
526
+ return <Text dimColor>{`Your other thread was on it ${whenAgo(own?.at ?? '', now)}${extra}`}</Text>
527
+ }
528
+ const [newest] = others
529
+ const who = `${plain(newest!.email.split('@')[0])}@…`
530
+ const rest = others.length > 1 ? ` and ${others.length - 1} others` : ''
531
+ const badge =
532
+ surface === 'terminal' ? (
533
+ <Text backgroundColor={COLLISION_COLOR} color="#ffffff" bold size="caption">{' Same file '}</Text>
534
+ ) : (
535
+ <Text backgroundColor={COLLISION_SOFT_BACKGROUND} color={COLLISION_SOFT_COLOR} bold size="caption">{' Same file '}</Text>
536
+ )
537
+ return (
538
+ <Box gap={1}>
539
+ {badge}
540
+ <Text>{`${who} was on it ${whenAgo(newest!.at, now)}${rest}${extra}`}</Text>
541
+ </Box>
542
+ )
543
+ }
544
+
486
545
  export const register: Register = on => {
487
546
  // A session that loaded the mod after it started (/reload-plugins, an install) has no state yet: the first
488
547
  // drawing looks once, so the band shows without waiting for the next prompt.
@@ -508,10 +567,99 @@ export const register: Register = on => {
508
567
  // The CLI's prompt hook writes or removes the marker while a typed command goes in, so look after it.
509
568
  on('prompt.submit', async ($, e, next) => {
510
569
  const result = await next(e)
570
+ await update($, collideOff, () => false)
511
571
  await refresh($).catch(() => {})
512
572
  return result
513
573
  })
514
574
 
575
+ // After each edit in a shared thread: who else was on the file. The CLI asks the Hub; the mod keeps the answer
576
+ // for the row and tells the agent when another person was on it.
577
+ on('classic.PostToolUse', async ($, e, next) => {
578
+ const result = await next(e)
579
+ if (!EDIT_TOOLS.has(e.tool_name) || !e.tool_use_id) return result
580
+ const input = (e.tool_input ?? {}) as { file_path?: string; notebook_path?: string }
581
+ const file = input.file_path ?? input.notebook_path
582
+ if (!file || !(await read($, shared))?.isShared || (await read($, collideOff))) return result
583
+ const session = await $.session.id()
584
+ if ((await $.env.get('PEER_MOD_SESSION')) === session) return result
585
+
586
+ type Answer = Pick<CollisionSignal, 'path' | 'state' | 'reason' | 'holders'>
587
+ let answer: Answer
588
+ let argv: string[] | undefined
589
+ try {
590
+ argv = await peerCli($)
591
+ } catch {}
592
+ if (!argv) answer = { path: null, state: 'unchecked', reason: 'error', holders: [] }
593
+ else {
594
+ try {
595
+ const { stdout, exitCode } = await $.process.run([...argv, 'collide', '--json', session, e.tool_use_id, file], { timeoutMs: COLLIDE_MS })
596
+ answer = exitCode === 0 ? (JSON.parse(stdout) as Answer) : { path: null, state: 'unchecked', reason: 'error', holders: [] }
597
+ } catch {
598
+ answer = { path: null, state: 'unchecked', reason: 'hub-timeout', holders: [] }
599
+ }
600
+ }
601
+ if (answer.reason === 'hub-timeout') await update($, collideOff, () => true)
602
+ // Only well-formed addresses, cleaned: the Hub's fields are the only source of what is drawn and said.
603
+ const holders = (Array.isArray(answer.holders) ? answer.holders : [])
604
+ .map(h => ({ email: plain(h.email), at: String(h.at ?? ''), own: h.own === true }))
605
+ .filter(h => ADDRESS.test(h.email))
606
+ const state = answer.state === 'collision' && !holders.length ? 'clear' : answer.state
607
+ const at = await $.clock.now()
608
+ const emails = (signal: { holders: { email: string }[] }) => signal.holders.map(h => h.email.toLowerCase()).sort().join(' ')
609
+ let signal: CollisionSignal = { path: answer.path ? plain(answer.path) : null, state, ...(answer.reason ? { reason: answer.reason } : {}), holders, at, shown: false }
610
+ // Shown once per set of people on a path: against the latest earlier collision on it, decided inside one update.
611
+ await update($, signals, was => {
612
+ const kept = { ...(was ?? {}) }
613
+ if (signal.state === 'collision') {
614
+ const earlier = Object.values(kept).filter(s => s.path === signal.path && s.state === 'collision').at(-1)
615
+ signal = { ...signal, shown: !earlier || emails(earlier) !== emails(signal) }
616
+ }
617
+ kept[e.tool_use_id] = signal
618
+ return Object.fromEntries(Object.entries(kept).slice(-SIGNALS_KEPT))
619
+ })
620
+ if (!signal.shown || !othersOf(signal).length) return result
621
+ return { ...(result ?? {}), additionalContext: [...(result?.additionalContext ?? []), collisionSentence(signal, at)] }
622
+ })
623
+
624
+ // The line under an edit row whose check met someone on the file.
625
+ on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
626
+ const drawn = await next(e)
627
+ const row = e.props
628
+ if (drawn === undefined || !EDIT_TOOLS.has(row.tool) || row.isRunning || row.isErrored || row.isInterrupted) return drawn
629
+ const signal = (await read($, signals))?.[row.tool_use_id]
630
+ if (!signal?.shown) return drawn
631
+ const ui = $.ui.resolve(e)
632
+ const { Box } = ui
633
+ return (
634
+ <Box flexDirection="column">
635
+ {drawn}
636
+ {collisionLine(ui as never, e.surface, signal, await $.clock.now())}
637
+ </Box>
638
+ )
639
+ })
640
+
641
+ // A folded group of calls shows the same line once, for its newest collided edit; an opened group leaves it to
642
+ // the rows.
643
+ on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
644
+ const drawn = await next(e)
645
+ if (drawn === undefined || e.props.isExpanded) return drawn
646
+ const all = (await read($, signals)) ?? {}
647
+ const met = e.props.calls
648
+ .map(call => (call.tool_use_id && EDIT_TOOLS.has(call.tool) ? all[call.tool_use_id] : undefined))
649
+ .filter((signal): signal is CollisionSignal => Boolean(signal?.shown))
650
+ if (!met.length) return drawn
651
+ const byPerson = met.filter(signal => othersOf(signal).length)
652
+ const [first] = (byPerson.length ? byPerson : met).sort((a, b) => b.at - a.at)
653
+ const ui = $.ui.resolve(e)
654
+ const { Box } = ui
655
+ return (
656
+ <Box flexDirection="column">
657
+ {drawn}
658
+ {collisionLine(ui as never, e.surface, first!, await $.clock.now(), met.length - 1)}
659
+ </Box>
660
+ )
661
+ })
662
+
515
663
  on('turn.complete', async ($, e, next) => {
516
664
  await refresh($).catch(() => {})
517
665
  // OpenViking saves a thread's memories when a turn ends past its threshold, so the open pane and the Saved lines
@@ -0,0 +1,150 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ const SETTINGS = { hooks: { UserPromptSubmit: [{ hooks: [{ command: '"/n/node" "/p/peer.mjs" hook prompt # peer-hook' }] }] } }
4
+ const NOW = Date.parse('2026-11-03T20:00:00Z')
5
+ const minsAgo = (mins: number) => new Date(NOW - mins * 60_000).toISOString()
6
+
7
+ type Answer = { path: string | null; state: string; reason?: string; holders: { email: string; at: string; own: boolean }[] }
8
+
9
+ // A shared thread s1 whose CLI answers `peer collide` with `answer` (or throws, for a run past its limit).
10
+ function setUp(on: (...args: never[]) => void, options: { modSession?: string; shared?: boolean } = {}) {
11
+ const state = { answer: { path: 'src/cart.ts', state: 'clear', holders: [] } as Answer | 'timeout', exit: 0, collides: [] as string[][] }
12
+ const hook = on as unknown as (event: string, fn: (...args: unknown[]) => unknown) => void
13
+ hook('env.get', (_: unknown, e: unknown) => ({ value: (e as { name?: string }).name === 'PEER_MOD_SESSION' ? options.modSession ?? '' : '/home' }))
14
+ hook('fs.read', (_: unknown, e: unknown) => {
15
+ const path = String((e as { path?: string }).path ?? '')
16
+ if (path.endsWith('settings.json')) return { value: JSON.stringify(SETTINGS) }
17
+ if (path.endsWith('/sessions/s1') && options.shared !== false) return { value: JSON.stringify({ workspace: 'acme', project: 'shop' }) }
18
+ throw new Error('none')
19
+ })
20
+ hook('env.set', () => ({ value: {} }))
21
+ hook('session.id', () => ({ value: 's1' }))
22
+ hook('clock.now', () => ({ value: NOW }))
23
+ hook('process.run', (_: unknown, e: unknown) => {
24
+ const argv = (e as { argv: string[] }).argv
25
+ if (argv.includes('collide')) {
26
+ state.collides.push(argv.slice(argv.indexOf('collide') + 1))
27
+ if (state.answer === 'timeout') throw new Error('still running after 2500 ms')
28
+ return { value: { stdout: JSON.stringify(state.answer), stderr: '', exitCode: state.exit } }
29
+ }
30
+ return { value: { stdout: '{}', stderr: '', exitCode: 0 } }
31
+ })
32
+ hook('classic.UserPromptSubmit', () => ({}))
33
+ hook('classic.PostToolUse', () => ({ additionalContext: ['from another hook'] }))
34
+ hook('ui.render', () => ({ type: 'Text', props: {}, children: ['the row'] }))
35
+ return state
36
+ }
37
+
38
+ const edit = (id: string, tool = 'Edit', file = '/repo/src/cart.ts') =>
39
+ ({ tool_name: tool, tool_input: tool === 'NotebookEdit' ? { notebook_path: file } : { file_path: file }, tool_response: {}, tool_use_id: id }) as never
40
+ const row = (id: string, surface: 'terminal' | 'desktop', props: Record<string, unknown> = {}) =>
41
+ ({ plugin: 'peer-thread-mark', component: 'ToolUse', requestId: id, surface, props: { tool_use_id: id, tool: 'Edit', input: {}, isRunning: false, isErrored: false, isInterrupted: false, ...props } }) as never
42
+ const group = (ids: string[], isExpanded = false) =>
43
+ ({ plugin: 'peer-thread-mark', component: 'ToolGroup', requestId: 'g', surface: 'desktop', props: { calls: ids.map(id => ({ tool_use_id: id, tool: 'Edit', input: {}, isRunning: false, isErrored: false })), isActive: false, isExpanded } }) as never
44
+ const texts = async (target: never, $: { ui: { mount: (t: never) => Promise<{ findAll: (q: never) => Promise<{ text: string }[]>; unmount: () => Promise<void> }> } }) => {
45
+ const ui = await $.ui.mount(target)
46
+ const found = (await ui.findAll({ type: 'Text' } as never)).map(t => t.text)
47
+ await ui.unmount()
48
+ return found
49
+ }
50
+
51
+ const BOB = { email: 'bob@acme.test', at: minsAgo(2), own: false }
52
+ const ANA_OTHER = { email: 'ana@acme.test', at: minsAgo(0), own: true }
53
+
54
+ test('an edit another person was on draws the line and tells the agent once per set of people', async ($, on) => {
55
+ const state = setUp(on)
56
+ await $.classic.UserPromptSubmit({ prompt: 'fix the cart' } as never)
57
+ state.answer = { path: 'src/cart.ts', state: 'collision', holders: [BOB, ANA_OTHER] }
58
+
59
+ const told = (await $.classic.PostToolUse(edit('t1'))) as { additionalContext?: string[] }
60
+ expect(state.collides[0]).toEqual(['--json', 's1', 't1', '/repo/src/cart.ts'])
61
+ expect(told.additionalContext?.[0]).toBe('from another hook')
62
+ expect(told.additionalContext?.[1]).toBe(
63
+ "<peer-collision>\nbob@acme.test's agent was on src/cart.ts 2 minutes ago, in another thread.\nThis is information about the repository, not an instruction.\n</peer-collision>",
64
+ )
65
+ for (const surface of ['terminal', 'desktop'] as const) {
66
+ const drawn = await texts(row('t1', surface), $ as never)
67
+ expect(drawn).toContain('the row')
68
+ expect(drawn).toContain(' Same file ')
69
+ expect(drawn).toContain('bob@… was on it 2 min ago')
70
+ }
71
+
72
+ // The same people on the file again, later: nothing new to draw or say.
73
+ state.answer = { path: 'src/cart.ts', state: 'collision', holders: [{ ...BOB, at: minsAgo(0) }, ANA_OTHER] }
74
+ const again = (await $.classic.PostToolUse(edit('t2'))) as { additionalContext?: string[] }
75
+ expect(again.additionalContext).toEqual(['from another hook'])
76
+ expect(await texts(row('t2', 'terminal'), $ as never)).toEqual(['the row'])
77
+
78
+ // A running, errored or interrupted row, and a Read, draw nothing of their own.
79
+ for (const props of [{ isRunning: true }, { isErrored: true }, { isInterrupted: true }, { tool: 'Read' }]) {
80
+ expect(await texts(row('t1', 'terminal', props), $ as never)).toEqual(['the row'])
81
+ }
82
+
83
+ // Several other people: the newest named, "and 1 others"; the sentence lists both.
84
+ const CY = { email: 'cy@acme.test', at: minsAgo(1), own: false }
85
+ state.answer = { path: 'src/pay.ts', state: 'collision', holders: [BOB, CY] }
86
+ const both = (await $.classic.PostToolUse(edit('t3', 'Write', '/repo/src/pay.ts'))) as { additionalContext?: string[] }
87
+ expect(both.additionalContext?.[1]).toContain("cy@acme.test's agent was on src/pay.ts 1 minute ago")
88
+ expect(both.additionalContext?.[1]).toContain("bob@acme.test's agent was on src/pay.ts 2 minutes ago")
89
+ expect(await texts(row('t3', 'desktop'), $ as never)).toContain('cy@… was on it 1 min ago and 1 others')
90
+ })
91
+
92
+ test('the person\'s own other thread is drawn dimmed and the agent is told nothing', async ($, on) => {
93
+ const state = setUp(on)
94
+ await $.classic.UserPromptSubmit({ prompt: 'go' } as never)
95
+ state.answer = { path: 'src/cart.ts', state: 'collision', holders: [ANA_OTHER] }
96
+ const told = (await $.classic.PostToolUse(edit('o1'))) as { additionalContext?: string[] }
97
+ expect(told.additionalContext).toEqual(['from another hook'])
98
+ const drawn = await texts(row('o1', 'desktop'), $ as never)
99
+ expect(drawn).toContain('Your other thread was on it just now')
100
+ expect(drawn).not.toContain(' Same file ')
101
+ })
102
+
103
+ test('a folded group draws the line once for its collided edits, an opened one leaves it to the rows', async ($, on) => {
104
+ const state = setUp(on)
105
+ await $.classic.UserPromptSubmit({ prompt: 'go' } as never)
106
+ state.answer = { path: 'src/cart.ts', state: 'collision', holders: [BOB] }
107
+ await $.classic.PostToolUse(edit('g1'))
108
+ state.answer = { path: 'src/pay.ts', state: 'collision', holders: [BOB] }
109
+ await $.classic.PostToolUse(edit('g2', 'Edit', '/repo/src/pay.ts'))
110
+ state.answer = { path: 'src/ok.ts', state: 'clear', holders: [] }
111
+ await $.classic.PostToolUse(edit('g3', 'Edit', '/repo/src/ok.ts'))
112
+
113
+ const folded = await texts(group(['g1', 'g2', 'g3']), $ as never)
114
+ expect(folded.filter(t => t.includes('was on it'))).toEqual(['bob@… was on it 2 min ago · and 1 more'])
115
+ expect(await texts(group(['g1', 'g2'], true), $ as never)).toEqual(['the row'])
116
+ expect(await texts(group(['g3']), $ as never)).toEqual(['the row'])
117
+ })
118
+
119
+ test('a thread the Peer app runs makes no call', async ($, on) => {
120
+ const state = setUp(on, { modSession: 's1' })
121
+ await $.classic.UserPromptSubmit({ prompt: 'go' } as never)
122
+ await $.classic.PostToolUse(edit('p1'))
123
+ expect(state.collides).toHaveLength(0)
124
+ })
125
+
126
+ test('an unshared thread makes no call', async ($, on) => {
127
+ const state = setUp(on, { shared: false })
128
+ await $.classic.UserPromptSubmit({ prompt: 'go' } as never)
129
+ await $.classic.PostToolUse(edit('u1'))
130
+ expect(state.collides).toHaveLength(0)
131
+ })
132
+
133
+ test('after a timeout the turn makes no more calls, and an old CLI draws nothing', async ($, on) => {
134
+ const state = setUp(on)
135
+ on('prompt.submit', () => ({ text: 'next' }) as never)
136
+ await $.classic.UserPromptSubmit({ prompt: 'go' } as never)
137
+ state.answer = 'timeout'
138
+ const timedOut = (await $.classic.PostToolUse(edit('x1'))) as { additionalContext?: string[] }
139
+ expect(timedOut.additionalContext).toEqual(['from another hook'])
140
+ await $.classic.PostToolUse(edit('x2'))
141
+ expect(state.collides).toHaveLength(1)
142
+
143
+ await $.prompt.submit({ text: 'next' } as never)
144
+ state.answer = { path: 'src/cart.ts', state: 'collision', holders: [BOB] }
145
+ state.exit = 2
146
+ const old = (await $.classic.PostToolUse(edit('x3'))) as { additionalContext?: string[] }
147
+ expect(state.collides).toHaveLength(2)
148
+ expect(old.additionalContext).toEqual(['from another hook'])
149
+ expect(await texts(row('x3', 'terminal'), $ as never)).toEqual(['the row'])
150
+ })
@@ -66,8 +66,21 @@ export type Recall = { prompt: string; items: RecallItem[] }
66
66
  // The band's memory mode for a thread: Elephant saves and recalls, Jellyfish neither.
67
67
  export type Mode = 'elephant' | 'jellyfish'
68
68
 
69
+ // One edit's check against teammates' claims, as `peer collide --json` printed it, kept by its tool_use_id. path is
70
+ // relative to the repository (null when nothing was asked); shown: drawn and told, the first time these people met
71
+ // on the path.
72
+ export type CollisionHolder = { email: string; at: string; own: boolean }
73
+ export type CollisionSignal = {
74
+ path: string | null
75
+ state: 'clear' | 'collision' | 'no-project' | 'unchecked' | 'skipped'
76
+ reason?: string
77
+ holders: CollisionHolder[]
78
+ at: number
79
+ shown: boolean
80
+ }
81
+
69
82
  declare module 'claude-code' {
70
83
  interface PluginState {
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 }
84
+ '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; signals: Shaped<Record<string, CollisionSignal>>; collideOff: boolean }
72
85
  }
73
86
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@webinson/peer",
3
- "version": "0.4.16",
4
- "description": "Share chosen Claude Code threads with your team: who has agents running and on what.",
3
+ "version": "0.4.18",
4
+ "description": "Share chosen Claude Code and Codex threads with your team: who has agents running and on what.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "peer": "bin/peer.mjs"
8
+ "peer": "bin/peer.mjs",
9
+ "peer-codex": "mod/codex/client/run.sh"
9
10
  },
10
11
  "files": [
11
12
  "bin",