edsger 0.65.0 → 0.67.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.
- package/dist/api/chat.d.ts +2 -1
- package/dist/api/chat.js +2 -1
- package/dist/commands/agent-workflow/processor.d.ts +7 -5
- package/dist/commands/agent-workflow/processor.js +17 -101
- package/dist/commands/chat-serve/chat-server.d.ts +114 -0
- package/dist/commands/chat-serve/chat-server.js +339 -0
- package/dist/commands/chat-serve/index.d.ts +20 -0
- package/dist/commands/chat-serve/index.js +58 -0
- package/dist/commands/session-serve/index.d.ts +32 -0
- package/dist/commands/session-serve/index.js +100 -0
- package/dist/commands/session-turn/index.d.ts +40 -0
- package/dist/commands/session-turn/index.js +98 -59
- package/dist/index.js +46 -0
- package/dist/phases/sync-org-repos/index.d.ts +9 -6
- package/dist/phases/sync-org-repos/index.js +22 -34
- package/dist/types/index.d.ts +0 -31
- package/package.json +2 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger chat-serve` — long-lived daemon that answers human messages on issue
|
|
3
|
+
* and product group chat channels.
|
|
4
|
+
*
|
|
5
|
+
* This is the standalone replacement for the old `chat-worker` that
|
|
6
|
+
* `AgentWorkflowProcessor` used to fork. Running it as its own top-level
|
|
7
|
+
* process (spawned by the desktop app, sibling to `edsger --verbose` and
|
|
8
|
+
* `edsger session-serve`) decouples chat from the autonomous workflow: chat
|
|
9
|
+
* works whether or not the workflow is running, and the two never both claim
|
|
10
|
+
* the same messages.
|
|
11
|
+
*
|
|
12
|
+
* Like the agent workflow, it requires `edsger login` first and exits promptly
|
|
13
|
+
* if no auth is found. The actual chat-serving logic lives in {@link ChatServer}.
|
|
14
|
+
*/
|
|
15
|
+
export interface ChatServeCliOptions {
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
/** Optional path to an edsger config file (forwarded to validateConfiguration). */
|
|
18
|
+
config?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function runChatServeCommand(options?: ChatServeCliOptions): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger chat-serve` — long-lived daemon that answers human messages on issue
|
|
3
|
+
* and product group chat channels.
|
|
4
|
+
*
|
|
5
|
+
* This is the standalone replacement for the old `chat-worker` that
|
|
6
|
+
* `AgentWorkflowProcessor` used to fork. Running it as its own top-level
|
|
7
|
+
* process (spawned by the desktop app, sibling to `edsger --verbose` and
|
|
8
|
+
* `edsger session-serve`) decouples chat from the autonomous workflow: chat
|
|
9
|
+
* works whether or not the workflow is running, and the two never both claim
|
|
10
|
+
* the same messages.
|
|
11
|
+
*
|
|
12
|
+
* Like the agent workflow, it requires `edsger login` first and exits promptly
|
|
13
|
+
* if no auth is found. The actual chat-serving logic lives in {@link ChatServer}.
|
|
14
|
+
*/
|
|
15
|
+
import { isLoggedIn } from '../../auth/auth-store.js';
|
|
16
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
17
|
+
import { validateConfiguration } from '../../utils/validation.js';
|
|
18
|
+
import { ensureWorkspaceDir } from '../../workspace/workspace-manager.js';
|
|
19
|
+
import { ChatServer } from './chat-server.js';
|
|
20
|
+
export async function runChatServeCommand(options = {}) {
|
|
21
|
+
// Mirror the agent workflow: auth is required, and we exit fast when absent
|
|
22
|
+
// so the desktop supervisor can surface a "run `edsger login`" hint.
|
|
23
|
+
if (!isLoggedIn()) {
|
|
24
|
+
logError('Not authenticated. Please run `edsger login` first.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const cliOptions = {
|
|
28
|
+
verbose: options.verbose,
|
|
29
|
+
config: options.config,
|
|
30
|
+
};
|
|
31
|
+
const config = validateConfiguration(cliOptions);
|
|
32
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
33
|
+
logInfo(`Workspace: ${workspaceRoot}`);
|
|
34
|
+
const server = new ChatServer({
|
|
35
|
+
config,
|
|
36
|
+
workspaceRoot,
|
|
37
|
+
verbose: options.verbose,
|
|
38
|
+
});
|
|
39
|
+
await server.start();
|
|
40
|
+
let shuttingDown = false;
|
|
41
|
+
const shutdown = async (signal) => {
|
|
42
|
+
if (shuttingDown) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
shuttingDown = true;
|
|
46
|
+
logInfo(`Received ${signal} — shutting down chat server.`);
|
|
47
|
+
await server.stop();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
};
|
|
50
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
51
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
52
|
+
process.on('uncaughtException', (error) => {
|
|
53
|
+
logError(`Uncaught exception: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
void server.stop().finally(() => process.exit(1));
|
|
55
|
+
});
|
|
56
|
+
// The Realtime socket / poll timer keep the event loop alive; nothing else
|
|
57
|
+
// to do on the main path. The process lives until a signal arrives.
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger session-serve <channelId>` — long-lived conversational agent for one
|
|
3
|
+
* chat session.
|
|
4
|
+
*
|
|
5
|
+
* Where `session-turn` runs a single turn and exits, `session-serve` prepares
|
|
6
|
+
* the session context once (repo clone, MCP toolbelt, system prompt) and then
|
|
7
|
+
* stays alive, running one turn per command it reads from stdin. The SDK
|
|
8
|
+
* session id is kept in memory and carried into the next turn automatically, so
|
|
9
|
+
* the conversation (and prompt cache) stays warm without a per-turn cold start.
|
|
10
|
+
*
|
|
11
|
+
* Protocol — one JSON object per line on stdin:
|
|
12
|
+
* {"type":"turn"} process pending messages
|
|
13
|
+
* {"type":"run-finished","runId":"…","command":"…"} report a finished cli_* run
|
|
14
|
+
* {"type":"shutdown"} drain + exit cleanly
|
|
15
|
+
*
|
|
16
|
+
* Turns run strictly one at a time (a session never has two turns in flight).
|
|
17
|
+
* When stdin closes, the process exits after the current turn drains. Markers
|
|
18
|
+
* (session id, background cli runs) are written to stdout exactly as the
|
|
19
|
+
* one-shot command emits them, so the desktop's existing parsing is unchanged.
|
|
20
|
+
*/
|
|
21
|
+
import { type SessionTurnCliOptions } from '../session-turn/index.js';
|
|
22
|
+
/**
|
|
23
|
+
* Emitted once the session context is built and the process is ready to accept
|
|
24
|
+
* turn commands on stdin. The desktop may use it to know the daemon is live;
|
|
25
|
+
* it is harmless to ignore.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SESSION_READY_MARKER = "__EDSGER_SESSION_READY__";
|
|
28
|
+
export type SessionServeCliOptions = Omit<SessionTurnCliOptions, 'runFinished'> & {
|
|
29
|
+
/** SDK session id to resume on the first turn (e.g. after a crash-restart). */
|
|
30
|
+
resumeSessionId?: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function runSessionServeCommand(options: SessionServeCliOptions): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edsger session-serve <channelId>` — long-lived conversational agent for one
|
|
3
|
+
* chat session.
|
|
4
|
+
*
|
|
5
|
+
* Where `session-turn` runs a single turn and exits, `session-serve` prepares
|
|
6
|
+
* the session context once (repo clone, MCP toolbelt, system prompt) and then
|
|
7
|
+
* stays alive, running one turn per command it reads from stdin. The SDK
|
|
8
|
+
* session id is kept in memory and carried into the next turn automatically, so
|
|
9
|
+
* the conversation (and prompt cache) stays warm without a per-turn cold start.
|
|
10
|
+
*
|
|
11
|
+
* Protocol — one JSON object per line on stdin:
|
|
12
|
+
* {"type":"turn"} process pending messages
|
|
13
|
+
* {"type":"run-finished","runId":"…","command":"…"} report a finished cli_* run
|
|
14
|
+
* {"type":"shutdown"} drain + exit cleanly
|
|
15
|
+
*
|
|
16
|
+
* Turns run strictly one at a time (a session never has two turns in flight).
|
|
17
|
+
* When stdin closes, the process exits after the current turn drains. Markers
|
|
18
|
+
* (session id, background cli runs) are written to stdout exactly as the
|
|
19
|
+
* one-shot command emits them, so the desktop's existing parsing is unchanged.
|
|
20
|
+
*/
|
|
21
|
+
import { createInterface } from 'node:readline';
|
|
22
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
23
|
+
import { prepareSessionContext, runTurn, } from '../session-turn/index.js';
|
|
24
|
+
/**
|
|
25
|
+
* Emitted once the session context is built and the process is ready to accept
|
|
26
|
+
* turn commands on stdin. The desktop may use it to know the daemon is live;
|
|
27
|
+
* it is harmless to ignore.
|
|
28
|
+
*/
|
|
29
|
+
export const SESSION_READY_MARKER = '__EDSGER_SESSION_READY__';
|
|
30
|
+
export async function runSessionServeCommand(options) {
|
|
31
|
+
const ctx = await prepareSessionContext(options);
|
|
32
|
+
process.stdout.write(`\n${SESSION_READY_MARKER}\n`);
|
|
33
|
+
logInfo(`Session ${ctx.channelId} ready — awaiting turns on stdin.`);
|
|
34
|
+
// Commands queue up here; we drain them one at a time so a session never has
|
|
35
|
+
// two turns running concurrently. `shuttingDown` stops accepting new work.
|
|
36
|
+
const queue = [];
|
|
37
|
+
let running = false;
|
|
38
|
+
let shuttingDown = false;
|
|
39
|
+
let stdinEnded = false;
|
|
40
|
+
const maybeExit = () => {
|
|
41
|
+
if ((shuttingDown || stdinEnded) && !running && queue.length === 0) {
|
|
42
|
+
logInfo(`Session ${ctx.channelId} shutting down.`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const drain = async () => {
|
|
47
|
+
if (running) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
running = true;
|
|
51
|
+
try {
|
|
52
|
+
while (queue.length > 0) {
|
|
53
|
+
const cmd = queue.shift();
|
|
54
|
+
if (cmd.type === 'shutdown') {
|
|
55
|
+
shuttingDown = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
if (cmd.type === 'run-finished' && cmd.runId) {
|
|
60
|
+
await runTurn(ctx, {
|
|
61
|
+
runFinished: { runId: cmd.runId, command: cmd.command },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await runTurn(ctx);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logError(`Turn failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
running = false;
|
|
75
|
+
maybeExit();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const rl = createInterface({ input: process.stdin });
|
|
79
|
+
rl.on('line', (line) => {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed || shuttingDown) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let cmd;
|
|
85
|
+
try {
|
|
86
|
+
cmd = JSON.parse(trimmed);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
logError(`Ignoring malformed command: ${trimmed}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
queue.push(cmd);
|
|
93
|
+
void drain();
|
|
94
|
+
});
|
|
95
|
+
// stdin closing is the desktop's normal "stop this session" signal.
|
|
96
|
+
rl.on('close', () => {
|
|
97
|
+
stdinEnded = true;
|
|
98
|
+
maybeExit();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -12,6 +12,7 @@ export declare const SESSION_ID_MARKER = "__EDSGER_SESSION_ID__=";
|
|
|
12
12
|
* to ask. The JSON payload is `{ run_id, pid, log_path, command }`.
|
|
13
13
|
*/
|
|
14
14
|
export declare const CLI_RUN_MARKER = "__EDSGER_CLI_RUN__=";
|
|
15
|
+
import { loadExternalMcpServers } from 'edsger-tools';
|
|
15
16
|
export interface SessionTurnCliOptions {
|
|
16
17
|
channelId: string;
|
|
17
18
|
productId: string;
|
|
@@ -30,4 +31,43 @@ export interface SessionTurnCliOptions {
|
|
|
30
31
|
};
|
|
31
32
|
verbose?: boolean;
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Everything a session needs to run turns, built once and reused. The daemon
|
|
36
|
+
* keeps a single instance alive across turns so repo clones, the MCP toolbelt,
|
|
37
|
+
* the system prompt, and the SDK session id all persist.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionContext {
|
|
40
|
+
channelId: string;
|
|
41
|
+
productId: string;
|
|
42
|
+
repositoryIds: string[];
|
|
43
|
+
verbose: boolean;
|
|
44
|
+
sessionDir?: string;
|
|
45
|
+
systemPrompt: string;
|
|
46
|
+
sessionServer: any;
|
|
47
|
+
external: ReturnType<typeof loadExternalMcpServers>;
|
|
48
|
+
/** Latest SDK session id; carried into the next turn's `resume`. Mutable. */
|
|
49
|
+
sdkSessionId: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the one-time session context: clone repos, assemble the MCP toolbelt
|
|
53
|
+
* and external servers, and build the system prompt. Cheap to call once per
|
|
54
|
+
* session process; expensive to call per turn (which is why the daemon doesn't).
|
|
55
|
+
*/
|
|
56
|
+
export declare function prepareSessionContext(options: SessionTurnCliOptions): Promise<SessionContext>;
|
|
57
|
+
/**
|
|
58
|
+
* Run a single agent turn against an already-prepared {@link SessionContext}.
|
|
59
|
+
* Mutates `ctx.sdkSessionId` so the next turn resumes the same SDK conversation
|
|
60
|
+
* (and prompt cache). Safe to call repeatedly for the life of the process.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runTurn(ctx: SessionContext, opts?: {
|
|
63
|
+
runFinished?: {
|
|
64
|
+
runId: string;
|
|
65
|
+
command?: string;
|
|
66
|
+
};
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* One-shot `session-turn`: prepare the context and run a single turn. Retained
|
|
70
|
+
* for callers that want the previous per-turn-process behaviour (and as the
|
|
71
|
+
* building block the daemon reuses).
|
|
72
|
+
*/
|
|
33
73
|
export declare function runSessionTurnCommand(options: SessionTurnCliOptions): Promise<void>;
|
|
@@ -23,6 +23,12 @@ export const CLI_RUN_MARKER = '__EDSGER_CLI_RUN__=';
|
|
|
23
23
|
* to the channel and marks the messages processed. The agent decides what to
|
|
24
24
|
* do (answer, generate stories/test cases, launch cli_* analyses, open PRs);
|
|
25
25
|
* there is no fixed pipeline.
|
|
26
|
+
*
|
|
27
|
+
* The per-turn work is split into {@link prepareSessionContext} (one-time
|
|
28
|
+
* workspace clone + toolbelt + prompt build) and {@link runTurn} (one agent
|
|
29
|
+
* turn against that context). The one-shot `session-turn` command does both
|
|
30
|
+
* once; the long-lived `session-serve` daemon prepares the context once and
|
|
31
|
+
* calls {@link runTurn} repeatedly, keeping the SDK session warm across turns.
|
|
26
32
|
*/
|
|
27
33
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
28
34
|
import { buildSessionAgentOptions, buildSessionSystemPrompt, buildSessionUserPrompt, createSessionMcpServer, listActiveCliRuns, loadExternalMcpServers, SESSION_MAX_TURNS, } from 'edsger-tools';
|
|
@@ -53,54 +59,21 @@ async function prepareSessionWorkspace(opts) {
|
|
|
53
59
|
repoScopeNote: workspace ? describeSessionRepos(workspace.repos) : '',
|
|
54
60
|
};
|
|
55
61
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
// desktop watcher can poll each to completion and re-engage the agent.
|
|
66
|
-
const emitActiveCliRuns = () => {
|
|
67
|
-
for (const run of listActiveCliRuns()) {
|
|
68
|
-
const payload = JSON.stringify({
|
|
69
|
-
run_id: run.run_id,
|
|
70
|
-
pid: run.pid,
|
|
71
|
-
log_path: run.log_path,
|
|
72
|
-
command: run.command,
|
|
73
|
-
});
|
|
74
|
-
process.stdout.write(`\n${CLI_RUN_MARKER}${payload}\n`);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
// 1. Decide what drives this turn. A normal turn replays the channel's
|
|
78
|
-
// pending human messages; a watcher-triggered follow-up replays a
|
|
79
|
-
// synthetic prompt about the background run that just finished.
|
|
80
|
-
let messages = [];
|
|
81
|
-
if (runFinished) {
|
|
82
|
-
logInfo(`Reporting completion of background run ${runFinished.runId} for session ${channelId}`);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
|
|
86
|
-
channel_id: channelId,
|
|
87
|
-
}));
|
|
88
|
-
messages = pendingResult.messages ?? [];
|
|
89
|
-
if (messages.length === 0) {
|
|
90
|
-
logInfo('No pending messages for this session — nothing to do.');
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
|
|
94
|
-
}
|
|
95
|
-
// 2. Clone the product's in-scope repositories into a local session
|
|
96
|
-
// directory so the agent's Read/Grep/Glob can inspect the real code.
|
|
62
|
+
/**
|
|
63
|
+
* Build the one-time session context: clone repos, assemble the MCP toolbelt
|
|
64
|
+
* and external servers, and build the system prompt. Cheap to call once per
|
|
65
|
+
* session process; expensive to call per turn (which is why the daemon doesn't).
|
|
66
|
+
*/
|
|
67
|
+
export async function prepareSessionContext(options) {
|
|
68
|
+
const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
|
|
69
|
+
// Clone the product's in-scope repositories into a local session directory
|
|
70
|
+
// so the agent's Read/Grep/Glob can inspect the real code.
|
|
97
71
|
const { sessionDir, repoScopeNote } = await prepareSessionWorkspace({
|
|
98
72
|
channelId,
|
|
99
73
|
productId,
|
|
100
74
|
repositoryIds,
|
|
101
75
|
verbose,
|
|
102
76
|
});
|
|
103
|
-
// 3. Build the session toolbelt + prompts.
|
|
104
77
|
const deps = getToolDeps({
|
|
105
78
|
verbose,
|
|
106
79
|
context: { productId, channelId, repositoryIds },
|
|
@@ -118,38 +91,95 @@ export async function runSessionTurnCommand(options) {
|
|
|
118
91
|
repoScopeNote,
|
|
119
92
|
externalMcpNames: external.names,
|
|
120
93
|
});
|
|
94
|
+
return {
|
|
95
|
+
channelId,
|
|
96
|
+
productId,
|
|
97
|
+
repositoryIds,
|
|
98
|
+
verbose,
|
|
99
|
+
sessionDir,
|
|
100
|
+
systemPrompt,
|
|
101
|
+
sessionServer,
|
|
102
|
+
external,
|
|
103
|
+
sdkSessionId: resumeSessionId ?? '',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Emit the SDK session id so the desktop can persist it for the next turn. */
|
|
107
|
+
function emitSessionId(id) {
|
|
108
|
+
if (id) {
|
|
109
|
+
process.stdout.write(`\n${SESSION_ID_MARKER}${id}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Emit a marker for every background cli_* run still in flight, so the desktop
|
|
114
|
+
* watcher can poll each to completion and re-engage the agent.
|
|
115
|
+
*/
|
|
116
|
+
function emitActiveCliRuns() {
|
|
117
|
+
for (const run of listActiveCliRuns()) {
|
|
118
|
+
const payload = JSON.stringify({
|
|
119
|
+
run_id: run.run_id,
|
|
120
|
+
pid: run.pid,
|
|
121
|
+
log_path: run.log_path,
|
|
122
|
+
command: run.command,
|
|
123
|
+
});
|
|
124
|
+
process.stdout.write(`\n${CLI_RUN_MARKER}${payload}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run a single agent turn against an already-prepared {@link SessionContext}.
|
|
129
|
+
* Mutates `ctx.sdkSessionId` so the next turn resumes the same SDK conversation
|
|
130
|
+
* (and prompt cache). Safe to call repeatedly for the life of the process.
|
|
131
|
+
*/
|
|
132
|
+
export async function runTurn(ctx, opts = {}) {
|
|
133
|
+
const { runFinished } = opts;
|
|
134
|
+
// 1. Decide what drives this turn. A normal turn replays the channel's
|
|
135
|
+
// pending human messages; a watcher-triggered follow-up replays a
|
|
136
|
+
// synthetic prompt about the background run that just finished.
|
|
137
|
+
let messages = [];
|
|
138
|
+
if (runFinished) {
|
|
139
|
+
logInfo(`Reporting completion of background run ${runFinished.runId} for session ${ctx.channelId}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const pendingResult = (await callMcpEndpoint('chat/messages/pending', {
|
|
143
|
+
channel_id: ctx.channelId,
|
|
144
|
+
}));
|
|
145
|
+
messages = pendingResult.messages ?? [];
|
|
146
|
+
if (messages.length === 0) {
|
|
147
|
+
logInfo('No pending messages for this session — nothing to do.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
logInfo(`Processing ${messages.length} message(s) for session ${ctx.channelId}`);
|
|
151
|
+
}
|
|
121
152
|
const userPrompt = runFinished
|
|
122
153
|
? buildRunFinishedPrompt(runFinished)
|
|
123
154
|
: buildSessionUserPrompt(messages);
|
|
124
|
-
//
|
|
155
|
+
// 2. Run one SDK turn. Resume the prior SDK session when we have its id so
|
|
125
156
|
// the conversation (and prompt cache) carries across turns. Point the
|
|
126
157
|
// agent's cwd at the session directory so its read-only file tools resolve
|
|
127
158
|
// against the cloned repos. Prompt + tool/MCP wiring come from the shared
|
|
128
159
|
// session core (edsger-tools) so CLI and worker can't drift apart.
|
|
129
160
|
let finalResponse = '';
|
|
130
|
-
let sdkSessionId = resumeSessionId ?? '';
|
|
131
161
|
try {
|
|
132
162
|
for await (const message of query({
|
|
133
163
|
prompt: userPrompt,
|
|
134
164
|
options: {
|
|
135
|
-
...buildSessionAgentOptions(systemPrompt, {
|
|
136
|
-
sessionServer,
|
|
137
|
-
externalServers: external.servers,
|
|
138
|
-
externalNames: external.names,
|
|
139
|
-
sessionDir,
|
|
165
|
+
...buildSessionAgentOptions(ctx.systemPrompt, {
|
|
166
|
+
sessionServer: ctx.sessionServer,
|
|
167
|
+
externalServers: ctx.external.servers,
|
|
168
|
+
externalNames: ctx.external.names,
|
|
169
|
+
sessionDir: ctx.sessionDir,
|
|
140
170
|
maxTurns: SESSION_MAX_TURNS,
|
|
141
171
|
}),
|
|
142
172
|
model: DEFAULT_MODEL,
|
|
143
|
-
...(
|
|
173
|
+
...(ctx.sdkSessionId ? { resume: ctx.sdkSessionId } : {}),
|
|
144
174
|
},
|
|
145
175
|
})) {
|
|
146
176
|
const msg = message;
|
|
147
177
|
// The system/init and result messages carry the (possibly new) session
|
|
148
178
|
// id — keep the latest so we persist what the next turn should resume.
|
|
149
179
|
if (msg.session_id) {
|
|
150
|
-
sdkSessionId = msg.session_id;
|
|
180
|
+
ctx.sdkSessionId = msg.session_id;
|
|
151
181
|
}
|
|
152
|
-
if (verbose && msg.type === 'assistant') {
|
|
182
|
+
if (ctx.verbose && msg.type === 'assistant') {
|
|
153
183
|
logInfo('· agent step');
|
|
154
184
|
}
|
|
155
185
|
if (msg.type === 'result') {
|
|
@@ -162,22 +192,22 @@ export async function runSessionTurnCommand(options) {
|
|
|
162
192
|
const reason = error instanceof Error ? error.message : String(error);
|
|
163
193
|
logError(`Session turn failed: ${reason}`);
|
|
164
194
|
await callMcpEndpoint('chat/messages/send_ai', {
|
|
165
|
-
channel_id: channelId,
|
|
195
|
+
channel_id: ctx.channelId,
|
|
166
196
|
content: `I hit an error processing that: ${reason}`,
|
|
167
197
|
message_type: 'text',
|
|
168
198
|
metadata: {},
|
|
169
199
|
}).catch(() => undefined);
|
|
170
|
-
emitSessionId(sdkSessionId);
|
|
200
|
+
emitSessionId(ctx.sdkSessionId);
|
|
171
201
|
emitActiveCliRuns();
|
|
172
202
|
await markProcessed(messages);
|
|
173
203
|
return;
|
|
174
204
|
}
|
|
175
|
-
//
|
|
205
|
+
// 3. Post the final reply (the agent may also have posted via
|
|
176
206
|
// send_chat_message during the turn; this carries the closing summary).
|
|
177
207
|
const reply = finalResponse.trim();
|
|
178
208
|
if (reply) {
|
|
179
209
|
await callMcpEndpoint('chat/messages/send_ai', {
|
|
180
|
-
channel_id: channelId,
|
|
210
|
+
channel_id: ctx.channelId,
|
|
181
211
|
content: reply,
|
|
182
212
|
message_type: 'text',
|
|
183
213
|
metadata: {},
|
|
@@ -185,16 +215,25 @@ export async function runSessionTurnCommand(options) {
|
|
|
185
215
|
logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
|
|
186
216
|
});
|
|
187
217
|
}
|
|
188
|
-
//
|
|
218
|
+
// 4. Mark the triggering messages processed so they aren't re-run. (No-op for
|
|
189
219
|
// a watcher follow-up, which has no triggering human messages.)
|
|
190
220
|
await markProcessed(messages);
|
|
191
|
-
//
|
|
221
|
+
// 5. Emit the SDK session id so the desktop persists it for the next turn,
|
|
192
222
|
// plus a marker for any background run launched this turn so the watcher
|
|
193
223
|
// keeps following it to completion.
|
|
194
|
-
emitSessionId(sdkSessionId);
|
|
224
|
+
emitSessionId(ctx.sdkSessionId);
|
|
195
225
|
emitActiveCliRuns();
|
|
196
226
|
logSuccess('Session turn complete.');
|
|
197
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* One-shot `session-turn`: prepare the context and run a single turn. Retained
|
|
230
|
+
* for callers that want the previous per-turn-process behaviour (and as the
|
|
231
|
+
* building block the daemon reuses).
|
|
232
|
+
*/
|
|
233
|
+
export async function runSessionTurnCommand(options) {
|
|
234
|
+
const ctx = await prepareSessionContext(options);
|
|
235
|
+
await runTurn(ctx, { runFinished: options.runFinished });
|
|
236
|
+
}
|
|
198
237
|
/**
|
|
199
238
|
* The synthetic user prompt for a watcher-triggered follow-up turn. Steers the
|
|
200
239
|
* agent to inspect what the finished run produced and report it — explicitly
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runAgentWorkflow } from './commands/agent-workflow/index.js';
|
|
|
10
10
|
import { runAnalyzeLogs } from './commands/analyze-logs/index.js';
|
|
11
11
|
import { runAppStoreGeneration } from './commands/app-store/index.js';
|
|
12
12
|
import { runBuild } from './commands/build/index.js';
|
|
13
|
+
import { runChatServeCommand } from './commands/chat-serve/index.js';
|
|
13
14
|
import { runChecklists } from './commands/checklists/index.js';
|
|
14
15
|
import { runCodeReview } from './commands/code-review/index.js';
|
|
15
16
|
import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './commands/config/index.js';
|
|
@@ -32,6 +33,7 @@ import { runRefactor } from './commands/refactor/refactor.js';
|
|
|
32
33
|
import { runReleaseSyncCommand } from './commands/release-sync/index.js';
|
|
33
34
|
import { runRunSheetCommand } from './commands/run-sheet/index.js';
|
|
34
35
|
import { runScreenFlow } from './commands/screen-flow/index.js';
|
|
36
|
+
import { runSessionServeCommand } from './commands/session-serve/index.js';
|
|
35
37
|
import { runSessionTurnCommand } from './commands/session-turn/index.js';
|
|
36
38
|
import { runSmokeTestCommand } from './commands/smoke-test/index.js';
|
|
37
39
|
import { runSyncAws } from './commands/sync-aws/index.js';
|
|
@@ -466,6 +468,50 @@ program
|
|
|
466
468
|
}
|
|
467
469
|
});
|
|
468
470
|
// ============================================================
|
|
471
|
+
// Subcommand: edsger session-serve <channelId>
|
|
472
|
+
// ============================================================
|
|
473
|
+
program
|
|
474
|
+
.command('session-serve <channelId>')
|
|
475
|
+
.description('Run a long-lived conversational agent for a chat session, taking one turn per command read from stdin')
|
|
476
|
+
.requiredOption('--product <productId>', 'Product the session is bound to')
|
|
477
|
+
.option('--repos <ids>', 'Comma-separated repository IDs the agent may touch', '')
|
|
478
|
+
.option('--resume <sessionId>', 'SDK session id to resume on the first turn (e.g. after a crash-restart)')
|
|
479
|
+
.option('-v, --verbose', 'Verbose output')
|
|
480
|
+
.action(async (channelId, opts) => {
|
|
481
|
+
try {
|
|
482
|
+
await runSessionServeCommand({
|
|
483
|
+
channelId,
|
|
484
|
+
productId: opts.product,
|
|
485
|
+
repositoryIds: (opts.repos ?? '')
|
|
486
|
+
.split(',')
|
|
487
|
+
.map((s) => s.trim())
|
|
488
|
+
.filter(Boolean),
|
|
489
|
+
resumeSessionId: opts.resume,
|
|
490
|
+
verbose: opts.verbose,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
// ============================================================
|
|
499
|
+
// Subcommand: edsger chat-serve
|
|
500
|
+
// ============================================================
|
|
501
|
+
program
|
|
502
|
+
.command('chat-serve')
|
|
503
|
+
.description('Run a long-lived daemon that answers human messages on issue and product group chat channels')
|
|
504
|
+
.option('-v, --verbose', 'Verbose output')
|
|
505
|
+
.action(async (opts) => {
|
|
506
|
+
try {
|
|
507
|
+
await runChatServeCommand({ verbose: opts.verbose });
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
// ============================================================
|
|
469
515
|
// Subcommand: edsger user-stories-analysis <issueId>
|
|
470
516
|
// ============================================================
|
|
471
517
|
program
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phase: sync-org-repos
|
|
3
3
|
*
|
|
4
|
-
* Fetches all repositories from a GitHub organization
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Fetches all repositories from a GitHub organization via the Edsger GitHub
|
|
5
|
+
* App (server-side, through the MCP `github/org_repos` endpoint), then upserts
|
|
6
|
+
* a row into the team-scoped `repositories` table for each repo. Products are
|
|
7
|
+
* NOT created here — a product is a higher-level grouping that a user links one
|
|
8
|
+
* or more repositories to afterwards.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Repos are listed with a short-lived GitHub App installation token minted on
|
|
11
|
+
* the server, so this no longer depends on the user's machine having the `gh`
|
|
12
|
+
* CLI installed, authenticated, and on PATH. Forks and archived repos are
|
|
13
|
+
* filtered out server-side.
|
|
11
14
|
*/
|
|
12
15
|
export interface SyncOrgReposResult {
|
|
13
16
|
status: 'success' | 'error';
|