vibe-coding-master 0.2.6 → 0.2.8
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 +164 -9
- package/dist/backend/api/claude-hook-routes.js +7 -1
- package/dist/backend/api/gateway-routes.js +17 -0
- package/dist/backend/api/round-routes.js +1 -1
- package/dist/backend/gateway/channels/weixin-ilink-channel.js +304 -0
- package/dist/backend/gateway/gateway-audit-log.js +39 -0
- package/dist/backend/gateway/gateway-command-parser.js +77 -0
- package/dist/backend/gateway/gateway-service.js +848 -0
- package/dist/backend/gateway/gateway-settings-service.js +214 -0
- package/dist/backend/server.js +42 -2
- package/dist/backend/services/claude-hook-service.js +46 -7
- package/dist/backend/services/harness-service.js +37 -32
- package/dist/backend/services/job-guard-service.js +126 -0
- package/dist/backend/services/round-service.js +110 -64
- package/dist/backend/services/session-service.js +10 -4
- package/dist/backend/services/task-service.js +32 -3
- package/dist/backend/services/translation-service.js +15 -0
- package/dist/backend/templates/harness/architect-agent.js +19 -8
- package/dist/backend/templates/harness/claude-root.js +7 -11
- package/dist/backend/templates/harness/coder-agent.js +45 -17
- package/dist/backend/templates/harness/gitignore.js +3 -2
- package/dist/backend/templates/harness/project-manager-agent.js +16 -11
- package/dist/backend/templates/harness/reviewer-agent.js +25 -28
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
- package/dist/shared/types/gateway.js +1 -0
- package/dist-frontend/assets/{index-CPXFnxAY.css → index-7lq6YPCq.css} +1 -1
- package/dist-frontend/assets/index-DHuS-DYr.js +92 -0
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +7 -5
- package/docs/gateway-design.md +200 -27
- package/docs/product-design.md +35 -14
- package/docs/v0.2-implementation-plan.md +22 -7
- package/docs/vcm-cc-best-practices.md +11 -4
- package/package.json +2 -1
- package/scripts/harness-tools/run-long-check +401 -0
- package/scripts/harness-tools/vcm-bash-guard +107 -0
- package/scripts/harness-tools/watch-job +204 -0
- package/scripts/install-vcm-harness.mjs +93 -387
- package/scripts/uninstall-vcm-harness.mjs +18 -1
- package/scripts/verify-package.mjs +3 -0
- package/dist/backend/templates/harness/known-issues-doc.js +0 -22
- package/dist-frontend/assets/index-D0_02lmQ.js +0 -90
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { ROLE_DEFINITIONS } from "../../shared/constants.js";
|
|
3
|
+
import { VcmError } from "../errors.js";
|
|
4
|
+
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
5
|
+
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
6
|
+
import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "../services/claude-transcript-service.js";
|
|
7
|
+
import { parseGatewayCommand } from "./gateway-command-parser.js";
|
|
8
|
+
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
9
|
+
const QR_LOGIN_TTL_MS = 8 * 60 * 1000;
|
|
10
|
+
const CLOSE_CONFIRM_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const POLL_ERROR_BACKOFF_MS = 2_000;
|
|
12
|
+
const POLL_LONG_BACKOFF_MS = 30_000;
|
|
13
|
+
const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
|
|
14
|
+
const DEFAULT_POLL_TIMEOUT_MS = 35_000;
|
|
15
|
+
export function createGatewayService(deps) {
|
|
16
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
17
|
+
let pollAbort = null;
|
|
18
|
+
let pollLoopPromise = null;
|
|
19
|
+
let qrLogin = null;
|
|
20
|
+
function isRunning() {
|
|
21
|
+
return Boolean(pollAbort && !pollAbort.signal.aborted);
|
|
22
|
+
}
|
|
23
|
+
async function ensurePolling() {
|
|
24
|
+
const settings = await deps.settings.loadSettings();
|
|
25
|
+
if (!settings.enabled || !settings.binding.token || isRunning()) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
pollAbort = new AbortController();
|
|
29
|
+
pollLoopPromise = pollLoop(pollAbort.signal).finally(() => {
|
|
30
|
+
pollAbort = null;
|
|
31
|
+
pollLoopPromise = null;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function stopPolling() {
|
|
35
|
+
if (!pollAbort) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
pollAbort.abort();
|
|
39
|
+
await pollLoopPromise?.catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
function toAccount(settings) {
|
|
42
|
+
if (!settings.binding.token) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
accountId: settings.binding.accountId,
|
|
47
|
+
baseUrl: settings.binding.baseUrl || DEFAULT_BASE_URL,
|
|
48
|
+
token: settings.binding.token
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function pollLoop(signal) {
|
|
52
|
+
let consecutiveFailures = 0;
|
|
53
|
+
let timeoutMs = DEFAULT_POLL_TIMEOUT_MS;
|
|
54
|
+
await savePollStatus("running");
|
|
55
|
+
while (!signal.aborted) {
|
|
56
|
+
const settings = await deps.settings.loadSettings();
|
|
57
|
+
const account = toAccount(settings);
|
|
58
|
+
if (!settings.enabled || !account) {
|
|
59
|
+
await savePollStatus("idle");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const result = await deps.channel.getUpdates({
|
|
64
|
+
account,
|
|
65
|
+
cursor: settings.binding.getUpdatesBuf,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
signal
|
|
68
|
+
});
|
|
69
|
+
consecutiveFailures = 0;
|
|
70
|
+
timeoutMs = result.timeoutMs ?? timeoutMs;
|
|
71
|
+
const nextSettings = await deps.settings.loadSettings();
|
|
72
|
+
await deps.settings.saveSettings({
|
|
73
|
+
...nextSettings,
|
|
74
|
+
binding: {
|
|
75
|
+
...nextSettings.binding,
|
|
76
|
+
getUpdatesBuf: result.cursor
|
|
77
|
+
},
|
|
78
|
+
lastPollStatus: {
|
|
79
|
+
state: "running",
|
|
80
|
+
checkedAt: now()
|
|
81
|
+
},
|
|
82
|
+
updatedAt: now()
|
|
83
|
+
});
|
|
84
|
+
for (const update of result.updates) {
|
|
85
|
+
if (signal.aborted) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await handleInbound(update).catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (signal.aborted) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
consecutiveFailures += 1;
|
|
96
|
+
const message = errorMessage(error);
|
|
97
|
+
const expired = message.toLowerCase().includes("expired");
|
|
98
|
+
const settingsAfterError = await deps.settings.loadSettings();
|
|
99
|
+
await deps.settings.saveSettings({
|
|
100
|
+
...settingsAfterError,
|
|
101
|
+
enabled: expired ? false : settingsAfterError.enabled,
|
|
102
|
+
lastPollStatus: {
|
|
103
|
+
state: expired ? "expired" : "error",
|
|
104
|
+
checkedAt: now(),
|
|
105
|
+
error: message
|
|
106
|
+
},
|
|
107
|
+
updatedAt: now()
|
|
108
|
+
});
|
|
109
|
+
await deps.audit.record({
|
|
110
|
+
type: "gateway.poll",
|
|
111
|
+
result: "error",
|
|
112
|
+
error: message
|
|
113
|
+
});
|
|
114
|
+
if (expired) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await sleep(consecutiveFailures >= MAX_FAILURES_BEFORE_LONG_BACKOFF ? POLL_LONG_BACKOFF_MS : POLL_ERROR_BACKOFF_MS, signal);
|
|
118
|
+
if (consecutiveFailures >= MAX_FAILURES_BEFORE_LONG_BACKOFF) {
|
|
119
|
+
consecutiveFailures = 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function savePollStatus(state, error) {
|
|
125
|
+
const settings = await deps.settings.loadSettings();
|
|
126
|
+
await deps.settings.saveSettings({
|
|
127
|
+
...settings,
|
|
128
|
+
lastPollStatus: {
|
|
129
|
+
state,
|
|
130
|
+
checkedAt: now(),
|
|
131
|
+
error
|
|
132
|
+
},
|
|
133
|
+
updatedAt: now()
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async function handleInbound(update) {
|
|
137
|
+
let settings = await deps.settings.loadSettings();
|
|
138
|
+
if (settings.dedupe.recentInboundMessageIds.includes(update.messageId)) {
|
|
139
|
+
await deps.audit.record({
|
|
140
|
+
type: "gateway.inbound",
|
|
141
|
+
result: "ignored",
|
|
142
|
+
messageId: update.messageId,
|
|
143
|
+
userId: update.fromUserId,
|
|
144
|
+
preview: update.text
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
settings = await deps.settings.saveSettings({
|
|
149
|
+
...settings,
|
|
150
|
+
binding: {
|
|
151
|
+
...settings.binding,
|
|
152
|
+
boundUserId: settings.binding.boundUserId ?? update.fromUserId,
|
|
153
|
+
contextTokens: update.contextToken
|
|
154
|
+
? {
|
|
155
|
+
...settings.binding.contextTokens,
|
|
156
|
+
[update.fromUserId]: update.contextToken
|
|
157
|
+
}
|
|
158
|
+
: settings.binding.contextTokens
|
|
159
|
+
},
|
|
160
|
+
dedupe: {
|
|
161
|
+
recentInboundMessageIds: [...settings.dedupe.recentInboundMessageIds, update.messageId].slice(-1000)
|
|
162
|
+
},
|
|
163
|
+
updatedAt: now()
|
|
164
|
+
});
|
|
165
|
+
if (settings.binding.boundUserId !== update.fromUserId) {
|
|
166
|
+
await reply(settings, update.fromUserId, "This VCM gateway is already bound to another Weixin DM.");
|
|
167
|
+
await recordMessageStatus("inbound", "ignored", update.text, "unbound user");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const command = parseGatewayCommand(update.text);
|
|
171
|
+
try {
|
|
172
|
+
const output = await executeCommand(command);
|
|
173
|
+
await reply(await deps.settings.loadSettings(), update.fromUserId, output);
|
|
174
|
+
await recordMessageStatus("inbound", "ok", update.text, undefined, command.kind);
|
|
175
|
+
await deps.audit.record({
|
|
176
|
+
type: "gateway.command",
|
|
177
|
+
result: "ok",
|
|
178
|
+
messageId: update.messageId,
|
|
179
|
+
userId: update.fromUserId,
|
|
180
|
+
command: command.kind,
|
|
181
|
+
preview: update.text
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const message = errorMessage(error);
|
|
186
|
+
await reply(await deps.settings.loadSettings(), update.fromUserId, `Error: ${message}`);
|
|
187
|
+
await recordMessageStatus("inbound", "error", update.text, message, command.kind);
|
|
188
|
+
await deps.audit.record({
|
|
189
|
+
type: "gateway.command",
|
|
190
|
+
result: "error",
|
|
191
|
+
messageId: update.messageId,
|
|
192
|
+
userId: update.fromUserId,
|
|
193
|
+
command: command.kind,
|
|
194
|
+
preview: update.text,
|
|
195
|
+
error: message
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function executeCommand(command) {
|
|
200
|
+
switch (command.kind) {
|
|
201
|
+
case "help":
|
|
202
|
+
return helpText();
|
|
203
|
+
case "status":
|
|
204
|
+
return statusText(await deps.settings.loadSettings());
|
|
205
|
+
case "projects":
|
|
206
|
+
return projectsText(await listProjectOptions());
|
|
207
|
+
case "use-project":
|
|
208
|
+
return useProject(command.selector);
|
|
209
|
+
case "pull-current":
|
|
210
|
+
return pullCurrent();
|
|
211
|
+
case "tasks":
|
|
212
|
+
return tasksText(await ensureProject());
|
|
213
|
+
case "use-task":
|
|
214
|
+
return useTask(command.selector);
|
|
215
|
+
case "create-task":
|
|
216
|
+
return createTask(command.taskSlug, command.title);
|
|
217
|
+
case "close-task":
|
|
218
|
+
return closeTaskPrompt();
|
|
219
|
+
case "close-task-confirm":
|
|
220
|
+
return closeTaskConfirm(command.taskSlug);
|
|
221
|
+
case "translate":
|
|
222
|
+
return setGatewayTranslation(command.enabled);
|
|
223
|
+
case "plain":
|
|
224
|
+
return sendPlainTextToPm(command.text);
|
|
225
|
+
case "unknown":
|
|
226
|
+
return `Unknown command: ${command.name}. Send /help for available commands.`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function ensureProject() {
|
|
230
|
+
const settings = await deps.settings.loadSettings();
|
|
231
|
+
let project = await deps.projectService.getCurrentProject();
|
|
232
|
+
if (!project && settings.currentProjectId) {
|
|
233
|
+
project = await deps.projectService.connectProject({ repoPath: settings.currentProjectId });
|
|
234
|
+
}
|
|
235
|
+
if (!project) {
|
|
236
|
+
throw new VcmError({
|
|
237
|
+
code: "PROJECT_NOT_CONNECTED",
|
|
238
|
+
message: "No project is selected. Use /projects and /use-project first.",
|
|
239
|
+
statusCode: 409
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (settings.currentProjectId !== project.repoRoot) {
|
|
243
|
+
await deps.settings.saveSettings({
|
|
244
|
+
...settings,
|
|
245
|
+
currentProjectId: project.repoRoot,
|
|
246
|
+
updatedAt: now()
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return project;
|
|
250
|
+
}
|
|
251
|
+
async function syncDesktopContext(settings) {
|
|
252
|
+
const project = await deps.projectService.getCurrentProject();
|
|
253
|
+
if (!project) {
|
|
254
|
+
return settings;
|
|
255
|
+
}
|
|
256
|
+
const tasks = await deps.taskService.listTasks(project.repoRoot);
|
|
257
|
+
const usableTasks = tasks.filter((task) => task.cleanupStatus !== "cleaned");
|
|
258
|
+
const selectedTask = settings.currentTaskSlug
|
|
259
|
+
? usableTasks.find((task) => task.taskSlug === settings.currentTaskSlug) ?? usableTasks[0]
|
|
260
|
+
: usableTasks[0];
|
|
261
|
+
const nextTaskSlug = selectedTask?.taskSlug ?? null;
|
|
262
|
+
if (settings.currentProjectId === project.repoRoot && settings.currentTaskSlug === nextTaskSlug) {
|
|
263
|
+
return settings;
|
|
264
|
+
}
|
|
265
|
+
return deps.settings.saveSettings({
|
|
266
|
+
...settings,
|
|
267
|
+
currentProjectId: project.repoRoot,
|
|
268
|
+
currentTaskSlug: nextTaskSlug,
|
|
269
|
+
updatedAt: now()
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
async function listProjectOptions() {
|
|
273
|
+
const project = await deps.projectService.getCurrentProject();
|
|
274
|
+
const recent = await deps.projectService.getRecentRepositoryPaths();
|
|
275
|
+
const paths = [project?.repoRoot, ...recent].filter((value) => Boolean(value));
|
|
276
|
+
return [...new Set(paths)];
|
|
277
|
+
}
|
|
278
|
+
async function useProject(selector) {
|
|
279
|
+
const options = await listProjectOptions();
|
|
280
|
+
const index = Number.parseInt(selector, 10);
|
|
281
|
+
const repoPath = Number.isFinite(index) && String(index) === selector.trim()
|
|
282
|
+
? options[index - 1]
|
|
283
|
+
: selector.trim();
|
|
284
|
+
if (!repoPath) {
|
|
285
|
+
throw new VcmError({
|
|
286
|
+
code: "PROJECT_SELECTION_INVALID",
|
|
287
|
+
message: `Project not found: ${selector}`,
|
|
288
|
+
statusCode: 404
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const project = await deps.projectService.connectProject({ repoPath });
|
|
292
|
+
const settings = await deps.settings.loadSettings();
|
|
293
|
+
await deps.settings.saveSettings({
|
|
294
|
+
...settings,
|
|
295
|
+
currentProjectId: project.repoRoot,
|
|
296
|
+
currentTaskSlug: null,
|
|
297
|
+
updatedAt: now()
|
|
298
|
+
});
|
|
299
|
+
return `Selected project:\n${project.repoRoot}\nbranch: ${project.branch}\ncommit: ${project.shortHeadCommit ?? project.headCommit ?? "unknown"}`;
|
|
300
|
+
}
|
|
301
|
+
async function pullCurrent() {
|
|
302
|
+
const project = await ensureProject();
|
|
303
|
+
const settings = await syncDesktopContext(await deps.settings.loadSettings());
|
|
304
|
+
if (settings.currentTaskSlug) {
|
|
305
|
+
const task = await deps.taskService.loadTask(project.repoRoot, settings.currentTaskSlug);
|
|
306
|
+
if (!task.worktreePath && task.cleanupStatus !== "cleaned") {
|
|
307
|
+
throw new VcmError({
|
|
308
|
+
code: "GATEWAY_PULL_BLOCKED_BY_INLINE_TASK",
|
|
309
|
+
message: `Inline task "${task.taskSlug}" uses the base repository.`,
|
|
310
|
+
statusCode: 409
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const pulled = await deps.projectService.pullCurrentProject();
|
|
315
|
+
return [
|
|
316
|
+
"Connected repository updated.",
|
|
317
|
+
`branch: ${pulled.branch}`,
|
|
318
|
+
`remote: ${pulled.upstreamBranch ?? "no upstream"}`,
|
|
319
|
+
`status: ${formatAheadBehind(pulled)}`,
|
|
320
|
+
`commit: ${pulled.shortHeadCommit ?? pulled.headCommit ?? "unknown"}`
|
|
321
|
+
].join("\n");
|
|
322
|
+
}
|
|
323
|
+
async function tasksText(project) {
|
|
324
|
+
const tasks = await deps.taskService.listTasks(project.repoRoot);
|
|
325
|
+
if (tasks.length === 0) {
|
|
326
|
+
return "No tasks. Use /create-task <task-slug> [title] to create one.";
|
|
327
|
+
}
|
|
328
|
+
return tasks.map((task, index) => `${index + 1}. ${task.taskSlug} [${task.status}] ${task.worktreePath ? "worktree" : "inline"}`).join("\n");
|
|
329
|
+
}
|
|
330
|
+
async function useTask(selector) {
|
|
331
|
+
const project = await ensureProject();
|
|
332
|
+
const tasks = await deps.taskService.listTasks(project.repoRoot);
|
|
333
|
+
const index = Number.parseInt(selector, 10);
|
|
334
|
+
const task = Number.isFinite(index) && String(index) === selector.trim()
|
|
335
|
+
? tasks[index - 1]
|
|
336
|
+
: tasks.find((candidate) => candidate.taskSlug === selector.trim());
|
|
337
|
+
if (!task) {
|
|
338
|
+
throw new VcmError({
|
|
339
|
+
code: "TASK_NOT_FOUND",
|
|
340
|
+
message: `Task not found: ${selector}`,
|
|
341
|
+
statusCode: 404
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const settings = await deps.settings.loadSettings();
|
|
345
|
+
await deps.settings.saveSettings({
|
|
346
|
+
...settings,
|
|
347
|
+
currentTaskSlug: task.taskSlug,
|
|
348
|
+
updatedAt: now()
|
|
349
|
+
});
|
|
350
|
+
return `Selected task: ${task.taskSlug}\nstatus: ${task.status}\nbranch: ${task.branch}`;
|
|
351
|
+
}
|
|
352
|
+
async function createTask(taskSlug, title) {
|
|
353
|
+
const project = await ensureProject();
|
|
354
|
+
const task = await deps.taskService.createTask(project.repoRoot, {
|
|
355
|
+
taskSlug,
|
|
356
|
+
title,
|
|
357
|
+
createWorktree: true
|
|
358
|
+
});
|
|
359
|
+
const config = await deps.projectService.loadConfig(project.repoRoot);
|
|
360
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
361
|
+
const preferences = await deps.appSettings.getPreferences();
|
|
362
|
+
const template = preferences.launchTemplate;
|
|
363
|
+
await deps.messageService.updateOrchestrationState({
|
|
364
|
+
repoRoot: project.repoRoot,
|
|
365
|
+
stateRepoRoot: taskRepoRoot,
|
|
366
|
+
stateRoot: config.stateRoot,
|
|
367
|
+
taskSlug: task.taskSlug,
|
|
368
|
+
mode: template.autoOrchestration ? "auto" : "manual"
|
|
369
|
+
});
|
|
370
|
+
const startedRoles = [];
|
|
371
|
+
for (const definition of ROLE_DEFINITIONS) {
|
|
372
|
+
const roleTemplate = template.roles[definition.name];
|
|
373
|
+
try {
|
|
374
|
+
await deps.sessionService.startRoleSession(project.repoRoot, task.taskSlug, definition.name, {
|
|
375
|
+
cols: 100,
|
|
376
|
+
rows: 28,
|
|
377
|
+
permissionMode: roleTemplate.permissionMode,
|
|
378
|
+
model: roleTemplate.model
|
|
379
|
+
});
|
|
380
|
+
startedRoles.push(definition.name);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const settings = await deps.settings.loadSettings();
|
|
384
|
+
await deps.settings.saveSettings({
|
|
385
|
+
...settings,
|
|
386
|
+
currentProjectId: project.repoRoot,
|
|
387
|
+
currentTaskSlug: task.taskSlug,
|
|
388
|
+
translationEnabled: template.translationEnabled,
|
|
389
|
+
updatedAt: now()
|
|
390
|
+
});
|
|
391
|
+
throw new VcmError({
|
|
392
|
+
code: "GATEWAY_TASK_PARTIAL_START",
|
|
393
|
+
message: `Task was created, but ${definition.name} failed to start.`,
|
|
394
|
+
statusCode: 409,
|
|
395
|
+
hint: errorMessage(error)
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const settings = await deps.settings.loadSettings();
|
|
400
|
+
await deps.settings.saveSettings({
|
|
401
|
+
...settings,
|
|
402
|
+
currentProjectId: project.repoRoot,
|
|
403
|
+
currentTaskSlug: task.taskSlug,
|
|
404
|
+
translationEnabled: template.translationEnabled,
|
|
405
|
+
updatedAt: now()
|
|
406
|
+
});
|
|
407
|
+
return [
|
|
408
|
+
`Task created and initialized: ${task.taskSlug}`,
|
|
409
|
+
`branch: ${task.branch}`,
|
|
410
|
+
`worktree: ${task.worktreePath ?? task.repoRoot}`,
|
|
411
|
+
`orchestration: ${template.autoOrchestration ? "auto" : "manual"}`,
|
|
412
|
+
`translation: ${template.translationEnabled ? "on" : "off"}`,
|
|
413
|
+
`sessions: ${startedRoles.join(", ")}`
|
|
414
|
+
].join("\n");
|
|
415
|
+
}
|
|
416
|
+
async function closeTaskPrompt() {
|
|
417
|
+
const project = await ensureProject();
|
|
418
|
+
const settings = await syncDesktopContext(await deps.settings.loadSettings());
|
|
419
|
+
const taskSlug = settings.currentTaskSlug;
|
|
420
|
+
if (!taskSlug) {
|
|
421
|
+
throw new VcmError({
|
|
422
|
+
code: "TASK_NOT_SELECTED",
|
|
423
|
+
message: "No task is selected. Use /tasks and /use-task first.",
|
|
424
|
+
statusCode: 409
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
await deps.taskService.loadTask(project.repoRoot, taskSlug);
|
|
428
|
+
const createdAt = now();
|
|
429
|
+
const expiresAt = new Date(Date.parse(createdAt) + CLOSE_CONFIRM_TTL_MS).toISOString();
|
|
430
|
+
await deps.settings.saveSettings({
|
|
431
|
+
...settings,
|
|
432
|
+
pendingConfirmations: {
|
|
433
|
+
...settings.pendingConfirmations,
|
|
434
|
+
closeTask: {
|
|
435
|
+
taskSlug,
|
|
436
|
+
createdAt,
|
|
437
|
+
expiresAt
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
updatedAt: now()
|
|
441
|
+
});
|
|
442
|
+
return [
|
|
443
|
+
`Close task "${taskSlug}"?`,
|
|
444
|
+
"This stops VCM-managed role sessions and removes the task worktree/branch when owned by the task.",
|
|
445
|
+
`Confirm with: /close-task confirm ${taskSlug}`
|
|
446
|
+
].join("\n");
|
|
447
|
+
}
|
|
448
|
+
async function closeTaskConfirm(taskSlug) {
|
|
449
|
+
const project = await ensureProject();
|
|
450
|
+
const settings = await deps.settings.loadSettings();
|
|
451
|
+
const confirmation = settings.pendingConfirmations.closeTask;
|
|
452
|
+
if (!confirmation || confirmation.taskSlug !== taskSlug || Date.parse(confirmation.expiresAt) < Date.now()) {
|
|
453
|
+
throw new VcmError({
|
|
454
|
+
code: "GATEWAY_CLOSE_CONFIRMATION_INVALID",
|
|
455
|
+
message: `No active close confirmation for ${taskSlug}. Run /close-task first.`,
|
|
456
|
+
statusCode: 409
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (settings.currentTaskSlug !== taskSlug) {
|
|
460
|
+
throw new VcmError({
|
|
461
|
+
code: "GATEWAY_CLOSE_TASK_MISMATCH",
|
|
462
|
+
message: `Current task is ${settings.currentTaskSlug ?? "not selected"}.`,
|
|
463
|
+
statusCode: 409,
|
|
464
|
+
hint: settings.currentTaskSlug ? `Use /close-task confirm ${settings.currentTaskSlug}` : undefined
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
|
|
468
|
+
await stopRunningRoleSessions(project.repoRoot, taskSlug);
|
|
469
|
+
await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), taskSlug, { clearCache: true });
|
|
470
|
+
deps.roundService.stopTask(taskSlug);
|
|
471
|
+
const result = await deps.taskService.cleanupTask(project.repoRoot, taskSlug, {
|
|
472
|
+
force: true,
|
|
473
|
+
deleteBranch: Boolean(task.worktreePath),
|
|
474
|
+
forceDeleteBranch: true
|
|
475
|
+
});
|
|
476
|
+
await deps.settings.saveSettings({
|
|
477
|
+
...settings,
|
|
478
|
+
currentTaskSlug: null,
|
|
479
|
+
pendingConfirmations: {
|
|
480
|
+
...settings.pendingConfirmations,
|
|
481
|
+
closeTask: null
|
|
482
|
+
},
|
|
483
|
+
updatedAt: now()
|
|
484
|
+
});
|
|
485
|
+
return [
|
|
486
|
+
`Closed task: ${result.taskSlug}`,
|
|
487
|
+
result.removedWorktreePath ? `removed worktree: ${result.removedWorktreePath}` : "removed worktree: none",
|
|
488
|
+
result.deletedBranch ? `deleted branch: ${result.deletedBranch}` : "deleted branch: none",
|
|
489
|
+
`removed state paths: ${result.removedStatePaths.length}`
|
|
490
|
+
].join("\n");
|
|
491
|
+
}
|
|
492
|
+
async function stopRunningRoleSessions(repoRoot, taskSlug) {
|
|
493
|
+
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
494
|
+
for (const session of sessions) {
|
|
495
|
+
if (session.status === "running") {
|
|
496
|
+
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function setGatewayTranslation(enabled) {
|
|
501
|
+
const settings = await deps.settings.updateSettings({ translationEnabled: enabled });
|
|
502
|
+
return `Gateway translation ${settings.translationEnabled ? "on" : "off"}.`;
|
|
503
|
+
}
|
|
504
|
+
async function sendPlainTextToPm(text) {
|
|
505
|
+
if (!text.trim()) {
|
|
506
|
+
return "Empty message ignored.";
|
|
507
|
+
}
|
|
508
|
+
const project = await ensureProject();
|
|
509
|
+
const settings = await syncDesktopContext(await deps.settings.loadSettings());
|
|
510
|
+
if (!settings.currentTaskSlug) {
|
|
511
|
+
throw new VcmError({
|
|
512
|
+
code: "TASK_NOT_SELECTED",
|
|
513
|
+
message: "No task is selected. Use /tasks and /use-task first.",
|
|
514
|
+
statusCode: 409
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const task = await deps.taskService.loadTask(project.repoRoot, settings.currentTaskSlug);
|
|
518
|
+
const session = await deps.sessionService.getRoleSession(project.repoRoot, task.taskSlug, "project-manager");
|
|
519
|
+
if (!session || session.status !== "running") {
|
|
520
|
+
throw new VcmError({
|
|
521
|
+
code: "PM_SESSION_NOT_RUNNING",
|
|
522
|
+
message: "The current task's PM session is not running.",
|
|
523
|
+
statusCode: 409
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (session.activityStatus === "running") {
|
|
527
|
+
return "PM is still working on the current turn. Please wait and send again later.";
|
|
528
|
+
}
|
|
529
|
+
const englishText = settings.translationEnabled
|
|
530
|
+
? (await deps.translationService.translateUserInput({
|
|
531
|
+
repoRoot: project.repoRoot,
|
|
532
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
533
|
+
taskSlug: task.taskSlug,
|
|
534
|
+
role: "project-manager",
|
|
535
|
+
text,
|
|
536
|
+
useContext: false,
|
|
537
|
+
send: false
|
|
538
|
+
})).englishPreview
|
|
539
|
+
: text;
|
|
540
|
+
await submitTerminalInput(deps.runtime, session.id, `[VCM Gateway]\n${englishText}`);
|
|
541
|
+
return "Sent to PM.";
|
|
542
|
+
}
|
|
543
|
+
async function reply(settings, userId, text) {
|
|
544
|
+
const account = toAccount(settings);
|
|
545
|
+
if (!account) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const contextToken = settings.binding.contextTokens[userId];
|
|
549
|
+
await deps.channel.sendText({
|
|
550
|
+
account,
|
|
551
|
+
toUserId: userId,
|
|
552
|
+
contextToken,
|
|
553
|
+
text
|
|
554
|
+
});
|
|
555
|
+
await recordMessageStatus("outbound", "ok", text);
|
|
556
|
+
}
|
|
557
|
+
async function recordMessageStatus(direction, result, preview, error, command) {
|
|
558
|
+
const settings = await deps.settings.loadSettings();
|
|
559
|
+
await deps.settings.saveSettings({
|
|
560
|
+
...settings,
|
|
561
|
+
lastMessageStatus: {
|
|
562
|
+
checkedAt: now(),
|
|
563
|
+
direction,
|
|
564
|
+
result,
|
|
565
|
+
preview: preview.slice(0, 160),
|
|
566
|
+
error,
|
|
567
|
+
command
|
|
568
|
+
},
|
|
569
|
+
updatedAt: now()
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async function statusText(settings) {
|
|
573
|
+
const synced = await syncDesktopContext(settings);
|
|
574
|
+
const project = await deps.projectService.getCurrentProject();
|
|
575
|
+
return [
|
|
576
|
+
`Gateway: ${synced.enabled ? "on" : "off"}${isRunning() ? " / polling" : ""}`,
|
|
577
|
+
`Binding: ${synced.binding.boundUserId ? "bound" : "not bound"}`,
|
|
578
|
+
`Translation: ${synced.translationEnabled ? "on" : "off"}`,
|
|
579
|
+
`Project: ${project?.repoRoot ?? synced.currentProjectId ?? "none"}`,
|
|
580
|
+
`Task: ${synced.currentTaskSlug ?? "none"}`,
|
|
581
|
+
`Last poll: ${synced.lastPollStatus.state}${synced.lastPollStatus.error ? ` (${synced.lastPollStatus.error})` : ""}`
|
|
582
|
+
].join("\n");
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
async start() {
|
|
586
|
+
await ensurePolling();
|
|
587
|
+
},
|
|
588
|
+
stop() {
|
|
589
|
+
void stopPolling();
|
|
590
|
+
},
|
|
591
|
+
async getStatus() {
|
|
592
|
+
return deps.settings.expose(await syncDesktopContext(await deps.settings.loadSettings()), isRunning());
|
|
593
|
+
},
|
|
594
|
+
async updateSettings(input) {
|
|
595
|
+
let settings = await deps.settings.updateSettings(input);
|
|
596
|
+
if (settings.enabled) {
|
|
597
|
+
settings = await syncDesktopContext(settings);
|
|
598
|
+
}
|
|
599
|
+
if (settings.enabled) {
|
|
600
|
+
await ensurePolling();
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
await stopPolling();
|
|
604
|
+
}
|
|
605
|
+
return deps.settings.expose(settings, isRunning());
|
|
606
|
+
},
|
|
607
|
+
async resetBinding() {
|
|
608
|
+
await stopPolling();
|
|
609
|
+
const settings = await deps.settings.resetBinding();
|
|
610
|
+
return deps.settings.expose(settings, isRunning());
|
|
611
|
+
},
|
|
612
|
+
async startQrLogin() {
|
|
613
|
+
const settings = await deps.settings.loadSettings();
|
|
614
|
+
const login = await deps.channel.startQrLogin({
|
|
615
|
+
localTokenList: settings.binding.token ? [settings.binding.token] : []
|
|
616
|
+
});
|
|
617
|
+
qrLogin = {
|
|
618
|
+
qrcode: login.qrcode,
|
|
619
|
+
qrcodeUrl: login.qrcodeUrl,
|
|
620
|
+
baseUrl: settings.binding.baseUrl || DEFAULT_BASE_URL,
|
|
621
|
+
expiresAt: new Date(Date.now() + QR_LOGIN_TTL_MS).toISOString()
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
status: "wait",
|
|
625
|
+
qrcode: login.qrcode,
|
|
626
|
+
qrcodeUrl: login.qrcodeUrl,
|
|
627
|
+
expiresAt: qrLogin.expiresAt
|
|
628
|
+
};
|
|
629
|
+
},
|
|
630
|
+
async checkQrLogin(input = {}) {
|
|
631
|
+
if (!qrLogin || Date.parse(qrLogin.expiresAt) < Date.now()) {
|
|
632
|
+
return {
|
|
633
|
+
status: "expired",
|
|
634
|
+
message: "QR login expired. Start a new QR login."
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const result = await deps.channel.checkQrLogin({
|
|
638
|
+
baseUrl: qrLogin.baseUrl,
|
|
639
|
+
qrcode: qrLogin.qrcode,
|
|
640
|
+
verifyCode: input.verifyCode
|
|
641
|
+
});
|
|
642
|
+
if (result.status === "scaned_but_redirect" && result.redirectHost) {
|
|
643
|
+
qrLogin = {
|
|
644
|
+
...qrLogin,
|
|
645
|
+
baseUrl: normalizeBaseUrl(result.redirectHost)
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (result.status === "confirmed" || result.status === "binded_redirect") {
|
|
649
|
+
const settings = await deps.settings.loadSettings();
|
|
650
|
+
const token = result.token ?? settings.binding.token;
|
|
651
|
+
if (token) {
|
|
652
|
+
await deps.settings.saveSettings({
|
|
653
|
+
...settings,
|
|
654
|
+
binding: {
|
|
655
|
+
...settings.binding,
|
|
656
|
+
accountId: result.accountId ?? settings.binding.accountId,
|
|
657
|
+
baseUrl: normalizeBaseUrl(result.baseUrl ?? settings.binding.baseUrl),
|
|
658
|
+
loginUserId: result.loginUserId ?? settings.binding.loginUserId,
|
|
659
|
+
boundUserId: result.loginUserId ?? settings.binding.boundUserId,
|
|
660
|
+
token
|
|
661
|
+
},
|
|
662
|
+
updatedAt: now()
|
|
663
|
+
});
|
|
664
|
+
qrLogin = null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
status: result.status,
|
|
669
|
+
qrcodeUrl: qrLogin?.qrcodeUrl,
|
|
670
|
+
accountId: result.accountId,
|
|
671
|
+
boundUserId: result.loginUserId,
|
|
672
|
+
loginUserId: result.loginUserId
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
async handlePmStop(input) {
|
|
676
|
+
if (input.session.role !== "project-manager") {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const settings = await deps.settings.loadSettings();
|
|
680
|
+
const account = toAccount(settings);
|
|
681
|
+
const boundUserId = settings.binding.boundUserId;
|
|
682
|
+
if (!settings.enabled || !account || !boundUserId) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
|
|
686
|
+
if (!transcriptPath) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
|
|
690
|
+
const cursor = settings.pushCursors[cursorKey];
|
|
691
|
+
const events = await readTranscriptTextEvents(transcriptPath);
|
|
692
|
+
const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId);
|
|
693
|
+
if (nextEvents.length === 0) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const text = nextEvents.map((event) => event.text).join("\n\n").trim();
|
|
697
|
+
if (!text) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
let output = text;
|
|
701
|
+
if (settings.translationEnabled) {
|
|
702
|
+
try {
|
|
703
|
+
output = await deps.translationService.translateGatewayOutput({
|
|
704
|
+
repoRoot: input.repoRoot,
|
|
705
|
+
taskSlug: input.taskSlug,
|
|
706
|
+
role: "project-manager",
|
|
707
|
+
text
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
output = `${text}\n\n[Translation failed; original PM reply shown.]`;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
await deps.channel.sendText({
|
|
715
|
+
account,
|
|
716
|
+
toUserId: boundUserId,
|
|
717
|
+
contextToken: settings.binding.contextTokens[boundUserId],
|
|
718
|
+
text: output
|
|
719
|
+
});
|
|
720
|
+
const lastEvent = nextEvents.at(-1);
|
|
721
|
+
const current = await deps.settings.loadSettings();
|
|
722
|
+
await deps.settings.saveSettings({
|
|
723
|
+
...current,
|
|
724
|
+
pushCursors: {
|
|
725
|
+
...current.pushCursors,
|
|
726
|
+
[cursorKey]: {
|
|
727
|
+
lastTranscriptEventId: lastEvent?.id ?? null,
|
|
728
|
+
lastTranscriptTimestamp: lastEvent?.timestamp ?? null
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
lastMessageStatus: {
|
|
732
|
+
checkedAt: now(),
|
|
733
|
+
direction: "outbound",
|
|
734
|
+
result: "ok",
|
|
735
|
+
command: "pm-stop",
|
|
736
|
+
preview: output.slice(0, 160)
|
|
737
|
+
},
|
|
738
|
+
updatedAt: now()
|
|
739
|
+
});
|
|
740
|
+
await deps.audit.record({
|
|
741
|
+
type: "gateway.pm_push",
|
|
742
|
+
result: "ok",
|
|
743
|
+
command: "pm-stop",
|
|
744
|
+
preview: output
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
async function readTranscriptTextEvents(transcriptPath) {
|
|
750
|
+
const raw = await readFile(transcriptPath, "utf8");
|
|
751
|
+
const events = [];
|
|
752
|
+
for (const line of raw.split("\n")) {
|
|
753
|
+
if (!line.trim()) {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
for (const event of parseAssistantContent(line)) {
|
|
757
|
+
if (event.kind === "text") {
|
|
758
|
+
events.push({
|
|
759
|
+
id: event.id,
|
|
760
|
+
timestamp: event.timestamp,
|
|
761
|
+
text: event.text
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return events;
|
|
767
|
+
}
|
|
768
|
+
function selectEventsAfterCursor(events, cursorId) {
|
|
769
|
+
if (events.length === 0) {
|
|
770
|
+
return [];
|
|
771
|
+
}
|
|
772
|
+
if (!cursorId) {
|
|
773
|
+
return [events[events.length - 1]];
|
|
774
|
+
}
|
|
775
|
+
const index = events.findIndex((event) => event.id === cursorId);
|
|
776
|
+
if (index < 0) {
|
|
777
|
+
return [events[events.length - 1]];
|
|
778
|
+
}
|
|
779
|
+
return events.slice(index + 1);
|
|
780
|
+
}
|
|
781
|
+
function projectsText(projects) {
|
|
782
|
+
if (projects.length === 0) {
|
|
783
|
+
return "No recent projects. Connect a repository from desktop VCM first.";
|
|
784
|
+
}
|
|
785
|
+
return projects.map((repoPath, index) => `${index + 1}. ${repoPath}`).join("\n");
|
|
786
|
+
}
|
|
787
|
+
function helpText() {
|
|
788
|
+
return [
|
|
789
|
+
"VCM Gateway commands:",
|
|
790
|
+
"/status",
|
|
791
|
+
"/projects",
|
|
792
|
+
"/use-project <index-or-path>",
|
|
793
|
+
"/pull-current",
|
|
794
|
+
"/tasks",
|
|
795
|
+
"/use-task <index-or-task-slug>",
|
|
796
|
+
"/create-task <task-slug> [title]",
|
|
797
|
+
"/close-task",
|
|
798
|
+
"/close-task confirm <task-slug>",
|
|
799
|
+
"/translate on",
|
|
800
|
+
"/translate off"
|
|
801
|
+
].join("\n");
|
|
802
|
+
}
|
|
803
|
+
function formatAheadBehind(project) {
|
|
804
|
+
if (!project.upstreamBranch) {
|
|
805
|
+
return "no upstream";
|
|
806
|
+
}
|
|
807
|
+
const ahead = project.ahead ?? 0;
|
|
808
|
+
const behind = project.behind ?? 0;
|
|
809
|
+
if (ahead === 0 && behind === 0) {
|
|
810
|
+
return "up to date";
|
|
811
|
+
}
|
|
812
|
+
if (ahead > 0 && behind > 0) {
|
|
813
|
+
return `ahead ${ahead}, behind ${behind}`;
|
|
814
|
+
}
|
|
815
|
+
return ahead > 0 ? `ahead ${ahead}` : `behind ${behind}`;
|
|
816
|
+
}
|
|
817
|
+
function sleep(ms, signal) {
|
|
818
|
+
return new Promise((resolve) => {
|
|
819
|
+
if (signal.aborted) {
|
|
820
|
+
resolve();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const timeout = setTimeout(resolve, ms);
|
|
824
|
+
signal.addEventListener("abort", () => {
|
|
825
|
+
clearTimeout(timeout);
|
|
826
|
+
resolve();
|
|
827
|
+
}, { once: true });
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
function normalizeBaseUrl(input) {
|
|
831
|
+
const trimmed = input.trim();
|
|
832
|
+
if (!trimmed) {
|
|
833
|
+
return DEFAULT_BASE_URL;
|
|
834
|
+
}
|
|
835
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
836
|
+
return trimmed.replace(/\/+$/, "");
|
|
837
|
+
}
|
|
838
|
+
return `https://${trimmed.replace(/\/+$/, "")}`;
|
|
839
|
+
}
|
|
840
|
+
function errorMessage(error) {
|
|
841
|
+
if (error instanceof VcmError) {
|
|
842
|
+
return error.hint ? `${error.message} ${error.hint}` : error.message;
|
|
843
|
+
}
|
|
844
|
+
if (error instanceof Error) {
|
|
845
|
+
return error.message;
|
|
846
|
+
}
|
|
847
|
+
return String(error);
|
|
848
|
+
}
|