work-ally 0.2.0-alpha.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.
Files changed (172) hide show
  1. package/AGENTS.md +110 -0
  2. package/DASHBOARD.md +160 -0
  3. package/PRODUCT.md +113 -0
  4. package/README.md +403 -0
  5. package/ally.sh +171 -0
  6. package/bridge/src/approval-rules.ts +360 -0
  7. package/bridge/src/channel-delivery.ts +207 -0
  8. package/bridge/src/channel-types.ts +22 -0
  9. package/bridge/src/channels/fake/adapter.ts +31 -0
  10. package/bridge/src/channels/feishu/adapter.ts +411 -0
  11. package/bridge/src/channels/feishu/approvals.ts +6 -0
  12. package/bridge/src/channels/feishu/formatter.ts +276 -0
  13. package/bridge/src/channels/feishu/normalize.ts +368 -0
  14. package/bridge/src/codex-config.ts +52 -0
  15. package/bridge/src/config.ts +240 -0
  16. package/bridge/src/fake-runtime-client.ts +505 -0
  17. package/bridge/src/handoff-service.ts +494 -0
  18. package/bridge/src/logger.ts +194 -0
  19. package/bridge/src/memory-digest.ts +186 -0
  20. package/bridge/src/receiver-approval-autonomy.ts +158 -0
  21. package/bridge/src/receiver-control-core.ts +140 -0
  22. package/bridge/src/receiver-control-work-session.ts +218 -0
  23. package/bridge/src/receiver-control.ts +83 -0
  24. package/bridge/src/receiver-delivery.ts +136 -0
  25. package/bridge/src/receiver-helpers.ts +96 -0
  26. package/bridge/src/receiver-human-gate.ts +333 -0
  27. package/bridge/src/receiver-inbound-preflight.ts +162 -0
  28. package/bridge/src/receiver-recovery.ts +236 -0
  29. package/bridge/src/receiver-runtime-callbacks.ts +367 -0
  30. package/bridge/src/receiver-runtime-policy.ts +132 -0
  31. package/bridge/src/receiver-runtime-state.ts +124 -0
  32. package/bridge/src/receiver-support-actions.ts +189 -0
  33. package/bridge/src/receiver-thread-start.ts +57 -0
  34. package/bridge/src/receiver-turn-coordination.ts +94 -0
  35. package/bridge/src/receiver-turn-execution.ts +257 -0
  36. package/bridge/src/receiver-turn-failure.ts +143 -0
  37. package/bridge/src/receiver-turn-result.ts +185 -0
  38. package/bridge/src/receiver-turn-steer.ts +70 -0
  39. package/bridge/src/receiver-work-session.ts +76 -0
  40. package/bridge/src/receiver.ts +329 -0
  41. package/bridge/src/router.ts +62 -0
  42. package/bridge/src/runtime-client-agent-messages.ts +150 -0
  43. package/bridge/src/runtime-client-message-dispatch.ts +176 -0
  44. package/bridge/src/runtime-client-protocol.ts +411 -0
  45. package/bridge/src/runtime-client-request-ops.ts +56 -0
  46. package/bridge/src/runtime-client-run-turn.ts +158 -0
  47. package/bridge/src/runtime-client-thread-ops.ts +270 -0
  48. package/bridge/src/runtime-client-transport.ts +309 -0
  49. package/bridge/src/runtime-client-turn-poll.ts +224 -0
  50. package/bridge/src/runtime-client-turn-read.ts +185 -0
  51. package/bridge/src/runtime-client-turn-state.ts +105 -0
  52. package/bridge/src/runtime-client.ts +344 -0
  53. package/bridge/src/runtime-user-input.ts +403 -0
  54. package/bridge/src/scheduler.ts +239 -0
  55. package/bridge/src/server-handoff-command.ts +364 -0
  56. package/bridge/src/server-main.ts +80 -0
  57. package/bridge/src/server-routine-command.ts +60 -0
  58. package/bridge/src/server-routine-execution.ts +222 -0
  59. package/bridge/src/server-runtime-app-support.ts +107 -0
  60. package/bridge/src/server-runtime-app.ts +238 -0
  61. package/bridge/src/server-thread-sync-command.ts +63 -0
  62. package/bridge/src/server.ts +17 -0
  63. package/bridge/src/session-store-delivery.ts +220 -0
  64. package/bridge/src/session-store-human-gate.ts +380 -0
  65. package/bridge/src/session-store-inbound-acceptance.ts +66 -0
  66. package/bridge/src/session-store-meta.ts +134 -0
  67. package/bridge/src/session-store-turn-ledger.ts +272 -0
  68. package/bridge/src/session-store.ts +380 -0
  69. package/bridge/src/system-notify.ts +220 -0
  70. package/bridge/src/thread-sync.ts +200 -0
  71. package/bridge/src/translator.ts +494 -0
  72. package/bridge/src/types.ts +289 -0
  73. package/bridge/src/utils.ts +104 -0
  74. package/bridge/src/work-session-store.ts +471 -0
  75. package/docs/.gitkeep +0 -0
  76. package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
  77. package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
  78. package/docs/completed/README.md +21 -0
  79. package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
  80. package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
  81. package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
  82. package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
  83. package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
  84. package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
  85. package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
  86. package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
  87. package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
  88. package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
  89. package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
  90. package/docs/developer-workflow.md +49 -0
  91. package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
  92. package/docs/implementation/test-coverage-map.md +363 -0
  93. package/docs/implementation/work-ally-implementation-guide.md +790 -0
  94. package/docs/issues/README.md +10 -0
  95. package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
  96. package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
  97. package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
  98. package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
  99. package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
  100. package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
  101. package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
  102. package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
  103. package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
  104. package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
  105. package/docs/manual-acceptance.md +127 -0
  106. package/docs/ops-runbook.md +44 -0
  107. package/docs/planning/FEATURE-memory-system.md +748 -0
  108. package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
  109. package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
  110. package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
  111. package/docs/planning/SPEC-assistant-rename.md +610 -0
  112. package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
  113. package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
  114. package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
  115. package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
  116. package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
  117. package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
  118. package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
  119. package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
  120. package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
  121. package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
  122. package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
  123. package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
  124. package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
  125. package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
  126. package/docs/planning/SPEC-stable-archive-contract.md +456 -0
  127. package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
  128. package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
  129. package/docs/planning/ally-next.md +1278 -0
  130. package/docs/planning/assistant-workbench-spec.md +725 -0
  131. package/docs/planning/product-workbench.md +283 -0
  132. package/docs/product-onboarding.md +227 -0
  133. package/docs/product-spec-standard.md +528 -0
  134. package/docs/troubleshooting.md +45 -0
  135. package/docs/user-quickstart.md +46 -0
  136. package/internal/dispatch.sh +95 -0
  137. package/internal/lib/common.sh +1450 -0
  138. package/internal/modules/assistant/manage.sh +1312 -0
  139. package/internal/modules/bootstrap/setup.sh +144 -0
  140. package/internal/modules/config/init-env.sh +10 -0
  141. package/internal/modules/global/manage.sh +154 -0
  142. package/internal/modules/handoff/manage.sh +54 -0
  143. package/internal/modules/mcp/manage.sh +83 -0
  144. package/internal/modules/ops/logs.sh +76 -0
  145. package/internal/modules/routines/manage.sh +55 -0
  146. package/internal/modules/runtime/assistant-autosave.sh +26 -0
  147. package/internal/modules/runtime/restart.sh +6 -0
  148. package/internal/modules/runtime/start.sh +283 -0
  149. package/internal/modules/runtime/status.sh +194 -0
  150. package/internal/modules/runtime/stop.sh +55 -0
  151. package/internal/modules/runtime/supervisor.sh +216 -0
  152. package/internal/modules/runtime/update.sh +26 -0
  153. package/package.json +41 -0
  154. package/runtime/config/.gitkeep +0 -0
  155. package/runtime/host/.gitkeep +0 -0
  156. package/runtime/host/healthcheck-codex-app-server.ts +22 -0
  157. package/runtime/host/ping-pong-codex-app-server.ts +66 -0
  158. package/runtime/host/probe-codex-app-server.ts +115 -0
  159. package/skills/archive-reader/SKILL.md +9 -0
  160. package/skills/feishu-production-debug/SKILL.md +37 -0
  161. package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
  162. package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
  163. package/skills/issue-to-spec-triage/SKILL.md +44 -0
  164. package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
  165. package/skills/memory-digest/SKILL.md +9 -0
  166. package/skills/post-implementation-closure/SKILL.md +39 -0
  167. package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
  168. package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
  169. package/skills/product-spec/SKILL.md +244 -0
  170. package/templates/env.example +5 -0
  171. package/templates/routines/nightly-memory-digest.yaml +10 -0
  172. package/templates/workspace/AGENTS.md +26 -0
@@ -0,0 +1,222 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { ChannelAdapter } from './channel-types.ts';
4
+ import {
5
+ clearBridgeDeliveryIssue,
6
+ describeChannelDeliveryIssue,
7
+ isNonFatalChannelDeliveryError,
8
+ normalizeChannelDeliveryError,
9
+ recordBridgeDeliveryIssue,
10
+ } from './channel-delivery.ts';
11
+ import type { loadConfig } from './config.ts';
12
+ import { FakeRuntimeClient } from './fake-runtime-client.ts';
13
+ import { logWarn } from './logger.ts';
14
+ import { buildMemoryDigestPrompt, parseMemoryDigestReply } from './memory-digest.ts';
15
+ import { CodexRuntimeClient } from './runtime-client.ts';
16
+ import { Scheduler, type RoutineDefinition } from './scheduler.ts';
17
+ import { SessionStore } from './session-store.ts';
18
+ import { formatSystemNoticeText, resolveNoticeConversationRef } from './system-notify.ts';
19
+ import { nowIso, todayStamp } from './utils.ts';
20
+
21
+ type RuntimeLike = FakeRuntimeClient | CodexRuntimeClient;
22
+
23
+ type BridgeConfig = ReturnType<typeof loadConfig>;
24
+
25
+ const ROUTINE_DEVELOPER_INSTRUCTIONS = [
26
+ 'You are running as a non-interactive work-ally routine.',
27
+ 'Do not ask the user for approval during this routine.',
28
+ 'Do not modify workspace files directly from the runtime.',
29
+ 'Read files as needed, then return the result in your final response only.',
30
+ 'work-ally will handle writeback, push delivery, and memory file persistence outside the runtime.',
31
+ ].join(' ');
32
+
33
+ export function startRoutineThread(runtime: RuntimeLike, cwd: string) {
34
+ if (runtime instanceof CodexRuntimeClient) {
35
+ return runtime.startThread(cwd, {
36
+ developerInstructions: ROUTINE_DEVELOPER_INSTRUCTIONS,
37
+ });
38
+ }
39
+ return runtime.startThread(cwd);
40
+ }
41
+
42
+ export function resumeRoutineThread(runtime: RuntimeLike, threadId: string, cwd: string) {
43
+ if (runtime instanceof CodexRuntimeClient) {
44
+ return runtime.resumeThread(threadId, cwd, {
45
+ developerInstructions: ROUTINE_DEVELOPER_INSTRUCTIONS,
46
+ });
47
+ }
48
+ return runtime.resumeThread(threadId, cwd);
49
+ }
50
+
51
+ function formatMemoryDigestSummary(summary: string | null | undefined): string {
52
+ const trimmed = String(summary || '').trim();
53
+ if (!trimmed) {
54
+ return '记忆整理已完成:无新增稳定记忆。';
55
+ }
56
+ return `记忆整理已完成。\n${trimmed}`;
57
+ }
58
+
59
+ function routineTurnSandboxPolicy(cwd: string) {
60
+ return {
61
+ type: 'readOnly' as const,
62
+ networkAccess: false,
63
+ access: {
64
+ type: 'restricted' as const,
65
+ includePlatformDefaults: true,
66
+ readableRoots: [cwd],
67
+ },
68
+ };
69
+ }
70
+
71
+ function normalizeDeliveryError(channelImpl: string, error: unknown): Error {
72
+ if (channelImpl === 'feishu') {
73
+ return normalizeChannelDeliveryError('feishu', error);
74
+ }
75
+ if (error instanceof Error) {
76
+ return error;
77
+ }
78
+ return new Error(String(error));
79
+ }
80
+
81
+ export async function executeRoutine(
82
+ config: BridgeConfig,
83
+ channel: ChannelAdapter,
84
+ runtime: RuntimeLike,
85
+ routine: RoutineDefinition,
86
+ threadId: string,
87
+ slot?: string,
88
+ ) {
89
+ const conversationRef = {
90
+ channel: config.channel.impl,
91
+ conversationId: config.defaultPushTarget || 'routine-runner',
92
+ userId: config.defaultPushTarget || 'routine-runner',
93
+ chatType: 'system',
94
+ };
95
+ const dateStamp = todayStamp(slot ? new Date(slot) : new Date(), config.timezone);
96
+ const prompt = routine.type === 'memory_digest'
97
+ ? buildMemoryDigestPrompt(config, dateStamp, routine.id)
98
+ : routine.prompt || `run routine: ${routine.id}`;
99
+ const startedAt = nowIso();
100
+
101
+
102
+ const result = await runtime.runTurn({
103
+ threadId,
104
+ prompt,
105
+ messageId: `routine:${routine.id}`,
106
+ cwd: config.workspaceRoot,
107
+ ...(runtime instanceof CodexRuntimeClient ? {
108
+ approvalPolicy: 'never',
109
+ sandboxPolicy: routineTurnSandboxPolicy(config.workspaceRoot),
110
+ } : {}),
111
+ onProgress: async (text) => {
112
+ },
113
+ });
114
+
115
+
116
+ let memoryDigestSummary: string | null = null;
117
+ if (routine.type === 'memory_digest' && result.status === 'completed') {
118
+ const parsed = parseMemoryDigestReply(result.reply);
119
+ const dailyPath = path.join(config.paths.dailyMemoryDir, `${dateStamp}.md`);
120
+ const longTermPath = path.join(config.paths.longTermMemoryDir, 'MEMORY.md');
121
+ if (parsed) {
122
+ fs.writeFileSync(dailyPath, `${parsed.daily}\n`, 'utf8');
123
+ fs.writeFileSync(longTermPath, `${parsed.longTerm}\n`, 'utf8');
124
+ memoryDigestSummary = formatMemoryDigestSummary(parsed.summary);
125
+ } else {
126
+ fs.writeFileSync(dailyPath, `${result.reply}\n`, 'utf8');
127
+ fs.writeFileSync(longTermPath, `${result.reply}\n`, 'utf8');
128
+ memoryDigestSummary = '记忆整理已完成,但未生成结构化摘要。';
129
+ }
130
+ }
131
+
132
+ const shouldNotifyMemoryDigest = routine.type === 'memory_digest' && Boolean(memoryDigestSummary);
133
+ if (shouldNotifyMemoryDigest) {
134
+ let noticeRef = config.defaultPushTarget ? conversationRef : null;
135
+ if (!noticeRef && config.channel.impl === 'feishu') {
136
+ const sessionStore = new SessionStore(config.paths.sessionsDir);
137
+ noticeRef = resolveNoticeConversationRef(sessionStore.listSessions(), config)?.conversationRef || null;
138
+ }
139
+ if (noticeRef) {
140
+ const outbound = {
141
+ type: 'progress_update' as const,
142
+ conversationRef: noticeRef,
143
+ text: formatSystemNoticeText(memoryDigestSummary || ''),
144
+ };
145
+ try {
146
+ await channel.send(outbound);
147
+ clearBridgeDeliveryIssue(config.paths.runtimeDir);
148
+ } catch (error) {
149
+ const normalizedError = normalizeDeliveryError(config.channel.impl, error);
150
+ if (isNonFatalChannelDeliveryError(normalizedError)) {
151
+ recordBridgeDeliveryIssue(config.paths.runtimeDir, normalizedError);
152
+ logWarn('routine', 'memory_digest_notice_delivery_unavailable', {
153
+ routineId: routine.id,
154
+ slot: slot || null,
155
+ threadId,
156
+ ...describeChannelDeliveryIssue(normalizedError),
157
+ });
158
+ } else {
159
+ throw normalizedError;
160
+ }
161
+ }
162
+ }
163
+ } else if (routine.push?.kind === 'default_im' && config.defaultPushTarget) {
164
+ const outbound = {
165
+ type: result.status === 'completed' ? 'final_reply' as const : 'error_reply' as const,
166
+ conversationRef,
167
+ text: result.status === 'completed' ? result.reply : (result.error || `routine failed: ${routine.id}`),
168
+ };
169
+ try {
170
+ await channel.send(outbound);
171
+ clearBridgeDeliveryIssue(config.paths.runtimeDir);
172
+ } catch (error) {
173
+ const normalizedError = normalizeDeliveryError(config.channel.impl, error);
174
+ if (isNonFatalChannelDeliveryError(normalizedError)) {
175
+ recordBridgeDeliveryIssue(config.paths.runtimeDir, normalizedError);
176
+ logWarn('routine', 'push_delivery_unavailable', {
177
+ routineId: routine.id,
178
+ slot: slot || null,
179
+ threadId,
180
+ ...describeChannelDeliveryIssue(normalizedError),
181
+ });
182
+ } else {
183
+ throw normalizedError;
184
+ }
185
+ }
186
+ }
187
+
188
+ if (routine.writeback?.kind === 'append_markdown' && routine.writeback.path && result.status === 'completed') {
189
+ const target = path.join(config.workspaceRoot, routine.writeback.path);
190
+ fs.mkdirSync(path.dirname(target), { recursive: true });
191
+ fs.appendFileSync(target, `\n\n${result.reply}\n`, 'utf8');
192
+ }
193
+
194
+ return result;
195
+ }
196
+
197
+ export async function resolveRoutineThread(
198
+ runtime: RuntimeLike,
199
+ scheduler: Scheduler,
200
+ config: BridgeConfig,
201
+ routine: RoutineDefinition,
202
+ ): Promise<{ id: string }> {
203
+ if (routine.thread_mode === 'reuse') {
204
+ const existingThreadId = scheduler.getRoutineThread(routine.id);
205
+ if (existingThreadId) {
206
+ try {
207
+ const resumed = await resumeRoutineThread(runtime, existingThreadId, config.workspaceRoot);
208
+ scheduler.bindRoutineThread(routine.id, resumed.id);
209
+ return resumed;
210
+ } catch {
211
+ scheduler.clearRoutineThread(routine.id);
212
+ }
213
+ }
214
+
215
+ const created = await startRoutineThread(runtime, config.workspaceRoot);
216
+ scheduler.bindRoutineThread(routine.id, created.id);
217
+ return created;
218
+ }
219
+
220
+ scheduler.clearRoutineThread(routine.id);
221
+ return startRoutineThread(runtime, config.workspaceRoot);
222
+ }
@@ -0,0 +1,107 @@
1
+ import type { ChannelAdapter } from './channel-types.ts';
2
+ import {
3
+ clearBridgeDeliveryIssue,
4
+ describeChannelDeliveryIssue,
5
+ isNonFatalChannelDeliveryError,
6
+ normalizeChannelDeliveryError,
7
+ recordBridgeDeliveryIssue,
8
+ } from './channel-delivery.ts';
9
+ import type { loadConfig } from './config.ts';
10
+ import { logInfo, logWarn } from './logger.ts';
11
+ import { Receiver } from './receiver.ts';
12
+ import { SessionStore } from './session-store.ts';
13
+ import { resolveNoticeConversationRef } from './system-notify.ts';
14
+ import type { ConversationRef } from './types.ts';
15
+ import { nowIso, readJsonFile, writeJsonFile } from './utils.ts';
16
+
17
+ type BridgeConfig = ReturnType<typeof loadConfig>;
18
+
19
+ export function normalizeDeliveryError(channelImpl: string, error: unknown): Error {
20
+ if (channelImpl === 'feishu') {
21
+ return normalizeChannelDeliveryError('feishu', error);
22
+ }
23
+ if (error instanceof Error) {
24
+ return error;
25
+ }
26
+ return new Error(String(error));
27
+ }
28
+
29
+ export async function reconcilePendingFinalRepliesOnStartup(
30
+ sessionStore: SessionStore,
31
+ receiver: Receiver,
32
+ ): Promise<void> {
33
+ const sessions = sessionStore.listSessions();
34
+ for (const session of sessions) {
35
+ const delivered = await receiver.redeliverPendingFinalReplies(session.conversationRef as ConversationRef);
36
+ if (delivered > 0) {
37
+ logInfo('bridge', 'startup_pending_final_replies_redelivered', {
38
+ conversationId: session.conversationRef.conversationId,
39
+ delivered,
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ export async function notifyRuntimeState(
46
+ config: BridgeConfig,
47
+ channel: ChannelAdapter,
48
+ text: string,
49
+ ): Promise<void> {
50
+ if (config.channel.impl !== 'feishu') {
51
+ return;
52
+ }
53
+ const sessionStore = new SessionStore(config.paths.sessionsDir);
54
+ const target = resolveNoticeConversationRef(sessionStore.listSessions(), config);
55
+ if (!target) {
56
+ return;
57
+ }
58
+ const outbound = {
59
+ type: 'progress_update' as const,
60
+ conversationRef: target.conversationRef,
61
+ text,
62
+ };
63
+ try {
64
+ await channel.send(outbound);
65
+ clearBridgeDeliveryIssue(config.paths.runtimeDir);
66
+ } catch (error) {
67
+ const normalizedError = normalizeDeliveryError(config.channel.impl, error);
68
+ if (isNonFatalChannelDeliveryError(normalizedError)) {
69
+ recordBridgeDeliveryIssue(config.paths.runtimeDir, normalizedError);
70
+ logWarn('bridge', 'runtime_state_delivery_unavailable', {
71
+ ...describeChannelDeliveryIssue(normalizedError),
72
+ });
73
+ return;
74
+ }
75
+ throw normalizedError;
76
+ }
77
+ }
78
+
79
+ export function writeBridgeHealth(options: {
80
+ healthFile: string;
81
+ config: BridgeConfig;
82
+ stopping: boolean;
83
+ status: string;
84
+ error?: string;
85
+ }): void {
86
+ const { healthFile, config, stopping, status, error } = options;
87
+ if (stopping && status === 'running') {
88
+ return;
89
+ }
90
+ const current = readJsonFile(healthFile, null);
91
+ const deliveryState = current && typeof current === 'object'
92
+ ? Object.fromEntries(
93
+ Object.entries(current).filter(([key]) => key.startsWith('delivery_')),
94
+ )
95
+ : {};
96
+ const health = {
97
+ ...deliveryState,
98
+ status,
99
+ at: nowIso(),
100
+ pid: process.pid,
101
+ workspaceRoot: config.workspaceRoot,
102
+ channel: config.channel.impl,
103
+ runtime: config.runtime.mode,
104
+ ...(error ? { error } : {}),
105
+ };
106
+ writeJsonFile(healthFile, health);
107
+ }
@@ -0,0 +1,238 @@
1
+ import path from 'node:path';
2
+ import type { ChannelAdapter, InboundHandler } from './channel-types.ts';
3
+ import { FakeChannelAdapter } from './channels/fake/adapter.ts';
4
+ import { FeishuAdapter } from './channels/feishu/adapter.ts';
5
+ import {
6
+ describeChannelDeliveryIssue,
7
+ isNonFatalChannelDeliveryError,
8
+ recordBridgeDeliveryIssue,
9
+ } from './channel-delivery.ts';
10
+ import { loadConfig } from './config.ts';
11
+ import { FakeRuntimeClient } from './fake-runtime-client.ts';
12
+ import { logError, logInfo, logWarn } from './logger.ts';
13
+ import { Receiver } from './receiver.ts';
14
+ import { CodexRuntimeClient } from './runtime-client.ts';
15
+ import { Scheduler, type RoutineDefinition } from './scheduler.ts';
16
+ import {
17
+ normalizeDeliveryError,
18
+ notifyRuntimeState,
19
+ reconcilePendingFinalRepliesOnStartup,
20
+ writeBridgeHealth,
21
+ } from './server-runtime-app-support.ts';
22
+ import { executeRoutine, resolveRoutineThread } from './server-routine-execution.ts';
23
+ import { SessionStore } from './session-store.ts';
24
+ import { nowIso, writeJsonFile } from './utils.ts';
25
+
26
+ type RuntimeLike = FakeRuntimeClient | CodexRuntimeClient;
27
+
28
+ class InboundProxy implements InboundHandler {
29
+ private target: InboundHandler | null = null;
30
+
31
+ bind(target: InboundHandler): void {
32
+ this.target = target;
33
+ }
34
+
35
+ async handleInbound(message: Parameters<InboundHandler['handleInbound']>[0]): Promise<void> {
36
+ if (!this.target) {
37
+ throw new Error('inbound handler is not ready');
38
+ }
39
+ await this.target.handleInbound(message);
40
+ }
41
+ }
42
+
43
+ export class RuntimeApp {
44
+ private readonly config;
45
+ private readonly channel;
46
+ private readonly runtime;
47
+ private readonly scheduler;
48
+ private readonly sessionStore;
49
+ private readonly receiver;
50
+ private readonly healthFile;
51
+ private readonly timers: NodeJS.Timeout[] = [];
52
+ private stopping = false;
53
+
54
+ constructor(
55
+ config: ReturnType<typeof loadConfig>,
56
+ channel: ChannelAdapter,
57
+ runtime: RuntimeLike,
58
+ scheduler: Scheduler,
59
+ sessionStore: SessionStore,
60
+ receiver: Receiver,
61
+ ) {
62
+ this.config = config;
63
+ this.channel = channel;
64
+ this.runtime = runtime;
65
+ this.scheduler = scheduler;
66
+ this.sessionStore = sessionStore;
67
+ this.receiver = receiver;
68
+ this.healthFile = path.join(config.paths.runtimeDir, 'bridge.health.json');
69
+ }
70
+
71
+ async start(): Promise<void> {
72
+ logInfo('bridge', 'channel_starting', {
73
+ impl: this.config.channel.impl,
74
+ runtime: this.config.runtime.mode,
75
+ });
76
+ await this.channel.start();
77
+ logInfo('bridge', 'channel_started', {
78
+ impl: this.config.channel.impl,
79
+ });
80
+ const healthy = await this.runtime.healthcheck();
81
+ if (!healthy) {
82
+ logError('bridge', 'runtime_healthcheck_failed', {
83
+ transport: 'stdio',
84
+ });
85
+ await notifyRuntimeState(this.config, this.channel, '**【系统消息】** Codex runtime 当前不可用,bridge 未能完成启动。');
86
+ throw new Error('runtime healthcheck failed');
87
+ }
88
+ logInfo('bridge', 'runtime_healthy', {
89
+ transport: 'stdio',
90
+ });
91
+ await reconcilePendingFinalRepliesOnStartup(this.sessionStore, this.receiver);
92
+ writeBridgeHealth({ healthFile: this.healthFile, config: this.config, stopping: this.stopping, status: 'running' });
93
+ this.timers.push(setInterval(() => {
94
+ if (this.stopping) {
95
+ return;
96
+ }
97
+ writeBridgeHealth({ healthFile: this.healthFile, config: this.config, stopping: this.stopping, status: 'running' });
98
+ }, 5000));
99
+ this.timers.push(setInterval(() => {
100
+ if (this.stopping) {
101
+ return;
102
+ }
103
+ this.tickScheduler().catch((error) => {
104
+ const normalizedError = normalizeDeliveryError(this.config.channel.impl, error);
105
+ if (isNonFatalChannelDeliveryError(normalizedError)) {
106
+ recordBridgeDeliveryIssue(this.config.paths.runtimeDir, normalizedError);
107
+ logWarn('bridge', 'scheduler_tick_delivery_unavailable', {
108
+ ...describeChannelDeliveryIssue(normalizedError),
109
+ });
110
+ writeBridgeHealth({ healthFile: this.healthFile, config: this.config, stopping: this.stopping, status: 'running' });
111
+ return;
112
+ }
113
+ logError('bridge', 'scheduler_tick_failed', {
114
+ error: normalizedError instanceof Error ? (normalizedError.stack || normalizedError.message) : String(normalizedError),
115
+ });
116
+ writeBridgeHealth({
117
+ healthFile: this.healthFile,
118
+ config: this.config,
119
+ stopping: this.stopping,
120
+ status: 'degraded',
121
+ error: String(normalizedError),
122
+ });
123
+ });
124
+ }, 30000));
125
+ }
126
+
127
+ async stop(): Promise<void> {
128
+ if (this.stopping) {
129
+ return;
130
+ }
131
+ this.stopping = true;
132
+ logInfo('bridge', 'app_stopping');
133
+ for (const timer of this.timers) {
134
+ clearInterval(timer);
135
+ }
136
+ await this.dispose();
137
+ writeBridgeHealth({ healthFile: this.healthFile, config: this.config, stopping: this.stopping, status: 'stopped' });
138
+ logInfo('bridge', 'app_stopped');
139
+ }
140
+
141
+ async dispose(): Promise<void> {
142
+ await this.channel.stop();
143
+ if (typeof this.runtime.disconnect === 'function') {
144
+ await this.runtime.disconnect();
145
+ }
146
+ }
147
+
148
+ async runRoutine(id: string, slot?: string): Promise<void> {
149
+ const routine = this.scheduler.listRoutines().find((item) => item.id === id);
150
+ if (!routine) {
151
+ throw new Error(`routine not found: ${id}`);
152
+ }
153
+
154
+ if (!this.scheduler.claimRun(routine.id)) {
155
+ logWarn('routine', 'busy_skip', { routineId: routine.id });
156
+ console.log(`routine busy, skipping: ${routine.id}`);
157
+ return;
158
+ }
159
+
160
+ logInfo('routine', 'run_start', {
161
+ routineId: routine.id,
162
+ slot: slot || null,
163
+ });
164
+ try {
165
+ const thread = await this.resolveRoutineThread(routine);
166
+ const effectiveSlot = slot || this.scheduler.previousSlotForRoutine(routine) || undefined;
167
+ const result = await executeRoutine(this.config, this.channel, this.runtime, routine, thread.id, effectiveSlot);
168
+ const at = nowIso();
169
+ const runFile = path.join(this.config.paths.runsDir, `${at.replace(/[:.]/g, '-')}-${routine.id}.json`);
170
+ writeJsonFile(runFile, { routine, result, slot: effectiveSlot || null, threadId: thread.id, at });
171
+ writeJsonFile(path.join(this.config.paths.runsDir, 'latest.json'), { routine: routine.id, result, slot: effectiveSlot || null, threadId: thread.id, at });
172
+ this.scheduler.markRun(routine.id, effectiveSlot || at);
173
+ logInfo('routine', 'run_completed', {
174
+ routineId: routine.id,
175
+ slot: effectiveSlot || null,
176
+ status: result.status,
177
+ });
178
+ } catch (error) {
179
+ this.scheduler.releaseRun(routine.id);
180
+ const normalizedError = normalizeDeliveryError(this.config.channel.impl, error);
181
+ logError('routine', 'run_failed', {
182
+ routineId: routine.id,
183
+ slot: slot || null,
184
+ error: normalizedError instanceof Error ? (normalizedError.stack || normalizedError.message) : String(normalizedError),
185
+ });
186
+ throw normalizedError;
187
+ }
188
+ }
189
+
190
+ private async resolveRoutineThread(routine: RoutineDefinition): Promise<{ id: string }> {
191
+ return resolveRoutineThread(this.runtime, this.scheduler, this.config, routine);
192
+ }
193
+
194
+ private async tickScheduler(): Promise<void> {
195
+ for (const due of this.scheduler.computeDue()) {
196
+ await this.runRoutine(due.routine.id, due.slot);
197
+ }
198
+ }
199
+ }
200
+
201
+ function createRuntime(config: ReturnType<typeof loadConfig>): RuntimeLike {
202
+ return config.runtime.mode === 'fake'
203
+ ? new FakeRuntimeClient()
204
+ : new CodexRuntimeClient({
205
+ codexHome: config.assistant.codexHome || null,
206
+ });
207
+ }
208
+
209
+ function createChannel(config: ReturnType<typeof loadConfig>, inbound: InboundHandler): ChannelAdapter {
210
+ if (config.channel.impl === 'fake') {
211
+ return new FakeChannelAdapter(inbound);
212
+ }
213
+
214
+ if (config.channel.impl === 'feishu') {
215
+ if (!config.feishu.appId || !config.feishu.appSecret) {
216
+ throw new Error('feishu credentials are required');
217
+ }
218
+ return new FeishuAdapter(inbound, {
219
+ appId: config.feishu.appId,
220
+ appSecret: config.feishu.appSecret,
221
+ reactEmoji: config.feishu.reactEmoji,
222
+ allowedUserIds: config.channel.allowedUserIds,
223
+ });
224
+ }
225
+
226
+ throw new Error(`unsupported channel impl: ${config.channel.impl}`);
227
+ }
228
+
229
+ export function createRuntimeApp(config: ReturnType<typeof loadConfig>): RuntimeApp {
230
+ const runtime = createRuntime(config);
231
+ const scheduler = new Scheduler(config.paths.routinesDir, path.join(config.paths.runtimeDir, 'routines-state.json'), config.timezone, config.paths.projectRoutinesDir);
232
+ const sessionStore = new SessionStore(config.paths.sessionsDir);
233
+ const inboundProxy = new InboundProxy();
234
+ const channel = createChannel(config, inboundProxy);
235
+ const receiver = new Receiver(config, channel, sessionStore, runtime);
236
+ inboundProxy.bind(receiver);
237
+ return new RuntimeApp(config, channel, runtime, scheduler, sessionStore, receiver);
238
+ }
@@ -0,0 +1,63 @@
1
+ import { loadConfig } from './config.ts';
2
+ import { FakeRuntimeClient } from './fake-runtime-client.ts';
3
+ import { CodexRuntimeClient } from './runtime-client.ts';
4
+ import { syncAllActiveWorkSessions, syncThreadConversationById } from './thread-sync.ts';
5
+ import { WorkSessionStore } from './work-session-store.ts';
6
+
7
+ function commandArgs(): string[] {
8
+ return process.argv.slice(3);
9
+ }
10
+
11
+ function pickThreadId(args: string[]): string | null {
12
+ for (let i = 0; i < args.length; i += 1) {
13
+ const value = String(args[i] || '').trim();
14
+ if (value === '--thread') {
15
+ return String(args[i + 1] || '').trim() || null;
16
+ }
17
+ if (value.startsWith('--thread=')) {
18
+ return value.slice('--thread='.length).trim() || null;
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function hasFlag(flag: string): boolean {
25
+ return commandArgs().includes(flag);
26
+ }
27
+
28
+ function createRuntime(config: ReturnType<typeof loadConfig>) {
29
+ if (config.runtime.mode === 'fake') {
30
+ return new FakeRuntimeClient();
31
+ }
32
+ return new CodexRuntimeClient({ codexHome: config.assistant?.codexHome || null });
33
+ }
34
+
35
+ export async function runThreadSyncCommand(): Promise<void> {
36
+ const config = loadConfig();
37
+ const runtime = createRuntime(config);
38
+ const store = new WorkSessionStore(config.paths.workSessionsDir || `${config.paths.runtimeDir}/work-sessions`);
39
+
40
+ try {
41
+ await runtime.connect?.();
42
+
43
+ const threadId = pickThreadId(commandArgs());
44
+ if (threadId) {
45
+ const result = await syncThreadConversationById(config, runtime, threadId);
46
+ if (hasFlag('--json')) {
47
+ console.log(JSON.stringify({ status: 'ok', mode: 'single', result }, null, 2));
48
+ return;
49
+ }
50
+ console.log(`thread sync completed: ${threadId}`);
51
+ return;
52
+ }
53
+
54
+ const result = await syncAllActiveWorkSessions(config, runtime, store);
55
+ if (hasFlag('--json')) {
56
+ console.log(JSON.stringify({ status: 'ok', mode: 'active_sessions', result }, null, 2));
57
+ return;
58
+ }
59
+ console.log(`thread sync completed: synced=${result.synced} skipped=${result.skipped} failed=${result.failed}`);
60
+ } finally {
61
+ await runtime.disconnect();
62
+ }
63
+ }
@@ -0,0 +1,17 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { main as runServerMain } from './server-main.ts';
3
+
4
+ export { executeRoutine } from './server-routine-execution.ts';
5
+ export { runHandoffCommand } from './server-handoff-command.ts';
6
+ export { main } from './server-main.ts';
7
+ export { runRoutineCommand } from './server-routine-command.ts';
8
+ export { RuntimeApp, createRuntimeApp } from './server-runtime-app.ts';
9
+
10
+ const isCliEntrypoint = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
11
+
12
+ if (isCliEntrypoint) {
13
+ runServerMain().catch((error) => {
14
+ console.error(error);
15
+ process.exit(1);
16
+ });
17
+ }