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.
- package/AGENTS.md +110 -0
- package/DASHBOARD.md +160 -0
- package/PRODUCT.md +113 -0
- package/README.md +403 -0
- package/ally.sh +171 -0
- package/bridge/src/approval-rules.ts +360 -0
- package/bridge/src/channel-delivery.ts +207 -0
- package/bridge/src/channel-types.ts +22 -0
- package/bridge/src/channels/fake/adapter.ts +31 -0
- package/bridge/src/channels/feishu/adapter.ts +411 -0
- package/bridge/src/channels/feishu/approvals.ts +6 -0
- package/bridge/src/channels/feishu/formatter.ts +276 -0
- package/bridge/src/channels/feishu/normalize.ts +368 -0
- package/bridge/src/codex-config.ts +52 -0
- package/bridge/src/config.ts +240 -0
- package/bridge/src/fake-runtime-client.ts +505 -0
- package/bridge/src/handoff-service.ts +494 -0
- package/bridge/src/logger.ts +194 -0
- package/bridge/src/memory-digest.ts +186 -0
- package/bridge/src/receiver-approval-autonomy.ts +158 -0
- package/bridge/src/receiver-control-core.ts +140 -0
- package/bridge/src/receiver-control-work-session.ts +218 -0
- package/bridge/src/receiver-control.ts +83 -0
- package/bridge/src/receiver-delivery.ts +136 -0
- package/bridge/src/receiver-helpers.ts +96 -0
- package/bridge/src/receiver-human-gate.ts +333 -0
- package/bridge/src/receiver-inbound-preflight.ts +162 -0
- package/bridge/src/receiver-recovery.ts +236 -0
- package/bridge/src/receiver-runtime-callbacks.ts +367 -0
- package/bridge/src/receiver-runtime-policy.ts +132 -0
- package/bridge/src/receiver-runtime-state.ts +124 -0
- package/bridge/src/receiver-support-actions.ts +189 -0
- package/bridge/src/receiver-thread-start.ts +57 -0
- package/bridge/src/receiver-turn-coordination.ts +94 -0
- package/bridge/src/receiver-turn-execution.ts +257 -0
- package/bridge/src/receiver-turn-failure.ts +143 -0
- package/bridge/src/receiver-turn-result.ts +185 -0
- package/bridge/src/receiver-turn-steer.ts +70 -0
- package/bridge/src/receiver-work-session.ts +76 -0
- package/bridge/src/receiver.ts +329 -0
- package/bridge/src/router.ts +62 -0
- package/bridge/src/runtime-client-agent-messages.ts +150 -0
- package/bridge/src/runtime-client-message-dispatch.ts +176 -0
- package/bridge/src/runtime-client-protocol.ts +411 -0
- package/bridge/src/runtime-client-request-ops.ts +56 -0
- package/bridge/src/runtime-client-run-turn.ts +158 -0
- package/bridge/src/runtime-client-thread-ops.ts +270 -0
- package/bridge/src/runtime-client-transport.ts +309 -0
- package/bridge/src/runtime-client-turn-poll.ts +224 -0
- package/bridge/src/runtime-client-turn-read.ts +185 -0
- package/bridge/src/runtime-client-turn-state.ts +105 -0
- package/bridge/src/runtime-client.ts +344 -0
- package/bridge/src/runtime-user-input.ts +403 -0
- package/bridge/src/scheduler.ts +239 -0
- package/bridge/src/server-handoff-command.ts +364 -0
- package/bridge/src/server-main.ts +80 -0
- package/bridge/src/server-routine-command.ts +60 -0
- package/bridge/src/server-routine-execution.ts +222 -0
- package/bridge/src/server-runtime-app-support.ts +107 -0
- package/bridge/src/server-runtime-app.ts +238 -0
- package/bridge/src/server-thread-sync-command.ts +63 -0
- package/bridge/src/server.ts +17 -0
- package/bridge/src/session-store-delivery.ts +220 -0
- package/bridge/src/session-store-human-gate.ts +380 -0
- package/bridge/src/session-store-inbound-acceptance.ts +66 -0
- package/bridge/src/session-store-meta.ts +134 -0
- package/bridge/src/session-store-turn-ledger.ts +272 -0
- package/bridge/src/session-store.ts +380 -0
- package/bridge/src/system-notify.ts +220 -0
- package/bridge/src/thread-sync.ts +200 -0
- package/bridge/src/translator.ts +494 -0
- package/bridge/src/types.ts +289 -0
- package/bridge/src/utils.ts +104 -0
- package/bridge/src/work-session-store.ts +471 -0
- package/docs/.gitkeep +0 -0
- package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
- package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
- package/docs/completed/README.md +21 -0
- package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
- package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
- package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
- package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
- package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
- package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
- package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
- package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
- package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
- package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
- package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
- package/docs/developer-workflow.md +49 -0
- package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
- package/docs/implementation/test-coverage-map.md +363 -0
- package/docs/implementation/work-ally-implementation-guide.md +790 -0
- package/docs/issues/README.md +10 -0
- package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
- package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
- package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
- package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
- package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
- package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
- package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
- package/docs/manual-acceptance.md +127 -0
- package/docs/ops-runbook.md +44 -0
- package/docs/planning/FEATURE-memory-system.md +748 -0
- package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
- package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
- package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
- package/docs/planning/SPEC-assistant-rename.md +610 -0
- package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
- package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
- package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
- package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
- package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
- package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
- package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
- package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
- package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
- package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
- package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
- package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
- package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
- package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
- package/docs/planning/SPEC-stable-archive-contract.md +456 -0
- package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
- package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
- package/docs/planning/ally-next.md +1278 -0
- package/docs/planning/assistant-workbench-spec.md +725 -0
- package/docs/planning/product-workbench.md +283 -0
- package/docs/product-onboarding.md +227 -0
- package/docs/product-spec-standard.md +528 -0
- package/docs/troubleshooting.md +45 -0
- package/docs/user-quickstart.md +46 -0
- package/internal/dispatch.sh +95 -0
- package/internal/lib/common.sh +1450 -0
- package/internal/modules/assistant/manage.sh +1312 -0
- package/internal/modules/bootstrap/setup.sh +144 -0
- package/internal/modules/config/init-env.sh +10 -0
- package/internal/modules/global/manage.sh +154 -0
- package/internal/modules/handoff/manage.sh +54 -0
- package/internal/modules/mcp/manage.sh +83 -0
- package/internal/modules/ops/logs.sh +76 -0
- package/internal/modules/routines/manage.sh +55 -0
- package/internal/modules/runtime/assistant-autosave.sh +26 -0
- package/internal/modules/runtime/restart.sh +6 -0
- package/internal/modules/runtime/start.sh +283 -0
- package/internal/modules/runtime/status.sh +194 -0
- package/internal/modules/runtime/stop.sh +55 -0
- package/internal/modules/runtime/supervisor.sh +216 -0
- package/internal/modules/runtime/update.sh +26 -0
- package/package.json +41 -0
- package/runtime/config/.gitkeep +0 -0
- package/runtime/host/.gitkeep +0 -0
- package/runtime/host/healthcheck-codex-app-server.ts +22 -0
- package/runtime/host/ping-pong-codex-app-server.ts +66 -0
- package/runtime/host/probe-codex-app-server.ts +115 -0
- package/skills/archive-reader/SKILL.md +9 -0
- package/skills/feishu-production-debug/SKILL.md +37 -0
- package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
- package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
- package/skills/issue-to-spec-triage/SKILL.md +44 -0
- package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
- package/skills/memory-digest/SKILL.md +9 -0
- package/skills/post-implementation-closure/SKILL.md +39 -0
- package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
- package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
- package/skills/product-spec/SKILL.md +244 -0
- package/templates/env.example +5 -0
- package/templates/routines/nightly-memory-digest.yaml +10 -0
- package/templates/workspace/AGENTS.md +26 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { WorkAllyConfig } from './config.ts';
|
|
2
|
+
import type { OutboundEnvelope } from './channel-types.ts';
|
|
3
|
+
import type { HandoffService } from './handoff-service.ts';
|
|
4
|
+
import type { ControlCommand } from './router.ts';
|
|
5
|
+
import type { SessionStore } from './session-store.ts';
|
|
6
|
+
import { handleCoreControlCommand } from './receiver-control-core.ts';
|
|
7
|
+
import { handleWorkSessionControlCommand } from './receiver-control-work-session.ts';
|
|
8
|
+
import { activeWorkSessionFor, resolveDeliveryConversationKey, syncLinkedWorkSessionThread } from './receiver-work-session.ts';
|
|
9
|
+
import { WorkSessionStore } from './work-session-store.ts';
|
|
10
|
+
import type { InboundMessage, RuntimeThreadStatus, SessionMeta } from './types.ts';
|
|
11
|
+
|
|
12
|
+
interface ControlRuntimeLike {
|
|
13
|
+
startThread(cwd: string): Promise<{ id: string }>;
|
|
14
|
+
resumeThread(threadId: string, cwd: string): Promise<{ id: string }>;
|
|
15
|
+
readThreadStatus(threadId: string): Promise<RuntimeThreadStatus | null>;
|
|
16
|
+
resolveApproval(requestId: string | number, decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel'): Promise<void>;
|
|
17
|
+
interruptTurn(threadId: string, turnId: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SendLike {
|
|
21
|
+
(
|
|
22
|
+
outbound: OutboundEnvelope,
|
|
23
|
+
extra?: Record<string, unknown>,
|
|
24
|
+
delivery?: { trackTurnId?: string | null },
|
|
25
|
+
): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HandleControlDeps {
|
|
29
|
+
config: WorkAllyConfig;
|
|
30
|
+
runtime: ControlRuntimeLike;
|
|
31
|
+
sessionStore: SessionStore;
|
|
32
|
+
workSessionStore: WorkSessionStore;
|
|
33
|
+
handoffService: HandoffService;
|
|
34
|
+
send: SendLike;
|
|
35
|
+
assistantName: string;
|
|
36
|
+
assistantCodexHome: string | null;
|
|
37
|
+
commandApprovalRulesStatusLines: string[];
|
|
38
|
+
noteUserRequestedStop(turnId: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface HandleControlArgs {
|
|
42
|
+
message: InboundMessage;
|
|
43
|
+
command: NonNullable<ControlCommand>;
|
|
44
|
+
session: SessionMeta;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleControlCommand(
|
|
48
|
+
deps: HandleControlDeps,
|
|
49
|
+
args: HandleControlArgs,
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
const workSessionDeps = {
|
|
52
|
+
assistantName: deps.assistantName,
|
|
53
|
+
workSessionStore: deps.workSessionStore,
|
|
54
|
+
};
|
|
55
|
+
const workSessionArgs = {
|
|
56
|
+
...args,
|
|
57
|
+
linkedWorkSession: activeWorkSessionFor(workSessionDeps, args.message.conversationRef),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (await handleWorkSessionControlCommand({
|
|
61
|
+
config: deps.config,
|
|
62
|
+
runtime: deps.runtime,
|
|
63
|
+
sessionStore: deps.sessionStore,
|
|
64
|
+
workSessionStore: deps.workSessionStore,
|
|
65
|
+
handoffService: deps.handoffService,
|
|
66
|
+
send: deps.send,
|
|
67
|
+
assistantName: deps.assistantName,
|
|
68
|
+
assistantCodexHome: deps.assistantCodexHome,
|
|
69
|
+
resolveDeliveryConversationKey,
|
|
70
|
+
syncLinkedWorkSessionThread: (ref, runtimeThreadId) => syncLinkedWorkSessionThread(workSessionDeps, ref, runtimeThreadId),
|
|
71
|
+
}, workSessionArgs)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return handleCoreControlCommand({
|
|
76
|
+
config: deps.config,
|
|
77
|
+
runtime: deps.runtime,
|
|
78
|
+
sessionStore: deps.sessionStore,
|
|
79
|
+
send: deps.send,
|
|
80
|
+
commandApprovalRulesStatusLines: deps.commandApprovalRulesStatusLines,
|
|
81
|
+
noteUserRequestedStop: deps.noteUserRequestedStop,
|
|
82
|
+
}, args);
|
|
83
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { OutboundEnvelope } from './channel-types.ts';
|
|
2
|
+
import type { SessionStore } from './session-store.ts';
|
|
3
|
+
import { finalReplySourceKey, payloadDigest } from './receiver-helpers.ts';
|
|
4
|
+
import { completionWithoutReplyStatusMessage, redeliveredPreviousTurnPrefixMessage, runtimeFinalStateRecoveryRequiredMessage, runtimeInfrastructureErrorMessage, systemNoticeMessage } from './translator.ts';
|
|
5
|
+
import type { ConversationRef } from './types.ts';
|
|
6
|
+
|
|
7
|
+
interface DeliveryDeps {
|
|
8
|
+
sessionStore: SessionStore;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface RedeliveryDeps extends DeliveryDeps {
|
|
12
|
+
send(
|
|
13
|
+
outbound: OutboundEnvelope,
|
|
14
|
+
extra?: Record<string, unknown>,
|
|
15
|
+
delivery?: { trackTurnId?: string | null },
|
|
16
|
+
): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TurnResultRedeliveryDeps extends RedeliveryDeps {
|
|
20
|
+
isRuntimeInfrastructureError(text: string): boolean;
|
|
21
|
+
normalizeErrorReplyText(text: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function recordPendingFinalReplyDelivery(
|
|
25
|
+
deps: DeliveryDeps,
|
|
26
|
+
conversationRef: ConversationRef,
|
|
27
|
+
threadId: string,
|
|
28
|
+
turnId: string,
|
|
29
|
+
text: string,
|
|
30
|
+
): string {
|
|
31
|
+
const sourceKey = finalReplySourceKey(threadId, turnId);
|
|
32
|
+
deps.sessionStore.recordOutboundDelivery(conversationRef, {
|
|
33
|
+
sourceKey,
|
|
34
|
+
threadId,
|
|
35
|
+
turnId,
|
|
36
|
+
conversationRef,
|
|
37
|
+
envelopeType: 'final_reply',
|
|
38
|
+
payloadText: text,
|
|
39
|
+
payloadDigest: payloadDigest(text),
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
status: 'pending',
|
|
42
|
+
deliveredAt: null,
|
|
43
|
+
});
|
|
44
|
+
return sourceKey;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function markFinalReplyDelivered(
|
|
48
|
+
deps: DeliveryDeps,
|
|
49
|
+
conversationRef: ConversationRef,
|
|
50
|
+
threadId: string,
|
|
51
|
+
turnId: string,
|
|
52
|
+
): void {
|
|
53
|
+
deps.sessionStore.markOutboundDelivered(conversationRef, finalReplySourceKey(threadId, turnId));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function redeliverPendingFinalReplies(
|
|
57
|
+
deps: RedeliveryDeps,
|
|
58
|
+
conversationRef: ConversationRef,
|
|
59
|
+
): Promise<number> {
|
|
60
|
+
const pending = deps.sessionStore.listPendingOutboundDeliveries(conversationRef)
|
|
61
|
+
.filter((record) => record.envelopeType === 'final_reply');
|
|
62
|
+
let delivered = 0;
|
|
63
|
+
for (const record of pending) {
|
|
64
|
+
const ok = await deps.send({
|
|
65
|
+
type: 'final_reply',
|
|
66
|
+
conversationRef,
|
|
67
|
+
text: record.payloadText,
|
|
68
|
+
}, {
|
|
69
|
+
source: 'startup_redeliver_final_reply',
|
|
70
|
+
thread_id: record.threadId,
|
|
71
|
+
turn_id: record.turnId,
|
|
72
|
+
source_key: record.sourceKey,
|
|
73
|
+
}, { trackTurnId: record.turnId });
|
|
74
|
+
if (!ok) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
deps.sessionStore.markOutboundDelivered(conversationRef, record.sourceKey);
|
|
78
|
+
delivered += 1;
|
|
79
|
+
}
|
|
80
|
+
return delivered;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function redeliverPendingTurnResult(
|
|
84
|
+
deps: TurnResultRedeliveryDeps,
|
|
85
|
+
conversationRef: ConversationRef,
|
|
86
|
+
ledgerTurnId: string,
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
const ledger = deps.sessionStore.getTurnLedger(conversationRef, ledgerTurnId);
|
|
89
|
+
if (!ledger || ledger.deliveryStatus !== 'delivery_unavailable') {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const turn = deps.sessionStore.getTurnRecord(conversationRef, ledgerTurnId);
|
|
93
|
+
if (!turn) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const replyText = String(turn.reply || '').trim();
|
|
98
|
+
const userRequestedStop = turn.error === 'user requested stop';
|
|
99
|
+
const interruptedWithoutReply = !replyText && (
|
|
100
|
+
turn.status === 'interrupted'
|
|
101
|
+
|| turn.error === 'turn interrupted'
|
|
102
|
+
|| userRequestedStop
|
|
103
|
+
);
|
|
104
|
+
const isCompleted = turn.status === 'completed';
|
|
105
|
+
const text = isCompleted
|
|
106
|
+
? (replyText ? `${redeliveredPreviousTurnPrefixMessage()}\n\n${turn.reply}` : completionWithoutReplyStatusMessage())
|
|
107
|
+
: interruptedWithoutReply
|
|
108
|
+
? (userRequestedStop
|
|
109
|
+
? '当前任务已停止。'
|
|
110
|
+
: deps.isRuntimeInfrastructureError(turn.error || '')
|
|
111
|
+
? systemNoticeMessage(runtimeInfrastructureErrorMessage())
|
|
112
|
+
: systemNoticeMessage(runtimeFinalStateRecoveryRequiredMessage()))
|
|
113
|
+
: deps.normalizeErrorReplyText(turn.error || '处理失败。');
|
|
114
|
+
const type = isCompleted
|
|
115
|
+
? (replyText ? 'final_reply' : 'status_reply')
|
|
116
|
+
: interruptedWithoutReply
|
|
117
|
+
? (userRequestedStop ? 'status_reply' : 'error_reply')
|
|
118
|
+
: 'error_reply';
|
|
119
|
+
|
|
120
|
+
const delivered = await deps.send({
|
|
121
|
+
type,
|
|
122
|
+
conversationRef,
|
|
123
|
+
text,
|
|
124
|
+
}, {
|
|
125
|
+
source: 'redeliver_previous_turn_result',
|
|
126
|
+
thread_id: turn.threadId,
|
|
127
|
+
turn_id: turn.turnId,
|
|
128
|
+
original_delivery_status: ledger.deliveryStatus,
|
|
129
|
+
original_turn_status: turn.status,
|
|
130
|
+
}, { trackTurnId: turn.turnId });
|
|
131
|
+
|
|
132
|
+
if (!delivered) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { PendingUserInputRecord } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export const LOCAL_APPROVAL_COMMAND_RE = /^(git\s+(?:status|diff)(?:\s+.*)?|git\s+add(?:\s+.*)?|git\s+commit\b(?:\s+.*)?|npm\s+(?:test|run\s+test(?::[\w-]+)?)(?:\s+--.*)?|node\s+--test\b(?:\s+.*)?|bash\s+tests\/shell\/[\w.-]+\.sh\b(?:\s+.*)?|bash\s+-n\s+ally\.sh\b|ally\s+(?:status|logs\b.*)|cmake\s+(?:-S\b.*\s-B\b.*|--build\b.*)|ctest\b(?:\s+.*)?|pytest\b(?:\s+.*)?|cargo\s+(?:test|check)\b(?:\s+.*)?|flutter\s+analyze\b(?:\s+.*)?|dart\s+test\b(?:\s+.*)?)$/i;
|
|
5
|
+
export const HIGH_RISK_APPROVAL_COMMAND_RE = /(git\s+push\b|ally\s+release\b|rm\s+-rf\b|git\s+reset\b|git\s+checkout\s+--\b|deploy\b|publish\b|kubectl\b|terraform\b|scp\b|shutdown\b|reboot\b)/i;
|
|
6
|
+
export const TURN_RECOVERY_VISIBILITY_DELAY_MS = 1500;
|
|
7
|
+
export const APPROVAL_BATCH_WINDOW_MS = 10;
|
|
8
|
+
|
|
9
|
+
export function compactPreview(text: string, limit = 160): string {
|
|
10
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
11
|
+
if (compact.length <= limit) {
|
|
12
|
+
return compact;
|
|
13
|
+
}
|
|
14
|
+
return `${compact.slice(0, limit)}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function finalReplySourceKey(threadId: string, turnId: string): string {
|
|
18
|
+
return `final_reply:${threadId}:${turnId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function payloadDigest(text: string): string {
|
|
22
|
+
return crypto.createHash('sha1').update(text).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function errorMessageFromUnknown(error: unknown): string {
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
return error.message || '处理失败。';
|
|
28
|
+
}
|
|
29
|
+
if (typeof error === 'string') {
|
|
30
|
+
return error.trim() || '处理失败。';
|
|
31
|
+
}
|
|
32
|
+
if (error && typeof error === 'object') {
|
|
33
|
+
const message = typeof (error as { message?: unknown }).message === 'string'
|
|
34
|
+
? String((error as { message?: string }).message).trim()
|
|
35
|
+
: '';
|
|
36
|
+
if (message) {
|
|
37
|
+
return message;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return '处理失败。';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function errorDetailsFromUnknown(error: unknown): string {
|
|
44
|
+
if (error instanceof Error) {
|
|
45
|
+
return error.stack || error.message;
|
|
46
|
+
}
|
|
47
|
+
if (typeof error === 'string') {
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
if (error && typeof error === 'object') {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(error);
|
|
53
|
+
} catch {
|
|
54
|
+
return String(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return String(error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseUserInputReply(request: PendingUserInputRecord, text: string): Record<string, { answers: string[] }> {
|
|
61
|
+
const trimmed = text.trim();
|
|
62
|
+
const questions = request.questions || [];
|
|
63
|
+
if (questions.length === 0) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (questions.length === 1) {
|
|
68
|
+
return {
|
|
69
|
+
[questions[0].id]: {
|
|
70
|
+
answers: trimmed ? [trimmed] : [],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const answers: Record<string, { answers: string[] }> = {};
|
|
76
|
+
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const match = line.match(/^([A-Za-z0-9_.-]+)\s*[::]\s*(.+)$/);
|
|
79
|
+
if (!match) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const [, questionId, answer] = match;
|
|
83
|
+
if (!questions.some((question) => question.id === questionId)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
answers[questionId] = { answers: [answer.trim()] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Object.keys(answers).length === 0) {
|
|
90
|
+
for (const question of questions) {
|
|
91
|
+
answers[question.id] = { answers: trimmed ? [trimmed] : [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return answers;
|
|
96
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { OutboundEnvelope } from './channel-types.ts';
|
|
2
|
+
import { compactPreview, parseUserInputReply } from './receiver-helpers.ts';
|
|
3
|
+
import { parseApprovalShortcut } from './router.ts';
|
|
4
|
+
import { buildElicitationResponseFromReply } from './runtime-user-input.ts';
|
|
5
|
+
import type { SessionStore } from './session-store.ts';
|
|
6
|
+
import { approvalMessage, batchedApprovalMessage, systemNoticeMessage, userInputMessage, waitingApprovalFollowUpMessage, waitingApprovalStatusMessage, waitingUserInputStatusMessage } from './translator.ts';
|
|
7
|
+
import type { ApprovalRecord, ConversationRef, InboundMessage, RuntimeThreadStatus, SessionMeta } from './types.ts';
|
|
8
|
+
|
|
9
|
+
export type WaitingState = 'approval' | 'user_input' | null;
|
|
10
|
+
|
|
11
|
+
interface SendLike {
|
|
12
|
+
(
|
|
13
|
+
outbound: OutboundEnvelope,
|
|
14
|
+
extra?: Record<string, unknown>,
|
|
15
|
+
delivery?: { trackTurnId?: string | null },
|
|
16
|
+
): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RuntimeLike {
|
|
20
|
+
resolveApproval(requestId: string | number, decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel'): Promise<void>;
|
|
21
|
+
resolveUserInput(requestId: string | number, response: { answers: Record<string, { answers: string[] }> }): Promise<void>;
|
|
22
|
+
resolveElicitation(
|
|
23
|
+
requestId: string | number,
|
|
24
|
+
response: { action: 'accept' | 'decline' | 'cancel'; content: unknown | null },
|
|
25
|
+
): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FlushApprovalNoticeDeps {
|
|
29
|
+
sessionStore: SessionStore;
|
|
30
|
+
send: SendLike;
|
|
31
|
+
approvalBoundaryReason(approval: Pick<ApprovalRecord, 'kind' | 'grantRoot' | 'cwd' | 'command'>): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ResolveVisibleApprovalsDeps {
|
|
35
|
+
sessionStore: SessionStore;
|
|
36
|
+
runtime: Pick<RuntimeLike, 'resolveApproval'>;
|
|
37
|
+
send: SendLike;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface WaitingApprovalShortcutDeps extends ResolveVisibleApprovalsDeps {
|
|
41
|
+
reactEmoji?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ResumePendingUserInputDeps {
|
|
45
|
+
sessionStore: SessionStore;
|
|
46
|
+
runtime: Pick<RuntimeLike, 'resolveUserInput' | 'resolveElicitation'>;
|
|
47
|
+
send: SendLike;
|
|
48
|
+
reactEmoji?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReplayHiddenApprovalDeps extends FlushApprovalNoticeDeps {}
|
|
52
|
+
|
|
53
|
+
interface ReplayHiddenUserInputDeps {
|
|
54
|
+
sessionStore: SessionStore;
|
|
55
|
+
send: SendLike;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function waitingStateFor(session: SessionMeta): WaitingState {
|
|
59
|
+
if (session.pendingApprovalIds.length > 0) {
|
|
60
|
+
return 'approval';
|
|
61
|
+
}
|
|
62
|
+
if (session.pendingUserInputRequestId) {
|
|
63
|
+
return 'user_input';
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function runtimeWaitingStateFor(status: RuntimeThreadStatus | null | undefined): WaitingState {
|
|
69
|
+
if (status?.activeFlags.includes('waitingOnApproval')) {
|
|
70
|
+
return 'approval';
|
|
71
|
+
}
|
|
72
|
+
if (status?.activeFlags.includes('waitingOnUserInput')) {
|
|
73
|
+
return 'user_input';
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function approvalPayloadsFor(
|
|
79
|
+
approvals: ApprovalRecord[],
|
|
80
|
+
approvalBoundaryReason: FlushApprovalNoticeDeps['approvalBoundaryReason'],
|
|
81
|
+
) {
|
|
82
|
+
return approvals.map((approval) => ({
|
|
83
|
+
...approval,
|
|
84
|
+
boundaryReason: approvalBoundaryReason(approval),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function flushApprovalNotice(
|
|
89
|
+
deps: FlushApprovalNoticeDeps,
|
|
90
|
+
conversationRef: ConversationRef,
|
|
91
|
+
threadId: string,
|
|
92
|
+
turnId: string,
|
|
93
|
+
noticeState: { sent: boolean },
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const session = deps.sessionStore.getSession(conversationRef);
|
|
96
|
+
if (!session || !session.activeTurnId || session.activeTurnId !== turnId) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const pendingApprovals = deps.sessionStore.listPendingApprovals(conversationRef)
|
|
100
|
+
.filter((approval) => approval.turnId === turnId);
|
|
101
|
+
if (!pendingApprovals.length) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const approvalPayloads = approvalPayloadsFor(pendingApprovals, deps.approvalBoundaryReason);
|
|
106
|
+
const delivered = await deps.send({
|
|
107
|
+
type: 'approval_requested',
|
|
108
|
+
conversationRef,
|
|
109
|
+
approval: approvalPayloads[0],
|
|
110
|
+
text: approvalPayloads.length > 1
|
|
111
|
+
? batchedApprovalMessage(approvalPayloads)
|
|
112
|
+
: approvalMessage(approvalPayloads[0]),
|
|
113
|
+
}, {
|
|
114
|
+
source: 'approval_requested',
|
|
115
|
+
thread_id: threadId,
|
|
116
|
+
turn_id: turnId,
|
|
117
|
+
approval_id: approvalPayloads[0].approvalId,
|
|
118
|
+
approval_count: approvalPayloads.length,
|
|
119
|
+
});
|
|
120
|
+
if (!delivered) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const approval of pendingApprovals) {
|
|
125
|
+
deps.sessionStore.markApprovalVisible(conversationRef, approval.approvalId);
|
|
126
|
+
}
|
|
127
|
+
if (noticeState.sent) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
noticeState.sent = true;
|
|
131
|
+
await deps.send({
|
|
132
|
+
type: 'status_reply',
|
|
133
|
+
conversationRef,
|
|
134
|
+
text: waitingApprovalStatusMessage(),
|
|
135
|
+
}, {
|
|
136
|
+
source: 'waiting_approval',
|
|
137
|
+
thread_id: threadId,
|
|
138
|
+
turn_id: turnId,
|
|
139
|
+
approval_id: approvalPayloads[0].approvalId,
|
|
140
|
+
approval_count: approvalPayloads.length,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function replayHiddenApprovalNotice(
|
|
145
|
+
deps: ReplayHiddenApprovalDeps,
|
|
146
|
+
conversationRef: ConversationRef,
|
|
147
|
+
threadId: string,
|
|
148
|
+
turnId: string,
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
const hiddenApprovals = deps.sessionStore.listHiddenPendingApprovals(conversationRef, turnId);
|
|
151
|
+
if (!hiddenApprovals.length) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const approvalPayloads = approvalPayloadsFor(hiddenApprovals, deps.approvalBoundaryReason);
|
|
156
|
+
const delivered = await deps.send({
|
|
157
|
+
type: 'approval_requested',
|
|
158
|
+
conversationRef,
|
|
159
|
+
approval: approvalPayloads[0],
|
|
160
|
+
text: approvalPayloads.length > 1
|
|
161
|
+
? batchedApprovalMessage(approvalPayloads)
|
|
162
|
+
: approvalMessage(approvalPayloads[0]),
|
|
163
|
+
}, {
|
|
164
|
+
source: 'approval_requested_replay',
|
|
165
|
+
thread_id: threadId,
|
|
166
|
+
turn_id: turnId,
|
|
167
|
+
approval_id: approvalPayloads[0].approvalId,
|
|
168
|
+
approval_count: approvalPayloads.length,
|
|
169
|
+
});
|
|
170
|
+
if (!delivered) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const approval of hiddenApprovals) {
|
|
175
|
+
deps.sessionStore.markApprovalVisible(conversationRef, approval.approvalId);
|
|
176
|
+
}
|
|
177
|
+
await deps.send({
|
|
178
|
+
type: 'status_reply',
|
|
179
|
+
conversationRef,
|
|
180
|
+
text: waitingApprovalStatusMessage(),
|
|
181
|
+
}, {
|
|
182
|
+
source: 'waiting_approval_replay',
|
|
183
|
+
thread_id: threadId,
|
|
184
|
+
turn_id: turnId,
|
|
185
|
+
approval_id: approvalPayloads[0].approvalId,
|
|
186
|
+
approval_count: approvalPayloads.length,
|
|
187
|
+
});
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function replayHiddenPendingUserInput(
|
|
192
|
+
deps: ReplayHiddenUserInputDeps,
|
|
193
|
+
conversationRef: ConversationRef,
|
|
194
|
+
threadId: string,
|
|
195
|
+
turnId: string,
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
const hiddenPending = deps.sessionStore.findHiddenPendingUserInput(conversationRef, turnId);
|
|
198
|
+
if (!hiddenPending) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const delivered = await deps.send({
|
|
203
|
+
type: 'user_input_requested',
|
|
204
|
+
conversationRef,
|
|
205
|
+
request: hiddenPending,
|
|
206
|
+
text: userInputMessage(hiddenPending),
|
|
207
|
+
}, {
|
|
208
|
+
source: 'user_input_requested_replay',
|
|
209
|
+
thread_id: threadId,
|
|
210
|
+
turn_id: turnId,
|
|
211
|
+
request_id: hiddenPending.requestId,
|
|
212
|
+
});
|
|
213
|
+
if (!delivered) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const visiblePending = deps.sessionStore.markPendingUserInputVisible(conversationRef, hiddenPending.requestId) || hiddenPending;
|
|
218
|
+
await deps.send({
|
|
219
|
+
type: 'status_reply',
|
|
220
|
+
conversationRef,
|
|
221
|
+
text: waitingUserInputStatusMessage(visiblePending),
|
|
222
|
+
}, {
|
|
223
|
+
source: 'waiting_user_input_replay',
|
|
224
|
+
thread_id: threadId,
|
|
225
|
+
turn_id: turnId,
|
|
226
|
+
request_id: hiddenPending.requestId,
|
|
227
|
+
});
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function resolveVisibleApprovals(
|
|
232
|
+
deps: ResolveVisibleApprovalsDeps,
|
|
233
|
+
conversationRef: ConversationRef,
|
|
234
|
+
approvalIds: string[],
|
|
235
|
+
decision: 'approve' | 'deny',
|
|
236
|
+
): Promise<{ resolved: number }> {
|
|
237
|
+
const runtimeDecision = decision === 'approve' ? 'accept' : 'decline';
|
|
238
|
+
const resolvedStatus = decision === 'approve' ? 'accepted' : 'declined';
|
|
239
|
+
let resolved = 0;
|
|
240
|
+
|
|
241
|
+
for (const approvalId of approvalIds) {
|
|
242
|
+
const match = deps.sessionStore.findApproval(approvalId);
|
|
243
|
+
if (!match || match.approval.status !== 'pending') {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
await deps.runtime.resolveApproval(match.approval.requestId, runtimeDecision);
|
|
247
|
+
deps.sessionStore.resolveApproval(match.meta.conversationRef, approvalId, resolvedStatus);
|
|
248
|
+
resolved += 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (resolved > 0) {
|
|
252
|
+
await deps.send({
|
|
253
|
+
type: 'status_reply',
|
|
254
|
+
conversationRef,
|
|
255
|
+
text: decision === 'approve' ? `已提交同意,共处理 ${resolved} 项审批。` : `已提交拒绝,共处理 ${resolved} 项审批。`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { resolved };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function maybeHandleWaitingApprovalShortcut(
|
|
263
|
+
deps: WaitingApprovalShortcutDeps,
|
|
264
|
+
message: InboundMessage,
|
|
265
|
+
session: SessionMeta,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
if (!session.pendingApprovalIds.length) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const decision = parseApprovalShortcut(message.text);
|
|
272
|
+
if (!decision) {
|
|
273
|
+
await deps.send({
|
|
274
|
+
type: 'status_reply',
|
|
275
|
+
conversationRef: message.conversationRef,
|
|
276
|
+
text: systemNoticeMessage(waitingApprovalFollowUpMessage()),
|
|
277
|
+
}, {
|
|
278
|
+
source: 'waiting_user_follow_up',
|
|
279
|
+
thread_id: session.threadId,
|
|
280
|
+
turn_id: session.activeTurnId,
|
|
281
|
+
waiting_state: 'approval',
|
|
282
|
+
message_id: message.messageId,
|
|
283
|
+
});
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await deps.send({
|
|
288
|
+
type: 'ack_received',
|
|
289
|
+
conversationRef: message.conversationRef,
|
|
290
|
+
messageId: message.messageId,
|
|
291
|
+
emoji: deps.reactEmoji || undefined,
|
|
292
|
+
});
|
|
293
|
+
await resolveVisibleApprovals(deps, message.conversationRef, [...session.pendingApprovalIds], decision);
|
|
294
|
+
deps.sessionStore.completeInboundReceipt(message.conversationRef, message.messageId);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function maybeResumePendingUserInput(
|
|
299
|
+
deps: ResumePendingUserInputDeps,
|
|
300
|
+
message: InboundMessage,
|
|
301
|
+
): Promise<boolean> {
|
|
302
|
+
const pending = deps.sessionStore.getPendingUserInput(message.conversationRef);
|
|
303
|
+
if (!pending) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const answers = parseUserInputReply(pending, message.text);
|
|
308
|
+
await deps.send({
|
|
309
|
+
type: 'ack_received',
|
|
310
|
+
conversationRef: message.conversationRef,
|
|
311
|
+
messageId: message.messageId,
|
|
312
|
+
emoji: deps.reactEmoji || undefined,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
let submittedPayload: unknown;
|
|
316
|
+
if (pending.kind === 'mcp_elicitation') {
|
|
317
|
+
const elicitationResponse = buildElicitationResponseFromReply(pending, message.text) || {
|
|
318
|
+
action: 'accept' as const,
|
|
319
|
+
content: null,
|
|
320
|
+
};
|
|
321
|
+
await deps.runtime.resolveElicitation(pending.requestId, elicitationResponse);
|
|
322
|
+
submittedPayload = elicitationResponse;
|
|
323
|
+
} else {
|
|
324
|
+
await deps.runtime.resolveUserInput(pending.requestId, { answers });
|
|
325
|
+
submittedPayload = { answers };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
deps.sessionStore.clearPendingUserInput(message.conversationRef, {
|
|
329
|
+
answerPreview: compactPreview(message.text),
|
|
330
|
+
});
|
|
331
|
+
deps.sessionStore.completeInboundReceipt(message.conversationRef, message.messageId);
|
|
332
|
+
return true;
|
|
333
|
+
}
|