loopengine 0.1.80 → 0.1.82
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/README.md +8 -6
- package/dist/core/agent-config.d.ts +1 -0
- package/dist/core/http-notifier.d.ts +2 -2
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/slack.d.ts +5 -1
- package/dist/core/http-notify-triggers/slack.js +12 -1
- package/dist/core/messaging/connectors/slack/events.js +2 -1
- package/dist/core/messaging/connectors/slack/format.d.ts +1 -0
- package/dist/core/messaging/connectors/slack/format.js +34 -0
- package/dist/core/messaging/connectors/slack/index.d.ts +1 -0
- package/dist/core/messaging/connectors/slack/index.js +2 -1
- package/dist/core/notifier-file.d.ts +9 -2
- package/dist/core/notifier-file.js +60 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/web/agents-config-page.js +119 -24
- package/dist/web/console-shell.js +2 -2
- package/dist/web/decide-page.js +1 -1
- package/dist/web/global-config-page.js +18 -4
- package/dist/web/notifier-admin.d.ts +8 -2
- package/dist/web/notifier-admin.js +21 -3
- package/dist/web/upgrade-admin.d.ts +6 -1
- package/dist/web/upgrade-admin.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,13 +172,15 @@ export const config: AgentConfig = {
|
|
|
172
172
|
}
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
Or skip the code: in the console, the
|
|
176
|
-
requests and questions are sent** section sets a
|
|
175
|
+
Or skip the code: in the console, the agent's **Messaging** tab sets a
|
|
177
176
|
webhook, Slack or Lark target for one agent (saved to
|
|
178
|
-
`agents/<name>/
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
`agents/<name>/messaging.yml`, with its secret in the agent's own `.env`;
|
|
178
|
+
an older `notifier.yml` is still read), sends a test and shows recent
|
|
179
|
+
deliveries. With Slack, people can also reply in a request's thread to
|
|
180
|
+
ask the agent about it, and it answers there (**Answer replies in a
|
|
181
|
+
request's thread**, on by default). The same tab lists every decision
|
|
182
|
+
still waiting with Approve/Deny buttons (its count shows on the tab), and
|
|
183
|
+
warns you when an agent has `ask` rules but nobody would be told.
|
|
182
184
|
|
|
183
185
|
Every notification links to a **decision page**: a signed, single-use
|
|
184
186
|
link (`/decide/<token>`, valid 7 days) where whoever got the message can
|
|
@@ -8,7 +8,7 @@ interface ResolvedHttpNotifier {
|
|
|
8
8
|
onRunFinish?: NonNullable<AgentConfig['onRunFinish']>;
|
|
9
9
|
}
|
|
10
10
|
/** Where an agent's durable approvals/questions go, for the console:
|
|
11
|
-
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/
|
|
11
|
+
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/messaging.yml),
|
|
12
12
|
* or `none`. A file whose secret isn't set yet is reported, not used. */
|
|
13
13
|
export declare function describeNotifier(config: AgentConfig): {
|
|
14
14
|
source: 'code' | 'file' | 'none';
|
|
@@ -19,7 +19,7 @@ export declare function describeNotifier(config: AgentConfig): {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
};
|
|
21
21
|
/** AgentConfig.httpNotifier when the agent sets one in code; otherwise
|
|
22
|
-
* its agents/<name>/
|
|
22
|
+
* its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
|
|
23
23
|
* every call so a console edit applies to the very next turn. */
|
|
24
24
|
export declare function resolveHttpNotifier(config: AgentConfig): ResolvedHttpNotifier;
|
|
25
25
|
export {};
|
|
@@ -33,7 +33,7 @@ function formatEmailLifecycleSubject(event, context) {
|
|
|
33
33
|
return event === 'run_start' ? `${context.agent} started` : `${context.agent} finished`;
|
|
34
34
|
}
|
|
35
35
|
/** Where an agent's durable approvals/questions go, for the console:
|
|
36
|
-
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/
|
|
36
|
+
* `code` (AgentConfig.httpNotifier), `file` (agents/<name>/messaging.yml),
|
|
37
37
|
* or `none`. A file whose secret isn't set yet is reported, not used. */
|
|
38
38
|
export function describeNotifier(config) {
|
|
39
39
|
if (config.httpNotifier)
|
|
@@ -44,7 +44,7 @@ export function describeNotifier(config) {
|
|
|
44
44
|
file = readNotifierFile(dir);
|
|
45
45
|
}
|
|
46
46
|
catch (err) {
|
|
47
|
-
return { source: 'none', error: `
|
|
47
|
+
return { source: 'none', error: `The messaging settings are invalid: ${err instanceof Error ? err.message : String(err)}` };
|
|
48
48
|
}
|
|
49
49
|
if (!file)
|
|
50
50
|
return { source: 'none' };
|
|
@@ -52,7 +52,7 @@ export function describeNotifier(config) {
|
|
|
52
52
|
return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
|
|
53
53
|
}
|
|
54
54
|
/** AgentConfig.httpNotifier when the agent sets one in code; otherwise
|
|
55
|
-
* its agents/<name>/
|
|
55
|
+
* its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
|
|
56
56
|
* every call so a console edit applies to the very next turn. */
|
|
57
57
|
export function resolveHttpNotifier(config) {
|
|
58
58
|
const httpNotifier = config.httpNotifier;
|
|
@@ -72,7 +72,7 @@ function resolveFromFile(config) {
|
|
|
72
72
|
file = readNotifierFile(dir);
|
|
73
73
|
}
|
|
74
74
|
catch (err) {
|
|
75
|
-
console.error(`[loopengine] ignoring agents/${config.name}
|
|
75
|
+
console.error(`[loopengine] ignoring the messaging settings in agents/${config.name}/:`, err instanceof Error ? err.message : err);
|
|
76
76
|
return EMPTY;
|
|
77
77
|
}
|
|
78
78
|
if (!file)
|
|
@@ -101,13 +101,13 @@ function buildResolved(httpNotifier, agentDir) {
|
|
|
101
101
|
break;
|
|
102
102
|
}
|
|
103
103
|
case 'slack': {
|
|
104
|
-
const { botToken, channelId, publicUrl, buttons } = httpNotifier.config;
|
|
104
|
+
const { botToken, channelId, publicUrl, buttons, replyHint } = httpNotifier.config;
|
|
105
105
|
// One shared instance for every concern this httpNotifier actually
|
|
106
106
|
// covers — same "it's Slack-specific, not approval/question/
|
|
107
107
|
// lifecycle-specific" reasoning SlackNotifier's own doc
|
|
108
108
|
// comment works out; cheap to construct (holds two strings, does no
|
|
109
109
|
// I/O itself) so there's no reason to build one per event kind.
|
|
110
|
-
const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons, onThread: ({ channel, ts, pendingId, kind }) => recordThread(agentDir, { app: 'slack', conversation: channel, ts, pendingId, kind }),
|
|
110
|
+
const slack = new SlackNotifier({ botToken, channelId, publicUrl, buttons, replyHint, onThread: ({ channel, ts, pendingId, kind }) => recordThread(agentDir, { app: 'slack', conversation: channel, ts, pendingId, kind }),
|
|
111
111
|
});
|
|
112
112
|
resolved = {
|
|
113
113
|
approver: events.has('approval') ? slack : undefined,
|
|
@@ -13,6 +13,7 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
|
|
|
13
13
|
private readonly channelId;
|
|
14
14
|
private readonly publicUrl?;
|
|
15
15
|
private readonly buttons;
|
|
16
|
+
private readonly replyHint;
|
|
16
17
|
private readonly onThread?;
|
|
17
18
|
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
18
19
|
* wins when set) — when known, each message links to a decision page
|
|
@@ -22,14 +23,17 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
|
|
|
22
23
|
* slack.ts); default true, false for a console-configured notifier,
|
|
23
24
|
* which relies on the link instead. `onThread`: told where each
|
|
24
25
|
* approval request or question landed, so a reply in its thread can be
|
|
25
|
-
* matched to it (core/messaging/threads.ts).
|
|
26
|
+
* matched to it (core/messaging/threads.ts). `replyHint`: add a line
|
|
27
|
+
* saying replies in the thread are answered, when they are. */
|
|
26
28
|
constructor(options: {
|
|
27
29
|
botToken: string;
|
|
28
30
|
channelId: string;
|
|
29
31
|
publicUrl?: string;
|
|
30
32
|
buttons?: boolean;
|
|
33
|
+
replyHint?: boolean;
|
|
31
34
|
onThread?: (posted: SlackPostedRequest) => void;
|
|
32
35
|
});
|
|
36
|
+
private replyHintBlocks;
|
|
33
37
|
private threadRecorder;
|
|
34
38
|
private decideLine;
|
|
35
39
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
@@ -31,6 +31,7 @@ export class SlackNotifier {
|
|
|
31
31
|
channelId;
|
|
32
32
|
publicUrl;
|
|
33
33
|
buttons;
|
|
34
|
+
replyHint;
|
|
34
35
|
onThread;
|
|
35
36
|
/** `publicUrl`: where this server is reachable (LOOPENGINE_PUBLIC_URL
|
|
36
37
|
* wins when set) — when known, each message links to a decision page
|
|
@@ -40,14 +41,22 @@ export class SlackNotifier {
|
|
|
40
41
|
* slack.ts); default true, false for a console-configured notifier,
|
|
41
42
|
* which relies on the link instead. `onThread`: told where each
|
|
42
43
|
* approval request or question landed, so a reply in its thread can be
|
|
43
|
-
* matched to it (core/messaging/threads.ts).
|
|
44
|
+
* matched to it (core/messaging/threads.ts). `replyHint`: add a line
|
|
45
|
+
* saying replies in the thread are answered, when they are. */
|
|
44
46
|
constructor(options) {
|
|
45
47
|
this.botToken = options.botToken;
|
|
46
48
|
this.channelId = options.channelId;
|
|
47
49
|
this.publicUrl = options.publicUrl;
|
|
48
50
|
this.buttons = options.buttons ?? true;
|
|
51
|
+
this.replyHint = options.replyHint ?? false;
|
|
49
52
|
this.onThread = options.onThread;
|
|
50
53
|
}
|
|
54
|
+
replyHintBlocks(kind) {
|
|
55
|
+
if (!this.replyHint)
|
|
56
|
+
return [];
|
|
57
|
+
const text = kind === 'approval' ? 'Reply in this thread to ask about it.' : 'Reply in this thread to ask about the question.';
|
|
58
|
+
return [{ type: 'context', elements: [{ type: 'mrkdwn', text }] }];
|
|
59
|
+
}
|
|
51
60
|
threadRecorder(pendingId, kind) {
|
|
52
61
|
const onThread = this.onThread;
|
|
53
62
|
return onThread ? (posted) => onThread({ ...posted, pendingId, kind }) : undefined;
|
|
@@ -75,6 +84,7 @@ export class SlackNotifier {
|
|
|
75
84
|
],
|
|
76
85
|
}]
|
|
77
86
|
: []),
|
|
87
|
+
...this.replyHintBlocks('approval'),
|
|
78
88
|
], this.threadRecorder(pendingId, 'approval'));
|
|
79
89
|
return { pendingId };
|
|
80
90
|
}
|
|
@@ -93,6 +103,7 @@ export class SlackNotifier {
|
|
|
93
103
|
...(this.buttons
|
|
94
104
|
? [{ type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] }]
|
|
95
105
|
: []),
|
|
106
|
+
...this.replyHintBlocks('question'),
|
|
96
107
|
], this.threadRecorder(pendingId, 'question'));
|
|
97
108
|
return { pendingId };
|
|
98
109
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toSlackMrkdwn } from './format.js';
|
|
1
2
|
/** Reads an Events API body. Only people's new messages come through;
|
|
2
3
|
* edits, deletions, joins and every bot's messages (this agent's own
|
|
3
4
|
* included) are `ignore`. */
|
|
@@ -49,7 +50,7 @@ export function createSlackConnector(botToken) {
|
|
|
49
50
|
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
50
51
|
method: 'POST',
|
|
51
52
|
headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
|
|
52
|
-
body: JSON.stringify({ channel: conversation, thread_ts: threadTs, text }),
|
|
53
|
+
body: JSON.stringify({ channel: conversation, thread_ts: threadTs, text: toSlackMrkdwn(text) }),
|
|
53
54
|
});
|
|
54
55
|
const body = (await res.json());
|
|
55
56
|
if (!body.ok)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toSlackMrkdwn(markdown: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Models write Markdown even when asked for Slack's own format, and Slack
|
|
2
|
+
// shows Markdown's **bold**, [links](url) and # headings as literal
|
|
3
|
+
// characters. This turns the common cases into Slack mrkdwn before a
|
|
4
|
+
// reply is posted; code (```blocks``` and `spans`) is left as written.
|
|
5
|
+
const CODE = /(```[\s\S]*?```|`[^`\n]*`)/;
|
|
6
|
+
export function toSlackMrkdwn(markdown) {
|
|
7
|
+
return markdown
|
|
8
|
+
.split(CODE)
|
|
9
|
+
.map((part, i) => (i % 2 === 1 ? part : convertProse(part)))
|
|
10
|
+
.join('');
|
|
11
|
+
}
|
|
12
|
+
function convertProse(text) {
|
|
13
|
+
const bold = [];
|
|
14
|
+
const keepBold = (inner) => {
|
|
15
|
+
bold.push(inner);
|
|
16
|
+
return `\u0000${bold.length - 1}\u0000`;
|
|
17
|
+
};
|
|
18
|
+
let out = text
|
|
19
|
+
// # Heading -> *Heading*
|
|
20
|
+
.replace(/^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, (_m, title) => keepBold(title))
|
|
21
|
+
// - item / * item -> • item
|
|
22
|
+
.replace(/^([ \t]*)[-*+][ \t]+/gm, '$1• ')
|
|
23
|
+
// **bold** / __bold__ -> *bold*
|
|
24
|
+
.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, (_m, inner) => keepBold(inner))
|
|
25
|
+
.replace(/__(?=\S)([\s\S]*?\S)__/g, (_m, inner) => keepBold(inner))
|
|
26
|
+
// *italic* -> _italic_ (what's left of single asterisks)
|
|
27
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1_$2_')
|
|
28
|
+
// ~~strike~~ -> ~strike~
|
|
29
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '~$1~')
|
|
30
|
+
// [text](url) -> <url|text>
|
|
31
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<$2|$1>');
|
|
32
|
+
out = out.replace(/\u0000(\d+)\u0000/g, (_m, n) => `*${bold[Number(n)]}*`);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createSlackConnector, parseSlackEvent, type SlackEvent } from './events.js';
|
|
2
|
+
export { toSlackMrkdwn } from './format.js';
|
|
2
3
|
export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, slackOutcomeText, verifySlackRequest, type SlackInteraction, } from './interactions.js';
|
|
3
4
|
export { listSlackChannels, slackAppManifest, type SlackChannel } from './setup.js';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The Slack connector (core/messaging/connector.ts), one file per part:
|
|
2
|
-
// thread replies (./events.ts), buttons and the signing check
|
|
2
|
+
// thread replies (./events.ts) and their formatting (./format.ts), buttons and the signing check
|
|
3
3
|
// (./interactions.ts), and the console's app setup (./setup.ts).
|
|
4
4
|
// Posting requests is still SlackNotifier's job
|
|
5
5
|
// (core/http-notify-triggers/slack.ts).
|
|
6
6
|
export { createSlackConnector, parseSlackEvent } from './events.js';
|
|
7
|
+
export { toSlackMrkdwn } from './format.js';
|
|
7
8
|
export { SLACK_ANSWER_ACTION_ID, SLACK_ANSWER_BLOCK_ID, openSlackAnswerModal, parseSlackInteraction, replyInSlack, slackOutcomeText, verifySlackRequest, } from './interactions.js';
|
|
8
9
|
export { listSlackChannels, slackAppManifest } from './setup.js';
|
|
@@ -14,6 +14,9 @@ export interface NotifierFile {
|
|
|
14
14
|
* remembered from the console's own address when saving.
|
|
15
15
|
* LOOPENGINE_PUBLIC_URL, when set, wins over it. */
|
|
16
16
|
publicUrl?: string;
|
|
17
|
+
/** slack: answer replies in a request's thread (core/messaging/).
|
|
18
|
+
* Unset counts as on. */
|
|
19
|
+
conversations?: boolean;
|
|
17
20
|
}
|
|
18
21
|
/** The env var each channel's secret is read from, per agent. */
|
|
19
22
|
export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
@@ -23,14 +26,18 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
|
23
26
|
export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
|
|
24
27
|
export declare class NotifierFileError extends Error {
|
|
25
28
|
}
|
|
29
|
+
/** The older single-connection file, still read when messaging.yml is absent. */
|
|
26
30
|
export declare function notifierFilePath(dir: string): string;
|
|
31
|
+
export declare function messagingFilePath(dir: string): string;
|
|
27
32
|
/** Throws NotifierFileError naming the first problem. */
|
|
28
33
|
export declare function validateNotifierFile(input: unknown): NotifierFile;
|
|
29
|
-
/**
|
|
34
|
+
/** The agent's connection: messaging.yml's first, else the older
|
|
35
|
+
* notifier.yml's; undefined when it has neither. */
|
|
30
36
|
export declare function readNotifierFile(dir: string): NotifierFile | undefined;
|
|
37
|
+
/** Writes messaging.yml (and drops an older notifier.yml it replaces). */
|
|
31
38
|
export declare function writeNotifierFile(dir: string, input: unknown): NotifierFile;
|
|
32
39
|
export declare function removeNotifierFile(dir: string): void;
|
|
33
|
-
/** The HttpNotifierConfig a
|
|
40
|
+
/** The HttpNotifierConfig a connection describes, with its secret read
|
|
34
41
|
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
35
42
|
* or unauthenticated. */
|
|
36
43
|
export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv): {
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
// agents/<name>/
|
|
2
|
-
// questions go when nobody is watching a live chat,
|
|
3
|
-
//
|
|
4
|
-
// agent's own AgentConfig.httpNotifier
|
|
1
|
+
// agents/<name>/messaging.yml — where an agent's "ask" approvals and
|
|
2
|
+
// questions go when nobody is watching a live chat, and whether it
|
|
3
|
+
// answers replies there, set from the console's Messaging tab instead of
|
|
4
|
+
// in code. Only consulted when the agent's own AgentConfig.httpNotifier
|
|
5
|
+
// is unset; code always wins.
|
|
6
|
+
//
|
|
7
|
+
// The file holds a list of connections, though only the first is used
|
|
8
|
+
// for now:
|
|
9
|
+
//
|
|
10
|
+
// connections:
|
|
11
|
+
// - app: slack
|
|
12
|
+
// channelId: C0123456
|
|
13
|
+
// events: [approval, question]
|
|
14
|
+
// conversations: true
|
|
15
|
+
//
|
|
16
|
+
// An older agents/<name>/notifier.yml (one connection, `channel:` for
|
|
17
|
+
// `app:`) is still read; saving from the console replaces it with
|
|
18
|
+
// messaging.yml.
|
|
5
19
|
//
|
|
6
20
|
// The file holds only non-secret settings. The one secret each channel
|
|
7
21
|
// needs (a webhook signing secret, a Slack bot token, a Lark app secret)
|
|
@@ -25,9 +39,13 @@ const CHANNELS = ['webhook', 'slack', 'lark'];
|
|
|
25
39
|
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
26
40
|
export class NotifierFileError extends Error {
|
|
27
41
|
}
|
|
42
|
+
/** The older single-connection file, still read when messaging.yml is absent. */
|
|
28
43
|
export function notifierFilePath(dir) {
|
|
29
44
|
return join(dir, 'notifier.yml');
|
|
30
45
|
}
|
|
46
|
+
export function messagingFilePath(dir) {
|
|
47
|
+
return join(dir, 'messaging.yml');
|
|
48
|
+
}
|
|
31
49
|
/** Throws NotifierFileError naming the first problem. */
|
|
32
50
|
export function validateNotifierFile(input) {
|
|
33
51
|
if (!input || typeof input !== 'object')
|
|
@@ -42,6 +60,12 @@ export function validateNotifierFile(input) {
|
|
|
42
60
|
}
|
|
43
61
|
const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
|
|
44
62
|
const file = { channel, events: events };
|
|
63
|
+
if (raw.conversations !== undefined) {
|
|
64
|
+
if (typeof raw.conversations !== 'boolean')
|
|
65
|
+
throw new NotifierFileError('conversations must be true or false');
|
|
66
|
+
if (channel === 'slack')
|
|
67
|
+
file.conversations = raw.conversations;
|
|
68
|
+
}
|
|
45
69
|
const publicUrl = str('publicUrl');
|
|
46
70
|
if (publicUrl) {
|
|
47
71
|
if (!/^https?:\/\/[^/]+/.test(publicUrl))
|
|
@@ -70,24 +94,43 @@ export function validateNotifierFile(input) {
|
|
|
70
94
|
}
|
|
71
95
|
return file;
|
|
72
96
|
}
|
|
73
|
-
/**
|
|
97
|
+
/** The agent's connection: messaging.yml's first, else the older
|
|
98
|
+
* notifier.yml's; undefined when it has neither. */
|
|
74
99
|
export function readNotifierFile(dir) {
|
|
100
|
+
const messagingPath = messagingFilePath(dir);
|
|
101
|
+
if (existsSync(messagingPath)) {
|
|
102
|
+
const parsed = parseYaml(readFileSync(messagingPath, 'utf8'));
|
|
103
|
+
if (parsed?.connections !== undefined && !Array.isArray(parsed.connections))
|
|
104
|
+
throw new NotifierFileError('connections must be a list');
|
|
105
|
+
const first = parsed?.connections?.[0];
|
|
106
|
+
if (first === undefined)
|
|
107
|
+
return undefined;
|
|
108
|
+
if (!first || typeof first !== 'object')
|
|
109
|
+
throw new NotifierFileError('each connection must be an object');
|
|
110
|
+
const { app, ...rest } = first;
|
|
111
|
+
return validateNotifierFile({ channel: app, ...rest });
|
|
112
|
+
}
|
|
75
113
|
const path = notifierFilePath(dir);
|
|
76
114
|
if (!existsSync(path))
|
|
77
115
|
return undefined;
|
|
78
116
|
return validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
|
|
79
117
|
}
|
|
118
|
+
/** Writes messaging.yml (and drops an older notifier.yml it replaces). */
|
|
80
119
|
export function writeNotifierFile(dir, input) {
|
|
81
120
|
const file = validateNotifierFile(input);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
const { channel, ...rest } = file;
|
|
122
|
+
const header = '# Where this agent sends approval requests and questions when it is called over HTTP,\n' +
|
|
123
|
+
'# and whether it answers replies there. Edited from the console (Messaging tab).\n' +
|
|
124
|
+
`# The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[channel]}.\n`;
|
|
125
|
+
writeFileSync(messagingFilePath(dir), header + stringifyYaml({ connections: [{ app: channel, ...rest }] }));
|
|
126
|
+
rmSync(notifierFilePath(dir), { force: true });
|
|
85
127
|
return file;
|
|
86
128
|
}
|
|
87
129
|
export function removeNotifierFile(dir) {
|
|
130
|
+
rmSync(messagingFilePath(dir), { force: true });
|
|
88
131
|
rmSync(notifierFilePath(dir), { force: true });
|
|
89
132
|
}
|
|
90
|
-
/** The HttpNotifierConfig a
|
|
133
|
+
/** The HttpNotifierConfig a connection describes, with its secret read
|
|
91
134
|
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
92
135
|
* or unauthenticated. */
|
|
93
136
|
export function notifierConfigFromFile(file, env) {
|
|
@@ -105,7 +148,14 @@ export function notifierConfigFromFile(file, env) {
|
|
|
105
148
|
return {
|
|
106
149
|
config: {
|
|
107
150
|
channel: 'slack',
|
|
108
|
-
config: {
|
|
151
|
+
config: {
|
|
152
|
+
botToken: secret,
|
|
153
|
+
channelId: file.channelId,
|
|
154
|
+
publicUrl: file.publicUrl,
|
|
155
|
+
buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)),
|
|
156
|
+
// Says replies in the thread are answered, when they are.
|
|
157
|
+
replyHint: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) && file.conversations !== false && process.env.LOOPENGINE_PLAN !== 'free',
|
|
158
|
+
},
|
|
109
159
|
events: file.events,
|
|
110
160
|
},
|
|
111
161
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackIn
|
|
|
19
19
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
|
|
20
20
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
|
|
21
21
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
22
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
|
|
22
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
|
|
23
23
|
export type * from './core/loop-events.js';
|
|
24
24
|
export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
|
|
25
25
|
export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ export { parseSlackEvent, createSlackConnector, verifySlackRequest, parseSlackIn
|
|
|
20
20
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
|
|
21
21
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
|
|
22
22
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
23
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
|
|
23
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, slackConversationsOn, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
|
|
24
24
|
// The framework-agnostic browser client for adapters/http.ts's own wire
|
|
25
25
|
// protocol (see client.ts's own header comment) — this, not a deep
|
|
26
26
|
// subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
|
|
@@ -615,10 +615,49 @@ ${consoleShellScript}
|
|
|
615
615
|
card('skills', 'Skills', String(cfg.skills.length), escapeHtml(cfg.skills.map(function (k) { return k.name; }).join(', ')) || 'None yet') +
|
|
616
616
|
card('actauth', 'Permissions', rules.length + (rules.length === 1 ? ' rule' : ' rules'),
|
|
617
617
|
(asks ? asks + ' need approval \u00b7 ' : '') + 'unmatched calls: ' + escapeHtml(cfg.permissions.defaultDecision)) +
|
|
618
|
+
card('messaging', 'Messaging', '<span id="ovMessagingValue">…</span>', '<span id="ovMessagingDetail">Loading…</span>') +
|
|
619
|
+
card('schedules', 'Schedules', '<span id="ovSchedulesValue">…</span>', '<span id="ovSchedulesDetail">Loading…</span>') +
|
|
618
620
|
card('env', 'Environment', 'Settings', '<span data-le-env-count="' + escapeHtml(cfg.name) + '">' + leShell.envCountHtml(cfg.name) + '</span> Values your installed abilities read') +
|
|
619
621
|
'</div>';
|
|
620
622
|
}
|
|
621
623
|
|
|
624
|
+
var APP_NAMES = { slack: 'Slack', lark: 'Lark', webhook: 'Webhook', email: 'Email', database: 'Database', redis: 'Redis' };
|
|
625
|
+
|
|
626
|
+
// Messaging and Schedules come from their own endpoints, so their
|
|
627
|
+
// cards are filled in once those answer. Called after every render of
|
|
628
|
+
// Overview, which is also re-rendered after edits.
|
|
629
|
+
function fillOverviewCards(name) {
|
|
630
|
+
Promise.all([
|
|
631
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier'),
|
|
632
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/pending'),
|
|
633
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules'),
|
|
634
|
+
]).then(function (results) {
|
|
635
|
+
if (currentName !== name) return;
|
|
636
|
+
var set = function (id, html) { var el = detail.querySelector('#' + id); if (el) el.innerHTML = html; };
|
|
637
|
+
var n = results[0].ok ? results[0].body : null;
|
|
638
|
+
var pending = (results[1].ok && results[1].body.pending) || [];
|
|
639
|
+
var connected = n && (n.source === 'file' || n.source === 'code' || n.source === 'default');
|
|
640
|
+
set('ovMessagingValue', !n ? '—' : connected ? escapeHtml(APP_NAMES[n.channel] || n.channel) + (n.source === 'default' ? ' (default)' : '') : 'Not connected');
|
|
641
|
+
var messagingDetail = [];
|
|
642
|
+
if (pending.length) messagingDetail.push(pending.length + ' waiting for a decision');
|
|
643
|
+
if (n && connected && n.channel === 'slack' && n.plan !== 'free' && (!n.file || n.file.conversations !== false)) messagingDetail.push('answers replies in threads');
|
|
644
|
+
if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark or a webhook');
|
|
645
|
+
if (!messagingDetail.length && n) messagingDetail.push('Sends ' + escapeHtml((n.events || []).join(', ')));
|
|
646
|
+
set('ovMessagingDetail', messagingDetail.join(' \u00b7 '));
|
|
647
|
+
showPendingCount(pending.length);
|
|
648
|
+
|
|
649
|
+
var tasks = (results[2].ok && results[2].body.tasks) || [];
|
|
650
|
+
var active = tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; });
|
|
651
|
+
var waiting = active.filter(function (t) { return t.status === 'awaiting_approval'; }).length;
|
|
652
|
+
var next = active.map(function (t) { return t.next_run_at; }).filter(Boolean).sort()[0];
|
|
653
|
+
set('ovSchedulesValue', String(active.length));
|
|
654
|
+
var schedulesDetail = [];
|
|
655
|
+
if (waiting) schedulesDetail.push(waiting + ' waiting for approval');
|
|
656
|
+
if (next) schedulesDetail.push('Next run ' + escapeHtml(new Date(next).toLocaleString()));
|
|
657
|
+
set('ovSchedulesDetail', schedulesDetail.join(' \u00b7 ') || (active.length ? 'None due' : 'Run this agent on a timer'));
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
622
661
|
function renderOverviewHtml(cfg) {
|
|
623
662
|
var systemPromptSection =
|
|
624
663
|
'<h3>System prompt <button type="button" class="edit-btn" id="editSystemPromptBtn">Edit</button></h3>' +
|
|
@@ -754,6 +793,7 @@ ${consoleShellScript}
|
|
|
754
793
|
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
755
794
|
currentCfg = result.body;
|
|
756
795
|
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
796
|
+
fillOverviewCards(currentName);
|
|
757
797
|
wireOverviewHandlers(name);
|
|
758
798
|
})
|
|
759
799
|
.catch(function (err) {
|
|
@@ -802,6 +842,7 @@ ${consoleShellScript}
|
|
|
802
842
|
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
803
843
|
currentCfg = result.body;
|
|
804
844
|
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
845
|
+
fillOverviewCards(currentName);
|
|
805
846
|
wireOverviewHandlers(name);
|
|
806
847
|
})
|
|
807
848
|
.catch(function (err) {
|
|
@@ -849,6 +890,7 @@ ${consoleShellScript}
|
|
|
849
890
|
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
850
891
|
currentCfg = result.body;
|
|
851
892
|
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
893
|
+
fillOverviewCards(currentName);
|
|
852
894
|
wireOverviewHandlers(name);
|
|
853
895
|
})
|
|
854
896
|
.catch(function (err) {
|
|
@@ -897,11 +939,42 @@ ${consoleShellScript}
|
|
|
897
939
|
// opened. ----
|
|
898
940
|
|
|
899
941
|
function renderActauthTabPlaceholder() {
|
|
942
|
+
return '<div id="pendingNote"></div>' +
|
|
943
|
+
'<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading…</p></div></section>';
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// The Messaging tab: the decisions still waiting, where approval
|
|
947
|
+
// requests and questions go, and whether the agent answers replies
|
|
948
|
+
// there. Filled by loadApprovalsSection.
|
|
949
|
+
function renderMessagingTabPlaceholder() {
|
|
900
950
|
return '<div id="pendingContent"></div>' +
|
|
901
|
-
'<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading…</p></div></section>' +
|
|
902
951
|
'<div id="approvalsContent"><p class="hint">Loading…</p></div>';
|
|
903
952
|
}
|
|
904
953
|
|
|
954
|
+
// How many decisions are waiting: a badge on the Messaging tab, seen
|
|
955
|
+
// from every tab, and a line at the top of Permissions.
|
|
956
|
+
function showPendingCount(n) {
|
|
957
|
+
leShell.setCount('messaging', n);
|
|
958
|
+
var link = detail.querySelector('.le-tab[data-tab="messaging"]');
|
|
959
|
+
var badge = link && link.querySelector('.le-count');
|
|
960
|
+
if (badge) {
|
|
961
|
+
badge.className = 'le-count warn';
|
|
962
|
+
if (!n) badge.remove();
|
|
963
|
+
}
|
|
964
|
+
var note = detail.querySelector('#pendingNote');
|
|
965
|
+
if (note) {
|
|
966
|
+
note.innerHTML = n
|
|
967
|
+
? '<div class="approvals-nudge">' + (n === 1 ? '1 request is' : n + ' requests are') + ' waiting for a decision. <a href="?tab=messaging" data-tab="messaging">See Messaging</a></div>'
|
|
968
|
+
: '';
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function refreshPendingCount(name) {
|
|
973
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/pending').then(function (r) {
|
|
974
|
+
if (currentName === name && r.ok) showPendingCount((r.body.pending || []).length);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
905
978
|
function renderActauthRuleRow(r) {
|
|
906
979
|
var actionsHtml = r.name
|
|
907
980
|
? '<button type="button" class="edit-rule-btn" data-name="' + escapeHtml(r.name) + '">Edit</button> ' +
|
|
@@ -963,11 +1036,11 @@ ${consoleShellScript}
|
|
|
963
1036
|
'</form></section>';
|
|
964
1037
|
}
|
|
965
1038
|
|
|
966
|
-
// ----
|
|
967
|
-
// in Chat
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
// current. ----
|
|
1039
|
+
// ---- Messaging tab: the decisions still waiting (GET .../pending),
|
|
1040
|
+
// where "ask" decisions go when nobody is in Chat, and whether the
|
|
1041
|
+
// agent answers replies there (web/notifier-admin.ts behind
|
|
1042
|
+
// GET/PUT/DELETE .../notifier), plus recent deliveries. Reloaded every
|
|
1043
|
+
// time the tab opens, so the waiting list is current. ----
|
|
971
1044
|
|
|
972
1045
|
var lastNotifier = null;
|
|
973
1046
|
var NOTIFIER_EVENTS = [
|
|
@@ -1006,6 +1079,19 @@ ${consoleShellScript}
|
|
|
1006
1079
|
return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
|
|
1007
1080
|
}
|
|
1008
1081
|
|
|
1082
|
+
// Slack only: whether replies in a request's thread are answered. On
|
|
1083
|
+
// unless turned off; locked on LoopEngine Cloud's free plan.
|
|
1084
|
+
function renderConversationsField(n, f) {
|
|
1085
|
+
var free = n.plan === 'free';
|
|
1086
|
+
var on = !free && f.conversations !== false;
|
|
1087
|
+
return '<div class="body-field" style="margin:4px 0 0">' +
|
|
1088
|
+
'<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="conversations" id="slackConversations"' + (on ? ' checked' : '') + (free ? ' disabled' : '') + '> ' +
|
|
1089
|
+
'Answer replies in a request's thread' + (free ? ' <span class="le-pill accent">Pro</span>' : '') + '</label>' +
|
|
1090
|
+
'<p class="hint" style="margin:2px 0 0">' + (free
|
|
1091
|
+
? 'Talking a request through in Slack needs LoopEngine Pro. Approve and Deny buttons work on every plan.'
|
|
1092
|
+
: 'People can ask the agent about a request in its thread, and it answers there. It can't approve or change anything from the thread yet: deciding still uses the buttons.') + '</p></div>';
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1009
1095
|
function renderNotifierForm(n) {
|
|
1010
1096
|
var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
|
|
1011
1097
|
var events = f.events || [];
|
|
@@ -1046,6 +1132,7 @@ ${consoleShellScript}
|
|
|
1046
1132
|
(n.slack && n.slack.signingSecretSet ? 'set — Approve/Deny buttons are on' : 'adds Approve/Deny buttons inside Slack; without it, messages carry the approval link') + ')</span>' +
|
|
1047
1133
|
'<input type="password" name="signingSecret" autocomplete="off"></label>' +
|
|
1048
1134
|
'<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>' +
|
|
1135
|
+
renderConversationsField(n, f) +
|
|
1049
1136
|
'<p class="hint" style="margin:0" id="slackEventsNote"></p>') +
|
|
1050
1137
|
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1051
1138
|
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
@@ -1091,32 +1178,29 @@ ${consoleShellScript}
|
|
|
1091
1178
|
if (tools.length && n && n.source === 'none') {
|
|
1092
1179
|
html += '<div class="approvals-nudge"><strong>Approval requests aren't sent anywhere yet.</strong> ' +
|
|
1093
1180
|
escapeHtml(tools.slice(0, 3).join(', ')) + (tools.length > 3 ? ' and more' : '') + ' can stop for approval, but when an app calls this agent over HTTP nobody is notified. ' +
|
|
1094
|
-
'
|
|
1181
|
+
'Choose where they go below.</div>';
|
|
1095
1182
|
}
|
|
1096
1183
|
if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
|
|
1097
1184
|
return html;
|
|
1098
1185
|
}
|
|
1099
1186
|
|
|
1100
1187
|
function renderApprovalsHtml(n) {
|
|
1101
|
-
return '<
|
|
1102
|
-
'<
|
|
1188
|
+
return '<p class="muted" style="margin:0 0 20px">Connect a messaging app, so this agent can reach people when nobody is in Chat. When an app calls it over HTTP or a scheduled task runs, and a tool call needs approval or the agent asks a question, the request is sent here. In Chat it shows up in the conversation instead.</p>' +
|
|
1189
|
+
'<section id="approvalsSetup"><h3>Connection</h3>' +
|
|
1103
1190
|
'<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
|
|
1104
1191
|
(n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
|
|
1105
1192
|
(n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
|
|
1193
|
+
'<section><h3>Deciding in Slack <span class="le-pill neutral">coming soon</span></h3>' +
|
|
1194
|
+
'<ul class="muted" style="margin:0;padding-left:18px">' +
|
|
1195
|
+
'<li><strong>Approvers</strong>: link Slack people to LoopEngine users, so they can approve, deny or change a request by replying in its thread.</li>' +
|
|
1196
|
+
'<li><strong>Confirm-required tools</strong>: tools, such as ones that move money or delete data, that always need a Confirm click, never just a typed yes.</li>' +
|
|
1197
|
+
'</ul></section>' +
|
|
1106
1198
|
'<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
|
|
1107
1199
|
}
|
|
1108
1200
|
|
|
1109
1201
|
function wirePendingHandlers(name) {
|
|
1110
1202
|
var content = detail.querySelector('#pendingContent');
|
|
1111
1203
|
if (!content) return;
|
|
1112
|
-
var nudgeLink = content.querySelector('.approvals-nudge-link');
|
|
1113
|
-
if (nudgeLink) {
|
|
1114
|
-
nudgeLink.addEventListener('click', function (ev) {
|
|
1115
|
-
ev.preventDefault();
|
|
1116
|
-
var target = detail.querySelector('#approvalsSetup');
|
|
1117
|
-
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
1204
|
var copyBtns = content.querySelectorAll('.pending-copy');
|
|
1121
1205
|
for (var c = 0; c < copyBtns.length; c++) {
|
|
1122
1206
|
copyBtns[c].addEventListener('click', function (ev) {
|
|
@@ -1231,6 +1315,10 @@ ${consoleShellScript}
|
|
|
1231
1315
|
var secret = data.get('secret_' + channel);
|
|
1232
1316
|
if (secret) body.secret = secret;
|
|
1233
1317
|
if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
|
|
1318
|
+
var conversationsBox = form.querySelector('#slackConversations');
|
|
1319
|
+
// A locked switch (free plan) keeps whatever was saved before.
|
|
1320
|
+
if (channel === 'slack' && conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
|
|
1321
|
+
else if (channel === 'slack' && lastNotifier && lastNotifier.file && lastNotifier.file.conversations !== undefined) body.conversations = lastNotifier.file.conversations;
|
|
1234
1322
|
result.textContent = 'Saving…';
|
|
1235
1323
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
|
|
1236
1324
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
@@ -1268,10 +1356,12 @@ ${consoleShellScript}
|
|
|
1268
1356
|
content.innerHTML = renderApprovalsHtml(results[0].body);
|
|
1269
1357
|
wireApprovalsHandlers(name);
|
|
1270
1358
|
var pendingEl = detail.querySelector('#pendingContent');
|
|
1359
|
+
var pending = (results[1].ok && results[1].body.pending) || [];
|
|
1271
1360
|
if (pendingEl) {
|
|
1272
|
-
pendingEl.innerHTML = renderPendingHtml(
|
|
1361
|
+
pendingEl.innerHTML = renderPendingHtml(pending, results[0].body);
|
|
1273
1362
|
wirePendingHandlers(name);
|
|
1274
1363
|
}
|
|
1364
|
+
showPendingCount(pending.length);
|
|
1275
1365
|
if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
|
|
1276
1366
|
}).catch(function (err) {
|
|
1277
1367
|
if (currentName !== name) return;
|
|
@@ -1326,7 +1416,7 @@ ${consoleShellScript}
|
|
|
1326
1416
|
'<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
|
|
1327
1417
|
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
|
|
1328
1418
|
'<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
|
|
1329
|
-
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in
|
|
1419
|
+
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
|
|
1330
1420
|
'<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
|
|
1331
1421
|
'<td><div style="display:flex;gap:6px;flex-wrap:wrap">' + actions.map(function (a) {
|
|
1332
1422
|
return '<button type="button" class="schedule-action' + (a[0] === 'run' ? ' primary' : '') + (a[0] === 'cancel' || a[0] === 'delete' ? ' delete-btn' : '') + '" data-id="' + escapeHtml(t.task_id) + '" data-action="' + a[0] + '">' + a[1] + '</button>';
|
|
@@ -1338,7 +1428,7 @@ ${consoleShellScript}
|
|
|
1338
1428
|
var list = tasks.length
|
|
1339
1429
|
? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
|
|
1340
1430
|
: '<p class="hint">Nothing scheduled yet.</p>';
|
|
1341
|
-
return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the
|
|
1431
|
+
return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Messaging tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (“remind me tomorrow at 9”); they appear here too.</p>' +
|
|
1342
1432
|
'<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
|
|
1343
1433
|
'<section><h3>New schedule</h3>' +
|
|
1344
1434
|
'<form class="add-source" id="scheduleForm" style="max-width:560px">' +
|
|
@@ -1351,7 +1441,7 @@ ${consoleShellScript}
|
|
|
1351
1441
|
'<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
|
|
1352
1442
|
'</div>' +
|
|
1353
1443
|
'<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
|
|
1354
|
-
'<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent's
|
|
1444
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent's messaging connection (set in the Messaging tab)</label>' +
|
|
1355
1445
|
'<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
|
|
1356
1446
|
'<div id="scheduleResult" class="hint"></div>' +
|
|
1357
1447
|
'</form></section>';
|
|
@@ -1379,7 +1469,7 @@ ${consoleShellScript}
|
|
|
1379
1469
|
}
|
|
1380
1470
|
var toApprovals = content.querySelectorAll('.schedule-to-approvals');
|
|
1381
1471
|
for (var j = 0; j < toApprovals.length; j++) {
|
|
1382
|
-
toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('
|
|
1472
|
+
toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('messaging'); });
|
|
1383
1473
|
}
|
|
1384
1474
|
var form = content.querySelector('#scheduleForm');
|
|
1385
1475
|
var result = content.querySelector('#scheduleResult');
|
|
@@ -2898,7 +2988,7 @@ ${consoleShellScript}
|
|
|
2898
2988
|
|
|
2899
2989
|
// ---- Tab switching + agent selection ----
|
|
2900
2990
|
|
|
2901
|
-
var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'schedules', 'env'];
|
|
2991
|
+
var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'messaging', 'schedules', 'env'];
|
|
2902
2992
|
|
|
2903
2993
|
function switchTab(tab) {
|
|
2904
2994
|
if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
|
|
@@ -2919,7 +3009,8 @@ ${consoleShellScript}
|
|
|
2919
3009
|
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
2920
3010
|
loadActauthTab(currentName);
|
|
2921
3011
|
}
|
|
2922
|
-
if (tab === '
|
|
3012
|
+
if (tab === 'messaging') loadApprovalsSection(currentName);
|
|
3013
|
+
if (tab === 'actauth') refreshPendingCount(currentName);
|
|
2923
3014
|
if (tab === 'env' && envLoadedFor !== currentName) {
|
|
2924
3015
|
loadEnvTab(currentName);
|
|
2925
3016
|
}
|
|
@@ -2948,6 +3039,7 @@ ${consoleShellScript}
|
|
|
2948
3039
|
var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
|
|
2949
3040
|
if (overviewPanel) {
|
|
2950
3041
|
overviewPanel.innerHTML = renderOverviewHtml(cfg);
|
|
3042
|
+
fillOverviewCards(currentName);
|
|
2951
3043
|
wireOverviewHandlers(name);
|
|
2952
3044
|
}
|
|
2953
3045
|
})
|
|
@@ -2991,6 +3083,7 @@ ${consoleShellScript}
|
|
|
2991
3083
|
var skillsPanel = skillsPanelEl();
|
|
2992
3084
|
if (overviewPanel) {
|
|
2993
3085
|
overviewPanel.innerHTML = renderOverviewHtml(cfg);
|
|
3086
|
+
fillOverviewCards(currentName);
|
|
2994
3087
|
wireOverviewHandlers(name);
|
|
2995
3088
|
}
|
|
2996
3089
|
if (skillsPanel) {
|
|
@@ -3021,6 +3114,7 @@ ${consoleShellScript}
|
|
|
3021
3114
|
'<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
|
|
3022
3115
|
'<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
|
|
3023
3116
|
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
|
|
3117
|
+
'<div class="tab-panel" data-tab-panel="messaging">' + renderMessagingTabPlaceholder() + '</div>' +
|
|
3024
3118
|
'<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
|
|
3025
3119
|
'<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>' +
|
|
3026
3120
|
'<div class="tab-panel" data-tab-panel="schedules">' + renderSchedulesTabPlaceholder() + '</div>' +
|
|
@@ -3037,6 +3131,7 @@ ${consoleShellScript}
|
|
|
3037
3131
|
empty.style.display = 'none';
|
|
3038
3132
|
detail.style.display = 'flex';
|
|
3039
3133
|
switchTab(currentTab);
|
|
3134
|
+
fillOverviewCards(cfg.name);
|
|
3040
3135
|
}
|
|
3041
3136
|
|
|
3042
3137
|
function selectAgent(name) {
|
|
@@ -43,8 +43,8 @@ export const consoleShellScript = `<script>
|
|
|
43
43
|
// after the list loads.
|
|
44
44
|
var toSet = {};
|
|
45
45
|
var TABS = [
|
|
46
|
-
['
|
|
47
|
-
['skills', 'Skills'], ['actauth', 'Permissions'], ['schedules', 'Schedules'], ['env', 'Environment']
|
|
46
|
+
['overview', 'Overview'], ['chat', 'Chat'], ['abilities', 'Abilities'], ['tools', 'Tools'],
|
|
47
|
+
['skills', 'Skills'], ['actauth', 'Permissions'], ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment']
|
|
48
48
|
];
|
|
49
49
|
|
|
50
50
|
function esc(s) {
|
package/dist/web/decide-page.js
CHANGED
|
@@ -53,7 +53,7 @@ ${script}
|
|
|
53
53
|
}
|
|
54
54
|
/** A link that's malformed, tampered with, or past its expiry. */
|
|
55
55
|
export function renderDecideInvalidPage() {
|
|
56
|
-
return shell('Link not valid', '<h1>This link isn't valid</h1><p class="muted">It may have expired or been copied incompletely. Ask for a new one, or decide from the agent's
|
|
56
|
+
return shell('Link not valid', '<h1>This link isn't valid</h1><p class="muted">It may have expired or been copied incompletely. Ask for a new one, or decide from the agent's Messaging tab.</p>');
|
|
57
57
|
}
|
|
58
58
|
/** A valid link whose decision has already been made. */
|
|
59
59
|
export function renderDecideGonePage() {
|
|
@@ -314,13 +314,19 @@ ${consoleShellScript}
|
|
|
314
314
|
|
|
315
315
|
function upgradeBox() { return document.getElementById('upgradeBox'); }
|
|
316
316
|
|
|
317
|
+
// When npm was last asked, and a link to ask again now.
|
|
318
|
+
function upgradeCheckedLine(u) {
|
|
319
|
+
var when = u.checkedAt ? 'Checked at ' + escapeHtml(new Date(u.checkedAt).toLocaleTimeString()) + '. ' : '';
|
|
320
|
+
return '<p class="hint" style="margin:6px 0 0">' + when + '<a href="#" id="upgradeRecheck">Check again</a></p>';
|
|
321
|
+
}
|
|
322
|
+
|
|
317
323
|
function renderUpgrade(u) {
|
|
318
324
|
var box = upgradeBox();
|
|
319
325
|
if (!box) return;
|
|
320
326
|
if (u.unavailableReason && !u.updateAvailable) {
|
|
321
|
-
box.innerHTML = '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
|
|
327
|
+
box.innerHTML = '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>' + (u.templateVersion ? upgradeCheckedLine(u) : '');
|
|
322
328
|
} else if (!u.updateAvailable) {
|
|
323
|
-
box.innerHTML = '<p>You' + String.fromCharCode(8217) + 're on the latest version, <code>' + escapeHtml(u.current) + '</code>.</p>';
|
|
329
|
+
box.innerHTML = '<p>You' + String.fromCharCode(8217) + 're on the latest version, <code>' + escapeHtml(u.current) + '</code>.</p>' + upgradeCheckedLine(u);
|
|
324
330
|
} else if (u.unavailableReason) {
|
|
325
331
|
box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p><p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
|
|
326
332
|
} else if (!u.canUpgrade) {
|
|
@@ -333,8 +339,8 @@ ${consoleShellScript}
|
|
|
333
339
|
}
|
|
334
340
|
}
|
|
335
341
|
|
|
336
|
-
function loadUpgrade() {
|
|
337
|
-
fetch('/config/upgrade')
|
|
342
|
+
function loadUpgrade(refresh) {
|
|
343
|
+
fetch('/config/upgrade' + (refresh ? '?refresh=1' : ''), { cache: 'no-store' })
|
|
338
344
|
.then(function (r) { return r.json(); })
|
|
339
345
|
.then(renderUpgrade)
|
|
340
346
|
.catch(function (err) {
|
|
@@ -366,6 +372,14 @@ ${consoleShellScript}
|
|
|
366
372
|
}
|
|
367
373
|
|
|
368
374
|
document.getElementById('settingsContent').addEventListener('click', function (ev) {
|
|
375
|
+
var recheck = ev.target.closest('#upgradeRecheck');
|
|
376
|
+
if (recheck) {
|
|
377
|
+
ev.preventDefault();
|
|
378
|
+
var box = upgradeBox();
|
|
379
|
+
if (box) box.innerHTML = '<p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p>';
|
|
380
|
+
loadUpgrade(true);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
369
383
|
var btn = ev.target.closest('#upgradeBtn');
|
|
370
384
|
if (!btn) return;
|
|
371
385
|
if (btn.getAttribute('data-armed') !== '1') {
|
|
@@ -15,6 +15,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
15
15
|
source: string;
|
|
16
16
|
defaultWebhookUrl: string | null;
|
|
17
17
|
publicUrl: string | null;
|
|
18
|
+
plan: string | null;
|
|
18
19
|
secretVars: Record<NotifierFileChannel, string>;
|
|
19
20
|
secretsSet: {
|
|
20
21
|
[k: string]: boolean;
|
|
@@ -23,6 +24,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
23
24
|
signingSecretVar: string;
|
|
24
25
|
signingSecretSet: boolean;
|
|
25
26
|
interactivityPath: string;
|
|
27
|
+
eventsPath: string;
|
|
26
28
|
manifestTemplate: Record<string, unknown>;
|
|
27
29
|
};
|
|
28
30
|
deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
|
|
@@ -32,7 +34,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
32
34
|
missingSecret?: string;
|
|
33
35
|
error?: string;
|
|
34
36
|
};
|
|
35
|
-
/** Writes agents/<name>/
|
|
37
|
+
/** Writes agents/<name>/messaging.yml from the console form and, when the
|
|
36
38
|
* form includes one, the channel's secret into the agent's own env.
|
|
37
39
|
* Throws NotifierFileError for an invalid form. */
|
|
38
40
|
export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
|
|
@@ -44,10 +46,14 @@ export declare function slackCredentialsFor(config: AgentConfig): {
|
|
|
44
46
|
botToken?: string;
|
|
45
47
|
signingSecret?: string;
|
|
46
48
|
};
|
|
49
|
+
/** Whether replies in a Slack request's thread are answered: on unless
|
|
50
|
+
* the Messaging tab turned Conversations off. An agent whose Slack
|
|
51
|
+
* notifier is set in code always answers. */
|
|
52
|
+
export declare function slackConversationsOn(config: AgentConfig): boolean;
|
|
47
53
|
/** Channels for the console's picker, with the token being typed in or,
|
|
48
54
|
* when none is given, the one already saved. */
|
|
49
55
|
export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
|
|
50
|
-
/** Removes
|
|
56
|
+
/** Removes the messaging settings; the stored secret is left alone, so turning the
|
|
51
57
|
* notifier back on doesn't need it re-entered. */
|
|
52
58
|
export declare function removeAgentNotifier(config: AgentConfig): void;
|
|
53
59
|
/** Posts a plain announcement through the agent's notifier — a scheduled
|
|
@@ -19,6 +19,9 @@ export function describeAgentNotifier(config, defaultWebhook) {
|
|
|
19
19
|
source,
|
|
20
20
|
defaultWebhookUrl: defaultWebhook?.webhookUrl ?? null,
|
|
21
21
|
publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
|
|
22
|
+
// LOOPENGINE_PLAN ('free' on LoopEngine Cloud's free plan): the
|
|
23
|
+
// console locks Conversations on free. null when self-hosted.
|
|
24
|
+
plan: process.env.LOOPENGINE_PLAN ?? null,
|
|
22
25
|
secretVars: NOTIFIER_SECRET_VARS,
|
|
23
26
|
secretsSet,
|
|
24
27
|
slack: {
|
|
@@ -26,6 +29,8 @@ export function describeAgentNotifier(config, defaultWebhook) {
|
|
|
26
29
|
signingSecretSet: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) !== undefined,
|
|
27
30
|
// Where the Slack app's Interactivity Request URL should point.
|
|
28
31
|
interactivityPath: `/hooks/slack/${encodeURIComponent(config.name)}`,
|
|
32
|
+
// Where replies in a request's thread arrive (core/messaging/).
|
|
33
|
+
eventsPath: `/hooks/slack/${encodeURIComponent(config.name)}/events`,
|
|
29
34
|
// The console swaps __INTERACTIVITY_URL__ for the link address +
|
|
30
35
|
// interactivityPath as you type.
|
|
31
36
|
manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
|
|
@@ -33,7 +38,7 @@ export function describeAgentNotifier(config, defaultWebhook) {
|
|
|
33
38
|
deliveries: listDeliveries(config.name),
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
|
-
/** Writes agents/<name>/
|
|
41
|
+
/** Writes agents/<name>/messaging.yml from the console form and, when the
|
|
37
42
|
* form includes one, the channel's secret into the agent's own env.
|
|
38
43
|
* Throws NotifierFileError for an invalid form. */
|
|
39
44
|
export async function saveAgentNotifier(config, body) {
|
|
@@ -54,6 +59,19 @@ export function slackCredentialsFor(config) {
|
|
|
54
59
|
const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
|
|
55
60
|
return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
|
|
56
61
|
}
|
|
62
|
+
/** Whether replies in a Slack request's thread are answered: on unless
|
|
63
|
+
* the Messaging tab turned Conversations off. An agent whose Slack
|
|
64
|
+
* notifier is set in code always answers. */
|
|
65
|
+
export function slackConversationsOn(config) {
|
|
66
|
+
if (config.httpNotifier)
|
|
67
|
+
return config.httpNotifier.channel === 'slack';
|
|
68
|
+
try {
|
|
69
|
+
return readNotifierFile(agentDirFor(config))?.conversations !== false;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
/** Channels for the console's picker, with the token being typed in or,
|
|
58
76
|
* when none is given, the one already saved. */
|
|
59
77
|
export async function listAgentSlackChannels(config, botToken) {
|
|
@@ -62,13 +80,13 @@ export async function listAgentSlackChannels(config, botToken) {
|
|
|
62
80
|
throw new Error('Enter the bot token first');
|
|
63
81
|
return listSlackChannels(token);
|
|
64
82
|
}
|
|
65
|
-
/** Removes
|
|
83
|
+
/** Removes the messaging settings; the stored secret is left alone, so turning the
|
|
66
84
|
* notifier back on doesn't need it re-entered. */
|
|
67
85
|
export function removeAgentNotifier(config) {
|
|
68
86
|
removeNotifierFile(agentDirFor(config));
|
|
69
87
|
}
|
|
70
88
|
/** The notifier settings that apply to an agent: its code httpNotifier,
|
|
71
|
-
* else its
|
|
89
|
+
* else its messaging.yml (once its secret is set), else the
|
|
72
90
|
* deployment-wide default webhook. */
|
|
73
91
|
function activeNotifierTarget(config, defaultWebhook) {
|
|
74
92
|
if (config.httpNotifier)
|
|
@@ -4,6 +4,8 @@ export interface UpgradeStatus {
|
|
|
4
4
|
current: string;
|
|
5
5
|
/** Latest loopengine on npm; null when npm couldn't be reached. */
|
|
6
6
|
latest: string | null;
|
|
7
|
+
/** When npm was last asked for `latest`. */
|
|
8
|
+
checkedAt: string | null;
|
|
7
9
|
updateAvailable: boolean;
|
|
8
10
|
/** The create-loopengine version this project was scaffolded or last
|
|
9
11
|
* upgraded from (.create-loopengine.json); null when the project
|
|
@@ -29,7 +31,10 @@ export interface UpgradeOutcome {
|
|
|
29
31
|
type Runner = (cmd: string, args: string[], cwd: string, timeoutMs: number) => Promise<string>;
|
|
30
32
|
/** Tests replace npm/npx with a fake. */
|
|
31
33
|
export declare function setUpgradeRunner(next: Runner | undefined): void;
|
|
32
|
-
|
|
34
|
+
/** `refresh`: ask npm again even if it was asked a moment ago. */
|
|
35
|
+
export declare function describeUpgrade(projectDir?: string, options?: {
|
|
36
|
+
refresh?: boolean;
|
|
37
|
+
}): Promise<UpgradeStatus>;
|
|
33
38
|
/**
|
|
34
39
|
* Runs the upgrade. `busy` is checked first: upgrading under a running
|
|
35
40
|
* chat turn or scheduled run would restart the server mid-reply. Resolves
|
|
@@ -40,6 +40,8 @@ export function setUpgradeRunner(next) {
|
|
|
40
40
|
run = next ?? realRun;
|
|
41
41
|
latestCache = undefined;
|
|
42
42
|
}
|
|
43
|
+
// A minute, so reloading Settings doesn't ask npm every time while a
|
|
44
|
+
// release that just came out shows up soon (the page's Check again skips it).
|
|
43
45
|
let latestCache;
|
|
44
46
|
async function latestLoopengine(projectDir) {
|
|
45
47
|
if (latestCache && latestCache.until > Date.now())
|
|
@@ -51,7 +53,7 @@ async function latestLoopengine(projectDir) {
|
|
|
51
53
|
catch {
|
|
52
54
|
value = null;
|
|
53
55
|
}
|
|
54
|
-
latestCache = { value, until: Date.now() +
|
|
56
|
+
latestCache = { value, until: Date.now() + 60_000, at: new Date().toISOString() };
|
|
55
57
|
return value;
|
|
56
58
|
}
|
|
57
59
|
function templateVersion(projectDir) {
|
|
@@ -66,13 +68,17 @@ function templateVersion(projectDir) {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
let running = false;
|
|
69
|
-
|
|
71
|
+
/** `refresh`: ask npm again even if it was asked a moment ago. */
|
|
72
|
+
export async function describeUpgrade(projectDir = process.cwd(), options = {}) {
|
|
73
|
+
if (options.refresh)
|
|
74
|
+
latestCache = undefined;
|
|
70
75
|
const current = loopengineVersion();
|
|
71
76
|
const latest = await latestLoopengine(projectDir);
|
|
72
77
|
const template = templateVersion(projectDir);
|
|
73
78
|
const status = {
|
|
74
79
|
current,
|
|
75
80
|
latest,
|
|
81
|
+
checkedAt: latestCache?.at ?? null,
|
|
76
82
|
updateAvailable: Boolean(latest && semver.valid(current) && semver.gt(latest, current)),
|
|
77
83
|
templateVersion: template,
|
|
78
84
|
running,
|