@tienne/gestalt 0.111.0 → 0.113.0

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.
Files changed (166) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/package.json +3 -1
  5. package/dist/plugin/role-agents/lens-context/AGENT.md +8 -7
  6. package/dist/plugin/role-agents/lens-reader/AGENT.md +48 -2
  7. package/dist/plugin/skills/lens/SKILL.md +22 -15
  8. package/dist/src/architecture/doc-fresh.d.ts.map +1 -1
  9. package/dist/src/architecture/doc-fresh.js +3 -1
  10. package/dist/src/architecture/doc-fresh.js.map +1 -1
  11. package/dist/src/cli/commands/lens.d.ts +8 -1
  12. package/dist/src/cli/commands/lens.d.ts.map +1 -1
  13. package/dist/src/cli/commands/lens.js +29 -2
  14. package/dist/src/cli/commands/lens.js.map +1 -1
  15. package/dist/src/cli/commands/peer.d.ts +20 -0
  16. package/dist/src/cli/commands/peer.d.ts.map +1 -1
  17. package/dist/src/cli/commands/peer.js +198 -12
  18. package/dist/src/cli/commands/peer.js.map +1 -1
  19. package/dist/src/cli/index.d.ts.map +1 -1
  20. package/dist/src/cli/index.js +44 -4
  21. package/dist/src/cli/index.js.map +1 -1
  22. package/dist/src/digest/lens-schema.d.ts +150 -3
  23. package/dist/src/digest/lens-schema.d.ts.map +1 -1
  24. package/dist/src/digest/lens-schema.js +14 -1
  25. package/dist/src/digest/lens-schema.js.map +1 -1
  26. package/dist/src/digest/lens-widget-schema.d.ts +2 -2
  27. package/dist/src/digest/lint-lens.d.ts +2 -1
  28. package/dist/src/digest/lint-lens.d.ts.map +1 -1
  29. package/dist/src/digest/lint-lens.js +68 -7
  30. package/dist/src/digest/lint-lens.js.map +1 -1
  31. package/dist/src/digest/ocr.d.ts +1 -1
  32. package/dist/src/digest/ocr.d.ts.map +1 -1
  33. package/dist/src/digest/ocr.js +10 -4
  34. package/dist/src/digest/ocr.js.map +1 -1
  35. package/dist/src/digest/schema.d.ts +62 -62
  36. package/dist/src/lens/attach.d.ts +741 -0
  37. package/dist/src/lens/attach.d.ts.map +1 -1
  38. package/dist/src/lens/attach.js +41 -11
  39. package/dist/src/lens/attach.js.map +1 -1
  40. package/dist/src/lens/context-input.d.ts +17 -0
  41. package/dist/src/lens/context-input.d.ts.map +1 -0
  42. package/dist/src/lens/context-input.js +31 -0
  43. package/dist/src/lens/context-input.js.map +1 -0
  44. package/dist/src/lens/ingest.d.ts +19 -1
  45. package/dist/src/lens/ingest.d.ts.map +1 -1
  46. package/dist/src/lens/ingest.js +92 -14
  47. package/dist/src/lens/ingest.js.map +1 -1
  48. package/dist/src/lens/pdf-extract-py.d.ts +2 -0
  49. package/dist/src/lens/pdf-extract-py.d.ts.map +1 -1
  50. package/dist/src/lens/pdf-extract-py.js +37 -2
  51. package/dist/src/lens/pdf-extract-py.js.map +1 -1
  52. package/dist/src/mcp/schemas.d.ts +29 -0
  53. package/dist/src/mcp/schemas.d.ts.map +1 -1
  54. package/dist/src/mcp/schemas.js +29 -0
  55. package/dist/src/mcp/schemas.js.map +1 -1
  56. package/dist/src/mcp/server.d.ts.map +1 -1
  57. package/dist/src/mcp/server.js +6 -1
  58. package/dist/src/mcp/server.js.map +1 -1
  59. package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -1
  60. package/dist/src/mcp/tools/architecture-passthrough.js +1 -1
  61. package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -1
  62. package/dist/src/mcp/tools/peer.d.ts +24 -0
  63. package/dist/src/mcp/tools/peer.d.ts.map +1 -0
  64. package/dist/src/mcp/tools/peer.js +103 -0
  65. package/dist/src/mcp/tools/peer.js.map +1 -0
  66. package/dist/src/peer/codex.d.ts +57 -0
  67. package/dist/src/peer/codex.d.ts.map +1 -0
  68. package/dist/src/peer/codex.js +303 -0
  69. package/dist/src/peer/codex.js.map +1 -0
  70. package/dist/src/peer/daemon.d.ts +76 -1
  71. package/dist/src/peer/daemon.d.ts.map +1 -1
  72. package/dist/src/peer/daemon.js +511 -19
  73. package/dist/src/peer/daemon.js.map +1 -1
  74. package/dist/src/peer/errors.d.ts +3 -0
  75. package/dist/src/peer/errors.d.ts.map +1 -1
  76. package/dist/src/peer/errors.js +3 -0
  77. package/dist/src/peer/errors.js.map +1 -1
  78. package/dist/src/peer/excerpt.d.ts +47 -0
  79. package/dist/src/peer/excerpt.d.ts.map +1 -0
  80. package/dist/src/peer/excerpt.js +160 -0
  81. package/dist/src/peer/excerpt.js.map +1 -0
  82. package/dist/src/peer/feed.d.ts +35 -2
  83. package/dist/src/peer/feed.d.ts.map +1 -1
  84. package/dist/src/peer/feed.js +22 -0
  85. package/dist/src/peer/feed.js.map +1 -1
  86. package/dist/src/peer/inbox.d.ts +42 -0
  87. package/dist/src/peer/inbox.d.ts.map +1 -0
  88. package/dist/src/peer/inbox.js +96 -0
  89. package/dist/src/peer/inbox.js.map +1 -0
  90. package/dist/src/peer/locate.d.ts +5 -0
  91. package/dist/src/peer/locate.d.ts.map +1 -1
  92. package/dist/src/peer/locate.js +19 -2
  93. package/dist/src/peer/locate.js.map +1 -1
  94. package/dist/src/peer/message.d.ts +113 -0
  95. package/dist/src/peer/message.d.ts.map +1 -0
  96. package/dist/src/peer/message.js +183 -0
  97. package/dist/src/peer/message.js.map +1 -0
  98. package/dist/src/peer/mirror.d.ts +50 -1
  99. package/dist/src/peer/mirror.d.ts.map +1 -1
  100. package/dist/src/peer/mirror.js +138 -0
  101. package/dist/src/peer/mirror.js.map +1 -1
  102. package/dist/src/peer/network.d.ts +229 -1
  103. package/dist/src/peer/network.d.ts.map +1 -1
  104. package/dist/src/peer/network.js +867 -44
  105. package/dist/src/peer/network.js.map +1 -1
  106. package/dist/src/peer/paths.d.ts +6 -0
  107. package/dist/src/peer/paths.d.ts.map +1 -1
  108. package/dist/src/peer/paths.js +8 -0
  109. package/dist/src/peer/paths.js.map +1 -1
  110. package/dist/src/peer/peek.d.ts +79 -0
  111. package/dist/src/peer/peek.d.ts.map +1 -0
  112. package/dist/src/peer/peek.js +465 -0
  113. package/dist/src/peer/peek.js.map +1 -0
  114. package/dist/src/peer/peeks.d.ts +115 -0
  115. package/dist/src/peer/peeks.d.ts.map +1 -0
  116. package/dist/src/peer/peeks.js +262 -0
  117. package/dist/src/peer/peeks.js.map +1 -0
  118. package/dist/src/peer/protocol.d.ts +128 -4
  119. package/dist/src/peer/protocol.d.ts.map +1 -1
  120. package/dist/src/peer/protocol.js.map +1 -1
  121. package/dist/src/peer/questions.d.ts +168 -0
  122. package/dist/src/peer/questions.d.ts.map +1 -0
  123. package/dist/src/peer/questions.js +389 -0
  124. package/dist/src/peer/questions.js.map +1 -0
  125. package/dist/src/peer/relay.d.ts +14 -0
  126. package/dist/src/peer/relay.d.ts.map +1 -1
  127. package/dist/src/peer/relay.js +102 -7
  128. package/dist/src/peer/relay.js.map +1 -1
  129. package/dist/src/peer/roster.d.ts +2 -0
  130. package/dist/src/peer/roster.d.ts.map +1 -1
  131. package/dist/src/peer/roster.js +5 -0
  132. package/dist/src/peer/roster.js.map +1 -1
  133. package/dist/src/peer/share.d.ts +48 -1
  134. package/dist/src/peer/share.d.ts.map +1 -1
  135. package/dist/src/peer/share.js +167 -2
  136. package/dist/src/peer/share.js.map +1 -1
  137. package/dist/src/peer/state.d.ts +12 -1
  138. package/dist/src/peer/state.d.ts.map +1 -1
  139. package/dist/src/peer/state.js +15 -0
  140. package/dist/src/peer/state.js.map +1 -1
  141. package/dist/src/peer/transform.d.ts +32 -0
  142. package/dist/src/peer/transform.d.ts.map +1 -1
  143. package/dist/src/peer/transform.js +52 -33
  144. package/dist/src/peer/transform.js.map +1 -1
  145. package/dist/src/peer/transport.d.ts +7 -3
  146. package/dist/src/peer/transport.d.ts.map +1 -1
  147. package/dist/src/peer/transport.js +12 -4
  148. package/dist/src/peer/transport.js.map +1 -1
  149. package/dist/src/peer/viewer-html.d.ts +13 -1
  150. package/dist/src/peer/viewer-html.d.ts.map +1 -1
  151. package/dist/src/peer/viewer-html.js +328 -4
  152. package/dist/src/peer/viewer-html.js.map +1 -1
  153. package/dist/src/peer/viewer.d.ts +49 -1
  154. package/dist/src/peer/viewer.d.ts.map +1 -1
  155. package/dist/src/peer/viewer.js +133 -7
  156. package/dist/src/peer/viewer.js.map +1 -1
  157. package/dist/src/peer/wire.d.ts +9 -2
  158. package/dist/src/peer/wire.d.ts.map +1 -1
  159. package/dist/src/peer/wire.js.map +1 -1
  160. package/package.json +3 -1
  161. package/plugin/.codex-plugin/plugin.json +1 -1
  162. package/plugin/.mcp.json +1 -1
  163. package/plugin/mcp.json +1 -1
  164. package/plugin/role-agents/lens-context/AGENT.md +8 -7
  165. package/plugin/role-agents/lens-reader/AGENT.md +48 -2
  166. package/plugin/skills/lens/SKILL.md +22 -15
@@ -1,18 +1,26 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { randomBytes } from 'node:crypto';
2
- import { chmodSync, existsSync, linkSync, lstatSync, realpathSync, statSync, unlinkSync, } from 'node:fs';
3
+ import { chmodSync, existsSync, linkSync, lstatSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
4
  import { createConnection, createServer } from 'node:net';
4
5
  import { isAbsolute, join } from 'node:path';
5
6
  import { isToolsLevel } from './redact.js';
6
- import { ensurePrivateDir, MAX_SOCKET_PATH_BYTES, socketPath, statePath } from './paths.js';
7
+ import { ensurePrivateDir, MAX_SOCKET_PATH_BYTES, socketPath, statePath, watchingDir, } from './paths.js';
7
8
  import { PeerState } from './state.js';
8
- import { ShareStream } from './share.js';
9
+ import { isTranscriptFormat, ShareStream } from './share.js';
9
10
  import { ViewerServer } from './viewer.js';
10
- import { DEFAULT_EXPIRES_MINUTES, MAX_EXPIRES_MINUTES, MAX_REQUEST_BYTES } from './protocol.js';
11
- import { isSessionId } from './locate.js';
12
- import { errMessage } from './errors.js';
11
+ import { DEFAULT_EXPIRES_MINUTES, DEFAULT_FIRST_WATCH_APPROVAL, MAX_EXPIRES_MINUTES, MAX_REQUEST_BYTES, } from './protocol.js';
12
+ import { claudeConfigDir, isSessionId, listSessions } from './locate.js';
13
+ import { errMessage, PeerNetworkError } from './errors.js';
13
14
  import { loadOrCreateMachineKey } from './keys.js';
14
15
  import { acquireDaemonLock, releaseDaemonLock } from './lock.js';
15
- import { PeerNetwork, PeerNetworkError } from './network.js';
16
+ import { PeerNetwork } from './network.js';
17
+ import { DEFAULT_ASK_TTL_MS, MAX_ASK_TTL_MS, MIN_ASK_TTL_MS } from './questions.js';
18
+ import { findInboxSocket, findSessionSocket, writeInbox } from './inbox.js';
19
+ import { answerHeader, frameAnswer, frameHeader, frameMessage, plainLabel, stripQuestion, } from './message.js';
20
+ import { redactText } from './redact.js';
21
+ import { isPersonName, isShareId } from './roster.js';
22
+ import { buildPeek, framePeek } from './peek.js';
23
+ import { composeExcerpt, frameExcerpt, parseExcerptItems } from './excerpt.js';
16
24
  const DEFAULT_POLL_MS = 500;
17
25
  /** stop이 IPC 서버가 닫히길 기다리는 상한. 넘으면 기다리지 않고 정리를 마친다 */
18
26
  const STOP_TIMEOUT_MS = 2_000;
@@ -41,6 +49,17 @@ export class PeerDaemon {
41
49
  /** listen할 임시 이름. 권한을 좁힌 뒤 진짜 이름으로 링크한다 */
42
50
  tmpSock;
43
51
  pollMs;
52
+ /** 훅 상태 파일을 다시 쓰기로 예약했는지. 한 틱 안의 바뀜을 한 번에 쓴다 */
53
+ watchingQueued = false;
54
+ /** 세션마다 마지막으로 쓴 훅 출력. 같으면 다시 안 쓴다 */
55
+ watchingWritten = new Map();
56
+ /**
57
+ * 이 프로세스가 시작한 시각을 ps로 읽은 글자. 훅이 같은 pid의 시작 시각과 맞춰 봐서 데몬이 죽은 뒤
58
+ * 그 pid를 받은 다른 프로세스를 데몬으로 믿지 않게 한다. ps를 못 부르면 빈 글자다
59
+ */
60
+ startStamp = processStartStamp(process.pid);
61
+ /** 보기마다 `peer watch`를 친 쪽의 Claude Code 설정 디렉토리. 발췌를 넣을 내 세션을 거기서 찾는다 */
62
+ watchConfigDirs = new Map();
44
63
  constructor(options) {
45
64
  this.options = options;
46
65
  this.sock = socketPath(options.root);
@@ -51,7 +70,15 @@ export class PeerDaemon {
51
70
  const s = this.shares.get(id);
52
71
  if (s)
53
72
  return { stream: s.stream, token: s.token };
54
- return this.network?.lookupWatch(id);
73
+ const w = this.network?.lookupWatch(id);
74
+ if (!w)
75
+ return undefined;
76
+ // 원격 공유를 보는 중이면 고른 줄을 내 세션으로 넘길 수 있다. 내 공유는 내 세션이라 없다
77
+ return {
78
+ ...w,
79
+ sessions: () => this.localSessions(id),
80
+ excerpt: (session, items) => this.sendExcerpt(id, session, items),
81
+ };
55
82
  },
56
83
  onConnect: (id) => this.state?.audit('viewer_connect', id),
57
84
  onDisconnect: (id) => this.state?.audit('viewer_disconnect', id),
@@ -80,6 +107,8 @@ export class PeerDaemon {
80
107
  state.audit('share_close', id, 'daemon_restart');
81
108
  }
82
109
  this.key = loadOrCreateMachineKey(this.options.root);
110
+ // 죽은 데몬이 남긴 보는 중 표시를 치운다. 훅은 pid도 보지만 그 pid를 다른 프로세스가 받을 수 있다
111
+ this.clearWatching();
83
112
  await this.viewer.start(0);
84
113
  await this.listenIpc();
85
114
  }
@@ -98,9 +127,36 @@ export class PeerDaemon {
98
127
  key: this.key,
99
128
  lookupShare: (id) => {
100
129
  const s = this.shares.get(id);
101
- return s ? { stream: s.stream, shareKey: s.shareKey } : undefined;
130
+ if (!s)
131
+ return undefined;
132
+ return {
133
+ stream: s.stream,
134
+ shareKey: s.shareKey,
135
+ // 인박스 소켓은 세션 id로 찾으므로 --transcript로 연 공유는 메시지를 못 받는다
136
+ ...(s.messages && s.sessionId
137
+ ? {
138
+ deliver: (m, onSeen) => this.deliverMessage(id, m, onSeen),
139
+ }
140
+ : {}),
141
+ ...(s.agentPeek
142
+ ? {
143
+ peek: (since) => buildPeek({
144
+ share: id,
145
+ feed: s.stream,
146
+ title: s.stream.sessionTitle,
147
+ streamStatus: s.stream.currentStatus,
148
+ since,
149
+ }),
150
+ }
151
+ : {}),
152
+ };
102
153
  },
103
154
  transport: this.options.network,
155
+ approvalTtlMs: this.options.approvalTtlMs,
156
+ approvalNoticeMs: this.options.approvalNoticeMs,
157
+ peekNoticeMs: this.options.peekNoticeMs,
158
+ peekTimeoutMs: this.options.peekTimeoutMs,
159
+ onControlChange: () => this.scheduleWatching(),
104
160
  });
105
161
  this.network.start();
106
162
  this.startedAt = Date.now();
@@ -136,6 +192,7 @@ export class PeerDaemon {
136
192
  }
137
193
  }
138
194
  finally {
195
+ this.clearWatching();
139
196
  this.removeOwnSocket();
140
197
  this.state?.close();
141
198
  this.state = null;
@@ -156,7 +213,11 @@ export class PeerDaemon {
156
213
  const { unknownGrew } = share.stream.poll();
157
214
  if (unknownGrew) {
158
215
  const added = share.stream.unknownLines - before;
159
- this.state?.audit('unknown_lines', id, JSON.stringify({ added, claudeVersion: share.stream.claudeVersion ?? null }));
216
+ this.state?.audit('unknown_lines', id, JSON.stringify({
217
+ added,
218
+ format: share.stream.format,
219
+ version: share.stream.toolVersion ?? null,
220
+ }));
160
221
  }
161
222
  }
162
223
  catch (err) {
@@ -192,11 +253,21 @@ export class PeerDaemon {
192
253
  return this.openShare(req);
193
254
  case 'unshare': {
194
255
  if (typeof req.id !== 'string' || !this.shares.has(req.id)) {
195
- return { ok: false, error: `열린 공유가 아니에요: ${String(req.id)}` };
256
+ return { ok: false, error: `열린 공유가 아니에요: ${plainLabel(req.id) ?? ''}` };
196
257
  }
258
+ // 끊기는 사람을 닫기 전에 센다. 닫으면 구독이 끝나 목록이 빈다
259
+ const ended = this.network?.shareControl(req.id).watchers ?? [];
197
260
  this.closeShareSafely(req.id, 'unshare');
198
- return { ok: true, result: { id: req.id } };
261
+ return { ok: true, result: { id: req.id, watchers: ended } };
199
262
  }
263
+ case 'kick':
264
+ case 'approve':
265
+ case 'deny':
266
+ return this.control(req);
267
+ case 'config':
268
+ return this.config(req);
269
+ case 'ask':
270
+ return this.ask(req);
200
271
  case 'init':
201
272
  return this.withNetwork((n) => n.init(String(req.person), String(req.relay)));
202
273
  case 'invite':
@@ -212,25 +283,225 @@ export class PeerDaemon {
212
283
  case 'watch':
213
284
  return this.withNetworkAsync(async (n) => {
214
285
  const w = await n.watch(String(req.link));
215
- return { id: w.id, share: w.share, owner: w.owner, url: this.viewerUrl(w.id, w.token) };
286
+ // 끝난 보기의 설정 디렉토리는 여기서 치운다. 보기는 kick이나 공유 종료로도 끝난다
287
+ for (const id of [...this.watchConfigDirs.keys()]) {
288
+ if (!n.lookupWatch(id))
289
+ this.watchConfigDirs.delete(id);
290
+ }
291
+ if (typeof req.configDir === 'string' && isAbsolute(req.configDir)) {
292
+ this.watchConfigDirs.set(w.id, req.configDir);
293
+ }
294
+ return {
295
+ id: w.id,
296
+ share: w.share,
297
+ owner: w.owner,
298
+ url: this.viewerUrl(w.id, w.token),
299
+ ...(w.waiting ? { waiting: true } : {}),
300
+ };
216
301
  });
217
302
  case 'watches':
218
- return this.withNetwork((n) => n.listWatches().map((w) => ({
219
- ...w,
220
- url: this.viewerUrl(w.id, n.lookupWatch(w.id).token),
221
- })));
303
+ return this.withNetwork((n) => n.listWatches().map((w) => {
304
+ const live = n.lookupWatch(w.id);
305
+ return live ? { ...w, url: this.viewerUrl(w.id, live.token) } : w;
306
+ }));
307
+ case 'send': {
308
+ const watch = String(req.watch);
309
+ const body = req.body;
310
+ return this.withNetworkAsync((n) => n.sendMessage(watch, String(body), 'human'));
311
+ }
312
+ case 'messages': {
313
+ if (req.watch !== undefined) {
314
+ const watch = String(req.watch);
315
+ return this.withNetwork((n) => n.listMessages(watch));
316
+ }
317
+ return { ok: true, result: this.state?.messageLog() ?? [] };
318
+ }
222
319
  case 'unwatch': {
223
320
  const id = String(req.id);
224
321
  return this.withNetwork((n) => {
225
322
  if (!n.unwatch(id))
226
323
  throw new PeerNetworkError(`보고 있는 공유가 아니에요: ${id}`);
324
+ this.watchConfigDirs.delete(id);
227
325
  return { id };
228
326
  });
229
327
  }
328
+ case 'peek':
329
+ return this.peek(req);
230
330
  default:
231
331
  return { ok: false, error: `모르는 요청이에요: ${String(req.op)}` };
232
332
  }
233
333
  }
334
+ /** kick, approve, deny. share가 없으면 열린 공유 전부에 건다 */
335
+ control(req) {
336
+ if (!isPersonName(req.person))
337
+ return { ok: false, error: '이름 꼴이 아니에요' };
338
+ let ids;
339
+ if (req.share !== undefined) {
340
+ if (!isShareId(req.share) || !this.shares.has(req.share)) {
341
+ return { ok: false, error: `열린 공유가 아니에요: ${plainLabel(req.share) ?? ''}` };
342
+ }
343
+ ids = [req.share];
344
+ }
345
+ else {
346
+ ids = [...this.shares.keys()];
347
+ if (ids.length === 0)
348
+ return { ok: false, error: '열린 공유가 없어요' };
349
+ }
350
+ const { op, person } = req;
351
+ return this.withNetwork((n) => CONTROL_OPS[op](n, person, ids));
352
+ }
353
+ config(req) {
354
+ const state = this.state;
355
+ if (!state)
356
+ return { ok: false, error: '데몬이 아직 안 떴어요' };
357
+ if (req.firstWatch !== undefined) {
358
+ if (req.firstWatch !== 'ask' && req.firstWatch !== 'team') {
359
+ return { ok: false, error: '--first-watch는 ask, team 중 하나예요' };
360
+ }
361
+ state.setSettings({ ...state.getSettings(), firstWatch: req.firstWatch });
362
+ const revoked = this.network?.setFirstWatch(req.firstWatch) ?? 0;
363
+ return { ok: true, result: { firstWatch: req.firstWatch, revoked } };
364
+ }
365
+ return {
366
+ ok: true,
367
+ result: { firstWatch: state.getSettings().firstWatch ?? DEFAULT_FIRST_WATCH_APPROVAL },
368
+ };
369
+ }
370
+ /**
371
+ * ges_peer ask_user. 그 세션 id로 연 Claude Code 공유를 찾아 지금 보는 팀원에게 질문을 보낸다.
372
+ * 메시지를 끈 공유는 고르지 않는다. 답이 메시지와 같은 길로 들어가는데 주인이 그 길을 막았다.
373
+ * 질문 글자는 이 머신을 떠나므로 대화 기록과 같은 가리기를 건다.
374
+ */
375
+ ask(req) {
376
+ if (typeof req.sessionId !== 'string' || !isSessionId(req.sessionId)) {
377
+ return { ok: false, error: 'sessionId 형식이 아니에요' };
378
+ }
379
+ if (typeof req.question !== 'string')
380
+ return { ok: false, error: '질문이 없어요' };
381
+ if (req.to !== undefined && !isPersonName(req.to)) {
382
+ return { ok: false, error: '이름 꼴이 아니에요' };
383
+ }
384
+ let ttl = DEFAULT_ASK_TTL_MS;
385
+ if (req.timeoutSecs !== undefined) {
386
+ ttl = Number(req.timeoutSecs) * 1000;
387
+ if (!Number.isFinite(ttl) || ttl < MIN_ASK_TTL_MS || ttl > MAX_ASK_TTL_MS) {
388
+ return {
389
+ ok: false,
390
+ error: `timeoutSecs는 ${MIN_ASK_TTL_MS / 1000}~${MAX_ASK_TTL_MS / 1000}초예요`,
391
+ };
392
+ }
393
+ }
394
+ const mine = [...this.shares].filter(([, s]) => s.sessionId === req.sessionId && s.format === 'claude');
395
+ if (mine.length === 0)
396
+ return { ok: false, error: '이 세션을 공유하고 있지 않아요' };
397
+ const open = mine.filter(([, s]) => s.messages).map(([id]) => id);
398
+ if (open.length === 0) {
399
+ return {
400
+ ok: false,
401
+ error: '팀원 메시지를 끈 공유(--no-messages)라 답을 받을 수 없어서 질문도 못 보내요',
402
+ };
403
+ }
404
+ // 보이지 않는 글자를 먼저 지우고 가린다. 거꾸로 하면 토큰 사이에 끼운 폭 없는 공백이 가리기를
405
+ // 피한 뒤 지워진다. 길이 검사와 자르기는 가린 뒤 network.ask가 한다
406
+ const question = redactText(stripQuestion(req.question) ?? '', { home: this.options.home });
407
+ const to = req.to;
408
+ return this.withNetwork((n) => n.ask(open, question, to, ttl));
409
+ }
410
+ /**
411
+ * ges_peer peek. 보고 있는 원격 공유의 요약을 주인 데몬에 청해 틀로 감싼 글자로 돌려준다. 받아 줄지는
412
+ * 주인 데몬이 정한다. 보는 쪽은 받은 요약의 꼴과 크기를 다시 본 뒤에 감싼다.
413
+ */
414
+ peek(req) {
415
+ const share = req.share;
416
+ const since = req.since;
417
+ return this.withNetworkAsync(async (n) => {
418
+ if (typeof share !== 'string')
419
+ throw new PeerNetworkError('share가 없어요');
420
+ if (since !== undefined && typeof since !== 'string') {
421
+ throw new PeerNetworkError('since는 앞 peek이 돌려준 cursor예요');
422
+ }
423
+ const r = await n.peek(share, since);
424
+ return {
425
+ share: r.peek.share,
426
+ owner: r.owner,
427
+ cursor: r.peek.cursor,
428
+ text: framePeek(r.owner, r.peek, Date.now()),
429
+ };
430
+ });
431
+ }
432
+ /**
433
+ * 고른 줄을 보는 사람 머신의 세션에 `<peer-excerpt>`로 넣는다. 글자는 브라우저가 보낸 것을 쓰지
434
+ * 않고 보기가 들고 있는 이벤트에서 다시 만든다. 넣는 길은 주인 데몬의 메시지와 같은 인박스 소켓
435
+ * 쓰기다. 데몬은 그 세션의 자식이 아니라서 내 세션이라도 수신 규칙(crossSessionInbound)을 탄다.
436
+ * 그 세션 대화 기록은 따라 읽지 않으므로 들어갔는지는 확인하지 않는다.
437
+ */
438
+ async sendExcerpt(watchId, session, rawItems) {
439
+ const w = this.network?.lookupWatch(watchId);
440
+ if (!w)
441
+ return { ok: false, error: '보고 있는 공유가 아니에요' };
442
+ if (typeof session !== 'string' || !isSessionId(session)) {
443
+ return { ok: false, error: '보낼 세션을 골라 주세요' };
444
+ }
445
+ const items = parseExcerptItems(rawItems);
446
+ if (!items)
447
+ return { ok: false, error: '고른 줄 꼴이 맞지 않아요' };
448
+ const composed = composeExcerpt(w.stream, items);
449
+ if (!composed.ok)
450
+ return { ok: false, error: composed.reason };
451
+ const found = findSessionSocket(session, this.sessionConfigDirs(watchId));
452
+ if (!found.ok)
453
+ return { ok: false, error: `보낼 ${found.reason}` };
454
+ const text = frameExcerpt({
455
+ owner: w.owner,
456
+ share: w.share,
457
+ pickedBy: this.network?.person ?? '?',
458
+ ...(composed.from ? { from: composed.from } : {}),
459
+ ...(composed.to ? { to: composed.to } : {}),
460
+ body: composed.body,
461
+ });
462
+ const result = await writeInbox(found.path, text);
463
+ // 붙은 뒤 실패면 들어갔을 수 있다. 다시 보내면 겹치므로 넣음으로 알리고 그 사실을 감사 행에 남긴다
464
+ if (!result.ok && !result.maybeWritten)
465
+ return { ok: false, error: result.reason };
466
+ const bytes = Buffer.byteLength(composed.body);
467
+ this.state?.audit('excerpt_send', null, JSON.stringify({
468
+ watch: watchId,
469
+ share: w.share,
470
+ session,
471
+ items: items.length,
472
+ bytes,
473
+ ...(result.ok ? {} : { maybeWritten: true }),
474
+ }));
475
+ return { ok: true, bytes };
476
+ }
477
+ /** 발췌를 넣을 수 있는 내 세션. 인박스 소켓이 있고 프로세스가 살아 있는 것만 */
478
+ localSessions(watchId) {
479
+ const seen = new Set();
480
+ const out = [];
481
+ for (const dir of this.sessionConfigDirs(watchId)) {
482
+ for (const rec of listSessions(dir)) {
483
+ if (seen.has(rec.sessionId) || !rec.messagingSocketPath)
484
+ continue;
485
+ if (rec.pid !== undefined && !processAlive(rec.pid))
486
+ continue;
487
+ seen.add(rec.sessionId);
488
+ out.push({
489
+ sessionId: rec.sessionId,
490
+ cwd: redactText(rec.cwd, { home: this.options.home }),
491
+ ...(rec.pid !== undefined ? { pid: rec.pid } : {}),
492
+ });
493
+ }
494
+ }
495
+ return out;
496
+ }
497
+ /** 발췌를 넣을 세션을 찾는 설정 디렉토리. `peer watch`를 친 쪽 것이 먼저고 데몬 것이 다음이다 */
498
+ sessionConfigDirs(watchId) {
499
+ const dirs = [
500
+ this.watchConfigDirs.get(watchId),
501
+ this.options.configDir ?? claudeConfigDir(),
502
+ ].filter((d) => d !== undefined);
503
+ return [...new Set(dirs)];
504
+ }
234
505
  withNetwork(fn) {
235
506
  if (!this.network)
236
507
  return { ok: false, error: '데몬이 아직 안 떴어요' };
@@ -251,6 +522,43 @@ export class PeerDaemon {
251
522
  return { ok: false, error: errMessage(err) };
252
523
  }
253
524
  }
525
+ /**
526
+ * 팀원이 보낸 메시지나 서명을 확인한 답을 주인 세션 인박스에 쓴다. 들어간 게 대화 기록에 보이면
527
+ * 대화 흐름에 끼우고 onSeen을 부른다. 지켜보는 일은 쓰기 전에 걸어야 쓰자마자 생긴 줄을 놓치지 않는다.
528
+ */
529
+ async deliverMessage(shareId, m, onSeen) {
530
+ const share = this.shares.get(shareId);
531
+ if (!share)
532
+ return { ok: false, reason: 'share_closed' };
533
+ const found = findInboxSocket(share.stream.transcriptPath, share.sessionId);
534
+ if (!found.ok)
535
+ return { ok: false, reason: found.reason };
536
+ const stream = share.stream;
537
+ const header = m.kind === 'answer'
538
+ ? answerHeader(m.person, m.question)
539
+ : frameHeader(m.person, m.origin, m.id);
540
+ stream.awaitMessage(header, () => {
541
+ if (m.kind === 'answer') {
542
+ stream.pushPeerAnswer({ question: m.question, from: m.person, text: m.body });
543
+ }
544
+ else {
545
+ stream.pushPeerMessage({ id: m.id, from: m.person, origin: m.origin, text: m.body });
546
+ }
547
+ onSeen();
548
+ });
549
+ let result = { ok: false, reason: 'deliver_failed' };
550
+ try {
551
+ result = await writeInbox(found.path, m.kind === 'answer'
552
+ ? frameAnswer({ from: m.person, question: m.question, body: m.body })
553
+ : frameMessage({ from: m.person, origin: m.origin, id: m.id, body: m.body }));
554
+ }
555
+ finally {
556
+ // 붙은 뒤 실패했으면 줄이 들어갔을 수 있다. 지켜보기를 남겨 들어갔으면 대화 흐름에 끼운다
557
+ if (!result.ok && !result.maybeWritten)
558
+ stream.cancelAwait(header);
559
+ }
560
+ return result;
561
+ }
254
562
  viewerUrl(id, token) {
255
563
  return `http://127.0.0.1:${this.viewer.port}/s/${id}?t=${token}`;
256
564
  }
@@ -285,6 +593,16 @@ export class PeerDaemon {
285
593
  if (req.sessionId !== undefined && !isSessionId(req.sessionId)) {
286
594
  return { ok: false, error: 'sessionId 형식이 아니에요' };
287
595
  }
596
+ const format = req.format ?? 'claude';
597
+ if (!isTranscriptFormat(format))
598
+ return { ok: false, error: 'format은 claude, codex 중 하나예요' };
599
+ // Codex 세션에는 메시지를 넣는 길이 없다. sessionId는 Claude Code 인박스를 찾는 데만 쓴다
600
+ if (format === 'codex' && req.sessionId !== undefined) {
601
+ return { ok: false, error: 'Codex 공유는 sessionId를 받지 않아요' };
602
+ }
603
+ const messages = format === 'claude' && req.messages !== false;
604
+ // 요약은 주인이 켜야만 된다. Codex 공유도 같은 규칙이다. 요약은 뷰어로 나간 이벤트에서만 추린다
605
+ const agentPeek = req.agentPeek === true;
288
606
  const tools = req.tools ?? 'summary';
289
607
  if (!isToolsLevel(tools))
290
608
  return { ok: false, error: '--tools는 full, summary, none 중 하나예요' };
@@ -297,13 +615,22 @@ export class PeerDaemon {
297
615
  const shareKey = randomBytes(32).toString('base64url');
298
616
  const createdAt = Date.now();
299
617
  const expiresAt = createdAt + Math.round(minutes * 60_000);
300
- const stream = new ShareStream({ id, transcriptPath: real, tools, home: this.options.home });
618
+ const stream = new ShareStream({
619
+ id,
620
+ transcriptPath: real,
621
+ tools,
622
+ format,
623
+ home: this.options.home,
624
+ });
301
625
  stream.poll();
302
626
  this.shares.set(id, {
303
627
  stream,
304
628
  token,
305
629
  shareKey,
306
630
  sessionId: req.sessionId ?? null,
631
+ format,
632
+ messages,
633
+ agentPeek,
307
634
  createdAt,
308
635
  expiresAt,
309
636
  });
@@ -315,7 +642,7 @@ export class PeerDaemon {
315
642
  createdAt,
316
643
  expiresAt,
317
644
  });
318
- this.state.audit('share_open', id, JSON.stringify({ tools, expiresAt }));
645
+ this.state.audit('share_open', id, JSON.stringify({ tools, format, expiresAt, messages, agentPeek }));
319
646
  this.announceShares();
320
647
  return { ok: true, result: this.info(id) };
321
648
  }
@@ -325,6 +652,8 @@ export class PeerDaemon {
325
652
  return;
326
653
  this.shares.delete(id);
327
654
  share.stream.close(reason);
655
+ this.network?.forgetShare(id);
656
+ this.scheduleWatching();
328
657
  this.announceShares();
329
658
  this.state?.closeShare(id, reason);
330
659
  this.state?.audit('share_close', id, reason);
@@ -338,6 +667,9 @@ export class PeerDaemon {
338
667
  sessionId: s.sessionId,
339
668
  transcriptPath: s.stream.transcriptPath,
340
669
  tools: s.stream.tools,
670
+ format: s.format,
671
+ messages: s.messages && s.sessionId !== null,
672
+ agentPeek: s.agentPeek,
341
673
  createdAt: s.createdAt,
342
674
  expiresAt: s.expiresAt,
343
675
  status: s.stream.currentStatus,
@@ -350,8 +682,103 @@ export class PeerDaemon {
350
682
  title: s.stream.sessionTitle,
351
683
  url: this.viewerUrl(id, s.token),
352
684
  link: this.network?.linkFor(id, s.shareKey),
685
+ ...(this.network?.shareControl(id) ?? { watchers: [], pending: [], blocked: [], peeks: [] }),
353
686
  };
354
687
  }
688
+ scheduleWatching() {
689
+ if (this.watchingQueued)
690
+ return;
691
+ this.watchingQueued = true;
692
+ setImmediate(() => {
693
+ this.watchingQueued = false;
694
+ this.writeWatching();
695
+ });
696
+ }
697
+ /**
698
+ * 보는 중 표시 훅이 읽을 세션별 상태 파일을 다시 쓴다. 원격으로 보는 사람이나 승인을 기다리는
699
+ * 사람이 있거나 최근 10분(PEEK_NOTICE_MS) 안에 팀원의 에이전트가 요약을 가져간 세션만 파일이 있다. 훅은 파일이 있으면 적힌 데몬이 살아 있는 한 그 내용을 그대로
700
+ * 내보낸다. 아무도 없는 세션은 파일을 지워서 끊긴 사람이 다음 프롬프트에 계속 보이지 않게 한다.
701
+ * `--transcript`로 연 공유는 세션 id가 없어서 표시하지 않는다.
702
+ *
703
+ * 마지막에 쓴 내용을 세션마다 기억해 같은 내용이면 건너뛰고 쓰기와 지우기는 파일마다 따로 실패를
704
+ * 처리한다. 디렉토리를 못 만들면 그 차례는 아무것도 안 쓴다.
705
+ */
706
+ writeWatching() {
707
+ const network = this.network;
708
+ if (!network || !this.state)
709
+ return;
710
+ const sessions = new Map();
711
+ for (const [id, s] of this.shares) {
712
+ if (!s.sessionId)
713
+ continue;
714
+ const c = network.shareControl(id);
715
+ if (c.watchers.length === 0 && c.pending.length === 0 && c.peeks.length === 0)
716
+ continue;
717
+ let entry = sessions.get(s.sessionId);
718
+ if (!entry) {
719
+ entry = { watchers: new Set(), pending: [], peeks: [] };
720
+ sessions.set(s.sessionId, entry);
721
+ }
722
+ for (const w of c.watchers)
723
+ entry.watchers.add(w);
724
+ entry.pending.push(...c.pending);
725
+ entry.peeks.push(...c.peeks);
726
+ }
727
+ const dir = watchingDir(this.options.root);
728
+ for (const sessionId of [...this.watchingWritten.keys()]) {
729
+ if (sessions.has(sessionId))
730
+ continue;
731
+ try {
732
+ unlinkSync(join(dir, sessionId));
733
+ this.watchingWritten.delete(sessionId);
734
+ }
735
+ catch (err) {
736
+ if (err.code === 'ENOENT')
737
+ this.watchingWritten.delete(sessionId);
738
+ else
739
+ process.stderr.write(`[peer] watching remove failed: ${errMessage(err)}\n`);
740
+ }
741
+ }
742
+ if (sessions.size === 0)
743
+ return;
744
+ try {
745
+ ensurePrivateDir(dir);
746
+ }
747
+ catch (err) {
748
+ process.stderr.write(`[peer] watching dir failed: ${errMessage(err)}\n`);
749
+ return;
750
+ }
751
+ for (const [sessionId, e] of sessions) {
752
+ const context = watchingContext([...e.watchers], e.pending, e.peeks);
753
+ const line = JSON.stringify({
754
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context },
755
+ });
756
+ if (this.watchingWritten.get(sessionId) === line)
757
+ continue;
758
+ try {
759
+ const tmp = join(dir, `.${sessionId}.tmp`);
760
+ writeFileSync(tmp, `${process.pid}\n${this.startStamp}\n${line}\n`, { mode: 0o600 });
761
+ renameSync(tmp, join(dir, sessionId));
762
+ this.watchingWritten.set(sessionId, line);
763
+ }
764
+ catch (err) {
765
+ process.stderr.write(`[peer] watching state failed: ${errMessage(err)}\n`);
766
+ }
767
+ }
768
+ }
769
+ clearWatching() {
770
+ this.watchingWritten.clear();
771
+ const dir = watchingDir(this.options.root);
772
+ try {
773
+ if (!existsSync(dir))
774
+ return;
775
+ for (const name of readdirSync(dir))
776
+ unlinkSync(join(dir, name));
777
+ }
778
+ catch (err) {
779
+ process.stderr.write(`[peer] watching cleanup failed: ${errMessage(err)}\n`);
780
+ }
781
+ }
355
782
  status() {
356
783
  let viewers = 0;
357
784
  for (const s of this.shares.values())
@@ -504,6 +931,71 @@ export class PeerDaemon {
504
931
  }
505
932
  }
506
933
  }
934
+ /**
935
+ * 훅이 주인 세션 모델에 붙일 글자. 이름은 명단에서 온 값이지만 모델 컨텍스트로 들어가므로 한 줄
936
+ * 라벨로 정리하고 이름 꼴이 아니면 뺀다. 승인 명령은 모델이 직접 치지 않게 적는다. 주인이
937
+ * 받아 주는 자리를 모델이 대신 채우면 승인이 뜻이 없다.
938
+ */
939
+ export function watchingContext(watchers, pending, peeks = []) {
940
+ const names = (list) => [...new Set(list.map((n) => plainLabel(n, 32)).filter(isPersonName))].sort();
941
+ const lines = [];
942
+ const who = names(watchers);
943
+ if (who.length > 0) {
944
+ lines.push(`[gestalt peer] ${who.join(', ')} 님이 이 세션의 대화를 보는 중이다.`);
945
+ }
946
+ // 긴 안내는 대기가 생기고 잠깐만 붙인다(brief가 서기 전). 프롬프트마다 같은 문장이 컨텍스트에
947
+ // 쌓이지 않게 그 뒤로는 줄인다
948
+ const fresh = names(pending.filter((p) => !p.brief).map((p) => p.person));
949
+ const old = names(pending.map((p) => p.person)).filter((n) => !fresh.includes(n));
950
+ if (fresh.length > 0) {
951
+ lines.push(`[gestalt peer] ${fresh.join(', ')} 님이 이 세션을 보려고 주인 승인을 기다린다. ` +
952
+ '사용자에게 알리기만 하고 승인이나 거절 명령은 직접 실행하지 않는다. ' +
953
+ `사용자가 \`gestalt peer approve ${fresh[0]}\`나 \`gestalt peer deny ${fresh[0]}\`로 정한다.`);
954
+ }
955
+ if (old.length > 0) {
956
+ lines.push(`[gestalt peer] 승인 대기: ${old.join(', ')}. 사용자가 정할 일이다.`);
957
+ }
958
+ // 요약을 가져간 사람은 알리는 시간(10분) 동안만 붙는다. 그 뒤로는 데몬이 줄을 뺀다
959
+ const peekers = names(peeks.map((p) => p.person));
960
+ if (peekers.length > 0) {
961
+ lines.push(`[gestalt peer] ${peekers.join(', ')} 님의 에이전트가 이 세션의 요약을 가져갔다.`);
962
+ }
963
+ return lines.join('\n');
964
+ }
965
+ /**
966
+ * pid의 시작 시각을 ps로 읽어 공백을 뺀 글자로. 훅(`scripts/peer-watching-hook.sh`)이 같은 명령으로
967
+ * 읽어 맞춰 본다. 로캘과 시간대에 따라 글자가 달라지지 않게 LC_ALL=C, TZ=UTC로 부른다. 데몬과 세션이
968
+ * 다른 TZ로 떠도 같은 프로세스면 같은 글자가 나와야 한다.
969
+ */
970
+ export function processStartStamp(pid) {
971
+ try {
972
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
973
+ encoding: 'utf8',
974
+ env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
975
+ stdio: ['ignore', 'pipe', 'ignore'],
976
+ timeout: 2_000,
977
+ });
978
+ return out.replace(/\s+/g, '');
979
+ }
980
+ catch {
981
+ return '';
982
+ }
983
+ }
984
+ const CONTROL_OPS = {
985
+ kick: (n, person, shares) => n.kick(person, shares),
986
+ approve: (n, person, shares) => n.approve(person, shares),
987
+ deny: (n, person, shares) => n.deny(person, shares),
988
+ };
989
+ /** pid가 살아 있는지. 남의 프로세스라 신호를 못 보내도(EPERM) 살아 있는 것이다 */
990
+ function processAlive(pid) {
991
+ try {
992
+ process.kill(pid, 0);
993
+ return true;
994
+ }
995
+ catch (err) {
996
+ return err.code === 'EPERM';
997
+ }
998
+ }
507
999
  function sameFile(path, id) {
508
1000
  try {
509
1001
  const st = lstatSync(path);