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,403 @@
1
+ import type { McpToolApprovalRule } from './config.ts';
2
+ import type { UserInputQuestion, UserInputRequest } from './types.ts';
3
+
4
+ export interface RuntimeUserInputAutoResolution {
5
+ kind: 'mcp_tool_call_approval';
6
+ serverName: string;
7
+ toolName: string;
8
+ matchedRule: string;
9
+ answerLabel: string;
10
+ answers?: Record<string, { answers: string[] }>;
11
+ elicitationResponse?: { action: 'accept'; content: unknown | null };
12
+ }
13
+
14
+ interface SchemaChoice {
15
+ label: string;
16
+ value: unknown;
17
+ description: string;
18
+ }
19
+
20
+ const MCP_TOOL_APPROVAL_RE = /^The\s+([^\s]+)\s+MCP server wants to run the tool\s+"([^"]+)".*Allow this action\?$/i;
21
+ const PREFERRED_APPROVAL_LABELS = ['Approve this Session', 'Approve Once'];
22
+ const ACCEPT_WORDS = new Set([
23
+ 'accept',
24
+ 'approve',
25
+ 'approved',
26
+ 'approve once',
27
+ 'approve this session',
28
+ 'yes',
29
+ 'y',
30
+ 'ok',
31
+ 'okay',
32
+ 'continue',
33
+ 'done',
34
+ 'open',
35
+ '同意',
36
+ ]);
37
+ const DECLINE_WORDS = new Set(['deny', 'decline', 'declined', 'reject', 'rejected', 'no', 'n', '拒绝']);
38
+ const CANCEL_WORDS = new Set(['cancel', 'cancelled', 'canceled', 'stop', '取消']);
39
+
40
+ function normalizeRulePart(value: string): string {
41
+ return value.trim().toLowerCase();
42
+ }
43
+
44
+ function normalizeSchemaObject(input: unknown): Record<string, unknown> | null {
45
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
46
+ return null;
47
+ }
48
+ return input as Record<string, unknown>;
49
+ }
50
+
51
+ function choiceFromVariant(input: unknown): SchemaChoice | null {
52
+ const variant = normalizeSchemaObject(input);
53
+ if (!variant) {
54
+ return null;
55
+ }
56
+ const description = typeof variant.description === 'string' ? variant.description : '';
57
+
58
+ if (Object.prototype.hasOwnProperty.call(variant, 'const')) {
59
+ const value = variant.const;
60
+ return {
61
+ label: typeof variant.title === 'string' ? variant.title : String(value),
62
+ value,
63
+ description,
64
+ };
65
+ }
66
+
67
+ if (Array.isArray(variant.enum) && variant.enum.length === 1) {
68
+ const value = variant.enum[0];
69
+ return {
70
+ label: typeof variant.title === 'string' ? variant.title : String(value),
71
+ value,
72
+ description,
73
+ };
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ function extractSchemaChoices(schema: Record<string, unknown> | null): SchemaChoice[] {
80
+ if (!schema) {
81
+ return [];
82
+ }
83
+
84
+ const choices: SchemaChoice[] = [];
85
+ const seen = new Set<string>();
86
+ const push = (choice: SchemaChoice | null) => {
87
+ if (!choice) {
88
+ return;
89
+ }
90
+ const key = `${choice.label}::${JSON.stringify(choice.value)}`;
91
+ if (seen.has(key)) {
92
+ return;
93
+ }
94
+ seen.add(key);
95
+ choices.push(choice);
96
+ };
97
+
98
+ if (Array.isArray(schema.enum)) {
99
+ for (const value of schema.enum) {
100
+ push({
101
+ label: String(value),
102
+ value,
103
+ description: typeof schema.description === 'string' ? schema.description : '',
104
+ });
105
+ }
106
+ }
107
+
108
+ for (const key of ['oneOf', 'anyOf']) {
109
+ const variants = schema[key];
110
+ if (!Array.isArray(variants)) {
111
+ continue;
112
+ }
113
+ for (const variant of variants) {
114
+ push(choiceFromVariant(variant));
115
+ }
116
+ }
117
+
118
+ return choices;
119
+ }
120
+
121
+ function matchesRule(rule: McpToolApprovalRule, serverName: string, toolName: string): boolean {
122
+ const normalizedServer = normalizeRulePart(serverName);
123
+ const normalizedTool = normalizeRulePart(toolName);
124
+ const serverMatches = rule.serverName === '*' || rule.serverName === normalizedServer;
125
+ const toolMatches = rule.toolName === '*' || rule.toolName === normalizedTool;
126
+ return serverMatches && toolMatches;
127
+ }
128
+
129
+ function parseMcpToolApproval(text: string | null | undefined): { serverName: string; toolName: string } | null {
130
+ const match = text?.trim().match(MCP_TOOL_APPROVAL_RE);
131
+ if (!match) {
132
+ return null;
133
+ }
134
+ return {
135
+ serverName: match[1],
136
+ toolName: match[2],
137
+ };
138
+ }
139
+
140
+ function detectMcpToolApprovalTarget(request: UserInputRequest): { serverName: string; toolName: string } | null {
141
+ if (request.kind === 'mcp_elicitation' && request.elicitation?.message) {
142
+ return parseMcpToolApproval(request.elicitation.message);
143
+ }
144
+ if (!request.questions?.length) {
145
+ return null;
146
+ }
147
+ return parseMcpToolApproval(request.questions[0]?.question);
148
+ }
149
+
150
+ function pickApprovalLabel(questions: UserInputQuestion[]): string | null {
151
+ const labels = questions
152
+ .flatMap((question) => (question.options || []).map((option) => option.label))
153
+ .filter(Boolean);
154
+ return PREFERRED_APPROVAL_LABELS.find((label) => labels.includes(label)) || null;
155
+ }
156
+
157
+ function formSchemaRoot(request: UserInputRequest): Record<string, unknown> | null {
158
+ if (request.kind !== 'mcp_elicitation') {
159
+ return null;
160
+ }
161
+ return normalizeSchemaObject(request.elicitation?.requestedSchema);
162
+ }
163
+
164
+ function schemaProperties(request: UserInputRequest): Record<string, unknown> {
165
+ const root = formSchemaRoot(request);
166
+ return normalizeSchemaObject(root?.properties) || {};
167
+ }
168
+
169
+ function propertySchema(request: UserInputRequest, questionId: string): Record<string, unknown> | null {
170
+ const value = schemaProperties(request)[questionId];
171
+ return normalizeSchemaObject(value);
172
+ }
173
+
174
+ function normalizeBoolean(raw: string): boolean | null {
175
+ const normalized = normalizeRulePart(raw);
176
+ if (ACCEPT_WORDS.has(normalized) || ['true', '1'].includes(normalized)) {
177
+ return true;
178
+ }
179
+ if (DECLINE_WORDS.has(normalized) || ['false', '0'].includes(normalized)) {
180
+ return false;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function isNegativeLabel(value: string): boolean {
186
+ const normalized = normalizeRulePart(value);
187
+ return DECLINE_WORDS.has(normalized) || CANCEL_WORDS.has(normalized);
188
+ }
189
+
190
+ function preferredAffirmativeValue(schema: Record<string, unknown> | null, question: UserInputQuestion): unknown {
191
+ const choices = extractSchemaChoices(schema);
192
+ if (choices.length) {
193
+ return choices.find((choice) => !isNegativeLabel(choice.label) && !isNegativeLabel(String(choice.value)))?.value ?? choices[0].value;
194
+ }
195
+ if (schema?.type === 'boolean') {
196
+ return true;
197
+ }
198
+ const optionLabel = (question.options || []).map((option) => option.label).find((label) => !isNegativeLabel(label));
199
+ if (optionLabel) {
200
+ return optionLabel;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function coerceScalarValue(raw: string, schema: Record<string, unknown> | null, question: UserInputQuestion): unknown {
206
+ const trimmed = raw.trim();
207
+ const choices = extractSchemaChoices(schema);
208
+ if (choices.length) {
209
+ const normalized = normalizeRulePart(trimmed);
210
+ for (const choice of choices) {
211
+ if (normalizeRulePart(choice.label) === normalized || normalizeRulePart(String(choice.value)) === normalized) {
212
+ return choice.value;
213
+ }
214
+ }
215
+ }
216
+
217
+ switch (schema?.type) {
218
+ case 'boolean': {
219
+ const value = normalizeBoolean(trimmed);
220
+ return value === null ? trimmed : value;
221
+ }
222
+ case 'integer': {
223
+ const value = Number(trimmed);
224
+ return Number.isInteger(value) ? value : trimmed;
225
+ }
226
+ case 'number': {
227
+ const value = Number(trimmed);
228
+ return Number.isFinite(value) ? value : trimmed;
229
+ }
230
+ case 'array': {
231
+ const itemSchema = normalizeSchemaObject(schema.items);
232
+ const parts = trimmed
233
+ .split(trimmed.includes('\n') ? /\r?\n/ : /\s*,\s*/)
234
+ .map((part) => part.trim())
235
+ .filter(Boolean);
236
+ const source = parts.length ? parts : [trimmed];
237
+ return source.map((part) => coerceScalarValue(part, itemSchema, question));
238
+ }
239
+ default:
240
+ break;
241
+ }
242
+
243
+ if (question.options?.length) {
244
+ const optionMatch = question.options.find((option) => normalizeRulePart(option.label) === normalizeRulePart(trimmed));
245
+ if (optionMatch) {
246
+ return optionMatch.label;
247
+ }
248
+ }
249
+
250
+ return trimmed;
251
+ }
252
+
253
+ function coerceAnswerWithAcceptFallback(raw: string, schema: Record<string, unknown> | null, question: UserInputQuestion): unknown {
254
+ const trimmed = raw.trim();
255
+ const value = coerceScalarValue(trimmed, schema, question);
256
+ if (typeof value === 'string' && value === trimmed && actionFromText(trimmed) === 'accept') {
257
+ const preferred = preferredAffirmativeValue(schema, question);
258
+ if (preferred !== null) {
259
+ return preferred;
260
+ }
261
+ }
262
+ return value;
263
+ }
264
+
265
+ function buildFormElicitationContent(request: UserInputRequest, text: string): unknown {
266
+ const trimmed = text.trim();
267
+ const root = formSchemaRoot(request);
268
+ const questions = request.questions || [];
269
+ const rootIsObject = Boolean(root && (root.type === 'object' || Object.keys(schemaProperties(request)).length > 0));
270
+
271
+ if (!questions.length) {
272
+ return rootIsObject ? { response: trimmed } : trimmed;
273
+ }
274
+
275
+ if (questions.length === 1) {
276
+ const question = questions[0];
277
+ const schema = propertySchema(request, question.id) || root;
278
+ const value = coerceAnswerWithAcceptFallback(trimmed, schema, question);
279
+ if (!rootIsObject) {
280
+ return value;
281
+ }
282
+ return { [question.id]: value };
283
+ }
284
+
285
+ const answers: Record<string, unknown> = {};
286
+ const byQuestion = new Map(questions.map((question) => [question.id, question]));
287
+ const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
288
+ let matchedAny = false;
289
+
290
+ for (const line of lines) {
291
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*[::]\s*(.+)$/);
292
+ if (!match) {
293
+ continue;
294
+ }
295
+ const [, questionId, answer] = match;
296
+ const question = byQuestion.get(questionId);
297
+ if (!question) {
298
+ continue;
299
+ }
300
+ answers[questionId] = coerceAnswerWithAcceptFallback(answer, propertySchema(request, questionId), question);
301
+ matchedAny = true;
302
+ }
303
+
304
+ if (!matchedAny) {
305
+ for (const question of questions) {
306
+ answers[question.id] = coerceAnswerWithAcceptFallback(trimmed, propertySchema(request, question.id), question);
307
+ }
308
+ }
309
+
310
+ return answers;
311
+ }
312
+
313
+ function actionFromText(text: string): 'accept' | 'decline' | 'cancel' | null {
314
+ const normalized = normalizeRulePart(text);
315
+ if (CANCEL_WORDS.has(normalized)) {
316
+ return 'cancel';
317
+ }
318
+ if (DECLINE_WORDS.has(normalized)) {
319
+ return 'decline';
320
+ }
321
+ if (ACCEPT_WORDS.has(normalized)) {
322
+ return 'accept';
323
+ }
324
+ return null;
325
+ }
326
+
327
+ export function buildElicitationResponseFromReply(
328
+ request: UserInputRequest,
329
+ text: string,
330
+ ): { action: 'accept' | 'decline' | 'cancel'; content: unknown | null } | null {
331
+ if (request.kind !== 'mcp_elicitation' || !request.elicitation) {
332
+ return null;
333
+ }
334
+
335
+ const explicitAction = actionFromText(text);
336
+ if (request.elicitation.mode === 'url') {
337
+ return {
338
+ action: explicitAction || 'accept',
339
+ content: null,
340
+ };
341
+ }
342
+
343
+ if (explicitAction === 'decline' || explicitAction === 'cancel') {
344
+ return {
345
+ action: explicitAction,
346
+ content: null,
347
+ };
348
+ }
349
+
350
+ return {
351
+ action: 'accept',
352
+ content: buildFormElicitationContent(request, text),
353
+ };
354
+ }
355
+
356
+ export function maybeAutoResolveRuntimeUserInput(
357
+ request: UserInputRequest,
358
+ rules: McpToolApprovalRule[],
359
+ ): RuntimeUserInputAutoResolution | null {
360
+ const target = detectMcpToolApprovalTarget(request);
361
+ if (!target) {
362
+ return null;
363
+ }
364
+
365
+ const matchedRule = rules.find((rule) => matchesRule(rule, target.serverName, target.toolName));
366
+ if (!matchedRule) {
367
+ return null;
368
+ }
369
+
370
+ const answerLabel = pickApprovalLabel(request.questions) || 'Approve this Session';
371
+
372
+ if (request.kind === 'mcp_elicitation') {
373
+ const elicitationResponse = buildElicitationResponseFromReply(request, answerLabel);
374
+ if (!elicitationResponse || elicitationResponse.action !== 'accept') {
375
+ return null;
376
+ }
377
+ return {
378
+ kind: 'mcp_tool_call_approval',
379
+ serverName: target.serverName,
380
+ toolName: target.toolName,
381
+ matchedRule: `${matchedRule.serverName}:${matchedRule.toolName}`,
382
+ answerLabel,
383
+ elicitationResponse,
384
+ };
385
+ }
386
+
387
+ if (!request.questions?.length) {
388
+ return null;
389
+ }
390
+
391
+ return {
392
+ kind: 'mcp_tool_call_approval',
393
+ serverName: target.serverName,
394
+ toolName: target.toolName,
395
+ matchedRule: `${matchedRule.serverName}:${matchedRule.toolName}`,
396
+ answerLabel,
397
+ answers: {
398
+ [request.questions[0].id]: {
399
+ answers: [answerLabel],
400
+ },
401
+ },
402
+ };
403
+ }
@@ -0,0 +1,239 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import cronParser from 'cron-parser';
4
+ import YAML from 'yaml';
5
+ import { nowIso, readJsonFile, writeJsonFile } from './utils.ts';
6
+
7
+ export interface RoutineDefinition {
8
+ id: string;
9
+ description?: string;
10
+ schedule?: string;
11
+ enabled?: boolean;
12
+ type?: 'prompt' | 'memory_digest';
13
+ prompt?: string;
14
+ thread_mode?: 'reuse' | 'new';
15
+ push?: { kind: 'none' | 'default_im' };
16
+ writeback?: { kind: 'none' | 'append_markdown'; path?: string };
17
+ }
18
+
19
+ export interface DueRoutine {
20
+ routine: RoutineDefinition;
21
+ slot: string;
22
+ }
23
+
24
+ export interface RunningStateEntry {
25
+ startedAt: string;
26
+ pid: number | null;
27
+ }
28
+
29
+ export interface SchedulerState {
30
+ disabled: string[];
31
+ lastRuns: Record<string, string>;
32
+ running: Record<string, RunningStateEntry>;
33
+ routineThreads: Record<string, string>;
34
+ }
35
+
36
+ function normalizeRunningEntry(value: unknown): RunningStateEntry | null {
37
+ if (typeof value === 'string') {
38
+ return { startedAt: value, pid: null };
39
+ }
40
+ if (!value || typeof value !== 'object') {
41
+ return null;
42
+ }
43
+ const parsed = value as Partial<RunningStateEntry>;
44
+ if (typeof parsed.startedAt !== 'string') {
45
+ return null;
46
+ }
47
+ return {
48
+ startedAt: parsed.startedAt,
49
+ pid: typeof parsed.pid === 'number' ? parsed.pid : null,
50
+ };
51
+ }
52
+
53
+ function isPidAlive(pid: number | null): boolean {
54
+ if (!pid || pid <= 0) {
55
+ return false;
56
+ }
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function normalizeState(value: unknown): SchedulerState {
66
+ const parsed = (value && typeof value === 'object') ? value as Partial<SchedulerState> : {};
67
+ const running = parsed.running && typeof parsed.running === 'object'
68
+ ? Object.fromEntries(
69
+ Object.entries(parsed.running).flatMap(([id, entry]) => {
70
+ const normalized = normalizeRunningEntry(entry);
71
+ return normalized ? [[id, normalized]] : [];
72
+ }),
73
+ )
74
+ : {};
75
+ return {
76
+ disabled: Array.isArray(parsed.disabled) ? parsed.disabled.filter((item): item is string => typeof item === 'string') : [],
77
+ lastRuns: parsed.lastRuns && typeof parsed.lastRuns === 'object' ? parsed.lastRuns as Record<string, string> : {},
78
+ running,
79
+ routineThreads: parsed.routineThreads && typeof parsed.routineThreads === 'object' ? parsed.routineThreads as Record<string, string> : {},
80
+ };
81
+ }
82
+
83
+ export class Scheduler {
84
+ private readonly routinesDir: string;
85
+ private readonly stateFile: string;
86
+ private readonly timeZone: string;
87
+ private readonly fallbackRoutinesDir: string | null;
88
+
89
+ constructor(routinesDir: string, stateFile: string, timeZone = 'UTC', fallbackRoutinesDir: string | null = null) {
90
+ this.routinesDir = routinesDir;
91
+ this.stateFile = stateFile;
92
+ this.timeZone = timeZone;
93
+ this.fallbackRoutinesDir = fallbackRoutinesDir;
94
+ }
95
+
96
+ private pruneStaleRunning(state: SchedulerState): SchedulerState {
97
+ let changed = false;
98
+ for (const [id, entry] of Object.entries(state.running)) {
99
+ if (isPidAlive(entry.pid)) {
100
+ continue;
101
+ }
102
+ delete state.running[id];
103
+ changed = true;
104
+ }
105
+ if (changed) {
106
+ this.saveState(state);
107
+ }
108
+ return state;
109
+ }
110
+
111
+ loadState(): SchedulerState {
112
+ return this.pruneStaleRunning(normalizeState(readJsonFile(this.stateFile, { disabled: [], lastRuns: {}, running: {}, routineThreads: {} })));
113
+ }
114
+
115
+ saveState(state: SchedulerState): void {
116
+ writeJsonFile(this.stateFile, state);
117
+ }
118
+
119
+ listRoutines(): RoutineDefinition[] {
120
+ const roots = [this.routinesDir, this.fallbackRoutinesDir].filter((value, index, list): value is string => Boolean(value) && list.indexOf(value as string) === index);
121
+ const files = roots.flatMap((root) => {
122
+ if (!fs.existsSync(root)) {
123
+ return [];
124
+ }
125
+ return fs.readdirSync(root)
126
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
127
+ .map((name) => path.join(root, name));
128
+ });
129
+
130
+ if (files.length === 0) {
131
+ return [];
132
+ }
133
+
134
+ const seen = new Set<string>();
135
+ return files
136
+ .map((fullPath) => {
137
+ const parsed = YAML.parse(fs.readFileSync(fullPath, 'utf8')) || {};
138
+ const fileName = path.basename(fullPath);
139
+ const routine: RoutineDefinition = {
140
+ id: parsed.id || path.basename(fileName, path.extname(fileName)),
141
+ description: parsed.description,
142
+ schedule: parsed.schedule,
143
+ enabled: parsed.enabled !== false,
144
+ type: parsed.type || 'prompt',
145
+ prompt: parsed.prompt,
146
+ thread_mode: parsed.thread_mode || 'reuse',
147
+ push: parsed.push || { kind: 'none' },
148
+ writeback: parsed.writeback || { kind: 'none' },
149
+ };
150
+ return routine;
151
+ })
152
+ .filter((routine) => {
153
+ if (seen.has(routine.id)) {
154
+ return false;
155
+ }
156
+ seen.add(routine.id);
157
+ return true;
158
+ })
159
+ .sort((a, b) => a.id.localeCompare(b.id));
160
+ }
161
+
162
+ enableRoutine(id: string): void {
163
+ const state = this.loadState();
164
+ state.disabled = state.disabled.filter((item) => item !== id);
165
+ this.saveState(state);
166
+ }
167
+
168
+ disableRoutine(id: string): void {
169
+ const state = this.loadState();
170
+ if (!state.disabled.includes(id)) {
171
+ state.disabled.push(id);
172
+ }
173
+ this.saveState(state);
174
+ }
175
+
176
+ previousSlotForRoutine(routine: RoutineDefinition, now = new Date()): string | null {
177
+ if (!routine.schedule) {
178
+ return null;
179
+ }
180
+ const interval = cronParser.CronExpressionParser.parse(routine.schedule, { currentDate: now, tz: this.timeZone });
181
+ return interval.prev().toDate().toISOString();
182
+ }
183
+
184
+ computeDue(now = new Date()): DueRoutine[] {
185
+ const state = this.loadState();
186
+ return this.listRoutines().flatMap((routine) => {
187
+ if (routine.enabled === false) return [];
188
+ if (state.disabled.includes(routine.id)) return [];
189
+ if (state.running[routine.id]) return [];
190
+ const slot = this.previousSlotForRoutine(routine, now);
191
+ if (!slot) return [];
192
+ if (state.lastRuns[routine.id] === slot) {
193
+ return [];
194
+ }
195
+
196
+ return [{ routine, slot }];
197
+ });
198
+ }
199
+
200
+ claimRun(id: string, startedAt = nowIso()): boolean {
201
+ const state = this.loadState();
202
+ if (state.running[id]) {
203
+ return false;
204
+ }
205
+ state.running[id] = { startedAt, pid: process.pid };
206
+ this.saveState(state);
207
+ return true;
208
+ }
209
+
210
+ releaseRun(id: string): void {
211
+ const state = this.loadState();
212
+ delete state.running[id];
213
+ this.saveState(state);
214
+ }
215
+
216
+ markRun(id: string, slot = nowIso()): void {
217
+ const state = this.loadState();
218
+ state.lastRuns[id] = slot;
219
+ delete state.running[id];
220
+ this.saveState(state);
221
+ }
222
+
223
+ getRoutineThread(id: string): string | null {
224
+ const state = this.loadState();
225
+ return state.routineThreads[id] || null;
226
+ }
227
+
228
+ bindRoutineThread(id: string, threadId: string): void {
229
+ const state = this.loadState();
230
+ state.routineThreads[id] = threadId;
231
+ this.saveState(state);
232
+ }
233
+
234
+ clearRoutineThread(id: string): void {
235
+ const state = this.loadState();
236
+ delete state.routineThreads[id];
237
+ this.saveState(state);
238
+ }
239
+ }