@adecore/agents 0.39.0 → 0.40.0

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 (168) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.js +0 -2
  5. package/dist/chat/chat-process.d.ts +1 -0
  6. package/dist/chat/chat-process.js +1 -1
  7. package/dist/chat/chat-session.js +6 -8
  8. package/dist/chat/claude-protocol.js +1 -6
  9. package/dist/chat/claude-transcript.js +1 -3
  10. package/dist/chat/codex-protocol.js +1 -6
  11. package/dist/chat/projector.js +1 -3
  12. package/dist/chat/request-summary.js +1 -3
  13. package/dist/coded-error.js +1 -5
  14. package/dist/context/argv.js +1 -2
  15. package/dist/context/verb.js +1 -3
  16. package/dist/error-text.d.ts +1 -0
  17. package/dist/error-text.js +4 -0
  18. package/dist/fields.d.ts +3 -0
  19. package/dist/fields.js +12 -0
  20. package/dist/fs.d.ts +1 -0
  21. package/dist/fs.js +8 -4
  22. package/dist/lineage.js +11 -5
  23. package/dist/messages/notice-store.js +3 -4
  24. package/dist/providers/accounts/acp-account.js +3 -20
  25. package/dist/providers/accounts/service.d.ts +1 -2
  26. package/dist/providers/accounts/service.js +5 -9
  27. package/dist/providers/accounts/status.js +1 -3
  28. package/dist/providers/acp-agent-state.js +1 -3
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/codex.js +0 -1
  31. package/dist/providers/detect.js +1 -1
  32. package/dist/providers/shell-commands.js +1 -2
  33. package/dist/run-process.js +1 -3
  34. package/dist/skills.d.ts +1 -1
  35. package/dist/skills.js +5 -8
  36. package/dist/tasks/background-limit.js +3 -2
  37. package/dist/tasks/task-coordinator.js +11 -17
  38. package/dist/testing/watch.js +0 -1
  39. package/dist/usage/limits/monitor.js +1 -2
  40. package/dist/usage/readers/claude.js +3 -10
  41. package/dist/usage/readers/codex.js +2 -9
  42. package/dist/usage/readers/grok.js +3 -10
  43. package/dist/usage/readers/host-log.js +3 -10
  44. package/dist/usage/record.d.ts +2 -0
  45. package/dist/usage/record.js +13 -0
  46. package/package.json +6 -6
  47. package/src/async.ts +23 -5
  48. package/src/chat/acp-backend.ts +121 -0
  49. package/src/chat/acp-protocol.ts +172 -6
  50. package/src/chat/attachment-store.ts +21 -0
  51. package/src/chat/backend.ts +0 -2
  52. package/src/chat/bookmark-store.ts +23 -0
  53. package/src/chat/chat-core.ts +208 -7
  54. package/src/chat/chat-log.ts +19 -0
  55. package/src/chat/chat-process.ts +52 -1
  56. package/src/chat/chat-session.ts +401 -10
  57. package/src/chat/chat-store.ts +50 -0
  58. package/src/chat/chat-title.ts +9 -0
  59. package/src/chat/claude-backend.ts +50 -0
  60. package/src/chat/claude-protocol.ts +155 -8
  61. package/src/chat/claude-title.ts +10 -0
  62. package/src/chat/claude-transcript.ts +47 -4
  63. package/src/chat/codex-backend.ts +131 -0
  64. package/src/chat/codex-protocol.ts +180 -8
  65. package/src/chat/codex-thread.ts +23 -0
  66. package/src/chat/composer-preferences.ts +6 -0
  67. package/src/chat/context-breakdown.ts +11 -0
  68. package/src/chat/delta-coalescer.ts +8 -0
  69. package/src/chat/external-sessions.ts +28 -0
  70. package/src/chat/generated-image.ts +25 -0
  71. package/src/chat/index.ts +5 -0
  72. package/src/chat/input.ts +13 -0
  73. package/src/chat/json-rpc-transport.ts +28 -0
  74. package/src/chat/limit-resume.ts +13 -0
  75. package/src/chat/projector.ts +160 -4
  76. package/src/chat/readable-text.ts +3 -0
  77. package/src/chat/request-summary.ts +18 -4
  78. package/src/chat/subagent-projection.ts +3 -0
  79. package/src/chat/subagent-reader.ts +76 -0
  80. package/src/chat/subagent-settlement.ts +24 -0
  81. package/src/chat/thread-search.ts +13 -0
  82. package/src/chat/thread.ts +31 -0
  83. package/src/chat/ui-queries.ts +122 -0
  84. package/src/chat/ui-replies.ts +34 -0
  85. package/src/chat/visual-store.ts +49 -0
  86. package/src/chat/wake-chat.ts +3 -0
  87. package/src/client-sinks.ts +2 -0
  88. package/src/coded-error.ts +1 -5
  89. package/src/context/argv.ts +18 -2
  90. package/src/context/index.ts +1 -0
  91. package/src/context/refusal.ts +3 -0
  92. package/src/context/verb.ts +32 -4
  93. package/src/coordination/index.ts +10 -0
  94. package/src/error-text.ts +7 -0
  95. package/src/fields.ts +14 -0
  96. package/src/fs.ts +20 -4
  97. package/src/host/agent-host.ts +20 -0
  98. package/src/host/environment.ts +6 -0
  99. package/src/host/handlers.ts +18 -0
  100. package/src/host/memory-port.ts +4 -0
  101. package/src/host/wiring.ts +10 -0
  102. package/src/index.ts +1 -0
  103. package/src/lineage.ts +24 -5
  104. package/src/messages/deliver-message.ts +6 -0
  105. package/src/messages/deliver-notice.ts +13 -0
  106. package/src/messages/notice-store.ts +16 -4
  107. package/src/modes.ts +1 -0
  108. package/src/outbox/outbox-worker.ts +31 -0
  109. package/src/outbox/outbox.ts +5 -0
  110. package/src/providers/accounts/accounts.ts +36 -0
  111. package/src/providers/accounts/acp-account.ts +22 -27
  112. package/src/providers/accounts/launch.ts +4 -0
  113. package/src/providers/accounts/service.ts +110 -12
  114. package/src/providers/accounts/shadow-home.ts +10 -0
  115. package/src/providers/accounts/status.ts +13 -4
  116. package/src/providers/accounts/store.ts +25 -0
  117. package/src/providers/accounts/variables.ts +11 -1
  118. package/src/providers/acp-agent-state.ts +24 -3
  119. package/src/providers/acp-provider.ts +5 -0
  120. package/src/providers/acp-usage-log.ts +2 -0
  121. package/src/providers/catalog.ts +17 -0
  122. package/src/providers/claude.ts +10 -1
  123. package/src/providers/codex-rules.ts +3 -0
  124. package/src/providers/codex.ts +2 -1
  125. package/src/providers/detect.ts +6 -1
  126. package/src/providers/provider.ts +1 -1
  127. package/src/providers/registry.ts +18 -2
  128. package/src/providers/shell-commands.ts +37 -2
  129. package/src/record-directory.ts +19 -0
  130. package/src/run-process.ts +24 -3
  131. package/src/serializer.ts +3 -0
  132. package/src/skills.ts +73 -10
  133. package/src/tasks/background-limit.ts +9 -2
  134. package/src/tasks/end-children.ts +9 -0
  135. package/src/tasks/give-task.ts +8 -0
  136. package/src/tasks/task-coordinator.ts +81 -17
  137. package/src/tasks/task-store.ts +28 -0
  138. package/src/tasks/waiting-child.ts +21 -0
  139. package/src/tasks/wake-parent.ts +26 -0
  140. package/src/tasks/wiring.ts +22 -0
  141. package/src/testing/accounts.ts +2 -0
  142. package/src/testing/fake-acp-capture.ts +16 -0
  143. package/src/testing/fake-acp.ts +66 -0
  144. package/src/testing/fake-claude.ts +177 -0
  145. package/src/testing/fake-cli.ts +29 -0
  146. package/src/testing/fake-codex.ts +122 -0
  147. package/src/testing/manual-clock.ts +2 -0
  148. package/src/testing/watch.ts +7 -1
  149. package/src/title-file.ts +12 -0
  150. package/src/usage/accounts.ts +6 -0
  151. package/src/usage/aggregate.ts +19 -0
  152. package/src/usage/exchange.ts +12 -0
  153. package/src/usage/index-file.ts +27 -0
  154. package/src/usage/limits/monitor.ts +31 -3
  155. package/src/usage/limits/normalize.ts +30 -0
  156. package/src/usage/limits/probe.ts +17 -0
  157. package/src/usage/pricing.ts +35 -0
  158. package/src/usage/projects.ts +8 -0
  159. package/src/usage/readers/claude.ts +12 -9
  160. package/src/usage/readers/codex.ts +40 -8
  161. package/src/usage/readers/grok.ts +17 -9
  162. package/src/usage/readers/host-log.ts +9 -9
  163. package/src/usage/record.ts +22 -0
  164. package/src/usage/roots.ts +6 -0
  165. package/src/usage/scanner.ts +61 -0
  166. package/src/usage/usage-service.ts +14 -0
  167. package/src/watch-seam.ts +8 -0
  168. package/src/windows-command.ts +12 -0
@@ -67,6 +67,7 @@ async function installRulesIn(kind: AgentKind, folder: string, rules: CodexRules
67
67
  if (kind !== 'codex') {
68
68
  return;
69
69
  }
70
+
70
71
  const path = codexRulesPathIn(folder, rules);
71
72
  await installCodexRules(path, rules).catch((e: unknown) => console.error(`Could not write the codex rules in ${path}:`, errorText(e)));
72
73
  }
@@ -82,6 +83,7 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
82
83
  const client = options.client ?? DEFAULT_CODEX_CLIENT;
83
84
  const codexRules = options.codexRules;
84
85
  const providers = new ProviderRegistry({ providers: [createClaudeProvider(options.claude), createCodexProvider({ client })], env, detect: options.detect });
86
+
85
87
  const accounts = new ProviderAccountsService({
86
88
  home: options.dataDir,
87
89
  providers,
@@ -90,15 +92,19 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
90
92
  ...(options.accountsHost ? { host: options.accountsHost } : {}),
91
93
  ...(codexRules ? { install: (kind: AgentKind, folder: string) => installRulesIn(kind, folder, codexRules) } : {})
92
94
  });
95
+
93
96
  if (codexRules) {
94
97
  void installRulesIn('codex', defaultCodexHome(env), codexRules);
95
98
  }
99
+
96
100
  const nameOf = (kind: AgentKind | UsageProvider): string => providers.get(kind).name;
101
+
97
102
  const limits = new UsageMonitor({ providers, accounts: limitAccountsOf(accounts, nameOf, env), client });
98
103
  accounts.listen(() => limits.accountsChanged());
99
104
  const attachments = new AttachmentStore(options.dataDir);
100
105
  // Only without `core`, where `Core` is `ChatCore` itself.
101
106
  const core = options.core ?? ((coreOptions: ChatCoreOptions) => new ChatCore(coreOptions) as Core);
107
+
102
108
  const chats = core({
103
109
  providers,
104
110
  store: new ChatStore(options.dataDir, { attachments }),
@@ -116,6 +122,7 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
116
122
  claudeTitles: new ClaudeTitleReader(),
117
123
  nameChat: (kind, input) => suggestChatTitle(providers, kind, input, definedEnv(env))
118
124
  });
125
+
119
126
  const usage = new UsageService({
120
127
  home: options.dataDir,
121
128
  knownProjects: () => Promise.resolve([]),
@@ -123,6 +130,7 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
123
130
  sessionAccounts: () => chatSessionAccounts(chats.list()),
124
131
  accounts: () => usageAccountsOf(accounts, nameOf)
125
132
  });
133
+
126
134
  return {
127
135
  providers,
128
136
  accounts,
@@ -141,10 +149,12 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
141
149
  usage.subscribe(clientId, send),
142
150
  limits.subscribe(clientId, send)
143
151
  ];
152
+
144
153
  return () => {
145
154
  for (const release of releases) {
146
155
  release();
147
156
  }
157
+
148
158
  chats.detachAll(clientId);
149
159
  usage.unfollow(clientId);
150
160
  };
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { RecordDirectory, type RecordDirectoryOptions } from './record-directory
7
7
  export { runProcess, type ProcessResult, type RunProcessOptions } from './run-process.ts';
8
8
  export { Serializer, KeyedSerializer } from './serializer.ts';
9
9
  export { cleanTitle, readLines } from './title-file.ts';
10
+
10
11
  export {
11
12
  type DirectoryWatcher,
12
13
  type WatchSeams,
package/src/lineage.ts CHANGED
@@ -60,24 +60,30 @@ export class AgentLineageStore {
60
60
  descendants(nodeId: string): string[] {
61
61
  const found: string[] = [];
62
62
  const seen = new Set([nodeId]);
63
+ const entries = this.opened.all();
64
+
63
65
  for (let i = -1; i < found.length; i++) {
64
66
  const parent = i === -1 ? nodeId : found[i]!;
65
- for (const entry of this.opened.all()) {
67
+
68
+ for (const entry of entries) {
66
69
  if (entry.openedBy === parent && openedByAgent(entry) && entry.endedAt === undefined && !seen.has(entry.nodeId)) {
67
70
  seen.add(entry.nodeId);
68
71
  found.push(entry.nodeId);
69
72
  }
70
73
  }
71
74
  }
75
+
72
76
  return found;
73
77
  }
74
78
 
75
79
  /* Moves a node's record to another project with its dates as they were; false when nothing was written down for it or a prune took it meanwhile. */
76
80
  async rehome(nodeId: string, projectId: string): Promise<boolean> {
77
81
  const entry = this.opened.get(nodeId);
82
+
78
83
  if (entry === undefined) {
79
84
  return false;
80
85
  }
86
+
81
87
  return this.opened.write({ ...entry, projectId });
82
88
  }
83
89
 
@@ -93,8 +99,10 @@ export class AgentLineageStore {
93
99
  /* On the wall clock, like `createdAt`: a chat holds the mark against the time its turn started. */
94
100
  async markEnded(nodeIds: readonly string[]): Promise<void> {
95
101
  const at = Date.now();
102
+
96
103
  for (const nodeId of nodeIds) {
97
104
  const entry = this.opened.get(nodeId);
105
+
98
106
  if (entry && entry.endedAt === undefined) {
99
107
  await this.opened.write({ ...entry, endedAt: at });
100
108
  }
@@ -103,7 +111,8 @@ export class AgentLineageStore {
103
111
 
104
112
  /* The nodes of this project whose opener it no longer places, and that no cascade ended yet. */
105
113
  orphans(projectId: string, ids: ReadonlySet<string>): Array<{ nodeId: string; openedBy: string }> {
106
- return [...this.opened.all()]
114
+ return this.opened
115
+ .all()
107
116
  .filter(
108
117
  (entry) =>
109
118
  entry.projectId === projectId && openedByAgent(entry) && entry.endedAt === undefined && ids.has(entry.nodeId) && !ids.has(entry.openedBy)
@@ -114,6 +123,7 @@ export class AgentLineageStore {
114
123
  /* The widest mode this agent node may start in, every time it starts; null for one no agent opened. */
115
124
  ceilingOf(nodeId: string): RuntimeMode | null {
116
125
  const entry = this.opened.get(nodeId);
126
+
117
127
  return entry !== undefined && openedByAgent(entry) ? (entry.ceiling ?? null) : null;
118
128
  }
119
129
 
@@ -122,7 +132,7 @@ export class AgentLineageStore {
122
132
  return this.opened.get(nodeId)?.peer ?? null;
123
133
  }
124
134
 
125
- /* How deep a node sits. A node nobody wrote down is one a person made, which is where a chain starts. */
135
+ /* A node nobody wrote down is one a person made, which is where a chain starts. */
126
136
  depthOf(nodeId: string): number {
127
137
  return this.opened.get(nodeId)?.depth ?? 0;
128
138
  }
@@ -130,39 +140,48 @@ export class AgentLineageStore {
130
140
  // A host may exclude completed hidden agents while keeping their lineage for later reads and tasks.
131
141
  openedCount(callerId: string, include: (nodeId: string) => boolean = () => true): number {
132
142
  let count = 0;
143
+
133
144
  for (const entry of this.opened.all()) {
134
145
  if (entry.openedBy === callerId && openedByAgent(entry) && include(entry.nodeId)) {
135
146
  count += 1;
136
147
  }
137
148
  }
149
+
138
150
  return count;
139
151
  }
140
152
 
141
153
  /* Who made this node, or null for one a person made: the whole of the rule node delete follows. */
142
154
  madeBy(nodeId: string): string | null {
143
155
  const entry = this.opened.get(nodeId);
156
+
144
157
  return entry === undefined || entry.relation === 'fork' ? null : entry.openedBy;
145
158
  }
146
159
 
147
160
  /* The node that opened this one as an agent, or null for a node no agent or team started. */
148
161
  startedBy(nodeId: string): string | null {
149
162
  const entry = this.opened.get(nodeId);
163
+
150
164
  return entry !== undefined && openedByAgent(entry) ? entry.openedBy : null;
151
165
  }
152
166
 
153
167
  /* The chat a fork was made from, or null for a node that is no fork. */
154
168
  forkedFrom(nodeId: string): string | null {
155
169
  const entry = this.opened.get(nodeId);
170
+
156
171
  return entry?.relation === 'fork' ? entry.openedBy : null;
157
172
  }
158
173
 
159
174
  forksOf(nodeId: string): string[] {
160
- return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
175
+ return this.opened
176
+ .all()
177
+ .filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId)
178
+ .map((entry) => entry.nodeId);
161
179
  }
162
180
 
163
181
  /* The forks of this project whose node is gone, read before `prune` forgets them. */
164
182
  forksLeaving(projectId: string, ids: ReadonlySet<string>): string[] {
165
- return [...this.opened.all()]
183
+ return this.opened
184
+ .all()
166
185
  .filter((entry) => entry.projectId === projectId && entry.relation === 'fork' && !ids.has(entry.nodeId))
167
186
  .map((entry) => entry.nodeId);
168
187
  }
@@ -36,18 +36,24 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
36
36
  if (!deps.placed(entry.target)) {
37
37
  return;
38
38
  }
39
+
39
40
  const waiting = deps.notices.waiting(entry.target);
41
+
40
42
  // A turn opened for an earlier message took these along, or they went stale: nothing to wake anyone for.
41
43
  if (waiting.length === 0) {
42
44
  return;
43
45
  }
46
+
44
47
  const chat = await deps.chat(entry.target);
48
+
45
49
  if (chat === null) {
46
50
  return;
47
51
  }
52
+
48
53
  if (chat.relocating?.() === true) {
49
54
  return 'wait';
50
55
  }
56
+
51
57
  // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
52
58
  // and the label on the turn names who it came from.
53
59
  chat.wake({
@@ -40,18 +40,23 @@ export interface ChatDelivery {
40
40
  */
41
41
  export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
42
42
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
43
+
43
44
  for (let i = from; i >= 0; i--) {
44
45
  const turn = items[i]!;
46
+
45
47
  if (turn.kind !== 'turn') {
46
48
  continue;
47
49
  }
50
+
48
51
  if ((turn.messageFrom ?? []).length > 0) {
49
52
  return true;
50
53
  }
54
+
51
55
  if (turn.origin !== 'agent' || turn.taskIds !== undefined || turn.summaryFor !== undefined) {
52
56
  return false;
53
57
  }
54
58
  }
59
+
55
60
  return false;
56
61
  }
57
62
 
@@ -60,14 +65,17 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
60
65
  return {
61
66
  chat: async (id) => {
62
67
  const session = chats.get(id);
68
+
63
69
  if (session) {
64
70
  return session.info.activeTurnId === null ? 'idle' : 'running';
65
71
  }
72
+
66
73
  // A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
67
74
  return (await chats.hasStored(id)) ? 'idle' : 'none';
68
75
  },
69
76
  fromMessage: (id) => {
70
77
  const session = chats.get(id);
78
+
71
79
  return session !== undefined && turnFromMessage(session.thread.list(), session.info.activeTurnId);
72
80
  }
73
81
  };
@@ -82,15 +90,19 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
82
90
  export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
83
91
  const waiting = await store.put(notice);
84
92
  const chat = await targets.chat(notice.targetId);
93
+
85
94
  if (chat === 'none') {
86
95
  return { outcome: 'no-chat', waiting };
87
96
  }
97
+
88
98
  if (chat === 'running') {
89
99
  return { outcome: 'in-turn', waiting };
90
100
  }
101
+
91
102
  if (targets.fromMessage(notice.from)) {
92
103
  return { outcome: 'from-message', waiting };
93
104
  }
105
+
94
106
  return { outcome: 'wake', waiting };
95
107
  }
96
108
 
@@ -110,6 +122,7 @@ export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: Notice
110
122
  if (!(await chat.has(targetId))) {
111
123
  return;
112
124
  }
125
+
113
126
  for (const notice of await store.show(targetId)) {
114
127
  await chat.note(targetId, words.shown(notice));
115
128
  }
@@ -37,10 +37,9 @@ const FileSchema = z.object({ notices: z.array(NoticeSchema).min(1) });
37
37
  type NoticeFile = z.infer<typeof FileSchema>;
38
38
 
39
39
  /*
40
- * The messages waiting for each node, held until that node's agent has a moment to hear them, under
41
- * `<dataDir>/notices`. On disk because the receiver may well be a node nobody has mounted yet, and a
42
- * host that restarts must not lose what was left for it. Delivered once, whichever channel gets there
43
- * first.
40
+ * The messages waiting for each node, held until that node's agent has a moment to hear them. On disk
41
+ * because the receiver may well be a node nobody has mounted yet, and a host that restarts must not
42
+ * lose what was left for it.
44
43
  */
45
44
  export class NoticeStore {
46
45
  readonly dir: string;
@@ -51,6 +50,7 @@ export class NoticeStore {
51
50
  constructor(dataDir: string, now: () => number = Date.now) {
52
51
  this.dir = join(dataDir, 'notices');
53
52
  this.now = now;
53
+
54
54
  this.queues = new RecordDirectory({
55
55
  dir: this.dir,
56
56
  schema: FileSchema,
@@ -58,6 +58,7 @@ export class NoticeStore {
58
58
  // What went stale while the host was down is not delivered; a queue of nothing else goes.
59
59
  keep: (file) => {
60
60
  const fresh = file.notices.filter((notice) => this.fresh(notice));
61
+
61
62
  return fresh[0] === undefined ? null : { notices: fresh };
62
63
  }
63
64
  });
@@ -71,6 +72,7 @@ export class NoticeStore {
71
72
  async put(notice: Omit<Notice, 'createdAt'>): Promise<number> {
72
73
  const queue = [...this.waiting(notice.targetId), { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
73
74
  await this.persist(notice.targetId, queue);
75
+
74
76
  return queue.length;
75
77
  }
76
78
 
@@ -82,10 +84,13 @@ export class NoticeStore {
82
84
  */
83
85
  take(targetId: string): Notice[] {
84
86
  const queue = this.waiting(targetId);
87
+
85
88
  if (queue.length === 0) {
86
89
  return [];
87
90
  }
91
+
88
92
  this.lastDrop = this.persist(targetId, []).catch((e) => console.error(`Dropping the messages of ${targetId} failed:`, errorText(e)));
93
+
89
94
  return queue;
90
95
  }
91
96
 
@@ -101,22 +106,27 @@ export class NoticeStore {
101
106
  async show(targetId: string): Promise<Notice[]> {
102
107
  const queue = this.waiting(targetId);
103
108
  const unshown = queue.filter((notice) => notice.shown !== true);
109
+
104
110
  if (unshown.length === 0) {
105
111
  return [];
106
112
  }
113
+
107
114
  await this.persist(
108
115
  targetId,
109
116
  queue.map((notice) => ({ ...notice, shown: true }))
110
117
  );
118
+
111
119
  return unshown;
112
120
  }
113
121
 
114
122
  /* What waits for a node, without taking it; the stale ones are already gone from the answer. */
115
123
  waiting(targetId: string): Notice[] {
116
124
  const file = this.queues.get(targetId);
125
+
117
126
  if (file === undefined) {
118
127
  return [];
119
128
  }
129
+
120
130
  return file.notices.filter((notice) => this.fresh(notice));
121
131
  }
122
132
 
@@ -133,8 +143,10 @@ export class NoticeStore {
133
143
  private async persist(targetId: string, queue: Notice[]): Promise<void> {
134
144
  if (queue.length === 0) {
135
145
  await this.queues.remove(targetId);
146
+
136
147
  return;
137
148
  }
149
+
138
150
  await this.queues.write({ notices: queue });
139
151
  }
140
152
  }
package/src/modes.ts CHANGED
@@ -32,6 +32,7 @@ export function ceilingForOpening(opener: RuntimeMode, requested: RuntimeMode |
32
32
  modeLines(opener)
33
33
  );
34
34
  }
35
+
35
36
  return opener;
36
37
  }
37
38
 
@@ -67,6 +67,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
67
67
 
68
68
  stop(): void {
69
69
  this.started = false;
70
+
70
71
  if (this.timer !== null) {
71
72
  this.clock.clearTimeout(this.timer);
72
73
  this.timer = null;
@@ -87,11 +88,13 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
87
88
  wake(target: string): void {
88
89
  this.wakes.set(target, (this.wakes.get(target) ?? 0) + 1);
89
90
  let woke = false;
91
+
90
92
  for (const entry of this.store.list()) {
91
93
  if (entry.target === target && this.waiting.delete(entry.id)) {
92
94
  woke = true;
93
95
  }
94
96
  }
97
+
95
98
  if (woke) {
96
99
  queueMicrotask(() => this.drain());
97
100
  }
@@ -102,6 +105,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
102
105
  if (this.isSettled()) {
103
106
  return Promise.resolve();
104
107
  }
108
+
105
109
  return new Promise((resolve) => {
106
110
  this.waiters.push(resolve);
107
111
  });
@@ -110,57 +114,76 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
110
114
  private drain(): void {
111
115
  if (!this.started) {
112
116
  this.notifySettled();
117
+
113
118
  return;
114
119
  }
120
+
115
121
  const now = this.clock.now();
116
122
  const busy = new Set(this.running);
117
123
  let nextDue: number | null = null;
124
+
118
125
  const dueAt = (at: number): void => {
119
126
  nextDue = nextDue === null ? at : Math.min(nextDue, at);
120
127
  };
128
+
121
129
  for (const entry of this.store.list()) {
122
130
  // A waiting entry holds no lane. The resume of a chat must not queue behind a wake that waits for it.
123
131
  if (busy.has(entry.target) || this.waiting.has(entry.id)) {
124
132
  continue;
125
133
  }
134
+
126
135
  // Nor does work due at a later time, which nothing owed before it has to wait for.
127
136
  if (entry.attempts === 0 && entry.notBefore > now) {
128
137
  dueAt(entry.notBefore);
138
+
129
139
  continue;
130
140
  }
141
+
131
142
  const lanes = this.store.lanesOf(entry);
143
+
132
144
  // Oldest first per target. A younger entry never overtakes one that waits out a retry.
133
145
  for (const lane of lanes) {
134
146
  busy.add(lane);
135
147
  }
148
+
136
149
  // The other lanes only wait for work that is running; work that is merely owed there is what ending takes away.
137
150
  if (lanes.some((lane) => lane !== entry.target && this.running.has(lane))) {
138
151
  continue;
139
152
  }
153
+
140
154
  if (entry.notBefore > now) {
141
155
  dueAt(entry.notBefore);
156
+
142
157
  continue;
143
158
  }
159
+
144
160
  void this.run(entry);
145
161
  }
162
+
146
163
  this.schedule(nextDue, now);
147
164
  this.notifySettled();
148
165
  }
149
166
 
150
167
  private async run(entry: OutboxEntryOf<Work>): Promise<void> {
151
168
  const lanes = this.store.lanesOf(entry);
169
+
152
170
  for (const lane of lanes) {
153
171
  this.running.add(lane);
154
172
  }
173
+
155
174
  const woken = this.wakes.get(entry.target) ?? 0;
175
+
156
176
  try {
157
177
  const outcome = await (this.handlers[entry.kind as Work['kind']] as (entry: OutboxEntryOf<Work>) => Promise<OutboxOutcome>)(entry);
178
+
158
179
  if (outcome === 'wait') {
159
180
  if ((this.wakes.get(entry.target) ?? 0) === woken && this.store.has(entry.id)) {
160
181
  this.waiting.add(entry.id);
161
182
  }
183
+
162
184
  return;
163
185
  }
186
+
164
187
  await this.store.remove(entry.id);
165
188
  } catch (e) {
166
189
  await this.failed(entry, e).catch((error: unknown) => console.error(`The outbox could not record a failed ${entry.kind}:`, errorText(error)));
@@ -168,18 +191,22 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
168
191
  for (const lane of lanes) {
169
192
  this.running.delete(lane);
170
193
  }
194
+
171
195
  this.drain();
172
196
  }
173
197
  }
174
198
 
175
199
  private async failed(entry: OutboxEntryOf<Work>, error: unknown): Promise<void> {
176
200
  const delay = RETRY_DELAYS_MS[entry.attempts];
201
+
177
202
  if (delay === undefined) {
178
203
  await this.store.remove(entry.id);
179
204
  console.error(`Gave up on ${entry.kind} for ${entry.target}:`, errorText(error));
180
205
  this.onParked(entry, error);
206
+
181
207
  return;
182
208
  }
209
+
183
210
  await this.store.update({ ...entry, attempts: entry.attempts + 1, notBefore: this.clock.now() + delay });
184
211
  }
185
212
 
@@ -188,6 +215,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
188
215
  this.clock.clearTimeout(this.timer);
189
216
  this.timer = null;
190
217
  }
218
+
191
219
  if (nextDue !== null) {
192
220
  this.timer = this.clock.setTimeout(
193
221
  () => {
@@ -201,6 +229,7 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
201
229
 
202
230
  private isSettled(): boolean {
203
231
  const now = this.clock.now();
232
+
204
233
  return this.running.size === 0 && (!this.started || !this.store.list().some((entry) => entry.notBefore <= now && !this.waiting.has(entry.id)));
205
234
  }
206
235
 
@@ -208,8 +237,10 @@ export class OutboxWorker<Work extends OutboxWorkShape> {
208
237
  if (!this.isSettled()) {
209
238
  return;
210
239
  }
240
+
211
241
  const waiting = this.waiters;
212
242
  this.waiters = [];
243
+
213
244
  for (const resolve of waiting) {
214
245
  resolve();
215
246
  }
@@ -45,11 +45,13 @@ export class OutboxStore<Work extends OutboxWorkShape> {
45
45
 
46
46
  constructor(options: OutboxStoreOptions<Work>) {
47
47
  this.dir = join(options.dataDir, 'outbox');
48
+
48
49
  this.entries = new RecordDirectory<OutboxEntryOf<Work>>({
49
50
  dir: this.dir,
50
51
  schema: z.intersection(options.work, OutboxEntryFieldsSchema),
51
52
  idOf: (entry) => entry.id
52
53
  });
54
+
53
55
  this.lanes = options.lanesOf ?? ((entry) => [entry.target]);
54
56
  this.outlivesTarget = options.outlivesTarget ?? (() => false);
55
57
  }
@@ -70,7 +72,9 @@ export class OutboxStore<Work extends OutboxWorkShape> {
70
72
  attempts: 0,
71
73
  notBefore
72
74
  };
75
+
73
76
  await this.entries.write(entry);
77
+
74
78
  return entry;
75
79
  }
76
80
 
@@ -79,6 +83,7 @@ export class OutboxStore<Work extends OutboxWorkShape> {
79
83
  if (!this.entries.has(entry.id)) {
80
84
  return;
81
85
  }
86
+
82
87
  await this.entries.write(entry);
83
88
  }
84
89