crewforth 3.0.3 → 3.1.0-rc.1
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/CHANGELOG.md +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
|
@@ -16,6 +16,14 @@ const FEED = process.env.CREW_UPDATE_URL
|
|
|
16
16
|
const FEED_TTL_MS = 60 * 60 * 1000; // the hook uses a day; a panel refreshes hourly
|
|
17
17
|
const FETCH_TIMEOUT_MS = 8000;
|
|
18
18
|
|
|
19
|
+
// How an install made of files is updated: the command, as the project's own update notice words it. It lives
|
|
20
|
+
// here and travels in the payload so that the panel never types a command of its own.
|
|
21
|
+
//
|
|
22
|
+
// It is given ONLY to a project that has `.claude/VERSION`. A plugin install leaves no file in the project, so
|
|
23
|
+
// from here it cannot be told from a project without Crewforth — and it does not update through npx. Such a
|
|
24
|
+
// project gets null, and the panel shows the `/crew-update` hint instead of a command it would have to invent.
|
|
25
|
+
const FILE_INSTALL_UPDATE = 'npx crewforth@latest update --here';
|
|
26
|
+
|
|
19
27
|
let latestCache = null; // { at, value }
|
|
20
28
|
let inflight = null;
|
|
21
29
|
|
|
@@ -120,7 +128,12 @@ export async function kitStatus(cwd, latest) {
|
|
|
120
128
|
// the other is a dead path.
|
|
121
129
|
let present = false;
|
|
122
130
|
try { await fsp.stat(cwd); present = true; } catch { present = false; }
|
|
123
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
installed: false,
|
|
133
|
+
dirExists: present,
|
|
134
|
+
reason: present ? 'Crewforth not installed' : 'directory no longer exists',
|
|
135
|
+
updateCommand: null,
|
|
136
|
+
};
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
const conf = {};
|
|
@@ -137,6 +150,7 @@ export async function kitStatus(cwd, latest) {
|
|
|
137
150
|
version,
|
|
138
151
|
stack: conf.stack ?? null,
|
|
139
152
|
installer: conf.installer ?? null,
|
|
153
|
+
updateCommand: FILE_INSTALL_UPDATE,
|
|
140
154
|
};
|
|
141
155
|
|
|
142
156
|
if (!latest?.measured) {
|
|
@@ -155,4 +169,4 @@ export async function kitStatus(cwd, latest) {
|
|
|
155
169
|
return out;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
|
-
export const _internals = { sane, FEED };
|
|
172
|
+
export const _internals = { sane, FEED, FILE_INSTALL_UPDATE };
|
|
@@ -39,7 +39,7 @@ export function spoolFor(sessionId) {
|
|
|
39
39
|
* are never read, written, or merged. Returns null when the hook is missing, so
|
|
40
40
|
* the caller can say "no gate" instead of quietly running without one.
|
|
41
41
|
*/
|
|
42
|
-
export function prepare(sessionId) {
|
|
42
|
+
export function prepare(sessionId, mode = null) {
|
|
43
43
|
if (!fs.existsSync(HOOK)) return null;
|
|
44
44
|
|
|
45
45
|
const spool = spoolFor(sessionId);
|
|
@@ -57,7 +57,10 @@ export function prepare(sessionId) {
|
|
|
57
57
|
// Named, not left to the default: on Windows without a detected Git Bash, Claude Code runs a hook through
|
|
58
58
|
// PowerShell, which cannot read the VAR=… prefix — the gate would fail open.
|
|
59
59
|
shell: 'bash',
|
|
60
|
-
|
|
60
|
+
// The mode goes to the hook so that an allowance given in the panel can be an approval the harness
|
|
61
|
+
// honours — in the modes that may write, and never in plan. A mode that is not a plain word is not
|
|
62
|
+
// passed at all, and the hook then approves nothing.
|
|
63
|
+
command: `CREW_GATE_WAIT=${HOOK_WAIT_S} ${gateMode(mode)}bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
|
|
61
64
|
timeout: HARNESS_TIMEOUT_S,
|
|
62
65
|
}],
|
|
63
66
|
}],
|
|
@@ -67,6 +70,10 @@ export function prepare(sessionId) {
|
|
|
67
70
|
return { settingsPath, spool, waitSeconds: HOOK_WAIT_S };
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
function gateMode(mode) {
|
|
74
|
+
return typeof mode === 'string' && /^[A-Za-z]+$/.test(mode) ? `CREW_GATE_MODE=${mode} ` : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
/** One pending request, as the panel needs to show it. */
|
|
71
78
|
function readRequest(spool, file) {
|
|
72
79
|
const toolUseId = file.replace(/\.json$/, '');
|
|
@@ -85,6 +92,10 @@ function readRequest(spool, file) {
|
|
|
85
92
|
detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
|
|
86
93
|
input,
|
|
87
94
|
cwd: payload.cwd ?? null,
|
|
95
|
+
// Who asked. Claude Code puts these in the hook's input only when the call comes from inside a subagent, so
|
|
96
|
+
// their absence means the session itself asked — it is not a missing answer.
|
|
97
|
+
agentId: typeof payload.agent_id === 'string' && /^[A-Za-z0-9_-]+$/.test(payload.agent_id) ? payload.agent_id : null,
|
|
98
|
+
agentType: typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 120) : null,
|
|
88
99
|
askedAt: (() => {
|
|
89
100
|
try { return fs.statSync(path.join(spool, 'req', file)).mtimeMs; } catch { return Date.now(); }
|
|
90
101
|
})(),
|
|
@@ -133,6 +144,70 @@ export function decide(sessionId, toolUseId, verdict) {
|
|
|
133
144
|
return { ok: true, verdict, toolName: req?.toolName ?? null };
|
|
134
145
|
}
|
|
135
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Take back an "allow this tool for the session". The hook looks for the tool's file on every call, so the next
|
|
149
|
+
* call of that tool is asked about again. Returns whether there was anything to take back.
|
|
150
|
+
*/
|
|
151
|
+
export function revoke(sessionId, tool) {
|
|
152
|
+
if (typeof tool !== 'string' || !/^[A-Za-z0-9_-]+$/.test(tool)) return { ok: false, reason: 'bad tool name' };
|
|
153
|
+
const file = path.join(spoolFor(sessionId), 'always', tool);
|
|
154
|
+
try {
|
|
155
|
+
fs.rmSync(file);
|
|
156
|
+
return { ok: true, tool, revoked: true };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (e?.code === 'ENOENT') return { ok: true, tool, revoked: false };
|
|
159
|
+
return { ok: false, reason: String(e?.message ?? e) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// How many answered requests a session remembers. In memory only: a server that restarts starts the record
|
|
164
|
+
// again, and the page says from when the record runs.
|
|
165
|
+
export const APPROVAL_LOG_MAX = 200;
|
|
166
|
+
// A request that vanished this close to the hook's own deadline, with no answer recorded, timed out. The hook
|
|
167
|
+
// polls and so does the watcher; this is their combined slack.
|
|
168
|
+
const TIMEOUT_SLACK_MS = 2500;
|
|
169
|
+
const VERDICT_OUTCOME = { allow: 'allowed', always: 'allowed-session', deny: 'denied' };
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Fold the requests waiting now into a session's record of approvals.
|
|
173
|
+
*
|
|
174
|
+
* A request not seen before is opened with the moment it was asked. One that was open and is no longer waiting
|
|
175
|
+
* is closed with the moment that was noticed and with what became of it: the answer the panel recorded, a
|
|
176
|
+
* timeout if nobody answered and the hook's deadline had come, and otherwise "unanswered" — the hook went away
|
|
177
|
+
* without one, as it does when the session is stopped. Nothing is guessed into an answer.
|
|
178
|
+
*
|
|
179
|
+
* @param log the record so far, oldest first; changed in place and returned
|
|
180
|
+
* @param pending what `pending()` returns now
|
|
181
|
+
* @param decisions Map toolUseId -> verdict the panel recorded
|
|
182
|
+
*/
|
|
183
|
+
export function logApprovals(log, pending, decisions, now, waitSeconds) {
|
|
184
|
+
const waiting = new Set(pending.map((r) => r.toolUseId));
|
|
185
|
+
const open = new Set(log.filter((e) => e.endedAt === null).map((e) => e.toolUseId));
|
|
186
|
+
for (const e of log) {
|
|
187
|
+
if (e.endedAt !== null || waiting.has(e.toolUseId)) continue;
|
|
188
|
+
e.endedAt = now;
|
|
189
|
+
const verdict = decisions?.get(e.toolUseId);
|
|
190
|
+
if (verdict && VERDICT_OUTCOME[verdict]) e.outcome = VERDICT_OUTCOME[verdict];
|
|
191
|
+
else if (typeof waitSeconds === 'number' && now >= e.askedAt + waitSeconds * 1000 - TIMEOUT_SLACK_MS) e.outcome = 'timed-out';
|
|
192
|
+
else e.outcome = 'unanswered';
|
|
193
|
+
decisions?.delete(e.toolUseId);
|
|
194
|
+
}
|
|
195
|
+
for (const r of pending) {
|
|
196
|
+
if (open.has(r.toolUseId)) continue;
|
|
197
|
+
log.push({
|
|
198
|
+
toolUseId: r.toolUseId, toolName: r.toolName, agentId: r.agentId ?? null, agentType: r.agentType ?? null,
|
|
199
|
+
askedAt: r.askedAt, endedAt: null, outcome: null,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// The oldest closed entries go first; a request still waiting is never dropped.
|
|
203
|
+
while (log.length > APPROVAL_LOG_MAX) {
|
|
204
|
+
const i = log.findIndex((e) => e.endedAt !== null);
|
|
205
|
+
if (i === -1) break;
|
|
206
|
+
log.splice(i, 1);
|
|
207
|
+
}
|
|
208
|
+
return log;
|
|
209
|
+
}
|
|
210
|
+
|
|
136
211
|
/** Watch a session's spool and call back whenever the pending set changes. */
|
|
137
212
|
export function watch(sessionId, onChange) {
|
|
138
213
|
let last = '';
|
|
@@ -68,15 +68,25 @@ export async function allProjectDirs() {
|
|
|
68
68
|
// Reading them means scanning the transcript, and the sidebar refreshes on a
|
|
69
69
|
// timer over every session in the project — so only the tail is read (the
|
|
70
70
|
// latest record wins anyway) and the result is cached against size+mtime.
|
|
71
|
+
//
|
|
72
|
+
// The branch comes out of the same read. Every record carries `gitBranch`, and the last one in the tail is the
|
|
73
|
+
// branch the session is on now — a session can change branch, and the navigator names where it is, not where it
|
|
74
|
+
// began. A transcript with no such field, or an empty one (not a repository), yields null: the caller shows
|
|
75
|
+
// nothing rather than a branch nobody read.
|
|
71
76
|
const TITLE_TAIL_BYTES = 131072;
|
|
72
|
-
const titleCache = new Map(); // file -> { sig, title }
|
|
77
|
+
const titleCache = new Map(); // file -> { sig, title, branch }
|
|
73
78
|
|
|
74
79
|
export async function sessionTitle(file, size, mtimeMs) {
|
|
80
|
+
return (await sessionTail(file, size, mtimeMs)).title;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sessionTail(file, size, mtimeMs) {
|
|
75
84
|
const sig = `${size}:${mtimeMs}`;
|
|
76
85
|
const hit = titleCache.get(file);
|
|
77
|
-
if (hit && hit.sig === sig) return hit.title;
|
|
86
|
+
if (hit && hit.sig === sig) return { title: hit.title, branch: hit.branch };
|
|
78
87
|
|
|
79
88
|
let title = null;
|
|
89
|
+
let branch = null;
|
|
80
90
|
let fh;
|
|
81
91
|
try {
|
|
82
92
|
fh = await fsp.open(file, 'r');
|
|
@@ -100,15 +110,24 @@ export async function sessionTitle(file, size, mtimeMs) {
|
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
title = found.custom ?? found.agent ?? found.ai ?? null;
|
|
113
|
+
|
|
114
|
+
// In a record the key is followed by a bare quote; inside a quoted message every quote is escaped, so text
|
|
115
|
+
// that merely mentions the field cannot match.
|
|
116
|
+
let last = null;
|
|
117
|
+
for (const m of text.matchAll(/"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/g)) last = m[1];
|
|
118
|
+
if (last) {
|
|
119
|
+
try { branch = JSON.parse(`"${last}"`) || null; } catch { branch = null; }
|
|
120
|
+
}
|
|
103
121
|
} catch {
|
|
104
122
|
title = null;
|
|
123
|
+
branch = null;
|
|
105
124
|
} finally {
|
|
106
125
|
if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
if (titleCache.size > 500) titleCache.clear();
|
|
110
|
-
titleCache.set(file, { sig, title });
|
|
111
|
-
return title;
|
|
129
|
+
titleCache.set(file, { sig, title, branch });
|
|
130
|
+
return { title, branch };
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// The working directory a session ran in. The encoded folder name cannot be
|
|
@@ -162,13 +181,16 @@ export async function listSessions(dir) {
|
|
|
162
181
|
try { st = await fsp.stat(file); } catch { return null; }
|
|
163
182
|
|
|
164
183
|
const subagentsDir = path.join(dir, sessionId, 'subagents');
|
|
165
|
-
const [agentCount,
|
|
184
|
+
const [agentCount, tail, cwd] = await Promise.all([
|
|
166
185
|
countAgentMetas(subagentsDir),
|
|
167
|
-
|
|
186
|
+
sessionTail(file, st.size, st.mtimeMs),
|
|
168
187
|
sessionCwd(file, st.size),
|
|
169
188
|
]);
|
|
170
189
|
|
|
171
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount,
|
|
192
|
+
title: tail.title, branch: tail.branch, cwd,
|
|
193
|
+
};
|
|
172
194
|
}));
|
|
173
195
|
|
|
174
196
|
const out = settled.filter(Boolean);
|
|
@@ -222,6 +244,34 @@ export async function agentMetaFiles(subagentsDir, depth = 0) {
|
|
|
222
244
|
return out;
|
|
223
245
|
}
|
|
224
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The branch a directory is on NOW, read from its `.git/HEAD` and nowhere else.
|
|
249
|
+
*
|
|
250
|
+
* No process is started: a `git` per project per poll is the cost this file exists to avoid. A worktree's `.git`
|
|
251
|
+
* is a file that points at its real git directory, and that pointer is followed once. A detached HEAD, a
|
|
252
|
+
* directory that is not a repository's root, and a file that cannot be read all answer null: the caller then
|
|
253
|
+
* names no branch, which is different from naming the one a session recorded last week.
|
|
254
|
+
*/
|
|
255
|
+
export async function headBranch(cwd) {
|
|
256
|
+
if (!cwd) return null;
|
|
257
|
+
const dot = path.join(cwd, '.git');
|
|
258
|
+
let head;
|
|
259
|
+
try {
|
|
260
|
+
const st = await fsp.stat(dot);
|
|
261
|
+
if (st.isDirectory()) {
|
|
262
|
+
head = await fsp.readFile(path.join(dot, 'HEAD'), 'utf8');
|
|
263
|
+
} else {
|
|
264
|
+
const m = (await fsp.readFile(dot, 'utf8')).match(/^gitdir:\s*(.+?)\s*$/m);
|
|
265
|
+
if (!m) return null;
|
|
266
|
+
head = await fsp.readFile(path.join(path.resolve(cwd, m[1]), 'HEAD'), 'utf8');
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const ref = head.match(/^ref:\s*refs\/heads\/(\S+)\s*$/m);
|
|
272
|
+
return ref ? ref[1] : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
225
275
|
/**
|
|
226
276
|
* Every session on this machine, grouped by the project it ran in.
|
|
227
277
|
*
|
|
@@ -257,6 +307,9 @@ export async function listProjects({ currentCwd = null, limitPerProject = 60, ki
|
|
|
257
307
|
// not deleted: the panel says how many it is holding back rather than
|
|
258
308
|
// quietly shortening the list.
|
|
259
309
|
exists: kit?.dirExists ?? (g.cwd ? await existsCached(g.cwd) : false),
|
|
310
|
+
// Where a session started here now would be working. Not the branch a session recorded: that one is on
|
|
311
|
+
// each session row, and may be weeks old.
|
|
312
|
+
branch: await headBranch(g.cwd),
|
|
260
313
|
total: g.sessions.length,
|
|
261
314
|
agentTotal: g.sessions.reduce((n, s) => n + s.agentCount, 0),
|
|
262
315
|
modifiedAt: g.sessions[0]?.modifiedAt ?? 0,
|
|
@@ -15,7 +15,7 @@ import { spawn } from 'node:child_process';
|
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
|
|
18
|
-
import { prepare, pending, watch, cleanup } from './permissions.js';
|
|
18
|
+
import { prepare, pending, watch, cleanup, alwaysList, logApprovals } from './permissions.js';
|
|
19
19
|
import { getFleet } from './fleet.js';
|
|
20
20
|
|
|
21
21
|
export const ALLOWED_MODES = ['plan', 'acceptEdits', 'default'];
|
|
@@ -49,12 +49,17 @@ class OwnedSession {
|
|
|
49
49
|
// The gate is prepared before the child exists, so its very first tool call
|
|
50
50
|
// is already covered. A missing hook is reported, never worked around: a
|
|
51
51
|
// panel that silently ran ungated would be worse than one that cannot run.
|
|
52
|
-
this.gate = prepare(this.id);
|
|
52
|
+
this.gate = prepare(this.id, this.permissionMode);
|
|
53
53
|
this.pendingPermissions = [];
|
|
54
54
|
this.gateEvents = []; // hook lifecycle, newest last
|
|
55
|
+
// What was asked of the viewer in this session, when, and what became of it. Kept from the moment Studio
|
|
56
|
+
// started the session, in memory: it is what the Timeline's waiting periods are drawn from.
|
|
57
|
+
this.approvals = [];
|
|
58
|
+
this.decisions = new Map();
|
|
55
59
|
this.unwatch = this.gate
|
|
56
60
|
? watch(this.id, (reqs) => {
|
|
57
61
|
this.pendingPermissions = reqs;
|
|
62
|
+
logApprovals(this.approvals, reqs, this.decisions, Date.now(), this.gate.waitSeconds);
|
|
58
63
|
this.#emit({ type: 'permissions', pending: reqs });
|
|
59
64
|
})
|
|
60
65
|
: null;
|
|
@@ -181,6 +186,9 @@ class OwnedSession {
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
/** The panel recorded an answer to a request. Remembered until the request is seen to have gone. */
|
|
190
|
+
noteDecision(toolUseId, verdict) { this.decisions.set(toolUseId, verdict); }
|
|
191
|
+
|
|
184
192
|
/** Replay from `after`, then follow. Returns an unsubscribe function. */
|
|
185
193
|
subscribe(fn, after = 0) {
|
|
186
194
|
for (const ev of this.events) if (ev.seq > after) fn(ev);
|
|
@@ -235,6 +243,12 @@ class OwnedSession {
|
|
|
235
243
|
gated: Boolean(this.gate),
|
|
236
244
|
gateWaitSeconds: this.gate?.waitSeconds ?? null,
|
|
237
245
|
pendingPermissions: this.pendingPermissions,
|
|
246
|
+
// Tools the viewer allowed for the rest of this session, so they can be listed and taken back.
|
|
247
|
+
alwaysAllowed: this.gate ? alwaysList(this.id) : [],
|
|
248
|
+
approvals: this.approvals,
|
|
249
|
+
// This machine's clock. A countdown is `askedAt + wait - now`, and all three have to come from one clock:
|
|
250
|
+
// a viewer on another machine has a different one.
|
|
251
|
+
now: Date.now(),
|
|
238
252
|
gateEvents: this.gateEvents.slice(-40),
|
|
239
253
|
};
|
|
240
254
|
}
|