@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.
- package/CLAUDE.md +3 -3
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/package.json +3 -1
- package/dist/plugin/role-agents/lens-context/AGENT.md +8 -7
- package/dist/plugin/role-agents/lens-reader/AGENT.md +48 -2
- package/dist/plugin/skills/lens/SKILL.md +22 -15
- package/dist/src/architecture/doc-fresh.d.ts.map +1 -1
- package/dist/src/architecture/doc-fresh.js +3 -1
- package/dist/src/architecture/doc-fresh.js.map +1 -1
- package/dist/src/cli/commands/lens.d.ts +8 -1
- package/dist/src/cli/commands/lens.d.ts.map +1 -1
- package/dist/src/cli/commands/lens.js +29 -2
- package/dist/src/cli/commands/lens.js.map +1 -1
- package/dist/src/cli/commands/peer.d.ts +20 -0
- package/dist/src/cli/commands/peer.d.ts.map +1 -1
- package/dist/src/cli/commands/peer.js +198 -12
- package/dist/src/cli/commands/peer.js.map +1 -1
- package/dist/src/cli/index.d.ts.map +1 -1
- package/dist/src/cli/index.js +44 -4
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/digest/lens-schema.d.ts +150 -3
- package/dist/src/digest/lens-schema.d.ts.map +1 -1
- package/dist/src/digest/lens-schema.js +14 -1
- package/dist/src/digest/lens-schema.js.map +1 -1
- package/dist/src/digest/lens-widget-schema.d.ts +2 -2
- package/dist/src/digest/lint-lens.d.ts +2 -1
- package/dist/src/digest/lint-lens.d.ts.map +1 -1
- package/dist/src/digest/lint-lens.js +68 -7
- package/dist/src/digest/lint-lens.js.map +1 -1
- package/dist/src/digest/ocr.d.ts +1 -1
- package/dist/src/digest/ocr.d.ts.map +1 -1
- package/dist/src/digest/ocr.js +10 -4
- package/dist/src/digest/ocr.js.map +1 -1
- package/dist/src/digest/schema.d.ts +62 -62
- package/dist/src/lens/attach.d.ts +741 -0
- package/dist/src/lens/attach.d.ts.map +1 -1
- package/dist/src/lens/attach.js +41 -11
- package/dist/src/lens/attach.js.map +1 -1
- package/dist/src/lens/context-input.d.ts +17 -0
- package/dist/src/lens/context-input.d.ts.map +1 -0
- package/dist/src/lens/context-input.js +31 -0
- package/dist/src/lens/context-input.js.map +1 -0
- package/dist/src/lens/ingest.d.ts +19 -1
- package/dist/src/lens/ingest.d.ts.map +1 -1
- package/dist/src/lens/ingest.js +92 -14
- package/dist/src/lens/ingest.js.map +1 -1
- package/dist/src/lens/pdf-extract-py.d.ts +2 -0
- package/dist/src/lens/pdf-extract-py.d.ts.map +1 -1
- package/dist/src/lens/pdf-extract-py.js +37 -2
- package/dist/src/lens/pdf-extract-py.js.map +1 -1
- package/dist/src/mcp/schemas.d.ts +29 -0
- package/dist/src/mcp/schemas.d.ts.map +1 -1
- package/dist/src/mcp/schemas.js +29 -0
- package/dist/src/mcp/schemas.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +6 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.js +1 -1
- package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -1
- package/dist/src/mcp/tools/peer.d.ts +24 -0
- package/dist/src/mcp/tools/peer.d.ts.map +1 -0
- package/dist/src/mcp/tools/peer.js +103 -0
- package/dist/src/mcp/tools/peer.js.map +1 -0
- package/dist/src/peer/codex.d.ts +57 -0
- package/dist/src/peer/codex.d.ts.map +1 -0
- package/dist/src/peer/codex.js +303 -0
- package/dist/src/peer/codex.js.map +1 -0
- package/dist/src/peer/daemon.d.ts +76 -1
- package/dist/src/peer/daemon.d.ts.map +1 -1
- package/dist/src/peer/daemon.js +511 -19
- package/dist/src/peer/daemon.js.map +1 -1
- package/dist/src/peer/errors.d.ts +3 -0
- package/dist/src/peer/errors.d.ts.map +1 -1
- package/dist/src/peer/errors.js +3 -0
- package/dist/src/peer/errors.js.map +1 -1
- package/dist/src/peer/excerpt.d.ts +47 -0
- package/dist/src/peer/excerpt.d.ts.map +1 -0
- package/dist/src/peer/excerpt.js +160 -0
- package/dist/src/peer/excerpt.js.map +1 -0
- package/dist/src/peer/feed.d.ts +35 -2
- package/dist/src/peer/feed.d.ts.map +1 -1
- package/dist/src/peer/feed.js +22 -0
- package/dist/src/peer/feed.js.map +1 -1
- package/dist/src/peer/inbox.d.ts +42 -0
- package/dist/src/peer/inbox.d.ts.map +1 -0
- package/dist/src/peer/inbox.js +96 -0
- package/dist/src/peer/inbox.js.map +1 -0
- package/dist/src/peer/locate.d.ts +5 -0
- package/dist/src/peer/locate.d.ts.map +1 -1
- package/dist/src/peer/locate.js +19 -2
- package/dist/src/peer/locate.js.map +1 -1
- package/dist/src/peer/message.d.ts +113 -0
- package/dist/src/peer/message.d.ts.map +1 -0
- package/dist/src/peer/message.js +183 -0
- package/dist/src/peer/message.js.map +1 -0
- package/dist/src/peer/mirror.d.ts +50 -1
- package/dist/src/peer/mirror.d.ts.map +1 -1
- package/dist/src/peer/mirror.js +138 -0
- package/dist/src/peer/mirror.js.map +1 -1
- package/dist/src/peer/network.d.ts +229 -1
- package/dist/src/peer/network.d.ts.map +1 -1
- package/dist/src/peer/network.js +867 -44
- package/dist/src/peer/network.js.map +1 -1
- package/dist/src/peer/paths.d.ts +6 -0
- package/dist/src/peer/paths.d.ts.map +1 -1
- package/dist/src/peer/paths.js +8 -0
- package/dist/src/peer/paths.js.map +1 -1
- package/dist/src/peer/peek.d.ts +79 -0
- package/dist/src/peer/peek.d.ts.map +1 -0
- package/dist/src/peer/peek.js +465 -0
- package/dist/src/peer/peek.js.map +1 -0
- package/dist/src/peer/peeks.d.ts +115 -0
- package/dist/src/peer/peeks.d.ts.map +1 -0
- package/dist/src/peer/peeks.js +262 -0
- package/dist/src/peer/peeks.js.map +1 -0
- package/dist/src/peer/protocol.d.ts +128 -4
- package/dist/src/peer/protocol.d.ts.map +1 -1
- package/dist/src/peer/protocol.js.map +1 -1
- package/dist/src/peer/questions.d.ts +168 -0
- package/dist/src/peer/questions.d.ts.map +1 -0
- package/dist/src/peer/questions.js +389 -0
- package/dist/src/peer/questions.js.map +1 -0
- package/dist/src/peer/relay.d.ts +14 -0
- package/dist/src/peer/relay.d.ts.map +1 -1
- package/dist/src/peer/relay.js +102 -7
- package/dist/src/peer/relay.js.map +1 -1
- package/dist/src/peer/roster.d.ts +2 -0
- package/dist/src/peer/roster.d.ts.map +1 -1
- package/dist/src/peer/roster.js +5 -0
- package/dist/src/peer/roster.js.map +1 -1
- package/dist/src/peer/share.d.ts +48 -1
- package/dist/src/peer/share.d.ts.map +1 -1
- package/dist/src/peer/share.js +167 -2
- package/dist/src/peer/share.js.map +1 -1
- package/dist/src/peer/state.d.ts +12 -1
- package/dist/src/peer/state.d.ts.map +1 -1
- package/dist/src/peer/state.js +15 -0
- package/dist/src/peer/state.js.map +1 -1
- package/dist/src/peer/transform.d.ts +32 -0
- package/dist/src/peer/transform.d.ts.map +1 -1
- package/dist/src/peer/transform.js +52 -33
- package/dist/src/peer/transform.js.map +1 -1
- package/dist/src/peer/transport.d.ts +7 -3
- package/dist/src/peer/transport.d.ts.map +1 -1
- package/dist/src/peer/transport.js +12 -4
- package/dist/src/peer/transport.js.map +1 -1
- package/dist/src/peer/viewer-html.d.ts +13 -1
- package/dist/src/peer/viewer-html.d.ts.map +1 -1
- package/dist/src/peer/viewer-html.js +328 -4
- package/dist/src/peer/viewer-html.js.map +1 -1
- package/dist/src/peer/viewer.d.ts +49 -1
- package/dist/src/peer/viewer.d.ts.map +1 -1
- package/dist/src/peer/viewer.js +133 -7
- package/dist/src/peer/viewer.js.map +1 -1
- package/dist/src/peer/wire.d.ts +9 -2
- package/dist/src/peer/wire.d.ts.map +1 -1
- package/dist/src/peer/wire.js.map +1 -1
- package/package.json +3 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/mcp.json +1 -1
- package/plugin/role-agents/lens-context/AGENT.md +8 -7
- package/plugin/role-agents/lens-reader/AGENT.md +48 -2
- package/plugin/skills/lens/SKILL.md +22 -15
package/dist/src/peer/daemon.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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: `열린 공유가 아니에요: ${
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
url: this.viewerUrl(w.id,
|
|
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({
|
|
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);
|