openryoko 2026.7.26 → 2026.8.5

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 (99) hide show
  1. package/dist/bin/jimmy.js +63 -0
  2. package/dist/bin/jimmy.js.map +1 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/connectors/slack/__tests__/send-thread.test.d.ts +2 -0
  5. package/dist/src/connectors/slack/__tests__/send-thread.test.d.ts.map +1 -0
  6. package/dist/src/connectors/slack/__tests__/send-thread.test.js +61 -0
  7. package/dist/src/connectors/slack/__tests__/send-thread.test.js.map +1 -0
  8. package/dist/src/connectors/slack/index.d.ts.map +1 -1
  9. package/dist/src/connectors/slack/index.js +9 -0
  10. package/dist/src/connectors/slack/index.js.map +1 -1
  11. package/dist/src/engines/__tests__/claude-interactive-race.test.js +10 -0
  12. package/dist/src/engines/__tests__/claude-interactive-race.test.js.map +1 -1
  13. package/dist/src/gateway/__tests__/connector-proxy-send.test.d.ts +2 -0
  14. package/dist/src/gateway/__tests__/connector-proxy-send.test.d.ts.map +1 -0
  15. package/dist/src/gateway/__tests__/connector-proxy-send.test.js +91 -0
  16. package/dist/src/gateway/__tests__/connector-proxy-send.test.js.map +1 -0
  17. package/dist/src/gateway/__tests__/notification-wake-delivery.test.d.ts +2 -0
  18. package/dist/src/gateway/__tests__/notification-wake-delivery.test.d.ts.map +1 -0
  19. package/dist/src/gateway/__tests__/notification-wake-delivery.test.js +145 -0
  20. package/dist/src/gateway/__tests__/notification-wake-delivery.test.js.map +1 -0
  21. package/dist/src/gateway/__tests__/notification-wake-restart.test.d.ts +2 -0
  22. package/dist/src/gateway/__tests__/notification-wake-restart.test.d.ts.map +1 -0
  23. package/dist/src/gateway/__tests__/notification-wake-restart.test.js +97 -0
  24. package/dist/src/gateway/__tests__/notification-wake-restart.test.js.map +1 -0
  25. package/dist/src/gateway/api.d.ts.map +1 -1
  26. package/dist/src/gateway/api.js +114 -15
  27. package/dist/src/gateway/api.js.map +1 -1
  28. package/dist/src/jobs/__tests__/monitor.test.d.ts +2 -0
  29. package/dist/src/jobs/__tests__/monitor.test.d.ts.map +1 -0
  30. package/dist/src/jobs/__tests__/monitor.test.js +142 -0
  31. package/dist/src/jobs/__tests__/monitor.test.js.map +1 -0
  32. package/dist/src/jobs/__tests__/notify.test.d.ts +2 -0
  33. package/dist/src/jobs/__tests__/notify.test.d.ts.map +1 -0
  34. package/dist/src/jobs/__tests__/notify.test.js +122 -0
  35. package/dist/src/jobs/__tests__/notify.test.js.map +1 -0
  36. package/dist/src/jobs/__tests__/state.test.d.ts +2 -0
  37. package/dist/src/jobs/__tests__/state.test.d.ts.map +1 -0
  38. package/dist/src/jobs/__tests__/state.test.js +95 -0
  39. package/dist/src/jobs/__tests__/state.test.js.map +1 -0
  40. package/dist/src/jobs/monitor.d.ts +7 -0
  41. package/dist/src/jobs/monitor.d.ts.map +1 -0
  42. package/dist/src/jobs/monitor.js +153 -0
  43. package/dist/src/jobs/monitor.js.map +1 -0
  44. package/dist/src/jobs/notify.d.ts +26 -0
  45. package/dist/src/jobs/notify.d.ts.map +1 -0
  46. package/dist/src/jobs/notify.js +132 -0
  47. package/dist/src/jobs/notify.js.map +1 -0
  48. package/dist/src/jobs/run.d.ts +21 -0
  49. package/dist/src/jobs/run.d.ts.map +1 -0
  50. package/dist/src/jobs/run.js +65 -0
  51. package/dist/src/jobs/run.js.map +1 -0
  52. package/dist/src/jobs/state.d.ts +63 -0
  53. package/dist/src/jobs/state.d.ts.map +1 -0
  54. package/dist/src/jobs/state.js +126 -0
  55. package/dist/src/jobs/state.js.map +1 -0
  56. package/dist/src/sessions/__tests__/context-process-lifetime.test.d.ts +2 -0
  57. package/dist/src/sessions/__tests__/context-process-lifetime.test.d.ts.map +1 -0
  58. package/dist/src/sessions/__tests__/context-process-lifetime.test.js +70 -0
  59. package/dist/src/sessions/__tests__/context-process-lifetime.test.js.map +1 -0
  60. package/dist/src/sessions/__tests__/detached-jobs-context.test.d.ts +2 -0
  61. package/dist/src/sessions/__tests__/detached-jobs-context.test.d.ts.map +1 -0
  62. package/dist/src/sessions/__tests__/detached-jobs-context.test.js +57 -0
  63. package/dist/src/sessions/__tests__/detached-jobs-context.test.js.map +1 -0
  64. package/dist/src/sessions/__tests__/manager-orphan-delivery.test.d.ts +2 -0
  65. package/dist/src/sessions/__tests__/manager-orphan-delivery.test.d.ts.map +1 -0
  66. package/dist/src/sessions/__tests__/manager-orphan-delivery.test.js +74 -0
  67. package/dist/src/sessions/__tests__/manager-orphan-delivery.test.js.map +1 -0
  68. package/dist/src/sessions/__tests__/origin-delivery.test.d.ts +2 -0
  69. package/dist/src/sessions/__tests__/origin-delivery.test.d.ts.map +1 -0
  70. package/dist/src/sessions/__tests__/origin-delivery.test.js +104 -0
  71. package/dist/src/sessions/__tests__/origin-delivery.test.js.map +1 -0
  72. package/dist/src/sessions/context.d.ts +14 -0
  73. package/dist/src/sessions/context.d.ts.map +1 -1
  74. package/dist/src/sessions/context.js +84 -0
  75. package/dist/src/sessions/context.js.map +1 -1
  76. package/dist/src/sessions/manager.d.ts.map +1 -1
  77. package/dist/src/sessions/manager.js +12 -16
  78. package/dist/src/sessions/manager.js.map +1 -1
  79. package/dist/src/sessions/origin-delivery.d.ts +36 -0
  80. package/dist/src/sessions/origin-delivery.d.ts.map +1 -0
  81. package/dist/src/sessions/origin-delivery.js +66 -0
  82. package/dist/src/sessions/origin-delivery.js.map +1 -0
  83. package/dist/src/sessions/registry.d.ts +7 -0
  84. package/dist/src/sessions/registry.d.ts.map +1 -1
  85. package/dist/src/sessions/registry.js +14 -0
  86. package/dist/src/sessions/registry.js.map +1 -1
  87. package/dist/src/shared/__tests__/threading.test.d.ts +2 -0
  88. package/dist/src/shared/__tests__/threading.test.d.ts.map +1 -0
  89. package/dist/src/shared/__tests__/threading.test.js +23 -0
  90. package/dist/src/shared/__tests__/threading.test.js.map +1 -0
  91. package/dist/src/shared/paths.d.ts +1 -0
  92. package/dist/src/shared/paths.d.ts.map +1 -1
  93. package/dist/src/shared/paths.js +1 -0
  94. package/dist/src/shared/paths.js.map +1 -1
  95. package/dist/src/shared/threading.d.ts +11 -0
  96. package/dist/src/shared/threading.d.ts.map +1 -0
  97. package/dist/src/shared/threading.js +16 -0
  98. package/dist/src/shared/threading.js.map +1 -0
  99. package/package.json +1 -1
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ // Regression pin for issue #38 follow-up: a session woken by a notification
7
+ // (detached job finished) runs on the gateway's connector-less web path. Its
8
+ // final answer MUST come back to the original Slack channel/thread stored in
9
+ // reply_context — the 2026-08-04 accident left an externally-shared customer
10
+ // thread waiting on "生成中です" forever.
11
+ //
12
+ // RYOKO_HOME must point at a temp dir BEFORE any module import pulls in
13
+ // shared/paths.js, so every module here is imported dynamically.
14
+ process.env.RYOKO_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "wake-test-home-"));
15
+ const STUB_CONFIG = {
16
+ jinn: { version: "0.0.0" },
17
+ gateway: { port: 0, host: "127.0.0.1" },
18
+ engines: {
19
+ default: "claude",
20
+ claude: { bin: "claude", model: "" },
21
+ codex: { bin: "codex", model: "" },
22
+ },
23
+ connectors: {},
24
+ sessions: {},
25
+ logging: { level: "error", stdout: false, file: "" },
26
+ };
27
+ describe("notification wake-up delivers the reply to the origin connector", () => {
28
+ let server;
29
+ let baseUrl;
30
+ let replyMessage;
31
+ let sessionId;
32
+ beforeAll(async () => {
33
+ const { handleApiRequest } = await import("../api.js");
34
+ const { createSession } = await import("../../sessions/registry.js");
35
+ const { SessionManager } = await import("../../sessions/manager.js");
36
+ const engineRun = vi.fn(async () => ({
37
+ result: "資料が完成しました。アップロード済みです。",
38
+ sessionId: "engine-sess-1",
39
+ numTurns: 1,
40
+ }));
41
+ const mockEngine = { run: engineRun };
42
+ const engines = new Map([["claude", mockEngine]]);
43
+ const sessionManager = new SessionManager(STUB_CONFIG, engines, ["slack"]);
44
+ replyMessage = vi.fn(async () => "posted-ts");
45
+ const fakeSlack = {
46
+ name: "slack",
47
+ replyMessage,
48
+ addReaction: vi.fn(async () => { }),
49
+ getCapabilities: () => ({ threading: true, messageEdits: true, reactions: true, attachments: true }),
50
+ reconstructTarget: (rc) => ({
51
+ channel: typeof rc.channel === "string" ? rc.channel : "",
52
+ thread: typeof rc.thread === "string" ? rc.thread : undefined,
53
+ messageTs: typeof rc.messageTs === "string" ? rc.messageTs : undefined,
54
+ }),
55
+ };
56
+ const session = createSession({
57
+ engine: "claude",
58
+ source: "slack",
59
+ sourceRef: "C_EXTERNAL",
60
+ connector: "slack",
61
+ sessionKey: "slack:C_EXTERNAL:100.200",
62
+ replyContext: { channel: "C_EXTERNAL", thread: "100.200", messageTs: "100.300" },
63
+ transportMeta: { channelExternal: true },
64
+ prompt: "資料を作って",
65
+ });
66
+ sessionId = session.id;
67
+ const context = {
68
+ config: STUB_CONFIG,
69
+ getConfig: () => STUB_CONFIG,
70
+ sessionManager,
71
+ startTime: 0,
72
+ emit: () => { },
73
+ connectors: new Map([["slack", fakeSlack]]),
74
+ };
75
+ server = http.createServer((req, res) => {
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ handleApiRequest(req, res, context);
78
+ });
79
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
80
+ const addr = server.address();
81
+ baseUrl = typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
82
+ });
83
+ afterAll(async () => {
84
+ await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
85
+ fs.rmSync(process.env.RYOKO_HOME, { recursive: true, force: true });
86
+ });
87
+ it("wakes the session and replies into the original channel/thread", async () => {
88
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json" },
91
+ body: JSON.stringify({
92
+ message: '✅ Detached job "pdf-build" completed successfully (exit 0).',
93
+ role: "notification",
94
+ }),
95
+ });
96
+ expect(res.status).toBe(200);
97
+ // The engine run + delivery happen asynchronously after the 200.
98
+ await vi.waitFor(() => {
99
+ expect(replyMessage).toHaveBeenCalledTimes(1);
100
+ }, { timeout: 10_000 });
101
+ const [target, text] = replyMessage.mock.calls[0];
102
+ expect(target).toMatchObject({ channel: "C_EXTERNAL", thread: "100.200" });
103
+ expect(text).toBe("資料が完成しました。アップロード済みです。");
104
+ });
105
+ it("a duplicate dedupeKey does not enqueue a second turn (monitor retry after lost response)", async () => {
106
+ replyMessage.mockClear();
107
+ const body = JSON.stringify({
108
+ message: '✅ Detached job "dedupe-job" completed successfully (exit 0).',
109
+ role: "notification",
110
+ dedupeKey: "job:dedupe-job-1",
111
+ });
112
+ const first = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
113
+ method: "POST",
114
+ headers: { "content-type": "application/json" },
115
+ body,
116
+ });
117
+ expect(first.status).toBe(200);
118
+ expect((await first.json()).status).toBe("queued");
119
+ const second = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body,
123
+ });
124
+ expect(second.status).toBe(200);
125
+ expect((await second.json()).status).toBe("duplicate");
126
+ await vi.waitFor(() => {
127
+ expect(replyMessage).toHaveBeenCalledTimes(1); // one turn, one delivery
128
+ }, { timeout: 10_000 });
129
+ await new Promise((r) => setTimeout(r, 1000));
130
+ expect(replyMessage).toHaveBeenCalledTimes(1);
131
+ });
132
+ it("a plain user message on the web path does NOT auto-post to the connector", async () => {
133
+ replyMessage.mockClear();
134
+ const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
135
+ method: "POST",
136
+ headers: { "content-type": "application/json" },
137
+ body: JSON.stringify({ message: "operator typing in web UI", role: "user" }),
138
+ });
139
+ expect(res.status).toBe(200);
140
+ // Give the async run time to complete, then confirm no connector post.
141
+ await new Promise((r) => setTimeout(r, 1500));
142
+ expect(replyMessage).not.toHaveBeenCalled();
143
+ });
144
+ });
145
+ //# sourceMappingURL=notification-wake-delivery.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-wake-delivery.test.js","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-delivery.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=notification-wake-restart.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-wake-restart.test.d.ts","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-restart.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ // Gateway-restart recovery for notification wake-ups (issue #38 follow-up):
6
+ // the job monitor got its 200 (message persisted + queue item enqueued) and
7
+ // will never re-send. If the gateway dies before running the turn, the resume
8
+ // pass must replay the queue item FOR CONNECTOR-ORIGIN SESSIONS TOO — and
9
+ // with origin-connector delivery, or the customer thread stays silent.
10
+ process.env.RYOKO_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "wake-restart-home-"));
11
+ const STUB_CONFIG = {
12
+ jinn: { version: "0.0.0" },
13
+ gateway: { port: 0, host: "127.0.0.1" },
14
+ engines: {
15
+ default: "claude",
16
+ claude: { bin: "claude", model: "" },
17
+ codex: { bin: "codex", model: "" },
18
+ },
19
+ connectors: {},
20
+ sessions: {},
21
+ logging: { level: "error", stdout: false, file: "" },
22
+ };
23
+ describe("resumePendingWebQueueItems — connector-origin notification wake-ups", () => {
24
+ let replyMessage;
25
+ let engineRun;
26
+ beforeAll(async () => {
27
+ const { resumePendingWebQueueItems } = await import("../api.js");
28
+ const { createSession, insertMessage, enqueueQueueItem } = await import("../../sessions/registry.js");
29
+ const { SessionManager } = await import("../../sessions/manager.js");
30
+ engineRun = vi.fn(async () => ({ result: "遅れましたが完成しました", sessionId: "es-1", numTurns: 1 }));
31
+ const engines = new Map([["claude", { run: engineRun }]]);
32
+ const sessionManager = new SessionManager(STUB_CONFIG, engines, ["slack"]);
33
+ replyMessage = vi.fn(async () => "ts");
34
+ const fakeSlack = {
35
+ name: "slack",
36
+ replyMessage,
37
+ addReaction: vi.fn(async () => { }),
38
+ getCapabilities: () => ({ threading: true, messageEdits: true, reactions: true, attachments: true }),
39
+ reconstructTarget: (rc) => ({
40
+ channel: typeof rc.channel === "string" ? rc.channel : "",
41
+ thread: typeof rc.thread === "string" ? rc.thread : undefined,
42
+ }),
43
+ };
44
+ // Simulate the pre-restart state the monitor left behind: notification
45
+ // message persisted + queue item pending, but no turn ever ran.
46
+ const wakeText = '✅ Detached job "pdf" completed successfully (exit 0).';
47
+ const slackSession = createSession({
48
+ engine: "claude",
49
+ source: "slack",
50
+ sourceRef: "C_CUST",
51
+ connector: "slack",
52
+ sessionKey: "slack:C_CUST:1.2",
53
+ replyContext: { channel: "C_CUST", thread: "1.2" },
54
+ transportMeta: { channelExternal: true },
55
+ prompt: "資料を作って",
56
+ });
57
+ insertMessage(slackSession.id, "notification", wakeText);
58
+ enqueueQueueItem(slackSession.id, slackSession.sessionKey, wakeText);
59
+ // Control: a connector session with a pending USER prompt must stay
60
+ // untouched (pre-existing behavior — the connector route owns it).
61
+ const userSession = createSession({
62
+ engine: "claude",
63
+ source: "slack",
64
+ sourceRef: "C_OTHER",
65
+ connector: "slack",
66
+ sessionKey: "slack:C_OTHER:9.9",
67
+ replyContext: { channel: "C_OTHER", thread: "9.9" },
68
+ prompt: "こんにちは",
69
+ });
70
+ insertMessage(userSession.id, "user", "続きをやって");
71
+ enqueueQueueItem(userSession.id, userSession.sessionKey, "続きをやって");
72
+ const context = {
73
+ config: STUB_CONFIG,
74
+ getConfig: () => STUB_CONFIG,
75
+ sessionManager,
76
+ startTime: 0,
77
+ emit: () => { },
78
+ connectors: new Map([["slack", fakeSlack]]),
79
+ };
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ resumePendingWebQueueItems(context);
82
+ });
83
+ afterAll(() => {
84
+ fs.rmSync(process.env.RYOKO_HOME, { recursive: true, force: true });
85
+ });
86
+ it("replays the notification wake-up and delivers to the original thread", async () => {
87
+ await vi.waitFor(() => {
88
+ expect(replyMessage).toHaveBeenCalledTimes(1);
89
+ }, { timeout: 10_000 });
90
+ const [target, text] = replyMessage.mock.calls[0];
91
+ expect(target).toMatchObject({ channel: "C_CUST", thread: "1.2" });
92
+ expect(text).toBe("遅れましたが完成しました");
93
+ // Only the notification item ran — the user-prompt item was left alone.
94
+ expect(engineRun).toHaveBeenCalledTimes(1);
95
+ });
96
+ });
97
+ //# sourceMappingURL=notification-wake-restart.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notification-wake-restart.test.js","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-restart.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/gateway/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,IAAI,WAAW,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAMhF,OAAO,KAAK,EAAoC,UAAU,EAAmB,MAAM,oBAAoB,CAAC;AAExG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAoD7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,UAAU,CAAC;IAC5B,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,oBAAoB,EAAE,SAAS,CAAC,CAAC;IAChE,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IACrG;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAChG;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,IAAI,CAAC;IACzC;;;;OAIG;IACH,gCAAgC,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9C;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,oBAAoB,EAAE,YAAY,CAAC;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI,CAgCpE;AAsLD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+BnH;AA4CD,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,WAAW,EAChB,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,IAAI,CAAC,CAsnDf"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/gateway/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,IAAI,WAAW,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAMhF,OAAO,KAAK,EAAoC,UAAU,EAAmB,MAAM,oBAAoB,CAAC;AAExG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAuD7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,UAAU,CAAC;IAC5B,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,oBAAoB,EAAE,SAAS,CAAC,CAAC;IAChE,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IACrG;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAChG;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,IAAI,CAAC;IACzC;;;;OAIG;IACH,gCAAgC,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9C;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,oBAAoB,EAAE,YAAY,CAAC;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI,CA4CpE;AAoMD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+BnH;AA4CD,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,WAAW,EAChB,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,IAAI,CAAC,CAoqDf"}
@@ -5,14 +5,16 @@ import path from "node:path";
5
5
  import yaml from "js-yaml";
6
6
  import { isInterruptibleEngine } from "../shared/types.js";
7
7
  import { buildContext } from "../sessions/context.js";
8
- import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
8
+ import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, insertNotificationWithQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
9
9
  import { forkEngineSession } from "../sessions/fork.js";
10
+ import { deliverToOriginConnector, isUndeliveredToOrigin, recordFailedOriginDelivery } from "../sessions/origin-delivery.js";
10
11
  import { CONFIG_PATH, CRON_RUNS, ORG_DIR, SKILLS_DIR, LOGS_DIR, TMP_DIR, FILES_DIR, } from "../shared/paths.js";
11
12
  import { logger } from "../shared/logger.js";
12
13
  import { getSttStatus, downloadModel, transcribe as sttTranscribe, resolveLanguages, WHISPER_LANGUAGES } from "../stt/stt.js";
13
14
  import { JINN_HOME } from "../shared/paths.js";
14
15
  import { handleHookPost, LOOPBACK as HOOK_LOOPBACK } from "./hook-endpoint.js";
15
16
  import { resolveEffort } from "../shared/effort.js";
17
+ import { explicitThread } from "../shared/threading.js";
16
18
  import { effortLevelsForModel, invalidateModelRegistry } from "../shared/models.js";
17
19
  import { computeNextRetryDelayMs, computeRateLimitDeadlineMs, detectRateLimit } from "../shared/rateLimit.js";
18
20
  import { getClaudeExpectedResetAt, recordClaudeRateLimit } from "../shared/usageAwareness.js";
@@ -36,8 +38,20 @@ export function resumePendingWebQueueItems(context) {
36
38
  cancelQueueItem(item.id);
37
39
  continue;
38
40
  }
39
- if (session.source !== "web")
40
- continue;
41
+ // Connector-origin sessions normally get their runs from the connector
42
+ // route, EXCEPT notification wake-ups (detached jobs, child callbacks):
43
+ // the job monitor already got its 200 and will never re-send, so a
44
+ // restart here would strand the wake-up forever. Detect those by the
45
+ // persisted notification message matching the queued prompt and resume
46
+ // them with origin-connector delivery.
47
+ let deliverToConnector = false;
48
+ if (session.source !== "web") {
49
+ const messages = getMessages(session.id);
50
+ const match = [...messages].reverse().find((m) => m.content === item.prompt);
51
+ if (match?.role !== "notification")
52
+ continue;
53
+ deliverToConnector = true;
54
+ }
41
55
  session = maybeRevertEngineOverride(session);
42
56
  const config = context.getConfig();
43
57
  const engine = context.sessionManager.getEngine(session.engine);
@@ -48,7 +62,7 @@ export function resumePendingWebQueueItems(context) {
48
62
  }
49
63
  // Ensure the session is in a runnable state
50
64
  updateSession(session.id, { status: "running", lastActivity: new Date().toISOString(), lastError: null });
51
- dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id });
65
+ dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id, deliverToConnector });
52
66
  resumed++;
53
67
  }
54
68
  if (resumed > 0) {
@@ -95,11 +109,24 @@ function maybeRevertEngineOverride(session) {
95
109
  lastError: null,
96
110
  }) ?? session;
97
111
  }
112
+ // In-memory idempotency keys for notification wake-ups. The persisted-message
113
+ // comparison in the /message handler covers gateway restarts; this map covers
114
+ // the common case cheaply and caps unbounded growth by TTL pruning on write.
115
+ const seenDedupeKeys = new Map();
116
+ const DEDUPE_TTL_MS = 60 * 60 * 1000;
117
+ function rememberDedupeKey(key) {
118
+ const now = Date.now();
119
+ for (const [k, t] of seenDedupeKeys) {
120
+ if (now - t > DEDUPE_TTL_MS)
121
+ seenDedupeKeys.delete(k);
122
+ }
123
+ seenDedupeKeys.set(key, now);
124
+ }
98
125
  function dispatchWebSessionRun(session, prompt, engine, config, context, opts) {
99
126
  const run = async () => {
100
127
  await context.sessionManager.getQueue().enqueue(session.sessionKey || session.sourceRef, async () => {
101
128
  context.emit("session:started", { sessionId: session.id });
102
- await runWebSession(session, prompt, engine, config, context, opts?.attachments);
129
+ await runWebSession(session, prompt, engine, config, context, opts?.attachments, opts?.deliverToConnector);
103
130
  }, opts?.queueItemId);
104
131
  };
105
132
  const launch = () => {
@@ -739,12 +766,39 @@ export async function handleApiRequest(req, res, context) {
739
766
  // Allow internal callers (e.g. child session callbacks) to specify a non-user role
740
767
  const messageRole = body.role === "notification" ? "notification" : "user";
741
768
  const isNotification = messageRole === "notification";
769
+ // Notification wake-ups are a loopback-only mechanism (job monitors,
770
+ // child-session callbacks). A remote caller must not be able to wake an
771
+ // arbitrary session — the woken turn can post into its origin Slack
772
+ // conversation, so this would be a remote-to-customer-channel bridge.
773
+ if (isNotification && (!req.socket.remoteAddress || !HOOK_LOOPBACK.has(req.socket.remoteAddress))) {
774
+ return json(res, { error: "notification role is loopback-only" }, 403);
775
+ }
776
+ // Idempotency: a job monitor retries its wake-up when a response is
777
+ // lost after the gateway already accepted it. Same dedupeKey (+ an
778
+ // identical persisted notification, which survives restarts) → don't
779
+ // enqueue a second engine turn. The key is only REMEMBERED after the
780
+ // message + queue item are durably persisted below, so a failure
781
+ // before that point never turns the retry into a false duplicate.
782
+ const dedupeKey = typeof body.dedupeKey === "string" && body.dedupeKey.trim() ? body.dedupeKey.trim() : null;
783
+ const dedupedNotification = isNotification && dedupeKey !== null;
784
+ if (dedupedNotification) {
785
+ const duplicate = seenDedupeKeys.has(dedupeKey)
786
+ || getMessages(session.id).some((m) => m.role === "notification" && m.content === prompt);
787
+ if (duplicate) {
788
+ return json(res, { status: "duplicate", sessionId: session.id });
789
+ }
790
+ }
742
791
  const config = context.getConfig();
743
792
  const engine = context.sessionManager.getEngine(session.engine);
744
793
  if (!engine)
745
794
  return serverError(res, `Engine "${session.engine}" not available`);
746
- // Persist the message immediately
747
- insertMessage(session.id, messageRole, prompt);
795
+ // Persist the message immediately. Deduped notifications defer this to
796
+ // the atomic message+queue-item write below — the duplicate check above
797
+ // treats "message exists" as "turn queued", so the two writes must
798
+ // never be separable by a crash.
799
+ if (!dedupedNotification) {
800
+ insertMessage(session.id, messageRole, prompt);
801
+ }
748
802
  // Emit notification event for UI display (renders as system banner, not user bubble)
749
803
  if (isNotification) {
750
804
  context.emit("session:notification", { sessionId: session.id, message: prompt });
@@ -788,9 +842,20 @@ export async function handleApiRequest(req, res, context) {
788
842
  context.sessionManager.getQueue().clearCancelled(session.sessionKey || session.sourceRef || session.id);
789
843
  const attachmentPaths = resolveAttachmentPaths(body.attachments);
790
844
  const sessionKey = session.sessionKey || session.sourceRef || session.id;
791
- const queueItemId = enqueueQueueItem(session.id, sessionKey, prompt);
845
+ const queueItemId = dedupedNotification
846
+ ? insertNotificationWithQueueItem(session.id, sessionKey, prompt)
847
+ : enqueueQueueItem(session.id, sessionKey, prompt);
848
+ if (dedupedNotification)
849
+ rememberDedupeKey(dedupeKey);
792
850
  context.emit("queue:updated", { sessionId: session.id, sessionKey });
793
- dispatchWebSessionRun(session, prompt, engine, config, context, { queueItemId, attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined });
851
+ dispatchWebSessionRun(session, prompt, engine, config, context, {
852
+ queueItemId,
853
+ attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined,
854
+ // A notification wake-up (detached job finished, child callback) runs
855
+ // on this connector-less path; deliver the answer back to the origin
856
+ // conversation so e.g. a Slack thread is not left waiting silently.
857
+ deliverToConnector: isNotification,
858
+ });
794
859
  return json(res, { status: "queued", sessionId: session.id });
795
860
  }
796
861
  // GET /api/cron
@@ -1464,11 +1529,18 @@ Handle this as a priority request from a colleague.`;
1464
1529
  const target = body.target;
1465
1530
  let messageId;
1466
1531
  switch (action) {
1467
- case "sendMessage":
1532
+ case "sendMessage": {
1468
1533
  if (!target || !body.text)
1469
1534
  return badRequest(res, "target and text are required");
1470
- messageId = (await connector.sendMessage(target, body.text));
1535
+ // Same contract as /send: an explicit thread on the target means a
1536
+ // thread reply. Connectors' sendMessage historically dropped
1537
+ // target.thread, landing "thread replies" bare in the channel (#6).
1538
+ const proxyThread = explicitThread(target.thread);
1539
+ messageId = proxyThread
1540
+ ? (await connector.replyMessage({ ...target, thread: proxyThread }, body.text))
1541
+ : (await connector.sendMessage(target, body.text));
1471
1542
  break;
1543
+ }
1472
1544
  case "replyMessage":
1473
1545
  if (!target || !body.text)
1474
1546
  return badRequest(res, "target and text are required");
@@ -1512,9 +1584,9 @@ Handle this as a priority request from a colleague.`;
1512
1584
  const body = _parsed.body;
1513
1585
  if (!body.channel || !body.text)
1514
1586
  return badRequest(res, "channel and text are required");
1515
- const hasThread = typeof body.thread === "string" && body.thread.trim().length > 0;
1516
- const target = { channel: body.channel, thread: body.thread };
1517
- if (hasThread) {
1587
+ const thread = explicitThread(body.thread);
1588
+ const target = { channel: body.channel, thread };
1589
+ if (thread) {
1518
1590
  await connector.replyMessage(target, body.text);
1519
1591
  }
1520
1592
  else {
@@ -2048,7 +2120,12 @@ function loadTranscriptMessages(engineSessionId) {
2048
2120
  }
2049
2121
  return [];
2050
2122
  }
2051
- async function runWebSession(session, prompt, engine, config, context, attachments) {
2123
+ async function runWebSession(session, prompt, engine, config, context, attachments,
2124
+ /** Deliver the final answer to the session's origin connector (Slack thread
2125
+ * etc.). Set for notification-triggered wake-ups: this run path has no
2126
+ * connector of its own, so without explicit delivery a woken Slack session
2127
+ * would compute its reply and post it nowhere (issue #38 follow-up). */
2128
+ deliverToConnector) {
2052
2129
  const currentSession = getSession(session.id);
2053
2130
  if (!currentSession) {
2054
2131
  logger.info(`Skipping deleted web session ${session.id} before run start`);
@@ -2083,6 +2160,13 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
2083
2160
  connectors: Array.from(context.connectors.keys()),
2084
2161
  config,
2085
2162
  sessionId: currentSession.id,
2163
+ // Interactive PTY survives across turns; everything else is a one-shot
2164
+ // process whose background tasks die at turn end (#38).
2165
+ processLifetime: currentSession.engine === "claude" &&
2166
+ config.engines.claude?.interactive === true &&
2167
+ !employee?.sshHost
2168
+ ? "persistent"
2169
+ : "one-shot",
2086
2170
  hierarchy: orgHierarchy,
2087
2171
  });
2088
2172
  const engineConfig = currentSession.engine === "codex"
@@ -2245,6 +2329,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
2245
2329
  });
2246
2330
  if (completedFallback) {
2247
2331
  notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2332
+ if (deliverToConnector && fallbackResult.result && !fallbackResult.error) {
2333
+ const delivery = await deliverToOriginConnector(completedFallback, fallbackResult.result, context.connectors);
2334
+ if (isUndeliveredToOrigin(delivery, completedFallback))
2335
+ recordFailedOriginDelivery(completedFallback, context.emit);
2336
+ }
2248
2337
  }
2249
2338
  context.emit("session:completed", {
2250
2339
  sessionId: currentSession.id,
@@ -2360,6 +2449,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
2360
2449
  notifyRateLimitResumed(completedAfterRetry);
2361
2450
  notifyDiscordChannel(`✅ Claude usage limit cleared. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} resumed.`);
2362
2451
  notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2452
+ if (deliverToConnector && retryResult.result && !retryResult.error) {
2453
+ const delivery = await deliverToOriginConnector(completedAfterRetry, retryResult.result, context.connectors);
2454
+ if (isUndeliveredToOrigin(delivery, completedAfterRetry))
2455
+ recordFailedOriginDelivery(completedAfterRetry, context.emit);
2456
+ }
2363
2457
  }
2364
2458
  context.emit("session:completed", {
2365
2459
  sessionId: currentSession.id,
@@ -2416,6 +2510,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
2416
2510
  }
2417
2511
  if (completedSession) {
2418
2512
  notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs }, { alwaysNotify: employee?.alwaysNotify });
2513
+ if (deliverToConnector && result.result && !result.error) {
2514
+ const delivery = await deliverToOriginConnector(completedSession, result.result, context.connectors);
2515
+ if (isUndeliveredToOrigin(delivery, completedSession))
2516
+ recordFailedOriginDelivery(completedSession, context.emit);
2517
+ }
2419
2518
  }
2420
2519
  context.emit("session:completed", {
2421
2520
  sessionId: currentSession.id,