dsh-session-messenger 0.4.2
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/LICENSE +21 -0
- package/README.md +125 -0
- package/coordinator.js +504 -0
- package/cordis.patch.yml +5 -0
- package/delivery.js +209 -0
- package/index.js +290 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/negotiation.js +415 -0
- package/package.json +45 -0
- package/registry.js +197 -0
- package/schemas.generated.js +290 -0
- package/storage.js +205 -0
- package/tools.js +290 -0
- package/util.js +227 -0
- package/write-guard.js +82 -0
package/tools.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 四个 agent 工具的定义(裸定义对象 + 生成的 JSON Schema,零外部导入)。
|
|
3
|
+
* 所有 execute 永不抛出:错误以结构化结果返回;render 把模型需要的信息全部带出
|
|
4
|
+
* (模型只看得到 render 文本,detail 字段本身不进上下文——v0.3.1 真机验收教训)。
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import * as S from './schemas.generated.js';
|
|
8
|
+
import { callerInfo, iso, labelFor, normalizePaths, oneLine, shortId } from './util.js';
|
|
9
|
+
import { Delivery, isDirectory } from './delivery.js';
|
|
10
|
+
|
|
11
|
+
const MAX_PATHS = 50;
|
|
12
|
+
const MAX_CONTENT_CHARS = 20000;
|
|
13
|
+
const DEFAULT_TTL_SECONDS = 1800;
|
|
14
|
+
|
|
15
|
+
function clampTtl(seconds) {
|
|
16
|
+
const n = Number(seconds);
|
|
17
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TTL_SECONDS;
|
|
18
|
+
return Math.min(Math.max(Math.round(n), 30), 86400);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildTools({ registry, negotiations, delivery, coordinator, metrics }) {
|
|
22
|
+
const notReady = () => !registry.ready || (negotiations && !negotiations.ready);
|
|
23
|
+
const NOT_READY = 'session-messenger is still loading its state; retry in a moment';
|
|
24
|
+
|
|
25
|
+
const claimFiles = {
|
|
26
|
+
name: 'claim_files',
|
|
27
|
+
description:
|
|
28
|
+
'Register a claim on files you are about to modify so other live DSH sessions in this workspace can see it before editing. All-or-nothing: when another session already holds a live claim on ANY requested path, nothing is registered and the conflicts are returned (then use negotiate, or pick other files). Claims auto-expire after ttl_seconds (default 1800). Writes also auto-register short claims, but only a manual claim declares intent early and survives your own writes. Run release_files when done.',
|
|
29
|
+
parameters: S.CLAIM_FILES_PARAMETERS,
|
|
30
|
+
output: {
|
|
31
|
+
schema: S.CLAIM_FILES_OUTPUT,
|
|
32
|
+
render: (_args, v) => {
|
|
33
|
+
if (!v.ok) return [{ type: 'text', text: `claim_files failed: ${v.hint}` }];
|
|
34
|
+
if (!v.registered) {
|
|
35
|
+
const rows = v.conflicts.map((c) => `- ${c.path} ← ${c.ownerLabel} (session ${c.ownerSessionId}, ${c.ownerOrigin}, until ${iso(c.expiresAt)}, note: ${c.note || 'n/a'})`);
|
|
36
|
+
return [{ type: 'text', text: `CONFLICT: nothing registered.\n${rows.join('\n')}\n${v.hint}` }];
|
|
37
|
+
}
|
|
38
|
+
return [{ type: 'text', text: v.hint }];
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
execute: async (args, exec) => {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const fail = (sessionId, hint) => ({ ok: false, sessionId, registered: false, expiresAt: 0, conflicts: [], hint });
|
|
44
|
+
try {
|
|
45
|
+
const caller = callerInfo(exec, args && args.cwd);
|
|
46
|
+
if (notReady()) return fail(caller.sessionId, NOT_READY);
|
|
47
|
+
const rawPaths = Array.isArray(args && args.paths) ? args.paths : [];
|
|
48
|
+
if (rawPaths.length === 0) return fail(caller.sessionId, 'paths must be a non-empty array');
|
|
49
|
+
if (rawPaths.length > MAX_PATHS) return fail(caller.sessionId, `too many paths (${rawPaths.length}); split into chunks of at most ${MAX_PATHS}`);
|
|
50
|
+
const { entries, unresolvable } = normalizePaths(rawPaths, caller.cwd);
|
|
51
|
+
if (unresolvable.length > 0) {
|
|
52
|
+
return fail(caller.sessionId, `cannot resolve relative path(s) without a cwd: ${unresolvable.slice(0, 5).join(', ')}; pass absolute paths or cwd`);
|
|
53
|
+
}
|
|
54
|
+
const dirs = entries.filter((e) => isDirectory(e.path)).map((e) => e.path);
|
|
55
|
+
if (dirs.length > 0) return fail(caller.sessionId, `claims are per file; these are directories: ${dirs.slice(0, 5).join(', ')}`);
|
|
56
|
+
const result = registry.claim({
|
|
57
|
+
sessionId: caller.sessionId,
|
|
58
|
+
parent: caller.parent,
|
|
59
|
+
label: labelFor(caller.sessionId, caller.cwd),
|
|
60
|
+
cwd: caller.cwd,
|
|
61
|
+
sessionOrigin: caller.origin,
|
|
62
|
+
entries,
|
|
63
|
+
ttlSeconds: clampTtl(args && args.ttl_seconds),
|
|
64
|
+
note: oneLine(args && args.note, 300),
|
|
65
|
+
origin: 'manual',
|
|
66
|
+
now
|
|
67
|
+
});
|
|
68
|
+
if (!result.registered) {
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
sessionId: caller.sessionId,
|
|
72
|
+
registered: false,
|
|
73
|
+
expiresAt: 0,
|
|
74
|
+
conflicts: result.conflicts,
|
|
75
|
+
hint: 'Do not edit these files now. Open a negotiation as the writer, e.g. negotiate({action:"offer", path:"<path>", terms:{action:"wait-until", at:"<ISO within 60m>"}}), or work on other files.'
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
await coordinator.persistClaims();
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
sessionId: caller.sessionId,
|
|
82
|
+
registered: true,
|
|
83
|
+
expiresAt: result.expiresAt,
|
|
84
|
+
conflicts: [],
|
|
85
|
+
hint: `Claim registered for ${entries.length} path(s) until ${iso(result.expiresAt)}. Run release_files when done.${
|
|
86
|
+
result.overlaps && result.overlaps.length
|
|
87
|
+
? ` Note: recently edited by other session(s) without a claim: ${result.overlaps
|
|
88
|
+
.slice(0, 5)
|
|
89
|
+
.map((o) => `${o.path} ← ${o.ownerLabel} (session ${o.ownerSessionId})`)
|
|
90
|
+
.join('; ')}.`
|
|
91
|
+
: ''
|
|
92
|
+
}`
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return fail('', String((error && error.message) || error));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const releaseFiles = {
|
|
101
|
+
name: 'release_files',
|
|
102
|
+
description: "Release file claims held by THIS session. Omit paths to release all of this session's claims.",
|
|
103
|
+
parameters: S.RELEASE_FILES_PARAMETERS,
|
|
104
|
+
output: {
|
|
105
|
+
schema: S.RELEASE_FILES_OUTPUT,
|
|
106
|
+
render: (_args, v) => [{ type: 'text', text: v.ok ? `Released ${v.released} claim(s). ${v.hint}` : `release_files failed: ${v.hint}` }]
|
|
107
|
+
},
|
|
108
|
+
execute: async (args, exec) => {
|
|
109
|
+
try {
|
|
110
|
+
const caller = callerInfo(exec, args && args.cwd);
|
|
111
|
+
if (notReady()) return { ok: false, sessionId: caller.sessionId, released: 0, hint: NOT_READY };
|
|
112
|
+
const hasPaths = Array.isArray(args && args.paths) && args.paths.length > 0;
|
|
113
|
+
const { entries, unresolvable } = normalizePaths(hasPaths ? args.paths : [], caller.cwd);
|
|
114
|
+
if (hasPaths && entries.length === 0) {
|
|
115
|
+
return { ok: false, sessionId: caller.sessionId, released: 0, hint: `no resolvable paths: ${unresolvable.slice(0, 5).join(', ')}` };
|
|
116
|
+
}
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const keys = hasPaths
|
|
119
|
+
? entries.map((e) => e.key)
|
|
120
|
+
: registry.listLive(now).filter((c) => c.sessionId === caller.sessionId).map((c) => c.key);
|
|
121
|
+
const released = registry.release(caller.sessionId, hasPaths ? keys : null);
|
|
122
|
+
if (released > 0) {
|
|
123
|
+
// 有人排队的文件直接交给队首(v0.4.2),不留「谁抢到算谁的」窗口。
|
|
124
|
+
coordinator.handleReleased(caller.sessionId, keys, now);
|
|
125
|
+
await coordinator.persistClaims();
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
sessionId: caller.sessionId,
|
|
130
|
+
released,
|
|
131
|
+
hint: released > 0 ? 'Files with queued writers were handed to the next one in line; other negotiations resolve at the next watchdog tick.' : 'no matching claims'
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { ok: false, sessionId: '', released: 0, hint: String((error && error.message) || error) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const sendToSession = {
|
|
140
|
+
name: 'send_to_session',
|
|
141
|
+
description:
|
|
142
|
+
"Send a message to ANOTHER live DSH session working in the same workspace. It arrives as a peer message (not a user instruction) in the target's chat. mode 'queue' (default) waits for the target's current turn to end; 'steer' inserts it into the target's current step. Either mode starts a turn when the target is idle, so send only when you need a reply. Rate limited. Use negotiate (not this tool) to settle a claim conflict.",
|
|
143
|
+
parameters: S.SEND_TO_SESSION_PARAMETERS,
|
|
144
|
+
output: {
|
|
145
|
+
schema: S.SEND_TO_SESSION_OUTPUT,
|
|
146
|
+
render: (_args, v) => [
|
|
147
|
+
{ type: 'text', text: v.ok ? `Message ${v.mode === 'steer' ? 'steered into' : 'queued into'} session ${v.target} (${v.detail}).` : `send_to_session failed: ${v.detail}` }
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
execute: async (args, exec) => {
|
|
151
|
+
const mode = args && args.mode === 'steer' ? 'steer' : 'queue';
|
|
152
|
+
const rawTarget = String((args && args.target) || '').trim();
|
|
153
|
+
const respond = (ok, target, delivered, detail) => ({ ok, target, mode, delivered, detail });
|
|
154
|
+
try {
|
|
155
|
+
const caller = callerInfo(exec);
|
|
156
|
+
const content = String((args && args.content) || '').trim();
|
|
157
|
+
if (!rawTarget) return respond(false, '', false, 'target is required');
|
|
158
|
+
if (!content) return respond(false, rawTarget, false, 'content is required');
|
|
159
|
+
if (content.length > MAX_CONTENT_CHARS) return respond(false, rawTarget, false, `content too long (${content.length} > ${MAX_CONTENT_CHARS} chars)`);
|
|
160
|
+
const live = delivery.liveSessions();
|
|
161
|
+
let match = live.find((s) => s.id === rawTarget);
|
|
162
|
+
if (!match && rawTarget.length >= 4) {
|
|
163
|
+
const prefixed = live.filter((s) => s.id.startsWith(rawTarget) || s.id.replace(/^session-/, '').startsWith(rawTarget));
|
|
164
|
+
if (prefixed.length > 1) {
|
|
165
|
+
return respond(false, rawTarget, false, `ambiguous id prefix; candidates: ${JSON.stringify(delivery.candidates(caller.sessionId, caller.cwd).filter((c) => prefixed.some((p) => p.id === c.sessionId)))}`);
|
|
166
|
+
}
|
|
167
|
+
match = prefixed[0];
|
|
168
|
+
}
|
|
169
|
+
if (!match || !delivery.reachable(caller.cwd, match.cwd)) {
|
|
170
|
+
return respond(false, rawTarget, false, `no reachable live session matches "${rawTarget}". Reachable sessions: ${JSON.stringify(delivery.candidates(caller.sessionId, caller.cwd))}`);
|
|
171
|
+
}
|
|
172
|
+
if (match.id === caller.sessionId) return respond(false, match.id, false, 'target resolves to the calling session; self-send is a no-op');
|
|
173
|
+
const limited = delivery.checkRate(caller.sessionId, match.id);
|
|
174
|
+
if (limited) return respond(false, match.id, false, limited);
|
|
175
|
+
const corr = randomUUID().slice(0, 8);
|
|
176
|
+
const text = Delivery.manualEnvelope({ fromId: caller.sessionId, fromCwd: caller.cwd, corr, topic: args && args.topic, mode, content });
|
|
177
|
+
await delivery.prompt(match.id, mode, text, exec && exec.signal);
|
|
178
|
+
return respond(true, match.id, true, `corr=${corr}; the target replies via its own send_to_session`);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return respond(false, rawTarget, false, String((error && error.message) || error));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const negotiate = {
|
|
186
|
+
name: 'negotiate',
|
|
187
|
+
description:
|
|
188
|
+
"Settle a file-claim conflict with another session through structured, multi-round offers. While a negotiation is open the writer's writes to that file are frozen. 'offer'/'counter' propose terms (only the current claim HOLDER may offer release-now / release-at; the WRITER may offer wait-until); 'accept' accepts the peer's pending offer and the plugin executes it; 'decline' ends it; 'escalate' asks for human arbitration; 'status' shows the negotiations on a path. Silence is NOT consent: without a reply before the deadline it escalates to the user and the holder keeps the claim.",
|
|
189
|
+
parameters: S.NEGOTIATE_PARAMETERS,
|
|
190
|
+
output: {
|
|
191
|
+
schema: S.NEGOTIATE_OUTPUT,
|
|
192
|
+
render: (_args, v) => [
|
|
193
|
+
{ type: 'text', text: `negotiate ${v.ok ? 'ok' : 'rejected'}${v.negId ? ` [neg ${v.negId}]` : ''}: ${v.detail}` }
|
|
194
|
+
]
|
|
195
|
+
},
|
|
196
|
+
execute: async (args, exec) => {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const respond = (ok, neg, released, detail) => ({
|
|
199
|
+
ok,
|
|
200
|
+
negId: neg ? neg.id : '',
|
|
201
|
+
path: neg ? neg.path : String((args && args.path) || ''),
|
|
202
|
+
state: neg ? neg.state : '',
|
|
203
|
+
rounds: neg ? neg.rounds : 0,
|
|
204
|
+
deadline: neg ? neg.deadline : 0,
|
|
205
|
+
released: !!released,
|
|
206
|
+
detail
|
|
207
|
+
});
|
|
208
|
+
if (!negotiations) return respond(false, null, false, 'negotiation layer is disabled (env DSH_SESSION_MESSENGER_NEGOTIATE=0)');
|
|
209
|
+
try {
|
|
210
|
+
const caller = callerInfo(exec);
|
|
211
|
+
if (notReady()) return respond(false, null, false, NOT_READY);
|
|
212
|
+
const action = String((args && args.action) || '').trim();
|
|
213
|
+
const { entries } = normalizePaths([args && args.path], caller.cwd);
|
|
214
|
+
const entry = entries[0];
|
|
215
|
+
if (!entry) return respond(false, null, false, 'path could not be resolved to an absolute path');
|
|
216
|
+
|
|
217
|
+
if (action === 'status') return respond(true, null, false, statusDetail(caller, entry, now));
|
|
218
|
+
|
|
219
|
+
let neg;
|
|
220
|
+
const corr = String((args && args.corr) || '').trim();
|
|
221
|
+
if (corr) {
|
|
222
|
+
neg = negotiations.byId(corr);
|
|
223
|
+
if (!neg) return respond(false, null, false, `unknown corr "${corr}"`);
|
|
224
|
+
if (neg.key !== entry.key) return respond(false, neg, false, `corr ${neg.id} belongs to ${neg.path}, not ${entry.path}`);
|
|
225
|
+
} else {
|
|
226
|
+
const open = negotiations.openForSession(caller.sessionId).filter((n) => n.key === entry.key);
|
|
227
|
+
if (open.length > 1) return respond(false, null, false, `multiple open negotiations for this path; pass corr (${open.map((n) => n.id).join(', ')})`);
|
|
228
|
+
neg = open[0];
|
|
229
|
+
}
|
|
230
|
+
if (!neg && (action === 'offer' || action === 'counter')) {
|
|
231
|
+
const holder = registry.othersOn(entry.key, caller.sessionId, now, caller.parent).find((c) => c.origin === 'manual');
|
|
232
|
+
if (!holder) return respond(false, null, false, `no manual claim by another session on ${entry.path}; nothing to negotiate (recent edits by others do not block you — claim_files it yourself)`);
|
|
233
|
+
neg = coordinator.openNegotiation(entry, holder.sessionId, holder.label, caller.sessionId, labelFor(caller.sessionId, caller.cwd), now, {
|
|
234
|
+
parent: caller.parent,
|
|
235
|
+
cwd: caller.cwd,
|
|
236
|
+
origin: caller.origin
|
|
237
|
+
}).neg;
|
|
238
|
+
if (!neg) return respond(false, null, false, 'could not open a negotiation');
|
|
239
|
+
// 不在此处单独通知持有方:下面的 offer 转移会带着条款通知一次,避免重复打扰。
|
|
240
|
+
}
|
|
241
|
+
if (!neg) return respond(false, null, false, `no open negotiation for ${entry.path}; use status, or wait for a conflict notice`);
|
|
242
|
+
if (neg.a !== caller.sessionId && neg.b !== caller.sessionId) return respond(false, neg, false, 'you are not a party of this negotiation');
|
|
243
|
+
|
|
244
|
+
const waitMs = negotiations.rateLimited(neg.id, caller.sessionId, now);
|
|
245
|
+
if (waitMs > 0) return respond(false, neg, false, `rate limited: one intent per ${negotiations.rateMs}ms per party; retry in ${Math.ceil(waitMs / 1000)}s`);
|
|
246
|
+
const result = negotiations.transition(neg, { by: caller.sessionId, action, terms: args && args.terms, message: args && args.message, now });
|
|
247
|
+
if (!result.ok) {
|
|
248
|
+
negotiations.refundRate(neg.id, caller.sessionId);
|
|
249
|
+
return respond(false, neg, false, result.reason || 'transition rejected');
|
|
250
|
+
}
|
|
251
|
+
const effects = result.effects || {};
|
|
252
|
+
const released = coordinator.applyEffects(neg, effects, now);
|
|
253
|
+
coordinator.persistNegotiations();
|
|
254
|
+
if (effects.notifyPeer) coordinator.notifyNegotiation(neg, effects.notifyPeer);
|
|
255
|
+
return respond(true, neg, released, summaryOf(neg, released));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return respond(false, null, false, String((error && error.message) || error));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const summaryOf = (neg, released) => {
|
|
263
|
+
const queueNote = neg.state === 'queued' ? ` queue-position=${negotiations.positionOf(neg, Date.now())}` : '';
|
|
264
|
+
const handoff = neg.handoffTo ? ` handoff-to=${shortId(neg.handoffTo)}` : '';
|
|
265
|
+
const offer = neg.lastOffer && neg.lastOffer.terms ? ` lastOffer=${neg.lastOffer.terms.action}${neg.lastOffer.terms.at ? '@' + iso(neg.lastOffer.terms.at) : ''} by ${shortId(neg.lastOffer.by)}` : '';
|
|
266
|
+
const pending = neg.pendingReleaseAt ? ` scheduled-handoff=${iso(neg.pendingReleaseAt)}` : neg.pendingWakeAt ? ` writer-wake=${iso(neg.pendingWakeAt)}` : '';
|
|
267
|
+
return `state=${neg.state}${queueNote} round=${neg.rounds}/${negotiations.maxRounds}${neg.resolution ? ` (${neg.resolution})` : ''}${offer}${handoff}${released ? ' claim-handed-over' : ''}${pending} deadline=${iso(neg.deadline)}`;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const statusDetail = (caller, entry, now) => {
|
|
271
|
+
const mine = [...negotiations.negotiations.values()].filter((n) => n.key === entry.key && (n.a === caller.sessionId || n.b === caller.sessionId));
|
|
272
|
+
const rows = mine
|
|
273
|
+
.sort((x, y) => y.updatedAt - x.updatedAt)
|
|
274
|
+
.slice(0, 5)
|
|
275
|
+
.map((n) => {
|
|
276
|
+
const peer = n.a === caller.sessionId ? n.b : n.a;
|
|
277
|
+
const you = caller.sessionId === n.holder ? 'holder' : 'writer';
|
|
278
|
+
return `${n.id} you=${you} peer=${(n.labels && n.labels[peer]) || shortId(peer)} (session ${peer}) ${summaryOf(n, false)}`;
|
|
279
|
+
});
|
|
280
|
+
const others = registry.othersOn(entry.key, caller.sessionId, now, caller.parent);
|
|
281
|
+
const claimLine = others.length
|
|
282
|
+
? `live claims by others: ${others.map((c) => `${c.label} (session ${c.sessionId}, ${c.origin}, until ${iso(c.expiresAt)})`).join('; ')}`
|
|
283
|
+
: 'no live claim by another session';
|
|
284
|
+
const plugin = typeof coordinator.statusText === 'function' ? ` [${coordinator.statusText()}]` : '';
|
|
285
|
+
return `${rows.length ? rows.join(' | ') : 'no negotiation on this path involving you'}; ${claimLine}${plugin}`;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
void metrics;
|
|
289
|
+
return [claimFiles, releaseFiles, sendToSession, negotiate];
|
|
290
|
+
}
|
package/util.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯工具函数:身份/标签、路径归一化与身份键、调用方解析、节流、信封转义。
|
|
3
|
+
* 本文件零副作用、零宿主依赖,便于单测直接覆盖。
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { realpathSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
export const ENVELOPE_TAG = '[[DSH session-messenger';
|
|
10
|
+
|
|
11
|
+
/** 会话 id 形如 "session-<uuid>"(持久会话)或裸 "<uuid>"(子代理会话);剥前缀再截。 */
|
|
12
|
+
export function shortId(id) {
|
|
13
|
+
return String(id || '')
|
|
14
|
+
.replace(/^session-/, '')
|
|
15
|
+
.slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function labelFor(id, cwd) {
|
|
19
|
+
const base = cwd ? path.basename(String(cwd)) : '';
|
|
20
|
+
return base ? `${shortId(id)} @ ${base}` : shortId(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 旧版 label 的已知坏形状("session- @ x")需要按当前规则重算。 */
|
|
24
|
+
export function isLegacyLabel(label) {
|
|
25
|
+
return typeof label !== 'string' || !label || /^session-(\s|$)/.test(label);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 路径身份键:与宿主 dsh-fs-local 的 targetKey 同算法——存在则 realpath,
|
|
30
|
+
* 不存在则「最近存在祖先的 realpath + 缺失后缀」。用于跨符号链接(如 /tmp → /private/tmp)
|
|
31
|
+
* 识别同一文件。任何异常都回退为规范化的绝对路径,绝不抛出。
|
|
32
|
+
*/
|
|
33
|
+
export function pathKey(absPath) {
|
|
34
|
+
const normalized = path.normalize(String(absPath || ''));
|
|
35
|
+
if (!path.isAbsolute(normalized)) return normalized;
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync.native(normalized);
|
|
38
|
+
} catch {
|
|
39
|
+
/* fall through to ancestor walk */
|
|
40
|
+
}
|
|
41
|
+
const missing = [path.basename(normalized)];
|
|
42
|
+
let ancestor = path.dirname(normalized);
|
|
43
|
+
for (let guard = 0; guard < 256; guard += 1) {
|
|
44
|
+
try {
|
|
45
|
+
return path.join(realpathSync.native(ancestor), ...missing);
|
|
46
|
+
} catch {
|
|
47
|
+
const parent = path.dirname(ancestor);
|
|
48
|
+
if (parent === ancestor) return normalized;
|
|
49
|
+
missing.unshift(path.basename(ancestor));
|
|
50
|
+
ancestor = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 把用户给的路径(绝对 / ~/ / 相对 cwd)解析为绝对路径;无法解析返回 ''。 */
|
|
57
|
+
export function toAbsolute(raw, cwd) {
|
|
58
|
+
const value = String(raw || '').trim();
|
|
59
|
+
if (!value) return '';
|
|
60
|
+
if (value === '~' || value.startsWith('~/')) return path.normalize(path.join(os.homedir(), value.slice(1)));
|
|
61
|
+
if (path.isAbsolute(value)) return path.normalize(value);
|
|
62
|
+
if (cwd && path.isAbsolute(String(cwd))) return path.resolve(String(cwd), value);
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 批量归一化并去重(按身份键去重)。 */
|
|
67
|
+
export function normalizePaths(rawPaths, cwd) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const unresolvable = [];
|
|
71
|
+
for (const raw of rawPaths || []) {
|
|
72
|
+
const abs = toAbsolute(raw, cwd);
|
|
73
|
+
if (!abs) {
|
|
74
|
+
if (String(raw || '').trim()) unresolvable.push(String(raw));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const key = pathKey(abs);
|
|
78
|
+
if (seen.has(key)) continue;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
out.push({ path: abs, key });
|
|
81
|
+
}
|
|
82
|
+
return { entries: out, unresolvable };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** abs 是否位于 root 之内(含 root 本身)。 */
|
|
86
|
+
export function isWithin(abs, root) {
|
|
87
|
+
if (!abs || !root) return false;
|
|
88
|
+
const rel = path.relative(String(root), String(abs));
|
|
89
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 从会话对象读 header。 */
|
|
93
|
+
export function headerOf(session) {
|
|
94
|
+
return (session && session.header) || {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 会话来源:'subagent'(宿主子代理路由所有,可被证明已销毁)或 'session'(持久会话)。 */
|
|
98
|
+
export function originOfHeader(header, id) {
|
|
99
|
+
if (header && (header.origin === 'subagent' || header.parentSession !== undefined)) return 'subagent';
|
|
100
|
+
if (header && (header.origin !== undefined || header.cwd !== undefined)) return 'session';
|
|
101
|
+
// 无 header 时按 id 形状推断:持久会话带 "session-" 前缀,子代理为裸 uuid(本机实测)。
|
|
102
|
+
return /^session-/.test(String(id || '')) ? 'session' : 'subagent';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 调用方身份:sessionId 必须可解析,否则明确报错(不静默猜)。 */
|
|
106
|
+
export function callerInfo(exec, cwdOverride) {
|
|
107
|
+
const agent = exec && exec.agent;
|
|
108
|
+
const header = headerOf(agent && agent.session);
|
|
109
|
+
const sessionId = (agent && agent.id) || header.id || (exec && exec.sessionId);
|
|
110
|
+
if (!sessionId) throw new Error('caller session id unavailable (tool executed without an agent context)');
|
|
111
|
+
const override = cwdOverride ? String(cwdOverride) : '';
|
|
112
|
+
const cwd =
|
|
113
|
+
(override && path.isAbsolute(override) ? override : '') ||
|
|
114
|
+
header.cwd ||
|
|
115
|
+
(agent && agent.options && agent.options.cwd) ||
|
|
116
|
+
'';
|
|
117
|
+
return {
|
|
118
|
+
sessionId: String(sessionId),
|
|
119
|
+
cwd: cwd ? String(cwd) : '',
|
|
120
|
+
origin: originOfHeader(header, sessionId),
|
|
121
|
+
parent: header.parentSession ? String(header.parentSession) : ''
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 父子会话视为同一协作单元:父会话占用的文件委派给子代理改(或反之、或兄弟子代理之间)
|
|
127
|
+
* 都不算冲突——否则争议冻结会把被委派的子代理挡在自己的任务之外。
|
|
128
|
+
*/
|
|
129
|
+
export function sameFamily(aId, aParent, bId, bParent) {
|
|
130
|
+
if (!aId || !bId) return false;
|
|
131
|
+
if (aId === bId) return true;
|
|
132
|
+
if (aParent && aParent === bId) return true;
|
|
133
|
+
if (bParent && bParent === aId) return true;
|
|
134
|
+
return !!aParent && aParent === bParent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* fs 意图事件信息(形状依据宿主源码):target = FsTarget { targetKey, displayPath },
|
|
139
|
+
* displayPath 相对 cwd 优先(工作区内 posix 相对 / "~/" / 绝对);actor = ToolExecution(.agent)。
|
|
140
|
+
*/
|
|
141
|
+
export function intentInfo(target, actor) {
|
|
142
|
+
if (!target || typeof target !== 'object') return undefined;
|
|
143
|
+
const display = typeof target.displayPath === 'string' ? target.displayPath.trim() : '';
|
|
144
|
+
if (!display) return undefined;
|
|
145
|
+
const agent = actor && typeof actor === 'object' ? actor.agent : undefined;
|
|
146
|
+
const sessionId = agent && typeof agent.id === 'string' ? agent.id : '';
|
|
147
|
+
const header = headerOf(agent && agent.session);
|
|
148
|
+
const cwd = header.cwd || '';
|
|
149
|
+
const abs = toAbsolute(display, cwd);
|
|
150
|
+
if (!abs) return undefined; // 无 cwd 无法定位:宁缺毋滥
|
|
151
|
+
return {
|
|
152
|
+
absPath: abs,
|
|
153
|
+
key: pathKey(abs),
|
|
154
|
+
sessionId,
|
|
155
|
+
cwd,
|
|
156
|
+
origin: originOfHeader(header, sessionId),
|
|
157
|
+
parent: header.parentSession ? String(header.parentSession) : ''
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 时间窗节流:同一 key 在 windowMs 内只放行第一次。
|
|
163
|
+
* 容量超限时淘汰过期项(而非整表清空——整表清空会让去重/冷却在突发时失效)。
|
|
164
|
+
*/
|
|
165
|
+
export function createThrottle({ max = 1024, now = Date.now } = {}) {
|
|
166
|
+
const map = new Map();
|
|
167
|
+
const evict = (t) => {
|
|
168
|
+
for (const [key, entry] of map) {
|
|
169
|
+
if (t - entry.at >= entry.window) map.delete(key);
|
|
170
|
+
}
|
|
171
|
+
while (map.size >= max) map.delete(map.keys().next().value);
|
|
172
|
+
};
|
|
173
|
+
const allow = (key, windowMs) => {
|
|
174
|
+
const t = now();
|
|
175
|
+
const entry = map.get(key);
|
|
176
|
+
if (entry && t - entry.at < windowMs) return false;
|
|
177
|
+
if (map.size >= max) evict(t);
|
|
178
|
+
map.delete(key);
|
|
179
|
+
map.set(key, { at: t, window: windowMs });
|
|
180
|
+
return true;
|
|
181
|
+
};
|
|
182
|
+
allow.size = () => map.size;
|
|
183
|
+
return allow;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 滑动窗口计数限速:windowMs 内最多 limit 次。返回 { ok, retryInMs }。 */
|
|
187
|
+
export function createWindowLimiter({ max = 1024, now = Date.now } = {}) {
|
|
188
|
+
const map = new Map();
|
|
189
|
+
return (key, limit, windowMs) => {
|
|
190
|
+
const t = now();
|
|
191
|
+
const hits = (map.get(key) || []).filter((at) => t - at < windowMs);
|
|
192
|
+
if (hits.length >= limit) {
|
|
193
|
+
map.set(key, hits);
|
|
194
|
+
return { ok: false, retryInMs: windowMs - (t - hits[0]) };
|
|
195
|
+
}
|
|
196
|
+
hits.push(t);
|
|
197
|
+
map.delete(key);
|
|
198
|
+
map.set(key, hits);
|
|
199
|
+
if (map.size > max) map.delete(map.keys().next().value);
|
|
200
|
+
return { ok: true, retryInMs: 0 };
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 中和正文里的信封标记:对方可能在正文伪造 "[[DSH session-messenger · auto]]"
|
|
206
|
+
* 冒充插件自动告警。任何出现都改写为不可混淆的引用形态。
|
|
207
|
+
*/
|
|
208
|
+
export function neutralizeEnvelope(text) {
|
|
209
|
+
return String(text || '').replace(/\[\[\s*DSH\s+session-messenger/gi, '[quoted: DSH session-messenger');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 单行化:topic/label 等头部字段禁止换行(否则可伪造头部行)。 */
|
|
213
|
+
export function oneLine(text, maxLen) {
|
|
214
|
+
const flat = neutralizeEnvelope(String(text || '')).replace(/[\r\n\u2028\u2029]+/g, ' ').trim();
|
|
215
|
+
return maxLen ? flat.slice(0, maxLen) : flat;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function iso(ms) {
|
|
219
|
+
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : 'n/a';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function envNumber(name, fallback, { min = 0 } = {}) {
|
|
223
|
+
const raw = process.env[name];
|
|
224
|
+
if (raw == null || raw === '') return fallback;
|
|
225
|
+
const n = Number(raw);
|
|
226
|
+
return Number.isFinite(n) && n >= min ? n : fallback;
|
|
227
|
+
}
|
package/write-guard.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写入守卫(H1 + M1):用宿主 ctx.tools.guard()(同步、单调、与注册顺序无关)拦截写入,
|
|
3
|
+
* 取代原先返回非契约形状 {action:'deny'} 的 tools/pre-execute 全局闸门。
|
|
4
|
+
*
|
|
5
|
+
* 冻结策略(最小误伤面):
|
|
6
|
+
* - 默认模式 'dispute':只冻结「争议中」的单个路径——存在 open 协商,或冷静期内 escalated 的协商;
|
|
7
|
+
* 持有方本人永不被冻结。未发生争议的普通 claim 不拦(第一次撞册仍是 写入+开协商桌)。
|
|
8
|
+
* - 'claims':额外冻结他人的手动 claim(origin=manual)——更强,适合明确要求「先谈后改」的团队。
|
|
9
|
+
* - 'off':不拦。
|
|
10
|
+
* - 自动 claim(origin=auto)永不作为拦截依据:写过一次就锁住别人 = 误伤。v0.4.1 起由
|
|
11
|
+
* registry 保证协商只对手动 claim 开桌,这里再以 holdsManual 兜底(旧版遗留的协商记录也不生效)。
|
|
12
|
+
*
|
|
13
|
+
* 覆盖面:write / edit / str_replace_editor(create/str_replace/insert)。
|
|
14
|
+
* 路径解析:相对路径按调用会话 cwd 解析,统一 realpath 身份键比对(与宿主 targetKey 同算法),
|
|
15
|
+
* 符号链接无法绕过。bash 子进程直写不经文件服务,仍是已知盲区(README 已声明)。
|
|
16
|
+
*/
|
|
17
|
+
import { callerInfo, pathKey, toAbsolute, iso, sameFamily } from './util.js';
|
|
18
|
+
|
|
19
|
+
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
20
|
+
const SRE_MUTATING = new Set(['create', 'str_replace', 'insert']);
|
|
21
|
+
|
|
22
|
+
/** 从一次工具调用里取出将被写入的绝对路径与调用方;非写入调用返回 undefined。 */
|
|
23
|
+
export function writeTargetOf(exec) {
|
|
24
|
+
if (!exec || typeof exec.name !== 'string') return undefined;
|
|
25
|
+
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
|
26
|
+
let raw = '';
|
|
27
|
+
if (WRITE_TOOLS.has(exec.name)) raw = args.file_path;
|
|
28
|
+
else if (exec.name === 'str_replace_editor' && SRE_MUTATING.has(args.command)) raw = args.path;
|
|
29
|
+
if (typeof raw !== 'string' || !raw.trim()) return undefined;
|
|
30
|
+
let caller;
|
|
31
|
+
try {
|
|
32
|
+
caller = callerInfo(exec);
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined; // 无 agent 上下文的调用不归属任何会话,不拦
|
|
35
|
+
}
|
|
36
|
+
const abs = toAbsolute(raw, caller.cwd);
|
|
37
|
+
return abs ? { abs, caller } : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createWriteGuard({ registry, negotiations, mode, metrics, logger, onDeny }) {
|
|
41
|
+
return (exec) => {
|
|
42
|
+
try {
|
|
43
|
+
if (mode === 'off') return undefined;
|
|
44
|
+
const target = writeTargetOf(exec);
|
|
45
|
+
if (!target) return undefined;
|
|
46
|
+
const { abs, caller } = target;
|
|
47
|
+
const callerId = caller.sessionId;
|
|
48
|
+
const key = pathKey(abs);
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
// 只有持有方仍持有**手动** claim 时争议才冻结写入:自动 claim 只是「最近写过」,不是占用声明。
|
|
51
|
+
const holderHolds = (holderId, k) => registry.holdsManual(holderId, k, now);
|
|
52
|
+
const found = negotiations ? negotiations.frozenFor(key, callerId, now, holderHolds) : undefined;
|
|
53
|
+
const neg = found && !sameFamily(callerId, caller.parent, found.holder, (registry.own(found.holder, key) || {}).parent) ? found : undefined;
|
|
54
|
+
if (neg) {
|
|
55
|
+
metrics.denies += 1;
|
|
56
|
+
if (onDeny) onDeny();
|
|
57
|
+
const holder = (neg.labels && neg.labels[neg.holder]) || neg.holder;
|
|
58
|
+
return `"${abs}" is FROZEN while a claim dispute is ${neg.state} (neg ${neg.id}, holder ${holder}). Do not write it now: reply with negotiate({action:"status", path:"${abs}"}) to see the terms, or work on other files. The freeze lifts when the holder releases or the negotiation is accepted.`;
|
|
59
|
+
}
|
|
60
|
+
if (mode === 'claims') {
|
|
61
|
+
const hit = registry.othersOn(key, callerId, now, caller.parent).find((c) => c.origin === 'manual');
|
|
62
|
+
if (hit) {
|
|
63
|
+
metrics.denies += 1;
|
|
64
|
+
if (onDeny) onDeny();
|
|
65
|
+
return `"${abs}" is claimed by ${hit.label} (session ${hit.sessionId}, until ${iso(hit.expiresAt)}, note: ${hit.note || 'n/a'}). Negotiate first: negotiate({action:"offer", path:"${abs}", terms:{action:"wait-until", at:"<ISO within 60m>"}}), or work on other files.`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// 守卫绝不能因自身缺陷拦截或打断宿主:任何异常一律放行并记录。
|
|
71
|
+
logger?.warn?.(`[session-messenger] write guard failed (allowing): ${(error && error.message) || error}`);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function guardMode(config) {
|
|
78
|
+
const env = process.env.DSH_SESSION_MESSENGER_GUARD;
|
|
79
|
+
const legacy = process.env.DSH_SESSION_MESSENGER_HARD_GATE;
|
|
80
|
+
const pick = env || (config && typeof config.guard === 'string' ? config.guard : '') || (legacy === '1' ? 'claims' : legacy === '0' ? 'off' : '');
|
|
81
|
+
return pick === 'off' || pick === 'claims' || pick === 'dispute' ? pick : 'dispute';
|
|
82
|
+
}
|