@zgeoff/atc 2.36.0 → 2.37.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.
@@ -0,0 +1,184 @@
1
+ import type { AgentEntry } from './shared/collect-agents';
2
+ import { parseConfig } from './shared/config';
3
+ import { formatJSONKind } from './shared/format-json-kind';
4
+ import { formatMixedAgentKeys } from './shared/format-mixed-agent-keys';
5
+ import { LEGACY_AGENT_KEYS } from './shared/legacy-agent-keys';
6
+ import { isRecord } from './shared/report';
7
+
8
+ /**
9
+ * What `atc config migrate` makes of a config.json's decoded value: the file
10
+ * rewritten around `agents` with a note per gateway it could not carry over,
11
+ * a file that needs no change, or a file it cannot migrate.
12
+ */
13
+ type MigratedConfig =
14
+ | { readonly kind: 'migrated'; readonly text: string; readonly notes: readonly string[] }
15
+ | { readonly kind: 'current' }
16
+ | { readonly kind: 'unusable'; readonly detail: string };
17
+
18
+ /**
19
+ * Rewrites a config.json that uses the old agent keys so it holds the same
20
+ * agents under `agents`. The old keys are removed and `agents` takes the
21
+ * place of the first of them, or the end of the file when it sets none. Each
22
+ * entry holds only the fields that differ from a default. Values are copied
23
+ * and never reported, so a note names a key and a reason only.
24
+ */
25
+ export function buildMigratedConfig(raw: unknown): MigratedConfig {
26
+ if (!isRecord(raw) || Array.isArray(raw)) {
27
+ return {
28
+ kind: 'unusable',
29
+ detail: `the root is ${formatJSONKind(raw)}, not an object`,
30
+ };
31
+ }
32
+
33
+ const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
34
+
35
+ if (Object.hasOwn(raw, 'agents')) {
36
+ return present.length === 0
37
+ ? { kind: 'current' }
38
+ : { kind: 'unusable', detail: formatMixedAgentKeys(present) };
39
+ }
40
+
41
+ const config = parseConfig(raw);
42
+ const agents: Record<string, unknown> = {};
43
+
44
+ for (const entry of config.agents) {
45
+ agents[entry.id] = renderAgentEntry(entry);
46
+ }
47
+
48
+ const migrated: Record<string, unknown> = {};
49
+ let placed = false;
50
+
51
+ for (const [key, value] of Object.entries(raw)) {
52
+ if (!LEGACY_AGENT_KEYS.includes(key)) {
53
+ migrated[key] = value;
54
+ } else if (!placed) {
55
+ migrated['agents'] = agents;
56
+ placed = true;
57
+ }
58
+ }
59
+
60
+ if (!placed) {
61
+ migrated['agents'] = agents;
62
+ }
63
+
64
+ return {
65
+ kind: 'migrated',
66
+ text: `${JSON.stringify(migrated, null, 2)}\n`,
67
+ notes: collectDroppedGateways(raw['gateways'], config.agents, config.agentErrors),
68
+ };
69
+ }
70
+
71
+ // The fields of an entry that differ from the defaults the `agents` parser
72
+ // applies, so a migrated entry reads as short as one written by hand.
73
+ function renderAgentEntry(entry: AgentEntry): Record<string, unknown> {
74
+ const rendered: Record<string, unknown> = {};
75
+
76
+ if (entry.baseURL !== undefined) {
77
+ rendered['kind'] = 'claude';
78
+ }
79
+
80
+ if (entry.label !== entry.id && entry.baseURL !== undefined) {
81
+ rendered['label'] = entry.label;
82
+ }
83
+
84
+ if (
85
+ entry.baseURL !== undefined &&
86
+ entry.mark !== String.fromCodePoint(entry.id.codePointAt(0) ?? 0)
87
+ ) {
88
+ rendered['mark'] = entry.mark;
89
+ }
90
+
91
+ if (entry.bin !== entry.kind) {
92
+ rendered['bin'] = entry.bin;
93
+ }
94
+
95
+ if (entry.args.length > 0) {
96
+ rendered['args'] = entry.args;
97
+ }
98
+
99
+ if (entry.baseURL !== undefined) {
100
+ rendered['baseURL'] = entry.baseURL;
101
+ }
102
+
103
+ if (entry.apiKeyHelper !== undefined) {
104
+ rendered['apiKeyHelper'] = entry.apiKeyHelper;
105
+ }
106
+
107
+ if (Object.keys(entry.env).length > 0) {
108
+ rendered['env'] = entry.env;
109
+ }
110
+
111
+ if (entry.settings !== undefined) {
112
+ rendered['settings'] = entry.settings;
113
+ }
114
+
115
+ if (entry.auth !== undefined) {
116
+ rendered['auth'] =
117
+ Object.keys(entry.auth.placeholderEnv).length > 0
118
+ ? { profiles: entry.auth.profiles, placeholderEnv: entry.auth.placeholderEnv }
119
+ : { profiles: entry.auth.profiles };
120
+ }
121
+
122
+ return rendered;
123
+ }
124
+
125
+ // One note per gateway the old parse left out, with the reason.
126
+ function collectDroppedGateways(
127
+ raw: unknown,
128
+ agents: readonly AgentEntry[],
129
+ errors: readonly string[],
130
+ ): string[] {
131
+ if (!isRecord(raw) || Array.isArray(raw)) {
132
+ return [];
133
+ }
134
+
135
+ // The three built-in agents come first, so the gateways follow them.
136
+ const kept = new Set(agents.slice(BUILT_IN_COUNT).map((entry) => entry.id));
137
+
138
+ const notes: string[] = [];
139
+
140
+ for (const [id, entry] of Object.entries(raw)) {
141
+ if (!isDropped(id, kept)) {
142
+ continue;
143
+ }
144
+
145
+ notes.push(
146
+ `atc config migrate: gateways.${id} is left out: ${findDropReason(id, entry, errors)}`,
147
+ );
148
+ }
149
+
150
+ return notes;
151
+ }
152
+
153
+ const BUILT_IN_COUNT = 3;
154
+
155
+ function isDropped(id: string, kept: ReadonlySet<string>): boolean {
156
+ return id === '__proto__' || !kept.has(id);
157
+ }
158
+
159
+ function findDropReason(id: string, entry: unknown, errors: readonly string[]): string {
160
+ if (id === '') {
161
+ return 'its id is empty';
162
+ }
163
+
164
+ if (id === '__proto__') {
165
+ return 'its id cannot be used';
166
+ }
167
+
168
+ if (id === 'claude' || id === 'grok' || id === 'codex') {
169
+ return `its id is the built-in agent ${id}`;
170
+ }
171
+
172
+ const baseURL = isRecord(entry) ? entry['baseURL'] : undefined;
173
+
174
+ if (typeof baseURL !== 'string' || baseURL === '') {
175
+ return 'it has no baseURL';
176
+ }
177
+
178
+ const prefix = `gateways.${id}: `;
179
+ const problems = errors.filter((error) => error.startsWith(prefix));
180
+
181
+ return problems.length > 0
182
+ ? problems.map((problem) => problem.slice(prefix.length)).join('; ')
183
+ : 'its entry could not be read';
184
+ }
package/src/cli.ts CHANGED
@@ -178,6 +178,44 @@ const main = defineCommand({
178
178
  await grants.runGrants(ctx.args.revoke ?? null);
179
179
  },
180
180
  }),
181
+ config: () =>
182
+ defineCommand({
183
+ meta: { name: 'config', description: "Work on atc's config.json" },
184
+ subCommands: {
185
+ migrate: () =>
186
+ defineCommand({
187
+ meta: {
188
+ name: 'migrate',
189
+ description:
190
+ 'Move the old agent keys of config.json into agents: print the result, or rewrite the file with --write',
191
+ },
192
+ args: {
193
+ write: {
194
+ type: 'boolean',
195
+ default: false,
196
+ description: 'Back the file up beside itself, then rewrite it in place',
197
+ },
198
+ file: {
199
+ type: 'string',
200
+ description: 'The config file to migrate (default ~/.config/atc/config.json)',
201
+ },
202
+ },
203
+ async run(ctx) {
204
+ const migrate = await import('./run-config-migrate');
205
+ const config = await import('./shared/config');
206
+
207
+ const code = migrate.runConfigMigrate(
208
+ ctx.args.file ?? config.configFile,
209
+ ctx.args.write,
210
+ );
211
+
212
+ if (code !== 0) {
213
+ process.exit(code);
214
+ }
215
+ },
216
+ }),
217
+ },
218
+ }),
181
219
  daemon: () =>
182
220
  defineCommand({
183
221
  meta: {
@@ -358,10 +396,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
358
396
 
359
397
  const daemon = await import('./daemon/daemon');
360
398
  const config = await import('./shared/config');
361
- const claude = await import('./agents/claude-adapter');
362
- const grok = await import('./agents/grok-adapter');
363
- const codex = await import('./agents/codex-adapter');
364
- const gateway = await import('./agents/gateway-adapter');
399
+ const adapterBuilder = await import('./agents/build-agent-adapters');
365
400
  const headless = await import('./agents/start-claude-headless-run');
366
401
  const targets = await import('./daemon/build-execution-targets');
367
402
  const sourceOrder = await import('./sources/build-sources');
@@ -389,12 +424,17 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
389
424
  ...cfg.principalErrors,
390
425
  ...cfg.workspaceErrors,
391
426
  ...cfg.authProfileErrors,
392
- ...cfg.claudeAuthErrors,
393
- ...cfg.gatewayErrors,
427
+ ...cfg.agentErrors,
394
428
  ]) {
395
429
  console.error(`atc daemon: config: ${problem}`);
396
430
  }
397
431
 
432
+ if (cfg.legacyAgentKeys.length > 0) {
433
+ console.error(
434
+ `atc daemon: config: config.json uses the old agent keys (${cfg.legacyAgentKeys.join(', ')}); run 'atc config migrate' to move them into agents`,
435
+ );
436
+ }
437
+
398
438
  const sources = sourceOrder.buildSources(
399
439
  builtinSources.collectBuiltinSources({
400
440
  roots: cfg.dirs.roots,
@@ -423,15 +463,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
423
463
  // How long a started session may go without a tap before a message
424
464
  // to it is refused. Tests pin it to 0 to reach the refusal at once.
425
465
  const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
426
-
427
- const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
428
- const grokAdapter = new grok.GrokAdapter(cfg);
429
- const codexAdapter = new codex.CodexAdapter(cfg);
430
-
431
- const gatewayAdapters = cfg.gateways.map(
432
- (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
433
- );
434
-
466
+ const adapters = adapterBuilder.buildAgentAdapters(cfg, headless.startClaudeHeadlessRun);
435
467
  let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
436
468
 
437
469
  try {
@@ -440,8 +472,8 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
440
472
  reporterSocketPath: config.socketPath,
441
473
  eventsSocketPath: config.eventsSocketPath,
442
474
  build: getBuild(),
443
- adapter: claudeAdapter,
444
- adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
475
+ adapters,
476
+ defaultAgent: cfg.defaultAgent,
445
477
  dbPath: config.dbFile,
446
478
  legacyFleetPath: config.legacyFleetFile,
447
479
  pidPath: config.daemonPidFile,
@@ -18,14 +18,7 @@ export function collectAgentPicks(config: Config): AgentPick[] {
18
18
  // rather than left to the snapshot Bun.which defaults to.
19
19
  const opts = { PATH: process.env['PATH'] ?? '' };
20
20
 
21
- const candidates: readonly (AgentPick & { readonly bin: string })[] = [
22
- { agent: 'claude', label: 'Claude', bin: config.claudeBin },
23
- { agent: 'grok', label: 'Grok', bin: config.grokBin },
24
- { agent: 'codex', label: 'Codex', bin: config.codexBin },
25
- ...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
- ];
27
-
28
- return candidates
29
- .filter((c) => Bun.which(c.bin, opts) !== null)
30
- .map((c) => ({ agent: c.agent, label: c.label }));
21
+ return config.agents
22
+ .filter((entry) => Bun.which(entry.bin, opts) !== null)
23
+ .map((entry) => ({ agent: entry.id, label: entry.label }));
31
24
  }
@@ -41,7 +41,7 @@ const doneAt = new Map<string, number>();
41
41
 
42
42
  let focusedID: string | null = null;
43
43
  let fleetCount = 0;
44
- let lastUsedAgent: AgentID = 'claude';
44
+ let lastUsedAgent: AgentID = loadConfig().defaultAgent;
45
45
 
46
46
  // The readable harness label and tier-alias map per agent: the config alone
47
47
  // until the daemon's `agents.list` answer arrives and re-resolves them
@@ -12,12 +12,6 @@ export interface AgentMetadata {
12
12
  readonly models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
13
13
  }
14
14
 
15
- const BUILT_IN_LABELS: Readonly<Record<AgentID, string>> = {
16
- claude: 'Claude',
17
- grok: 'Grok',
18
- codex: 'Codex',
19
- };
20
-
21
15
  /**
22
16
  * The labels and model aliases the overlay draws with, resolved from the
23
17
  * config a client reads at start and the daemon's `agents.list` answer once
@@ -26,12 +20,12 @@ const BUILT_IN_LABELS: Readonly<Record<AgentID, string>> = {
26
20
  * the window before the answer lands.
27
21
  */
28
22
  export function resolveAgentMetadata(config: Config, listed: unknown): AgentMetadata {
29
- const labels: Record<AgentID, string> = { ...BUILT_IN_LABELS };
23
+ const labels: Record<AgentID, string> = {};
30
24
  const models: Record<AgentID, Readonly<Record<string, string>>> = {};
31
25
 
32
- for (const gateway of config.gateways) {
33
- if (isSafeKey(gateway.id)) {
34
- labels[gateway.id] = gateway.label;
26
+ for (const entry of config.agents) {
27
+ if (isSafeKey(entry.id)) {
28
+ labels[entry.id] = entry.label;
35
29
  }
36
30
  }
37
31
 
@@ -434,8 +434,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
434
434
  let hint = '↑↓ move · ⏎ select · esc cancel';
435
435
 
436
436
  if (this.picks.length === 0) {
437
- hint =
438
- 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel';
437
+ hint = 'no agent CLI found — set agents in config.json · esc cancel';
439
438
  } else if (this.sourcesPending) {
440
439
  hint = 'reading targets and sources… · esc cancel';
441
440
  }
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import type { AgentID } from '../shared/agent-id';
3
+ import type { GatewayAuth } from '../shared/check-gateway-auth';
3
4
  import type { AuthProfile } from '../shared/collect-auth-profiles';
4
- import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
5
+ import type { GatewayConfig } from '../shared/collect-gateways';
5
6
  import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
6
7
  import type { AuthProfileProblem, ResolvedAuthSecret } from '../shared/resolve-auth-profiles';
7
8
  import { sortJSONKeys } from '../shared/sort-json-keys';
@@ -941,7 +941,7 @@ export class DaemonConnection {
941
941
  // The target goes first: a config or target problem is the cause a
942
942
  // spawn reports, ahead of any agent check that problem can skew.
943
943
  const target = ctx.resolveSpawnTarget(data.target);
944
- const agent: AgentID = data.agent ?? 'claude';
944
+ const agent: AgentID = data.agent ?? ctx.collectAgents().spawnDefaults.agent;
945
945
  const adapter = ctx.findAdapter(agent);
946
946
  const entry = ctx.collectAgents().agents.find((candidate) => candidate.id === agent);
947
947
 
@@ -11,6 +11,7 @@ import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
11
11
  import type { EventMsg } from '../protocol/protocol';
12
12
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
+ import type { AgentID } from '../shared/agent-id';
14
15
  import type { HooksConfig } from '../shared/collect-hooks';
15
16
  import type { TargetConfigError } from '../shared/collect-targets';
16
17
  import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
@@ -101,13 +102,17 @@ export interface DaemonOptions {
101
102
 
102
103
  // Build string sent in the handshake and in mismatch errors, e.g. "atc/0.1.0".
103
104
  readonly build: string;
104
- readonly adapter: AgentAdapter;
105
+ readonly adapter?: AgentAdapter | null;
105
106
 
106
107
  // Adapters registered over and above the default one, each keyed by the id
107
108
  // it declares. Lookup never falls back across ids: a grok session with no
108
109
  // grok adapter is unsupported, not a Claude spawn.
109
110
  readonly adapters?: readonly AgentAdapter[];
110
111
 
112
+ // The agent a spawn without one runs, reported in `agents.list`'s spawn
113
+ // defaults; `claude` when unset.
114
+ readonly defaultAgent?: AgentID;
115
+
111
116
  // Where sessions run, in config order; one `local` target on the local
112
117
  // pseudo-terminal provider when unset. A spawn without a target runs on
113
118
  // the default one: `local` when unset and the targets hold it, and none
@@ -888,7 +893,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
888
893
  if (
889
894
  before !== undefined &&
890
895
  runtime !== undefined &&
891
- !isOwnHookEvent(e.agent, before.agent, runtime.hasAgentHookLines)
896
+ !isOwnHookEvent(
897
+ e.agent,
898
+ mgr.findAdapter(before.agent)?.hookAgent ?? before.agent,
899
+ runtime.hasAgentHookLines,
900
+ )
892
901
  ) {
893
902
  return;
894
903
  }
@@ -1412,7 +1421,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1412
1421
  targets.some((target) => target.provider?.brokerAuth !== undefined),
1413
1422
  ),
1414
1423
  targets: buildTargetList(targets, defaultTarget),
1415
- spawnDefaults: { agent: 'claude', target: defaultTarget },
1424
+ spawnDefaults: { agent: opts.defaultAgent ?? 'claude', target: defaultTarget },
1416
1425
  configRevision,
1417
1426
  targetErrors,
1418
1427
  sources: sources.map((source) => ({
@@ -383,7 +383,7 @@ export class SessionManager {
383
383
  private nextBridgeEpoch = 1;
384
384
 
385
385
  constructor(
386
- fallback: AgentAdapter,
386
+ fallback: AgentAdapter | null | undefined,
387
387
  store: FleetStore,
388
388
  statusPath: string | undefined = statusFile,
389
389
  adapters: readonly AgentAdapter[] = [],
@@ -400,7 +400,13 @@ export class SessionManager {
400
400
  ) {
401
401
  // Each adapter names the id it answers to, so a registry key can never
402
402
  // disagree with the adapter behind it. A later one wins the id.
403
- this.adapters = Object.fromEntries([fallback, ...adapters].map((a) => [a.id, a]));
403
+ this.adapters = Object.fromEntries(
404
+ [...(fallback === null || fallback === undefined ? [] : [fallback]), ...adapters].map((a) => [
405
+ a.id,
406
+ a,
407
+ ]),
408
+ );
409
+
404
410
  this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
405
411
  this.store = store;
406
412
  this.statusPath = statusPath ?? statusFile;
@@ -29,8 +29,8 @@ export function buildSpawnDescriptions(
29
29
  : ` When this tool list was built, the host registered: ${formatRoster(agents)}.`;
30
30
 
31
31
  return {
32
- tool: `Spawn a new session in a directory. Optional agent is a registered agent id; omitted agent is always Claude, never the TUI last-used value.${roster} atc_agents_list returns the current agents, whether each is installed, and the model and effort each takes. An unregistered agent, a registered agent that is not installed, and a model or effort the agent does not take are refused before anything spawns. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.`,
33
- agent: `Registered agent id to spawn; defaults to claude.${roster} atc_agents_list returns the current list.`,
32
+ tool: `Spawn a new session in a directory. Optional agent is a registered agent id; omitted agent is the host's default agent (claude when it is registered, else the first registered agent), never the TUI last-used value.${roster} atc_agents_list returns the current agents, whether each is installed, and the model and effort each takes. An unregistered agent, a registered agent that is not installed, and a model or effort the agent does not take are refused before anything spawns. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.`,
33
+ agent: `Registered agent id to spawn; defaults to claude when it is registered, else the first registered agent.${roster} atc_agents_list returns the current list.`,
34
34
  };
35
35
  }
36
36
 
@@ -0,0 +1,85 @@
1
+ import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { buildMigratedConfig } from './build-migrated-config';
3
+
4
+ /**
5
+ * Runs `atc config migrate`: prints the config.json rewritten around
6
+ * `agents`, or with `write` copies the file to a timestamped backup beside it
7
+ * and rewrites it in place. Returns the exit code. Notes and problems go to
8
+ * stderr and name keys and paths, never values.
9
+ */
10
+ export function runConfigMigrate(file: string, write: boolean): number {
11
+ let text: string;
12
+
13
+ try {
14
+ text = readFileSync(file, 'utf8');
15
+ } catch {
16
+ console.error(`atc config migrate: ${file} cannot be read`);
17
+
18
+ return 1;
19
+ }
20
+
21
+ let raw: unknown;
22
+
23
+ try {
24
+ raw = JSON.parse(text);
25
+ } catch {
26
+ console.error(`atc config migrate: ${file} is not valid JSON`);
27
+
28
+ return 1;
29
+ }
30
+
31
+ const result = buildMigratedConfig(raw);
32
+
33
+ if (result.kind === 'unusable') {
34
+ console.error(`atc config migrate: ${file}: ${result.detail}`);
35
+
36
+ return 1;
37
+ }
38
+
39
+ if (result.kind === 'current') {
40
+ console.log('config.json already uses agents; nothing to migrate');
41
+
42
+ return 0;
43
+ }
44
+
45
+ for (const note of result.notes) {
46
+ console.error(note);
47
+ }
48
+
49
+ if (!write) {
50
+ process.stdout.write(result.text);
51
+
52
+ return 0;
53
+ }
54
+
55
+ const backup = `${file}.bak-${formatBackupStamp(Date.now())}`;
56
+
57
+ try {
58
+ copyFileSync(file, backup);
59
+ } catch {
60
+ console.error(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
61
+
62
+ return 1;
63
+ }
64
+
65
+ try {
66
+ writeFileSync(file, result.text);
67
+ } catch {
68
+ console.error(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
69
+
70
+ return 1;
71
+ }
72
+
73
+ console.log(`backup: ${backup}`);
74
+ console.log(`wrote: ${file}`);
75
+
76
+ return 0;
77
+ }
78
+
79
+ // A UTC time as `20261006T152200Z`.
80
+ function formatBackupStamp(time: number): string {
81
+ return new Date(time)
82
+ .toISOString()
83
+ .replaceAll(/[-:]/gu, '')
84
+ .replace(/\.\d+Z$/u, 'Z');
85
+ }