vibe-coding-master 0.2.5 → 0.2.7

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.
@@ -0,0 +1,214 @@
1
+ import path from "node:path";
2
+ import { homedir } from "node:os";
3
+ const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
4
+ const MAX_DEDUPE_IDS = 1000;
5
+ export function createGatewaySettingsService(deps) {
6
+ const settingsPath = deps.settingsPath ?? path.join(homedir(), ".vcm", "gateway", "settings.json");
7
+ const auditPath = deps.auditPath ?? path.join(homedir(), ".vcm", "gateway", "audit.jsonl");
8
+ const now = deps.now ?? (() => new Date().toISOString());
9
+ let cachedSettings = null;
10
+ async function loadSettings() {
11
+ if (cachedSettings) {
12
+ return cachedSettings;
13
+ }
14
+ if (!(await deps.fs.pathExists(settingsPath))) {
15
+ cachedSettings = normalizeSettings({}, now());
16
+ await saveSettings(cachedSettings);
17
+ return cachedSettings;
18
+ }
19
+ cachedSettings = normalizeSettings(await deps.fs.readJson(settingsPath), now());
20
+ return cachedSettings;
21
+ }
22
+ async function saveSettings(settings) {
23
+ cachedSettings = normalizeSettings(settings, now());
24
+ await deps.fs.writeJsonAtomic(settingsPath, cachedSettings);
25
+ return cachedSettings;
26
+ }
27
+ return {
28
+ loadSettings,
29
+ async updateSettings(input) {
30
+ const current = await loadSettings();
31
+ return saveSettings({
32
+ ...current,
33
+ enabled: input.enabled ?? current.enabled,
34
+ translationEnabled: input.translationEnabled ?? current.translationEnabled,
35
+ currentProjectId: input.currentProjectId !== undefined ? normalizeNullableString(input.currentProjectId) : current.currentProjectId,
36
+ currentTaskSlug: input.currentTaskSlug !== undefined ? normalizeNullableString(input.currentTaskSlug) : current.currentTaskSlug,
37
+ updatedAt: now()
38
+ });
39
+ },
40
+ saveSettings,
41
+ async resetBinding() {
42
+ const current = await loadSettings();
43
+ return saveSettings({
44
+ ...current,
45
+ enabled: false,
46
+ binding: createDefaultBinding(),
47
+ dedupe: {
48
+ recentInboundMessageIds: []
49
+ },
50
+ pushCursors: {},
51
+ lastPollStatus: {
52
+ state: "idle"
53
+ },
54
+ lastMessageStatus: null,
55
+ updatedAt: now()
56
+ });
57
+ },
58
+ expose(settings, running = false) {
59
+ return {
60
+ version: 1,
61
+ enabled: settings.enabled,
62
+ running,
63
+ channel: settings.channel,
64
+ translationEnabled: settings.translationEnabled,
65
+ currentProjectId: settings.currentProjectId,
66
+ currentTaskSlug: settings.currentTaskSlug,
67
+ binding: {
68
+ accountId: settings.binding.accountId,
69
+ baseUrl: settings.binding.baseUrl,
70
+ boundUserId: settings.binding.boundUserId,
71
+ loginUserId: settings.binding.loginUserId,
72
+ tokenConfigured: Boolean(settings.binding.token)
73
+ },
74
+ pendingConfirmations: settings.pendingConfirmations,
75
+ lastPollStatus: settings.lastPollStatus,
76
+ lastMessageStatus: settings.lastMessageStatus,
77
+ updatedAt: settings.updatedAt
78
+ };
79
+ },
80
+ getSettingsPath() {
81
+ return settingsPath;
82
+ },
83
+ getAuditPath() {
84
+ return auditPath;
85
+ }
86
+ };
87
+ }
88
+ export function normalizeSettings(input, timestamp) {
89
+ const bindingInput = isObject(input.binding) ? input.binding : {};
90
+ const dedupeInput = isObject(input.dedupe) ? input.dedupe : {};
91
+ const pendingInput = isObject(input.pendingConfirmations)
92
+ ? input.pendingConfirmations
93
+ : {};
94
+ const pushCursors = isObject(input.pushCursors) ? input.pushCursors : {};
95
+ return {
96
+ version: 1,
97
+ enabled: input.enabled === true,
98
+ channel: "weixin-ilink",
99
+ translationEnabled: input.translationEnabled !== false,
100
+ currentProjectId: normalizeNullableString(input.currentProjectId),
101
+ currentTaskSlug: normalizeNullableString(input.currentTaskSlug),
102
+ binding: {
103
+ ...createDefaultBinding(),
104
+ accountId: normalizeNullableString(bindingInput.accountId),
105
+ baseUrl: normalizeBaseUrl(bindingInput.baseUrl),
106
+ boundUserId: normalizeNullableString(bindingInput.boundUserId),
107
+ loginUserId: normalizeNullableString(bindingInput.loginUserId),
108
+ token: normalizeNullableString(bindingInput.token),
109
+ getUpdatesBuf: typeof bindingInput.getUpdatesBuf === "string" ? bindingInput.getUpdatesBuf : "",
110
+ contextTokens: isObject(bindingInput.contextTokens)
111
+ ? normalizeStringRecord(bindingInput.contextTokens)
112
+ : {}
113
+ },
114
+ dedupe: {
115
+ recentInboundMessageIds: Array.isArray(dedupeInput.recentInboundMessageIds)
116
+ ? dedupeInput.recentInboundMessageIds.filter((value) => typeof value === "string").slice(-MAX_DEDUPE_IDS)
117
+ : []
118
+ },
119
+ pendingConfirmations: {
120
+ closeTask: normalizeCloseTaskConfirmation(pendingInput.closeTask)
121
+ },
122
+ pushCursors: normalizePushCursors(pushCursors),
123
+ lastPollStatus: normalizePollStatus(input.lastPollStatus),
124
+ lastMessageStatus: normalizeMessageStatus(input.lastMessageStatus),
125
+ updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : timestamp
126
+ };
127
+ }
128
+ function createDefaultBinding() {
129
+ return {
130
+ accountId: null,
131
+ baseUrl: DEFAULT_BASE_URL,
132
+ boundUserId: null,
133
+ loginUserId: null,
134
+ token: null,
135
+ getUpdatesBuf: "",
136
+ contextTokens: {}
137
+ };
138
+ }
139
+ function normalizeCloseTaskConfirmation(input) {
140
+ if (!isObject(input)) {
141
+ return null;
142
+ }
143
+ const taskSlug = normalizeNullableString(input.taskSlug);
144
+ const createdAt = normalizeNullableString(input.createdAt);
145
+ const expiresAt = normalizeNullableString(input.expiresAt);
146
+ if (!taskSlug || !createdAt || !expiresAt) {
147
+ return null;
148
+ }
149
+ return { taskSlug, createdAt, expiresAt };
150
+ }
151
+ function normalizePushCursors(input) {
152
+ const out = {};
153
+ for (const [key, value] of Object.entries(input)) {
154
+ if (!isObject(value)) {
155
+ continue;
156
+ }
157
+ out[key] = {
158
+ lastTranscriptEventId: normalizeNullableString(value.lastTranscriptEventId),
159
+ lastTranscriptTimestamp: normalizeNullableString(value.lastTranscriptTimestamp)
160
+ };
161
+ }
162
+ return out;
163
+ }
164
+ function normalizePollStatus(input) {
165
+ if (!isObject(input)) {
166
+ return { state: "idle" };
167
+ }
168
+ const state = input.state === "running" || input.state === "error" || input.state === "expired"
169
+ ? input.state
170
+ : "idle";
171
+ return {
172
+ state,
173
+ checkedAt: normalizeNullableString(input.checkedAt) ?? undefined,
174
+ error: normalizeNullableString(input.error) ?? undefined
175
+ };
176
+ }
177
+ function normalizeMessageStatus(input) {
178
+ if (!isObject(input)) {
179
+ return null;
180
+ }
181
+ return {
182
+ checkedAt: normalizeNullableString(input.checkedAt) ?? undefined,
183
+ direction: input.direction === "inbound" || input.direction === "outbound" ? input.direction : undefined,
184
+ command: normalizeNullableString(input.command) ?? undefined,
185
+ result: input.result === "ok" || input.result === "ignored" || input.result === "error" ? input.result : undefined,
186
+ preview: normalizeNullableString(input.preview) ?? undefined,
187
+ error: normalizeNullableString(input.error) ?? undefined
188
+ };
189
+ }
190
+ function normalizeBaseUrl(input) {
191
+ const raw = typeof input === "string" ? input.trim() : "";
192
+ if (!raw) {
193
+ return DEFAULT_BASE_URL;
194
+ }
195
+ if (raw.startsWith("http://") || raw.startsWith("https://")) {
196
+ return raw.replace(/\/+$/, "");
197
+ }
198
+ return `https://${raw.replace(/\/+$/, "")}`;
199
+ }
200
+ function normalizeNullableString(input) {
201
+ return typeof input === "string" && input.trim() ? input.trim() : null;
202
+ }
203
+ function normalizeStringRecord(input) {
204
+ const out = {};
205
+ for (const [key, value] of Object.entries(input)) {
206
+ if (typeof value === "string") {
207
+ out[key] = value;
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ function isObject(value) {
213
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
214
+ }
@@ -14,6 +14,11 @@ import { createHarnessService, createScriptFixedHarnessInstaller } from "./servi
14
14
  import { createNodeFileSystemAdapter } from "./adapters/filesystem.js";
15
15
  import { createNodePtyTerminalRuntime } from "./runtime/node-pty-runtime.js";
16
16
  import { createOpenAiCompatibleTranslationProvider } from "./adapters/translation-provider.js";
17
+ import { registerGatewayRoutes } from "./api/gateway-routes.js";
18
+ import { createWeixinIlinkChannel } from "./gateway/channels/weixin-ilink-channel.js";
19
+ import { createGatewayAuditLog } from "./gateway/gateway-audit-log.js";
20
+ import { createGatewayService } from "./gateway/gateway-service.js";
21
+ import { createGatewaySettingsService } from "./gateway/gateway-settings-service.js";
17
22
  import { createProjectService } from "./services/project-service.js";
18
23
  import { createSessionRegistry } from "./runtime/session-registry.js";
19
24
  import { createSessionService } from "./services/session-service.js";
@@ -90,7 +95,14 @@ export async function createServer(deps, options = {}) {
90
95
  taskService: deps.taskService,
91
96
  translationService: deps.translationService
92
97
  });
98
+ registerGatewayRoutes(app, { gatewayService: deps.gatewayService });
93
99
  registerTerminalWs(app, { runtime: deps.runtime });
100
+ app.addHook("onReady", async () => {
101
+ await deps.gatewayService.start();
102
+ });
103
+ app.addHook("onClose", async () => {
104
+ deps.gatewayService.stop();
105
+ });
94
106
  if (options.staticDir) {
95
107
  await app.register(fastifyStatic, {
96
108
  root: options.staticDir,
@@ -162,7 +174,12 @@ export function createDefaultServerDeps(options = {}) {
162
174
  sessionService,
163
175
  taskService
164
176
  });
165
- const roundService = createRoundService({ fs });
177
+ const roundService = createRoundService({
178
+ fs,
179
+ onSessionStatusChange: async ({ repoRoot, taskSlug, status }) => {
180
+ await taskService.updateTaskStatus(repoRoot, taskSlug, status);
181
+ }
182
+ });
166
183
  const transcripts = createClaudeTranscriptService();
167
184
  const translationService = createTranslationService({
168
185
  runtime,
@@ -174,6 +191,25 @@ export function createDefaultServerDeps(options = {}) {
174
191
  appSettings,
175
192
  provider: createOpenAiCompatibleTranslationProvider()
176
193
  });
194
+ const gatewaySettings = createGatewaySettingsService({ fs });
195
+ const gatewayAudit = createGatewayAuditLog({
196
+ fs,
197
+ auditPath: gatewaySettings.getAuditPath()
198
+ });
199
+ const gatewayService = createGatewayService({
200
+ fs,
201
+ settings: gatewaySettings,
202
+ audit: gatewayAudit,
203
+ channel: createWeixinIlinkChannel(),
204
+ projectService,
205
+ taskService,
206
+ sessionService,
207
+ messageService,
208
+ translationService,
209
+ roundService,
210
+ runtime,
211
+ appSettings
212
+ });
177
213
  const claudeHookService = createClaudeHookService({
178
214
  projectService,
179
215
  taskService,
@@ -181,7 +217,8 @@ export function createDefaultServerDeps(options = {}) {
181
217
  messageService,
182
218
  roundService,
183
219
  translationService,
184
- appSettings
220
+ appSettings,
221
+ gatewayService
185
222
  });
186
223
  return {
187
224
  appSettings,
@@ -196,6 +233,7 @@ export function createDefaultServerDeps(options = {}) {
196
233
  roundService,
197
234
  statusService,
198
235
  translationService,
236
+ gatewayService,
199
237
  runtime
200
238
  };
201
239
  }
@@ -43,6 +43,7 @@ export function createClaudeHookService(deps) {
43
43
  cwd: stringOrUndefined(input.event.cwd)
44
44
  });
45
45
  await deps.roundService.recordClaudeHookEvent({
46
+ repoRoot: context.project.repoRoot,
46
47
  stateRepoRoot: context.taskRepoRoot,
47
48
  stateRoot: context.config.stateRoot,
48
49
  taskSlug: input.taskSlug,
@@ -57,7 +58,7 @@ export function createClaudeHookService(deps) {
57
58
  role: input.role,
58
59
  sessionId: session.id,
59
60
  boundaryKind: "start",
60
- occurredAt: session.lastPromptSubmittedAt ?? session.updatedAt
61
+ occurredAt: session.lastTurnStartedAt ?? session.updatedAt
61
62
  });
62
63
  }
63
64
  const submitted = await deps.messageService.confirmPromptSubmitted({
@@ -112,6 +113,7 @@ export function createClaudeHookService(deps) {
112
113
  cwd: stringOrUndefined(input.event.cwd)
113
114
  });
114
115
  await deps.roundService.recordClaudeHookEvent({
116
+ repoRoot: context.project.repoRoot,
115
117
  stateRepoRoot: context.taskRepoRoot,
116
118
  stateRoot: context.config.stateRoot,
117
119
  taskSlug: input.taskSlug,
@@ -120,12 +122,12 @@ export function createClaudeHookService(deps) {
120
122
  settleGuard: async () => {
121
123
  const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
122
124
  if (pending.length === 0) {
123
- return { action: "pause" };
125
+ return { action: "stop" };
124
126
  }
125
127
  const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
126
128
  return retried.some((result) => result.delivered)
127
129
  ? { action: "continue", reason: "pending route message dispatched" }
128
- : { action: "pause" };
130
+ : { action: "stop" };
129
131
  }
130
132
  });
131
133
  if (session) {
@@ -136,9 +138,16 @@ export function createClaudeHookService(deps) {
136
138
  role: input.role,
137
139
  sessionId: session.id,
138
140
  boundaryKind: "end",
139
- occurredAt: session.lastStopAt ?? session.updatedAt
141
+ occurredAt: session.lastTurnEndedAt ?? session.updatedAt
140
142
  });
141
143
  }
144
+ if (session && input.role === "project-manager") {
145
+ void deps.gatewayService?.handlePmStop({
146
+ repoRoot: context.project.repoRoot,
147
+ taskSlug: input.taskSlug,
148
+ session
149
+ }).catch(() => undefined);
150
+ }
142
151
  const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput);
143
152
  return {
144
153
  ok: true,
@@ -33,38 +33,43 @@ export function createProjectService(deps) {
33
33
  await deps.fs.ensureDir(path.join(this.getProjectDataRoot(repoRoot), "tasks"));
34
34
  await deps.fs.ensureDir(path.join(repoRoot, ".claude", "worktrees"));
35
35
  await this.saveConfig(config, true);
36
- const warnings = [];
37
- let branch = "unknown";
38
- let isDirty = false;
39
- try {
40
- branch = await deps.git.getCurrentBranch(repoRoot);
41
- }
42
- catch (caught) {
43
- warnings.push(`Unable to read current Git branch. ${getErrorHint(caught)}`);
44
- }
45
- try {
46
- isDirty = await deps.git.isDirty(repoRoot);
47
- }
48
- catch (caught) {
49
- warnings.push(`Unable to read Git dirty status. ${getErrorHint(caught)}`);
50
- }
51
- if (branch === "main" || branch === "master") {
52
- warnings.push(`You are on ${branch}. Consider creating a task branch before coding.`);
53
- }
54
- if (!(await deps.claude.isAvailable(config.claudeCommand))) {
55
- warnings.push("Claude Code command is not available. You can still inspect artifacts, but sessions will not start.");
56
- }
57
- currentProject = {
58
- repoRoot,
59
- branch,
60
- isDirty,
61
- config,
62
- warnings
63
- };
36
+ currentProject = await readConnectedProjectSummary(repoRoot, config);
64
37
  await deps.appSettings.recordRecentRepositoryPath(repoRoot);
65
38
  return currentProject;
66
39
  },
67
40
  async getCurrentProject() {
41
+ if (currentProject) {
42
+ currentProject = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
43
+ }
44
+ return currentProject;
45
+ },
46
+ async pullCurrentProject() {
47
+ if (!currentProject) {
48
+ throw new VcmError({
49
+ code: "NO_PROJECT",
50
+ message: "No connected repository.",
51
+ statusCode: 409
52
+ });
53
+ }
54
+ const beforePull = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
55
+ if (beforePull.isDirty) {
56
+ throw new VcmError({
57
+ code: "BASE_REPO_DIRTY",
58
+ message: "The connected repository has uncommitted changes.",
59
+ statusCode: 409,
60
+ hint: "Commit, stash, or discard base repository changes before pulling."
61
+ });
62
+ }
63
+ if (!beforePull.upstreamBranch) {
64
+ throw new VcmError({
65
+ code: "NO_UPSTREAM",
66
+ message: "The connected repository branch has no upstream.",
67
+ statusCode: 409,
68
+ hint: "Set an upstream branch before using Pull."
69
+ });
70
+ }
71
+ await deps.git.pullFastForward(currentProject.repoRoot);
72
+ currentProject = await readConnectedProjectSummary(currentProject.repoRoot, currentProject.config);
68
73
  return currentProject;
69
74
  },
70
75
  async getRecentRepositoryPaths() {
@@ -92,6 +97,77 @@ export function createProjectService(deps) {
92
97
  return path.dirname(deps.appSettings.getProjectConfigPath(repoRoot));
93
98
  }
94
99
  };
100
+ async function readConnectedProjectSummary(repoRoot, config) {
101
+ const warnings = [];
102
+ let branch = "unknown";
103
+ let isDirty = false;
104
+ let headCommit = "unknown";
105
+ let upstreamBranch = null;
106
+ let ahead = null;
107
+ let behind = null;
108
+ try {
109
+ branch = await deps.git.getCurrentBranch(repoRoot);
110
+ }
111
+ catch (caught) {
112
+ warnings.push(`Unable to read current Git branch. ${getErrorHint(caught)}`);
113
+ }
114
+ try {
115
+ isDirty = await deps.git.isDirty(repoRoot);
116
+ }
117
+ catch (caught) {
118
+ warnings.push(`Unable to read Git dirty status. ${getErrorHint(caught)}`);
119
+ }
120
+ try {
121
+ headCommit = await deps.git.getHeadCommit(repoRoot);
122
+ }
123
+ catch (caught) {
124
+ warnings.push(`Unable to read current Git commit. ${getErrorHint(caught)}`);
125
+ }
126
+ try {
127
+ upstreamBranch = await deps.git.getUpstreamBranch(repoRoot);
128
+ }
129
+ catch (caught) {
130
+ warnings.push(`Unable to read Git upstream branch. ${getErrorHint(caught)}`);
131
+ }
132
+ if (upstreamBranch) {
133
+ try {
134
+ const status = await deps.git.getAheadBehind(repoRoot, upstreamBranch);
135
+ ahead = status.ahead;
136
+ behind = status.behind;
137
+ }
138
+ catch (caught) {
139
+ warnings.push(`Unable to read Git ahead/behind status. ${getErrorHint(caught)}`);
140
+ }
141
+ }
142
+ if (!(await deps.claude.isAvailable(config.claudeCommand))) {
143
+ warnings.push("Claude Code command is not available. You can still inspect artifacts, but sessions will not start.");
144
+ }
145
+ const pullDisabledReason = getPullDisabledReason({ isDirty, upstreamBranch });
146
+ return {
147
+ repoRoot,
148
+ branch,
149
+ isDirty,
150
+ headCommit,
151
+ shortHeadCommit: headCommit === "unknown" ? "unknown" : headCommit.slice(0, 12),
152
+ upstreamBranch,
153
+ ahead,
154
+ behind,
155
+ canPull: !pullDisabledReason,
156
+ pullDisabledReason,
157
+ checkedAt: new Date().toISOString(),
158
+ config,
159
+ warnings
160
+ };
161
+ }
162
+ }
163
+ function getPullDisabledReason(input) {
164
+ if (input.isDirty) {
165
+ return "Base repository has uncommitted changes.";
166
+ }
167
+ if (!input.upstreamBranch) {
168
+ return "Current branch has no upstream.";
169
+ }
170
+ return undefined;
95
171
  }
96
172
  function getErrorHint(caught) {
97
173
  if (caught instanceof VcmError) {