@zgeoff/atc 2.27.0 → 2.29.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/src/client/ui.ts CHANGED
@@ -3,9 +3,11 @@ import type { SessionState } from '../protocol/session-state';
3
3
  import type { AgentID } from '../shared/agent-id';
4
4
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
5
5
  import { formatDir } from './dirs';
6
- import { formatOverlayAgentMark } from './format-overlay-agent-mark';
6
+ import { planOverlayColumns } from './plan-overlay-columns';
7
+ import type { OverlayColumnPlan } from './plan-overlay-columns';
7
8
  import { planVacatedRows } from './plan-vacated-rows';
8
9
  import type { BoxExtent } from './plan-vacated-rows';
10
+ import type { HarnessLifecycle } from './to-mirror-session';
9
11
 
10
12
  // The slice of a session the drawing layer needs; satisfied by both the
11
13
  // daemon's sessions and the wire descriptors a client mirrors.
@@ -39,13 +41,6 @@ const GLYPH: Record<SessionState, string> = {
39
41
  exited: `${ESC}[90m✗${ESC}[0m`,
40
42
  };
41
43
 
42
- const STATE_LABEL: Record<SessionState, string> = {
43
- needs_you: 'NEEDS YOU',
44
- running: 'running',
45
- done: 'done',
46
- exited: 'exited',
47
- };
48
-
49
44
  function out(s: string) {
50
45
  process.stdout.write(s);
51
46
  }
@@ -65,6 +60,14 @@ export function cols(): number {
65
60
  return process.stdout.columns || 80;
66
61
  }
67
62
 
63
+ /**
64
+ * The width of the picker box, borders included. An item row holds 4
65
+ * columns fewer.
66
+ */
67
+ export function getPickerWidth(): number {
68
+ return Math.min(cols() - 4, 90);
69
+ }
70
+
68
71
  export function rows(): number {
69
72
  return process.stdout.rows || 24;
70
73
  }
@@ -186,14 +189,25 @@ export interface OverlaySessionView extends SessionView {
186
189
  readonly agent: AgentID;
187
190
  readonly pinned: boolean;
188
191
  readonly repoRoot: string;
192
+ readonly target: string;
193
+ readonly model: string | null;
194
+
195
+ // Where the session's harness stands, apart from the attention its state
196
+ // carries: running, suspended inside a sleeping host, or exited.
197
+ readonly harness: HarnessLifecycle;
189
198
  }
190
199
 
191
200
  export interface OverlayView {
192
201
  sessions: readonly OverlaySessionView[];
193
202
 
194
- // The overlay column character per agent id, for the ids that do not use
195
- // the built-in one.
196
- agentMarks: Readonly<Record<AgentID, string>>;
203
+ // The readable name per agent id, and each agent's tier-alias map, from
204
+ // the daemon's `agents.list` answer over the config.
205
+ agentLabels: Readonly<Record<AgentID, string>>;
206
+ agentModels: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
207
+
208
+ // Whether more than one execution target is available to spawn on, which
209
+ // is what brings the target column in.
210
+ showTarget: boolean;
197
211
  selected: number;
198
212
  confirmKill: boolean;
199
213
 
@@ -205,6 +219,67 @@ export interface OverlayView {
205
219
  grouped: boolean;
206
220
  }
207
221
 
222
+ // A daemon-supplied id or alias can name an inherited property, so map
223
+ // reads go through an own-property check instead of indexing blindly.
224
+ function readMapValue<V>(map: Readonly<Record<string, V>>, key: string): V | undefined {
225
+ return Object.hasOwn(map, key) ? map[key] : undefined;
226
+ }
227
+
228
+ // A session's model as the row draws it: the daemon answer's alias target,
229
+ // or the raw model string when the answer maps nothing for it.
230
+ function resolveModelAlias(
231
+ models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>,
232
+ agent: AgentID,
233
+ model: string,
234
+ ): string {
235
+ const aliases = readMapValue(models, agent);
236
+
237
+ return aliases === undefined ? model : (readMapValue(aliases, model) ?? model);
238
+ }
239
+
240
+ // A session row draws status (the attention glyph plus the unread mark),
241
+ // pin, name, the flat view's directory, then the target, harness, model,
242
+ // harness lifecycle, and last event columns the width plan allows.
243
+ export function buildSessionRow(
244
+ s: Readonly<OverlaySessionView>,
245
+ plan: Readonly<OverlayColumnPlan>,
246
+ view: Readonly<OverlayView>,
247
+ sel: boolean,
248
+ ): Row {
249
+ const sub = s.parent !== null && view.sessions.some((x) => x.id === s.parent);
250
+
251
+ const name =
252
+ plan.nameWidth >= 4 && sub
253
+ ? `↳ ${truncate(s.name, plan.nameWidth - 2)}`.padEnd(plan.nameWidth)
254
+ : truncate(s.name, plan.nameWidth).padEnd(plan.nameWidth);
255
+
256
+ const dir =
257
+ plan.dirWidth > 0 ? truncate(formatDir(s.cwd), plan.dirWidth).padEnd(plan.dirWidth) : '';
258
+
259
+ const target =
260
+ plan.targetWidth > 0 ? truncate(s.target, plan.targetWidth).padEnd(plan.targetWidth) : '';
261
+
262
+ const label = readMapValue(view.agentLabels, s.agent) ?? s.agent;
263
+ const harness = truncate(label, plan.harnessWidth).padEnd(plan.harnessWidth);
264
+ const resolved = s.model === null ? '' : resolveModelAlias(view.agentModels, s.agent, s.model);
265
+
266
+ const model =
267
+ plan.modelWidth > 0 ? truncate(resolved, plan.modelWidth).padEnd(plan.modelWidth) : '';
268
+
269
+ const lifecycle = truncate(s.harness, plan.lifecycleWidth).padEnd(plan.lifecycleWidth);
270
+ const event = truncate(s.lastMsg, plan.eventWidth).padEnd(plan.eventWidth);
271
+ const cells = [name, dir, target, harness, model, lifecycle, event].filter((cell) => cell !== '');
272
+ const body = cells.join(' ');
273
+ const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
274
+ const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
275
+ const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
276
+
277
+ return {
278
+ styled: `${GLYPH[s.state]}${unread}${pin} ${styledBody}`,
279
+ width: 4 + body.length,
280
+ };
281
+ }
282
+
208
283
  export function drawOverlay(view: OverlayView) {
209
284
  const width = Math.min(cols() - 4, 90);
210
285
  const rowsList: Row[] = [boxTop(width, 'sessions')];
@@ -215,6 +290,35 @@ export function drawOverlay(view: OverlayView) {
215
290
  rowsList.push(dimRow(width, empty));
216
291
  }
217
292
 
293
+ // The harness label and the model each row would draw, so the columns
294
+ // take the width of what is actually on screen.
295
+ const harnessMax = Math.max(
296
+ 0,
297
+ ...view.sessions.map((s) => (readMapValue(view.agentLabels, s.agent) ?? s.agent).length),
298
+ );
299
+
300
+ const modelMax = Math.max(
301
+ 0,
302
+ ...view.sessions.map((s) => {
303
+ if (s.model === null) {
304
+ return 0;
305
+ }
306
+
307
+ return resolveModelAlias(view.agentModels, s.agent, s.model).length;
308
+ }),
309
+ );
310
+
311
+ const targetMax = Math.max(0, ...view.sessions.map((s) => s.target.length));
312
+
313
+ const plan = planOverlayColumns({
314
+ innerWidth: width - 4,
315
+ grouped: view.grouped,
316
+ showTarget: view.showTarget,
317
+ targetMax,
318
+ harnessMax,
319
+ modelMax,
320
+ });
321
+
218
322
  // The grouped view clusters sessions under dim repository headers, with
219
323
  // pinned sessions leading in their own cluster; the flat view shows a
220
324
  // directory column instead. The rows keep the sorted order either way. A
@@ -231,22 +335,9 @@ export function drawOverlay(view: OverlayView) {
231
335
  rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
232
336
  }
233
337
 
234
- const sel = i === view.selected;
235
- const name = (sub ? `↳ ${truncate(s.name, 14)}` : truncate(s.name, 16)).padEnd(16);
236
- const state = STATE_LABEL[s.state].padEnd(9);
237
- const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
238
- const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
239
- const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
240
- const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
241
- const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
242
- const mark = formatOverlayAgentMark(s.agent, view.agentMarks);
243
- const styledMark = mark === ' ' ? mark : `${ESC}[90m${mark}${ESC}[0m`;
244
- const body = `${name} ${state}${dir} ${msg}`;
245
- const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
338
+ const row = buildSessionRow(s, plan, view, i === view.selected);
246
339
 
247
- rowsList.push(
248
- boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledMark}${styledBody}`, 4 + body.length),
249
- );
340
+ rowsList.push(boxRow(width, row.styled, row.width));
250
341
  }
251
342
 
252
343
  rowsList.push(boxDivider(width));
@@ -363,7 +454,6 @@ export function drawHelp() {
363
454
  'p pin or unpin — pinned sessions stay on top',
364
455
  ' a sub-session pins with its parent',
365
456
  'g toggle grouping by repository',
366
- ' Grok rows show a dim g after the pin mark',
367
457
  'n new session',
368
458
  'r adopt an external session',
369
459
  '/ filter · ↑↓/jk move · q quit',
@@ -393,7 +483,7 @@ export interface PickerView {
393
483
  }
394
484
 
395
485
  export function drawPicker(view: PickerView) {
396
- const width = Math.min(cols() - 4, 90);
486
+ const width = getPickerWidth();
397
487
  const rowsList: Row[] = [boxTop(width, view.title)];
398
488
  const shown = view.items.slice(0, 10);
399
489
 
@@ -9,7 +9,17 @@ const MAX_QUEUED_BYTES = 64 * 1024;
9
9
  const writeAsync = promisify(write);
10
10
 
11
11
  /**
12
- * Makes a log that writes each line to a file descriptor without ever
12
+ * A log that never blocks the event loop. `log` queues one line, and
13
+ * `drain` resolves once every queued line is written or the given number of
14
+ * milliseconds has passed, whichever comes first.
15
+ */
16
+ export interface NonBlockingLog {
17
+ readonly log: (line: string) => void;
18
+ readonly drain: (timeoutMs: number) => Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Creates a log that writes each line to a file descriptor without ever
13
23
  * blocking the event loop, so a reader that stops reading stalls nothing
14
24
  * but the log. Each write goes through the asynchronous node:fs write,
15
25
  * which Bun runs on its thread pool, one write at a time. Lines that
@@ -17,13 +27,22 @@ const writeAsync = promisify(write);
17
27
  * next write after the drops starts with one `atc log dropped=N` line. A
18
28
  * failed write drops its lines and counts them the same way, and the next
19
29
  * line logged writes again.
30
+ *
31
+ * A write to a full pipe that nobody reads never returns, and it keeps the
32
+ * process alive until the process calls exit. A process that shuts down
33
+ * drains the log with a timeout and then exits: every queued line reaches a
34
+ * reader that keeps reading, and an unread pipe holds the exit back no
35
+ * longer than the timeout.
20
36
  */
21
- export function makeNonBlockingLog(fd: number): (line: string) => void {
37
+ export function createNonBlockingLog(fd: number): NonBlockingLog {
22
38
  let queue: string[] = [];
23
39
  let queuedBytes = 0;
24
40
  let dropped = 0;
25
41
  let writing = false;
26
42
 
43
+ // The drains that wait for the log to have nothing left to write.
44
+ let idleWaiters: (() => void)[] = [];
45
+
27
46
  const recordLine = (line: string): boolean => {
28
47
  const text = `${line}\n`;
29
48
  const bytes = Buffer.byteLength(text);
@@ -43,6 +62,8 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
43
62
  if (queue.length === 0 && dropped === 0) {
44
63
  writing = false;
45
64
 
65
+ releaseIdleWaiters();
66
+
46
67
  return;
47
68
  }
48
69
 
@@ -70,22 +91,59 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
70
91
  rest = rest.subarray(result.bytesWritten);
71
92
  }
72
93
  } catch {
94
+ // A drain stops waiting here too: the next write would most likely
95
+ // fail the same way, and the lines it would carry stay counted.
73
96
  dropped += lineCount;
74
97
  writing = false;
75
98
 
99
+ releaseIdleWaiters();
100
+
76
101
  return;
77
102
  }
78
103
 
79
104
  writeNext();
80
105
  };
81
106
 
82
- return (line) => {
83
- if (!recordLine(line)) {
84
- dropped++;
85
- }
107
+ const releaseIdleWaiters = (): void => {
108
+ const waiters = idleWaiters;
109
+
110
+ idleWaiters = [];
86
111
 
87
- if (!writing) {
88
- writeNext();
112
+ for (const resolve of waiters) {
113
+ resolve();
89
114
  }
90
115
  };
116
+
117
+ return {
118
+ log: (line) => {
119
+ if (!recordLine(line)) {
120
+ dropped++;
121
+ }
122
+
123
+ if (!writing) {
124
+ writeNext();
125
+ }
126
+ },
127
+ drain: async (timeoutMs) => {
128
+ // Lines left behind by a failed write wait for the next line logged,
129
+ // so a drain writes them itself.
130
+ if (!writing) {
131
+ writeNext();
132
+ }
133
+
134
+ if (!writing) {
135
+ return;
136
+ }
137
+
138
+ const idle = Promise.withResolvers<void>();
139
+
140
+ idleWaiters.push(idle.resolve);
141
+
142
+ const timer = setTimeout(idle.resolve, timeoutMs);
143
+
144
+ await idle.promise;
145
+
146
+ clearTimeout(timer);
147
+ },
148
+ };
91
149
  }
@@ -7,6 +7,7 @@ import { LineDecoder } from '../protocol/line-decoder';
7
7
  import { OutboundQueue } from '../protocol/outbound-queue';
8
8
  import type { SocketWriter } from '../protocol/outbound-queue';
9
9
  import { parseRequestParams } from '../protocol/parse-request-params';
10
+ import type { RequestMethod } from '../protocol/parse-request-params';
10
11
  import {
11
12
  MAX_CHUNK,
12
13
  MAX_LINE,
@@ -26,6 +27,7 @@ import { buildTargetForbiddenError } from './build-target-forbidden-error';
26
27
  import type { DaemonContext, SpawnParams } from './daemon-context';
27
28
  import type { TranscriptPosition } from './load-transcript-page';
28
29
  import { parseSpawnOverrides } from './parse-spawn-overrides';
30
+ import { REQUEST_ACCESS_CLASSES } from './request-access-classes';
29
31
  import { TargetAccess } from './target-access';
30
32
 
31
33
  // The targets a request may use and the namespace of its idempotency keys.
@@ -34,15 +36,6 @@ interface RequestScope {
34
36
  readonly keyNamespace: string;
35
37
  }
36
38
 
37
- // The requests that act on the whole daemon, or on the credentials a
38
- // session's host may use, which only its owner may make.
39
- const OWNER_METHODS: ReadonlySet<string> = new Set([
40
- 'daemon.quit',
41
- 'fleet.restore',
42
- 'session.auth.revoke',
43
- 'session.auth.rebind',
44
- ]);
45
-
46
39
  // What a limited connection is sent for one event, and the sessions that
47
40
  // event moved out of its view.
48
41
  interface ViewUpdate {
@@ -316,7 +309,7 @@ export class DaemonConnection {
316
309
 
317
310
  const scope = this.findRequestScope(req);
318
311
 
319
- if (scope !== null && OWNER_METHODS.has(req.m)) {
312
+ if (scope !== null && isOwnerOnlyMethod(req.m)) {
320
313
  this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
321
314
 
322
315
  return true;
@@ -379,7 +372,7 @@ export class DaemonConnection {
379
372
  return;
380
373
  }
381
374
 
382
- if (OWNER_METHODS.has(req.m)) {
375
+ if (isOwnerOnlyMethod(req.m)) {
383
376
  this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
384
377
 
385
378
  return;
@@ -548,6 +541,12 @@ export class DaemonConnection {
548
541
  private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
549
542
  await this.helloAnswered;
550
543
 
544
+ if (!isRequestMethod(req.m)) {
545
+ this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
546
+
547
+ return;
548
+ }
549
+
551
550
  switch (req.m) {
552
551
  case 'daemon.ping': {
553
552
  this.sendOk(req.id, {});
@@ -908,7 +907,10 @@ export class DaemonConnection {
908
907
 
909
908
  return;
910
909
  }
911
- default: {
910
+
911
+ // The handshake is answered before a request is admitted, so it never
912
+ // runs as one.
913
+ case 'daemon.hello': {
912
914
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
913
915
  }
914
916
  }
@@ -1779,6 +1781,17 @@ export class DaemonConnection {
1779
1781
  }
1780
1782
  }
1781
1783
 
1784
+ // Whether a request method is open to the daemon's owner alone. A method
1785
+ // the protocol does not define is not: it runs as no request and is
1786
+ // answered as unknown.
1787
+ function isOwnerOnlyMethod(method: string): boolean {
1788
+ return isRequestMethod(method) && REQUEST_ACCESS_CLASSES[method] === 'owner';
1789
+ }
1790
+
1791
+ function isRequestMethod(method: string): method is RequestMethod {
1792
+ return Object.hasOwn(REQUEST_ACCESS_CLASSES, method);
1793
+ }
1794
+
1782
1795
  // The token of a handshake's `auth: { scheme: "bearer", token }`, or null
1783
1796
  // when its params hold none.
1784
1797
  function findBearerToken(p: Readonly<Record<string, unknown>> | undefined): string | null {
@@ -48,6 +48,8 @@ import { buildTargetAccess } from './build-target-access';
48
48
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
49
49
  import { buildTargetList } from './build-target-list';
50
50
  import { claimDaemonLock } from './claim-daemon-lock';
51
+ import { createNonBlockingLog } from './create-non-blocking-log';
52
+ import type { NonBlockingLog } from './create-non-blocking-log';
51
53
  import { DaemonConnection } from './daemon-connection';
52
54
  import type {
53
55
  DaemonContext,
@@ -69,7 +71,6 @@ import { loadListenerTokens } from './load-listener-tokens';
69
71
  import { loadTranscriptPage } from './load-transcript-page';
70
72
  import { makeHookRunner } from './make-hook-runner';
71
73
  import type { HookScope } from './make-hook-runner';
72
- import { makeNonBlockingLog } from './make-non-blocking-log';
73
74
  import { materializeWorkspace } from './materialize-workspace';
74
75
  import { mintMessageID } from './mint-message-id';
75
76
  import { mintSessionID } from './mint-session-id';
@@ -253,6 +254,10 @@ const MAX_REFUSAL_WINDOWS = 1024;
253
254
  // Where the TCP listener logs when the daemon is given no log.
254
255
  const STDERR_FD = 2;
255
256
 
257
+ // How long a stopping daemon waits for the TCP listener's log to write the
258
+ // lines it still holds, so an unread stderr delays the exit no longer.
259
+ const LOG_DRAIN_TIMEOUT_MS = 1000;
260
+
256
261
  // How long startup waits for a daemon that is shutting down to release the
257
262
  // state lock before refusing to start.
258
263
  const LOCK_WAIT_MS = 2000;
@@ -2082,8 +2087,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2082
2087
  // refuses the start before any client can connect, and releases what the
2083
2088
  // daemon holds, the lock included.
2084
2089
  let tcpListener: TCPListener | null = null;
2090
+ let listenerLog: NonBlockingLog | null = null;
2085
2091
 
2086
2092
  if (opts.listen !== undefined && listenTokens !== null) {
2093
+ // A given log writes as it is called, so it has nothing to drain.
2094
+ listenerLog =
2095
+ opts.log === undefined
2096
+ ? createNonBlockingLog(STDERR_FD)
2097
+ : { log: opts.log, drain: () => Promise.resolve() };
2098
+
2087
2099
  try {
2088
2100
  tcpListener = startTCPListener({
2089
2101
  host: opts.listen.host,
@@ -2099,7 +2111,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2099
2111
  return connection;
2100
2112
  },
2101
2113
  closeConnection: detachConnection,
2102
- log: opts.log ?? makeNonBlockingLog(STDERR_FD),
2114
+ log: listenerLog.log,
2103
2115
  now: opts.listen.now ?? Date.now,
2104
2116
  refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
2105
2117
  maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
@@ -2167,6 +2179,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2167
2179
  server.stop(true);
2168
2180
 
2169
2181
  await releaseResources();
2182
+
2183
+ // The stopped listener logs no more lines, so this writes the ones the
2184
+ // log still holds, waiting no longer than the timeout when nothing reads
2185
+ // stderr.
2186
+ await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
2170
2187
  };
2171
2188
 
2172
2189
  writeDaemonRecord(recordPath, {
@@ -326,7 +326,7 @@ async function resolveSource(
326
326
  await requireNoOriginRewriteCredentials(source.path);
327
327
 
328
328
  const resolved = await resolvePathSource(source.path, {
329
- allowDirty: source.allowDirty ?? 'refuse',
329
+ ...(source.allowDirty === undefined ? {} : { allowDirty: source.allowDirty }),
330
330
  transports,
331
331
  });
332
332
 
@@ -0,0 +1,54 @@
1
+ import type { RequestMethod } from '../protocol/parse-request-params';
2
+
3
+ /**
4
+ * Who may make a request: `owner` is the daemon's owner alone, and
5
+ * `principal` is also open to a principal, which runs it through the
6
+ * context scoped to that principal.
7
+ */
8
+ export type RequestAccessClass = 'owner' | 'principal';
9
+
10
+ /**
11
+ * The access class of every request method, checked when a request is
12
+ * admitted and before it runs. The type takes every method the protocol
13
+ * defines, so a method without a class fails typecheck. Owner-class
14
+ * methods act on the whole daemon, or on the credentials a session's host
15
+ * may use.
16
+ */
17
+ export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAccessClass>> = {
18
+ 'daemon.hello': 'principal',
19
+ 'daemon.ping': 'principal',
20
+ 'daemon.quit': 'owner',
21
+ 'session.list': 'principal',
22
+ 'dirs.list': 'principal',
23
+ 'agents.list': 'principal',
24
+ 'sources.list': 'principal',
25
+ 'sources.interpret': 'principal',
26
+ 'git.probe': 'principal',
27
+ 'fleet.list': 'principal',
28
+ 'fleet.restore': 'owner',
29
+ 'session.spawn': 'principal',
30
+ 'session.kill': 'principal',
31
+ 'session.ack': 'principal',
32
+ 'session.forget': 'principal',
33
+ 'session.resumeCommand': 'principal',
34
+ 'session.auth.revoke': 'owner',
35
+ 'session.auth.rebind': 'owner',
36
+ 'session.update': 'principal',
37
+ 'session.attach': 'principal',
38
+ 'session.detach': 'principal',
39
+ 'session.input': 'principal',
40
+ 'session.submit': 'principal',
41
+ 'session.resize': 'principal',
42
+ 'session.screen': 'principal',
43
+ 'session.eject': 'principal',
44
+ 'session.adopt': 'principal',
45
+ 'permission.respond': 'principal',
46
+ 'session.get': 'principal',
47
+ 'session.read': 'principal',
48
+ 'events.read': 'principal',
49
+ 'session.message': 'principal',
50
+ 'session.tap': 'principal',
51
+ 'message.get': 'principal',
52
+ 'report.get': 'principal',
53
+ 'message.ack': 'principal',
54
+ };