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,380 @@
1
+ import path from 'node:path';
2
+ import { conversationKey, ensureDir, nowIso, readJsonFile, sanitizeSegment } from './utils.ts';
3
+ import {
4
+ clearInboundReceipt,
5
+ completeInboundReceipt,
6
+ getInboundReceipt,
7
+ getOutboundDelivery,
8
+ listPendingOutboundDeliveries,
9
+ markOutboundDelivered,
10
+ noteInboundReceipt,
11
+ recordOutboundDelivery,
12
+ type ReceiptClaimResult,
13
+ } from './session-store-delivery.ts';
14
+ import {
15
+ clearPendingUserInput,
16
+ findHiddenPendingUserInput,
17
+ findApproval,
18
+ findApprovalByRequestId,
19
+ findPendingUserInputByRequestId,
20
+ getApproval,
21
+ getPendingUserInput,
22
+ listHiddenPendingApprovals,
23
+ listPendingApprovals,
24
+ markApprovalVisible,
25
+ markPendingUserInputVisible,
26
+ notePendingUserInput,
27
+ recordApproval,
28
+ resolveApproval,
29
+ } from './session-store-human-gate.ts';
30
+ import {
31
+ bindThread as bindStoredThread,
32
+ clearActiveTurn as clearStoredActiveTurn,
33
+ ensureSession as ensureStoredSession,
34
+ ensureSessionDirs as ensureStoredSessionDirs,
35
+ listSessions as listStoredSessions,
36
+ normalizeSessionMeta,
37
+ noteLatestInbound as noteStoredLatestInbound,
38
+ } from './session-store-meta.ts';
39
+ import {
40
+ getInboundAcceptance as getStoredInboundAcceptance,
41
+ noteInboundObserved as noteStoredInboundObserved,
42
+ updateInboundAcceptance as updateStoredInboundAcceptance,
43
+ } from './session-store-inbound-acceptance.ts';
44
+ import {
45
+ findLatestDeliveryIssue,
46
+ getTurnLedger,
47
+ getTurnRecord,
48
+ markActiveTurn,
49
+ markTurnDelivery,
50
+ markTurnRunning,
51
+ markTurnSuppressed,
52
+ noteTurnTerminalState,
53
+ recordTurn,
54
+ } from './session-store-turn-ledger.ts';
55
+ import type {
56
+ ApprovalRecord,
57
+ ConversationRef,
58
+ InboundAcceptanceRecord,
59
+ InboundReceipt,
60
+ OutboundDeliveryRecord,
61
+ PendingUserInputRecord,
62
+ SessionMeta,
63
+ TurnLedgerRecord,
64
+ TurnRecord,
65
+ UserInputRequest,
66
+ } from './types.ts';
67
+
68
+ export class SessionStore {
69
+ private readonly sessionsDir: string;
70
+
71
+ constructor(sessionsDir: string) {
72
+ this.sessionsDir = sessionsDir;
73
+ ensureDir(sessionsDir);
74
+ }
75
+
76
+ resolveSessionKey(ref: ConversationRef): string {
77
+ return conversationKey(ref.channel, ref.conversationId);
78
+ }
79
+
80
+ resolveSessionDir(ref: ConversationRef): string {
81
+ return path.join(this.sessionsDir, this.resolveSessionKey(ref));
82
+ }
83
+
84
+ private sessionMetaStoreDeps() {
85
+ return {
86
+ sessionsDir: this.sessionsDir,
87
+ resolveSessionKey: (ref: ConversationRef) => this.resolveSessionKey(ref),
88
+ resolveSessionDir: (ref: ConversationRef) => this.resolveSessionDir(ref),
89
+ metaFile: (ref: ConversationRef) => this.metaFile(ref),
90
+ eventsFile: (ref: ConversationRef) => this.eventsFile(ref),
91
+ };
92
+ }
93
+
94
+ private humanGateStoreDeps() {
95
+ return {
96
+ sessionsDir: this.sessionsDir,
97
+ ensureSessionDirs: (ref: ConversationRef) => this.ensureSessionDirs(ref),
98
+ ensureSession: (ref: ConversationRef) => this.ensureSession(ref),
99
+ getSession: (ref: ConversationRef) => this.getSession(ref),
100
+ normalizeMeta: (ref: ConversationRef, current: SessionMeta | null, now?: string) => this.normalizeMeta(ref, current, now),
101
+ metaFile: (ref: ConversationRef) => this.metaFile(ref),
102
+ eventsFile: (ref: ConversationRef) => this.eventsFile(ref),
103
+ approvalFile: (ref: ConversationRef, approvalId: string) => this.approvalFile(ref, approvalId),
104
+ pendingUserInputFile: (ref: ConversationRef, requestId: string | number) => this.pendingUserInputFile(ref, requestId),
105
+ };
106
+ }
107
+
108
+ private inboundAcceptanceStoreDeps() {
109
+ return {
110
+ ensureSessionDirs: (ref: ConversationRef) => this.ensureSessionDirs(ref),
111
+ eventsFile: (ref: ConversationRef) => this.eventsFile(ref),
112
+ inboundAcceptanceFile: (ref: ConversationRef, messageId: string) => this.inboundAcceptanceFile(ref, messageId),
113
+ };
114
+ }
115
+
116
+ private turnLedgerStoreDeps() {
117
+ return {
118
+ ensureSessionDirs: (ref: ConversationRef) => this.ensureSessionDirs(ref),
119
+ ensureSession: (ref: ConversationRef) => this.ensureSession(ref),
120
+ getSession: (ref: ConversationRef) => this.getSession(ref),
121
+ resolveSessionDir: (ref: ConversationRef) => this.resolveSessionDir(ref),
122
+ metaFile: (ref: ConversationRef) => this.metaFile(ref),
123
+ eventsFile: (ref: ConversationRef) => this.eventsFile(ref),
124
+ turnLedgerFile: (ref: ConversationRef, turnId: string) => this.turnLedgerFile(ref, turnId),
125
+ updateInboundAcceptance: (
126
+ ref: ConversationRef,
127
+ messageId: string,
128
+ seed: Partial<InboundAcceptanceRecord> = {},
129
+ mutate?: (record: InboundAcceptanceRecord, now: string) => void,
130
+ ) => this.updateInboundAcceptance(ref, messageId, seed, mutate),
131
+ };
132
+ }
133
+
134
+ private deliveryStoreDeps() {
135
+ return {
136
+ ensureSessionDirs: (ref: ConversationRef) => this.ensureSessionDirs(ref),
137
+ resolveSessionDir: (ref: ConversationRef) => this.resolveSessionDir(ref),
138
+ eventsFile: (ref: ConversationRef) => this.eventsFile(ref),
139
+ receiptFile: (ref: ConversationRef, messageId: string) => this.receiptFile(ref, messageId),
140
+ seenInboundFile: (ref: ConversationRef, messageId: string) => this.seenInboundFile(ref, messageId),
141
+ outboundDeliveryFile: (ref: ConversationRef, sourceKey: string) => this.outboundDeliveryFile(ref, sourceKey),
142
+ getSession: (ref: ConversationRef) => this.getSession(ref),
143
+ getInboundAcceptance: (ref: ConversationRef, messageId: string) => this.getInboundAcceptance(ref, messageId),
144
+ noteInboundObserved: (ref: ConversationRef, messageId: string) => this.noteInboundObserved(ref, messageId),
145
+ };
146
+ }
147
+
148
+ private ensureSessionDirs(ref: ConversationRef): string {
149
+ return ensureStoredSessionDirs(this.sessionMetaStoreDeps(), ref);
150
+ }
151
+
152
+ private normalizeMeta(ref: ConversationRef, current: SessionMeta | null, now = nowIso()): SessionMeta {
153
+ return normalizeSessionMeta(this.sessionMetaStoreDeps(), ref, current, now);
154
+ }
155
+
156
+ private metaFile(ref: ConversationRef): string {
157
+ return path.join(this.resolveSessionDir(ref), 'meta.json');
158
+ }
159
+
160
+ private eventsFile(ref: ConversationRef): string {
161
+ return path.join(this.resolveSessionDir(ref), 'events.ndjson');
162
+ }
163
+
164
+ private approvalFile(ref: ConversationRef, approvalId: string): string {
165
+ return path.join(this.resolveSessionDir(ref), 'approvals', `${sanitizeSegment(approvalId)}.json`);
166
+ }
167
+
168
+ private receiptFile(ref: ConversationRef, messageId: string): string {
169
+ return path.join(this.resolveSessionDir(ref), 'receipts', `${sanitizeSegment(messageId)}.json`);
170
+ }
171
+
172
+ private pendingUserInputFile(ref: ConversationRef, requestId: string | number): string {
173
+ return path.join(this.resolveSessionDir(ref), 'user-inputs', `${sanitizeSegment(String(requestId))}.json`);
174
+ }
175
+
176
+ private seenInboundFile(ref: ConversationRef, messageId: string): string {
177
+ return path.join(this.resolveSessionDir(ref), 'seen-inbounds', `${sanitizeSegment(messageId)}.json`);
178
+ }
179
+
180
+ private turnLedgerFile(ref: ConversationRef, turnId: string): string {
181
+ return path.join(this.resolveSessionDir(ref), 'turn-ledger', `${sanitizeSegment(turnId)}.json`);
182
+ }
183
+
184
+ private outboundDeliveryFile(ref: ConversationRef, sourceKey: string): string {
185
+ return path.join(this.resolveSessionDir(ref), 'outbound-delivery', `${sanitizeSegment(sourceKey)}.json`);
186
+ }
187
+
188
+ private inboundAcceptanceFile(ref: ConversationRef, messageId: string): string {
189
+ return path.join(this.resolveSessionDir(ref), 'inbound-ledger', `${sanitizeSegment(messageId)}.json`);
190
+ }
191
+
192
+ private updateInboundAcceptance(
193
+ ref: ConversationRef,
194
+ messageId: string,
195
+ seed: Partial<InboundAcceptanceRecord> = {},
196
+ mutate?: (record: InboundAcceptanceRecord, now: string) => void,
197
+ ): InboundAcceptanceRecord {
198
+ return updateStoredInboundAcceptance(this.inboundAcceptanceStoreDeps(), ref, messageId, seed, mutate);
199
+ }
200
+
201
+ ensureSession(ref: ConversationRef): SessionMeta {
202
+ return ensureStoredSession(this.sessionMetaStoreDeps(), ref);
203
+ }
204
+
205
+ getSession(ref: ConversationRef): SessionMeta | null {
206
+ const meta = readJsonFile(this.metaFile(ref)) as SessionMeta | null;
207
+ if (!meta) {
208
+ return null;
209
+ }
210
+ return this.normalizeMeta(ref, meta, meta.updatedAt || nowIso());
211
+ }
212
+
213
+ bindThread(ref: ConversationRef, threadId: string | null): SessionMeta {
214
+ return bindStoredThread(this.sessionMetaStoreDeps(), ref, threadId);
215
+ }
216
+
217
+ noteLatestInbound(ref: ConversationRef, details: { messageId?: string | null; promptPreview?: string | null }): SessionMeta {
218
+ return noteStoredLatestInbound(this.sessionMetaStoreDeps(), ref, details);
219
+ }
220
+
221
+ markActiveTurn(
222
+ ref: ConversationRef,
223
+ turnId: string,
224
+ details?: { messageId?: string | null; promptPreview?: string | null },
225
+ ): SessionMeta {
226
+ return markActiveTurn(this.turnLedgerStoreDeps(), ref, turnId, details);
227
+ }
228
+
229
+ markTurnRunning(ref: ConversationRef, turnId: string): TurnLedgerRecord {
230
+ return markTurnRunning(this.turnLedgerStoreDeps(), ref, turnId);
231
+ }
232
+
233
+ noteTurnTerminalState(
234
+ ref: ConversationRef,
235
+ turnId: string,
236
+ details: { threadId: string; status: string; reply?: string | null; error?: string | null },
237
+ ): TurnLedgerRecord {
238
+ return noteTurnTerminalState(this.turnLedgerStoreDeps(), ref, turnId, details);
239
+ }
240
+
241
+ markTurnSuppressed(
242
+ ref: ConversationRef,
243
+ turnId: string,
244
+ details: { threadId?: string | null; suppressedByMessageId?: string | null; suppressedByTurnId?: string | null; reason: string },
245
+ ): TurnLedgerRecord {
246
+ return markTurnSuppressed(this.turnLedgerStoreDeps(), ref, turnId, details);
247
+ }
248
+
249
+ markTurnDelivery(ref: ConversationRef, turnId: string, status: 'delivered' | 'delivery_unavailable'): TurnLedgerRecord {
250
+ return markTurnDelivery(this.turnLedgerStoreDeps(), ref, turnId, status);
251
+ }
252
+
253
+ findLatestDeliveryIssue(ref: ConversationRef): TurnLedgerRecord | null {
254
+ return findLatestDeliveryIssue(this.turnLedgerStoreDeps(), ref);
255
+ }
256
+
257
+ getTurnLedger(ref: ConversationRef, turnId: string): TurnLedgerRecord | null {
258
+ return getTurnLedger(this.turnLedgerStoreDeps(), ref, turnId);
259
+ }
260
+
261
+ getTurnRecord(ref: ConversationRef, turnId: string): TurnRecord | null {
262
+ return getTurnRecord(this.turnLedgerStoreDeps(), ref, turnId);
263
+ }
264
+
265
+ clearActiveTurn(ref: ConversationRef): SessionMeta {
266
+ return clearStoredActiveTurn(this.sessionMetaStoreDeps(), ref);
267
+ }
268
+
269
+ hasActiveTurn(ref: ConversationRef): boolean {
270
+ const meta = this.getSession(ref);
271
+ return Boolean(meta?.activeTurnId);
272
+ }
273
+
274
+ noteInboundObserved(ref: ConversationRef, messageId: string): InboundAcceptanceRecord {
275
+ return noteStoredInboundObserved(this.inboundAcceptanceStoreDeps(), ref, messageId);
276
+ }
277
+
278
+ getInboundAcceptance(ref: ConversationRef, messageId: string): InboundAcceptanceRecord | null {
279
+ return getStoredInboundAcceptance(this.inboundAcceptanceStoreDeps(), ref, messageId);
280
+ }
281
+
282
+ noteInboundReceipt(ref: ConversationRef, messageId: string): ReceiptClaimResult {
283
+ return noteInboundReceipt(this.deliveryStoreDeps(), ref, messageId);
284
+ }
285
+
286
+ completeInboundReceipt(ref: ConversationRef, messageId: string): InboundReceipt {
287
+ return completeInboundReceipt(this.deliveryStoreDeps(), ref, messageId);
288
+ }
289
+
290
+ clearInboundReceipt(ref: ConversationRef, messageId: string): void {
291
+ return clearInboundReceipt(this.deliveryStoreDeps(), ref, messageId);
292
+ }
293
+
294
+ getInboundReceipt(ref: ConversationRef, messageId: string): InboundReceipt | null {
295
+ return getInboundReceipt(this.deliveryStoreDeps(), ref, messageId);
296
+ }
297
+
298
+ recordTurn(ref: ConversationRef, turn: TurnRecord): void {
299
+ return recordTurn(this.turnLedgerStoreDeps(), ref, turn);
300
+ }
301
+
302
+ recordApproval(ref: ConversationRef, approval: ApprovalRecord, options: { visible?: boolean } = {}): void {
303
+ return recordApproval(this.humanGateStoreDeps(), ref, approval, options);
304
+ }
305
+
306
+ markApprovalVisible(ref: ConversationRef, approvalId: string): ApprovalRecord | null {
307
+ return markApprovalVisible(this.humanGateStoreDeps(), ref, approvalId);
308
+ }
309
+
310
+ resolveApproval(ref: ConversationRef, approvalId: string, status: ApprovalRecord['status']): ApprovalRecord | null {
311
+ return resolveApproval(this.humanGateStoreDeps(), ref, approvalId, status);
312
+ }
313
+
314
+ notePendingUserInput(ref: ConversationRef, request: UserInputRequest, options: { visible?: boolean } = {}): PendingUserInputRecord {
315
+ return notePendingUserInput(this.humanGateStoreDeps(), ref, request, options);
316
+ }
317
+
318
+ markPendingUserInputVisible(ref: ConversationRef, requestId: string | number): PendingUserInputRecord | null {
319
+ return markPendingUserInputVisible(this.humanGateStoreDeps(), ref, requestId);
320
+ }
321
+
322
+ getPendingUserInput(ref: ConversationRef): PendingUserInputRecord | null {
323
+ return getPendingUserInput(this.humanGateStoreDeps(), ref);
324
+ }
325
+
326
+ findPendingUserInputByRequestId(requestId: string | number): { meta: SessionMeta; request: PendingUserInputRecord } | null {
327
+ return findPendingUserInputByRequestId(this.humanGateStoreDeps(), requestId);
328
+ }
329
+
330
+ clearPendingUserInput(ref: ConversationRef, details?: { messageId?: string | null; answerPreview?: string | null }): void {
331
+ return clearPendingUserInput(this.humanGateStoreDeps(), ref, details);
332
+ }
333
+
334
+ listSessions(): SessionMeta[] {
335
+ return listStoredSessions(this.sessionMetaStoreDeps());
336
+ }
337
+
338
+ findApproval(approvalId: string): { meta: SessionMeta; approval: ApprovalRecord } | null {
339
+ return findApproval(this.humanGateStoreDeps(), approvalId);
340
+ }
341
+
342
+ findApprovalByRequestId(requestId: string | number): { meta: SessionMeta; approval: ApprovalRecord } | null {
343
+ return findApprovalByRequestId(this.humanGateStoreDeps(), requestId);
344
+ }
345
+
346
+ getApproval(ref: ConversationRef, approvalId: string): ApprovalRecord | null {
347
+ return getApproval(this.humanGateStoreDeps(), ref, approvalId);
348
+ }
349
+
350
+ listPendingApprovals(ref: ConversationRef): ApprovalRecord[] {
351
+ return listPendingApprovals(this.humanGateStoreDeps(), ref);
352
+ }
353
+
354
+ listHiddenPendingApprovals(ref: ConversationRef, turnId?: string | null): ApprovalRecord[] {
355
+ return listHiddenPendingApprovals(this.humanGateStoreDeps(), ref, turnId);
356
+ }
357
+
358
+ findHiddenPendingUserInput(ref: ConversationRef, turnId?: string | null): PendingUserInputRecord | null {
359
+ return findHiddenPendingUserInput(this.humanGateStoreDeps(), ref, turnId);
360
+ }
361
+
362
+ recordOutboundDelivery(
363
+ ref: ConversationRef,
364
+ record: Omit<OutboundDeliveryRecord, 'updatedAt'>,
365
+ ): OutboundDeliveryRecord {
366
+ return recordOutboundDelivery(this.deliveryStoreDeps(), ref, record);
367
+ }
368
+
369
+ markOutboundDelivered(ref: ConversationRef, sourceKey: string): OutboundDeliveryRecord | null {
370
+ return markOutboundDelivered(this.deliveryStoreDeps(), ref, sourceKey);
371
+ }
372
+
373
+ getOutboundDelivery(ref: ConversationRef, sourceKey: string): OutboundDeliveryRecord | null {
374
+ return getOutboundDelivery(this.deliveryStoreDeps(), ref, sourceKey);
375
+ }
376
+
377
+ listPendingOutboundDeliveries(ref: ConversationRef): OutboundDeliveryRecord[] {
378
+ return listPendingOutboundDeliveries(this.deliveryStoreDeps(), ref);
379
+ }
380
+ }
@@ -0,0 +1,220 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { FakeChannelAdapter } from './channels/fake/adapter.ts';
3
+ import { FeishuAdapter } from './channels/feishu/adapter.ts';
4
+ import {
5
+ describeChannelDeliveryIssue,
6
+ isNonFatalChannelDeliveryError,
7
+ normalizeChannelDeliveryError,
8
+ recordBridgeDeliveryIssue,
9
+ clearBridgeDeliveryIssue,
10
+ } from './channel-delivery.ts';
11
+ import type { WorkAllyConfig } from './config.ts';
12
+ import { loadConfig, validateConfig } from './config.ts';
13
+ import { SessionStore } from './session-store.ts';
14
+ import type { ChannelAdapter } from './channel-types.ts';
15
+ import type { ConversationRef, SessionMeta } from './types.ts';
16
+
17
+ export type SystemNoticeKind = 'start' | 'stop' | 'text';
18
+ export type NoticeTargetSource = 'latest_session' | 'default_push_target' | 'allowlisted_user';
19
+
20
+ export interface ResolvedNoticeConversationRef {
21
+ conversationRef: ConversationRef;
22
+ source: NoticeTargetSource;
23
+ }
24
+
25
+ export interface SendSystemNoticeOptions {
26
+ config?: WorkAllyConfig;
27
+ channel?: Pick<ChannelAdapter, 'send'>;
28
+ sessionStore?: Pick<SessionStore, 'listSessions'>;
29
+ strictDelivery?: boolean;
30
+ }
31
+
32
+ export interface SendSystemNoticeResult {
33
+ delivered: boolean;
34
+ skippedReason?: 'channel_not_feishu' | 'no_target' | 'delivery_unavailable';
35
+ issue?: ReturnType<typeof describeChannelDeliveryIssue>;
36
+ }
37
+
38
+ export const SYSTEM_MESSAGE_PREFIX = '**【系统消息】**';
39
+ export const START_NOTICE_TEXT = `${SYSTEM_MESSAGE_PREFIX} 我来上班咯,有事随时叫我~`;
40
+ export const STOP_NOTICE_TEXT = `${SYSTEM_MESSAGE_PREFIX} 我先下班咯,拜拜~`;
41
+
42
+ function createChannel(config: WorkAllyConfig): ChannelAdapter {
43
+ if (config.channel.impl === 'fake') {
44
+ return new FakeChannelAdapter({ handleInbound: async () => undefined });
45
+ }
46
+ return new FeishuAdapter(
47
+ { handleInbound: async () => undefined },
48
+ {
49
+ appId: config.feishu.appId!,
50
+ appSecret: config.feishu.appSecret!,
51
+ reactEmoji: config.feishu.reactEmoji,
52
+ allowedUserIds: config.channel.allowedUserIds,
53
+ },
54
+ );
55
+ }
56
+
57
+ function strictDeliveryEnabled(): boolean {
58
+ return ['1', 'true', 'yes', 'on'].includes(String(process.env.WORK_ALLY_SYSTEM_NOTICE_STRICT || '').trim().toLowerCase());
59
+ }
60
+
61
+ export function formatStartupDeliveryFailure(error: unknown): string {
62
+ const normalized = normalizeChannelDeliveryError('feishu', error);
63
+ if (isNonFatalChannelDeliveryError(normalized)) {
64
+ const issueMessageByKind: Record<string, string> = {
65
+ permission_denied: '当前机器人还没有发消息权限。',
66
+ app_not_ready: '当前机器人还没有完成审核发布。',
67
+ };
68
+ const issueMessage = issueMessageByKind[normalized.issue.kind] || '当前机器人还不能对外发消息。';
69
+ return `Feishu 启动探测失败(${normalized.issue.kind}):${issueMessage} ${normalized.issue.guidance}`;
70
+ }
71
+ if (normalized instanceof Error) {
72
+ return normalized.message;
73
+ }
74
+ return String(normalized);
75
+ }
76
+
77
+ export function latestConversationRef(sessions: SessionMeta[]): ConversationRef | null {
78
+ const latest = [...sessions]
79
+ .filter((session) => session.conversationRef.channel === 'feishu')
80
+ .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))[0];
81
+ return latest?.conversationRef || null;
82
+ }
83
+
84
+ export function formatSystemNoticeText(text: string): string {
85
+ const normalized = String(text || '').trim();
86
+ if (!normalized) {
87
+ return SYSTEM_MESSAGE_PREFIX;
88
+ }
89
+ if (normalized.startsWith(SYSTEM_MESSAGE_PREFIX)) {
90
+ return normalized;
91
+ }
92
+ return `${SYSTEM_MESSAGE_PREFIX} ${normalized}`;
93
+ }
94
+
95
+ export function resolveNoticeText(kind: SystemNoticeKind, customText?: string): string {
96
+ if (kind === 'start') {
97
+ return START_NOTICE_TEXT;
98
+ }
99
+ if (kind === 'stop') {
100
+ return STOP_NOTICE_TEXT;
101
+ }
102
+ return formatSystemNoticeText(customText || '');
103
+ }
104
+
105
+ export function resolveNoticeConversationRef(sessions: SessionMeta[], config: Pick<WorkAllyConfig, 'defaultPushTarget' | 'channel'>): ResolvedNoticeConversationRef | null {
106
+ const latest = latestConversationRef(sessions);
107
+ if (latest) {
108
+ return {
109
+ conversationRef: latest,
110
+ source: 'latest_session',
111
+ };
112
+ }
113
+
114
+ const pushTarget = String(config.defaultPushTarget || '').trim();
115
+ if (pushTarget) {
116
+ return {
117
+ conversationRef: {
118
+ channel: 'feishu',
119
+ conversationId: pushTarget,
120
+ userId: pushTarget,
121
+ chatType: pushTarget.startsWith('ou_') ? 'p2p' : 'dm',
122
+ },
123
+ source: 'default_push_target',
124
+ };
125
+ }
126
+
127
+ if (config.channel.allowedUserIds.length === 1) {
128
+ const userId = config.channel.allowedUserIds[0];
129
+ return {
130
+ conversationRef: {
131
+ channel: 'feishu',
132
+ conversationId: userId,
133
+ userId,
134
+ chatType: 'p2p',
135
+ },
136
+ source: 'allowlisted_user',
137
+ };
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ function shouldEnforceStrictDelivery(options: SendSystemNoticeOptions, target: ResolvedNoticeConversationRef): boolean {
144
+ return Boolean(options.strictDelivery && target.source !== 'allowlisted_user');
145
+ }
146
+
147
+ function normalizeNoticeKind(value: string | undefined): SystemNoticeKind {
148
+ const normalized = String(value || 'stop').trim().toLowerCase();
149
+ if (normalized === 'start') {
150
+ return 'start';
151
+ }
152
+ if (normalized === 'text') {
153
+ return 'text';
154
+ }
155
+ return 'stop';
156
+ }
157
+
158
+ export async function sendSystemNotice(kind: SystemNoticeKind, customText?: string, options: SendSystemNoticeOptions = {}): Promise<SendSystemNoticeResult> {
159
+ const config = options.config || loadConfig();
160
+ validateConfig(config);
161
+ if (config.channel.impl !== 'feishu') {
162
+ return { delivered: false, skippedReason: 'channel_not_feishu' };
163
+ }
164
+
165
+ const sessionStore = options.sessionStore || new SessionStore(config.paths.sessionsDir);
166
+ const target = resolveNoticeConversationRef(sessionStore.listSessions(), config);
167
+ if (!target) {
168
+ return { delivered: false, skippedReason: 'no_target' };
169
+ }
170
+
171
+ const channel = options.channel || createChannel(config);
172
+ const outbound = {
173
+ type: 'status_reply' as const,
174
+ conversationRef: target.conversationRef,
175
+ text: resolveNoticeText(kind, customText),
176
+ };
177
+
178
+ try {
179
+ await channel.send(outbound);
180
+ clearBridgeDeliveryIssue(config.paths.runtimeDir);
181
+ return { delivered: true };
182
+ } catch (error) {
183
+ const normalized = normalizeChannelDeliveryError(config.channel.impl, error);
184
+ if (isNonFatalChannelDeliveryError(normalized)) {
185
+ recordBridgeDeliveryIssue(config.paths.runtimeDir, normalized);
186
+ if (shouldEnforceStrictDelivery(options, target)) {
187
+ throw normalized;
188
+ }
189
+ return {
190
+ delivered: false,
191
+ skippedReason: 'delivery_unavailable',
192
+ issue: describeChannelDeliveryIssue(normalized),
193
+ };
194
+ }
195
+ throw normalized;
196
+ }
197
+ }
198
+
199
+ export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
200
+ const kind = normalizeNoticeKind(args[0]);
201
+ const customText = kind === 'text' ? args.slice(1).join(' ').trim() : '';
202
+ if (kind === 'text' && !customText) {
203
+ return;
204
+ }
205
+
206
+ await sendSystemNotice(kind, customText, {
207
+ strictDelivery: strictDeliveryEnabled(),
208
+ });
209
+ }
210
+
211
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
212
+ main().catch((error) => {
213
+ if (isNonFatalChannelDeliveryError(error)) {
214
+ console.error(formatStartupDeliveryFailure(error));
215
+ process.exit(2);
216
+ }
217
+ console.error(error instanceof Error ? error.message : String(error));
218
+ process.exit(1);
219
+ });
220
+ }