typeclaw 0.1.1 → 0.1.3

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.
Files changed (74) hide show
  1. package/README.md +16 -12
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -1,3 +1,5 @@
1
+ import { supportsColor } from './log-colors'
2
+ import { makeLogTimestampReformatter, type TimestampReformatter } from './log-timestamps'
1
3
  import { containerExists, containerNameFromCwd, getBun } from './shared'
2
4
 
3
5
  export type LogsPlan = {
@@ -7,7 +9,25 @@ export type LogsPlan = {
7
9
 
8
10
  export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
9
11
 
10
- export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): Promise<LogsResult> {
12
+ export type LogsOptions = {
13
+ cwd: string
14
+ follow: boolean
15
+ out?: NodeJS.WritableStream
16
+ err?: NodeJS.WritableStream
17
+ signal?: AbortSignal
18
+ // When undefined, defaults to TTY+NO_COLOR detection on `out`/`err`.
19
+ // Tests pass `false` for deterministic plain output.
20
+ useColor?: boolean
21
+ }
22
+
23
+ export async function logs({
24
+ cwd,
25
+ follow,
26
+ out = process.stdout,
27
+ err = process.stderr,
28
+ signal,
29
+ useColor,
30
+ }: LogsOptions): Promise<LogsResult> {
11
31
  const bun = getBun()
12
32
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
13
33
 
@@ -18,14 +38,30 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
18
38
  return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
19
39
  }
20
40
 
21
- const cmd = ['docker', 'logs']
41
+ const cmd = ['docker', 'logs', '--timestamps']
22
42
  if (follow) cmd.push('-f')
23
43
  cmd.push(containerName)
24
44
 
25
- // Inherit stdio so logs stream live and Ctrl+C reaches `docker logs`,
26
- // which exits cleanly on SIGINT in follow mode.
27
- const proc = bun.spawn({ cmd, cwd, stdout: 'inherit', stderr: 'inherit' })
45
+ const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
46
+
47
+ const onAbort = (): void => {
48
+ try {
49
+ proc.kill('SIGTERM')
50
+ } catch {
51
+ // already exited
52
+ }
53
+ }
54
+ signal?.addEventListener('abort', onAbort, { once: true })
55
+
56
+ const colorOut = useColor ?? supportsColor(out)
57
+ const colorErr = useColor ?? supportsColor(err)
58
+ await Promise.all([
59
+ pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
60
+ pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
61
+ ])
28
62
  const exitCode = await proc.exited
63
+ signal?.removeEventListener('abort', onAbort)
64
+
29
65
  return { ok: true, containerName, exitCode }
30
66
  } catch (error) {
31
67
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -35,3 +71,33 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
35
71
  export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
36
72
  return { containerName: containerNameFromCwd(cwd), follow }
37
73
  }
74
+
75
+ // Exported for `compose/logs.ts` so the multi-agent path reuses the same
76
+ // reformatter and stays consistent with single-agent output.
77
+ export async function pumpWithTimestamps(
78
+ stream: ReadableStream<Uint8Array>,
79
+ sink: NodeJS.WritableStream,
80
+ reformatter: TimestampReformatter = makeLogTimestampReformatter(),
81
+ ): Promise<void> {
82
+ const decoder = new TextDecoder()
83
+ const reader = stream.getReader()
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read()
87
+ if (done) break
88
+ if (value && value.byteLength > 0) {
89
+ const out = reformatter.write(decoder.decode(value, { stream: true }))
90
+ if (out.length > 0) sink.write(out)
91
+ }
92
+ }
93
+ const tail = decoder.decode()
94
+ if (tail.length > 0) {
95
+ const out = reformatter.write(tail)
96
+ if (out.length > 0) sink.write(out)
97
+ }
98
+ const flushed = reformatter.flush()
99
+ if (flushed.length > 0) sink.write(flushed)
100
+ } finally {
101
+ reader.releaseLock()
102
+ }
103
+ }
@@ -7,10 +7,20 @@ import { configSchema, expandMountPath, type Config } from '@/config/config'
7
7
  import { send as sendToDaemon } from '@/hostd/client'
8
8
  import type { HttpInfoResult } from '@/hostd/protocol'
9
9
  import { ensureDaemon } from '@/hostd/spawn'
10
+ import {
11
+ autoUpgradeTypeclawDep,
12
+ type AutoUpgradeOutcome,
13
+ expectedInstalledAfterUpgrade,
14
+ outcomeForcesInstall,
15
+ readInstalledTypeclawVersionFromAgent,
16
+ } from '@/init/auto-upgrade'
17
+ import { resolveBaseImageVersion } from '@/init/cli-version'
10
18
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
11
19
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
12
20
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
13
21
  import { refreshPackageJson } from '@/init/packagejson'
22
+ import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
23
+ import { migrateKakaotalkCredentials } from '@/secrets'
14
24
 
15
25
  import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
16
26
  import {
@@ -76,6 +86,25 @@ export type StartOptions = {
76
86
  // Reusing that daemon avoids a self-shutdown when disk source has drifted.
77
87
  reuseCurrentHostDaemon?: boolean
78
88
  ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
89
+ // Test seam for the typeclaw-version auto-upgrade. Production callers omit
90
+ // this and get the real autoUpgradeTypeclawDep (which reads the CLI's own
91
+ // package.json). Tests inject a stub to simulate `bun -g update typeclaw`
92
+ // having bumped the CLI without touching the agent folder.
93
+ autoUpgrade?: (cwd: string) => Promise<AutoUpgradeOutcome>
94
+ // Test seam for the auto-upgrade-triggered registry resolution. Defaults
95
+ // to `bun update typeclaw --latest`. Cannot be `runBunInstall` — see the
96
+ // module header in src/init/auto-upgrade.ts for why install doesn't move
97
+ // an already-locked in-range dep.
98
+ forceBunUpdate?: UpdateRunner
99
+ // Test seam for the post-install verification. Reads the version actually
100
+ // present in <agent>/node_modules/typeclaw/package.json after the upgrade
101
+ // install completes. Defaults to readInstalledTypeclawVersionFromAgent.
102
+ // Verification is mandatory: `bun update` can succeed (exit 0) but still
103
+ // resolve to an older version than expected if the registry has issues
104
+ // or the spec resolution surprises us; we MUST refuse to proceed to
105
+ // refreshDockerfile in that case, otherwise the Dockerfile pins a stale
106
+ // base image and the build either fails or runs against the wrong runtime.
107
+ readInstalledVersion?: (cwd: string) => string | null
79
108
  // Post-`docker run` verifier. `docker run -d` returns exit 0 the moment the
80
109
  // container is created, even if its entrypoint crashes milliseconds later.
81
110
  // The default verifier polls `docker inspect` for 1.5s and converts crashes
@@ -105,6 +134,7 @@ export type StartResult =
105
134
  // every fresh launch, including the post-stale-corpse `--rm` recovery
106
135
  // path — that one rebuilds the container from scratch.
107
136
  alreadyRunning: boolean
137
+ autoUpgrade: AutoUpgradeOutcome
108
138
  }
109
139
  | { ok: false; reason: string }
110
140
 
@@ -117,6 +147,9 @@ export async function start({
117
147
  cliEntry,
118
148
  reuseCurrentHostDaemon = false,
119
149
  ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
150
+ autoUpgrade = (dir) => autoUpgradeTypeclawDep({ cwd: dir }),
151
+ forceBunUpdate = runBunUpdate,
152
+ readInstalledVersion = readInstalledTypeclawVersionFromAgent,
120
153
  verifyRunning = createVerifyRunning({ exec }),
121
154
  }: StartOptions): Promise<StartResult> {
122
155
  try {
@@ -142,13 +175,48 @@ export async function start({
142
175
  // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
143
176
  // is a no-op, so users who never edit their agent folder pay zero cost on
144
177
  // subsequent starts and users who customized `workspaces` are not clobbered.
145
- await refreshDockerfile(cwd)
146
178
  await refreshGitignore(cwd)
147
179
  const pkgRefresh = await refreshPackageJson(cwd)
148
180
  await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
149
181
  if (pkgRefresh.changed) {
150
182
  await commitSystemFile(cwd, pkgRefresh.files, 'Enable bun workspaces (packages/*)')
151
183
  }
184
+
185
+ // Align the agent's typeclaw dep with the global CLI version BEFORE
186
+ // ensureDeps runs. The classic regression this prevents: `bun -g update
187
+ // typeclaw` bumps the global CLI but the agent's node_modules/typeclaw
188
+ // stays pinned to whatever was installed at init time. refreshDockerfile
189
+ // then pins FROM ghcr/typeclaw-base:<old-version> and the docker build
190
+ // either fails (image never published) or runs against a stale runtime.
191
+ //
192
+ // We use `bun update typeclaw --latest` (NOT `bun install`) because plain
193
+ // install honors the lockfile and is a no-op when the lockfile already
194
+ // satisfies the declared spec — which is the canonical regression case
195
+ // (lockfile pins 0.1.0, spec says ^0.1.0, CLI is 0.1.2; install does
196
+ // nothing, update force-resolves to 0.1.2).
197
+ //
198
+ // After the update we MUST verify the installed version actually matches
199
+ // the upgrade target. `bun update` can exit 0 but resolve to a stale
200
+ // version (registry hiccups, surprising spec resolution). If verification
201
+ // fails we abort before refreshDockerfile so we never pin a stale base
202
+ // image to a fresh container build.
203
+ const upgrade = await autoUpgrade(cwd)
204
+ const upgradeCommitMessage = commitMessageForAutoUpgrade(upgrade)
205
+ if (outcomeForcesInstall(upgrade)) {
206
+ const forced = await forceBunUpdate(cwd, 'typeclaw')
207
+ if (!forced.ok) {
208
+ return { ok: false, reason: `typeclaw auto-upgrade install failed: ${forced.reason}` }
209
+ }
210
+ const expected = expectedInstalledAfterUpgrade(upgrade)
211
+ const installedAfter = readInstalledVersion(cwd)
212
+ if (expected !== null && (installedAfter === null || !installedReachesTarget(installedAfter, expected))) {
213
+ return {
214
+ ok: false,
215
+ reason: `typeclaw auto-upgrade verification failed: bun update reported success but <agent>/node_modules/typeclaw is ${installedAfter ?? 'missing'} (expected >= ${expected}). Refusing to build a Docker image against a stale runtime.`,
216
+ }
217
+ }
218
+ }
219
+
152
220
  // Run `bun install` BEFORE the dependency-drift commit so the lockfile
153
221
  // changes the install produces are caught by the same commit. Without
154
222
  // this, upgrading the typeclaw CLI to a version that adds a new dep
@@ -160,7 +228,13 @@ export async function start({
160
228
  if (!deps.ok) {
161
229
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
162
230
  }
163
- await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
231
+ await commitSystemFile(cwd, DEPENDENCY_FILES, upgradeCommitMessage ?? 'Update dependencies')
232
+ // Dockerfile refresh AFTER ensureDeps so the version pin in the FROM
233
+ // line resolves against the agent's installed node_modules/typeclaw —
234
+ // ensures the base image's CLI version matches the runtime the
235
+ // container will actually load.
236
+ await refreshDockerfile(cwd)
237
+ await migrateKakaotalkCredentials(cwd)
164
238
 
165
239
  if (state.exists) {
166
240
  // Container holds the name but is not running. Without `--rm`, this is
@@ -298,12 +372,31 @@ export async function start({
298
372
  hostPort,
299
373
  hostd: stripHostDaemonControl(hostd),
300
374
  alreadyRunning: false,
375
+ autoUpgrade: upgrade,
301
376
  }
302
377
  } catch (error) {
303
378
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
304
379
  }
305
380
  }
306
381
 
382
+ function commitMessageForAutoUpgrade(outcome: AutoUpgradeOutcome): string | null {
383
+ if (outcome.kind === 'spec-rewritten') return `Upgrade typeclaw to ${outcome.to}`
384
+ if (outcome.kind === 'reinstall-needed') return `Upgrade typeclaw to ${outcome.to}`
385
+ return null
386
+ }
387
+
388
+ function installedReachesTarget(installed: string, target: string): boolean {
389
+ const ai = installed.match(/^(\d+)\.(\d+)\.(\d+)$/)
390
+ const at = target.match(/^(\d+)\.(\d+)\.(\d+)$/)
391
+ if (!ai || !at) return false
392
+ for (let i = 1; i <= 3; i++) {
393
+ const a = Number.parseInt(ai[i]!, 10)
394
+ const t = Number.parseInt(at[i]!, 10)
395
+ if (a !== t) return a > t
396
+ }
397
+ return true
398
+ }
399
+
307
400
  export async function planStart({
308
401
  cwd,
309
402
  hostPort,
@@ -315,7 +408,8 @@ export async function planStart({
315
408
  const imageTag = imageTagFromCwd(cwd)
316
409
 
317
410
  const devSourcePath = await detectDevSource(cwd)
318
- const mounts = await loadMounts(cwd)
411
+ const cfg = await loadTypeclawConfig(cwd)
412
+ const mounts = cfg.mounts
319
413
 
320
414
  // No `--rm`: a crashed container's logs MUST survive past exit so users can
321
415
  // debug the failure. `typeclaw stop` removes the container explicitly, and
@@ -324,6 +418,17 @@ export async function planStart({
324
418
  // a running container or one the user has not started again yet.
325
419
  const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
326
420
 
421
+ // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
422
+ // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
423
+ // install iptables OUTPUT rules. The shim drops the capability from the
424
+ // bounding set via setpriv before exec'ing the agent — see the shim source
425
+ // in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
426
+ // tells the shim to take the on-path; absent or set to anything other than
427
+ // "1", the shim is a no-op.
428
+ if (cfg.network.blockInternal) {
429
+ runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
430
+ }
431
+
327
432
  if (hostdControl) {
328
433
  runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
329
434
  }
@@ -386,7 +491,10 @@ export async function planStart({
386
491
 
387
492
  export async function refreshDockerfile(cwd: string): Promise<void> {
388
493
  const cfg = await loadTypeclawConfig(cwd)
389
- await writeFile(join(cwd, DOCKERFILE), buildDockerfile(cfg.dockerfile))
494
+ await writeFile(
495
+ join(cwd, DOCKERFILE),
496
+ buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
497
+ )
390
498
  }
391
499
 
392
500
  export async function refreshGitignore(cwd: string): Promise<void> {
@@ -517,6 +625,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
517
625
  hostPort,
518
626
  hostd: { state: 'disabled' },
519
627
  alreadyRunning: true,
628
+ autoUpgrade: { kind: 'skipped-already-running' },
520
629
  }
521
630
  }
522
631
 
@@ -576,11 +685,6 @@ async function detectDevSource(cwd: string): Promise<string | null> {
576
685
  // folder mid-init). Anything else — malformed JSON, schema-invalid config,
577
686
  // invalid mount entry — must surface so the user sees they configured a mount
578
687
  // that won't be applied.
579
- async function loadMounts(cwd: string): Promise<Config['mounts']> {
580
- const cfg = await loadTypeclawConfig(cwd)
581
- return cfg.mounts
582
- }
583
-
584
688
  async function loadTypeclawConfig(cwd: string): Promise<Config> {
585
689
  return configSchema.parse(await loadConfigJson(cwd))
586
690
  }
@@ -1,20 +1,25 @@
1
+ import type { SessionOrigin } from '@/agent/session-origin'
1
2
  import type { HookBus } from '@/plugin'
2
3
  import type { Stream, Unsubscribe } from '@/stream'
3
4
 
4
5
  import type { CronJob, ExecJob, PromptJob } from './schema'
5
6
 
6
- // `hooks`, `sessionId`, and `getTranscriptPath` are optional so test fakes can
7
- // stay one-liners. When present, the consumer fires `session.idle` after every
8
- // prompt completion and `session.end` on dispose, mirroring the lifecycle
9
- // signals the TUI server already emits in `src/server/index.ts`. Without this
10
- // the bundled memory plugin's debounced `memory-logger` never spawns for cron
11
- // prompt jobs because it only wakes on `session.idle`.
7
+ // `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
8
+ // test fakes can stay one-liners. When present, the consumer fires
9
+ // `session.turn.start`/`session.turn.end` around `prompt()`, then
10
+ // `session.idle` after, then `session.end` on dispose mirroring the
11
+ // lifecycle signals the TUI server emits in `src/server/index.ts`. Without
12
+ // this the bundled memory plugin's debounced `memory-logger` never spawns for
13
+ // cron prompt jobs (it only wakes on `session.idle`), and the bundled backup
14
+ // plugin's turn counter would miss cron-driven activity.
12
15
  export type CronSession = {
13
16
  prompt: (text: string) => Promise<void>
14
17
  dispose?: () => void
15
18
  hooks?: HookBus
16
19
  sessionId?: string
20
+ agentDir?: string
17
21
  getTranscriptPath?: () => string | undefined
22
+ origin?: SessionOrigin
18
23
  }
19
24
 
20
25
  export type CronConsumerLogger = {
@@ -102,8 +107,25 @@ async function runPrompt(
102
107
  return
103
108
  }
104
109
  const session = await createSessionForCron(job)
110
+ const turnEvent =
111
+ session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
112
+ ? {
113
+ sessionId: session.sessionId,
114
+ agentDir: session.agentDir,
115
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
116
+ }
117
+ : undefined
105
118
  try {
106
- await session.prompt(job.prompt)
119
+ if (session.hooks && turnEvent !== undefined) {
120
+ await session.hooks.runSessionTurnStart(turnEvent)
121
+ }
122
+ try {
123
+ await session.prompt(job.prompt)
124
+ } finally {
125
+ if (session.hooks && turnEvent !== undefined) {
126
+ await session.hooks.runSessionTurnEnd(turnEvent)
127
+ }
128
+ }
107
129
  if (session.hooks && session.sessionId !== undefined) {
108
130
  await session.hooks.runSessionIdle({
109
131
  sessionId: session.sessionId,