@orgloop/agentctl 1.0.0 → 1.1.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/README.md +132 -3
- package/dist/adapters/claude-code.js +1 -1
- package/dist/adapters/openclaw.js +23 -4
- package/dist/cli.js +4 -1
- package/dist/daemon/session-tracker.d.ts +9 -0
- package/dist/daemon/session-tracker.js +74 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ Over time, agentctl can extend to handle more concerns of headless coding — au
|
|
|
28
28
|
## Installation
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
npm install -g agentctl
|
|
31
|
+
npm install -g @orgloop/agentctl
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Requires Node.js >= 20.
|
|
@@ -90,6 +90,11 @@ agentctl stop <id> [options]
|
|
|
90
90
|
agentctl resume <id> <message> [options]
|
|
91
91
|
--adapter <name> Adapter to use
|
|
92
92
|
|
|
93
|
+
# <message> is a continuation prompt sent to the agent.
|
|
94
|
+
# The agent receives it as new user input and resumes work.
|
|
95
|
+
# Example: resume a stopped session with a follow-up instruction
|
|
96
|
+
agentctl resume abc123 "fix the failing tests and re-run the suite"
|
|
97
|
+
|
|
93
98
|
agentctl events [options]
|
|
94
99
|
--json Output as NDJSON (default)
|
|
95
100
|
```
|
|
@@ -109,15 +114,68 @@ agentctl locks [options]
|
|
|
109
114
|
--json Output as JSON
|
|
110
115
|
```
|
|
111
116
|
|
|
117
|
+
### Lifecycle Hooks
|
|
118
|
+
|
|
119
|
+
Hooks are shell commands that run at specific points in a session's lifecycle. Pass them as flags to `launch` or `merge`:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
agentctl launch -p "implement feature X" \
|
|
123
|
+
--on-create "echo 'Session $AGENTCTL_SESSION_ID started'" \
|
|
124
|
+
--on-complete "npm test"
|
|
125
|
+
|
|
126
|
+
agentctl merge <id> \
|
|
127
|
+
--pre-merge "npm run lint && npm test" \
|
|
128
|
+
--post-merge "curl -X POST https://slack.example.com/webhook -d '{\"text\": \"PR merged\"}'"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Available hooks:
|
|
132
|
+
|
|
133
|
+
| Hook | Trigger | Typical use |
|
|
134
|
+
|------|---------|-------------|
|
|
135
|
+
| `--on-create <script>` | After a session is created | Notify, set up environment |
|
|
136
|
+
| `--on-complete <script>` | After a session completes | Run tests, send alerts |
|
|
137
|
+
| `--pre-merge <script>` | Before `agentctl merge` commits | Lint, test, validate |
|
|
138
|
+
| `--post-merge <script>` | After `agentctl merge` pushes/opens PR | Notify, trigger CI |
|
|
139
|
+
|
|
140
|
+
Hook scripts receive context via environment variables:
|
|
141
|
+
|
|
142
|
+
| Variable | Description |
|
|
143
|
+
|----------|-------------|
|
|
144
|
+
| `AGENTCTL_SESSION_ID` | Session UUID |
|
|
145
|
+
| `AGENTCTL_CWD` | Working directory of the session |
|
|
146
|
+
| `AGENTCTL_ADAPTER` | Adapter name (e.g. `claude-code`) |
|
|
147
|
+
| `AGENTCTL_BRANCH` | Git branch (when using `--worktree`) |
|
|
148
|
+
| `AGENTCTL_EXIT_CODE` | Process exit code (in `--on-complete`) |
|
|
149
|
+
|
|
150
|
+
Hooks run with a 60-second timeout. If a hook fails, its stderr is printed but execution continues.
|
|
151
|
+
|
|
112
152
|
### Fuse Timers
|
|
113
153
|
|
|
114
|
-
|
|
154
|
+
Fuse timers provide automatic cleanup of [Kind](https://kind.sigs.k8s.io/) Kubernetes clusters tied to coding sessions. When a session exits, agentctl starts a countdown timer. If no new session starts in the same worktree directory before the timer expires, the associated Kind cluster is deleted to free resources.
|
|
155
|
+
|
|
156
|
+
This is useful when running agents in worktree-per-branch workflows where each branch has its own Kind cluster (e.g. `kindo-charlie-<branch>`). Without fuse timers, forgotten clusters accumulate and waste resources.
|
|
157
|
+
|
|
158
|
+
**How it works:**
|
|
159
|
+
|
|
160
|
+
1. Agent session exits in a worktree directory (e.g. `~/code/mono-my-feature`)
|
|
161
|
+
2. agentctl derives the cluster name (`kindo-charlie-my-feature`) and starts a fuse timer
|
|
162
|
+
3. If the timer expires (default: configured at daemon startup), the cluster is deleted via `kind delete cluster`
|
|
163
|
+
4. If a new session starts in the same directory before expiry, the fuse is cancelled
|
|
115
164
|
|
|
116
165
|
```bash
|
|
166
|
+
# List active fuse timers
|
|
117
167
|
agentctl fuses [options]
|
|
118
168
|
--json Output as JSON
|
|
119
169
|
```
|
|
120
170
|
|
|
171
|
+
Example output:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Directory Cluster Expires In
|
|
175
|
+
~/code/mono-feat-x kindo-charlie-feat-x 12m
|
|
176
|
+
~/code/mono-hotfix kindo-charlie-hotfix 45m
|
|
177
|
+
```
|
|
178
|
+
|
|
121
179
|
### Daemon
|
|
122
180
|
|
|
123
181
|
The daemon provides session tracking, directory locks, fuse timers, and Prometheus metrics. It auto-starts on first `agentctl` command.
|
|
@@ -135,7 +193,76 @@ agentctl daemon install # Install macOS LaunchAgent (auto-start on login)
|
|
|
135
193
|
agentctl daemon uninstall # Remove LaunchAgent
|
|
136
194
|
```
|
|
137
195
|
|
|
138
|
-
Metrics are exposed at `http://localhost:9200/metrics` in Prometheus text format.
|
|
196
|
+
Metrics are exposed at `http://localhost:9200/metrics` in Prometheus text format. See [Prometheus Metrics](#prometheus-metrics) for the full list.
|
|
197
|
+
|
|
198
|
+
### Events
|
|
199
|
+
|
|
200
|
+
`agentctl events` streams session lifecycle events as NDJSON (newline-delimited JSON). Each line is a self-contained JSON object:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{"type":"session.started","adapter":"claude-code","sessionId":"abc123...","timestamp":"2025-06-15T10:30:00.000Z","session":{...}}
|
|
204
|
+
{"type":"session.stopped","adapter":"claude-code","sessionId":"abc123...","timestamp":"2025-06-15T11:00:00.000Z","session":{...}}
|
|
205
|
+
{"type":"session.idle","adapter":"claude-code","sessionId":"def456...","timestamp":"2025-06-15T11:05:00.000Z","session":{...}}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Event types: `session.started`, `session.stopped`, `session.idle`, `session.error`.
|
|
209
|
+
|
|
210
|
+
The `session` field contains the full session object (id, adapter, status, cwd, model, prompt, tokens, etc.).
|
|
211
|
+
|
|
212
|
+
**Piping events into OrgLoop (or similar event routers):**
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
# Pipe lifecycle events to OrgLoop's webhook endpoint
|
|
216
|
+
agentctl events | while IFS= read -r event; do
|
|
217
|
+
curl -s -X POST https://orgloop.example.com/hooks/agentctl \
|
|
218
|
+
-H "Content-Type: application/json" \
|
|
219
|
+
-d "$event"
|
|
220
|
+
done
|
|
221
|
+
|
|
222
|
+
# Or use a persistent pipe with jq filtering
|
|
223
|
+
agentctl events | jq -c 'select(.type == "session.stopped")' | while IFS= read -r event; do
|
|
224
|
+
curl -s -X POST https://orgloop.example.com/hooks/session-ended \
|
|
225
|
+
-H "Content-Type: application/json" \
|
|
226
|
+
-d "$event"
|
|
227
|
+
done
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
This pattern works with any webhook-based system — OrgLoop, Zapier, n8n, or a custom event router. The NDJSON format is compatible with standard Unix tools (`jq`, `grep`, `awk`) for filtering and transformation.
|
|
231
|
+
|
|
232
|
+
## Prometheus Metrics
|
|
233
|
+
|
|
234
|
+
The daemon exposes metrics at `http://localhost:9200/metrics` in Prometheus text format. Default port is 9200, configurable via `--metrics-port`.
|
|
235
|
+
|
|
236
|
+
### Gauges
|
|
237
|
+
|
|
238
|
+
| Metric | Labels | Description |
|
|
239
|
+
|--------|--------|-------------|
|
|
240
|
+
| `agentctl_sessions_active` | — | Number of currently active sessions |
|
|
241
|
+
| `agentctl_locks_active` | `type="auto"\|"manual"` | Number of active directory locks by type |
|
|
242
|
+
| `agentctl_fuses_active` | — | Number of active fuse timers |
|
|
243
|
+
|
|
244
|
+
### Counters
|
|
245
|
+
|
|
246
|
+
| Metric | Labels | Description |
|
|
247
|
+
|--------|--------|-------------|
|
|
248
|
+
| `agentctl_sessions_total` | `status="completed"\|"failed"\|"stopped"` | Total sessions by final status |
|
|
249
|
+
| `agentctl_fuses_fired_total` | — | Total fuse timers that fired (clusters deleted) |
|
|
250
|
+
| `agentctl_kind_clusters_deleted_total` | — | Total Kind clusters deleted by fuse timers |
|
|
251
|
+
|
|
252
|
+
### Histogram
|
|
253
|
+
|
|
254
|
+
| Metric | Buckets (seconds) | Description |
|
|
255
|
+
|--------|-------------------|-------------|
|
|
256
|
+
| `agentctl_session_duration_seconds` | 60, 300, 600, 1800, 3600, 7200, +Inf | Session duration distribution |
|
|
257
|
+
|
|
258
|
+
Example scrape config for Prometheus:
|
|
259
|
+
|
|
260
|
+
```yaml
|
|
261
|
+
scrape_configs:
|
|
262
|
+
- job_name: agentctl
|
|
263
|
+
static_configs:
|
|
264
|
+
- targets: ["localhost:9200"]
|
|
265
|
+
```
|
|
139
266
|
|
|
140
267
|
## Architecture
|
|
141
268
|
|
|
@@ -155,6 +282,8 @@ Reads session data from `~/.claude/projects/` and cross-references with running
|
|
|
155
282
|
|
|
156
283
|
Connects to the OpenClaw gateway via WebSocket RPC. Read-only — sessions are managed through the gateway.
|
|
157
284
|
|
|
285
|
+
Requires the `OPENCLAW_WEBHOOK_TOKEN` environment variable. The adapter warns clearly if the token is missing or authentication fails.
|
|
286
|
+
|
|
158
287
|
### Writing an Adapter
|
|
159
288
|
|
|
160
289
|
Implement the `AgentAdapter` interface:
|
|
@@ -148,7 +148,7 @@ export class ClaudeCodeAdapter {
|
|
|
148
148
|
if (pid) {
|
|
149
149
|
resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
|
|
150
150
|
}
|
|
151
|
-
const sessionId = resolvedSessionId || crypto.randomUUID();
|
|
151
|
+
const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
|
|
152
152
|
// Persist session metadata so status checks work after wrapper exits
|
|
153
153
|
if (pid) {
|
|
154
154
|
await this.writeSessionMeta({
|
|
@@ -17,6 +17,11 @@ export class OpenClawAdapter {
|
|
|
17
17
|
this.rpcCall = opts?.rpcCall || this.defaultRpcCall.bind(this);
|
|
18
18
|
}
|
|
19
19
|
async list(opts) {
|
|
20
|
+
if (!this.authToken) {
|
|
21
|
+
console.warn("Warning: OPENCLAW_WEBHOOK_TOKEN is not set — OpenClaw adapter cannot authenticate. " +
|
|
22
|
+
"Set this environment variable to connect to the gateway.");
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
20
25
|
let result;
|
|
21
26
|
try {
|
|
22
27
|
result = (await this.rpcCall("sessions.list", {
|
|
@@ -24,8 +29,16 @@ export class OpenClawAdapter {
|
|
|
24
29
|
includeLastMessage: true,
|
|
25
30
|
}));
|
|
26
31
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
32
|
+
catch (err) {
|
|
33
|
+
const msg = err?.message || "unknown error";
|
|
34
|
+
if (msg.includes("auth") || msg.includes("Auth")) {
|
|
35
|
+
console.warn(`Warning: OpenClaw gateway authentication failed: ${msg}. ` +
|
|
36
|
+
"Check that OPENCLAW_WEBHOOK_TOKEN is valid.");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.warn(`Warning: OpenClaw gateway unreachable (${msg}). ` +
|
|
40
|
+
`Is the gateway running at ${this.baseUrl}?`);
|
|
41
|
+
}
|
|
29
42
|
return [];
|
|
30
43
|
}
|
|
31
44
|
let sessions = result.sessions.map((row) => this.mapRowToSession(row, result.defaults));
|
|
@@ -67,6 +80,9 @@ export class OpenClawAdapter {
|
|
|
67
80
|
return assistantMessages.slice(-limit).join("\n---\n");
|
|
68
81
|
}
|
|
69
82
|
async status(sessionId) {
|
|
83
|
+
if (!this.authToken) {
|
|
84
|
+
throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
|
|
85
|
+
}
|
|
70
86
|
let result;
|
|
71
87
|
try {
|
|
72
88
|
result = (await this.rpcCall("sessions.list", {
|
|
@@ -184,14 +200,17 @@ export class OpenClawAdapter {
|
|
|
184
200
|
* Resolve a sessionId (or prefix) to a gateway session key.
|
|
185
201
|
*/
|
|
186
202
|
async resolveKey(sessionId) {
|
|
203
|
+
if (!this.authToken) {
|
|
204
|
+
throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
|
|
205
|
+
}
|
|
187
206
|
let result;
|
|
188
207
|
try {
|
|
189
208
|
result = (await this.rpcCall("sessions.list", {
|
|
190
209
|
search: sessionId,
|
|
191
210
|
}));
|
|
192
211
|
}
|
|
193
|
-
catch {
|
|
194
|
-
|
|
212
|
+
catch (err) {
|
|
213
|
+
throw new Error(`Failed to resolve session ${sessionId}: ${err.message}`);
|
|
195
214
|
}
|
|
196
215
|
const row = result.sessions.find((s) => s.sessionId === sessionId ||
|
|
197
216
|
s.key === sessionId ||
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import os from "node:os";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { version: PKG_VERSION } = require("../package.json");
|
|
5
8
|
import path from "node:path";
|
|
6
9
|
import { fileURLToPath } from "node:url";
|
|
7
10
|
import { Command } from "commander";
|
|
@@ -158,7 +161,7 @@ const program = new Command();
|
|
|
158
161
|
program
|
|
159
162
|
.name("agentctl")
|
|
160
163
|
.description("Universal agent supervision interface")
|
|
161
|
-
.version(
|
|
164
|
+
.version(PKG_VERSION);
|
|
162
165
|
// list
|
|
163
166
|
program
|
|
164
167
|
.command("list")
|
|
@@ -3,16 +3,25 @@ import type { SessionRecord, StateManager } from "./state.js";
|
|
|
3
3
|
export interface SessionTrackerOpts {
|
|
4
4
|
adapters: Record<string, AgentAdapter>;
|
|
5
5
|
pollIntervalMs?: number;
|
|
6
|
+
/** Override PID liveness check for testing (default: process.kill(pid, 0)) */
|
|
7
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
6
8
|
}
|
|
7
9
|
export declare class SessionTracker {
|
|
8
10
|
private state;
|
|
9
11
|
private adapters;
|
|
10
12
|
private pollIntervalMs;
|
|
11
13
|
private pollHandle;
|
|
14
|
+
private readonly isProcessAlive;
|
|
12
15
|
constructor(state: StateManager, opts: SessionTrackerOpts);
|
|
13
16
|
startPolling(): void;
|
|
14
17
|
stopPolling(): void;
|
|
15
18
|
private poll;
|
|
19
|
+
/**
|
|
20
|
+
* Clean up ghost sessions in the daemon state:
|
|
21
|
+
* - pending-* entries whose PID matches a resolved session → remove pending
|
|
22
|
+
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
23
|
+
*/
|
|
24
|
+
private reapStaleEntries;
|
|
16
25
|
/** Track a newly launched session */
|
|
17
26
|
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
18
27
|
/** Get session record by id (exact or prefix) */
|
|
@@ -3,10 +3,12 @@ export class SessionTracker {
|
|
|
3
3
|
adapters;
|
|
4
4
|
pollIntervalMs;
|
|
5
5
|
pollHandle = null;
|
|
6
|
+
isProcessAlive;
|
|
6
7
|
constructor(state, opts) {
|
|
7
8
|
this.state = state;
|
|
8
9
|
this.adapters = opts.adapters;
|
|
9
10
|
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
11
|
+
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
10
12
|
}
|
|
11
13
|
startPolling() {
|
|
12
14
|
if (this.pollHandle)
|
|
@@ -24,10 +26,15 @@ export class SessionTracker {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
async poll() {
|
|
29
|
+
// Collect PIDs from all adapter-returned sessions (the source of truth)
|
|
30
|
+
const adapterPidToId = new Map();
|
|
27
31
|
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
28
32
|
try {
|
|
29
33
|
const sessions = await adapter.list({ all: true });
|
|
30
34
|
for (const session of sessions) {
|
|
35
|
+
if (session.pid) {
|
|
36
|
+
adapterPidToId.set(session.pid, session.id);
|
|
37
|
+
}
|
|
31
38
|
const existing = this.state.getSession(session.id);
|
|
32
39
|
const record = sessionToRecord(session, adapterName);
|
|
33
40
|
if (!existing) {
|
|
@@ -49,6 +56,43 @@ export class SessionTracker {
|
|
|
49
56
|
// Adapter unavailable — skip
|
|
50
57
|
}
|
|
51
58
|
}
|
|
59
|
+
// Reap stale entries from daemon state
|
|
60
|
+
this.reapStaleEntries(adapterPidToId);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Clean up ghost sessions in the daemon state:
|
|
64
|
+
* - pending-* entries whose PID matches a resolved session → remove pending
|
|
65
|
+
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
66
|
+
*/
|
|
67
|
+
reapStaleEntries(adapterPidToId) {
|
|
68
|
+
const sessions = this.state.getSessions();
|
|
69
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
70
|
+
// Bug 2: If this is a pending-* entry and a real session has the same PID,
|
|
71
|
+
// the pending entry is stale — remove it
|
|
72
|
+
if (id.startsWith("pending-") && record.pid) {
|
|
73
|
+
const resolvedId = adapterPidToId.get(record.pid);
|
|
74
|
+
if (resolvedId && resolvedId !== id) {
|
|
75
|
+
this.state.removeSession(id);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
|
|
80
|
+
if ((record.status === "running" || record.status === "idle") &&
|
|
81
|
+
record.pid) {
|
|
82
|
+
// Only reap if the adapter didn't return this session as running
|
|
83
|
+
// (adapter is the source of truth for sessions it knows about)
|
|
84
|
+
const adapterId = adapterPidToId.get(record.pid);
|
|
85
|
+
if (adapterId === id)
|
|
86
|
+
continue; // Adapter confirmed this PID is active
|
|
87
|
+
if (!this.isProcessAlive(record.pid)) {
|
|
88
|
+
this.state.setSession(id, {
|
|
89
|
+
...record,
|
|
90
|
+
status: "stopped",
|
|
91
|
+
stoppedAt: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
52
96
|
}
|
|
53
97
|
/** Track a newly launched session */
|
|
54
98
|
track(session, adapterName) {
|
|
@@ -79,6 +123,8 @@ export class SessionTracker {
|
|
|
79
123
|
else if (!opts?.all) {
|
|
80
124
|
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
81
125
|
}
|
|
126
|
+
// Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
|
|
127
|
+
filtered = deduplicatePendingSessions(filtered);
|
|
82
128
|
return filtered.sort((a, b) => {
|
|
83
129
|
// Running first, then by recency
|
|
84
130
|
if (a.status === "running" && b.status !== "running")
|
|
@@ -102,6 +148,34 @@ export class SessionTracker {
|
|
|
102
148
|
return session;
|
|
103
149
|
}
|
|
104
150
|
}
|
|
151
|
+
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
152
|
+
function defaultIsProcessAlive(pid) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(pid, 0);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Remove pending-* entries that share a PID with a resolved (non-pending) session.
|
|
163
|
+
* This is a safety net for list output — the poll() reaper handles cleanup in state.
|
|
164
|
+
*/
|
|
165
|
+
function deduplicatePendingSessions(sessions) {
|
|
166
|
+
const realPids = new Set();
|
|
167
|
+
for (const s of sessions) {
|
|
168
|
+
if (!s.id.startsWith("pending-") && s.pid) {
|
|
169
|
+
realPids.add(s.pid);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return sessions.filter((s) => {
|
|
173
|
+
if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
105
179
|
function sessionToRecord(session, adapterName) {
|
|
106
180
|
return {
|
|
107
181
|
id: session.id,
|