@andares/pi-toolkits 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +53 -2
- package/src/features/autostart/index.ts +29 -0
- package/src/features/permission/index.test.ts +44 -2
- package/src/features/permission/index.ts +32 -5
- package/src/features/permission/modes.ts +10 -0
package/README.md
CHANGED
|
@@ -106,6 +106,8 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
|
|
|
106
106
|
- `/ask-perm allow allow allow ask` — positional form (`in-read in-write out-read out-write`), enables immediately without a dialog. Missing arguments fall back to the defaults. This is the form autostart uses.
|
|
107
107
|
- `/ask-perm` again — turns it off.
|
|
108
108
|
|
|
109
|
+
Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode notifies and clears those grants, because they were made under the old policy.
|
|
110
|
+
|
|
109
111
|
**Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
|
|
110
112
|
|
|
111
113
|
| Scope | reads | writes / exec |
|
|
@@ -307,6 +309,8 @@ No file = feature disabled, zero overhead, silently skipped.
|
|
|
307
309
|
|
|
308
310
|
The global list runs first, so a project can override an earlier setting (for example a later `/ask-perm` with different arguments). Use the global file only when you want the same commands in every project; per-project files remain the recommended default.
|
|
309
311
|
|
|
312
|
+
An **identical command line** declared by both sources runs once: a duplicate is skipped for that replay, so a global `/ask-perm ...` copied into a project does not run (or announce itself) twice. Different commands still both run, which is what keeps a project override working.
|
|
313
|
+
|
|
310
314
|
**When it runs**:
|
|
311
315
|
|
|
312
316
|
| session_start reason | Replayed | Why |
|
package/README.zh-CN.md
CHANGED
|
@@ -106,6 +106,8 @@ pi -e ./src/index.ts
|
|
|
106
106
|
- `/ask-perm allow allow allow ask` —— 位置参数形式(`in-read in-write out-read out-write`),不弹窗直接开启;没给全的参数回落到默认值。这也是 autostart 使用的形式
|
|
107
107
|
- 再输入一次 `/ask-perm` —— 关闭
|
|
108
108
|
|
|
109
|
+
参数形式重复执行、且四项模式与当前一致时是**空操作**:不会再出一条「ON」提示,也不会清掉已经攒下的会话授权。只有模式真的变了才会提示并清空授权——那些授权是按旧策略给的。
|
|
110
|
+
|
|
109
111
|
**默认值**:项目内读/写全放行,项目外读放行,项目外写询问。
|
|
110
112
|
|
|
111
113
|
| 作用域 | 读 | 写 / 执行 |
|
|
@@ -307,6 +309,8 @@ run = [
|
|
|
307
309
|
|
|
308
310
|
全局列表先执行,项目列表后执行,因此项目可以覆盖此前的设置(例如后面再执行一条不同参数的 `/ask-perm`)。只有希望所有项目都用同一套命令时才用全局文件;仍然建议以项目级配置为主。
|
|
309
311
|
|
|
312
|
+
**完全相同的命令行**若同时出现在两个来源,一次重放里只执行一次:重复项跳过,所以把全局的 `/ask-perm ...` 复制到项目里不会再跑一遍、也不会再提示一遍。不同的命令仍然各跑一次——这正是「项目覆盖全局」得以成立的原因。
|
|
313
|
+
|
|
310
314
|
**执行时机**:
|
|
311
315
|
|
|
312
316
|
| session_start reason | 是否重放 | 原因 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -145,7 +145,10 @@ type SessionStartHandler = (
|
|
|
145
145
|
cwd: string;
|
|
146
146
|
isProjectTrusted: () => boolean;
|
|
147
147
|
ui: { notify: (message: string, type?: string) => void };
|
|
148
|
-
sessionManager: {
|
|
148
|
+
sessionManager: {
|
|
149
|
+
buildContextEntries: () => Array<{ type: string }>;
|
|
150
|
+
getSessionId: () => string;
|
|
151
|
+
};
|
|
149
152
|
},
|
|
150
153
|
) => Promise<void>;
|
|
151
154
|
|
|
@@ -159,6 +162,7 @@ interface Harness {
|
|
|
159
162
|
setTrusted(trusted: boolean): void;
|
|
160
163
|
/** Simulate a session that already had a conversation (resumed process). */
|
|
161
164
|
setHistory(entries: Array<{ type: string }>): void;
|
|
165
|
+
setSessionId(id: string): void;
|
|
162
166
|
start(reason: string): Promise<void>;
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -190,6 +194,7 @@ async function makeHarness(
|
|
|
190
194
|
{ type: "model_change" },
|
|
191
195
|
{ type: "thinking_level_change" },
|
|
192
196
|
];
|
|
197
|
+
let sessionId = "session-1";
|
|
193
198
|
let handler: SessionStartHandler = async () => {};
|
|
194
199
|
const sent: Harness["sent"] = [];
|
|
195
200
|
const notifications: Harness["notifications"] = [];
|
|
@@ -226,6 +231,9 @@ async function makeHarness(
|
|
|
226
231
|
setHistory(entries) {
|
|
227
232
|
history = entries;
|
|
228
233
|
},
|
|
234
|
+
setSessionId(id) {
|
|
235
|
+
sessionId = id;
|
|
236
|
+
},
|
|
229
237
|
async start(reason: string) {
|
|
230
238
|
await handler({ reason }, {
|
|
231
239
|
cwd,
|
|
@@ -235,7 +243,10 @@ async function makeHarness(
|
|
|
235
243
|
notifications.push({ message, type });
|
|
236
244
|
},
|
|
237
245
|
},
|
|
238
|
-
sessionManager: {
|
|
246
|
+
sessionManager: {
|
|
247
|
+
buildContextEntries: () => history,
|
|
248
|
+
getSessionId: () => sessionId,
|
|
249
|
+
},
|
|
239
250
|
});
|
|
240
251
|
},
|
|
241
252
|
cwd,
|
|
@@ -286,6 +297,8 @@ describe("registerAutostart", () => {
|
|
|
286
297
|
it("replays on new and fork", async () => {
|
|
287
298
|
const h = await harness('[command]\nrun = ["/ask on"]');
|
|
288
299
|
await h.start("new");
|
|
300
|
+
// A fork is a different session, so it replays again.
|
|
301
|
+
h.setSessionId("forked-session");
|
|
289
302
|
await h.start("fork");
|
|
290
303
|
expect(h.sent).toEqual([
|
|
291
304
|
{ command: "/ask on", options: { expandPromptTemplates: true } },
|
|
@@ -302,6 +315,21 @@ describe("registerAutostart", () => {
|
|
|
302
315
|
expect(h.notifications).toEqual([]);
|
|
303
316
|
});
|
|
304
317
|
|
|
318
|
+
it("replays once per session even if session_start is emitted twice", async () => {
|
|
319
|
+
const h = await harness(fullConfig);
|
|
320
|
+
await h.start("startup");
|
|
321
|
+
await h.start("startup");
|
|
322
|
+
expect(h.sent).toHaveLength(2);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("replays again for a new session id", async () => {
|
|
326
|
+
const h = await harness(fullConfig);
|
|
327
|
+
await h.start("startup");
|
|
328
|
+
h.setSessionId("session-2");
|
|
329
|
+
await h.start("startup");
|
|
330
|
+
expect(h.sent).toHaveLength(4);
|
|
331
|
+
});
|
|
332
|
+
|
|
305
333
|
it("skips a process started on an existing session (pi -c reports startup)", async () => {
|
|
306
334
|
const h = await harness(fullConfig);
|
|
307
335
|
h.setHistory([
|
|
@@ -418,6 +446,29 @@ describe("registerAutostart", () => {
|
|
|
418
446
|
expect(h.notifications[0]?.type).toBe("warning");
|
|
419
447
|
});
|
|
420
448
|
|
|
449
|
+
it("runs an identical command only once across sources", async () => {
|
|
450
|
+
const h = await harness(
|
|
451
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
452
|
+
false,
|
|
453
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
454
|
+
);
|
|
455
|
+
await h.start("startup");
|
|
456
|
+
expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("still runs different commands from both sources", async () => {
|
|
460
|
+
const h = await harness(
|
|
461
|
+
"[command]\nrun = [\"/ask on\", \"/add-dir ../p\"]",
|
|
462
|
+
false,
|
|
463
|
+
"[command]\nrun = [\"/ask on\"]",
|
|
464
|
+
);
|
|
465
|
+
await h.start("startup");
|
|
466
|
+
expect(h.sent.map((entry) => entry.command)).toEqual([
|
|
467
|
+
"/ask on",
|
|
468
|
+
"/add-dir ../p",
|
|
469
|
+
]);
|
|
470
|
+
});
|
|
471
|
+
|
|
421
472
|
it("keeps running the project config when the global one is absent", async () => {
|
|
422
473
|
const h = await harness('[command]\nrun = ["/ask on"]', false, null);
|
|
423
474
|
await h.start("startup");
|
|
@@ -87,6 +87,15 @@ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
|
|
|
87
87
|
* session manager counts as new, which keeps the feature working in hosts where
|
|
88
88
|
* the session surface is unavailable.
|
|
89
89
|
*/
|
|
90
|
+
/** Session id, or `undefined` when the host exposes none. */
|
|
91
|
+
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
92
|
+
try {
|
|
93
|
+
return ctx.sessionManager.getSessionId();
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
function hasConversationHistory(ctx: ExtensionContext): boolean {
|
|
91
100
|
try {
|
|
92
101
|
return ctx.sessionManager
|
|
@@ -102,6 +111,11 @@ export function registerAutostart(
|
|
|
102
111
|
options: AutostartOptions = {},
|
|
103
112
|
): void {
|
|
104
113
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
114
|
+
// One replay per session. The host can emit `session_start` more than once
|
|
115
|
+
// for the same session (a runtime rebind during startup reports `startup`
|
|
116
|
+
// again), and replaying on the second emission would announce everything
|
|
117
|
+
// twice.
|
|
118
|
+
const replayedSessions = new Set<string>();
|
|
105
119
|
|
|
106
120
|
async function readCommands(
|
|
107
121
|
ctx: ExtensionContext,
|
|
@@ -140,6 +154,12 @@ export function registerAutostart(
|
|
|
140
154
|
// shouldReplay in autostart.ts for the host-side evidence.
|
|
141
155
|
if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
|
|
142
156
|
|
|
157
|
+
const sessionId = sessionIdOf(ctx);
|
|
158
|
+
if (sessionId !== undefined) {
|
|
159
|
+
if (replayedSessions.has(sessionId)) return;
|
|
160
|
+
replayedSessions.add(sessionId);
|
|
161
|
+
}
|
|
162
|
+
|
|
143
163
|
const sources: Source[] = [
|
|
144
164
|
{
|
|
145
165
|
path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
|
|
@@ -163,6 +183,13 @@ export function registerAutostart(
|
|
|
163
183
|
),
|
|
164
184
|
);
|
|
165
185
|
|
|
186
|
+
// The same line may be declared by both sources (global defaults plus a
|
|
187
|
+
// project copy). Running an identical command twice in one replay is
|
|
188
|
+
// never meaningful — a repeated `/ask-perm` would re-notify, and a
|
|
189
|
+
// repeated `/add-dir` is pure noise — so identical text runs once.
|
|
190
|
+
// Different commands still both run, which keeps the project's override.
|
|
191
|
+
const dispatched = new Set<string>();
|
|
192
|
+
|
|
166
193
|
for (const source of sources) {
|
|
167
194
|
const commands = await readCommands(ctx, source);
|
|
168
195
|
if (commands === undefined || commands.length === 0) continue;
|
|
@@ -184,6 +211,8 @@ export function registerAutostart(
|
|
|
184
211
|
);
|
|
185
212
|
continue;
|
|
186
213
|
}
|
|
214
|
+
if (dispatched.has(command)) continue;
|
|
215
|
+
dispatched.add(command);
|
|
187
216
|
// One failing command must not cancel the rest of the list.
|
|
188
217
|
try {
|
|
189
218
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -51,6 +51,7 @@ interface Harness {
|
|
|
51
51
|
readonly statuses: Array<[string, string | undefined]>;
|
|
52
52
|
readonly notes: Array<{ message: string; kind?: string }>;
|
|
53
53
|
readonly branch: Array<Record<string, unknown>>;
|
|
54
|
+
setSessionId(id: string): void;
|
|
54
55
|
evaluate(event: ToolCallEvent): Promise<unknown>;
|
|
55
56
|
command(args: string): Promise<void>;
|
|
56
57
|
sessionStart(): Promise<void>;
|
|
@@ -62,6 +63,7 @@ function makeHarness(): Harness {
|
|
|
62
63
|
const statuses: Harness["statuses"] = [];
|
|
63
64
|
const notes: Harness["notes"] = [];
|
|
64
65
|
const branch: Harness["branch"] = [];
|
|
66
|
+
let sessionId = "session-1";
|
|
65
67
|
const ctx = {
|
|
66
68
|
cwd,
|
|
67
69
|
hasUI: true,
|
|
@@ -73,7 +75,10 @@ function makeHarness(): Harness {
|
|
|
73
75
|
custom: async () => undefined,
|
|
74
76
|
select: async () => undefined,
|
|
75
77
|
},
|
|
76
|
-
sessionManager: {
|
|
78
|
+
sessionManager: {
|
|
79
|
+
getBranch: () => branch,
|
|
80
|
+
getSessionId: () => sessionId,
|
|
81
|
+
},
|
|
77
82
|
} as unknown as ExtensionContext;
|
|
78
83
|
|
|
79
84
|
const captured: ToolGate[] = [];
|
|
@@ -110,6 +115,9 @@ function makeHarness(): Harness {
|
|
|
110
115
|
statuses,
|
|
111
116
|
notes,
|
|
112
117
|
branch,
|
|
118
|
+
setSessionId(id: string) {
|
|
119
|
+
sessionId = id;
|
|
120
|
+
},
|
|
113
121
|
async evaluate(event) {
|
|
114
122
|
return gate.evaluate(event, ctx);
|
|
115
123
|
},
|
|
@@ -214,6 +222,25 @@ describe("/ask-perm", () => {
|
|
|
214
222
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
215
223
|
});
|
|
216
224
|
|
|
225
|
+
it("keeps the state when the same session emits session_start again", async () => {
|
|
226
|
+
const h = makeHarness();
|
|
227
|
+
await h.sessionStart(); // the real first emission
|
|
228
|
+
await h.command("allow allow allow ask");
|
|
229
|
+
// A startup rebind reports the same session again; state must survive.
|
|
230
|
+
await h.sessionStart();
|
|
231
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
|
|
232
|
+
"ask",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("resets on session_start for a different session", async () => {
|
|
237
|
+
const h = makeHarness();
|
|
238
|
+
await h.command("allow allow allow ask");
|
|
239
|
+
h.setSessionId("session-2");
|
|
240
|
+
await h.sessionStart();
|
|
241
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
217
244
|
it("resets on session_start", async () => {
|
|
218
245
|
const h = makeHarness();
|
|
219
246
|
await h.command("allow allow allow ask");
|
|
@@ -269,15 +296,30 @@ describe("/ask-perm", () => {
|
|
|
269
296
|
expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
|
|
270
297
|
});
|
|
271
298
|
|
|
272
|
-
it("
|
|
299
|
+
it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
|
|
273
300
|
const h = makeHarness();
|
|
274
301
|
await h.command("allow allow allow ask");
|
|
302
|
+
expect(h.notes).toHaveLength(1);
|
|
303
|
+
|
|
275
304
|
asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
|
|
276
305
|
"allow-session",
|
|
277
306
|
);
|
|
307
|
+
await h.command("allow allow allow ask");
|
|
308
|
+
expect(h.notes).toHaveLength(1);
|
|
309
|
+
// The grant collected before the repeat is still in force.
|
|
278
310
|
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
311
|
+
});
|
|
279
312
|
|
|
313
|
+
it("notifies and clears session grants when the modes actually change", async () => {
|
|
314
|
+
const h = makeHarness();
|
|
280
315
|
await h.command("allow allow allow ask");
|
|
316
|
+
asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
|
|
317
|
+
"allow-session",
|
|
318
|
+
);
|
|
319
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
320
|
+
|
|
321
|
+
await h.command("ask allow allow ask");
|
|
322
|
+
expect(h.notes).toHaveLength(2);
|
|
281
323
|
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).kind).toBe("ask");
|
|
282
324
|
});
|
|
283
325
|
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
formatModes,
|
|
33
33
|
parsePermissionArgs,
|
|
34
|
+
sameModes,
|
|
34
35
|
shortMode,
|
|
35
36
|
} from "./modes.js";
|
|
36
37
|
import { openPermissionPanel } from "./panel.js";
|
|
@@ -98,6 +99,10 @@ function toolUnit(
|
|
|
98
99
|
export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
99
100
|
let enabled = false;
|
|
100
101
|
let modes: PermissionModes = { ...DEFAULT_MODES };
|
|
102
|
+
// Session this state belongs to. A runtime rebind can emit `session_start`
|
|
103
|
+
// again for the same session; resetting then would wipe a mode that the
|
|
104
|
+
// autostart replay had just enabled, so only a different session resets.
|
|
105
|
+
let currentSessionId: string | undefined;
|
|
101
106
|
const rules = new SessionRules();
|
|
102
107
|
let rootsCache:
|
|
103
108
|
| { cwd: string; branchLength: number; roots: string[] }
|
|
@@ -110,6 +115,15 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
110
115
|
rootsCache = undefined;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/** Session id, or `undefined` when the host exposes none. */
|
|
119
|
+
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
120
|
+
try {
|
|
121
|
+
return ctx.sessionManager.getSessionId();
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function syncStatus(ctx: ExtensionContext): void {
|
|
114
128
|
if (!enabled) {
|
|
115
129
|
ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
|
|
@@ -232,12 +246,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
232
246
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
233
247
|
|
|
234
248
|
pi.on("session_start", (_event, ctx) => {
|
|
249
|
+
const sessionId = sessionIdOf(ctx);
|
|
250
|
+
// Same session, repeated emission (a startup rebind): keep the state.
|
|
251
|
+
if (sessionId !== undefined && sessionId === currentSessionId) {
|
|
252
|
+
syncStatus(ctx);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
currentSessionId = sessionId;
|
|
235
256
|
reset();
|
|
236
257
|
syncStatus(ctx);
|
|
237
258
|
});
|
|
238
|
-
pi.on("session_shutdown", () => {
|
|
239
|
-
reset();
|
|
240
|
-
});
|
|
241
259
|
|
|
242
260
|
// ── Command ────────────────────────────────────────────────────────────
|
|
243
261
|
|
|
@@ -253,9 +271,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
253
271
|
ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
|
|
254
272
|
return;
|
|
255
273
|
}
|
|
274
|
+
// Idempotent: the same command may arrive twice in one session start
|
|
275
|
+
// (both autostart sources declaring it, a duplicated config line, …).
|
|
276
|
+
// Re-applying an unchanged policy is a no-op — no second "ON" notice,
|
|
277
|
+
// and no loss of the session grants collected so far.
|
|
278
|
+
if (enabled && sameModes(modes, parsed.modes)) return;
|
|
279
|
+
|
|
280
|
+
const changed = !sameModes(modes, parsed.modes);
|
|
256
281
|
modes = parsed.modes;
|
|
257
|
-
// A
|
|
258
|
-
rules.clear();
|
|
282
|
+
// A changed policy invalidates grants made under the old one.
|
|
283
|
+
if (changed) rules.clear();
|
|
259
284
|
enabled = true;
|
|
260
285
|
syncStatus(ctx);
|
|
261
286
|
ctx.ui.notify(
|
|
@@ -284,7 +309,9 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
284
309
|
);
|
|
285
310
|
return;
|
|
286
311
|
}
|
|
312
|
+
// The panel only opens while off, so this always transitions on.
|
|
287
313
|
modes = chosen;
|
|
314
|
+
rules.clear();
|
|
288
315
|
enabled = true;
|
|
289
316
|
syncStatus(ctx);
|
|
290
317
|
ctx.ui.notify(
|
|
@@ -80,3 +80,13 @@ export function shortMode(mode: TriMode | "allow"): string {
|
|
|
80
80
|
return "D";
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Structural equality for the four modes. */
|
|
85
|
+
export function sameModes(a: PermissionModes, b: PermissionModes): boolean {
|
|
86
|
+
return (
|
|
87
|
+
a.inRead === b.inRead &&
|
|
88
|
+
a.inWrite === b.inWrite &&
|
|
89
|
+
a.outRead === b.outRead &&
|
|
90
|
+
a.outWrite === b.outWrite
|
|
91
|
+
);
|
|
92
|
+
}
|