cc-viewer 1.9.3 → 1.9.5
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 +5 -5
- package/cli.js +29 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/index-CloXv0sm.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +176 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +1524 -895
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +190 -74
- package/server/routes/resume.js +79 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +31 -2
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +616 -108
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
package/server/lib/task-state.js
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* task-state.js — in-memory reducer for the Claude Code task checklist.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* Per-workspace (2026-10): the checklist is bucketed by producer scope
|
|
5
|
+
* ('' = the bound project — the legacy singleton bucket; a parallel workspace
|
|
6
|
+
* gets its own bucket) so two concurrent claudes' task lists never clobber
|
|
7
|
+
* each other over SSE. Within a bucket the original single-map semantics are
|
|
8
|
+
* preserved: the main agent and its teammates share one list, cleared at
|
|
9
|
+
* session boundaries.
|
|
10
|
+
*
|
|
11
|
+
* The bucket KEY is computed by the caller (server.js's hook-payload scope
|
|
12
|
+
* resolver) — this module stays import-free (L1-lib, boundary gate) and never
|
|
13
|
+
* sees pty-manager. Callers that pass no bucket get the legacy '' bucket, so
|
|
14
|
+
* the single-workspace behavior is byte-identical.
|
|
13
15
|
*
|
|
14
16
|
* L1-lib: pure module, Node builtins only, no imports.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
|
-
/** @type {string|null
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const _tasks = new Map();
|
|
19
|
+
/** @type {Map<string, {sessionId: string|null, tasks: Map<string, object>, lastUsedAt: number}>} */
|
|
20
|
+
const _buckets = new Map();
|
|
21
|
+
const _BUCKET_CAP = 16;
|
|
21
22
|
|
|
22
23
|
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
23
24
|
|
|
25
|
+
function _normKey(bucket) {
|
|
26
|
+
return (typeof bucket === 'string' && bucket) ? bucket : '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Get (or create) one scope's bucket. LRU-capped; the '' bucket is never
|
|
30
|
+
* evicted (it is the legacy singleton view). */
|
|
31
|
+
function _bucketFor(bucket) {
|
|
32
|
+
const k = _normKey(bucket);
|
|
33
|
+
let b = _buckets.get(k);
|
|
34
|
+
if (b) {
|
|
35
|
+
_buckets.delete(k);
|
|
36
|
+
_buckets.set(k, b);
|
|
37
|
+
return b;
|
|
38
|
+
}
|
|
39
|
+
b = { sessionId: null, tasks: new Map(), lastUsedAt: Date.now() };
|
|
40
|
+
_buckets.set(k, b);
|
|
41
|
+
while (_buckets.size > _BUCKET_CAP) {
|
|
42
|
+
let evicted = false;
|
|
43
|
+
for (const [bk] of _buckets) {
|
|
44
|
+
if (bk === '') continue; // never evict the bound bucket
|
|
45
|
+
_buckets.delete(bk);
|
|
46
|
+
evicted = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (!evicted) break;
|
|
50
|
+
}
|
|
51
|
+
return b;
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
function _touch(task) {
|
|
25
55
|
task.updatedAt = Date.now();
|
|
26
56
|
return task;
|
|
@@ -30,26 +60,29 @@ function _touch(task) {
|
|
|
30
60
|
* Apply one normalized task event (the camelCase envelope task-bridge.js
|
|
31
61
|
* POSTs). Unknown/malformed payloads are ignored.
|
|
32
62
|
* @param {object} payload
|
|
63
|
+
* @param {string} [bucket] - producer scope key ('' = bound, the legacy view)
|
|
33
64
|
*/
|
|
34
|
-
export function applyTaskEvent(payload) {
|
|
65
|
+
export function applyTaskEvent(payload, bucket = '') {
|
|
35
66
|
if (!payload || typeof payload !== 'object') return;
|
|
67
|
+
const b = _bucketFor(bucket);
|
|
68
|
+
b.lastUsedAt = Date.now();
|
|
36
69
|
const { hookEventName } = payload;
|
|
37
70
|
// New-prompt reset. MUST stay above the taskId guard (this event has no
|
|
38
|
-
// task_id) and MUST NOT move
|
|
39
|
-
// must not change the shouldResetTasks comparison base. resetTasks()
|
|
40
|
-
// the tag anyway, so the next TaskCreated re-tags the session.
|
|
71
|
+
// task_id) and MUST NOT move the sessionId tag — a foreign/malformed prompt
|
|
72
|
+
// event must not change the shouldResetTasks comparison base. resetTasks()
|
|
73
|
+
// nulls the tag anyway, so the next TaskCreated re-tags the session.
|
|
41
74
|
if (hookEventName === 'UserPromptSubmit') {
|
|
42
|
-
if (shouldResetTasksOnPrompt(payload,
|
|
75
|
+
if (shouldResetTasksOnPrompt(payload, b.sessionId)) resetTasks(bucket);
|
|
43
76
|
return;
|
|
44
77
|
}
|
|
45
78
|
const taskId = payload.taskId != null ? String(payload.taskId) : null;
|
|
46
79
|
// Validate BEFORE touching the session tag: a malformed/unknown event must
|
|
47
80
|
// not move the shouldResetTasks comparison base.
|
|
48
81
|
if (!taskId) return;
|
|
49
|
-
if (payload.sessionId && !payload.agentId)
|
|
82
|
+
if (payload.sessionId && !payload.agentId) b.sessionId = payload.sessionId;
|
|
50
83
|
|
|
51
84
|
if (hookEventName === 'TaskCreated') {
|
|
52
|
-
const existing =
|
|
85
|
+
const existing = b.tasks.get(taskId);
|
|
53
86
|
if (existing) {
|
|
54
87
|
// Idempotent re-fire: keep the current status, refresh the text fields
|
|
55
88
|
// and the teammate attribution (same field set as TaskCompleted).
|
|
@@ -59,7 +92,7 @@ export function applyTaskEvent(payload) {
|
|
|
59
92
|
_touch(existing);
|
|
60
93
|
return;
|
|
61
94
|
}
|
|
62
|
-
|
|
95
|
+
b.tasks.set(taskId, _touch({
|
|
63
96
|
taskId,
|
|
64
97
|
subject: payload.taskSubject ?? null,
|
|
65
98
|
description: payload.taskDescription ?? null,
|
|
@@ -74,8 +107,8 @@ export function applyTaskEvent(payload) {
|
|
|
74
107
|
}
|
|
75
108
|
|
|
76
109
|
if (hookEventName === 'TaskCompleted') {
|
|
77
|
-
const existing =
|
|
78
|
-
if (!
|
|
110
|
+
const existing = b.tasks.get(taskId) || _stub(taskId, payload);
|
|
111
|
+
if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
|
|
79
112
|
existing.status = 'completed';
|
|
80
113
|
if (payload.taskSubject != null) existing.subject = payload.taskSubject;
|
|
81
114
|
if (payload.taskDescription != null) existing.description = payload.taskDescription;
|
|
@@ -87,11 +120,11 @@ export function applyTaskEvent(payload) {
|
|
|
87
120
|
if (hookEventName === 'PostToolUse' && payload.toolName === 'TaskUpdate') {
|
|
88
121
|
const status = VALID_STATUSES.has(payload.status) ? payload.status : null;
|
|
89
122
|
if (status === 'deleted') {
|
|
90
|
-
|
|
123
|
+
b.tasks.delete(taskId);
|
|
91
124
|
return;
|
|
92
125
|
}
|
|
93
|
-
const existing =
|
|
94
|
-
if (!
|
|
126
|
+
const existing = b.tasks.get(taskId) || _stub(taskId, payload);
|
|
127
|
+
if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
|
|
95
128
|
// Patch only the fields present in the event; a TaskUpdate may carry any
|
|
96
129
|
// subset (status flips, renames, owner claims). Re-opening a completed
|
|
97
130
|
// task (completed → in_progress → pending) is valid and passed through.
|
|
@@ -124,10 +157,16 @@ function _stub(taskId, payload) {
|
|
|
124
157
|
});
|
|
125
158
|
}
|
|
126
159
|
|
|
127
|
-
/** Clear
|
|
128
|
-
export function resetTasks() {
|
|
129
|
-
|
|
130
|
-
|
|
160
|
+
/** Clear ONE bucket's checklist (session boundary: startup/clear by main agent). */
|
|
161
|
+
export function resetTasks(bucket = '') {
|
|
162
|
+
const b = _bucketFor(bucket);
|
|
163
|
+
b.tasks.clear();
|
|
164
|
+
b.sessionId = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Tear down every bucket (server stop/start cycles). */
|
|
168
|
+
export function resetAllTasks() {
|
|
169
|
+
_buckets.clear();
|
|
131
170
|
}
|
|
132
171
|
|
|
133
172
|
/**
|
|
@@ -171,12 +210,13 @@ export function shouldResetTasksOnPrompt(payload, currentSessionId) {
|
|
|
171
210
|
return true;
|
|
172
211
|
}
|
|
173
212
|
|
|
174
|
-
/** Full-snapshot view for SSE broadcast; insertion order preserved. */
|
|
175
|
-
export function getTaskSnapshot() {
|
|
176
|
-
|
|
213
|
+
/** Full-snapshot view of ONE bucket for SSE broadcast; insertion order preserved. */
|
|
214
|
+
export function getTaskSnapshot(bucket = '') {
|
|
215
|
+
const b = _bucketFor(bucket);
|
|
216
|
+
return { sessionId: b.sessionId, tasks: [...b.tasks.values()] };
|
|
177
217
|
}
|
|
178
218
|
|
|
179
219
|
/** Test hook: restore pristine module state. */
|
|
180
220
|
export function __resetForTests() {
|
|
181
|
-
|
|
221
|
+
resetAllTasks();
|
|
182
222
|
}
|
|
@@ -60,16 +60,21 @@ try {
|
|
|
60
60
|
} catch { /* stdin may not be piped — fine, still notify */ }
|
|
61
61
|
let sessionId = null;
|
|
62
62
|
let transcriptPath = null;
|
|
63
|
+
let cwd = null;
|
|
63
64
|
try {
|
|
64
65
|
const parsed = JSON.parse(stdinData);
|
|
65
66
|
sessionId = parsed?.session_id || null;
|
|
66
67
|
// transcript_path lets the DingTalk bridge read the final assistant turn straight from
|
|
67
68
|
// Claude's own JSONL (the clean source, free of ANSI noise / the log-dedup bug).
|
|
68
69
|
transcriptPath = parsed?.transcript_path || null;
|
|
70
|
+
// Per-workspace routing (2026-10): the Stop-hook stdin carries the session cwd —
|
|
71
|
+
// the server prefers sessionId→PTY resolution, with this cwd as the fallback
|
|
72
|
+
// scope hint (same precedence the task bridge already uses).
|
|
73
|
+
cwd = parsed?.cwd || null;
|
|
69
74
|
} catch { /* fine */ }
|
|
70
75
|
|
|
71
76
|
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
72
|
-
const body = JSON.stringify({ sessionId, transcriptPath, ts: Date.now() });
|
|
77
|
+
const body = JSON.stringify({ sessionId, transcriptPath, cwd, ts: Date.now() });
|
|
73
78
|
const reqOpts = {
|
|
74
79
|
hostname: '127.0.0.1',
|
|
75
80
|
port: parseInt(port, 10),
|
|
@@ -27,6 +27,7 @@ import { join } from 'node:path';
|
|
|
27
27
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
28
28
|
import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
|
|
29
29
|
import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
|
|
30
|
+
import { filterClientsByInstance } from './view-router.js';
|
|
30
31
|
import { SessionSynthesizer } from './adapter.js';
|
|
31
32
|
import { isDiscardableSession } from './session-select.js';
|
|
32
33
|
import { isForeignLiveOwned } from './session-owner.js';
|
|
@@ -214,14 +215,14 @@ export class V2LiveFeed {
|
|
|
214
215
|
* Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
|
|
215
216
|
* single-instance view) sees every session regardless of `sessionInstance`; a session with
|
|
216
217
|
* no stamped instance (legacy/external) is likewise visible to all.
|
|
218
|
+
*
|
|
219
|
+
* The project half keeps going through `_clientsFor()` (the server's filter
|
|
220
|
+
* closure — not necessarily the pure predicate); only the instance narrowing
|
|
221
|
+
* is delegated to the shared rule (view-router.filterClientsByInstance) so it
|
|
222
|
+
* can never drift from the server-side scoped broadcasts.
|
|
217
223
|
*/
|
|
218
224
|
_clientsForInstance(sessionInstance) {
|
|
219
|
-
|
|
220
|
-
if (!sessionInstance) return base; // session carries no instance → visible to all viewers
|
|
221
|
-
let anyInstanceViewer = false;
|
|
222
|
-
for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
|
|
223
|
-
if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
|
|
224
|
-
return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
|
|
225
|
+
return filterClientsByInstance(this._clientsFor(), sessionInstance || null);
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
/** Begin (or switch to) live-following a project's sessions/ root. */
|
|
@@ -38,6 +38,54 @@ export function filterClientsByViewProject(clientList, project, boundProject) {
|
|
|
38
38
|
return clientList.filter((c) => c && ((c._ccvViewProject || bound) === project));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Instance-narrowed variant of filterClientsByViewProject (multi-instance,
|
|
43
|
+
* 2026-10-06 rule lifted from live-feed._clientsForInstance): a frame stamped
|
|
44
|
+
* with an instance is visible to every viewer of the project EXCEPT clients
|
|
45
|
+
* stamped with a DIFFERENT instance. Unset on either side ⇒ visible (a client
|
|
46
|
+
* with no `_ccvViewInstance` stamp sees every instance; a frame with no
|
|
47
|
+
* instance reaches all of the project's viewers).
|
|
48
|
+
*
|
|
49
|
+
* @param {Array} clientList - SSE response objects.
|
|
50
|
+
* @param {string} project - the frame's project ('' ⇒ bound).
|
|
51
|
+
* @param {string|null} instance - the frame's instance, when known.
|
|
52
|
+
* @param {string} boundProject - the server's bound project.
|
|
53
|
+
* @returns {Array} the subset of clients that should receive the frame.
|
|
54
|
+
*/
|
|
55
|
+
export function filterClientsByScope(clientList, project, instance, boundProject) {
|
|
56
|
+
// Composition over duplication: project half, then the shared instance rule —
|
|
57
|
+
// the two can never drift apart.
|
|
58
|
+
return filterClientsByInstance(filterClientsByViewProject(clientList, project, boundProject), instance);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Instance-only narrowing over an ALREADY project-filtered client list — the
|
|
63
|
+
* live-feed composes its own project filter (`opts.clientFilter`, which may be
|
|
64
|
+
* a server-supplied closure rather than the pure predicate) with this rule.
|
|
65
|
+
* Same semantics as the instance half of filterClientsByScope: unset on either
|
|
66
|
+
* side ⇒ visible; nobody instance-stamped ⇒ the input array (same reference).
|
|
67
|
+
*
|
|
68
|
+
* @param {Array} clientList - already project-filtered SSE responses.
|
|
69
|
+
* @param {string|null} instance - the frame's instance, when known.
|
|
70
|
+
* @returns {Array}
|
|
71
|
+
*/
|
|
72
|
+
export function filterClientsByInstance(clientList, instance) {
|
|
73
|
+
if (!instance) return clientList;
|
|
74
|
+
// Exclude ONLY clients explicitly stamped with a DIFFERENT instance. An
|
|
75
|
+
// UNSTAMPED (project-level) viewer of the same project must also receive the
|
|
76
|
+
// frame: if an earlier `active:true` frame reached it while no instance viewer
|
|
77
|
+
// was online, the matching `active:false` must reach it too or its spinner
|
|
78
|
+
// never clears (the clear would be narrowed away the moment a parallel
|
|
79
|
+
// instance chip connects). Keeping active/clear on the SAME recipient set
|
|
80
|
+
// avoids that split delivery.
|
|
81
|
+
// Fast path: nobody is excluded (no differently-stamped client) ⇒ return the
|
|
82
|
+
// SAME array reference (no filter alloc), preserving legacy identity.
|
|
83
|
+
let anyExcluded = false;
|
|
84
|
+
for (const c of clientList) { if (c && c._ccvViewInstance && c._ccvViewInstance !== instance) { anyExcluded = true; break; } }
|
|
85
|
+
if (!anyExcluded) return clientList;
|
|
86
|
+
return clientList.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === instance);
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
/**
|
|
42
90
|
* Extract the owning project dir name from a session dir path of the form
|
|
43
91
|
* `<LOG_DIR>/<projectSan>/sessions/<dirName>`. Returns the on-disk `<projectSan>`
|
|
@@ -39,7 +39,7 @@ function _signature(p) {
|
|
|
39
39
|
function _isJournalFile(f) { return f.startsWith('wf_') && f.endsWith('.json'); }
|
|
40
40
|
|
|
41
41
|
function _scan(state) {
|
|
42
|
-
const { workflowsDir, sessionId, project, clients, seen } = state;
|
|
42
|
+
const { workflowsDir, sessionId, project, clients, clientFilter, seen } = state;
|
|
43
43
|
let files;
|
|
44
44
|
try { files = readdirSync(workflowsDir); } catch { return; }
|
|
45
45
|
for (const f of files) {
|
|
@@ -51,7 +51,11 @@ function _scan(state) {
|
|
|
51
51
|
seen.set(f, sig);
|
|
52
52
|
const data = readNormalizedJournal(p);
|
|
53
53
|
if (!data) continue;
|
|
54
|
-
|
|
54
|
+
// Per-workspace: a journal belongs to one project; when a client filter is
|
|
55
|
+
// installed the frame reaches only that project's viewers (legacy default:
|
|
56
|
+
// broadcast to all).
|
|
57
|
+
const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
|
|
58
|
+
sendEventToClients(target, 'workflow_update', {
|
|
55
59
|
sessionId,
|
|
56
60
|
project: project || null,
|
|
57
61
|
runId: data.runId,
|
|
@@ -99,7 +103,7 @@ function _baseline(state) {
|
|
|
99
103
|
* 武装对某 workflows 目录的监视。同目录重复调用只刷新 clients 引用。
|
|
100
104
|
* @param {{ workflowsDir: string, sessionId?: string, project?: string, clients: Array }} opts
|
|
101
105
|
*/
|
|
102
|
-
export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } = {}) {
|
|
106
|
+
export function armWorkflowWatch({ workflowsDir, sessionId, project, clients, clientFilter } = {}) {
|
|
103
107
|
if (!workflowsDir || !Array.isArray(clients)) return null;
|
|
104
108
|
|
|
105
109
|
const existing = _armed.get(workflowsDir);
|
|
@@ -107,6 +111,7 @@ export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } =
|
|
|
107
111
|
|
|
108
112
|
const state = {
|
|
109
113
|
workflowsDir, sessionId, project, clients,
|
|
114
|
+
clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
|
|
110
115
|
seen: new Map(), watcher: null,
|
|
111
116
|
debounceTimer: null, safetyTimer: null, presenceTimer: null,
|
|
112
117
|
};
|
|
@@ -177,13 +182,14 @@ function _authoritativeJournalExists(runDir, runId) {
|
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
function _scanLive(state) {
|
|
180
|
-
const { runDir, runId, sessionId, project, clients } = state;
|
|
185
|
+
const { runDir, runId, sessionId, project, clients, clientFilter } = state;
|
|
181
186
|
const data = deriveLiveJournal(runDir, runId);
|
|
182
187
|
if (data) {
|
|
183
188
|
const sig = _liveSignature(data);
|
|
184
189
|
if (sig !== state.lastSig) { // 有实质变化才广播
|
|
185
190
|
state.lastSig = sig;
|
|
186
|
-
|
|
191
|
+
const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
|
|
192
|
+
sendEventToClients(target, 'workflow_update', {
|
|
187
193
|
sessionId,
|
|
188
194
|
project: project || null,
|
|
189
195
|
runId: data.runId,
|
|
@@ -228,7 +234,7 @@ function _startLiveWatch(state) {
|
|
|
228
234
|
* 同 runDir 重复调用只刷新 clients。arm 时立即广播一次当前推导态(闭合 REST/arm 竞态)。
|
|
229
235
|
* @param {{ runDir: string, runId: string, sessionId?: string, project?: string, clients: Array }} opts
|
|
230
236
|
*/
|
|
231
|
-
export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients } = {}) {
|
|
237
|
+
export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients, clientFilter } = {}) {
|
|
232
238
|
if (!runDir || !runId || !Array.isArray(clients)) return null;
|
|
233
239
|
|
|
234
240
|
const existing = _armedLive.get(runDir);
|
|
@@ -236,6 +242,7 @@ export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, client
|
|
|
236
242
|
|
|
237
243
|
const state = {
|
|
238
244
|
runDir, runId, sessionId, project, clients,
|
|
245
|
+
clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
|
|
239
246
|
lastSig: null, watcher: null,
|
|
240
247
|
debounceTimer: null, safetyTimer: null, presenceTimer: null,
|
|
241
248
|
};
|